Clean up unused code and fix profile selection

- Removed unused UserProfile.kt, ApiModels.kt
- Fixed profile checkmark showing for all profiles (using lambda)
- Increased text bubble width to 350dp
- Auto-scroll to beginning of AI messages
- Auto-select profile on creation
- Model selection persists across restarts
- Removed null-unsafe !! operators
- Added Russian language support for UI strings
This commit is contained in:
Алексей Будаев 2026-04-06 20:14:32 +08:00
parent a5fe4bc29e
commit 21505aae75
10 changed files with 1070 additions and 332 deletions

View file

@ -5,35 +5,60 @@ import com.google.gson.Gson
import com.google.gson.JsonArray
import com.mistral.chat.data.Message
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.util.concurrent.TimeUnit
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class MistralClient(private val apiKey: String) {
private val client = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
private var client = createNewClient()
private fun createNewClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(25, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(false)
.build()
}
private val gson = Gson()
private val jsonMediaType = "application/json".toMediaType()
private var currentCall: Call? = null
private var currentContinuation: Continuation<Result<Pair<String, String>>>? = null
companion object {
private const val BASE_URL = "https://api.mistral.ai/v1"
private val SUPPORTED_MODELS = setOf(
"mistral-small-latest",
"mistral-medium-latest",
"mistral-large-latest",
"mistral-small",
"mistral-medium",
"mistral-large",
"codestral-latest",
"codestral",
"pixtral-large-latest",
"pixtral-12b-2409"
)
val AVAILABLE_MODELS = listOf(
"mistral-small-latest" to "Mistral Small",
"mistral-medium-latest" to "Mistral Medium",
"mistral-large-latest" to "Mistral Large",
"codestral-latest" to "Codestral"
"codestral-latest" to "Codestral",
"pixtral-large-latest" to "Pixtral Large"
)
}
@ -45,8 +70,7 @@ import java.util.concurrent.TimeUnit
.get()
.build()
val response = client.newCall(request).execute()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext Result.failure(Exception("API error: ${response.code}"))
}
@ -57,28 +81,47 @@ import java.util.concurrent.TimeUnit
val models = responseJson
.getAsJsonArray("data")
?.mapNotNull { obj ->
try {
val jsonObj = obj.asJsonObject
val id = jsonObj.get("id")?.asString
val created = jsonObj.get("created")?.asLong ?: 0L
if (id != null && created > 0 && id.endsWith("-latest")) {
val id = jsonObj.get("id")?.asString ?: return@mapNotNull null
if (id in SUPPORTED_MODELS) {
val displayName = id
.replace("-latest", "")
.replace("-12b-2409", "")
.replace("-", " ")
.split(" ")
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } }
id to displayName
} else null
} ?: emptyList()
} catch (e: Exception) {
null
}
}
?.distinctBy { it.first }
?: emptyList()
Result.success(models)
}
} catch (e: Exception) {
if (e is java.io.IOException && !e.message.orEmpty().contains("cancel", ignoreCase = true)) {
Result.failure(e)
} else {
Result.failure(Exception("Request cancelled"))
}
}
}
fun cancelRequest() {
currentCall?.cancel()
val call = currentCall
val continuation = currentContinuation
call?.cancel()
currentCall = null
continuation?.resume(Result.failure(Exception("Request cancelled")))
currentContinuation = null
client = createNewClient()
}
suspend fun chat(
@ -90,7 +133,7 @@ import java.util.concurrent.TimeUnit
val jsonObject = JsonObject()
jsonObject.addProperty("model", model)
jsonObject.addProperty("temperature", 0.7)
jsonObject.addProperty("stream", onChunk != null)
jsonObject.addProperty("stream", false)
val messagesArray = JsonArray()
messages.forEach { msg ->
@ -112,15 +155,40 @@ import java.util.concurrent.TimeUnit
.build()
currentCall = client.newCall(request)
val response = currentCall!!.execute()
if (response.code == 0 || response.code == -1) {
return@withContext Result.failure(Exception("Request cancelled"))
suspendCancellableCoroutine { continuation ->
currentContinuation = continuation
currentCall?.enqueue(object : okhttp3.Callback {
override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
val cont = currentContinuation
currentCall = null
currentContinuation = null
if (cont != null) {
if (call.isCanceled()) {
cont.resume(Result.failure(Exception("Request cancelled")))
} else {
cont.resume(Result.failure(e))
}
}
}
override fun onResponse(call: okhttp3.Call, response: Response) {
val cont = currentContinuation
if (cont == null) {
response.close()
currentCall = null
return
}
try {
if (!response.isSuccessful) {
val errorBody = response.body?.string() ?: "Unknown error"
return@withContext Result.failure(Exception("API error: ${response.code} - $errorBody"))
currentCall = null
currentContinuation = null
cont.resume(Result.failure(Exception("API error: ${response.code} - $errorBody")))
return
}
val responseBody = response.body?.string() ?: ""
@ -129,24 +197,54 @@ import java.util.concurrent.TimeUnit
onChunk(responseBody)
}
val responseJson = gson.fromJson(responseBody, JsonObject::class.java)
val responseJson = try {
gson.fromJson(responseBody, JsonObject::class.java)
} catch (e: Exception) {
currentCall = null
currentContinuation = null
cont.resume(Result.failure(Exception("Invalid JSON response")))
return
}
val choices = responseJson.getAsJsonArray("choices")
if (choices == null || choices.size() == 0) {
return@withContext Result.failure(Exception("No response from API"))
currentCall = null
currentContinuation = null
cont.resume(Result.failure(Exception("No response from API")))
return
}
val content = choices
.get(0)
?.asJsonObject
?.getAsJsonObject("message")
?.get("content")
?.asString ?: ""
val firstChoice = choices.get(0)?.asJsonObject
if (firstChoice == null) {
currentCall = null
currentContinuation = null
cont.resume(Result.failure(Exception("Empty choice")))
return
}
val message = firstChoice.getAsJsonObject("message")
val content = message?.get("content")?.asString ?: ""
val usedModel = responseJson.get("model")?.asString ?: model
currentCall = null
Result.success(content to usedModel)
currentContinuation = null
cont.resume(Result.success(content to usedModel))
} catch (e: Exception) {
currentCall = null
currentContinuation = null
cont.resume(Result.failure(e))
}
}
})
continuation.invokeOnCancellation {
currentCall?.cancel()
currentCall = null
currentContinuation = null
client = createNewClient()
}
}
} catch (e: Exception) {
Result.failure(e)
}

