Fix timeout issues: increased to 60s, add retry on CANCEL, default model Medium
This commit is contained in:
parent
ae5907c45f
commit
cabc6b8d85
3 changed files with 252 additions and 92 deletions
55
AGENTS.md
55
AGENTS.md
|
|
@ -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*
|
||||||
|
|
@ -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,61 +68,17 @@ 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
|
||||||
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 cancelRequest() {
|
fun cancelRequest() {
|
||||||
|
|
@ -124,11 +94,55 @@ class MistralClient(private val apiKey: String) {
|
||||||
client = createNewClient()
|
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(
|
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"}"""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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,25 +1114,53 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
while (iteration < maxIterations) {
|
while (iteration < maxIterations) {
|
||||||
iteration++
|
iteration++
|
||||||
|
|
||||||
val result = withTimeout(30000L) {
|
var result: Result<ChatResponse>? = null
|
||||||
client?.chat(selectedModel, apiMessages, tools)
|
var retryCount = 0
|
||||||
?: Result.failure(Exception("Client not initialized"))
|
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
|
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()) {
|
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"}"""
|
||||||
|
|
||||||
apiMessages.add(Message(
|
// Если tool вернул ошибку - добавляем, но не накапливаем
|
||||||
content = """[${toolCall.name}] result: $toolResult""",
|
if (!toolResult.contains("error")) {
|
||||||
isUser = true,
|
apiMessages.add(Message(
|
||||||
role = "user"
|
content = """[${toolCall.name}] result: $toolResult""",
|
||||||
))
|
isUser = true,
|
||||||
|
role = "user"
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Продолжаем цикл - AI решит нужен ли еще поиск
|
// Продолжаем цикл - AI решит нужен ли еще поиск
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1129,15 +1168,21 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
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,22 +1196,30 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
|
|
||||||
// Проверяем что sessionId не изменился пока работал запрос
|
// Проверяем что sessionId не изменился пока работал запрос
|
||||||
if (currentSessionId == sessionIdAtStart) {
|
if (currentSessionId == sessionIdAtStart) {
|
||||||
addMessage(Message(content = responseToShow, isUser = false, senderName = selectedModel), sessionIdAtStart)
|
// НЕ добавляем сообщения об ошибках в БД - они портят контекст
|
||||||
|
val isError = responseToShow.contains("Timed out") ||
|
||||||
|
responseToShow.contains("таймаут") ||
|
||||||
|
responseToShow.startsWith("Ошибка:")
|
||||||
|
|
||||||
// Прокрутка к началу нового сообщения ИИ
|
if (!isError) {
|
||||||
recyclerView.post {
|
addMessage(Message(content = responseToShow, isUser = false, senderName = selectedModel), sessionIdAtStart)
|
||||||
if (!userScrolledAfterSend) {
|
|
||||||
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
|
// Прокрутка к началу нового сообщения ИИ
|
||||||
layoutManager.scrollToPositionWithOffset(messages.size - 1, 0)
|
recyclerView.post {
|
||||||
|
if (!userScrolledAfterSend) {
|
||||||
|
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
|
||||||
|
layoutManager.scrollToPositionWithOffset(messages.size - 1, 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!responseToShow.startsWith("Ошибка:")) {
|
|
||||||
// Генерируем название сессии после второго сообщения
|
// Генерируем название сессии после второго сообщения
|
||||||
userMessageCount++
|
userMessageCount++
|
||||||
if (userMessageCount == 2) {
|
if (userMessageCount == 2) {
|
||||||
generateSessionTitle()
|
generateSessionTitle()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Показываем ошибку пользователю через Toast
|
||||||
|
Toast.makeText(this@MainActivity, responseToShow, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue