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):**
- Максимум итераций: 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*
*Version: 1.10*

View file

@ -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<ToolCall>
)
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<Result<Pair<String, String>>>? = null
private var currentContinuation: Continuation<Result<ChatResponse>>? = 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<List<Pair<String, String>>> = 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<List<Pair<String, String>>> = 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<Message>,
tools: List<JsonObject>? = null,
onChunk: ((String) -> Unit)? = null
): Result<Pair<String, String>> = withContext(Dispatchers.IO) {
): Result<ChatResponse> = withContext(Dispatchers.IO) {
try {
val jsonObject = JsonObject()
jsonObject.addProperty("model", model)
@ -138,15 +152,30 @@ 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")
.addHeader("Authorization", "Bearer $apiKey")
@ -156,11 +185,12 @@ class MistralClient(private val apiKey: String) {
currentCall = client.newCall(request)
suspendCancellableCoroutine { continuation ->
val result = suspendCancellableCoroutine<Result<ChatResponse>> { 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
@ -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")))
@ -227,9 +261,31 @@ class MistralClient(private val apiKey: String) {
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
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"}"""
}
}

View file

@ -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
@ -117,6 +119,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) {
selectedModelName = savedModel
@ -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
}
}
}
@ -1103,25 +1114,53 @@ 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<ChatResponse>? = 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")
// Если есть финальный ответ или превышен лимит - выходим
if (finalResponse != null || iteration >= maxIterations) {
// При ошибке - показываем её и выходим
finalResponse = "Ошибка: $errorMsg"
}
} // close else block for result == null check
// Выходим только если есть ответ
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()
}
}