Fix timeout issues: increased to 60s, add retry on CANCEL, default model Medium

This commit is contained in:
Алексей Будаев 2026-04-10 20:14:17 +08:00
parent ae5907c45f
commit cabc6b8d85
3 changed files with 252 additions and 92 deletions

View file

@ -95,7 +95,8 @@ web_search НЕ является интерфейсом поисковика и
**Tool Loop (MainActivity):** **Tool Loop (MainActivity):**
- Максимум итераций: 15 - Максимум итераций: 15
- Timeout на итерацию: 30 секунд - Timeout на итерацию: 60 секунд
- Retry (до 2 попыток) при ошибке "stream was reset: CANCEL"
- AI может сделать несколько последовательных поисков если нужно - AI может сделать несколько последовательных поисков если нужно
### 🌤️ Weather Tool (БЕСПЛАТНОЕ решение) ### 🌤️ Weather Tool (БЕСПЛАТНОЕ решение)
@ -201,6 +202,8 @@ Kai имеет отличную документацию по tools: https://kai
- LEARNING — выводы и паттерны - LEARNING — выводы и паттерны
- ERROR — известные ошибки - ERROR — известные ошибки
- PREFERENCE — предпочтения пользователя - PREFERENCE — предпочтения пользователя
- REMINDER_CAL — напоминания календаря (local mode)
- CALENDAR_ERROR — ошибки подключения CalDAV
**Prompt injection:** **Prompt injection:**
``` ```
@ -240,6 +243,29 @@ Kai имеет отличную документацию по tools: https://kai
| Safety | ✅ Max iterations (15), timeout (30s), result truncation (2000 chars) | | Safety | ✅ Max iterations (15), timeout (30s), result truncation (2000 chars) |
| **OpenUrlTool (RSS)** | ✅ Автоматическое определение и парсинг RSS/Atom | | **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-ленты (протестировано):** **RSS-ленты (протестировано):**
- lenta.ru/rss/ ✅ - lenta.ru/rss/ ✅
- kommersant.ru/rss/news.xml ✅ - 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 дня **Оценка:** 2-3 дня
Автономная периодическая самопроверка: Автономная периодическая самопроверка:
@ -315,6 +350,16 @@ app/build/outputs/apk/debug/app-debug.apk
### Current Issues ### Current Issues
- Кнопка STOP не работает (требует streaming mode) - Кнопка 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* *Last updated: 2026-04-10*
*Version: 1.9* *Version: 1.10*

View file