View file

@ -1,27 +1,52 @@
package com.mistral.chat.data
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "messages",
foreignKeys = [
ForeignKey(
entity = Session::class,
parentColumns = ["id"],
childColumns = ["sessionId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("sessionId")]
)
data class MessageEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val sessionId: Long,
val content: String,
val isUser: Boolean,
val timestamp: Long = System.currentTimeMillis()
)
data class Message(
val id: String = System.currentTimeMillis().toString(),
val id: Long = 0,
val sessionId: Long = 0,
val content: String,
val isUser: Boolean,
val timestamp: Long = System.currentTimeMillis(),
val senderName: String? = null
)
data class ChatRequest(
val model: String,
val messages: List<Message>,
val temperature: Double = 0.7,
val stream: Boolean = false
fun MessageEntity.toMessage(): Message = Message(
id = id,
sessionId = sessionId,
content = content,
isUser = isUser,
timestamp = timestamp
)
data class ChatResponse(
val id: String,
val choices: List<Choice>,
val model: String
)
data class Choice(
val index: Int,
val message: Message
fun Message.toEntity(): MessageEntity = MessageEntity(
id = id,
sessionId = sessionId,
content = content,
isUser = isUser,
timestamp = timestamp
)

View file

@ -1,18 +0,0 @@
package com.mistral.chat.data
data class UserProfile(
val name: String = "",
val bio: String = "",
val preferences: String = ""
) {
fun isEmpty(): Boolean = name.isBlank() && bio.isBlank() && preferences.isBlank()
fun toContextString(): String {
return buildString {
append("[User Profile]\n")
if (name.isNotBlank()) append("Name: $name\n")
if (bio.isNotBlank()) append("Bio: $bio\n")
if (preferences.isNotBlank()) append("Preferences: $preferences\n")
}
}
}

View file

@ -8,14 +8,17 @@ import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.view.MenuItem
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.PopupMenu
import android.widget.TextView
import android.widget.Toast
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.view.GravityCompat
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
@ -23,17 +26,29 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.google.android.material.color.DynamicColors
import com.google.android.material.navigation.NavigationView
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.button.MaterialButton
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.mistral.chat.R
import com.mistral.chat.api.MistralClient
import com.mistral.chat.data.ChatDatabase
import com.mistral.chat.data.Message
import com.mistral.chat.data.UserProfile
import com.mistral.chat.data.MessageEntity
import com.mistral.chat.data.Profile
import com.mistral.chat.data.Session
import com.mistral.chat.data.toMessage
import com.mistral.chat.data.toEntity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity() {
class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: MessageAdapter
@ -43,6 +58,10 @@ class MainActivity : AppCompatActivity() {
private lateinit var logoButton: ImageView
private lateinit var menuButton: ImageButton
private lateinit var toolbarTitle: TextView
private lateinit var hamburgerButton: ImageButton
private lateinit var drawerLayout: androidx.drawerlayout.widget.DrawerLayout
private lateinit var navigationView: NavigationView
private lateinit var rightPanel: View
private lateinit var gson: Gson
private var currentJob: kotlinx.coroutines.Job? = null
@ -52,15 +71,26 @@ class MainActivity : AppCompatActivity() {
private var selectedModelName: String = "mistral-small-latest"
private lateinit var prefs: SharedPreferences
private lateinit var encryptedPrefs: SharedPreferences
private lateinit var database: ChatDatabase
private val profiles = mutableListOf<Profile>()
private val sessions = mutableListOf<Session>()
private var currentProfileId: Long? = null
private var currentSessionId: Long? = null
private var profilesAdapter: ProfilesAdapter? = null
private var isRightPanelVisible = false
private var isLeftDrawerOpen = false
private var userMessageCount = 0
private var titleGenerationJob: kotlinx.coroutines.Job? = null
private var isFirstLoad = true
companion object {
private const val PREFS_NAME = "mistral_chat_prefs"
private const val KEY_USER_NAME = "user_name"
private const val KEY_USER_BIO = "user_bio"
private const val KEY_USER_PREFS = "user_preferences"
private const val KEY_MESSAGES = "chat_messages"
private const val KEY_PROFILE_HASH = "profile_hash"
private const val KEY_API_KEY = "api_key"
private const val KEY_NEW_SESSION_ON_START = "new_session_on_start"
private const val KEY_LAST_PROFILE_ID = "last_profile_id"
private const val KEY_SELECTED_MODEL = "selected_model"
private const val MAX_PROFILES = 10
}
override fun onCreate(savedInstanceState: Bundle?) {
@ -74,6 +104,11 @@ class MainActivity : AppCompatActivity() {
gson = Gson()
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val savedModel = prefs.getString(KEY_SELECTED_MODEL, null)
if (savedModel != null) {
selectedModelName = savedModel
}
val masterKey = MasterKey.Builder(this)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
@ -91,20 +126,27 @@ class MainActivity : AppCompatActivity() {
client = MistralClient(getApiKey())
loadMessages()
logoButton = findViewById(R.id.logoButton)
menuButton = findViewById(R.id.menuButton)
hamburgerButton = findViewById(R.id.hamburgerButton)
toolbarTitle = findViewById(R.id.toolbarTitle)
recyclerView = findViewById(R.id.recyclerView)
inputField = findViewById(R.id.inputField)
sendButton = findViewById(R.id.sendButton)
progressIndicator = findViewById(R.id.progressIndicator)
drawerLayout = findViewById(R.id.drawerLayout)
navigationView = findViewById(R.id.navigationView)
rightPanel = findViewById(R.id.rightPanelContainer)
database = ChatDatabase.getInstance(this)
setupToolbar()
setupRecyclerView()
setupDrawer()
setupRightPanel()
loadModels()
setupInput()
loadProfilesAndSessions()
inputField.postDelayed({
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
@ -113,48 +155,409 @@ class MainActivity : AppCompatActivity() {
}, 300)
}
private fun setupDrawer() {
navigationView.setNavigationItemSelectedListener(this)
hamburgerButton.setOnClickListener {
drawerLayout.openDrawer(GravityCompat.START)
}
drawerLayout.addDrawerListener(object : androidx.drawerlayout.widget.DrawerLayout.DrawerListener {
override fun onDrawerSlide(drawerView: View, slideOffset: Float) {}
override fun onDrawerOpened(drawerView: View) {
if (drawerView == navigationView) {
isLeftDrawerOpen = true
} else if (drawerView == rightPanel) {
isRightPanelVisible = true
}
}
override fun onDrawerClosed(drawerView: View) {
if (drawerView == navigationView) {
isLeftDrawerOpen = false
} else if (drawerView == rightPanel) {
isRightPanelVisible = false
}
}
override fun onDrawerStateChanged(newState: Int) {}
})
}
override fun onNavigationItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_profiles -> {
showProfilesManager()
}
R.id.action_api_key -> {
showApiKeyDialog()
}
R.id.action_clear_all -> {
showClearAllDialog()
}
R.id.action_settings -> {
showSettingsDialog()
}
R.id.action_about -> {
showAboutDialog()
}
}
drawerLayout.closeDrawer(GravityCompat.START)
return true
}
private fun showProfilesManager() {
if (profiles.isEmpty()) {
showCreateProfileDialog()
} else {
val options = profiles.map { it.name }.toMutableList()
if (profiles.size < MAX_PROFILES) {
options.add("Создать новый профиль")
}
AlertDialog.Builder(this)
.setTitle(R.string.manage_profiles)
.setItems(options.toTypedArray()) { _, which ->
if (which < profiles.size) {
showEditProfileDialog(profiles[which])
} else if (profiles.size < MAX_PROFILES) {
showCreateProfileDialog()
}
}
.show()
}
}
private fun showCreateProfileDialog() {
showProfileDialog(null)
}
private fun showEditProfileDialog(profile: Profile) {
showProfileDialog(profile)
}
private fun showClearAllDialog() {
val dialogView = layoutInflater.inflate(R.layout.dialog_clear_all, null)
val deleteProfilesCheckbox = dialogView.findViewById<android.widget.CheckBox>(R.id.deleteProfilesCheckbox)
AlertDialog.Builder(this)
.setTitle(R.string.clear_all_history)
.setView(dialogView)
.setPositiveButton(R.string.yes) { _, _ ->
val deleteProfiles = deleteProfilesCheckbox.isChecked
lifecycleScope.launch {
database.sessionDao().deleteAll()
// Wait for transaction to complete
kotlinx.coroutines.delay(100)
if (deleteProfiles) {
database.profileDao().deleteAll()
currentProfileId = null
prefs.edit().remove(KEY_LAST_PROFILE_ID).apply()
profiles.clear()
}
messages.clear()
adapter.notifyDataSetChanged()
// Create fresh session with null profileId
val newSession = Session(
profileId = null,
title = "Новая сессия"
)
val sessionId = database.sessionDao().insert(newSession)
currentSessionId = sessionId
userMessageCount = 0
messages.clear()
adapter.notifyDataSetChanged()
sessions.clear()
sessions.add(newSession.copy(id = sessionId))
updateRightPanel()
Toast.makeText(this@MainActivity, R.string.history_cleared, Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton(R.string.no, null)
.show()
}
private fun showSettingsDialog() {
val currentSetting = prefs.getBoolean(KEY_NEW_SESSION_ON_START, false)
val options = arrayOf("Открывать последнюю сессию", "Начинать новую сессию")
val selectedIndex = if (currentSetting) 1 else 0
AlertDialog.Builder(this)
.setTitle(R.string.settings)
.setSingleChoiceItems(options, selectedIndex) { dialog, which ->
val newValue = which == 1
prefs.edit().putBoolean(KEY_NEW_SESSION_ON_START, newValue).apply()
dialog.dismiss()
Toast.makeText(this, "Настройка сохранена", Toast.LENGTH_SHORT).show()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun showAboutDialog() {
AlertDialog.Builder(this)
.setTitle(R.string.about)
.setMessage(R.string.about_text)
.setPositiveButton(R.string.ok, null)
.show()
}
private fun setupToolbar() {
hamburgerButton.isVisible = true
logoButton.setOnClickListener {
showModelSelectorDialog()
}
menuButton.setOnClickListener { view ->
val popup = PopupMenu(this, view)
popup.menuInflater.inflate(R.menu.main_menu, popup.menu)
popup.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_api_key -> {
showApiKeyDialog()
true
}
R.id.action_profile -> {
showProfileDialog()
true
}
R.id.action_clear -> {
showClearChatDialog()
true
}
R.id.action_about -> {
showAboutDialog()
true
}
else -> false
showRightPanelMenu(view)
}
}
popup.show()
private fun showRightPanelMenu(view: View) {
if (isRightPanelVisible) {
drawerLayout.closeDrawer(GravityCompat.END)
} else {
if (isLeftDrawerOpen) {
drawerLayout.closeDrawer(GravityCompat.START)
}
drawerLayout.openDrawer(GravityCompat.END)
}
}
private fun setupRightPanel() {
val includedPanel = rightPanel.findViewById<View>(R.id.panelRightContent)
val profilesRecyclerView = includedPanel.findViewById<RecyclerView>(R.id.profilesRecyclerView)
val sessionsRecyclerView = includedPanel.findViewById<RecyclerView>(R.id.sessionsRecyclerView)
val newSessionButton = includedPanel.findViewById<MaterialButton>(R.id.newSessionButton)
profilesAdapter = ProfilesAdapter(
profiles = profiles,
onProfileClick = { profile -> selectProfile(profile) },
onProfileLongClick = { profile -> showProfileOptions(profile) },
getSelectedProfileId = { currentProfileId }
)
profilesRecyclerView.layoutManager = LinearLayoutManager(this)
profilesRecyclerView.adapter = profilesAdapter
val sessionsAdapter = SessionsAdapter(
sessions = sessions,
getCurrentSessionId = { currentSessionId },
onSessionClick = { session -> selectSession(session) },
onSessionLongClick = { session -> showSessionOptions(session) }
)
sessionsRecyclerView.layoutManager = LinearLayoutManager(this)
sessionsRecyclerView.adapter = sessionsAdapter
newSessionButton.setOnClickListener { createNewSession() }
}
private fun loadProfilesAndSessions() {
lifecycleScope.launch {
database.profileDao().getAllProfiles().collect { profileList ->
profiles.clear()
profiles.addAll(profileList)
val lastProfileId = prefs.getLong(KEY_LAST_PROFILE_ID, -1L)
val profileExists = profiles.any { it.id == lastProfileId }
val currentStillValid = currentProfileId != null && profiles.any { it.id == currentProfileId }
if (!currentStillValid) {
currentProfileId = if (lastProfileId > 0 && profileExists) {
lastProfileId
} else if (profiles.isNotEmpty()) {
profiles.first().id
} else {
null
}
val profileId = currentProfileId
if (profileId != null) {
prefs.edit().putLong(KEY_LAST_PROFILE_ID, profileId).apply()
}
}
profilesAdapter?.refresh()
updateRightPanel()
}
}
lifecycleScope.launch {
database.sessionDao().getAllSessions().collect { sessionList ->
sessions.clear()
sessions.addAll(sessionList)
updateRightPanel()
val lastSessionId = prefs.getLong("last_session_id", -1L)
if (currentSessionId == null && sessions.isNotEmpty() && isFirstLoad) {
val newSessionOnStart = prefs.getBoolean(KEY_NEW_SESSION_ON_START, false)
if (newSessionOnStart) {
createNewSession()
} else if (lastSessionId > 0 && sessions.any { it.id == lastSessionId }) {
val session = sessions.find { it.id == lastSessionId }
if (session != null) {
selectSession(session)
} else {
selectSession(sessions.first())
}
} else {
selectSession(sessions.first())
}
isFirstLoad = false
} else if (currentSessionId != null && !sessions.any { it.id == currentSessionId }) {
if (sessions.isNotEmpty()) {
selectSession(sessions.first())
}
}
}
}
}
private fun updateRightPanel() {
try {
profilesAdapter?.refresh()
val includedPanel = rightPanel.findViewById<View>(R.id.panelRightContent) ?: return
val sessionsRecyclerView = includedPanel.findViewById<RecyclerView>(R.id.sessionsRecyclerView) ?: return
(sessionsRecyclerView.adapter as? SessionsAdapter)?.notifyDataSetChanged()
} catch (e: Exception) {
// Panel not initialized yet
}
}
private fun selectProfile(profile: Profile) {
currentProfileId = profile.id
prefs.edit().putLong(KEY_LAST_PROFILE_ID, profile.id).apply()
profilesAdapter?.refresh()
val profileName = profiles.find { it.id == currentProfileId }?.name ?: profile.name
Toast.makeText(this, getString(R.string.profile_info, profileName), Toast.LENGTH_SHORT).show()
}
private fun showProfileOptions(profile: Profile) {
val options = arrayOf("Редактировать", "Удалить")
AlertDialog.Builder(this)
.setTitle(profile.name)
.setItems(options) { _, which ->
when (which) {
0 -> editProfile(profile)
1 -> deleteProfile(profile)
}
}
.show()
}
private fun editProfile(profile: Profile) {
showProfileDialog(profile)
}
private fun deleteProfile(profile: Profile) {
AlertDialog.Builder(this)
.setTitle(R.string.delete)
.setMessage("Удалить профиль ${profile.name}?")
.setPositiveButton(R.string.yes) { _, _ ->
lifecycleScope.launch {
database.profileDao().delete(profile)
}
}
.setNegativeButton(R.string.no, null)
.show()
}
private fun selectSession(session: Session) {
currentSessionId = session.id
userMessageCount = 0
prefs.edit().putLong("last_session_id", session.id).apply()
loadSessionMessages(session.id)
updateRightPanel()
drawerLayout.closeDrawer(GravityCompat.END)
}
private fun loadSessionMessages(sessionId: Long) {
lifecycleScope.launch {
val dbMessages = database.messageDao().getMessagesBySessionSync(sessionId)
messages.clear()
messages.addAll(dbMessages.map { it.toMessage() })
adapter.notifyDataSetChanged()
}
}
private fun showSessionOptions(session: Session) {
val options = arrayOf("Переименовать", "Удалить")
AlertDialog.Builder(this)
.setTitle(session.title)
.setItems(options) { _, which ->
when (which) {
0 -> renameSession(session)
1 -> deleteSession(session)
}
}
.show()
}
private fun renameSession(session: Session) {
val input = EditText(this)
input.setText(session.title)
AlertDialog.Builder(this)
.setTitle("Переименовать")
.setView(input)
.setPositiveButton(R.string.save) { _, _ ->
val newTitle = input.text.toString().trim()
if (newTitle.isNotEmpty()) {
lifecycleScope.launch {
database.sessionDao().updateTitle(session.id, newTitle)
}
}
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun deleteSession(session: Session) {
AlertDialog.Builder(this)
.setTitle(R.string.delete)
.setMessage("Удалить сессию ${session.title}?")
.setPositiveButton(R.string.yes) { _, _ ->
lifecycleScope.launch {
database.sessionDao().delete(session)
}
}
.setNegativeButton(R.string.no, null)
.show()
}
private fun createNewSession() {
lifecycleScope.launch {
val session = Session(
profileId = if (currentProfileId != null && profiles.any { it.id == currentProfileId }) currentProfileId else null,
title = "Новая сессия"
)
val sessionId = database.sessionDao().insert(session)
currentSessionId = sessionId
userMessageCount = 0
messages.clear()
adapter.notifyDataSetChanged()
updateRightPanel()
Toast.makeText(this@MainActivity, R.string.new_session, Toast.LENGTH_SHORT).show()
drawerLayout.closeDrawer(GravityCompat.END)
}
}
private fun showModelSelectorDialog() {
val modelNames = availableModels.map { it.second }.toTypedArray()
val currentModelId = availableModels.find { it.first == selectedModelName }?.second ?: modelNames.firstOrNull() ?: ""
val currentIndex = modelNames.indexOf(currentModelId).coerceAtLeast(0)
val uniqueModels = availableModels.distinctBy { it.second }
val modelNames = uniqueModels.map { it.second }.toTypedArray()
val currentModelId = uniqueModels.find { it.first == selectedModelName }?.second
?: uniqueModels.firstOrNull()?.second
?: ""
val currentIndex = uniqueModels.indexOfFirst { it.second == currentModelId }.coerceAtLeast(0)
AlertDialog.Builder(this)
.setTitle(R.string.select_model)
.setSingleChoiceItems(modelNames, currentIndex) { dialog, which ->
selectedModelName = availableModels[which].first
selectedModelName = uniqueModels[which].first
prefs.edit().putString(KEY_SELECTED_MODEL, selectedModelName).apply()
dialog.dismiss()
}
.setNegativeButton(R.string.cancel, null)
@ -192,17 +595,96 @@ class MainActivity : AppCompatActivity() {
val userInput = inputField.text?.toString()?.trim()
if (userInput.isNullOrEmpty()) return
addMessage(Message(content = userInput, isUser = true))
inputField.text?.clear()
if (currentSessionId == null) {
createNewSessionAndSend(userInput)
return
}
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()))
inputField.text?.clear()
sendMessage(userInput)
}
private fun createNewSessionAndSend(userInput: String) {
lifecycleScope.launch {
val session = Session(
profileId = if (currentProfileId != null && profiles.any { it.id == currentProfileId }) currentProfileId else null,
title = "Новая сессия"
)
val sessionId = database.sessionDao().insert(session)
currentSessionId = sessionId
userMessageCount = 0
messages.clear()
adapter.notifyDataSetChanged()
updateRightPanel()
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()))
inputField.text?.clear()
sendMessage(userInput)
}
}
private fun generateSessionTitle(): kotlinx.coroutines.Job {
val sessionId = currentSessionId ?: return kotlinx.coroutines.Job()
return lifecycleScope.launch(Dispatchers.IO) {
var attempts = 0
val maxAttempts = 2
while (attempts < maxAttempts && isActive) {
attempts++
try {
val recentMessages = messages.takeLast(4).map { msg ->
if (msg.isUser) "User: ${msg.content}" else "AI: ${msg.content}"
}.joinToString("\n")
val prompt = "Кратко озаглавь этот чат в 3-5 слов. Отвечай только названием, без кавычек."
val fullPrompt = "$prompt\n\n$recentMessages"
val result = withTimeoutOrNull(10000L) {
client?.chat(selectedModelName, listOf(
Message(content = fullPrompt, isUser = true)
))
}
if (!isActive) return@launch
if (result != null) {
result.onSuccess { (response, _) ->
val title = response.trim().take(50)
if (title.isNotEmpty() && currentSessionId == sessionId) {
database.sessionDao().updateTitle(sessionId, title)
runOnUiThread {
sessions.find { it.id == sessionId }?.let { session ->
val index = sessions.indexOf(session)
if (index >= 0) {
sessions[index] = session.copy(title = title)
updateRightPanel()
}
}
}
}
}
return@launch
}
} catch (e: Exception) {
if (!isActive) return@launch
}
if (attempts < maxAttempts && isActive) {
kotlinx.coroutines.delay(1000L)
}
}
}
}
private fun loadModels() {
lifecycleScope.launch {
val result = client?.getModels()
result?.onSuccess { models ->
availableModels = models
val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL)
if (!hasUserSelectedModel) {
runOnUiThread {
val codestralIndex = models.indexOfFirst { it.first.contains("codestral", ignoreCase = true) }
if (codestralIndex >= 0) {
@ -211,44 +693,71 @@ class MainActivity : AppCompatActivity() {
selectedModelName = models[0].first
}
}
}
}?.onFailure {
availableModels = MistralClient.AVAILABLE_MODELS
val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL)
if (!hasUserSelectedModel) {
runOnUiThread {
selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-small-latest"
}
}
}
}
}
private fun addMessage(message: Message) {
val isAssistantMessage = !message.isUser
val newPosition = messages.size - 1
messages.add(message)
adapter.notifyItemInserted(messages.size - 1)
saveMessages()
adapter.notifyItemInserted(newPosition)
// Scroll to beginning of assistant messages so user sees the sender name first
if (isAssistantMessage) {
recyclerView.post {
recyclerView.scrollToPosition(0)
// Then scroll to show the new message from beginning
val scrollAmount = (recyclerView.computeVerticalScrollExtent() - 200).coerceAtLeast(0)
recyclerView.post {
recyclerView.scrollBy(0, scrollAmount)
}
}
}
val sessionId = currentSessionId
if (sessionId != null) {
lifecycleScope.launch {
val entity = MessageEntity(
sessionId = sessionId,
content = message.content,
isUser = message.isUser,
timestamp = message.timestamp
)
database.messageDao().insert(entity)
database.sessionDao().updateTimestamp(sessionId)
}
}
recyclerView.postDelayed({
recyclerView.scrollToPosition(messages.size - 1)
}, 100)
}
private fun loadMessages() {
val json = prefs.getString(KEY_MESSAGES, null)
if (json != null) {
try {
val type = object : TypeToken<List<Message>>() {}.type
val loaded: List<Message> = gson.fromJson(json, type)
messages.clear()
messages.addAll(loaded)
} catch (e: Exception) {
// Ignore parse errors
private fun saveMessageToDatabase(sessionId: Long?, content: String, isUser: Boolean, senderName: String?) {
if (sessionId != null) {
lifecycleScope.launch {
database.messageDao().insert(MessageEntity(
sessionId = sessionId,
content = content,
isUser = isUser,
timestamp = System.currentTimeMillis()
))
database.sessionDao().updateTimestamp(sessionId)
}
}
}
private fun saveMessages() {
val json = gson.toJson(messages)
prefs.edit().putString(KEY_MESSAGES, json).apply()
}
private fun getApiKey(): String {
return encryptedPrefs.getString(KEY_API_KEY, null) ?: ""
}
@ -320,11 +829,7 @@ class MainActivity : AppCompatActivity() {
currentJob = lifecycleScope.launch {
try {
val userProfile = loadUserProfile()
val profileContext = if (!userProfile.isEmpty()) {
userProfile.toContextString()
} else ""
val profileContext = getSelectedProfileContext()
val apiMessages = messages.map { msg ->
Message(
@ -337,18 +842,46 @@ class MainActivity : AppCompatActivity() {
apiMessages.add(0, Message(content = profileContext, isUser = true))
}
val result = client?.chat(selectedModel, apiMessages)
val result = withTimeout(15000L) {
client?.chat(selectedModel, apiMessages) ?: Result.failure(Exception("Client not initialized"))
}
result?.onSuccess { (response, usedModel) ->
addMessage(Message(content = response, isUser = false, senderName = usedModel))
}?.onFailure { error ->
if (!isActive) return@launch
result.onSuccess { (response, usedModel) ->
val displayModel = usedModel.ifEmpty { "Assistant" }
addMessage(Message(content = response, isUser = false, senderName = displayModel))
lifecycleScope.launch {
saveMessageToDatabase(currentSessionId, response, false, displayModel)
}
val count = userMessageCount + 1
userMessageCount = count
if (count == 2 && titleGenerationJob?.isActive != true) {
titleGenerationJob = generateSessionTitle()
}
}.onFailure { error ->
if (!isActive) return@launch
val errorMessage = error.message ?: "Unknown error"
if (!errorMessage.contains("cancelled", ignoreCase = true)) {
val userFriendlyMessage = getUserFriendlyError(errorMessage)
addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error"))
}
}
sendButton.isEnabled = true
sendButton.setImageResource(R.drawable.ic_mistral_logo)
progressIndicator.isVisible = false
} catch (e: kotlinx.coroutines.CancellationException) {
addMessage(Message(content = "❌ Отменено пользователем", isUser = false, senderName = "Cancelled"))
} finally {
if (!isActive) return@launch
addMessage(Message(content = "Запрос отменён", isUser = false, senderName = "System"))
sendButton.isEnabled = true
sendButton.setImageResource(R.drawable.ic_mistral_logo)
progressIndicator.isVisible = false
} catch (e: Exception) {
if (!isActive) return@launch
val userFriendlyMessage = getUserFriendlyError(e.message ?: "Unknown error")
addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error"))
sendButton.isEnabled = true
sendButton.setImageResource(R.drawable.ic_mistral_logo)
progressIndicator.isVisible = false
@ -358,62 +891,92 @@ class MainActivity : AppCompatActivity() {
private fun cancelRequest() {
currentJob?.cancel()
titleGenerationJob?.cancel()
client?.cancelRequest()
sendButton.isEnabled = true
sendButton.setImageResource(R.drawable.ic_mistral_logo)
progressIndicator.isVisible = false
}
private fun loadUserProfile(): UserProfile {
return UserProfile(
name = prefs.getString(KEY_USER_NAME, "") ?: "",
bio = prefs.getString(KEY_USER_BIO, "") ?: "",
preferences = prefs.getString(KEY_USER_PREFS, "") ?: ""
)
private fun getSelectedProfileContext(): String {
if (currentProfileId == null) return ""
val profile = profiles.find { it.id == currentProfileId }
if (profile == null) return ""
return buildString {
append("[Profile: ${profile.name}]\n")
if (profile.bio.isNotBlank()) append("Bio: ${profile.bio}\n")
if (profile.preferences.isNotBlank()) append("Preferences: ${profile.preferences}\n")
}
}
private fun saveUserProfile(profile: UserProfile) {
prefs.edit()
.putString(KEY_USER_NAME, profile.name)
.putString(KEY_USER_BIO, profile.bio)
.putString(KEY_USER_PREFS, profile.preferences)
.remove(KEY_PROFILE_HASH)
.apply()
private fun getCurrentProfileName(): String {
if (currentProfileId == null) return "Вы"
val profileName = profiles.find { it.id == currentProfileId }?.name
return if (profileName.isNullOrBlank()) "Вы" else profileName
}
private fun deleteUserProfile() {
prefs.edit()
.remove(KEY_USER_NAME)
.remove(KEY_USER_BIO)
.remove(KEY_USER_PREFS)
.remove(KEY_PROFILE_HASH)
.apply()
private fun showProfileDialog(existingProfile: Profile? = null) {
if (existingProfile == null && profiles.size >= MAX_PROFILES) {
Toast.makeText(this, "Максимум $MAX_PROFILES профилей", Toast.LENGTH_SHORT).show()
return
}
private fun showProfileDialog() {
val profile = loadUserProfile()
val dialogView = layoutInflater.inflate(R.layout.dialog_profile, null)
val nameInput = dialogView.findViewById<TextInputEditText>(R.id.nameInput)
val bioInput = dialogView.findViewById<TextInputEditText>(R.id.bioInput)
val preferencesInput = dialogView.findViewById<TextInputEditText>(R.id.preferencesInput)
nameInput.setText(profile.name)
bioInput.setText(profile.bio)
preferencesInput.setText(profile.preferences)
existingProfile?.let {
nameInput.setText(it.name)
bioInput.setText(it.bio)
preferencesInput.setText(it.preferences)
}
AlertDialog.Builder(this)
val dialog = AlertDialog.Builder(this)
.setTitle(if (existingProfile != null) R.string.profile_title else R.string.new_profile)
.setView(dialogView)
.setPositiveButton(R.string.save) { _, _ ->
val newProfile = UserProfile(
name = nameInput.text?.toString() ?: "",
val name = nameInput.text?.toString()?.trim() ?: ""
if (name.isNotEmpty()) {
lifecycleScope.launch {
if (existingProfile != null) {
database.profileDao().update(existingProfile.copy(
name = name,
bio = bioInput.text?.toString() ?: "",
preferences = preferencesInput.text?.toString() ?: "",
updatedAt = System.currentTimeMillis()
))
} else {
val newId = database.profileDao().insert(Profile(
name = name,
bio = bioInput.text?.toString() ?: "",
preferences = preferencesInput.text?.toString() ?: ""
)
saveUserProfile(newProfile)
))
if (currentProfileId == null) {
currentProfileId = newId
}
}
}
}
}
.setNegativeButton(R.string.cancel, null)
.setNeutralButton(R.string.delete) { _, _ ->
deleteUserProfile()
if (existingProfile != null) {
dialog.setNeutralButton(R.string.delete) { _, _ ->
lifecycleScope.launch {
database.profileDao().delete(existingProfile)
if (currentProfileId == existingProfile.id) {
currentProfileId = null
}
.show()
}
}
}
dialog.show()
}
private fun showClearChatDialog() {
@ -422,19 +985,11 @@ class MainActivity : AppCompatActivity() {
.setPositiveButton(R.string.yes) { _, _ ->
messages.clear()
adapter.notifyDataSetChanged()
prefs.edit().remove(KEY_MESSAGES).apply()
}
.setNegativeButton(R.string.no, null)
.show()
}
private fun showAboutDialog() {
AlertDialog.Builder(this)
.setMessage(R.string.about_text)
.setPositiveButton(android.R.string.ok, null)
.show()
}
private fun getUserFriendlyError(error: String): String {
return when {
error.contains("timeout", ignoreCase = true) ||

View file

@ -6,6 +6,7 @@ import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
@ -46,6 +47,11 @@ class MessageAdapter(private val messages: List<Message>) : RecyclerView.Adapter
class UserMessageHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(message: Message) {
val textView = itemView.findViewById<TextView>(R.id.messageText)
val senderNameView = itemView.findViewById<TextView>(R.id.senderName)
val senderIconView = itemView.findViewById<ImageView>(R.id.senderIcon)
senderIconView.visibility = View.VISIBLE
senderNameView.text = message.senderName ?: "Вы"
textView.text = message.content
textView.setBackgroundResource(R.drawable.bg_message_user)
textView.setTextColor(ContextCompat.getColor(itemView.context, R.color.user_message_text))

View file

@ -1,13 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
<androidx.drawerlayout.widget.DrawerLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".ui.MainActivity">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
@ -19,9 +24,19 @@
android:layout_height="?attr/actionBarSize"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingStart="4dp"
android:paddingEnd="16dp">
<ImageButton
android:id="@+id/hamburgerButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_menu_hamburger"
android:contentDescription="@string/settings"
android:clickable="true"
android:focusable="true" />
<ImageView
android:id="@+id/logoButton"
android:layout_width="36dp"
@ -107,7 +122,7 @@
android:background="@android:color/transparent"
android:hint="@string/enter_message"
android:imeOptions="actionSend"
android:inputType="textMultiLine"
android:inputType="textMultiLine|textCapSentences"
android:maxLines="5"
android:minHeight="56dp"
android:paddingStart="8dp"
@ -133,3 +148,25 @@
</LinearLayout>
</RelativeLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/navigationView"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:headerLayout="@layout/nav_header"
app:menu="@menu/drawer_menu" />
<FrameLayout
android:id="@+id/rightPanelContainer"
android:layout_width="300dp"
android:layout_height="match_parent"
android:layout_gravity="end"
android:background="?attr/colorSurface">
<include layout="@layout/panel_right" />
</FrameLayout>
</androidx.drawerlayout.widget.DrawerLayout>

View file

@ -24,7 +24,7 @@
android:id="@+id/nameInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName"
android:inputType="textPersonName|textCapSentences"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
@ -41,7 +41,7 @@
android:id="@+id/bioInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:inputType="textMultiLine|textCapSentences"
android:minLines="3"
android:maxLines="5" />
@ -59,7 +59,7 @@
android:id="@+id/preferencesInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:inputType="textMultiLine|textCapSentences"
android:minLines="2"
android:maxLines="4" />

View file

@ -31,7 +31,7 @@
android:id="@+id/messageText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="280dp"
android:maxWidth="350dp"
android:padding="12dp"
android:textSize="16sp"
app:layout_constraintStart_toStartOf="parent"

View file

@ -7,13 +7,33 @@
android:padding="8dp">
<TextView
android:id="@+id/messageText"
android:id="@+id/senderName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="280dp"
android:padding="12dp"
android:textSize="16sp"
android:layout_marginEnd="4dp"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant"
app:layout_constraintEnd_toStartOf="@id/senderIcon"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/senderIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@drawable/ic_person"
android:contentDescription="@string/profile"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/messageText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="350dp"
android:layout_marginTop="2dp"
android:padding="12dp"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/senderIcon" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -34,4 +34,19 @@
<string name="api_key_current">Текущий ключ: %s</string>
<string name="enter_api_key">Введите API ключ</string>
<string name="api_key_required">Требуется API ключ Mistral</string>
<string name="clear_all_history">Очистить всю историю</string>
<string name="clear_all_confirm">Удалить все сессии и сообщения?</string>
<string name="history_cleared">История очищена</string>
<string name="sessions">Сессии</string>
<string name="new_session">Новая сессия</string>
<string name="no_sessions">Нет сессий</string>
<string name="ok">OK</string>
<string name="profiles">Профили</string>
<string name="profiles_uppercase">ПРОФИЛИ</string>
<string name="new_profile">Новый профиль</string>
<string name="manage_profiles">Управление профилями</string>
<string name="edit">Редактировать</string>
<string name="selected">Выбрано</string>
<string name="clear_all_delete_profiles">Удалить все профили</string>
<string name="profile_info">Профиль: %s</string>
</resources>