From d18d4d4de06f18c87fa697ec0a8368d98062975e 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: Sun, 5 Apr 2026 14:56:29 +0800 Subject: [PATCH 01/10] Add API key settings with save/delete options --- .../java/com/mistral/chat/ui/MainActivity.kt | 68 ++++++++++++++++++- app/src/main/res/layout/dialog_api_key.xml | 26 +++++++ app/src/main/res/menu/main_menu.xml | 4 ++ app/src/main/res/values/strings.xml | 7 ++ 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/layout/dialog_api_key.xml 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 9d51756..bbcfea4 100644 --- a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt +++ b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt @@ -50,13 +50,14 @@ class MainActivity : AppCompatActivity() { private lateinit var prefs: SharedPreferences companion object { - private const val API_KEY = "YW0IjDBRLuyEBcgNjVeVUFlMI6fcZYLA" 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 DEFAULT_API_KEY = "YW0IjDBRLuyEBcgNjVeVUFlMI6fcZYLA" } override fun onCreate(savedInstanceState: Bundle?) { @@ -69,7 +70,7 @@ class MainActivity : AppCompatActivity() { gson = Gson() prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - client = MistralClient(API_KEY) + client = MistralClient(getApiKey()) loadMessages() @@ -103,6 +104,10 @@ class MainActivity : AppCompatActivity() { 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 @@ -225,6 +230,65 @@ class MainActivity : AppCompatActivity() { prefs.edit().putString(KEY_MESSAGES, json).apply() } + private fun getApiKey(): String { + return prefs.getString(KEY_API_KEY, DEFAULT_API_KEY) ?: DEFAULT_API_KEY + } + + private fun saveApiKey(apiKey: String) { + prefs.edit().putString(KEY_API_KEY, apiKey).apply() + client = MistralClient(apiKey) + } + + private fun deleteApiKey() { + prefs.edit().remove(KEY_API_KEY).apply() + client = MistralClient(DEFAULT_API_KEY) + } + + private fun showApiKeyDialog() { + val currentKey = getApiKey() + val hasCustomKey = currentKey != DEFAULT_API_KEY && prefs.contains(KEY_API_KEY) + val displayKey = if (hasCustomKey && currentKey.length > 8) { + currentKey.take(4) + "*".repeat(currentKey.length - 8) + currentKey.takeLast(4) + } else if (hasCustomKey) { + "******" + } else { + getString(R.string.no_api_key) + } + + val dialogView = layoutInflater.inflate(R.layout.dialog_api_key, null) + val inputField = dialogView.findViewById(R.id.apiKeyInput) + + if (hasCustomKey) { + inputField.setText(currentKey) + } + + AlertDialog.Builder(this) + .setTitle(R.string.api_key_title) + .setMessage(getString(R.string.api_key_current, displayKey)) + .setView(dialogView) + .setPositiveButton(R.string.save) { _, _ -> + val newKey = inputField.text?.toString()?.trim() + if (!newKey.isNullOrEmpty()) { + saveApiKey(newKey) + showToast(getString(R.string.api_key_saved)) + } + } + .setNegativeButton(R.string.cancel, null) + .apply { + if (hasCustomKey) { + setNeutralButton(R.string.delete) { _, _ -> + deleteApiKey() + showToast(getString(R.string.api_key_deleted)) + } + } + } + .show() + } + + private fun showToast(message: String) { + android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_SHORT).show() + } + private fun sendMessage(userInput: String) { val selectedModel = selectedModelName diff --git a/app/src/main/res/layout/dialog_api_key.xml b/app/src/main/res/layout/dialog_api_key.xml new file mode 100644 index 0000000..f9e89ab --- /dev/null +++ b/app/src/main/res/layout/dialog_api_key.xml @@ -0,0 +1,26 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml index af13832..d0dc9c6 100644 --- a/app/src/main/res/menu/main_menu.xml +++ b/app/src/main/res/menu/main_menu.xml @@ -2,6 +2,10 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6486dbd..2095aec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,4 +25,11 @@ Расскажите о себе... Профиль не установлен Настройки + Mistral API + Mistral API ключ + Введите API ключ + API ключ сохранён + API ключ удалён + API ключ не установлен + Текущий ключ: %s \ No newline at end of file From e98cd8b8e7a93a13d1608d4c01e4846b2325d873 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: Sun, 5 Apr 2026 15:12:36 +0800 Subject: [PATCH 02/10] Unify corner radius across all UI elements to Material Design 3 standard --- app/src/main/res/drawable/bg_send_button.xml | 2 +- app/src/main/res/layout/activity_main.xml | 4 ++-- app/src/main/res/values-night/themes.xml | 2 +- app/src/main/res/values/themes.xml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/drawable/bg_send_button.xml b/app/src/main/res/drawable/bg_send_button.xml index f2b1576..44e1f70 100644 --- a/app/src/main/res/drawable/bg_send_button.xml +++ b/app/src/main/res/drawable/bg_send_button.xml @@ -2,5 +2,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 22e5f48..33a0ff8 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -86,8 +86,8 @@ android:id="@+id/inputCard" android:layout_width="match_parent" android:layout_height="wrap_content" - app:cardCornerRadius="28dp" - app:cardElevation="4dp"> + app:cardCornerRadius="16dp" + app:cardElevation="2dp"> rounded - 24dp + 28dp \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 5e89a57..6d25ccd 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -29,6 +29,6 @@ \ No newline at end of file From a5fe4bc29eb636e81c7fbb8a2449ad91920b7bd4 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: Sun, 5 Apr 2026 18:02:58 +0800 Subject: [PATCH 03/10] Remove hardcoded API key, add encrypted storage with AES-256 --- app/build.gradle | 6 +++ .../java/com/mistral/chat/ui/MainActivity.kt | 48 ++++++++++++++----- app/src/main/res/values/strings.xml | 2 + 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index c4df6ef..81982a3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -40,8 +40,14 @@ dependencies { implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0' + implementation 'androidx.security:security-crypto:1.1.0-alpha06' implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'com.google.code.gson:gson:2.10.1' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + + implementation 'io.noties.markwon:core:4.6.2' + implementation 'io.noties.markwon:ext-strikethrough:4.6.2' + implementation 'io.noties.markwon:ext-tables:4.6.2' + implementation 'io.noties.markwon:ext-tasklist:4.6.2' } \ 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 bbcfea4..cd9b12e 100644 --- a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt +++ b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt @@ -12,6 +12,7 @@ import android.widget.ImageButton import android.widget.ImageView import android.widget.PopupMenu import android.widget.TextView +import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate @@ -19,6 +20,8 @@ import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager 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.progressindicator.LinearProgressIndicator import com.google.android.material.textfield.TextInputEditText @@ -48,6 +51,7 @@ class MainActivity : AppCompatActivity() { private var availableModels: List> = emptyList() private var selectedModelName: String = "mistral-small-latest" private lateinit var prefs: SharedPreferences + private lateinit var encryptedPrefs: SharedPreferences companion object { private const val PREFS_NAME = "mistral_chat_prefs" @@ -57,7 +61,6 @@ class MainActivity : AppCompatActivity() { 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 DEFAULT_API_KEY = "YW0IjDBRLuyEBcgNjVeVUFlMI6fcZYLA" } override fun onCreate(savedInstanceState: Bundle?) { @@ -70,6 +73,22 @@ class MainActivity : AppCompatActivity() { gson = Gson() prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + val masterKey = MasterKey.Builder(this) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + encryptedPrefs = EncryptedSharedPreferences.create( + this, + PREFS_NAME + "_secure", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + if (!hasApiKey()) { + showApiKeyDialog() + } + client = MistralClient(getApiKey()) loadMessages() @@ -231,22 +250,27 @@ class MainActivity : AppCompatActivity() { } private fun getApiKey(): String { - return prefs.getString(KEY_API_KEY, DEFAULT_API_KEY) ?: DEFAULT_API_KEY + return encryptedPrefs.getString(KEY_API_KEY, null) ?: "" + } + + private fun hasApiKey(): Boolean { + return encryptedPrefs.contains(KEY_API_KEY) } private fun saveApiKey(apiKey: String) { - prefs.edit().putString(KEY_API_KEY, apiKey).apply() + encryptedPrefs.edit().putString(KEY_API_KEY, apiKey).apply() client = MistralClient(apiKey) } private fun deleteApiKey() { - prefs.edit().remove(KEY_API_KEY).apply() - client = MistralClient(DEFAULT_API_KEY) + encryptedPrefs.edit().remove(KEY_API_KEY).apply() + Toast.makeText(this, getString(R.string.api_key_deleted), Toast.LENGTH_SHORT).show() + showApiKeyDialog() } private fun showApiKeyDialog() { val currentKey = getApiKey() - val hasCustomKey = currentKey != DEFAULT_API_KEY && prefs.contains(KEY_API_KEY) + val hasCustomKey = currentKey.isNotEmpty() val displayKey = if (hasCustomKey && currentKey.length > 8) { currentKey.take(4) + "*".repeat(currentKey.length - 8) + currentKey.takeLast(4) } else if (hasCustomKey) { @@ -270,25 +294,23 @@ class MainActivity : AppCompatActivity() { val newKey = inputField.text?.toString()?.trim() if (!newKey.isNullOrEmpty()) { saveApiKey(newKey) - showToast(getString(R.string.api_key_saved)) + Toast.makeText(this, getString(R.string.api_key_saved), Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, getString(R.string.enter_api_key), Toast.LENGTH_SHORT).show() } } - .setNegativeButton(R.string.cancel, null) .apply { if (hasCustomKey) { + setNegativeButton(R.string.cancel, null) setNeutralButton(R.string.delete) { _, _ -> deleteApiKey() - showToast(getString(R.string.api_key_deleted)) } } } + .setCancelable(false) .show() } - private fun showToast(message: String) { - android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_SHORT).show() - } - private fun sendMessage(userInput: String) { val selectedModel = selectedModelName diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2095aec..703f8ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,4 +32,6 @@ API ключ удалён API ключ не установлен Текущий ключ: %s + Введите API ключ + Требуется API ключ Mistral \ No newline at end of file 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 04/10] 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 From 7eadec669ce7775f485caa16fc603af767d813d3 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: Tue, 7 Apr 2026 19:23:52 +0800 Subject: [PATCH 05/10] Add drawer menu with flat structure, unified panel styling, Room/SQLCipher setup --- app/build.gradle | 7 ++ .../com/mistral/chat/data/ChatDatabase.kt | 64 ++++++++++++++ .../java/com/mistral/chat/data/MessageDao.kt | 31 +++++++ .../java/com/mistral/chat/data/Profile.kt | 15 ++++ .../java/com/mistral/chat/data/ProfileDao.kt | 28 ++++++ .../java/com/mistral/chat/data/Session.kt | 29 ++++++ .../java/com/mistral/chat/data/SessionDao.kt | 40 +++++++++ .../java/com/mistral/chat/data/Setting.kt | 11 +++ .../java/com/mistral/chat/data/SettingDao.kt | 22 +++++ .../mistral/chat/ui/DrawerSubmenuAdapter.kt | 54 ++++++++++++ .../java/com/mistral/chat/ui/MainActivity.kt | 51 +++++++---- .../com/mistral/chat/ui/ProfilesAdapter.kt | 55 ++++++++++++ .../com/mistral/chat/ui/SessionsAdapter.kt | 45 ++++++++++ app/src/main/res/drawable/ic_add.xml | 10 +++ app/src/main/res/drawable/ic_arrow_back.xml | 10 +++ app/src/main/res/drawable/ic_check.xml | 10 +++ app/src/main/res/drawable/ic_delete.xml | 10 +++ app/src/main/res/drawable/ic_edit.xml | 11 +++ app/src/main/res/drawable/ic_info.xml | 10 +++ app/src/main/res/drawable/ic_key.xml | 10 +++ .../main/res/drawable/ic_menu_hamburger.xml | 10 +++ app/src/main/res/drawable/ic_person.xml | 10 +++ app/src/main/res/drawable/ic_settings.xml | 10 +++ app/src/main/res/layout/dialog_clear_all.xml | 22 +++++ .../main/res/layout/dialog_profile_edit.xml | 55 ++++++++++++ app/src/main/res/layout/dialog_profiles.xml | 22 +++++ app/src/main/res/layout/drawer_main_menu.xml | 73 +++++++++++++++ .../main/res/layout/drawer_profile_edit.xml | 88 +++++++++++++++++++ .../main/res/layout/drawer_settings_menu.xml | 45 ++++++++++ app/src/main/res/layout/drawer_submenu.xml | 50 +++++++++++ app/src/main/res/layout/item_drawer.xml | 36 ++++++++ app/src/main/res/layout/item_profile.xml | 34 +++++++ app/src/main/res/layout/item_session.xml | 28 ++++++ app/src/main/res/layout/nav_header.xml | 17 ++++ app/src/main/res/layout/panel_right.xml | 56 ++++++++++++ app/src/main/res/layout/screen_api_key.xml | 30 +++++++ app/src/main/res/layout/screen_appearance.xml | 31 +++++++ app/src/main/res/layout/screen_base.xml | 47 ++++++++++ .../main/res/layout/screen_clear_history.xml | 32 +++++++ .../main/res/layout/screen_profile_edit.xml | 79 +++++++++++++++++ app/src/main/res/layout/screen_profiles.xml | 25 ++++++ app/src/main/res/layout/screen_session.xml | 23 +++++ app/src/main/res/menu/drawer_menu.xml | 35 ++++++++ app/src/main/res/values/strings.xml | 12 ++- build.gradle | 3 +- 45 files changed, 1378 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/mistral/chat/data/ChatDatabase.kt create mode 100644 app/src/main/java/com/mistral/chat/data/MessageDao.kt create mode 100644 app/src/main/java/com/mistral/chat/data/Profile.kt create mode 100644 app/src/main/java/com/mistral/chat/data/ProfileDao.kt create mode 100644 app/src/main/java/com/mistral/chat/data/Session.kt create mode 100644 app/src/main/java/com/mistral/chat/data/SessionDao.kt create mode 100644 app/src/main/java/com/mistral/chat/data/Setting.kt create mode 100644 app/src/main/java/com/mistral/chat/data/SettingDao.kt create mode 100644 app/src/main/java/com/mistral/chat/ui/DrawerSubmenuAdapter.kt create mode 100644 app/src/main/java/com/mistral/chat/ui/ProfilesAdapter.kt create mode 100644 app/src/main/java/com/mistral/chat/ui/SessionsAdapter.kt create mode 100644 app/src/main/res/drawable/ic_add.xml create mode 100644 app/src/main/res/drawable/ic_arrow_back.xml create mode 100644 app/src/main/res/drawable/ic_check.xml create mode 100644 app/src/main/res/drawable/ic_delete.xml create mode 100644 app/src/main/res/drawable/ic_edit.xml create mode 100644 app/src/main/res/drawable/ic_info.xml create mode 100644 app/src/main/res/drawable/ic_key.xml create mode 100644 app/src/main/res/drawable/ic_menu_hamburger.xml create mode 100644 app/src/main/res/drawable/ic_person.xml create mode 100644 app/src/main/res/drawable/ic_settings.xml create mode 100644 app/src/main/res/layout/dialog_clear_all.xml create mode 100644 app/src/main/res/layout/dialog_profile_edit.xml create mode 100644 app/src/main/res/layout/dialog_profiles.xml create mode 100644 app/src/main/res/layout/drawer_main_menu.xml create mode 100644 app/src/main/res/layout/drawer_profile_edit.xml create mode 100644 app/src/main/res/layout/drawer_settings_menu.xml create mode 100644 app/src/main/res/layout/drawer_submenu.xml create mode 100644 app/src/main/res/layout/item_drawer.xml create mode 100644 app/src/main/res/layout/item_profile.xml create mode 100644 app/src/main/res/layout/item_session.xml create mode 100644 app/src/main/res/layout/nav_header.xml create mode 100644 app/src/main/res/layout/panel_right.xml create mode 100644 app/src/main/res/layout/screen_api_key.xml create mode 100644 app/src/main/res/layout/screen_appearance.xml create mode 100644 app/src/main/res/layout/screen_base.xml create mode 100644 app/src/main/res/layout/screen_clear_history.xml create mode 100644 app/src/main/res/layout/screen_profile_edit.xml create mode 100644 app/src/main/res/layout/screen_profiles.xml create mode 100644 app/src/main/res/layout/screen_session.xml create mode 100644 app/src/main/res/menu/drawer_menu.xml diff --git a/app/build.gradle b/app/build.gradle index 81982a3..b13c057 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id 'com.google.devtools.ksp' version '1.9.22-1.0.17' } android { @@ -50,4 +51,10 @@ dependencies { implementation 'io.noties.markwon:ext-strikethrough:4.6.2' implementation 'io.noties.markwon:ext-tables:4.6.2' implementation 'io.noties.markwon:ext-tasklist:4.6.2' + + implementation 'androidx.room:room-runtime:2.6.1' + implementation 'androidx.room:room-ktx:2.6.1' + ksp 'androidx.room:room-compiler:2.6.1' + implementation 'net.zetetic:android-database-sqlcipher:4.5.4' + implementation 'androidx.sqlite:sqlite-ktx:2.4.0' } \ No newline at end of file diff --git a/app/src/main/java/com/mistral/chat/data/ChatDatabase.kt b/app/src/main/java/com/mistral/chat/data/ChatDatabase.kt new file mode 100644 index 0000000..d735978 --- /dev/null +++ b/app/src/main/java/com/mistral/chat/data/ChatDatabase.kt @@ -0,0 +1,64 @@ +package com.mistral.chat.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import net.sqlcipher.database.SupportFactory + +@Database( + entities = [Profile::class, Session::class, MessageEntity::class, Setting::class], + version = 1, + exportSchema = false +) +abstract class ChatDatabase : RoomDatabase() { + abstract fun profileDao(): ProfileDao + abstract fun sessionDao(): SessionDao + abstract fun messageDao(): MessageDao + abstract fun settingDao(): SettingDao + + companion object { + private const val DATABASE_NAME = "mistral_chat.db" + + @Volatile + private var INSTANCE: ChatDatabase? = null + + fun getInstance(context: Context): ChatDatabase { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: buildDatabase(context).also { INSTANCE = it } + } + } + + private fun buildDatabase(context: Context): ChatDatabase { + val passphrase = getOrCreatePassphrase(context) + val factory = SupportFactory(passphrase) + + return Room.databaseBuilder( + context.applicationContext, + ChatDatabase::class.java, + DATABASE_NAME + ) + .openHelperFactory(factory) + .fallbackToDestructiveMigration() + .build() + } + + private fun getOrCreatePassphrase(context: Context): ByteArray { + val prefs = context.getSharedPreferences("db_prefs", Context.MODE_PRIVATE) + val existingKey = prefs.getString("db_key", null) + + return if (existingKey != null) { + existingKey.toByteArray(Charsets.UTF_8) + } else { + val newKey = generateSecureKey() + prefs.edit().putString("db_key", newKey).apply() + newKey.toByteArray(Charsets.UTF_8) + } + } + + private fun generateSecureKey(): String { + val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*" + return (1..32).map { chars.random() }.joinToString("") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mistral/chat/data/MessageDao.kt b/app/src/main/java/com/mistral/chat/data/MessageDao.kt new file mode 100644 index 0000000..a4bedba --- /dev/null +++ b/app/src/main/java/com/mistral/chat/data/MessageDao.kt @@ -0,0 +1,31 @@ +package com.mistral.chat.data + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface MessageDao { + @Query("SELECT * FROM messages WHERE sessionId = :sessionId ORDER BY timestamp ASC") + fun getMessagesBySession(sessionId: Long): Flow> + + @Query("SELECT * FROM messages WHERE sessionId = :sessionId ORDER BY timestamp ASC") + suspend fun getMessagesBySessionSync(sessionId: Long): List + + @Query("SELECT COUNT(*) FROM messages WHERE sessionId = :sessionId") + suspend fun getMessageCount(sessionId: Long): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(message: MessageEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(messages: List) + + @Delete + suspend fun delete(message: MessageEntity) + + @Query("DELETE FROM messages WHERE sessionId = :sessionId") + suspend fun deleteBySession(sessionId: Long) + + @Query("DELETE FROM messages") + suspend fun deleteAll() +} \ No newline at end of file diff --git a/app/src/main/java/com/mistral/chat/data/Profile.kt b/app/src/main/java/com/mistral/chat/data/Profile.kt new file mode 100644 index 0000000..cd2fd59 --- /dev/null +++ b/app/src/main/java/com/mistral/chat/data/Profile.kt @@ -0,0 +1,15 @@ +package com.mistral.chat.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "profiles") +data class Profile( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val name: String, + val bio: String = "", + val preferences: String = "", + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis() +) \ No newline at end of file diff --git a/app/src/main/java/com/mistral/chat/data/ProfileDao.kt b/app/src/main/java/com/mistral/chat/data/ProfileDao.kt new file mode 100644 index 0000000..4941ba5 --- /dev/null +++ b/app/src/main/java/com/mistral/chat/data/ProfileDao.kt @@ -0,0 +1,28 @@ +package com.mistral.chat.data + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface ProfileDao { + @Query("SELECT * FROM profiles ORDER BY updatedAt DESC") + fun getAllProfiles(): Flow> + + @Query("SELECT * FROM profiles WHERE id = :id") + suspend fun getProfileById(id: Long): Profile? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(profile: Profile): Long + + @Update + suspend fun update(profile: Profile) + + @Delete + suspend fun delete(profile: Profile) + + @Query("DELETE FROM profiles WHERE id = :id") + suspend fun deleteById(id: Long) + + @Query("DELETE FROM profiles") + suspend fun deleteAll() +} \ No newline at end of file diff --git a/app/src/main/java/com/mistral/chat/data/Session.kt b/app/src/main/java/com/mistral/chat/data/Session.kt new file mode 100644 index 0000000..28faa9e --- /dev/null +++ b/app/src/main/java/com/mistral/chat/data/Session.kt @@ -0,0 +1,29 @@ +package com.mistral.chat.data + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "sessions", + foreignKeys = [ + ForeignKey( + entity = Profile::class, + parentColumns = ["id"], + childColumns = ["profileId"], + onDelete = ForeignKey.SET_NULL + ) + ], + indices = [Index("profileId")] +) +data class Session( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val profileId: Long? = null, + val title: String = "Новая сессия", + val isManuallyRenamed: Boolean = false, + val isTitleGenerated: Boolean = false, + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis() +) \ No newline at end of file diff --git a/app/src/main/java/com/mistral/chat/data/SessionDao.kt b/app/src/main/java/com/mistral/chat/data/SessionDao.kt new file mode 100644 index 0000000..63bdaab --- /dev/null +++ b/app/src/main/java/com/mistral/chat/data/SessionDao.kt @@ -0,0 +1,40 @@ +package com.mistral.chat.data + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface SessionDao { + @Query("SELECT * FROM sessions ORDER BY updatedAt DESC") + fun getAllSessions(): Flow> + + @Query("SELECT * FROM sessions WHERE profileId = :profileId ORDER BY updatedAt DESC") + fun getSessionsByProfile(profileId: Long): Flow> + + @Query("SELECT * FROM sessions WHERE profileId IS NULL ORDER BY updatedAt DESC") + fun getSessionsWithoutProfile(): Flow> + + @Query("SELECT * FROM sessions WHERE id = :id") + suspend fun getSessionById(id: Long): Session? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(session: Session): Long + + @Update + suspend fun update(session: Session) + + @Delete + suspend fun delete(session: Session) + + @Query("DELETE FROM sessions WHERE id = :id") + suspend fun deleteById(id: Long) + + @Query("DELETE FROM sessions") + suspend fun deleteAll() + + @Query("UPDATE sessions SET updatedAt = :timestamp WHERE id = :sessionId") + suspend fun updateTimestamp(sessionId: Long, timestamp: Long = System.currentTimeMillis()) + + @Query("UPDATE sessions SET title = :title, isManuallyRenamed = 1 WHERE id = :sessionId") + suspend fun updateTitle(sessionId: Long, title: String) +} \ No newline at end of file diff --git a/app/src/main/java/com/mistral/chat/data/Setting.kt b/app/src/main/java/com/mistral/chat/data/Setting.kt new file mode 100644 index 0000000..7530abd --- /dev/null +++ b/app/src/main/java/com/mistral/chat/data/Setting.kt @@ -0,0 +1,11 @@ +package com.mistral.chat.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "settings") +data class Setting( + @PrimaryKey + val key: String, + val value: String +) \ No newline at end of file diff --git a/app/src/main/java/com/mistral/chat/data/SettingDao.kt b/app/src/main/java/com/mistral/chat/data/SettingDao.kt new file mode 100644 index 0000000..0b06554 --- /dev/null +++ b/app/src/main/java/com/mistral/chat/data/SettingDao.kt @@ -0,0 +1,22 @@ +package com.mistral.chat.data + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface SettingDao { + @Query("SELECT * FROM settings WHERE `key` = :key") + suspend fun getSetting(key: String): Setting? + + @Query("SELECT value FROM settings WHERE `key` = :key") + suspend fun getValue(key: String): String? + + @Query("SELECT value FROM settings WHERE `key` = :key") + fun getValueFlow(key: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(setting: Setting) + + @Query("DELETE FROM settings WHERE `key` = :key") + suspend fun delete(key: String) +} \ No newline at end of file diff --git a/app/src/main/java/com/mistral/chat/ui/DrawerSubmenuAdapter.kt b/app/src/main/java/com/mistral/chat/ui/DrawerSubmenuAdapter.kt new file mode 100644 index 0000000..0a6ab8a --- /dev/null +++ b/app/src/main/java/com/mistral/chat/ui/DrawerSubmenuAdapter.kt @@ -0,0 +1,54 @@ +package com.mistral.chat.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import com.mistral.chat.R + +class DrawerSubmenuAdapter( + private val items: List, + private val onItemClick: (DrawerMenuItem) -> Unit +) : RecyclerView.Adapter() { + + data class DrawerMenuItem( + val id: String, + val title: String, + val icon: Int? = null, + val isSelected: Boolean = false + ) + + class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val icon: ImageView = itemView.findViewById(R.id.itemIcon) + val title: TextView = itemView.findViewById(R.id.itemTitle) + val check: ImageView = itemView.findViewById(R.id.itemCheck) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_drawer, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item = items[position] + holder.title.text = item.title + + if (item.icon != null) { + holder.icon.setImageResource(item.icon) + holder.icon.visibility = View.VISIBLE + } else { + holder.icon.visibility = View.GONE + } + + holder.check.visibility = if (item.isSelected) View.VISIBLE else View.GONE + + holder.itemView.setOnClickListener { + onItemClick(item) + } + } + + override fun getItemCount(): Int = items.size +} \ 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 5503020..14a1c0d 100644 --- a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt +++ b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt @@ -90,6 +90,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte 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 KEY_THEME_MODE = "theme_mode" private const val MAX_PROFILES = 10 } @@ -193,7 +194,10 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte R.id.action_clear_all -> { showClearAllDialog() } - R.id.action_settings -> { + R.id.action_appearance -> { + showThemeDialog() + } + R.id.action_session -> { showSettingsDialog() } R.id.action_about -> { @@ -298,6 +302,31 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte .show() } + private fun showThemeDialog() { + val currentTheme = prefs.getInt(KEY_THEME_MODE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + val options = arrayOf("Системная", "Светлая", "Тёмная") + val selectedIndex = when (currentTheme) { + AppCompatDelegate.MODE_NIGHT_NO -> 1 + AppCompatDelegate.MODE_NIGHT_YES -> 2 + else -> 0 + } + + AlertDialog.Builder(this) + .setTitle(R.string.appearance_settings) + .setSingleChoiceItems(options, selectedIndex) { dialog, which -> + val mode = when (which) { + 1 -> AppCompatDelegate.MODE_NIGHT_NO + 2 -> AppCompatDelegate.MODE_NIGHT_YES + else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + prefs.edit().putInt(KEY_THEME_MODE, mode).apply() + AppCompatDelegate.setDefaultNightMode(mode) + dialog.dismiss() + } + .setNegativeButton(R.string.cancel, null) + .show() + } + private fun showAboutDialog() { AlertDialog.Builder(this) .setTitle(R.string.about) @@ -707,22 +736,16 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } private fun addMessage(message: Message) { - val isAssistantMessage = !message.isUser val newPosition = messages.size - 1 messages.add(message) 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) - } - } + // Scroll to show new AI response + if (!message.isUser) { + recyclerView.postDelayed({ + recyclerView.scrollToPosition(newPosition) + }, 150) } val sessionId = currentSessionId @@ -738,10 +761,6 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte database.sessionDao().updateTimestamp(sessionId) } } - - recyclerView.postDelayed({ - recyclerView.scrollToPosition(messages.size - 1) - }, 100) } private fun saveMessageToDatabase(sessionId: Long?, content: String, isUser: Boolean, senderName: String?) { diff --git a/app/src/main/java/com/mistral/chat/ui/ProfilesAdapter.kt b/app/src/main/java/com/mistral/chat/ui/ProfilesAdapter.kt new file mode 100644 index 0000000..a7a1a3d --- /dev/null +++ b/app/src/main/java/com/mistral/chat/ui/ProfilesAdapter.kt @@ -0,0 +1,55 @@ +package com.mistral.chat.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.mistral.chat.R +import com.mistral.chat.data.Profile + +class ProfilesAdapter( + private val profiles: List, + private val onProfileClick: (Profile) -> Unit, + private val onProfileLongClick: (Profile) -> Unit, + private val getSelectedProfileId: () -> Long? +) : RecyclerView.Adapter() { + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val icon: ImageView = view.findViewById(R.id.profileIcon) + val name: TextView = view.findViewById(R.id.profileName) + val checkmark: ImageView = view.findViewById(R.id.profileCheckmark) + } + + fun refresh() { + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_profile, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val profile = profiles[position] + val selectedId = getSelectedProfileId() + + holder.name.text = if (profile.name.length > 12) { + profile.name.take(12) + "..." + } else { + profile.name + } + holder.name.alpha = if (profile.id == selectedId) 1.0f else 0.7f + holder.checkmark.visibility = if (profile.id == selectedId) View.VISIBLE else View.GONE + + holder.itemView.setOnClickListener { onProfileClick(profile) } + holder.itemView.setOnLongClickListener { + onProfileLongClick(profile) + true + } + } + + override fun getItemCount(): Int = profiles.size +} \ No newline at end of file diff --git a/app/src/main/java/com/mistral/chat/ui/SessionsAdapter.kt b/app/src/main/java/com/mistral/chat/ui/SessionsAdapter.kt new file mode 100644 index 0000000..9947898 --- /dev/null +++ b/app/src/main/java/com/mistral/chat/ui/SessionsAdapter.kt @@ -0,0 +1,45 @@ +package com.mistral.chat.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.mistral.chat.R +import com.mistral.chat.data.Session + +class SessionsAdapter( + private val sessions: List, + private val getCurrentSessionId: () -> Long?, + private val onSessionClick: (Session) -> Unit, + private val onSessionLongClick: (Session) -> Unit +) : RecyclerView.Adapter() { + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val title: TextView = view.findViewById(R.id.sessionTitle) + val checkmark: ImageView = view.findViewById(R.id.sessionCheckmark) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_session, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val session = sessions[position] + val currentId = getCurrentSessionId() + holder.title.text = session.title + holder.title.alpha = if (session.id == currentId) 1.0f else 0.7f + holder.checkmark.visibility = if (session.id == currentId) View.VISIBLE else View.GONE + + holder.itemView.setOnClickListener { onSessionClick(session) } + holder.itemView.setOnLongClickListener { + onSessionLongClick(session) + true + } + } + + override fun getItemCount(): Int = sessions.size +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..1cc0ebc --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..3acb8a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..67fd43c --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..4fd798e --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000..64c725a --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000..6513bf2 --- /dev/null +++ b/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_key.xml b/app/src/main/res/drawable/ic_key.xml new file mode 100644 index 0000000..12e9933 --- /dev/null +++ b/app/src/main/res/drawable/ic_key.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_menu_hamburger.xml b/app/src/main/res/drawable/ic_menu_hamburger.xml new file mode 100644 index 0000000..9b9c873 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_hamburger.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml new file mode 100644 index 0000000..26cece3 --- /dev/null +++ b/app/src/main/res/drawable/ic_person.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..4fb4dc2 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_clear_all.xml b/app/src/main/res/layout/dialog_clear_all.xml new file mode 100644 index 0000000..5060023 --- /dev/null +++ b/app/src/main/res/layout/dialog_clear_all.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_profile_edit.xml b/app/src/main/res/layout/dialog_profile_edit.xml new file mode 100644 index 0000000..d61571e --- /dev/null +++ b/app/src/main/res/layout/dialog_profile_edit.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_profiles.xml b/app/src/main/res/layout/dialog_profiles.xml new file mode 100644 index 0000000..42bd6e6 --- /dev/null +++ b/app/src/main/res/layout/dialog_profiles.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/app/src/main/res/layout/drawer_main_menu.xml b/app/src/main/res/layout/drawer_main_menu.xml new file mode 100644 index 0000000..0771fac --- /dev/null +++ b/app/src/main/res/layout/drawer_main_menu.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/drawer_profile_edit.xml b/app/src/main/res/layout/drawer_profile_edit.xml new file mode 100644 index 0000000..696ff31 --- /dev/null +++ b/app/src/main/res/layout/drawer_profile_edit.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/drawer_settings_menu.xml b/app/src/main/res/layout/drawer_settings_menu.xml new file mode 100644 index 0000000..e20facf --- /dev/null +++ b/app/src/main/res/layout/drawer_settings_menu.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/drawer_submenu.xml b/app/src/main/res/layout/drawer_submenu.xml new file mode 100644 index 0000000..e6e6c96 --- /dev/null +++ b/app/src/main/res/layout/drawer_submenu.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_drawer.xml b/app/src/main/res/layout/item_drawer.xml new file mode 100644 index 0000000..4f0e9b7 --- /dev/null +++ b/app/src/main/res/layout/item_drawer.xml @@ -0,0 +1,36 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_profile.xml b/app/src/main/res/layout/item_profile.xml new file mode 100644 index 0000000..3c12f6e --- /dev/null +++ b/app/src/main/res/layout/item_profile.xml @@ -0,0 +1,34 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_session.xml b/app/src/main/res/layout/item_session.xml new file mode 100644 index 0000000..04bc00c --- /dev/null +++ b/app/src/main/res/layout/item_session.xml @@ -0,0 +1,28 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/nav_header.xml b/app/src/main/res/layout/nav_header.xml new file mode 100644 index 0000000..b3fdd97 --- /dev/null +++ b/app/src/main/res/layout/nav_header.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/panel_right.xml b/app/src/main/res/layout/panel_right.xml new file mode 100644 index 0000000..d32b743 --- /dev/null +++ b/app/src/main/res/layout/panel_right.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/screen_api_key.xml b/app/src/main/res/layout/screen_api_key.xml new file mode 100644 index 0000000..5ba01f3 --- /dev/null +++ b/app/src/main/res/layout/screen_api_key.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/screen_appearance.xml b/app/src/main/res/layout/screen_appearance.xml new file mode 100644 index 0000000..43136ee --- /dev/null +++ b/app/src/main/res/layout/screen_appearance.xml @@ -0,0 +1,31 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/screen_base.xml b/app/src/main/res/layout/screen_base.xml new file mode 100644 index 0000000..a83b50c --- /dev/null +++ b/app/src/main/res/layout/screen_base.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/screen_clear_history.xml b/app/src/main/res/layout/screen_clear_history.xml new file mode 100644 index 0000000..82b8e6e --- /dev/null +++ b/app/src/main/res/layout/screen_clear_history.xml @@ -0,0 +1,32 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/screen_profile_edit.xml b/app/src/main/res/layout/screen_profile_edit.xml new file mode 100644 index 0000000..0077274 --- /dev/null +++ b/app/src/main/res/layout/screen_profile_edit.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/screen_profiles.xml b/app/src/main/res/layout/screen_profiles.xml new file mode 100644 index 0000000..1d6a8d2 --- /dev/null +++ b/app/src/main/res/layout/screen_profiles.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/screen_session.xml b/app/src/main/res/layout/screen_session.xml new file mode 100644 index 0000000..9092799 --- /dev/null +++ b/app/src/main/res/layout/screen_session.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/drawer_menu.xml b/app/src/main/res/menu/drawer_menu.xml new file mode 100644 index 0000000..54b2f49 --- /dev/null +++ b/app/src/main/res/menu/drawer_menu.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 39c539f..a459d7c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -42,11 +42,21 @@ Нет сессий OK Профили + Управление профилями ПРОФИЛИ Новый профиль - Управление профилями Редактировать Выбрано Удалить все профили Профиль: %s + Внешний вид + Сессия при запуске + Очистить историю + Удалить все сессии и сообщения? + Открывать последнюю сессию + Начинать новую сессию + Светлая + Тёмная + Назад + Системная \ No newline at end of file diff --git a/build.gradle b/build.gradle index 79bc0f9..788c34b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,6 @@ // Top-level build file plugins { id 'com.android.application' version '8.2.0' apply false - id 'org.jetbrains.kotlin.android' version '1.9.0' apply false + id 'org.jetbrains.kotlin.android' version '1.9.22' apply false + id 'com.google.devtools.ksp' version '1.9.22-1.0.17' apply false } \ No newline at end of file From 5d59c5e35171a0578f7eb2bb9f8dc6cc410b335b 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: Tue, 7 Apr 2026 22:31:28 +0800 Subject: [PATCH 06/10] Add API key validation, secure DB key storage, input field padding, scroll fixes --- .../com/mistral/chat/data/ChatDatabase.kt | 19 ++++- .../java/com/mistral/chat/ui/MainActivity.kt | 71 +++++++++++++------ app/src/main/res/layout/activity_main.xml | 9 +-- app/src/main/res/layout/dialog_api_key.xml | 2 + 4 files changed, 71 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/mistral/chat/data/ChatDatabase.kt b/app/src/main/java/com/mistral/chat/data/ChatDatabase.kt index d735978..1a23bc7 100644 --- a/app/src/main/java/com/mistral/chat/data/ChatDatabase.kt +++ b/app/src/main/java/com/mistral/chat/data/ChatDatabase.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey import net.sqlcipher.database.SupportFactory @Database( @@ -44,14 +46,25 @@ abstract class ChatDatabase : RoomDatabase() { } private fun getOrCreatePassphrase(context: Context): ByteArray { - val prefs = context.getSharedPreferences("db_prefs", Context.MODE_PRIVATE) - val existingKey = prefs.getString("db_key", null) + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + val securePrefs = EncryptedSharedPreferences.create( + context, + "db_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + val existingKey = securePrefs.getString("db_key", null) return if (existingKey != null) { existingKey.toByteArray(Charsets.UTF_8) } else { val newKey = generateSecureKey() - prefs.edit().putString("db_key", newKey).apply() + securePrefs.edit().putString("db_key", newKey).apply() newKey.toByteArray(Charsets.UTF_8) } } 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 14a1c0d..c8038e7 100644 --- a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt +++ b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt @@ -83,6 +83,9 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private var userMessageCount = 0 private var titleGenerationJob: kotlinx.coroutines.Job? = null private var isFirstLoad = true + private var userScrolledAfterSend = false + private var lastUserMessagePosition = -1 + private var apiKeyDialog: AlertDialog? = null companion object { private const val PREFS_NAME = "mistral_chat_prefs" @@ -599,15 +602,24 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte stackFromEnd = true } recyclerView.adapter = adapter + + recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + val lastVisible = layoutManager.findLastVisibleItemPosition() + val totalItems = layoutManager.itemCount + + if (lastVisible < totalItems - 2) { + userScrolledAfterSend = true + } + } + }) } private fun setupInput() { sendButton.setOnClickListener { - if (currentJob?.isActive == true) { - cancelRequest() - } else { - sendInput() - } + sendInput() } inputField.setOnEditorActionListener { _, actionId, event -> @@ -624,6 +636,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val userInput = inputField.text?.toString()?.trim() if (userInput.isNullOrEmpty()) return + val apiKey = getApiKey() + if (apiKey.isEmpty() || apiKey.length < 32 || !apiKey.matches(Regex("^[a-zA-Z0-9]+$"))) { + Toast.makeText(this, getString(R.string.api_key_required), Toast.LENGTH_SHORT).show() + showApiKeyDialog() + return + } + if (currentSessionId == null) { createNewSessionAndSend(userInput) return @@ -632,6 +651,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName())) inputField.text?.clear() + + userScrolledAfterSend = false + lastUserMessagePosition = messages.size - 1 + + recyclerView.postDelayed({ + recyclerView.scrollToPosition(lastUserMessagePosition) + }, 100) + sendMessage(userInput) } @@ -741,10 +768,11 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte messages.add(message) adapter.notifyItemInserted(newPosition) - // Scroll to show new AI response - if (!message.isUser) { + if (!message.isUser && !userScrolledAfterSend) { recyclerView.postDelayed({ - recyclerView.scrollToPosition(newPosition) + if (!userScrolledAfterSend) { + recyclerView.scrollToPosition(newPosition) + } }, 150) } @@ -821,7 +849,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte .setPositiveButton(R.string.save) { _, _ -> val newKey = inputField.text?.toString()?.trim() if (!newKey.isNullOrEmpty()) { + if (newKey.length < 32 || !newKey.matches(Regex("^[a-zA-Z0-9]+$"))) { + Toast.makeText(this, "Неверный формат API ключа (минимум 32 символа, только a-z, A-Z, 0-9)", Toast.LENGTH_LONG).show() + return@setPositiveButton + } + saveApiKey(newKey) + client = MistralClient(newKey) + apiKeyDialog?.dismiss() Toast.makeText(this, getString(R.string.api_key_saved), Toast.LENGTH_SHORT).show() } else { Toast.makeText(this, getString(R.string.enter_api_key), Toast.LENGTH_SHORT).show() @@ -829,13 +864,17 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } .apply { if (hasCustomKey) { - setNegativeButton(R.string.cancel, null) + setNegativeButton(R.string.cancel) { dialog, _ -> + dialog.dismiss() + } setNeutralButton(R.string.delete) { _, _ -> deleteApiKey() } } } .setCancelable(false) + .create() + .also { apiKeyDialog = it } .show() } @@ -843,7 +882,6 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val selectedModel = selectedModelName sendButton.isEnabled = false - sendButton.setImageResource(R.drawable.ic_stop) progressIndicator.isVisible = true currentJob = lifecycleScope.launch { @@ -889,34 +927,21 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } sendButton.isEnabled = true - sendButton.setImageResource(R.drawable.ic_mistral_logo) progressIndicator.isVisible = false } catch (e: kotlinx.coroutines.CancellationException) { 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 } } } - - private fun cancelRequest() { - currentJob?.cancel() - titleGenerationJob?.cancel() - client?.cancelRequest() - - sendButton.isEnabled = true - sendButton.setImageResource(R.drawable.ic_mistral_logo) - progressIndicator.isVisible = false - } private fun getSelectedProfileContext(): String { if (currentProfileId == null) return "" diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index fcbe9a1..b2e085d 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -109,8 +109,8 @@ android:layout_height="wrap_content" android:gravity="center_vertical" android:orientation="horizontal" - android:paddingStart="4dp" - android:paddingEnd="0dp" + android:paddingStart="8dp" + android:paddingEnd="8dp" android:paddingTop="4dp" android:paddingBottom="4dp"> @@ -125,8 +125,8 @@ android:inputType="textMultiLine|textCapSentences" android:maxLines="5" android:minHeight="56dp" - android:paddingStart="8dp" - android:paddingEnd="8dp" + android:paddingStart="12dp" + android:paddingEnd="12dp" android:paddingTop="12dp" android:paddingBottom="12dp" /> @@ -134,6 +134,7 @@ android:id="@+id/sendButton" android:layout_width="40dp" android:layout_height="40dp" + android:layout_marginStart="4dp" android:padding="8dp" android:src="@drawable/ic_mistral_logo" android:background="@drawable/bg_send_button" diff --git a/app/src/main/res/layout/dialog_api_key.xml b/app/src/main/res/layout/dialog_api_key.xml index f9e89ab..0d50567 100644 --- a/app/src/main/res/layout/dialog_api_key.xml +++ b/app/src/main/res/layout/dialog_api_key.xml @@ -19,6 +19,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="textPassword" + android:digits="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + android:maxLength="64" android:maxLines="1" /> From ae5907c45f0f333dc4de05c90f30e2a5f9fa4a43 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: Fri, 10 Apr 2026 00:04:25 +0800 Subject: [PATCH 07/10] Add RSS parsing for news, update system prompt with RSS URLs, add current year dynamic substitution, update AGENTS.md with context optimization discussion --- AGENTS.md | 452 ++++++++++++++++ .../java/com/mistral/chat/api/TimeTools.kt | 488 ++++++++++++++++++ .../java/com/mistral/chat/ui/MainActivity.kt | 366 +++++++++++-- app/src/main/res/values/strings.xml | 69 +++ 4 files changed, 1323 insertions(+), 52 deletions(-) create mode 100644 AGENTS.md create mode 100644 app/src/main/java/com/mistral/chat/api/TimeTools.kt diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d8fa777 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,452 @@ +# Mistral Chat App - Development Context + +## Project Overview + +Android-приложение для чата с Mistral AI. Перспективный проект с развитием в сторону AI-агента с памятью, tools и автономной работой. + +**Основные технологии:** +- Kotlin + Android (minSdk 26, targetSdk 34) +- Room + SQLCipher (encrypted database) +- OkHttp для API +- Material Design 3 +- Russian language UI + +**Расположение проекта:** +``` +/Users/alexabudaev/Documents/Zed/mistral-chat-app/ +``` + +**⚠️ ВАЖНО: Принципы разработки** +- Приложение должно работать БЕЗ платных подписок и инвестиций +- Всегда использовать собственные разработки или бесплатные решения +- Не рассчитывать на имеющиеся платные API при планировании функций +- Сначала находим бесплатное решение, потом реализуем +- **Если что-то невозможно сделать без платных API или ты не можешь понять задачу - говори честно!** +- При планировании любого следующего этапа следует тщательно анализировать возможность реализовать ту или иную функцию не только исходя из программной совместимости, но и с учётом наличия бесплатных версий необходимых сервисов, API и других продуктов (или возможности написать собственное решение) + +--- + +## Completed Work + +### ✅ Core Features +- Чат с Mistral API (Chat Completion) +- Управление профилями (до 10 профилей) +- Управление сессиями (множественные чаты) +- **Генерация названия сессии** - после 2-го сообщения AI генерирует краткое название (3-5 слов) +- Шифрованное хранилище (SQLCipher + EncryptedSharedPreferences) +- Валидация API ключа (32+ символов, A-Z, a-z, 0-9) +- Левое drawer-меню с диалогами +- Тёмная/светлая тема + +### ✅ UI/UX +- Material Design 3 +- Русский язык интерфейса +- Отступы в поле ввода (12dp) +- Прокрутка к новым сообщениям +- **Долгий тап на сообщение** - меню Копировать/Редактировать/Удалить + +### ✅ Security +- API ключ: EncryptedSharedPreferences (AES-256-GCM) +- Ключ БД: EncryptedSharedPreferences (AES-256-SIV + AES-256-GCM) +- Профили, сессии, сообщения: SQLCipher + +--- + +## Current Issues & Architecture + +### ⚠️ Важное: Назначение web_search + +web_search НЕ является интерфейсом поисковика или Wikipedia. Это инструмент для AI-агента: + +**Правильная логика работы (Kai-style):** +``` +1. AI получает вопрос пользователя +2. AI решает что нужен поиск → вызывает web_search +3. Выполняются ВСЕ tool_calls параллельно +4. Результаты НЕ показываются пользователю - только отправляются AI +5. AI интерпретирует результаты → выдаёт ОДИН финальный ответ +``` + +**Проблемы с текущей реализацией:** +- ❌ Показываем промежуточные ответы пользователю (каждый tool result = сообщение) +- ❌ AI получает результаты и отвечает после КАЖДОГО tool_calls +- ❌ AI выводит куски данных вместо интерпретации + +**Требуется исправление:** +- ✅ Выполнить ВСЕ tool_calls за один проход (уже делаем) +- ✅ Результаты НЕ показывать пользователю (только AI видит) +- ✅ AI интерпретирует и выдаёт ОДИН ответ + +### 🔍 Web Search (Текущая реализация - БЕСПЛАТНОЕ решение) + +**Используется:** Russian Wikipedia API (бесплатно, без API ключа) +- **API:** `https://ru.wikipedia.org/w/api.php` +- **Метод:** `query/list/search` - поиск статей по заголовкам +- **Ограничение результатов:** до 10 статей (параметр `num_results`) +- **Ограничение символов:** 4000 символов на ответ +- **ПРИМЕЧАНИЕ:** Это временное решение! Позже можно добавить платный API для полноценного поиска (новости, погода, актуальная информация) + +**Логика работы:** +1. AI вызывает `web_search` с текстовым запросом +2. Выполняется поиск по Wikipedia API +3. Результаты (заголовки + сниппеты) обрезаются до 4000 символов +4. Результаты отправляются AI для интерпретации +5. AI выдаёт ОДИН финальный ответ пользователю + +**Tool Loop (MainActivity):** +- Максимум итераций: 15 +- Timeout на итерацию: 30 секунд +- AI может сделать несколько последовательных поисков если нужно + +### 🌤️ Weather Tool (БЕСПЛАТНОЕ решение) + +**Используется:** Open-Meteo API (полностью бесплатно, без API ключа) +- **Geocoding API:** `https://geocoding-api.open-meteo.com/v1/search` - определение координат города +- **Weather API:** `https://api.open-meteo.com/v1/forecast` - текущая погода + прогноз на 7 дней +- **Параметры:** температура, ветер, погодные коды, осадки + +**Логика работы:** +1. AI вызывает `get_weather` с названием города +2. Определяются координаты через Geocoding API +3. Запрашивается погода по координатам +4. Возвращается текущая погода + прогноз на 7 дней + +### 🔗 OpenUrlTool (Часть Phase 3) +**Статус:** ✅ Реализовано | **Оценка:** 1 день + +**Назначение:** Позволяет AI парсить любую веб-страницу по URL. + +**Гибридная схема (AI сам решает откуда взять URL):** +1. **RSS-ленты новостей** (рекомендуется): + - lenta.ru → https://lenta.ru/rss/ + - kommersant.ru → https://www.kommersant.ru/rss/news.xml +2. **Из памяти** - AI помнит рабочие URL +3. **Через web_search** - находит URL в интернете +4. **От пользователя** - пользователь может передать URL + +**Логика работы с новостями:** +1. Получив запрос о новостях → сначала проверь память пользователя (предпочтения по темам) +2. Открой RSS-ленту через open_url (самый эффективный способ) +3. Составь сводку с учётом интересов пользователя +4. Если источники недоступны → используй web_search +5. Проверь память приложения +6. Выдай ответ на основе всех доступных источников + +**Реализация:** +- HTTP GET запрос к любому URL +- Возврат ТОЛЬКО текста (удаляются HTML теги) +- Ограничение: 4000 символов +- Таймаут: 10 секунд +- Блокировка опасных URL (javascript:, file:, data:) + +--- + +### 📚 Изучено из Kai (open-source AI assistant) + +Kai имеет отличную документацию по tools: https://kai9000.com/docs/features/tools/ + +**Ключевые решения из Kai:** + +1. **Execution Flow:** + - Все tool calls выполняются параллельно (coroutine async/await) + - TOOL_EXECUTING показывается в UI как "пульсирующий индикатор" + - Результаты НЕ показываются пользователю - только отправляются AI + - AI может вызвать еще tool calls → цикл повторяется + - Когда AI отвечает без tool_calls → финальный текст показан пользователю + +2. **Safety Guards (важно!):** + - Iteration limit: максимум 15 итераций + - Repeated call detection: если одинаковый tool с одинаковыми аргументами вызывается 3 раза подряд → остановка + - Timeout: 30 секунд по умолчанию + - Result truncation: результаты > 8000 символов обрезаются + - Context trimming: между итерациями обрезается история сообщений + +3. **Web Search в Kai:** + - Есть встроенный web_search tool + - Работает (вероятно использует платный API или свой парсинг) + +--- + +## Active Plan (Phases 1-3) + +### Phase 1: Расширенные профили (Extended Profiles) +**Статус:** ✅ Завершена | **Оценка:** 1-2 дня + +Добавлено поле `systemPrompt` в профиль для отправки как role: "system". + +| Задача | Статус | +|--------|--------| +| Profile entity | ✅ Добавлено поле systemPrompt | +| Profile dialog UI | ✅ Добавлен EditText с maxLength=4000 | +| ProfileDao | ✅ CRUD работает | +| MainActivity | ✅ Инжектирует systemPrompt как role: "system" | +| MistralClient | ✅ Использует msg.role | + +--- + +### Phase 2: Система памяти (Memory System) +**Статус:** ✅ Завершена | **Оценка:** 2-3 дня + +Система запоминания информации с категориями и hitCount. + +| Задача | Статус | +|--------|--------| +| Memory entity | ✅ key, value, category, hitCount, timestamps | +| MemoryDao | ✅ CRUD + getByCategory, incrementHitCount, getPromotionCandidates | +| ChatDatabase | ✅ Добавлен MemoryDao, version=2 | +| MemoryRepository | ✅ buildMemoryContext() для инжекции в prompt | + +**Memory categories:** +- GENERAL — общие факты +- LEARNING — выводы и паттерны +- ERROR — известные ошибки +- PREFERENCE — предпочтения пользователя + +**Prompt injection:** +``` +=== Важная информация === +[Факты] +- ключ: значение + +[Выводы] +- ключ: значение (N использований) + +[Предпочтения пользователя] +- ключ: значение +``` + +--- + +### Phase 3: Tools / Tool Execution +**Статус:** ✅ Завершена (тестирование) | **Оценка:** 3-4 дня + +Инструменты для AI (function calling) для выполнения действий. + +| Задача | Статус | +|--------|--------| +| Tool abstract class | ✅ name, description, inputSchema, executor | +| GetTimeTool | ✅ get_local_time с timezone | +| GetDateTool | ✅ get_date с timezone | +| WebSearchTool | ✅ Протестировано (только Wikipedia) | +| GetWeatherTool | ✅ Протестировано (Open-Meteo API) | +| NotificationTool | ✅ send_notification | +| MemoryStoreTool | ✅ Протестировано | +| MemoryLearnTool | ✅ Протестировано | +| MemoryForgetTool | ✅ Протестировано | +| MemoryReinforceTool | ✅ Протестировано | +| MemoryPreferenceTool | ✅ Протестировано | +| ToolExecutor | ✅ управление всеми tools, updateSettings() | +| MistralClient | ✅ tools в chat completion, обработка tool_calls | +| Safety | ✅ Max iterations (15), timeout (30s), result truncation (2000 chars) | +| **OpenUrlTool (RSS)** | ✅ Автоматическое определение и парсинг RSS/Atom | + +**RSS-ленты (протестировано):** +- lenta.ru/rss/ ✅ +- kommersant.ru/rss/news.xml ✅ + +**Тестирование Phase 3:** +- ✅ web_search (Wikipedia) - работает +- ✅ get_weather (Open-Meteo) - работает +- ✅ Memory tools - работает, изолирована по профилям (протестировано) + +**Location Settings (в рамках Phase 3):** +| Задача | Статус | +|--------|--------| +| Preferences keys | ✅ KEY_DEFAULT_TIMEZONE, KEY_DEFAULT_CITY | +| dialog_location.xml | ✅ UI для ввода timezone/city | +| showLocationDialog() | ✅ Реализована в MainActivity | +| drawer_menu.xml | ✅ Добавлен item action_location | +| ic_location.xml | ✅ Создан vector drawable | +| ToolExecutor.updateSettings() | ✅ Принимает timezone/city при сохранении | + +**Defaults:** +- Timezone: Asia/Irkutsk +- City: Иркутск + +--- + +## Unconfirmed Phases (Not Approved) + +Следующие фазы требуют дополнительного планирования: + +### Phase 4: Heartbeat +**Оценка:** 2-3 дня + +Автономная периодическая самопроверка: +- WorkManager задача (каждые 30 минут) +- Active hours (8:00-22:00) +- Обработка ответа (молча vs уведомление) + +### Phase 5: Email (IMAP/SMTP) +**Оценка:** 4-5 дней + +Интеграция с email без OAuth: +- IMAP клиент (чтение писем) +- SMTP клиент (отправка) +- UI настройки ящика (сервер, порт, логин, пароль) +- Email tools для AI + +--- + +## Technical Context + +### ⚠️ ВАЖНО: Сборка APK после каждого изменения +**После каждого исправления или добавления функций НЕОБХОДИМО собирать APK!** + +Пользователь должен иметь возможность сразу протестировать изменения. + +```bash +# Сборка APK +JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug + +# Путь к APK +app/build/outputs/apk/debug/app-debug.apk +``` + +### Key Files +- `app/src/main/java/com/mistral/chat/ui/MainActivity.kt` — главная активность +- `app/src/main/java/com/mistral/chat/api/MistralClient.kt` — API клиент +- `app/src/main/java/com/mistral/chat/api/ToolExecutor.kt` — менеджер tools +- `app/src/main/java/com/mistral/chat/data/ChatDatabase.kt` — база данных +- `app/src/main/java/com/mistral/chat/data/Profile.kt` — профиль +- `app/src/main/java/com/mistral/chat/data/Memory.kt` — память +- `app/src/main/res/layout/dialog_location.xml` — настройки местоположения + +### Current Issues +- Кнопка STOP не работает (требует streaming mode) + +--- + +## ⚠️ ВАЖНЫЕ ПРАВИЛА РАЗРАБОТКИ + +### Запрет на удаление реализованных функций +**НИКОГДА не удаляй уже реализованные функции!** Даже если они кажутся неидеальными: +- Если нужно изменить поведение - исправь, а не удаляй +- Если что-то сломалось - почини, а не упрощай удалением +- При удалении функций (даже "неиспользуемых") всегда согласовывай с пользователем + +### Запрет на хардкодинг переменных +**НИКОГДА не хардкодь значения, которые должны быть динамическими!** +- Даты, года, время,地名, названия - всё должно подставляться из системы/контекста +- Если что-то не получается реализовать без хардкода - ОБСУДИ с пользователем перед реализацией +- Пример правильного подхода: `{CURRENT_YEAR}` → подставляется через `SimpleDateFormat` + +### Сборка APK после каждого изменения +**После каждого исправления или добавления функций ОБЯЗАТЕЛЬНО собирай APK!** +- Пользователь должен иметь возможность сразу протестировать изменения +- Команда: `JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug` +- Расположение: `app/build/outputs/apk/debug/app-debug.apk` + +### Дублирование сообщений при переключении сессий (BUG FIX) +**Проблема:** При переходе из второй сессии в первую (или любую другую) сообщения дублировались. + +**Причина:** Асинхронная загрузка сообщений без проверки актуальности sessionId. + +**Решение в MainActivity.kt:** +1. Очищаем список СРАЗУ при переключении (до асинхронной загрузки) +2. Используем `loadMessagesJob` для отмены предыдущей загрузки сообщений +3. Проверяем sessionId внутри async загрузки (несколько раз) +4. Передаём `expectedSessionId` в `addMessage` для правильного сохранения в БД +5. Прокрутка к последнему сообщению после загрузки + +### ⚠️ ВАЖНО: Логика прокрутки чата +**Правильная реализация:** +1. **К концу сообщения пользователя** - прокрутка к концу (scrollToPosition) через 100мс после добавления +2. **К началу ответа ИИ** - прокрутка к НАЧАЛУ (scrollToPositionWithOffset) через 150мс после добавления сообщения ИИ + +**Техническая реализация:** +- В `addMessage()`: для сообщений ИИ (`!message.isUser`) - прокрутка к началу через 150мс +- Используй `layoutManager.scrollToPositionWithOffset(position, 0)` для прокрутки к началу элемента +- Используй `scrollToPosition(position)` для прокрутки к концу элемента +- Проверяй `!userScrolledAfterSend` перед прокруткой к ответу ИИ + +### Удаление debug логирования +После отладки и подтверждения что баг исправлен - удали все `android.util.Log.d("DEBUG", ...)` из кода. + +### Порядок действий при работе с багом +1. Проанализируй код и найди причину +2. Исправь проблему, а не симптомы +3. Не удаляй существующий функционал +4. Проверь что исправление не ломает другие сценарии +5. Документируй исправление в agents.md + +--- + +## Выводы и предмет для обсуждения + +### WebSearchTool +- **Wikipedia API** - работает, но содержит только энциклопедические статьи (нет погоды, новостей) +- **DuckDuckGo Instant Answer API** - возвращает 0 результатов для большинства запросов (ограничение бесплатного API) +- **Вывод:** Текущая реализация web_search не может полноценно заменить поисковик + +### OpenUrlTool (предложено, отложено) +- AI не знает все URL наизусть - нужен либо справочник в system prompt, либо web_search для нахождения URL +- При гибридном подходе: web_search находит URL → open_url парсит страницу +- Проблема: в system prompt не влезет список URL для всех типичных запросов (погода, новости, курсы валют и т.д.) +- **Вывод:** Реализация отложена до починки web_search + +### Tool Execution Loop +- Предыдущая реализация: 1 итерация → результаты → финальный запрос без tools +- **Проблема:** Не даёт AI сделать несколько последовательных поисков (web_search → получить URL → open_url) +- Новая реализация: до 15 итераций, как в Kai - AI сам решает сколько поисков нужно +- Лимит iteration: 15 +- Timeout на итерацию: 30 сек +- Если API Mistral не выдержит - снизим до 10 или 5 + +--- + +## 📋 Контекст сессии и оптимизация (В ОБСУЖДЕНИИ) + +### Текущая реализация (без оптимизации) + +При каждом запросе отправляется полный контекст: +1. System prompt (профиль) +2. Текущая дата и время +3. Часовой пояс + город +4. Контекст профиля (имя, о себе) +5. Контекст памяти (факты, выводы, предпочтения) +6. **ВСЕ сообщения сессии** +7. Результаты tool calls (полностью, до 2000 символов каждый) + +**Проблемы:** +- При 2-3 tool calls (RSS + статья) добавляется 4000-6000 символов в контекст +- При росте сессии (100+ сообщений) запрос станет слишком большим +- 503 ошибки чаще происходят при больших запросах +- Превышение лимита токенов контекста + +### Варианты решения + +**1. Trimming (простое)** +- Оставлять только последние N сообщений + память + system prompt +- Просто реализовать, но теряется история + +**2. Свёртывание tool results** +- Не добавлять полный результат open_url в историю +- Добавлять краткую выжимку: "Найдено 5 новостей о [тема]" +- Сложнее реализовать, сохраняет суть + +**3. Контекстное окно (гибкое)** +- Оставлять последние N сообщений + summary предыдущих +- ИИ сам решает что важно +- Сложная реализация + +**Статус:** Не решено, требует обсуждения с пользователем + +--- + +## Conversation Context (for AI Agent) + +**При начале новой сессии:** +Прочитай файл AGENTS.md для понимания текущего контекста разработки. + +**При запросе "продолжаем":** +Мы работаем над Phase 3 (Tools). Последняя завершённая задача — добавление настроек location (timezone/city) в drawer menu. + +**Важно:** +- Пушить в GitHub только после тестирования и подтверждения пользователя +- Не делать push автоматически после каждого изменения + +--- + +*Last updated: 2026-04-10* +*Version: 1.9* \ No newline at end of file diff --git a/app/src/main/java/com/mistral/chat/api/TimeTools.kt b/app/src/main/java/com/mistral/chat/api/TimeTools.kt new file mode 100644 index 0000000..8aa0c89 --- /dev/null +++ b/app/src/main/java/com/mistral/chat/api/TimeTools.kt @@ -0,0 +1,488 @@ +package com.mistral.chat.api + +import com.google.gson.JsonObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class GetTimeTool( + private val getDefaultTimezone: () -> String +) : Tool( + name = "get_local_time", + description = "Получить текущую дату и время.", + inputSchema = JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("object")) + add("properties", JsonObject().apply { + add("timezone", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("string")) + add("description", com.google.gson.JsonPrimitive("Часовой пояс (опционально)")) + }) + add("format", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("string")) + add("description", com.google.gson.JsonPrimitive("Формат даты/времени (опционально). Пример: 'dd MMMM yyyy, HH:mm'")) + }) + }) + } +) { + override suspend fun execute(arguments: JsonObject): String { + val timezone = arguments.get("timezone")?.asString ?: getDefaultTimezone() + val format = arguments.get("format")?.asString ?: "dd MMMM yyyy, HH:mm" + + return try { + val sdf = SimpleDateFormat(format, Locale("ru", "RU")) + sdf.timeZone = java.util.TimeZone.getTimeZone(timezone) + val now = Date() + val formatted = sdf.format(now) + """{"status": "success", "time": "$formatted", "timezone": "$timezone"}""" + } catch (e: Exception) { + """{"status": "error", "message": "${e.message}"}""" + } + } +} + +class GetDateTool( + private val getDefaultTimezone: () -> String +) : Tool( + name = "get_date", + description = "Получить текущую дату. Используй когда пользователь спрашивает какое сегодня число.", + inputSchema = JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("object")) + add("properties", JsonObject().apply { + add("format", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("string")) + add("description", com.google.gson.JsonPrimitive("Формат даты (опционально). Пример: 'dd MMMM yyyy'")) + }) + }) + } +) { + override suspend fun execute(arguments: JsonObject): String { + val format = arguments.get("format")?.asString ?: "dd MMMM yyyy" + val timezone = getDefaultTimezone() + + return try { + val sdf = SimpleDateFormat(format, Locale("ru", "RU")) + sdf.timeZone = java.util.TimeZone.getTimeZone(timezone) + val now = Date() + """{"status": "success", "date": "${sdf.format(now)}"}""" + } catch (e: Exception) { + """{"status": "error", "message": "${e.message}"}""" + } + } +} + +class GetWeatherTool( + private val getDefaultCity: () -> String +) : Tool( + name = "get_weather", + description = "Получить текущую погоду и прогноз на 7 дней в указанном городе.", + inputSchema = JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("object")) + add("properties", JsonObject().apply { + add("city", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("string")) + add("description", com.google.gson.JsonPrimitive("Город для прогноза погоды")) + }) + }) + } +) { + private val httpClient = okhttp3.OkHttpClient.Builder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .build() + + override suspend fun execute(arguments: JsonObject): String = withContext(Dispatchers.IO) { + val city = arguments.get("city")?.asString ?: getDefaultCity() + getWeather(city) + } + + private fun getCityCoords(cityName: String): Pair? { + return try { + val encodedCity = java.net.URLEncoder.encode(cityName, "UTF-8") + val request = okhttp3.Request.Builder() + .url("https://geocoding-api.open-meteo.com/v1/search?name=$encodedCity&count=1&language=ru&format=json") + .get() + .build() + val response = httpClient.newCall(request).execute() + val body = response.body?.string() ?: "" + val json = com.google.gson.JsonParser.parseString(body).asJsonObject + val results = json.get("results")?.asJsonArray + if (results != null && results.size() > 0) { + val lat = results[0].asJsonObject.get("latitude").asDouble + val lon = results[0].asJsonObject.get("longitude").asDouble + Pair(lat, lon) + } else null + } catch (e: Exception) { + null + } + } + + private fun getWeather(cityName: String): String { + val coords = getCityCoords(cityName) ?: return """{"status": "error", "message": "Город не найден"}""" + return try { + val (lat, lon) = coords + + // Запрашиваем текущую погоду + прогноз на 7 дней + val request = okhttp3.Request.Builder() + .url("https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon¤t=temperature_2m,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max&timezone=auto&forecast_days=7") + .get() + .build() + val response = httpClient.newCall(request).execute() + val body = response.body?.string() ?: "" + val json = com.google.gson.JsonParser.parseString(body).asJsonObject + + val current = json.get("current")?.asJsonObject + val daily = json.get("daily")?.asJsonObject + + if (current != null && daily != null) { + // Текущая погода + val temp = current.get("temperature_2m")?.asDouble ?: 0.0 + val wind = current.get("wind_speed_10m")?.asDouble ?: 0.0 + val code = current.get("weather_code")?.asInt ?: 0 + val weather = getWeatherDescription(code) + + // Прогноз на 7 дней + val dailyTime = daily.get("time")?.asJsonArray + val dailyMaxTemp = daily.get("temperature_2m_max")?.asJsonArray + val dailyMinTemp = daily.get("temperature_2m_min")?.asJsonArray + val dailyCode = daily.get("weather_code")?.asJsonArray + val dailyPrecip = daily.get("precipitation_sum")?.asJsonArray + val dailyPrecipProb = daily.get("precipitation_probability_max")?.asJsonArray + + val forecastLines = mutableListOf() + + if (dailyTime != null && dailyMaxTemp != null) { + for (i in 0 until minOf(dailyTime.size(), 7)) { + val date = dailyTime.get(i).asString?.takeLast(5) ?: "" + val maxTemp = dailyMaxTemp.get(i).asDouble ?: 0.0 + val minTemp = dailyMinTemp?.get(i)?.asDouble ?: maxTemp + val dayCode = dailyCode?.get(i)?.asInt ?: 0 + val precip = dailyPrecip?.get(i)?.asDouble ?: 0.0 + val precipProb = dailyPrecipProb?.get(i)?.asInt ?: 0 + + val dayWeather = getWeatherDescription(dayCode) + val dayName = getDayName(i) + + forecastLines.add("$dayName ($date): макс $maxTemp°C, мин $minTemp°C, $dayWeather, осадки ${precip}мм ($precipProb%)") + } + } + + val forecastText = if (forecastLines.isNotEmpty()) { + "\n\nПрогноз на 7 дней:\n" + forecastLines.joinToString("\n") + } else { + "" + } + + """Текущая погода в $cityName: $temp°C, $weather, ветер ${wind}km/h$forecastText""" + } else { + """{"status": "error", "message": "Не удалось получить погоду"}""" + } + } catch (e: Exception) { + android.util.Log.e("Weather", "Error: ${e.message}", e) + """{"status": "error", "message": "Ошибка получения погоды: ${e.message}"}""" + } + } + + private fun getDayName(dayIndex: Int): String { + return when (dayIndex) { + 0 -> "Сегодня" + 1 -> "Завтра" + 2 -> "Послезавтра" + else -> "День ${dayIndex + 1}" + } + } + + private fun getWeatherDescription(code: Int): String { + return when (code) { + 0 -> "Ясно" + 1, 2, 3 -> "Облачно" + 45, 48 -> "Туман" + 51, 53, 55 -> "Морось" + 56, 57 -> "Ледяная морось" + 61, 63, 65 -> "Дождь" + 66, 67 -> "Ледяной дождь" + 71, 73, 75 -> "Снег" + 77 -> "Снежные зёрна" + 80, 81, 82 -> "Ливень" + 85, 86 -> "Снегопад" + 95 -> "Гроза" + 96, 99 -> "Гроза с градом" + else -> "Неизвестно" + } + } +} + +class WebSearchTool( + private val getDefaultCity: () -> String +) : Tool( + name = "web_search", + description = "Поиск информации в Wikipedia (русской и английской). Проверяй обе версии для получения актуальной информации.", + inputSchema = JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("object")) + add("properties", JsonObject().apply { + add("query", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("string")) + add("description", com.google.gson.JsonPrimitive("Поисковый запрос")) + }) + add("location", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("string")) + add("description", com.google.gson.JsonPrimitive("Место (опционально). По умолчанию - ${getDefaultCity()}")) + }) + add("num_results", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("number")) + add("description", com.google.gson.JsonPrimitive("Количество результатов (по умолчанию 10)")) + }) + }) + add("required", com.google.gson.JsonArray().apply { + add("query") + }) + } +) { + private val httpClient = okhttp3.OkHttpClient.Builder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .build() + + override suspend fun execute(arguments: JsonObject): String = withContext(Dispatchers.IO) { + val query = arguments.get("query")?.asString + val location = arguments.get("location")?.asString ?: getDefaultCity() + val numResults = arguments.get("num_results")?.asInt ?: 10 + + if (query.isNullOrEmpty()) { + return@withContext """{"status": "error", "message": "Query is required"}""" + } + + try { + // Ищем и в русской, и в английской Wikipedia параллельно + val ruResults = coroutineScope { + async { searchWikipedia(query, numResults, "ru") }.await() + } + val enResults = coroutineScope { + async { searchWikipedia(query, numResults, "en") }.await() + } + + val allResults = mutableListOf() + + if (ruResults.isNotEmpty()) { + allResults.add("=== РУССКАЯ WIKIPEDIA ===") + allResults.addAll(ruResults) + } + + if (enResults.isNotEmpty()) { + allResults.add("=== ENGLISH WIKIPEDIA ===") + allResults.addAll(enResults) + } + + if (allResults.isEmpty()) { + """{"status": "success", "message": "Ничего не найдено по запросу '$query'"}""" + } else { + val responseText = allResults.joinToString("\n\n").take(4000) + """Найденная информация:\n\n$responseText""" + } + } catch (e: Exception) { + """{"status": "error", "message": "Search failed: ${e.message}"}""" + } + } + + private fun searchWikipedia(query: String, limit: Int, lang: String): List { + val results = mutableListOf() + + try { + val encodedQuery = java.net.URLEncoder.encode(query, "UTF-8") + val wikiLang = if (lang == "en") "en" else "ru" + + val searchRequest = okhttp3.Request.Builder() + .url("https://$wikiLang.wikipedia.org/w/api.php?action=query&list=search&srsearch=$encodedQuery&srlimit=$limit&format=json&origin=*") + .header("User-Agent", "MistralChat/1.0") + .header("Accept", "application/json") + .get() + .build() + + val searchResponse = httpClient.newCall(searchRequest).execute() + val searchBody = searchResponse.body?.string() ?: "" + + val json = com.google.gson.JsonParser.parseString(searchBody).asJsonObject + val queryObj = json.get("query")?.asJsonObject + val searchArray = queryObj?.get("search")?.asJsonArray + + if (searchArray != null && searchArray.size() > 0) { + for (i in 0 until minOf(searchArray.size(), limit)) { + val item = searchArray[i].asJsonObject + val title = item.get("title")?.asString ?: "" + val snippet = item.get("snippet")?.asString ?: "" + + if (title.isNotEmpty()) { + val cleanSnippet = snippet + .replace(Regex("<[^>]*>"), "") + .replace(""", "\"") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + + val text = if (cleanSnippet.isNotEmpty()) { + "Статья: $title\nСодержание: $cleanSnippet" + } else { + "Статья: $title" + } + results.add(text) + } + } + } + } catch (e: Exception) { + // Игнорируем ошибки поиска + } + + return results + } +} + +class OpenUrlTool : Tool( + name = "open_url", + description = "Получить текст с веб-страницы или RSS-ленты по URL. Автоматически определяет RSS и парсит заголовки новостей.", + inputSchema = JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("object")) + add("properties", JsonObject().apply { + add("url", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("string")) + add("description", com.google.gson.JsonPrimitive("URL страницы или RSS-ленты (например: https://lenta.ru/rss/, https://www.kommersant.ru/rss/news.xml)")) + }) + }) + add("required", com.google.gson.JsonArray().apply { + add("url") + }) + } +) { + private val httpClient = okhttp3.OkHttpClient.Builder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .build() + + override suspend fun execute(arguments: JsonObject): String = withContext(Dispatchers.IO) { + val url = arguments.get("url")?.asString + + if (url.isNullOrEmpty()) { + return@withContext """{"status": "error", "message": "URL is required"}""" + } + + val normalizedUrl = url.lowercase() + if (normalizedUrl.startsWith("javascript:") || + normalizedUrl.startsWith("file:") || + normalizedUrl.startsWith("data:") || + normalizedUrl.startsWith("mailto:") || + normalizedUrl.startsWith("tel:")) { + return@withContext """{"status": "error", "message": "Недопустимый тип URL"}""" + } + + try { + val request = okhttp3.Request.Builder() + .url(url) + .header("User-Agent", "Mozilla/5.0 (Android)") + .header("Accept", "application/rss+xml,application/atom+xml,application/xml,text/xml,text/html,application/xhtml+xml") + .get() + .build() + + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext """{"status": "error", "message": "Ошибка HTTP: ${response.code}"}""" + } + + val contentType = response.header("Content-Type") ?: "" + val body = response.body?.string() ?: "" + + val isRss = contentType.contains("xml") || body.trim().startsWith("]*>.*?", RegexOption.DOT_MATCHES_ALL), "") + .replace(Regex("]*>.*?", RegexOption.DOT_MATCHES_ALL), "") + .replace(Regex("<[^>]+>"), " ") + .replace(Regex("\\s+"), " ") + .replace(" ", " ") + .replace(""", "\"") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("—", "—") + .replace("–", "–") + .trim() + } + + val result = textOnly.take(2000) + """{"status": "success", "content": "$result"}""" + } catch (e: Exception) { + """{"status": "error", "message": "Ошибка загрузки: ${e.message}""" + } + } + + private fun parseRssFeed(xml: String): String { + val items = mutableListOf() + + try { + val cleanXml = xml + .replace(Regex("", RegexOption.DOT_MATCHES_ALL), "") + + val titleMatch = Regex("<!\\[CDATA\\[(.*?)\\]\\]>|(.*?)", RegexOption.DOT_MATCHES_ALL).find(cleanXml) + val feedTitle = titleMatch?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } ?: "" + + val itemRegex = Regex( + "|", + RegexOption.DOT_MATCHES_ALL + ) + + val itemMatches = itemRegex.findAll(cleanXml) + + for ((index, match) in itemMatches.withIndex()) { + if (index >= 15) break + + val start = match.range.first + val endRange = if (index + 1 < itemMatches.count()) { + itemRegex.findAll(cleanXml).toList()[index + 1].range.first + } else { + cleanXml.length + } + + val itemXml = cleanXml.substring(start, endRange) + + val itemTitle = Regex("<!\\[CDATA\\[(.*?)\\]\\]>|(.*?)", RegexOption.DOT_MATCHES_ALL) + .find(itemXml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } ?: "" + + val itemLink = Regex("(.*?)").find(itemXml)?.groupValues?.getOrNull(1) ?: "" + + val itemDesc = Regex("|(.*?)", RegexOption.DOT_MATCHES_ALL) + .find(itemXml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } ?: "" + + val itemDate = Regex("|").find(itemXml)?.let { dateMatch -> + val dateStart = dateMatch.range.last + 1 + val dateEnd = minOf(dateStart + 50, cleanXml.length) + val dateSection = cleanXml.substring(dateStart, dateEnd) + Regex("(<[^>]+>)").replace(dateSection, "").trim() + } ?: "" + + if (itemTitle.isNotEmpty()) { + val itemText = buildString { + append("• $itemTitle") + if (itemDate.isNotEmpty()) append(" [$itemDate]") + if (itemLink.isNotEmpty()) append(" | ${itemLink}") + if (itemDesc.isNotEmpty() && itemDesc.length < 150) append(" - $itemDesc") + } + items.add(itemText) + } + } + + if (feedTitle.isNotEmpty()) { + return "=== $feedTitle ===\n\n" + items.joinToString("\n") + } + return items.joinToString("\n") + + } catch (e: Exception) { + return "Ошибка парсинга RSS: ${e.message}" + } + } +} \ 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 c8038e7..4bfedb0 100644 --- a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt +++ b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt @@ -33,9 +33,11 @@ import com.google.android.material.button.MaterialButton import com.google.gson.Gson import com.mistral.chat.R import com.mistral.chat.api.MistralClient +import com.mistral.chat.api.ToolExecutor import com.mistral.chat.data.ChatDatabase import com.mistral.chat.data.Message import com.mistral.chat.data.MessageEntity +import com.mistral.chat.data.MemoryRepository import com.mistral.chat.data.Profile import com.mistral.chat.data.Session import com.mistral.chat.data.toMessage @@ -47,6 +49,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener { @@ -66,12 +71,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private var currentJob: kotlinx.coroutines.Job? = null private var client: MistralClient? = null + private var toolExecutor: ToolExecutor? = null private val messages = mutableListOf() private var availableModels: List> = emptyList() - private var selectedModelName: String = "mistral-small-latest" + private var selectedModelName: String = "mistral-medium-latest" private lateinit var prefs: SharedPreferences private lateinit var encryptedPrefs: SharedPreferences private lateinit var database: ChatDatabase + private lateinit var memoryRepository: MemoryRepository private val profiles = mutableListOf() private val sessions = mutableListOf() @@ -94,6 +101,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private const val KEY_LAST_PROFILE_ID = "last_profile_id" private const val KEY_SELECTED_MODEL = "selected_model" private const val KEY_THEME_MODE = "theme_mode" + private const val KEY_DEFAULT_TIMEZONE = "default_timezone" + private const val KEY_DEFAULT_CITY = "default_city" private const val MAX_PROFILES = 10 } @@ -128,8 +137,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte showApiKeyDialog() } + database = ChatDatabase.getInstance(this) + memoryRepository = MemoryRepository(database.memoryDao()) + client = MistralClient(getApiKey()) - + toolExecutor = ToolExecutor(memoryRepository, this, getDefaultTimezone(), getDefaultCity()) + client?.setToolExecutor(toolExecutor!!) + logoButton = findViewById(R.id.logoButton) menuButton = findViewById(R.id.menuButton) hamburgerButton = findViewById(R.id.hamburgerButton) @@ -141,8 +155,6 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte drawerLayout = findViewById(R.id.drawerLayout) navigationView = findViewById(R.id.navigationView) rightPanel = findViewById(R.id.rightPanelContainer) - - database = ChatDatabase.getInstance(this) setupToolbar() setupRecyclerView() @@ -203,6 +215,9 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte R.id.action_session -> { showSettingsDialog() } + R.id.action_location -> { + showLocationDialog() + } R.id.action_about -> { showAboutDialog() } @@ -260,6 +275,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte if (deleteProfiles) { database.profileDao().deleteAll() currentProfileId = null + memoryRepository.setCurrentProfile(null) + memoryRepository.deleteAnonymous() prefs.edit().remove(KEY_LAST_PROFILE_ID).apply() profiles.clear() } @@ -338,6 +355,35 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte .show() } + private fun showLocationDialog() { + val dialogView = layoutInflater.inflate(R.layout.dialog_location, null) + val timezoneInput = dialogView.findViewById(R.id.timezoneInput) + val cityInput = dialogView.findViewById(R.id.cityInput) + + timezoneInput.setText(getDefaultTimezone()) + cityInput.setText(getDefaultCity()) + + AlertDialog.Builder(this) + .setTitle(R.string.location_title) + .setView(dialogView) + .setPositiveButton(R.string.save) { _, _ -> + val timezone = timezoneInput.text.toString().trim() + val city = cityInput.text.toString().trim() + + if (timezone.isNotEmpty()) { + setDefaultTimezone(timezone) + } + if (city.isNotEmpty()) { + setDefaultCity(city) + } + + toolExecutor?.updateSettings(timezone = getDefaultTimezone(), city = getDefaultCity()) + Toast.makeText(this, "Настройки сохранены", Toast.LENGTH_SHORT).show() + } + .setNegativeButton(R.string.cancel, null) + .show() + } + private fun setupToolbar() { hamburgerButton.isVisible = true @@ -406,6 +452,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } else { null } + memoryRepository.setCurrentProfile(currentProfileId) val profileId = currentProfileId if (profileId != null) { prefs.edit().putLong(KEY_LAST_PROFILE_ID, profileId).apply() @@ -462,6 +509,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private fun selectProfile(profile: Profile) { currentProfileId = profile.id + memoryRepository.setCurrentProfile(profile.id) prefs.edit().putLong(KEY_LAST_PROFILE_ID, profile.id).apply() profilesAdapter?.refresh() val profileName = profiles.find { it.id == currentProfileId }?.name ?: profile.name @@ -491,7 +539,12 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte .setMessage("Удалить профиль ${profile.name}?") .setPositiveButton(R.string.yes) { _, _ -> lifecycleScope.launch { + memoryRepository.deleteByProfile(profile.id) database.profileDao().delete(profile) + if (currentProfileId == profile.id) { + currentProfileId = null + memoryRepository.setCurrentProfile(null) + } } } .setNegativeButton(R.string.no, null) @@ -499,20 +552,49 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } private fun selectSession(session: Session) { + currentJob?.cancel() + currentSessionId = session.id userMessageCount = 0 + userScrolledAfterSend = false prefs.edit().putLong("last_session_id", session.id).apply() + + messages.clear() + adapter.notifyDataSetChanged() + loadSessionMessages(session.id) updateRightPanel() drawerLayout.closeDrawer(GravityCompat.END) } + private var loadMessagesJob: Job? = null + private fun loadSessionMessages(sessionId: Long) { - lifecycleScope.launch { - val dbMessages = database.messageDao().getMessagesBySessionSync(sessionId) + loadMessagesJob?.cancel() + + loadMessagesJob = lifecycleScope.launch { + val targetSessionId = sessionId + + if (!isActive || currentSessionId != targetSessionId) { + return@launch + } + + val dbMessages = database.messageDao().getMessagesBySessionSync(targetSessionId) + + if (!isActive || currentSessionId != targetSessionId) { + return@launch + } + messages.clear() messages.addAll(dbMessages.map { it.toMessage() }) adapter.notifyDataSetChanged() + + if (messages.isNotEmpty()) { + recyclerView.postDelayed({ + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + layoutManager.scrollToPositionWithOffset(messages.size - 1, 0) + }, 100) + } } } @@ -597,7 +679,11 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } private fun setupRecyclerView() { - adapter = MessageAdapter(messages) + adapter = MessageAdapter( + messages, + onMessageEdit = { position, message -> editMessage(position, message) }, + onMessageDelete = { position, message -> deleteMessage(position, message) } + ) recyclerView.layoutManager = LinearLayoutManager(this).apply { stackFromEnd = true } @@ -617,6 +703,49 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte }) } + private fun editMessage(position: Int, message: Message) { + val editText = EditText(this) + editText.setText(message.content) + AlertDialog.Builder(this) + .setTitle("Редактировать сообщение") + .setView(editText) + .setPositiveButton(R.string.save) { _, _ -> + val newContent = editText.text.toString().trim() + if (newContent.isNotEmpty() && newContent != message.content) { + val sessionId = currentSessionId + val timestamp = message.timestamp + messages[position] = message.copy(content = newContent) + adapter.notifyItemChanged(position) + lifecycleScope.launch { + if (sessionId != null) { + database.messageDao().updateContent(sessionId, timestamp, newContent) + } + } + } + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun deleteMessage(position: Int, message: Message) { + AlertDialog.Builder(this) + .setTitle("Удалить сообщение") + .setMessage("Вы уверены, что хотите удалить это сообщение?") + .setPositiveButton(R.string.yes) { _, _ -> + val sessionId = currentSessionId + val timestamp = message.timestamp + lifecycleScope.launch { + if (sessionId != null) { + database.messageDao().deleteByTimestamp(sessionId, timestamp) + } + } + messages.removeAt(position) + adapter.notifyItemRemoved(position) + } + .setNegativeButton(R.string.no, null) + .show() + } + private fun setupInput() { sendButton.setOnClickListener { sendInput() @@ -643,12 +772,15 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte return } + // Отменяем предыдущий запрос перед новым + currentJob?.cancel() + if (currentSessionId == null) { createNewSessionAndSend(userInput) return } - addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName())) + addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()), currentSessionId) inputField.text?.clear() @@ -656,13 +788,17 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte lastUserMessagePosition = messages.size - 1 recyclerView.postDelayed({ - recyclerView.scrollToPosition(lastUserMessagePosition) + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + layoutManager.scrollToPositionWithOffset(lastUserMessagePosition, 0) }, 100) sendMessage(userInput) } private fun createNewSessionAndSend(userInput: String) { + // Отменяем предыдущий запрос + currentJob?.cancel() + lifecycleScope.launch { val session = Session( profileId = if (currentProfileId != null && profiles.any { it.id == currentProfileId }) currentProfileId else null, @@ -671,11 +807,12 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val sessionId = database.sessionDao().insert(session) currentSessionId = sessionId userMessageCount = 0 + userScrolledAfterSend = false messages.clear() adapter.notifyDataSetChanged() updateRightPanel() - addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName())) + addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()), sessionId) inputField.text?.clear() sendMessage(userInput) } @@ -755,38 +892,40 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL) if (!hasUserSelectedModel) { runOnUiThread { - selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-small-latest" + selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-medium-latest" } } } } } - private fun addMessage(message: Message) { - val newPosition = messages.size - 1 + private fun addMessage(message: Message, expectedSessionId: Long? = null) { + val targetSessionId = expectedSessionId ?: currentSessionId messages.add(message) + val newPosition = messages.size - 1 + adapter.notifyItemInserted(newPosition) if (!message.isUser && !userScrolledAfterSend) { recyclerView.postDelayed({ if (!userScrolledAfterSend) { - recyclerView.scrollToPosition(newPosition) + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + layoutManager.scrollToPositionWithOffset(newPosition, 0) } }, 150) } - val sessionId = currentSessionId - if (sessionId != null) { + if (targetSessionId != null) { lifecycleScope.launch { val entity = MessageEntity( - sessionId = sessionId, + sessionId = targetSessionId, content = message.content, isUser = message.isUser, timestamp = message.timestamp ) database.messageDao().insert(entity) - database.sessionDao().updateTimestamp(sessionId) + database.sessionDao().updateTimestamp(targetSessionId) } } } @@ -809,6 +948,22 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte return encryptedPrefs.getString(KEY_API_KEY, null) ?: "" } + private fun getDefaultTimezone(): String { + return prefs.getString(KEY_DEFAULT_TIMEZONE, "Europe/Moscow") ?: "Europe/Moscow" + } + + private fun setDefaultTimezone(timezone: String) { + prefs.edit().putString(KEY_DEFAULT_TIMEZONE, timezone).apply() + } + + private fun getDefaultCity(): String { + return prefs.getString(KEY_DEFAULT_CITY, "Москва") ?: "Москва" + } + + private fun setDefaultCity(city: String) { + prefs.edit().putString(KEY_DEFAULT_CITY, city).apply() + } + private fun hasApiKey(): Boolean { return encryptedPrefs.contains(KEY_API_KEY) } @@ -816,10 +971,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private fun saveApiKey(apiKey: String) { encryptedPrefs.edit().putString(KEY_API_KEY, apiKey).apply() client = MistralClient(apiKey) + client?.setToolExecutor(toolExecutor!!) } private fun deleteApiKey() { encryptedPrefs.edit().remove(KEY_API_KEY).apply() + client = MistralClient("") + client?.setToolExecutor(toolExecutor!!) Toast.makeText(this, getString(R.string.api_key_deleted), Toast.LENGTH_SHORT).show() showApiKeyDialog() } @@ -856,6 +1014,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte saveApiKey(newKey) client = MistralClient(newKey) + client?.setToolExecutor(toolExecutor!!) apiKeyDialog?.dismiss() Toast.makeText(this, getString(R.string.api_key_saved), Toast.LENGTH_SHORT).show() } else { @@ -880,6 +1039,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private fun sendMessage(userInput: String) { val selectedModel = selectedModelName + val sessionIdAtStart = currentSessionId sendButton.isEnabled = false progressIndicator.isVisible = true @@ -887,56 +1047,137 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte currentJob = lifecycleScope.launch { try { val profileContext = getSelectedProfileContext() + val systemPrompt = getSelectedSystemPrompt() + val memoryContext = memoryRepository.buildMemoryContext() + val tools = toolExecutor?.getToolsSchema() - val apiMessages = messages.map { msg -> - Message( - content = msg.content, - isUser = msg.isUser - ) - }.toMutableList() + // Автоматически получаем текущую дату + val currentDateResult = client?.executeTool("get_date", com.google.gson.JsonObject()) + ?: """{"status": "error", "message": "Tool failed"}""" + + val apiMessages = mutableListOf() + + var finalSystemPrompt = systemPrompt + + if (finalSystemPrompt.isNotEmpty()) { + apiMessages.add(Message(content = finalSystemPrompt, isUser = false, role = "system")) + } + + // Добавляем информацию о текущей дате и местоположении + apiMessages.add(Message( + content = "Текущая дата: $currentDateResult", + isUser = true, + role = "user" + )) + + // Добавляем информацию о часовом поясе и городе + val timezone = getDefaultTimezone() + val city = getDefaultCity() + apiMessages.add(Message( + content = "Мое местоположение: часовой пояс $timezone, город $city", + isUser = true, + role = "user" + )) if (profileContext.isNotEmpty()) { - apiMessages.add(0, Message(content = profileContext, isUser = true)) + apiMessages.add(Message(content = profileContext, isUser = true, role = "user")) } - val result = withTimeout(15000L) { - client?.chat(selectedModel, apiMessages) ?: Result.failure(Exception("Client not initialized")) + if (memoryContext.isNotEmpty()) { + apiMessages.add(Message(content = memoryContext, isUser = true, role = "user")) } - if (!isActive) return@launch + apiMessages.addAll(messages.map { msg -> + Message( + content = msg.content, + isUser = msg.isUser, + role = if (msg.isUser) "user" else "assistant" + ) + }) - result.onSuccess { (response, usedModel) -> - val displayModel = usedModel.ifEmpty { "Assistant" } - addMessage(Message(content = response, isUser = false, senderName = displayModel)) - lifecycleScope.launch { - saveMessageToDatabase(currentSessionId, response, false, displayModel) + // Tool loop - до 15 итераций + var iteration = 0 + val maxIterations = 15 + var finalResponse: String? = null + + while (iteration < maxIterations) { + iteration++ + + val result = withTimeout(30000L) { + client?.chat(selectedModel, apiMessages, tools) + ?: Result.failure(Exception("Client not initialized")) } - - 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")) + + result.onSuccess { chatResponse -> + if (chatResponse.toolCalls.isNotEmpty()) { + // Выполняем все tool calls и добавляем результаты в историю + for (toolCall in chatResponse.toolCalls) { + val toolResult = client?.executeTool(toolCall.name, toolCall.arguments) + ?: """{"status": "error", "message": "Tool failed"}""" + + apiMessages.add(Message( + content = """[${toolCall.name}] result: $toolResult""", + isUser = true, + role = "user" + )) + } + // Продолжаем цикл - AI решит нужен ли еще поиск + } else { + // Нет tool calls - это финальный ответ + finalResponse = chatResponse.content + } + }.onFailure { error -> + finalResponse = "Ошибка: ${error.message}" } + + // Если есть финальный ответ или превышен лимит - выходим + if (finalResponse != null || iteration >= maxIterations) { + break + } + } + + if (finalResponse == null && iteration >= maxIterations) { + finalResponse = "Превышен лимит итераций (${maxIterations}). Попробуйте более конкретный запрос." + } + + // Показываем финальный ответ + if (finalResponse.isNullOrEmpty()) { + finalResponse = "Не удалось получить ответ. Попробуйте ещё раз." } - sendButton.isEnabled = true - progressIndicator.isVisible = false - } catch (e: kotlinx.coroutines.CancellationException) { - if (!isActive) return@launch - addMessage(Message(content = "Запрос отменён", isUser = false, senderName = "System")) + val responseToShow = finalResponse!! + + // Проверяем что sessionId не изменился пока работал запрос + if (currentSessionId == sessionIdAtStart) { + addMessage(Message(content = responseToShow, isUser = false, senderName = selectedModel), sessionIdAtStart) + + // Прокрутка к началу нового сообщения ИИ + recyclerView.post { + if (!userScrolledAfterSend) { + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + layoutManager.scrollToPositionWithOffset(messages.size - 1, 0) + } + } + + if (!responseToShow.startsWith("Ошибка:")) { + // Генерируем название сессии после второго сообщения + userMessageCount++ + if (userMessageCount == 2) { + generateSessionTitle() + } + } + } + sendButton.isEnabled = true 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")) + android.util.Log.e("MainActivity", "Exception: ${e.message}", e) + if (currentSessionId == sessionIdAtStart) { + addMessage(Message(content = "Произошла ошибка: ${e.message}", isUser = false, senderName = "Error"), sessionIdAtStart) + } sendButton.isEnabled = true progressIndicator.isVisible = false } @@ -956,6 +1197,16 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } } + private fun getSelectedSystemPrompt(): String { + if (currentProfileId == null) return "" + val profile = profiles.find { it.id == currentProfileId } + val profilePrompt = profile?.systemPrompt ?: "" + val defaultPrompt = getString(R.string.profile_system_prompt_default) + val currentYear = SimpleDateFormat("yyyy", Locale.getDefault()).format(Date()) + return if (profilePrompt.isNotEmpty()) profilePrompt + else defaultPrompt.replace("{CURRENT_YEAR}", currentYear) + } + private fun getCurrentProfileName(): String { if (currentProfileId == null) return "Вы" val profileName = profiles.find { it.id == currentProfileId }?.name @@ -973,11 +1224,17 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val nameInput = dialogView.findViewById(R.id.nameInput) val bioInput = dialogView.findViewById(R.id.bioInput) val preferencesInput = dialogView.findViewById(R.id.preferencesInput) + val systemPromptInput = dialogView.findViewById(R.id.systemPromptInput) existingProfile?.let { nameInput.setText(it.name) bioInput.setText(it.bio) preferencesInput.setText(it.preferences) + systemPromptInput.setText(it.systemPrompt) + } ?: run { + if (systemPromptInput.text.isNullOrEmpty()) { + systemPromptInput.setText(getString(R.string.profile_system_prompt_default)) + } } val dialog = AlertDialog.Builder(this) @@ -992,16 +1249,20 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte name = name, bio = bioInput.text?.toString() ?: "", preferences = preferencesInput.text?.toString() ?: "", + systemPrompt = systemPromptInput.text?.toString() ?: "", updatedAt = System.currentTimeMillis() )) } else { + val defaultSystemPrompt = getString(R.string.profile_system_prompt_default) val newId = database.profileDao().insert(Profile( name = name, bio = bioInput.text?.toString() ?: "", - preferences = preferencesInput.text?.toString() ?: "" + preferences = preferencesInput.text?.toString() ?: "", + systemPrompt = systemPromptInput.text?.toString()?.ifEmpty { defaultSystemPrompt } ?: defaultSystemPrompt )) if (currentProfileId == null) { currentProfileId = newId + memoryRepository.setCurrentProfile(newId) } } } @@ -1015,6 +1276,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte database.profileDao().delete(existingProfile) if (currentProfileId == existingProfile.id) { currentProfileId = null + memoryRepository.setCurrentProfile(null) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a459d7c..0e8bc96 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,6 +11,69 @@ Имя О себе Предпочтения + Системный промт + Инструкции для AI (до 4000 символов). Этот текст будет добавлен как system message. + Ты - AI ассистент с долговременной памятью и доступом к интернету. + +=== ТЕКУЩАЯ ДАТА === +При старте сессии ты получаешь актуальную дату автоматически. Используй эту информацию для ответов. +ВНИМАНИЕ: Сейчас {CURRENT_YEAR} год. Если пользователь спрашивает про свежие новости - проверяй даты ВСЕХ событий. Не используй старые статьи (прошлого года и ранее) как "свежие новости". + +=== ТВОИ ВОЗМОЖНОСТИ === + +У тебя есть инструменты: +1. open_url - получить текст с веб-страницы (ОСНОВНОЙ для новостей и статей) +2. web_search - поиск в Wikipedia (для фактов, справок, НЕ для новостей) +3. get_local_time - текущее время +4. get_date - текущая дата +5. get_weather - погода +6. send_notification - уведомление +7. memory_* - управление памятью + +=== ПРИОРИТЕТЫ === + +1. Свежие новости → open_url → RSS-ленты (самый эффективный способ): + - lenta.ru → https://lenta.ru/rss/ + - kommersant.ru → https://www.kommersant.ru/rss/news.xml + - ria.ru → страница (нет RSS) +2. Подробности о новости → open_url → URL статьи (если знаешь URL или видишь в тексте) +3. Факты, справки, "что такое..." → web_search → Wikipedia +4. Погода → get_weather +5. Дата/время → get_date / get_local_time + +=== ПРАВИЛА === + +1. Сейчас {CURRENT_YEAR} год - используй для проверки актуальности +2. Новости - ИСПОЛЬЗУЙ RSS для свежих новостей, это самый быстрый способ +3. Для новостей открывай RSS-ленты, они дают чистые заголовки с датами и ссылками +4. Для статьи подробнее - используй open_url с URL этой статьи +5. Факты/справки - web_search (Wikipedia) +6. Если не можешь найти актуальную информацию - скажи "не могу найти свежие данные" +7. Не придумывай даты - проверяй через open_url на сайте +8. Для предпочтений пользователя - memory_preference +9. Для важных фактов о пользователе - memory_store +10. Для выводов из поведения - memory_learn +11. При подтверждении твоего вывода - memory_reinforce +12. ЕСЛИ в памяти есть информация - используй когда нет результатов поиска +13. "Что ты обо мне знаешь?" - выведи ВСЕ факты И предпочтения +14. НЕ копируй целиком - перескажи своими словами + +=== ПРИМЕРЫ === + +- "Что в новостях?" → open_url → https://lenta.ru/rss/ (или kommersant.ru/rss/news.xml) +- "Что-то про спорт?" → open_url → lenta.ru/rss/ → выбери категорию "Спорт" +- "Подробнее про [заголовок]" → open_url → URL статьи из RSS +- "Что такое X?" → web_search → Wikipedia +- "Какая погода в Москве?" → get_weather +- "Какое сегодня число?" → get_date +- "Предпочитаю новости киберспорта" → memory_preference(key="интересы", value="киберспорт") +- "Что ты обо мне знаешь?" → выведи всю память +- "Подробнее про [заголовок]" → open_url → URL статьи (если знаешь или видишь в тексте) +- "Что такое X?" → web_search → Wikipedia +- "Какая погода в Москве?" → get_weather +- "Какое сегодня число?" → get_date +- "Предпочитаю новости киберспорта" → memory_preference(key="интересы", value="киберспорт") +- "Что ты обо мне знаешь?" → выведи всю память Сохранить Отмена Удалить @@ -51,6 +114,12 @@ Профиль: %s Внешний вид Сессия при запуске + Местоположение + Настройки местоположения + Часовой пояс + Город по умолчанию + Например: Москва, Санкт-Петербург + Настройки сохранены Очистить историю Удалить все сессии и сообщения? Открывать последнюю сессию From cabc6b8d85310cd93917b4c99e71611a26631f8b 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: Fri, 10 Apr 2026 20:14:17 +0800 Subject: [PATCH 08/10] Fix timeout issues: increased to 60s, add retry on CANCEL, default model Medium --- AGENTS.md | 55 +++++- .../com/mistral/chat/api/MistralClient.kt | 186 ++++++++++++------ .../java/com/mistral/chat/ui/MainActivity.kt | 103 +++++++--- 3 files changed, 252 insertions(+), 92 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d8fa777..1f988a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,8 @@ web_search НЕ является интерфейсом поисковика и **Tool Loop (MainActivity):** - Максимум итераций: 15 -- Timeout на итерацию: 30 секунд +- Timeout на итерацию: 60 секунд +- Retry (до 2 попыток) при ошибке "stream was reset: CANCEL" - AI может сделать несколько последовательных поисков если нужно ### 🌤️ Weather Tool (БЕСПЛАТНОЕ решение) @@ -201,6 +202,8 @@ Kai имеет отличную документацию по tools: https://kai - LEARNING — выводы и паттерны - ERROR — известные ошибки - PREFERENCE — предпочтения пользователя +- REMINDER_CAL — напоминания календаря (local mode) +- CALENDAR_ERROR — ошибки подключения CalDAV **Prompt injection:** ``` @@ -240,6 +243,29 @@ Kai имеет отличную документацию по tools: https://kai | Safety | ✅ Max iterations (15), timeout (30s), result truncation (2000 chars) | | **OpenUrlTool (RSS)** | ✅ Автоматическое определение и парсинг RSS/Atom | +**CalDAV Calendar + Local Reminders (Phase 3 extension):** +| Задача | Статус | +|--------|--------| +| iCalDAV зависимость (Apache 2.0) | ⏳ | +| CalDavRepository (CRUD) | ⏳ | +| UI: drawer_menu → диалог настроек CalDAV | ⏳ | +| Настройка синхронизации (15м-сутки) | ⏳ | +| caldav_get_events, create, update, delete | ⏳ | +| Memory category REMINDER_CAL | ⏳ | +| calendar_add_reminder tool | ⏳ | +| calendar_get_reminders tool | ⏳ | +| Напоминания (любой период: 5мин, 13мин, 2ч, 24ч) | ⏳ | +| Счётчик ошибок CalDAV → memory | ⏳ | +| UnifiedPush (опционально, на потом) | ⏳ | + +**Memory REMINDER_CAL fields:** +- key: название напоминания +- value: описание +- triggerTime: unix timestamp когда напомнить +- status: pending / triggered / expired + +**Trigger logic:** AI проверяет pending напоминания при каждом запросе + **RSS-ленты (протестировано):** - lenta.ru/rss/ ✅ - kommersant.ru/rss/news.xml ✅ @@ -265,11 +291,20 @@ Kai имеет отличную документацию по tools: https://kai --- -## Unconfirmed Phases (Not Approved) +## Active Plan (Phases 1-5) -Следующие фазы требуют дополнительного планирования: +### Phase 3 (Active): CalDAV Calendar + Local Reminders +**Статус:** 🔄 В разработке | **Оценка:** 4-5 дней -### Phase 4: Heartbeat +**Два режима:** +1. CalDAV — синхронизация с Baikal сервером +2. Local — автономная напоминалка в памяти AI (работает БЕЗ интернета) + +**Подробнее:** см. таблицу в разделе Phase 3 выше + +--- + +### Phase 4: Heartbeat (Scheduled) **Оценка:** 2-3 дня Автономная периодическая самопроверка: @@ -315,6 +350,16 @@ app/build/outputs/apk/debug/app-debug.apk ### Current Issues - Кнопка STOP не работает (требует streaming mode) +### Model Selection +- **Default:** mistral-medium-latest (быстрее, меньше ошибок) +- **Доступные модели:** Large, Medium, Codestral, Pixtral +- **OkHttp timeouts:** connect 60s, read 120s, write 60s + +### Error Handling (Исправлено) +- Ошибки tool execution (таймауты, network errors) НЕ сохраняются в БД +- Показываются пользователю через Toast +- Предотвращает "отравление" контекста сообщениями об ошибках + --- ## ⚠️ ВАЖНЫЕ ПРАВИЛА РАЗРАБОТКИ @@ -449,4 +494,4 @@ app/build/outputs/apk/debug/app-debug.apk --- *Last updated: 2026-04-10* -*Version: 1.9* \ No newline at end of file +*Version: 1.10* \ No newline at end of file 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 b6b9d1d..7db75a8 100644 --- a/app/src/main/java/com/mistral/chat/api/MistralClient.kt +++ b/app/src/main/java/com/mistral/chat/api/MistralClient.kt @@ -1,8 +1,9 @@ package com.mistral.chat.api -import com.google.gson.JsonObject +import android.util.Log import com.google.gson.Gson import com.google.gson.JsonArray +import com.google.gson.JsonObject import com.mistral.chat.data.Message import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine @@ -18,15 +19,28 @@ import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException +data class ChatResponse( + val content: String, + val usedModel: String, + val toolCalls: List +) + +data class ToolCall( + val id: String, + val name: String, + val arguments: JsonObject +) + class MistralClient(private val apiKey: String) { private var client = createNewClient() + private var toolExecutor: ToolExecutor? = null private fun createNewClient(): OkHttpClient { return OkHttpClient.Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(25, TimeUnit.SECONDS) - .writeTimeout(15, TimeUnit.SECONDS) + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) .retryOnConnectionFailure(false) .build() } @@ -35,7 +49,7 @@ class MistralClient(private val apiKey: String) { private val jsonMediaType = "application/json".toMediaType() private var currentCall: Call? = null - private var currentContinuation: Continuation>>? = null + private var currentContinuation: Continuation>? = null companion object { private const val BASE_URL = "https://api.mistral.ai/v1" @@ -54,61 +68,17 @@ class MistralClient(private val apiKey: String) { ) val AVAILABLE_MODELS = listOf( - "mistral-small-latest" to "Mistral Small", - "mistral-medium-latest" to "Mistral Medium", "mistral-large-latest" to "Mistral Large", + "mistral-medium-latest" to "Mistral Medium", "codestral-latest" to "Codestral", "pixtral-large-latest" to "Pixtral Large" ) + + const val DEFAULT_MODEL = "mistral-medium-latest" } - suspend fun getModels(): Result>> = withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - 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) - } - } 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 setToolExecutor(executor: ToolExecutor) { + this.toolExecutor = executor } fun cancelRequest() { @@ -124,11 +94,55 @@ class MistralClient(private val apiKey: String) { client = createNewClient() } + suspend fun getModels(): Result>> = try { + val request = Request.Builder() + .url("$BASE_URL/models") + .addHeader("Authorization", "Bearer $apiKey") + .get() + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return 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) + } + } catch (e: Exception) { + Result.failure(e) + } + suspend fun chat( model: String, messages: List, + tools: List? = null, onChunk: ((String) -> Unit)? = null - ): Result> = withContext(Dispatchers.IO) { + ): Result = withContext(Dispatchers.IO) { try { val jsonObject = JsonObject() jsonObject.addProperty("model", model) @@ -138,14 +152,29 @@ class MistralClient(private val apiKey: String) { val messagesArray = JsonArray() messages.forEach { msg -> val msgObj = JsonObject() - msgObj.addProperty("role", if (msg.isUser) "user" else "assistant") + msgObj.addProperty("role", msg.role) msgObj.addProperty("content", msg.content) messagesArray.add(msgObj) } jsonObject.add("messages", messagesArray) + if (!tools.isNullOrEmpty()) { + val toolsArray = JsonArray() + tools.forEach { toolsArray.add(it) } + jsonObject.add("tools", toolsArray) + jsonObject.addProperty("tool_choice", "auto") + } + val json = gson.toJson(jsonObject) val body = json.toRequestBody(jsonMediaType) + + Log.d("MistralClient", "Request JSON size: ${json.length} chars") + Log.d("MistralClient", "Request: model=$model, msgs=${messages.size}, tools=${tools?.size ?: 0}") + + // Логируем все сообщения + messages.forEachIndexed { idx, msg -> + Log.d("MistralClient", "Msg[$idx] role=${msg.role}, len=${msg.content.length}, content=${msg.content.take(100)}...") + } val request = Request.Builder() .url("$BASE_URL/chat/completions") @@ -155,12 +184,13 @@ class MistralClient(private val apiKey: String) { .build() currentCall = client.newCall(request) - - suspendCancellableCoroutine { continuation -> + + val result = suspendCancellableCoroutine> { continuation -> currentContinuation = continuation - + currentCall?.enqueue(object : okhttp3.Callback { override fun onFailure(call: okhttp3.Call, e: java.io.IOException) { + Log.e("MistralClient", "onFailure: ${e.message}", e) val cont = currentContinuation currentCall = null currentContinuation = null @@ -175,7 +205,7 @@ class MistralClient(private val apiKey: String) { override fun onResponse(call: okhttp3.Call, response: Response) { val cont = currentContinuation - + if (cont == null) { response.close() currentCall = null @@ -193,6 +223,8 @@ class MistralClient(private val apiKey: String) { val responseBody = response.body?.string() ?: "" + Log.d("MistralClient", "Response: code=${response.code}, len=${responseBody.length}") + if (onChunk != null) { onChunk(responseBody) } @@ -208,6 +240,7 @@ class MistralClient(private val apiKey: String) { val choices = responseJson.getAsJsonArray("choices") if (choices == null || choices.size() == 0) { + Log.e("MistralClient", "No choices in response: $responseBody") currentCall = null currentContinuation = null cont.resume(Result.failure(Exception("No response from API"))) @@ -216,6 +249,7 @@ class MistralClient(private val apiKey: String) { val firstChoice = choices.get(0)?.asJsonObject if (firstChoice == null) { + Log.e("MistralClient", "First choice is null") currentCall = null currentContinuation = null cont.resume(Result.failure(Exception("Empty choice"))) @@ -226,10 +260,32 @@ class MistralClient(private val apiKey: String) { val content = message?.get("content")?.asString ?: "" val usedModel = responseJson.get("model")?.asString ?: model - + + val toolCalls = mutableListOf() + val toolCallsElement = message?.get("tool_calls") + if (toolCallsElement != null && !toolCallsElement.isJsonNull) { + val toolCallsArray = toolCallsElement.asJsonArray + for ((index, tcElement) in toolCallsArray.withIndex()) { + val tc = tcElement.asJsonObject + if (tc != null) { + val tcId = tc.get("id")?.asString ?: "call_${System.currentTimeMillis()}_$index" + val tcName = tc.get("function")?.asJsonObject?.get("name")?.asString ?: "" + val tcArgs = tc.get("function")?.asJsonObject?.get("arguments")?.asString + if (tcName.isNotEmpty() && tcArgs != null) { + try { + val argsJson = gson.fromJson(tcArgs, JsonObject::class.java) + toolCalls.add(ToolCall(tcId, tcName, argsJson)) + } catch (e: Exception) { + // Skip invalid arguments + } + } + } + } + } + currentCall = null currentContinuation = null - cont.resume(Result.success(content to usedModel)) + cont.resume(Result.success(ChatResponse(content, usedModel, toolCalls))) } catch (e: Exception) { currentCall = null currentContinuation = null @@ -245,8 +301,14 @@ class MistralClient(private val apiKey: String) { client = createNewClient() } } + result } catch (e: Exception) { Result.failure(e) } } + + suspend fun executeTool(name: String, arguments: JsonObject): String { + return toolExecutor?.executeTool(name, arguments) + ?: """{"status": "error", "message": "Tool executor not initialized"}""" + } } \ 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 4bfedb0..08b1224 100644 --- a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt +++ b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt @@ -2,6 +2,7 @@ package com.mistral.chat.ui import android.content.Context import android.content.SharedPreferences +import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.view.KeyEvent @@ -32,6 +33,7 @@ import com.google.android.material.textfield.TextInputEditText import com.google.android.material.button.MaterialButton import com.google.gson.Gson import com.mistral.chat.R +import com.mistral.chat.api.ChatResponse import com.mistral.chat.api.MistralClient import com.mistral.chat.api.ToolExecutor import com.mistral.chat.data.ChatDatabase @@ -116,6 +118,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte gson = Gson() prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + // Запрос разрешения на уведомления (Android 13+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permission = android.Manifest.permission.POST_NOTIFICATIONS + if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(arrayOf(permission), 1001) + } + } val savedModel = prefs.getString(KEY_SELECTED_MODEL, null) if (savedModel != null) { @@ -879,9 +889,10 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte 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 + // По умолчанию выбираем medium, иначе первую доступную + val mediumIndex = models.indexOfFirst { it.first.contains("mistral-medium", ignoreCase = true) } + if (mediumIndex >= 0) { + selectedModelName = models[mediumIndex].first } else if (models.isNotEmpty()) { selectedModelName = models[0].first } @@ -892,7 +903,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL) if (!hasUserSelectedModel) { runOnUiThread { - selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-medium-latest" + selectedModelName = MistralClient.DEFAULT_MODEL } } } @@ -1102,26 +1113,54 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte while (iteration < maxIterations) { iteration++ - - val result = withTimeout(30000L) { - client?.chat(selectedModel, apiMessages, tools) - ?: Result.failure(Exception("Client not initialized")) + + var result: Result? = null + var retryCount = 0 + val maxRetries = 2 + + //Retry при CANCEL ошибке + while (retryCount <= maxRetries) { + result = withTimeout(60000L) { + client?.chat(selectedModel, apiMessages, tools) + ?: Result.failure(Exception("Client not initialized")) + } + + if (!isActive) return@launch + + val errorMsg = result?.exceptionOrNull()?.message ?: "" + if ((errorMsg.contains("CANCEL") || errorMsg.contains("stream was reset")) && retryCount < maxRetries) { + retryCount++ + android.util.Log.w("MainActivity", "Retry $retryCount after CANCEL, iteration $iteration") + kotlinx.coroutines.delay(2000L) + } else { + break + } } if (!isActive) return@launch - result.onSuccess { chatResponse -> + // Handle nullable result - выходим если null + if (result == null) { + finalResponse = "Ошибка: Не удалось получить ответ от API" + } else { + val chatResult = result + chatResult.onSuccess { chatResponse -> + android.util.Log.d("MainActivity", "API response: toolCalls=${chatResponse.toolCalls.size}") + if (chatResponse.toolCalls.isNotEmpty()) { // Выполняем все tool calls и добавляем результаты в историю for (toolCall in chatResponse.toolCalls) { val toolResult = client?.executeTool(toolCall.name, toolCall.arguments) ?: """{"status": "error", "message": "Tool failed"}""" - apiMessages.add(Message( - content = """[${toolCall.name}] result: $toolResult""", - isUser = true, - role = "user" - )) + // Если tool вернул ошибку - добавляем, но не накапливаем + if (!toolResult.contains("error")) { + apiMessages.add(Message( + content = """[${toolCall.name}] result: $toolResult""", + isUser = true, + role = "user" + )) + } } // Продолжаем цикл - AI решит нужен ли еще поиск } else { @@ -1129,15 +1168,21 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte finalResponse = chatResponse.content } }.onFailure { error -> - finalResponse = "Ошибка: ${error.message}" + val errorMsg = error.message ?: "Unknown error" + android.util.Log.e("MainActivity", "API error: $errorMsg, iteration: $iteration") + + // При ошибке - показываем её и выходим + finalResponse = "Ошибка: $errorMsg" } + } // close else block for result == null check - // Если есть финальный ответ или превышен лимит - выходим - if (finalResponse != null || iteration >= maxIterations) { + // Выходим только если есть ответ + if (finalResponse != null) { break } } + // Если прошли все итерации без ответа if (finalResponse == null && iteration >= maxIterations) { finalResponse = "Превышен лимит итераций (${maxIterations}). Попробуйте более конкретный запрос." } @@ -1151,22 +1196,30 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte // Проверяем что sessionId не изменился пока работал запрос if (currentSessionId == sessionIdAtStart) { - addMessage(Message(content = responseToShow, isUser = false, senderName = selectedModel), sessionIdAtStart) + // НЕ добавляем сообщения об ошибках в БД - они портят контекст + val isError = responseToShow.contains("Timed out") || + responseToShow.contains("таймаут") || + responseToShow.startsWith("Ошибка:") - // Прокрутка к началу нового сообщения ИИ - recyclerView.post { - if (!userScrolledAfterSend) { - val layoutManager = recyclerView.layoutManager as LinearLayoutManager - layoutManager.scrollToPositionWithOffset(messages.size - 1, 0) + if (!isError) { + addMessage(Message(content = responseToShow, isUser = false, senderName = selectedModel), sessionIdAtStart) + + // Прокрутка к началу нового сообщения ИИ + recyclerView.post { + if (!userScrolledAfterSend) { + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + layoutManager.scrollToPositionWithOffset(messages.size - 1, 0) + } } - } - if (!responseToShow.startsWith("Ошибка:")) { // Генерируем название сессии после второго сообщения userMessageCount++ if (userMessageCount == 2) { generateSessionTitle() } + } else { + // Показываем ошибку пользователю через Toast + Toast.makeText(this@MainActivity, responseToShow, Toast.LENGTH_LONG).show() } } From dc461ad5dcda19655c222cf64a0313f8d5e06671 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: Wed, 15 Apr 2026 17:08:36 +0800 Subject: [PATCH 09/10] Add Foreground Service for background work, clean up logging --- AGENTS.md | 535 ++++++------------ .../com/mistral/chat/api/MistralClient.kt | 10 - .../com/mistral/chat/api/NotificationTool.kt | 82 +++ .../java/com/mistral/chat/ui/MainActivity.kt | 356 +++++++++++- 4 files changed, 567 insertions(+), 416 deletions(-) create mode 100644 app/src/main/java/com/mistral/chat/api/NotificationTool.kt diff --git a/AGENTS.md b/AGENTS.md index 1f988a9..3975605 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,454 +44,235 @@ Android-приложение для чата с Mistral AI. Перспектив - Отступы в поле ввода (12dp) - Прокрутка к новым сообщениям - **Долгий тап на сообщение** - меню Копировать/Редактировать/Удалить +- **Скролл в поле system prompt** - multiline text field scrollable +- **Адаптивные цвета AlertDialog** - Material Design 3 colors + +### ✅ Bug Fixes +- Исправлена ошибка "ответ в Toast вместо чата" - теперь только явные ошибки показываются через Toast +- Сортировка drawer меню - профили, настройки, остальное +- **WakeLock** - приложение остаётся активным при выключенном экране (ожидание ответа API) +- **Timeout 120 сек** - увеличен с 60 до 120 секунд для больших ответов +- **Foreground Service** - приложение продолжает работу в фоне при выключенном экране (ожидание ответа API) ### ✅ Security - API ключ: EncryptedSharedPreferences (AES-256-GCM) - Ключ БД: EncryptedSharedPreferences (AES-256-SIV + AES-256-GCM) - Профили, сессии, сообщения: SQLCipher +- CalDAV данные: зашифрованы (url, username, password) + +**⚠️ ВАЖНО: Все чувствительные данные должны храниться в EncryptedSharedPreferences:** +- API ключи, пароли (CalDAV, email), ключи БД, токены --- -## Current Issues & Architecture +## Tools -### ⚠️ Важное: Назначение web_search +### 🌐 Web Search +- ✅ Russian Wikipedia API (бесплатно, без API ключа) +- ✅ English Wikipedia API +- Ограничение: 4000 символов на ответ -web_search НЕ является интерфейсом поисковика или Wikipedia. Это инструмент для AI-агента: +### 🌤️ Weather +- ✅ Open-Meteo API (полностью бесплатно) +- Geocoding API + Weather API +- Текущая погода + прогноз на 7 дней -**Правильная логика работы (Kai-style):** -``` -1. AI получает вопрос пользователя -2. AI решает что нужен поиск → вызывает web_search -3. Выполняются ВСЕ tool_calls параллельно -4. Результаты НЕ показываются пользователю - только отправляются AI -5. AI интерпретирует результаты → выдаёт ОДИН финальный ответ -``` +### 🔗 OpenUrlTool +- ✅ HTTP GET к любому URL +- ✅ RSS/Atom парсинг (lenta.ru, kommersant.ru) +- Ограничение: 4000 символов, таймаут 10 сек -**Проблемы с текущей реализацией:** -- ❌ Показываем промежуточные ответы пользователю (каждый tool result = сообщение) -- ❌ AI получает результаты и отвечает после КАЖДОГО tool_calls -- ❌ AI выводит куски данных вместо интерпретации +### ⏰ Time Tools +- ✅ get_local_time - возвращает timestamp в миллисекундах +- ✅ get_date - текущая дата -**Требуется исправление:** -- ✅ Выполнить ВСЕ tool_calls за один проход (уже делаем) -- ✅ Результаты НЕ показывать пользователю (только AI видит) -- ✅ AI интерпретирует и выдаёт ОДИН ответ +### 📅 CalDAV Calendar +- ✅ calendar_add_event - создание событий с VALARM (уведомления) +- ✅ calendar_get_events - получение списка событий +- ✅ calendar_delete_event - удаление событий +- Баikal сервер интеграция -### 🔍 Web Search (Текущая реализация - БЕСПЛАТНОЕ решение) - -**Используется:** Russian Wikipedia API (бесплатно, без API ключа) -- **API:** `https://ru.wikipedia.org/w/api.php` -- **Метод:** `query/list/search` - поиск статей по заголовкам -- **Ограничение результатов:** до 10 статей (параметр `num_results`) -- **Ограничение символов:** 4000 символов на ответ -- **ПРИМЕЧАНИЕ:** Это временное решение! Позже можно добавить платный API для полноценного поиска (новости, погода, актуальная информация) - -**Логика работы:** -1. AI вызывает `web_search` с текстовым запросом -2. Выполняется поиск по Wikipedia API -3. Результаты (заголовки + сниппеты) обрезаются до 4000 символов -4. Результаты отправляются AI для интерпретации -5. AI выдаёт ОДИН финальный ответ пользователю - -**Tool Loop (MainActivity):** -- Максимум итераций: 15 -- Timeout на итерацию: 60 секунд -- Retry (до 2 попыток) при ошибке "stream was reset: CANCEL" -- AI может сделать несколько последовательных поисков если нужно - -### 🌤️ Weather Tool (БЕСПЛАТНОЕ решение) - -**Используется:** Open-Meteo API (полностью бесплатно, без API ключа) -- **Geocoding API:** `https://geocoding-api.open-meteo.com/v1/search` - определение координат города -- **Weather API:** `https://api.open-meteo.com/v1/forecast` - текущая погода + прогноз на 7 дней -- **Параметры:** температура, ветер, погодные коды, осадки - -**Логика работы:** -1. AI вызывает `get_weather` с названием города -2. Определяются координаты через Geocoding API -3. Запрашивается погода по координатам -4. Возвращается текущая погода + прогноз на 7 дней - -### 🔗 OpenUrlTool (Часть Phase 3) -**Статус:** ✅ Реализовано | **Оценка:** 1 день - -**Назначение:** Позволяет AI парсить любую веб-страницу по URL. - -**Гибридная схема (AI сам решает откуда взять URL):** -1. **RSS-ленты новостей** (рекомендуется): - - lenta.ru → https://lenta.ru/rss/ - - kommersant.ru → https://www.kommersant.ru/rss/news.xml -2. **Из памяти** - AI помнит рабочие URL -3. **Через web_search** - находит URL в интернете -4. **От пользователя** - пользователь может передать URL - -**Логика работы с новостями:** -1. Получив запрос о новостях → сначала проверь память пользователя (предпочтения по темам) -2. Открой RSS-ленту через open_url (самый эффективный способ) -3. Составь сводку с учётом интересов пользователя -4. Если источники недоступны → используй web_search -5. Проверь память приложения -6. Выдай ответ на основе всех доступных источников - -**Реализация:** -- HTTP GET запрос к любому URL -- Возврат ТОЛЬКО текста (удаляются HTML теги) -- Ограничение: 4000 символов -- Таймаут: 10 секунд -- Блокировка опасных URL (javascript:, file:, data:) +### 💾 Memory Tools +- ✅ memory_store, memory_learn, memory_forget +- ✅ memory_reinforce, memory_preference --- -### 📚 Изучено из Kai (open-source AI assistant) +## Tool Execution Parameters -Kai имеет отличную документацию по tools: https://kai9000.com/docs/features/tools/ - -**Ключевые решения из Kai:** - -1. **Execution Flow:** - - Все tool calls выполняются параллельно (coroutine async/await) - - TOOL_EXECUTING показывается в UI как "пульсирующий индикатор" - - Результаты НЕ показываются пользователю - только отправляются AI - - AI может вызвать еще tool calls → цикл повторяется - - Когда AI отвечает без tool_calls → финальный текст показан пользователю - -2. **Safety Guards (важно!):** - - Iteration limit: максимум 15 итераций - - Repeated call detection: если одинаковый tool с одинаковыми аргументами вызывается 3 раза подряд → остановка - - Timeout: 30 секунд по умолчанию - - Result truncation: результаты > 8000 символов обрезаются - - Context trimming: между итерациями обрезается история сообщений - -3. **Web Search в Kai:** - - Есть встроенный web_search tool - - Работает (вероятно использует платный API или свой парсинг) +| Параметр | Значение | +|----------|----------| +| Max iterations | 15 | +| Timeout на итерацию | 120 сек | +| Retry при CANCEL | до 2 раз | +| Result truncation | 2000 символов | +| WakeLock | ✅ для длительных запросов | --- -## Active Plan (Phases 1-3) +## Active Plan -### Phase 1: Расширенные профили (Extended Profiles) -**Статус:** ✅ Завершена | **Оценка:** 1-2 дня - -Добавлено поле `systemPrompt` в профиль для отправки как role: "system". - -| Задача | Статус | -|--------|--------| -| Profile entity | ✅ Добавлено поле systemPrompt | -| Profile dialog UI | ✅ Добавлен EditText с maxLength=4000 | -| ProfileDao | ✅ CRUD работает | -| MainActivity | ✅ Инжектирует systemPrompt как role: "system" | -| MistralClient | ✅ Использует msg.role | - ---- - -### Phase 2: Система памяти (Memory System) -**Статус:** ✅ Завершена | **Оценка:** 2-3 дня - -Система запоминания информации с категориями и hitCount. - -| Задача | Статус | -|--------|--------| -| Memory entity | ✅ key, value, category, hitCount, timestamps | -| MemoryDao | ✅ CRUD + getByCategory, incrementHitCount, getPromotionCandidates | -| ChatDatabase | ✅ Добавлен MemoryDao, version=2 | -| MemoryRepository | ✅ buildMemoryContext() для инжекции в prompt | - -**Memory categories:** -- GENERAL — общие факты -- LEARNING — выводы и паттерны -- ERROR — известные ошибки -- PREFERENCE — предпочтения пользователя -- REMINDER_CAL — напоминания календаря (local mode) -- CALENDAR_ERROR — ошибки подключения CalDAV - -**Prompt injection:** -``` -=== Важная информация === -[Факты] -- ключ: значение - -[Выводы] -- ключ: значение (N использований) - -[Предпочтения пользователя] -- ключ: значение -``` - ---- - -### Phase 3: Tools / Tool Execution -**Статус:** ✅ Завершена (тестирование) | **Оценка:** 3-4 дня - -Инструменты для AI (function calling) для выполнения действий. - -| Задача | Статус | -|--------|--------| -| Tool abstract class | ✅ name, description, inputSchema, executor | -| GetTimeTool | ✅ get_local_time с timezone | -| GetDateTool | ✅ get_date с timezone | -| WebSearchTool | ✅ Протестировано (только Wikipedia) | -| GetWeatherTool | ✅ Протестировано (Open-Meteo API) | -| NotificationTool | ✅ send_notification | -| MemoryStoreTool | ✅ Протестировано | -| MemoryLearnTool | ✅ Протестировано | -| MemoryForgetTool | ✅ Протестировано | -| MemoryReinforceTool | ✅ Протестировано | -| MemoryPreferenceTool | ✅ Протестировано | -| ToolExecutor | ✅ управление всеми tools, updateSettings() | -| MistralClient | ✅ tools в chat completion, обработка tool_calls | -| Safety | ✅ Max iterations (15), timeout (30s), result truncation (2000 chars) | -| **OpenUrlTool (RSS)** | ✅ Автоматическое определение и парсинг RSS/Atom | - -**CalDAV Calendar + Local Reminders (Phase 3 extension):** -| Задача | Статус | -|--------|--------| -| iCalDAV зависимость (Apache 2.0) | ⏳ | -| CalDavRepository (CRUD) | ⏳ | -| UI: drawer_menu → диалог настроек CalDAV | ⏳ | -| Настройка синхронизации (15м-сутки) | ⏳ | -| caldav_get_events, create, update, delete | ⏳ | -| Memory category REMINDER_CAL | ⏳ | -| calendar_add_reminder tool | ⏳ | -| calendar_get_reminders tool | ⏳ | -| Напоминания (любой период: 5мин, 13мин, 2ч, 24ч) | ⏳ | -| Счётчик ошибок CalDAV → memory | ⏳ | -| UnifiedPush (опционально, на потом) | ⏳ | - -**Memory REMINDER_CAL fields:** -- key: название напоминания -- value: описание -- triggerTime: unix timestamp когда напомнить -- status: pending / triggered / expired - -**Trigger logic:** AI проверяет pending напоминания при каждом запросе - -**RSS-ленты (протестировано):** -- lenta.ru/rss/ ✅ -- kommersant.ru/rss/news.xml ✅ - -**Тестирование Phase 3:** -- ✅ web_search (Wikipedia) - работает -- ✅ get_weather (Open-Meteo) - работает -- ✅ Memory tools - работает, изолирована по профилям (протестировано) - -**Location Settings (в рамках Phase 3):** -| Задача | Статус | -|--------|--------| -| Preferences keys | ✅ KEY_DEFAULT_TIMEZONE, KEY_DEFAULT_CITY | -| dialog_location.xml | ✅ UI для ввода timezone/city | -| showLocationDialog() | ✅ Реализована в MainActivity | -| drawer_menu.xml | ✅ Добавлен item action_location | -| ic_location.xml | ✅ Создан vector drawable | -| ToolExecutor.updateSettings() | ✅ Принимает timezone/city при сохранении | - -**Defaults:** -- Timezone: Asia/Irkutsk -- City: Иркутск - ---- - -## Active Plan (Phases 1-5) - -### Phase 3 (Active): CalDAV Calendar + Local Reminders -**Статус:** 🔄 В разработке | **Оценка:** 4-5 дней +### Phase 3: CalDAV Calendar + Local Reminders (✅ В основном готово) **Два режима:** -1. CalDAV — синхронизация с Baikal сервером -2. Local — автономная напоминалка в памяти AI (работает БЕЗ интернета) +1. **CalDAV** — синхронизация с Baikal сервером +2. **Local** — автономная напоминалка в памяти AI -**Подробнее:** см. таблицу в разделе Phase 3 выше +**CalDAV Status:** +| Задача | Статус | +|--------|--------| +| Подключение к Baikal | ✅ | +| calendar_add_event | ✅ Работает (с VALARM) | +| calendar_get_events | ✅ Работает (лимит 100 событий) | +| calendar_delete_event | ✅ Работает (только свои события) | +| VALARM (уведомления) | ✅ Добавляются к событиям | +| UID consistency | ✅ Исправлено | +| Timestamp (time_string) | ✅ AI передаёт строку, сервер парсит | ---- +**Нерешённые проблемы:** +- При переустановке app старые события становятся "чужими" (новый UUID) -### Phase 4: Heartbeat (Scheduled) -**Оценка:** 2-3 дня +**Как помочь AI правильно работать с календарём:** +1. Обязательно вызвать get_local_time для получения текущего UTC timestamp +2. Использовать формулу: new_timestamp = current_timestamp + (часы * 3600000) + (минуты * 60000) +3. НЕ добавлять случайные минуты! -Автономная периодическая самопроверка: +### Phase 4: Heartbeat (⏳ В очереди) - WorkManager задача (каждые 30 минут) - Active hours (8:00-22:00) -- Обработка ответа (молча vs уведомление) -### Phase 5: Email (IMAP/SMTP) -**Оценка:** 4-5 дней +### Phase 5: Email (⏳ В очереди) +- IMAP/SMTP клиент (без OAuth) -Интеграция с email без OAuth: -- IMAP клиент (чтение писем) -- SMTP клиент (отправка) -- UI настройки ящика (сервер, порт, логин, пароль) -- Email tools для AI +### Phase 6: API Key Rotation (📋 Запланировано) + +**Проблема:** +- При достижении лимита токенов или блокировке ключа приложение перестаёт работать +- Нужна система ротации для отказоустойчивости + +**Механизм:** + +1. **Хранение нескольких ключей:** + - До 5 API ключей в EncryptedSharedPreferences + - Каждый ключ имеет статус: active, disabled, blocked + - Приоритет использования (порядок) + +2. **Автоматическая ротация:** + - При ошибке 429 (rate limit) → переключить на следующий ключ + - При ошибке 401/403 (blocked) → пометить ключ как blocked + - При успешном ответе → ключ working + +3. **Логика переключения:** + ``` + При ошибке: + - 429 (Too Many Requests) → nextKey() + - 401/403 (Unauthorized) → markKeyBlocked(), nextKey() + - 500+ → markKeyDisabled(), nextKey() + + При успехе: + - workingCount++ (счётчик успешных использований) + ``` + +4. **Ручное управление:** + - UI для добавления/удаления ключей + - Просмотр статуса каждого ключа + - Ручное переключение + +**UI реализация:** +- Настройки профиля → "API ключи" → список ключей +- Статус: ✅ рабочий, ⚠️ лимит, ❌ заблокирован +- Возможность добавить/удалить/переключить + +**Files to modify:** +- `EncryptedPrefs.kt` - хранение нескольких ключей +- `MistralClient.kt` - логика ротации +- UI: dialog_settings.xml или новое диалоговое окно --- -## Technical Context - -### ⚠️ ВАЖНО: Сборка APK после каждого изменения -**После каждого исправления или добавления функций НЕОБХОДИМО собирать APK!** - -Пользователь должен иметь возможность сразу протестировать изменения. - -```bash -# Сборка APK -JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug - -# Путь к APK -app/build/outputs/apk/debug/app-debug.apk -``` - -### Key Files +## Key Files - `app/src/main/java/com/mistral/chat/ui/MainActivity.kt` — главная активность - `app/src/main/java/com/mistral/chat/api/MistralClient.kt` — API клиент - `app/src/main/java/com/mistral/chat/api/ToolExecutor.kt` — менеджер tools +- `app/src/main/java/com/mistral/chat/api/CalDavClient.kt` — CalDAV клиент - `app/src/main/java/com/mistral/chat/data/ChatDatabase.kt` — база данных -- `app/src/main/java/com/mistral/chat/data/Profile.kt` — профиль -- `app/src/main/java/com/mistral/chat/data/Memory.kt` — память -- `app/src/main/res/layout/dialog_location.xml` — настройки местоположения - -### Current Issues -- Кнопка STOP не работает (требует streaming mode) ### Model Selection -- **Default:** mistral-medium-latest (быстрее, меньше ошибок) -- **Доступные модели:** Large, Medium, Codestral, Pixtral +- **Default:** mistral-medium-latest - **OkHttp timeouts:** connect 60s, read 120s, write 60s -### Error Handling (Исправлено) -- Ошибки tool execution (таймауты, network errors) НЕ сохраняются в БД -- Показываются пользователю через Toast -- Предотвращает "отравление" контекста сообщениями об ошибках - --- ## ⚠️ ВАЖНЫЕ ПРАВИЛА РАЗРАБОТКИ ### Запрет на удаление реализованных функций -**НИКОГДА не удаляй уже реализованные функции!** Даже если они кажутся неидеальными: -- Если нужно изменить поведение - исправь, а не удаляй -- Если что-то сломалось - почини, а не упрощай удалением -- При удалении функций (даже "неиспользуемых") всегда согласовывай с пользователем +**НИКОГДА не удаляй уже реализованные функции!** -### Запрет на хардкодинг переменных +### Запрет на хардкодинг **НИКОГДА не хардкодь значения, которые должны быть динамическими!** -- Даты, года, время,地名, названия - всё должно подставляться из системы/контекста -- Если что-то не получается реализовать без хардкода - ОБСУДИ с пользователем перед реализацией -- Пример правильного подхода: `{CURRENT_YEAR}` → подставляется через `SimpleDateFormat` -### Сборка APK после каждого изменения -**После каждого исправления или добавления функций ОБЯЗАТЕЛЬНО собирай APK!** -- Пользователь должен иметь возможность сразу протестировать изменения -- Команда: `JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug` -- Расположение: `app/build/outputs/apk/debug/app-debug.apk` - -### Дублирование сообщений при переключении сессий (BUG FIX) -**Проблема:** При переходе из второй сессии в первую (или любую другую) сообщения дублировались. - -**Причина:** Асинхронная загрузка сообщений без проверки актуальности sessionId. - -**Решение в MainActivity.kt:** -1. Очищаем список СРАЗУ при переключении (до асинхронной загрузки) -2. Используем `loadMessagesJob` для отмены предыдущей загрузки сообщений -3. Проверяем sessionId внутри async загрузки (несколько раз) -4. Передаём `expectedSessionId` в `addMessage` для правильного сохранения в БД -5. Прокрутка к последнему сообщению после загрузки - -### ⚠️ ВАЖНО: Логика прокрутки чата -**Правильная реализация:** -1. **К концу сообщения пользователя** - прокрутка к концу (scrollToPosition) через 100мс после добавления -2. **К началу ответа ИИ** - прокрутка к НАЧАЛУ (scrollToPositionWithOffset) через 150мс после добавления сообщения ИИ - -**Техническая реализация:** -- В `addMessage()`: для сообщений ИИ (`!message.isUser`) - прокрутка к началу через 150мс -- Используй `layoutManager.scrollToPositionWithOffset(position, 0)` для прокрутки к началу элемента -- Используй `scrollToPosition(position)` для прокрутки к концу элемента -- Проверяй `!userScrolledAfterSend` перед прокруткой к ответу ИИ - -### Удаление debug логирования -После отладки и подтверждения что баг исправлен - удали все `android.util.Log.d("DEBUG", ...)` из кода. - -### Порядок действий при работе с багом -1. Проанализируй код и найди причину -2. Исправь проблему, а не симптомы -3. Не удаляй существующий функционал -4. Проверь что исправление не ломает другие сценарии -5. Документируй исправление в agents.md +### Сборка APK +```bash +JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug +# Путь: app/build/outputs/apk/debug/app-debug.apk +``` --- -## Выводы и предмет для обсуждения +## 📋 Контекст сессии и оптимизация (📋 Запланировано) -### WebSearchTool -- **Wikipedia API** - работает, но содержит только энциклопедические статьи (нет погоды, новостей) -- **DuckDuckGo Instant Answer API** - возвращает 0 результатов для большинства запросов (ограничение бесплатного API) -- **Вывод:** Текущая реализация web_search не может полноценно заменить поисковик +### Текущая реализация +При каждом запросе отправляется полный контекст. При росте сессии возможны 503 ошибки. -### OpenUrlTool (предложено, отложено) -- AI не знает все URL наизусть - нужен либо справочник в system prompt, либо web_search для нахождения URL -- При гибридном подходе: web_search находит URL → open_url парсит страницу -- Проблема: в system prompt не влезет список URL для всех типичных запросов (погода, новости, курсы валют и т.д.) -- **Вывод:** Реализация отложена до починки web_search +### План реализации (Kai 9000 style) -### Tool Execution Loop -- Предыдущая реализация: 1 итерация → результаты → финальный запрос без tools -- **Проблема:** Не даёт AI сделать несколько последовательных поисков (web_search → получить URL → open_url) -- Новая реализация: до 15 итераций, как в Kai - AI сам решает сколько поисков нужно -- Лимит iteration: 15 -- Timeout на итерацию: 30 сек -- Если API Mistral не выдержит - снизим до 10 или 5 +**Источник:** https://kai9000.com/docs/features/tools/ ---- +**Часть 1: Trimming (меж-итеративный)** +- Обрезать историю ПОСЛЕ КАЖДОГО tool вызова +- После каждого tool execution - проверить размер контекста +- Если > MAX_CONTEXT - удалить старые сообщения (кроме system prompt) +- MAX_CONTEXT = ~16000 токенов (50% от лимита mistral-medium) -## 📋 Контекст сессии и оптимизация (В ОБСУЖДЕНИИ) +**Часть 2: Compaction (AI summary)** +- При 70% лимита (~22000 токенов) - запустить AI summary +- Последние 4 user обмена - оставить verbatim +- Остальное - одно summary message +- Сохранить summary в БД для персистентности -### Текущая реализация (без оптимизации) +### Реализация +ToolExecutor.kt → модифицировать loop: +```kotlin +while (toolCalls.isNotEmpty()) { + result = executeTool() + messages.add(result) + + // Trimming после каждого tool + if (getTokenCount(messages) > MAX_CONTEXT) { + trimOldMessages() + } +} +``` -При каждом запросе отправляется полный контекст: -1. System prompt (профиль) -2. Текущая дата и время -3. Часовой пояс + город -4. Контекст профиля (имя, о себе) -5. Контекст памяти (факты, выводы, предпочтения) -6. **ВСЕ сообщения сессии** -7. Результаты tool calls (полностью, до 2000 символов каждый) - -**Проблемы:** -- При 2-3 tool calls (RSS + статья) добавляется 4000-6000 символов в контекст -- При росте сессии (100+ сообщений) запрос станет слишком большим -- 503 ошибки чаще происходят при больших запросах -- Превышение лимита токенов контекста - -### Варианты решения - -**1. Trimming (простое)** -- Оставлять только последние N сообщений + память + system prompt -- Просто реализовать, но теряется история - -**2. Свёртывание tool results** -- Не добавлять полный результат open_url в историю -- Добавлять краткую выжимку: "Найдено 5 новостей о [тема]" -- Сложнее реализовать, сохраняет суть - -**3. Контекстное окно (гибкое)** -- Оставлять последние N сообщений + summary предыдущих -- ИИ сам решает что важно -- Сложная реализация - -**Статус:** Не решено, требует обсуждения с пользователем +### Files to modify +- ToolExecutor.kt - добавить trimming в loop +- MistralClient.kt - добавить getTokenCount, trimOldMessages --- ## Conversation Context (for AI Agent) **При начале новой сессии:** -Прочитай файл AGENTS.md для понимания текущего контекста разработки. +Прочитай файл AGENTS.md для понимания текущего контекста. **При запросе "продолжаем":** -Мы работаем над Phase 3 (Tools). Последняя завершённая задача — добавление настроек location (timezone/city) в drawer menu. +Мы работаем над Phase 3 - CalDAV календарь. Тестируем: создание событий, получение списка, исправление timezone. **Важно:** -- Пушить в GitHub только после тестирования и подтверждения пользователя -- Не делать push автоматически после каждого изменения +- Пушить в GitHub только после подтверждения пользователя +- Не делать push автоматически --- -*Last updated: 2026-04-10* -*Version: 1.10* \ No newline at end of file +*Last updated: 2026-04-12* +*Version: 1.11* \ No newline at end of file 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 7db75a8..f7125f2 100644 --- a/app/src/main/java/com/mistral/chat/api/MistralClient.kt +++ b/app/src/main/java/com/mistral/chat/api/MistralClient.kt @@ -168,14 +168,6 @@ class MistralClient(private val apiKey: String) { val json = gson.toJson(jsonObject) val body = json.toRequestBody(jsonMediaType) - Log.d("MistralClient", "Request JSON size: ${json.length} chars") - Log.d("MistralClient", "Request: model=$model, msgs=${messages.size}, tools=${tools?.size ?: 0}") - - // Логируем все сообщения - messages.forEachIndexed { idx, msg -> - Log.d("MistralClient", "Msg[$idx] role=${msg.role}, len=${msg.content.length}, content=${msg.content.take(100)}...") - } - val request = Request.Builder() .url("$BASE_URL/chat/completions") .addHeader("Authorization", "Bearer $apiKey") @@ -223,8 +215,6 @@ class MistralClient(private val apiKey: String) { val responseBody = response.body?.string() ?: "" - Log.d("MistralClient", "Response: code=${response.code}, len=${responseBody.length}") - if (onChunk != null) { onChunk(responseBody) } diff --git a/app/src/main/java/com/mistral/chat/api/NotificationTool.kt b/app/src/main/java/com/mistral/chat/api/NotificationTool.kt new file mode 100644 index 0000000..9c117ae --- /dev/null +++ b/app/src/main/java/com/mistral/chat/api/NotificationTool.kt @@ -0,0 +1,82 @@ +package com.mistral.chat.api + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import com.google.gson.JsonObject + +class NotificationTool(private val context: Context) : Tool( + name = "send_notification", + description = "Отправить уведомление пользователю. Используй когда нужно сообщить важную информацию или напомнить о чём-то.", + inputSchema = JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("object")) + add("properties", JsonObject().apply { + add("title", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("string")) + add("description", com.google.gson.JsonPrimitive("Заголовок уведомления")) + }) + add("message", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("string")) + add("description", com.google.gson.JsonPrimitive("Текст уведомления")) + }) + }) + add("required", com.google.gson.JsonArray().apply { + add("title") + add("message") + }) + } +) { + companion object { + private const val CHANNEL_ID = "mistral_chat_notifications" + private const val CHANNEL_NAME = "Chat Notifications" + } + + override suspend fun execute(arguments: JsonObject): String { + val title = arguments.get("title")?.asString ?: "Уведомление" + val message = arguments.get("message")?.asString ?: "" + + if (message.isEmpty()) { + return """{"status": "error", "message": "Message cannot be empty"}""" + } + + // Проверка разрешения для Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permission = android.Manifest.permission.POST_NOTIFICATIONS + if (context.checkSelfPermission(permission) != android.content.pm.PackageManager.PERMISSION_GRANTED) { + return """{"status": "error", "message": "permission_denied: Уведомления отключены в настройках. Попроси пользователя включить их в настройках приложения."}""" + } + } + + return try { + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Уведомления от Mistral Chat" + enableVibration(true) + } + notificationManager.createNotificationChannel(channel) + } + + val notification = android.app.Notification.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(message) + .setStyle(android.app.Notification.BigTextStyle().bigText(message)) + .setPriority(android.app.Notification.PRIORITY_HIGH) + .setAutoCancel(true) + .build() + + notificationManager.notify(System.currentTimeMillis().toInt(), notification) + + """{"status": "success", "message": "Уведомление отправлено: $title"}""" + } catch (e: Exception) { + """{"status": "error", "message": "${e.message}"}""" + } + } +} \ 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 08b1224..36cddc8 100644 --- a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt +++ b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.SharedPreferences import android.content.pm.PackageManager import android.os.Build import android.os.Bundle +import android.os.PowerManager import android.view.KeyEvent import android.view.View import android.view.inputmethod.EditorInfo @@ -33,7 +34,10 @@ import com.google.android.material.textfield.TextInputEditText import com.google.android.material.button.MaterialButton import com.google.gson.Gson import com.mistral.chat.R +import com.mistral.chat.api.CalDavCalendar +import com.mistral.chat.api.CalDavClient import com.mistral.chat.api.ChatResponse +import com.mistral.chat.api.ApiForegroundService import com.mistral.chat.api.MistralClient import com.mistral.chat.api.ToolExecutor import com.mistral.chat.data.ChatDatabase @@ -173,6 +177,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte loadModels() setupInput() loadProfilesAndSessions() + restoreCalDavConnection() inputField.postDelayed({ val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager @@ -181,6 +186,36 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte }, 300) } + private fun restoreCalDavConnection() { + val isConnected = encryptedPrefs.getBoolean("caldav_connected", false) + + if (isConnected) { + val url = encryptedPrefs.getString("caldav_url", "") ?: "" + val username = encryptedPrefs.getString("caldav_username", "") ?: "" + val password = encryptedPrefs.getString("caldav_password", "") ?: "" + + if (url.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty()) { + lifecycleScope.launch { + try { + calDavClient = CalDavClient(url, username, password) + // Skip connection test - directly fetch calendars + val calendarsResult = calDavClient?.getCalendars() + calDavCalendars = calendarsResult?.getOrNull() ?: emptyList() + + if (calDavCalendars.isNotEmpty()) { + val calendarUrl = calDavCalendars.first().url + toolExecutor?.setCalDavClient(calDavClient, calendarUrl) + } else { + // No calendars - might be connection issue + } + } catch (e: Exception) { + // Keep connected flag but try next time + } + } + } + } + } + private fun setupDrawer() { navigationView.setNavigationItemSelectedListener(this) @@ -228,6 +263,9 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte R.id.action_location -> { showLocationDialog() } + R.id.action_calendar -> { + showCalendarDialog() + } R.id.action_about -> { showAboutDialog() } @@ -321,7 +359,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val selectedIndex = if (currentSetting) 1 else 0 AlertDialog.Builder(this) - .setTitle(R.string.settings) + .setTitle(R.string.session_menu_title) .setSingleChoiceItems(options, selectedIndex) { dialog, which -> val newValue = which == 1 prefs.edit().putBoolean(KEY_NEW_SESSION_ON_START, newValue).apply() @@ -367,11 +405,50 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private fun showLocationDialog() { val dialogView = layoutInflater.inflate(R.layout.dialog_location, null) - val timezoneInput = dialogView.findViewById(R.id.timezoneInput) - val cityInput = dialogView.findViewById(R.id.cityInput) + val timezoneInput = dialogView.findViewById(R.id.timezoneInput) + val cityInput = dialogView.findViewById(R.id.cityInput) - timezoneInput.setText(getDefaultTimezone()) - cityInput.setText(getDefaultCity()) + // Российские города + val russianCities = listOf( + "Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург", "Казань", + "Нижний Новгород", "Челябинск", "Самара", "Омск", "Ростов-на-Дону", + "Уфа", "Красноярск", "Воронеж", "Пермь", "Волгоград", + "Улан-Удэ", "Иркутск", "Хабаровск", "Ярославль", "Тюмень", "Архангельск" + ) + + // Мировые столицы + val worldCities = listOf( + "Лондон", "Париж", "Берлин", "Рим", "Мадрид", "Амстердам", "Брюссель", + "Нью-Йорк", "Лос-Анджелес", "Чикаго", "Сан-Франциско", "Майami", + "Токио", "Сеул", "Пекин", "Шанхай", "Гонконг", "Сингапур", + "Дубай", "Мумбаи", "Дели", "Сидней", "Мельбурн", "Окленд", + "Торонто", "Ванкувер", "Монреаль", "Мехико", "Сантьяго", "София" + ) + + val allCities = russianCities + worldCities + val cityAdapter = android.widget.ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, allCities) + cityInput.setAdapter(cityAdapter) + + // Timezones - крупнейшие города России и мира + val timezones = listOf( + "Europe/Moscow", "Europe/Kaliningrad", "Europe/Samara", "Europe/Volgograd", + "Asia/Yekaterinburg", "Asia/Omsk", "Asia/Novosibirsk", "Asia/Krasnoyarsk", + "Asia/Irkutsk", "Asia/Yakutsk", "Asia/Vladivostok", "Asia/Magadan", + "Europe/London", "Europe/Paris", "Europe/Berlin", "Europe/Rome", "Europe/Madrid", + "Europe/Amsterdam", "Europe/Brussels", + "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", + "America/Toronto", "America/Vancouver", "America/Mexico_City", + "Asia/Tokyo", "Asia/Seoul", "Asia/Shanghai", "Asia/Hong_Kong", "Asia/Singapore", + "Asia/Dubai", "Asia/Kolkata", "Australia/Sydney", "Australia/Melbourne", + "Pacific/Auckland" + ) + + val tzAdapter = android.widget.ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, timezones) + timezoneInput.setAdapter(tzAdapter) + + // Установка текущих значений + timezoneInput.setText(getDefaultTimezone(), false) + cityInput.setText(getDefaultCity(), false) AlertDialog.Builder(this) .setTitle(R.string.location_title) @@ -394,6 +471,167 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte .show() } + private var calDavClient: CalDavClient? = null + private var calDavCalendars: List = emptyList() + + private fun showCalendarDialog() { + val dialogView = layoutInflater.inflate(R.layout.dialog_calendar, null) + val caldavStatusText = dialogView.findViewById(R.id.caldavStatusText) + val caldavUrlInput = dialogView.findViewById(R.id.caldavUrlInput) + val caldavUsernameInput = dialogView.findViewById(R.id.caldavUsernameInput) + val caldavPasswordInput = dialogView.findViewById(R.id.caldavPasswordInput) + val syncIntervalSpinner = dialogView.findViewById(R.id.syncIntervalSpinner) + + val prefs = getSharedPreferences("mistral_prefs", MODE_PRIVATE) + // Sensitive data: encryptedPrefs, Non-sensitive: regular prefs + val caldavUrl = encryptedPrefs.getString("caldav_url", "") ?: "" + val caldavUsername = encryptedPrefs.getString("caldav_username", "") ?: "" + + caldavUrlInput.setText(caldavUrl) + caldavUsernameInput.setText(caldavUsername) + + // Setup dropdown + val intervals = arrayOf("15 минут", "30 минут", "1 час", "3 часа", "6 часов", "12 часов", "1 день") + val intervalValues = arrayOf("15", "30", "60", "180", "360", "720", "1440") + val adapter = android.widget.ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, intervals) + syncIntervalSpinner.setAdapter(adapter) + + val savedInterval = prefs.getString("caldav_sync_interval", "60") ?: "60" + val savedIndex = intervalValues.indexOf(savedInterval) + if (savedIndex >= 0) syncIntervalSpinner.setText(intervals[savedIndex], false) + + val isConnected = encryptedPrefs.getBoolean("caldav_connected", false) + + val dialogBuilder = AlertDialog.Builder(this) + .setTitle(R.string.calendar_title) + .setView(dialogView) + .setCancelable(true) + + if (isConnected) { + dialogBuilder.setPositiveButton(R.string.caldav_disconnect) { _, _ -> + disconnectCalDav() + } + dialogBuilder.setNeutralButton("Синхронизировать") { _, _ -> + syncCalDav() + } + } else { + dialogBuilder.setPositiveButton(R.string.caldav_connect) { _, _ -> + val url = caldavUrlInput.text.toString().trim() + val username = caldavUsernameInput.text.toString().trim() + val password = caldavPasswordInput.text.toString() + val selectedText = syncIntervalSpinner.text.toString() + val selectedIndex = intervals.indexOf(selectedText) + val selectedInterval = if (selectedIndex >= 0) intervalValues[selectedIndex] else "60" + + if (url.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty()) { + prefs.edit().putString("caldav_sync_interval", selectedInterval).apply() + connectCalDav(url, username, password, prefs, caldavStatusText) + } else { + Toast.makeText(this, "Заполните все поля", Toast.LENGTH_SHORT).show() + } + } + } + + dialogBuilder.setNegativeButton(R.string.cancel, null) + val dialog = dialogBuilder.create() + + // Update status text + caldavStatusText.setText(if (isConnected) R.string.caldav_connected else R.string.caldav_disconnected) + + dialog.show() + } + + private fun connectCalDav(url: String, username: String, password: String, prefs: android.content.SharedPreferences, statusText: android.widget.TextView) { + statusText.setText("Подключение...") + + lifecycleScope.launch { + try { + calDavClient = CalDavClient(url, username, password) + val result = calDavClient?.testConnection() + + if (result?.isSuccess == true) { + // Fetch calendars + val calendarsResult = calDavClient?.getCalendars() + calDavCalendars = calendarsResult?.getOrNull() ?: emptyList() + + // Save to ENCRYPTED storage (IMPORTANT!) + encryptedPrefs.edit() + .putString("caldav_url", url) + .putString("caldav_username", username) + .putString("caldav_password", password) + .putBoolean("caldav_connected", true) + .apply() + + // Update ToolExecutor with CalDAV client + val calendarUrl = calDavCalendars.firstOrNull()?.url + toolExecutor?.setCalDavClient(calDavClient, calendarUrl) + + val calendarsCount = calDavCalendars.size + Toast.makeText(this@MainActivity, "Подключено! Найдено календарей: $calendarsCount", Toast.LENGTH_SHORT).show() + } else { + encryptedPrefs.edit().putBoolean("caldav_connected", false).apply() + Toast.makeText(this@MainActivity, "Ошибка: ${result?.exceptionOrNull()?.message}", Toast.LENGTH_LONG).show() + } + } catch (e: Exception) { + encryptedPrefs.edit().putBoolean("caldav_connected", false).apply() + Toast.makeText(this@MainActivity, "Ошибка: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + + private fun disconnectCalDav() { + calDavClient = null + calDavCalendars = emptyList() + toolExecutor?.setCalDavClient(null, null) + encryptedPrefs.edit() + .putBoolean("caldav_connected", false) + .apply() + Toast.makeText(this, R.string.caldav_disconnected, Toast.LENGTH_SHORT).show() + } + + private fun syncCalDav() { + val url = encryptedPrefs.getString("caldav_url", "") ?: "" + val username = encryptedPrefs.getString("caldav_username", "") ?: "" + val password = encryptedPrefs.getString("caldav_password", "") ?: "" + + if (url.isEmpty() || username.isEmpty() || password.isEmpty()) { + Toast.makeText(this, "Настройки не найдены", Toast.LENGTH_SHORT).show() + return + } + + lifecycleScope.launch { + try { + if (calDavClient == null) { + calDavClient = CalDavClient(url, username, password) + } + + val calendarsResult = calDavClient?.getCalendars() + + if (calendarsResult?.isSuccess == true) { + val calendars = calendarsResult.getOrNull() ?: emptyList() + + if (calendars.isNotEmpty()) { + val eventsResult = calDavClient?.getEvents(calendars.first().url) + val events = eventsResult?.getOrNull() ?: emptyList() + + val msg = if (events.isEmpty()) { + "Календари: ${calendars.size}, нет событий" + } else { + "Календари: ${calendars.size}, событий: ${events.size}" + } + Toast.makeText(this@MainActivity, msg, Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this@MainActivity, "Календари не найдены", Toast.LENGTH_SHORT).show() + } + } else { + Toast.makeText(this@MainActivity, "Ошибка: ${calendarsResult?.exceptionOrNull()?.message}", Toast.LENGTH_LONG).show() + } + } catch (e: Exception) { + Toast.makeText(this@MainActivity, "Ошибка синхронизации: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + private fun setupToolbar() { hamburgerButton.isVisible = true @@ -960,7 +1198,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } private fun getDefaultTimezone(): String { - return prefs.getString(KEY_DEFAULT_TIMEZONE, "Europe/Moscow") ?: "Europe/Moscow" + return prefs.getString(KEY_DEFAULT_TIMEZONE, "Asia/Irkutsk") ?: "Asia/Irkutsk" } private fun setDefaultTimezone(timezone: String) { @@ -968,7 +1206,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } private fun getDefaultCity(): String { - return prefs.getString(KEY_DEFAULT_CITY, "Москва") ?: "Москва" + return prefs.getString(KEY_DEFAULT_CITY, "Улан-Удэ") ?: "Улан-Удэ" } private fun setDefaultCity(city: String) { @@ -1055,6 +1293,15 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte sendButton.isEnabled = false progressIndicator.isVisible = true + // Start foreground service to keep app alive + val activityContext = this + ApiForegroundService.start(activityContext) + + // Acquire WakeLock to keep CPU awake during API call + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "MistralChat::ApiCallWakeLock") + wakeLock.acquire(180000L) // Max 3 minutes + currentJob = lifecycleScope.launch { try { val profileContext = getSelectedProfileContext() @@ -1108,6 +1355,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte // Tool loop - до 15 итераций var iteration = 0 + var repeatCount = 0 + var lastToolCalls: List = emptyList() val maxIterations = 15 var finalResponse: String? = null @@ -1117,10 +1366,11 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte var result: Result? = null var retryCount = 0 val maxRetries = 2 + val apiTimeout = 120000L // 120 seconds for large responses //Retry при CANCEL ошибке while (retryCount <= maxRetries) { - result = withTimeout(60000L) { + result = withTimeout(apiTimeout) { client?.chat(selectedModel, apiMessages, tools) ?: Result.failure(Exception("Client not initialized")) } @@ -1130,39 +1380,67 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val errorMsg = result?.exceptionOrNull()?.message ?: "" if ((errorMsg.contains("CANCEL") || errorMsg.contains("stream was reset")) && retryCount < maxRetries) { retryCount++ - android.util.Log.w("MainActivity", "Retry $retryCount after CANCEL, iteration $iteration") kotlinx.coroutines.delay(2000L) } else { break } } - if (!isActive) return@launch - - // Handle nullable result - выходим если null - if (result == null) { - finalResponse = "Ошибка: Не удалось получить ответ от API" - } else { - val chatResult = result - chatResult.onSuccess { chatResponse -> - android.util.Log.d("MainActivity", "API response: toolCalls=${chatResponse.toolCalls.size}") +if (!isActive) return@launch + // Handle nullable result - выходим если null + if (result == null) { + finalResponse = "Ошибка: Не удалось получить ответ от API" + } else { + val chatResult = result + chatResult.onSuccess { chatResponse -> + if (chatResponse.toolCalls.isNotEmpty()) { + // Проверяем на повторяющиеся tool calls (защита от бесконечного цикла) + val currentToolCalls = chatResponse.toolCalls.map { "${it.name}:${it.arguments.toString().take(50)}" } + + if (iteration > 1 && lastToolCalls == currentToolCalls) { + repeatCount++ + if (repeatCount >= 2) { + // AI повторяет тот же tool 2+ раза - останавливаем + finalResponse = "Не удалось выполнить действие. Попробуйте переформулировать запрос." + return@onSuccess + } + } else { + repeatCount = 0 + } + lastToolCalls = currentToolCalls + // Выполняем все tool calls и добавляем результаты в историю + var writeOperationCompleted = false + var writeOperationMessage = "" + for (toolCall in chatResponse.toolCalls) { val toolResult = client?.executeTool(toolCall.name, toolCall.arguments) ?: """{"status": "error", "message": "Tool failed"}""" - // Если tool вернул ошибку - добавляем, но не накапливаем - if (!toolResult.contains("error")) { - apiMessages.add(Message( - content = """[${toolCall.name}] result: $toolResult""", - isUser = true, - role = "user" - )) + // Все результаты добавляем в историю + apiMessages.add(Message( + content = """[${toolCall.name}] result: $toolResult""", + isUser = true, + role = "user" + )) + + // Для write-операций (calendar_add) - после успеха запоминаем + if (toolCall.name == "calendar_add_event" && toolResult.contains("success")) { + writeOperationCompleted = true + writeOperationMessage = "Готово! Событие добавлено в календарь." } } - // Продолжаем цикл - AI решит нужен ли еще поиск + + // Если write-операция выполнена - выходим из цикла + if (writeOperationCompleted) { + finalResponse = writeOperationMessage + // Выходим из while цикла + return@onSuccess + } + + // Продолжаем цикл только если не было write-операции } else { // Нет tool calls - это финальный ответ finalResponse = chatResponse.content @@ -1197,9 +1475,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte // Проверяем что sessionId не изменился пока работал запрос if (currentSessionId == sessionIdAtStart) { // НЕ добавляем сообщения об ошибках в БД - они портят контекст - val isError = responseToShow.contains("Timed out") || - responseToShow.contains("таймаут") || - responseToShow.startsWith("Ошибка:") + // Проверяем более строго - только явные ошибки, а не просто упоминание в тексте + val isError = responseToShow.startsWith("Timed out", ignoreCase = true) || + responseToShow.startsWith("Timeout", ignoreCase = true) || + responseToShow.startsWith("таймаут", ignoreCase = true) || + responseToShow.startsWith("Ошибка:", ignoreCase = true) || + responseToShow.startsWith("Error:", ignoreCase = true) || + responseToShow.startsWith("Failed to", ignoreCase = true) if (!isError) { addMessage(Message(content = responseToShow, isUser = false, senderName = selectedModel), sessionIdAtStart) @@ -1225,7 +1507,23 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte sendButton.isEnabled = true progressIndicator.isVisible = false + + // Release WakeLock + if (wakeLock.isHeld) { + wakeLock.release() + } + + // Stop foreground service + ApiForegroundService.stop(this@MainActivity) } catch (e: Exception) { + // Release WakeLock on error + if (wakeLock.isHeld) { + wakeLock.release() + } + + // Stop foreground service on error + ApiForegroundService.stop(activityContext) + if (!isActive) return@launch android.util.Log.e("MainActivity", "Exception: ${e.message}", e) if (currentSessionId == sessionIdAtStart) { From 16720a035a92484267b803b6cbc207099300c3cf 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: Wed, 15 Apr 2026 19:11:04 +0800 Subject: [PATCH 10/10] Add background notification with clickable action --- AGENTS.md | 1 + .../mistral/chat/api/ApiForegroundService.kt | 66 +++++++++++++ .../java/com/mistral/chat/ui/MainActivity.kt | 96 ++++++++++++++++++- 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/mistral/chat/api/ApiForegroundService.kt diff --git a/AGENTS.md b/AGENTS.md index 3975605..175e05c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ Android-приложение для чата с Mistral AI. Перспектив - **WakeLock** - приложение остаётся активным при выключенном экране (ожидание ответа API) - **Timeout 120 сек** - увеличен с 60 до 120 секунд для больших ответов - **Foreground Service** - приложение продолжает работу в фоне при выключенном экране (ожидание ответа API) +- **Уведомление о ответе ИИ** - при получении ответа в фоне показывается системное уведомление с текстом ответа и звуком/вибрацией, нажатие открывает сессию с ответом ### ✅ Security - API ключ: EncryptedSharedPreferences (AES-256-GCM) diff --git a/app/src/main/java/com/mistral/chat/api/ApiForegroundService.kt b/app/src/main/java/com/mistral/chat/api/ApiForegroundService.kt new file mode 100644 index 0000000..bd67c52 --- /dev/null +++ b/app/src/main/java/com/mistral/chat/api/ApiForegroundService.kt @@ -0,0 +1,66 @@ +package com.mistral.chat.api + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.mistral.chat.R + +class ApiForegroundService : Service() { + + companion object { + const val CHANNEL_ID = "api_service_channel" + const val NOTIFICATION_ID = 1002 // Different from AI response notification + + fun start(context: Context) { + val intent = Intent(context, ApiForegroundService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + val intent = Intent(context, ApiForegroundService::class.java) + context.stopService(intent) + } + } + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Mistral Chat") + .setContentText("Получение ответа от AI...") + .setSmallIcon(android.R.drawable.ic_menu_send) + .setOngoing(true) + .build() + startForeground(NOTIFICATION_ID, notification) + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + super.onDestroy() + stopForeground(STOP_FOREGROUND_REMOVE) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "API Service", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Уведомление о работе API" + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + } +} \ 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 36cddc8..7759c13 100644 --- a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt +++ b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt @@ -1,6 +1,10 @@ package com.mistral.chat.ui +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager import android.os.Build @@ -20,6 +24,7 @@ import android.widget.EditText import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate +import androidx.core.app.NotificationCompat import androidx.core.view.GravityCompat import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope @@ -99,6 +104,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private var userScrolledAfterSend = false private var lastUserMessagePosition = -1 private var apiKeyDialog: AlertDialog? = null + private var leftAppDuringApiCall = false + private var notificationSessionId: Long = -1L companion object { private const val PREFS_NAME = "mistral_chat_prefs" @@ -110,6 +117,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private const val KEY_DEFAULT_TIMEZONE = "default_timezone" private const val KEY_DEFAULT_CITY = "default_city" private const val MAX_PROFILES = 10 + private const val CHANNEL_ID_AI_RESPONSE = "ai_response_channel" + private const val NOTIFICATION_ID_AI_RESPONSE = 1001 } override fun onCreate(savedInstanceState: Bundle?) { @@ -179,6 +188,18 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte loadProfilesAndSessions() restoreCalDavConnection() + // Обработка session_id из уведомления + intent?.getLongExtra("session_id", -1L)?.let { sessionId -> + if (sessionId > 0) { + lifecycleScope.launch { + val session = database.sessionDao().getSessionById(sessionId) + session?.let { + selectSession(it) + } + } + } + } + inputField.postDelayed({ val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager inputField.requestFocus() @@ -1290,6 +1311,10 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val selectedModel = selectedModelName val sessionIdAtStart = currentSessionId + // Сбрасываем флаг в начале нового запроса + leftAppDuringApiCall = false + notificationSessionId = currentSessionId ?: -1L + sendButton.isEnabled = false progressIndicator.isVisible = true @@ -1504,7 +1529,15 @@ if (!isActive) return@launch Toast.makeText(this@MainActivity, responseToShow, Toast.LENGTH_LONG).show() } } - + + // Если пользователь ушел из приложения пока готовился ответ - показываем пуш + if (leftAppDuringApiCall) { + showBackgroundNotification(responseToShow) + } + + // Сбрасываем флаг после обработки + leftAppDuringApiCall = false + sendButton.isEnabled = true progressIndicator.isVisible = false @@ -1693,4 +1726,65 @@ if (!isActive) return@launch else -> "❌ Произошла ошибка: $error" } } + + private fun showBackgroundNotification(response: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val hasPermission = checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + if (!hasPermission) { + return + } + } + + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID_AI_RESPONSE, + "Ответы ИИ", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Уведомления о полученных ответах ИИ" + enableVibration(true) + enableLights(true) + } + notificationManager.createNotificationChannel(channel) + } + + val title = "Ответ ИИ получен" + val body = if (response.length > 100) response.take(100) + "..." else response + + val intent = Intent(applicationContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra("session_id", notificationSessionId) + } + val pendingIntent = PendingIntent.getActivity( + applicationContext, + notificationSessionId.toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID_AI_RESPONSE) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(response)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .setContentIntent(pendingIntent) + .build() + + notificationManager.notify(NOTIFICATION_ID_AI_RESPONSE, notification) + } + + override fun onPause() { + super.onPause() + leftAppDuringApiCall = true + } + + override fun onResume() { + super.onResume() + leftAppDuringApiCall = false + } } \ No newline at end of file