@ -1,8 +1,9 @@
package com.mistral.chat.api package com.mistral.chat.api
import com.google.gson.JsonObject import android.util.Log
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonArray import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.mistral.chat.data.Message import com.mistral.chat.data.Message
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@ -18,15 +19,28 @@ import kotlin.coroutines.Continuation
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
data class ChatResponse(
val content: String,
val usedModel: String,
val toolCalls: List<ToolCall>
)
data class ToolCall(
val id: String,
val name: String,
val arguments: JsonObject
)
class MistralClient(private val apiKey: String) { class MistralClient(private val apiKey: String) {
private var client = createNewClient() private var client = createNewClient()
private var toolExecutor: ToolExecutor? = null
private fun createNewClient(): OkHttpClient { private fun createNewClient(): OkHttpClient {
return OkHttpClient.Builder() return OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS) .connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(25, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS)
.retryOnConnectionFailure(false) .retryOnConnectionFailure(false)
.build() .build()
} }
@ -35,7 +49,7 @@ class MistralClient(private val apiKey: String) {
private val jsonMediaType = "application/json".toMediaType() private val jsonMediaType = "application/json".toMediaType()
private var currentCall: Call? = null private var currentCall: Call? = null
private var currentContinuation: Continuation<Result<Pair<String, String>>>? = null private var currentContinuation: Continuation<Result<ChatResponse>>? = null
companion object { companion object {
private const val BASE_URL = "https://api.mistral.ai/v1" private const val BASE_URL = "https://api.mistral.ai/v1"
@ -54,16 +68,33 @@ class MistralClient(private val apiKey: String) {
) )
val AVAILABLE_MODELS = listOf( val AVAILABLE_MODELS = listOf(
"mistral-small-latest" to "Mistral Small",
"mistral-medium-latest" to "Mistral Medium",
"mistral-large-latest" to "Mistral Large", "mistral-large-latest" to "Mistral Large",
"mistral-medium-latest" to "Mistral Medium",
"codestral-latest" to "Codestral", "codestral-latest" to "Codestral",
"pixtral-large-latest" to "Pixtral Large" "pixtral-large-latest" to "Pixtral Large"
) )
const val DEFAULT_MODEL = "mistral-medium-latest"
} }
suspend fun getModels(): Result<List<Pair<String, String>>> = withContext(Dispatchers.IO) { fun setToolExecutor(executor: ToolExecutor) {
try { this.toolExecutor = executor
}
fun cancelRequest() {
val call = currentCall
val continuation = currentContinuation
call?.cancel()
currentCall = null
continuation?.resume(Result.failure(Exception("Request cancelled")))
currentContinuation = null
client = createNewClient()
}
suspend fun getModels(): Result<List<Pair<String, String>>> = try {
val request = Request.Builder() val request = Request.Builder()
.url("$BASE_URL/models") .url("$BASE_URL/models")
.addHeader("Authorization", "Bearer $apiKey") .addHeader("Authorization", "Bearer $apiKey")
@ -72,7 +103,7 @@ class MistralClient(private val apiKey: String) {
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
return@withContext Result.failure(Exception("API error: ${response.code}")) return Result.failure(Exception("API error: ${response.code}"))
} }
val responseBody = response.body?.string() ?: "" val responseBody = response.body?.string() ?: ""
@ -103,32 +134,15 @@ class MistralClient(private val apiKey: String) {
Result.success(models) Result.success(models)
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is java.io.IOException && !e.message.orEmpty().contains("cancel", ignoreCase = true)) {
Result.failure(e) Result.failure(e)
} else {
Result.failure(Exception("Request cancelled"))
}
}
}
fun cancelRequest() {
val call = currentCall
val continuation = currentContinuation
call?.cancel()
currentCall = null
continuation?.resume(Result.failure(Exception("Request cancelled")))
currentContinuation = null
client = createNewClient()
} }
suspend fun chat( suspend fun chat(
model: String, model: String,
messages: List<Message>, messages: List<Message>,
tools: List<JsonObject>? = null,
onChunk: ((String) -> Unit)? = null onChunk: ((String) -> Unit)? = null
): Result<Pair<String, String>> = withContext(Dispatchers.IO) { ): Result<ChatResponse> = withContext(Dispatchers.IO) {
try { try {
val jsonObject = JsonObject() val jsonObject = JsonObject()
jsonObject.addProperty("model", model) jsonObject.addProperty("model", model)
@ -138,15 +152,30 @@ class MistralClient(private val apiKey: String) {
val messagesArray = JsonArray() val messagesArray = JsonArray()
messages.forEach { msg -> messages.forEach { msg ->
val msgObj = JsonObject() val msgObj = JsonObject()
msgObj.addProperty("role", if (msg.isUser) "user" else "assistant") msgObj.addProperty("role", msg.role)
msgObj.addProperty("content", msg.content) msgObj.addProperty("content", msg.content)
messagesArray.add(msgObj) messagesArray.add(msgObj)
} }
jsonObject.add("messages", messagesArray) 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 json = gson.toJson(jsonObject)
val body = json.toRequestBody(jsonMediaType) 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() val request = Request.Builder()
.url("$BASE_URL/chat/completions") .url("$BASE_URL/chat/completions")
.addHeader("Authorization", "Bearer $apiKey") .addHeader("Authorization", "Bearer $apiKey")
@ -156,11 +185,12 @@ class MistralClient(private val apiKey: String) {
currentCall = client.newCall(request) currentCall = client.newCall(request)
suspendCancellableCoroutine { continuation -> val result = suspendCancellableCoroutine<Result<ChatResponse>> { continuation ->
currentContinuation = continuation currentContinuation = continuation
currentCall?.enqueue(object : okhttp3.Callback { currentCall?.enqueue(object : okhttp3.Callback {
override fun onFailure(call: okhttp3.Call, e: java.io.IOException) { override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
Log.e("MistralClient", "onFailure: ${e.message}", e)
val cont = currentContinuation val cont = currentContinuation
currentCall = null currentCall = null
currentContinuation = null currentContinuation = null
@ -193,6 +223,8 @@ class MistralClient(private val apiKey: String) {
val responseBody = response.body?.string() ?: "" val responseBody = response.body?.string() ?: ""
Log.d("MistralClient", "Response: code=${response.code}, len=${responseBody.length}")
if (onChunk != null) { if (onChunk != null) {
onChunk(responseBody) onChunk(responseBody)
} }
@ -208,6 +240,7 @@ class MistralClient(private val apiKey: String) {
val choices = responseJson.getAsJsonArray("choices") val choices = responseJson.getAsJsonArray("choices")
if (choices == null || choices.size() == 0) { if (choices == null || choices.size() == 0) {
Log.e("MistralClient", "No choices in response: $responseBody")
currentCall = null currentCall = null
currentContinuation = null currentContinuation = null
cont.resume(Result.failure(Exception("No response from API"))) 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 val firstChoice = choices.get(0)?.asJsonObject
if (firstChoice == null) { if (firstChoice == null) {
Log.e("MistralClient", "First choice is null")
currentCall = null currentCall = null
currentContinuation = null currentContinuation = null
cont.resume(Result.failure(Exception("Empty choice"))) cont.resume(Result.failure(Exception("Empty choice")))
@ -227,9 +261,31 @@ class MistralClient(private val apiKey: String) {
val usedModel = responseJson.get("model")?.asString ?: model val usedModel = responseJson.get("model")?.asString ?: model
val toolCalls = mutableListOf<ToolCall>()
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 currentCall = null
currentContinuation = null currentContinuation = null
cont.resume(Result.success(content to usedModel)) cont.resume(Result.success(ChatResponse(content, usedModel, toolCalls)))
} catch (e: Exception) { } catch (e: Exception) {
currentCall = null currentCall = null
currentContinuation = null currentContinuation = null
@ -245,8 +301,14 @@ class MistralClient(private val apiKey: String) {
client = createNewClient() client = createNewClient()
} }
} }
result
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
} }
} }
suspend fun executeTool(name: String, arguments: JsonObject): String {
return toolExecutor?.executeTool(name, arguments)
?: """{"status": "error", "message": "Tool executor not initialized"}"""
}
} }

View file

@ -2,6 +2,7 @@ package com.mistral.chat.ui
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent 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.android.material.button.MaterialButton
import com.google.gson.Gson import com.google.gson.Gson
import com.mistral.chat.R import com.mistral.chat.R
import com.mistral.chat.api.ChatResponse
import com.mistral.chat.api.MistralClient import com.mistral.chat.api.MistralClient
import com.mistral.chat.api.ToolExecutor import com.mistral.chat.api.ToolExecutor
import com.mistral.chat.data.ChatDatabase import com.mistral.chat.data.ChatDatabase
@ -117,6 +119,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
gson = Gson() gson = Gson()
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) 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) val savedModel = prefs.getString(KEY_SELECTED_MODEL, null)
if (savedModel != null) { if (savedModel != null) {
selectedModelName = savedModel selectedModelName = savedModel
@ -879,9 +889,10 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL) val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL)
if (!hasUserSelectedModel) { if (!hasUserSelectedModel) {
runOnUiThread { runOnUiThread {
val codestralIndex = models.indexOfFirst { it.first.contains("codestral", ignoreCase = true) } // По умолчанию выбираем medium, иначе первую доступную
if (codestralIndex >= 0) { val mediumIndex = models.indexOfFirst { it.first.contains("mistral-medium", ignoreCase = true) }
selectedModelName = models[codestralIndex].first if (mediumIndex >= 0) {
selectedModelName = models[mediumIndex].first
} else if (models.isNotEmpty()) { } else if (models.isNotEmpty()) {
selectedModelName = models[0].first selectedModelName = models[0].first
} }
@ -892,7 +903,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL) val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL)
if (!hasUserSelectedModel) { if (!hasUserSelectedModel) {
runOnUiThread { runOnUiThread {
selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-medium-latest" selectedModelName = MistralClient.DEFAULT_MODEL
} }
} }
} }
@ -1103,41 +1114,75 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
while (iteration < maxIterations) { while (iteration < maxIterations) {
iteration++ iteration++
val result = withTimeout(30000L) { var result: Result<ChatResponse>? = null
var retryCount = 0
val maxRetries = 2
//Retry при CANCEL ошибке
while (retryCount <= maxRetries) {
result = withTimeout(60000L) {
client?.chat(selectedModel, apiMessages, tools) client?.chat(selectedModel, apiMessages, tools)
?: Result.failure(Exception("Client not initialized")) ?: Result.failure(Exception("Client not initialized"))
} }
if (!isActive) return@launch if (!isActive) return@launch
result.onSuccess { chatResponse -> 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 (chatResponse.toolCalls.isNotEmpty()) { if (chatResponse.toolCalls.isNotEmpty()) {
// Выполняем все tool calls и добавляем результаты в историю // Выполняем все tool calls и добавляем результаты в историю
for (toolCall in chatResponse.toolCalls) { for (toolCall in chatResponse.toolCalls) {
val toolResult = client?.executeTool(toolCall.name, toolCall.arguments) val toolResult = client?.executeTool(toolCall.name, toolCall.arguments)
?: """{"status": "error", "message": "Tool failed"}""" ?: """{"status": "error", "message": "Tool failed"}"""
// Если tool вернул ошибку - добавляем, но не накапливаем
if (!toolResult.contains("error")) {
apiMessages.add(Message( apiMessages.add(Message(
content = """[${toolCall.name}] result: $toolResult""", content = """[${toolCall.name}] result: $toolResult""",
isUser = true, isUser = true,
role = "user" role = "user"
)) ))
} }
}
// Продолжаем цикл - AI решит нужен ли еще поиск // Продолжаем цикл - AI решит нужен ли еще поиск
} else { } else {
// Нет tool calls - это финальный ответ // Нет tool calls - это финальный ответ
finalResponse = chatResponse.content finalResponse = chatResponse.content
} }
}.onFailure { error -> }.onFailure { error ->
finalResponse = "Ошибка: ${error.message}" val errorMsg = error.message ?: "Unknown error"
} android.util.Log.e("MainActivity", "API error: $errorMsg, iteration: $iteration")
// Если есть финальный ответ или превышен лимит - выходим // При ошибке - показываем её и выходим
if (finalResponse != null || iteration >= maxIterations) { finalResponse = "Ошибка: $errorMsg"
}
} // close else block for result == null check
// Выходим только если есть ответ
if (finalResponse != null) {
break break
} }
} }
// Если прошли все итерации без ответа
if (finalResponse == null && iteration >= maxIterations) { if (finalResponse == null && iteration >= maxIterations) {
finalResponse = "Превышен лимит итераций (${maxIterations}). Попробуйте более конкретный запрос." finalResponse = "Превышен лимит итераций (${maxIterations}). Попробуйте более конкретный запрос."
} }
@ -1151,6 +1196,12 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
// Проверяем что sessionId не изменился пока работал запрос // Проверяем что sessionId не изменился пока работал запрос
if (currentSessionId == sessionIdAtStart) { if (currentSessionId == sessionIdAtStart) {
// НЕ добавляем сообщения об ошибках в БД - они портят контекст
val isError = responseToShow.contains("Timed out") ||
responseToShow.contains("таймаут") ||
responseToShow.startsWith("Ошибка:")
if (!isError) {
addMessage(Message(content = responseToShow, isUser = false, senderName = selectedModel), sessionIdAtStart) addMessage(Message(content = responseToShow, isUser = false, senderName = selectedModel), sessionIdAtStart)
// Прокрутка к началу нового сообщения ИИ // Прокрутка к началу нового сообщения ИИ
@ -1161,12 +1212,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
} }
} }
if (!responseToShow.startsWith("Ошибка:")) {
// Генерируем название сессии после второго сообщения // Генерируем название сессии после второго сообщения
userMessageCount++ userMessageCount++
if (userMessageCount == 2) { if (userMessageCount == 2) {
generateSessionTitle() generateSessionTitle()
} }
} else {
// Показываем ошибку пользователю через Toast
Toast.makeText(this@MainActivity, responseToShow, Toast.LENGTH_LONG).show()
} }
} }