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] 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() } }