From 21505aae75904585478462151b0d0d7f562761d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=91=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=B5=D0=B2?= Date: Mon, 6 Apr 2026 20:14:32 +0800 Subject: [PATCH] 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 --- .../com/mistral/chat/api/MistralClient.kt | 232 +++-- .../java/com/mistral/chat/data/Message.kt | 55 +- .../java/com/mistral/chat/data/UserProfile.kt | 18 - .../java/com/mistral/chat/ui/MainActivity.kt | 793 +++++++++++++++--- .../com/mistral/chat/ui/MessageAdapter.kt | 6 + app/src/main/res/layout/activity_main.xml | 247 +++--- app/src/main/res/layout/dialog_profile.xml | 6 +- .../res/layout/item_message_assistant.xml | 2 +- app/src/main/res/layout/item_message_user.xml | 28 +- app/src/main/res/values/strings.xml | 15 + 10 files changed, 1070 insertions(+), 332 deletions(-) delete mode 100644 app/src/main/java/com/mistral/chat/data/UserProfile.kt diff --git a/app/src/main/java/com/mistral/chat/api/MistralClient.kt b/app/src/main/java/com/mistral/chat/api/MistralClient.kt index c76aa21..b6b9d1d 100644 --- a/app/src/main/java/com/mistral/chat/api/MistralClient.kt +++ b/app/src/main/java/com/mistral/chat/api/MistralClient.kt @@ -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) { +class MistralClient(private val apiKey: String) { - private val client = OkHttpClient.Builder() - .connectTimeout(60, TimeUnit.SECONDS) - .readTimeout(120, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) - .build() + 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>>? = 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,40 +70,58 @@ import java.util.concurrent.TimeUnit .get() .build() - val response = client.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("API error: ${response.code}")) + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("API error: ${response.code}")) + } + + val responseBody = response.body?.string() ?: "" + val responseJson = gson.fromJson(responseBody, JsonObject::class.java) + + val models = responseJson + .getAsJsonArray("data") + ?.mapNotNull { obj -> + try { + val jsonObj = obj.asJsonObject + 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 + } catch (e: Exception) { + null + } + } + ?.distinctBy { it.first } + ?: emptyList() + + Result.success(models) } - - val responseBody = response.body?.string() ?: "" - val responseJson = gson.fromJson(responseBody, JsonObject::class.java) - - val models = responseJson - .getAsJsonArray("data") - ?.mapNotNull { obj -> - 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 displayName = id - .replace("-latest", "") - .replace("-", " ") - .split(" ") - .joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } } - id to displayName - } else null - } ?: emptyList() - - Result.success(models) } catch (e: Exception) { - Result.failure(e) + 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,41 +155,96 @@ 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")) - } - - if (!response.isSuccessful) { - val errorBody = response.body?.string() ?: "Unknown error" - return@withContext Result.failure(Exception("API error: ${response.code} - $errorBody")) - } + 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)) + } + } + } - val responseBody = response.body?.string() ?: "" - - if (onChunk != null) { - onChunk(responseBody) - } + 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" + currentCall = null + currentContinuation = null + cont.resume(Result.failure(Exception("API error: ${response.code} - $errorBody"))) + return + } - val responseJson = gson.fromJson(responseBody, JsonObject::class.java) - - val choices = responseJson.getAsJsonArray("choices") - if (choices == null || choices.size() == 0) { - return@withContext Result.failure(Exception("No response from API")) + val responseBody = response.body?.string() ?: "" + + if (onChunk != null) { + onChunk(responseBody) + } + + 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) { + currentCall = null + currentContinuation = null + cont.resume(Result.failure(Exception("No response from API"))) + return + } + + 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 + 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() + } } - - val content = choices - .get(0) - ?.asJsonObject - ?.getAsJsonObject("message") - ?.get("content") - ?.asString ?: "" - - val usedModel = responseJson.get("model")?.asString ?: model - - currentCall = null - Result.success(content to usedModel) } catch (e: Exception) { Result.failure(e) } diff --git a/app/src/main/java/com/mistral/chat/data/Message.kt b/app/src/main/java/com/mistral/chat/data/Message.kt index 9cd48e6..febd899 100644 --- a/app/src/main/java/com/mistral/chat/data/Message.kt +++ b/app/src/main/java/com/mistral/chat/data/Message.kt @@ -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, - 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, - 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 ) \ No newline at end of file diff --git a/app/src/main/java/com/mistral/chat/data/UserProfile.kt b/app/src/main/java/com/mistral/chat/data/UserProfile.kt deleted file mode 100644 index 61605c7..0000000 --- a/app/src/main/java/com/mistral/chat/data/UserProfile.kt +++ /dev/null @@ -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") - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt index cd9b12e..5503020 100644 --- a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt +++ b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt @@ -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() + private val sessions = mutableListOf() + 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(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 + showRightPanelMenu(view) + } + } + + 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(R.id.panelRightContent) + val profilesRecyclerView = includedPanel.findViewById(R.id.profilesRecyclerView) + val sessionsRecyclerView = includedPanel.findViewById(R.id.sessionsRecyclerView) + val newSessionButton = includedPanel.findViewById(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 } - R.id.action_profile -> { - showProfileDialog() - true + val profileId = currentProfileId + if (profileId != null) { + prefs.edit().putLong(KEY_LAST_PROFILE_ID, profileId).apply() } - R.id.action_clear -> { - showClearChatDialog() - true + } + + 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()) } - R.id.action_about -> { - showAboutDialog() - true + isFirstLoad = false + } else if (currentSessionId != null && !sessions.any { it.id == currentSessionId }) { + if (sessions.isNotEmpty()) { + selectSession(sessions.first()) } - else -> false } } - popup.show() + } + } + + private fun updateRightPanel() { + try { + profilesAdapter?.refresh() + val includedPanel = rightPanel.findViewById(R.id.panelRightContent) ?: return + val sessionsRecyclerView = includedPanel.findViewById(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,63 +595,169 @@ 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 - runOnUiThread { - val codestralIndex = models.indexOfFirst { it.first.contains("codestral", ignoreCase = true) } - if (codestralIndex >= 0) { - selectedModelName = models[codestralIndex].first - } else if (models.isNotEmpty()) { - selectedModelName = models[0].first + val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL) + if (!hasUserSelectedModel) { + runOnUiThread { + val codestralIndex = models.indexOfFirst { it.first.contains("codestral", ignoreCase = true) } + if (codestralIndex >= 0) { + selectedModelName = models[codestralIndex].first + } else if (models.isNotEmpty()) { + selectedModelName = models[0].first + } } } }?.onFailure { availableModels = MistralClient.AVAILABLE_MODELS - runOnUiThread { - selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-small-latest" + 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>() {}.type - val loaded: List = 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) - - result?.onSuccess { (response, usedModel) -> - addMessage(Message(content = response, isUser = false, senderName = usedModel)) - }?.onFailure { error -> - val errorMessage = error.message ?: "Unknown error" - val userFriendlyMessage = getUserFriendlyError(errorMessage) - addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error")) + val result = withTimeout(15000L) { + client?.chat(selectedModel, apiMessages) ?: Result.failure(Exception("Client not initialized")) } + + 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() { - val profile = loadUserProfile() + private fun showProfileDialog(existingProfile: Profile? = null) { + if (existingProfile == null && profiles.size >= MAX_PROFILES) { + Toast.makeText(this, "Максимум $MAX_PROFILES профилей", Toast.LENGTH_SHORT).show() + return + } + val dialogView = layoutInflater.inflate(R.layout.dialog_profile, null) val nameInput = dialogView.findViewById(R.id.nameInput) val bioInput = dialogView.findViewById(R.id.bioInput) val preferencesInput = dialogView.findViewById(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() ?: "", - bio = bioInput.text?.toString() ?: "", - preferences = preferencesInput.text?.toString() ?: "" - ) - saveUserProfile(newProfile) + 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() ?: "" + )) + 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) || diff --git a/app/src/main/java/com/mistral/chat/ui/MessageAdapter.kt b/app/src/main/java/com/mistral/chat/ui/MessageAdapter.kt index 85d8557..e62d424 100644 --- a/app/src/main/java/com/mistral/chat/ui/MessageAdapter.kt +++ b/app/src/main/java/com/mistral/chat/ui/MessageAdapter.kt @@ -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) : RecyclerView.Adapter class UserMessageHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { fun bind(message: Message) { val textView = itemView.findViewById(R.id.messageText) + val senderNameView = itemView.findViewById(R.id.senderName) + val senderIconView = itemView.findViewById(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)) diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 33a0ff8..fcbe9a1 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,135 +1,172 @@ - - + android:layout_height="match_parent"> - - - - - - - - - - - - - - - - - - - + android:layout_alignParentTop="true"> + android:paddingEnd="16dp"> - + + + + + android:layout_marginStart="12dp" + android:text="Le Chat" + android:textSize="20sp" + android:textStyle="bold" + android:textColor="?attr/colorOnSurface" /> - - + - + - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_profile.xml b/app/src/main/res/layout/dialog_profile.xml index 38d8258..fe3e696 100644 --- a/app/src/main/res/layout/dialog_profile.xml +++ b/app/src/main/res/layout/dialog_profile.xml @@ -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" /> @@ -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" /> diff --git a/app/src/main/res/layout/item_message_assistant.xml b/app/src/main/res/layout/item_message_assistant.xml index c10501a..a7b5a26 100644 --- a/app/src/main/res/layout/item_message_assistant.xml +++ b/app/src/main/res/layout/item_message_assistant.xml @@ -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" diff --git a/app/src/main/res/layout/item_message_user.xml b/app/src/main/res/layout/item_message_user.xml index 92292b3..969a526 100644 --- a/app/src/main/res/layout/item_message_user.xml +++ b/app/src/main/res/layout/item_message_user.xml @@ -7,13 +7,33 @@ android:padding="8dp"> + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 703f8ae..39c539f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,4 +34,19 @@ Текущий ключ: %s Введите API ключ Требуется API ключ Mistral + Очистить всю историю + Удалить все сессии и сообщения? + История очищена + Сессии + Новая сессия + Нет сессий + OK + Профили + ПРОФИЛИ + Новый профиль + Управление профилями + Редактировать + Выбрано + Удалить все профили + Профиль: %s \ No newline at end of file