diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 175e05c..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,279 +0,0 @@ -# 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) -- Прокрутка к новым сообщениям -- **Долгий тап на сообщение** - меню Копировать/Редактировать/Удалить -- **Скролл в поле 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), ключи БД, токены - ---- - -## Tools - -### 🌐 Web Search -- ✅ Russian Wikipedia API (бесплатно, без API ключа) -- ✅ English Wikipedia API -- Ограничение: 4000 символов на ответ - -### 🌤️ Weather -- ✅ Open-Meteo API (полностью бесплатно) -- Geocoding API + Weather API -- Текущая погода + прогноз на 7 дней - -### 🔗 OpenUrlTool -- ✅ HTTP GET к любому URL -- ✅ RSS/Atom парсинг (lenta.ru, kommersant.ru) -- Ограничение: 4000 символов, таймаут 10 сек - -### ⏰ Time Tools -- ✅ get_local_time - возвращает timestamp в миллисекундах -- ✅ get_date - текущая дата - -### 📅 CalDAV Calendar -- ✅ calendar_add_event - создание событий с VALARM (уведомления) -- ✅ calendar_get_events - получение списка событий -- ✅ calendar_delete_event - удаление событий -- Баikal сервер интеграция - -### 💾 Memory Tools -- ✅ memory_store, memory_learn, memory_forget -- ✅ memory_reinforce, memory_preference - ---- - -## Tool Execution Parameters - -| Параметр | Значение | -|----------|----------| -| Max iterations | 15 | -| Timeout на итерацию | 120 сек | -| Retry при CANCEL | до 2 раз | -| Result truncation | 2000 символов | -| WakeLock | ✅ для длительных запросов | - ---- - -## Active Plan - -### Phase 3: CalDAV Calendar + Local Reminders (✅ В основном готово) - -**Два режима:** -1. **CalDAV** — синхронизация с Baikal сервером -2. **Local** — автономная напоминалка в памяти AI - -**CalDAV Status:** -| Задача | Статус | -|--------|--------| -| Подключение к Baikal | ✅ | -| calendar_add_event | ✅ Работает (с VALARM) | -| calendar_get_events | ✅ Работает (лимит 100 событий) | -| calendar_delete_event | ✅ Работает (только свои события) | -| VALARM (уведомления) | ✅ Добавляются к событиям | -| UID consistency | ✅ Исправлено | -| Timestamp (time_string) | ✅ AI передаёт строку, сервер парсит | - -**Нерешённые проблемы:** -- При переустановке app старые события становятся "чужими" (новый UUID) - -**Как помочь 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) - -### Phase 5: Email (⏳ В очереди) -- IMAP/SMTP клиент (без OAuth) - -### 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 или новое диалоговое окно - ---- - -## 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` — база данных - -### Model Selection -- **Default:** mistral-medium-latest -- **OkHttp timeouts:** connect 60s, read 120s, write 60s - ---- - -## ⚠️ ВАЖНЫЕ ПРАВИЛА РАЗРАБОТКИ - -### Запрет на удаление реализованных функций -**НИКОГДА не удаляй уже реализованные функции!** - -### Запрет на хардкодинг -**НИКОГДА не хардкодь значения, которые должны быть динамическими!** - -### Сборка APK -```bash -JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug -# Путь: app/build/outputs/apk/debug/app-debug.apk -``` - ---- - -## 📋 Контекст сессии и оптимизация (📋 Запланировано) - -### Текущая реализация -При каждом запросе отправляется полный контекст. При росте сессии возможны 503 ошибки. - -### План реализации (Kai 9000 style) - -**Источник:** 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() - } -} -``` - -### Files to modify -- ToolExecutor.kt - добавить trimming в loop -- MistralClient.kt - добавить getTokenCount, trimOldMessages - ---- - -## Conversation Context (for AI Agent) - -**При начале новой сессии:** -Прочитай файл AGENTS.md для понимания текущего контекста. - -**При запросе "продолжаем":** -Мы работаем над Phase 3 - CalDAV календарь. Тестируем: создание событий, получение списка, исправление timezone. - -**Важно:** -- Пушить в GitHub только после подтверждения пользователя -- Не делать push автоматически - ---- - -*Last updated: 2026-04-12* -*Version: 1.11* \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index b13c057..c4df6ef 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,7 +1,6 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' - id 'com.google.devtools.ksp' version '1.9.22-1.0.17' } android { @@ -41,20 +40,8 @@ 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' - - 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/api/ApiForegroundService.kt b/app/src/main/java/com/mistral/chat/api/ApiForegroundService.kt deleted file mode 100644 index bd67c52..0000000 --- a/app/src/main/java/com/mistral/chat/api/ApiForegroundService.kt +++ /dev/null @@ -1,66 +0,0 @@ -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/api/MistralClient.kt b/app/src/main/java/com/mistral/chat/api/MistralClient.kt index f7125f2..c76aa21 100644 --- a/app/src/main/java/com/mistral/chat/api/MistralClient.kt +++ b/app/src/main/java/com/mistral/chat/api/MistralClient.kt @@ -1,109 +1,54 @@ package com.mistral.chat.api -import android.util.Log +import com.google.gson.JsonObject 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 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 -data class ChatResponse( - val content: String, - val usedModel: String, - val toolCalls: List -) + class MistralClient(private val apiKey: String) { -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(60, TimeUnit.SECONDS) - .readTimeout(120, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) - .retryOnConnectionFailure(false) - .build() - } + private val client = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .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", - "mistral-medium-latest" to "Mistral Medium", - "codestral-latest" to "Codestral", - "pixtral-large-latest" to "Pixtral Large" + "codestral-latest" to "Codestral" ) - - const val DEFAULT_MODEL = "mistral-medium-latest" } - fun setToolExecutor(executor: ToolExecutor) { - this.toolExecutor = executor - } + suspend fun getModels(): Result>> = withContext(Dispatchers.IO) { + try { + val request = Request.Builder() + .url("$BASE_URL/models") + .addHeader("Authorization", "Bearer $apiKey") + .get() + .build() - 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>> = try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - client.newCall(request).execute().use { response -> + val response = client.newCall(request).execute() + if (!response.isSuccessful) { - return Result.failure(Exception("API error: ${response.code}")) + return@withContext Result.failure(Exception("API error: ${response.code}")) } val responseBody = response.body?.string() ?: "" @@ -112,62 +57,53 @@ class MistralClient(private val apiKey: String) { 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() + 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) } - } catch (e: Exception) { - Result.failure(e) + } + + fun cancelRequest() { + currentCall?.cancel() + currentCall = null } 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) jsonObject.addProperty("temperature", 0.7) - jsonObject.addProperty("stream", false) + jsonObject.addProperty("stream", onChunk != null) val messagesArray = JsonArray() messages.forEach { msg -> val msgObj = JsonObject() - msgObj.addProperty("role", msg.role) + msgObj.addProperty("role", if (msg.isUser) "user" else "assistant") 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) - + val request = Request.Builder() .url("$BASE_URL/chat/completions") .addHeader("Authorization", "Bearer $apiKey") @@ -176,129 +112,43 @@ class MistralClient(private val apiKey: String) { .build() currentCall = client.newCall(request) - - 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 - if (cont != null) { - if (call.isCanceled()) { - cont.resume(Result.failure(Exception("Request cancelled"))) - } else { - cont.resume(Result.failure(e)) - } - } - } - - 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 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) { - Log.e("MistralClient", "No choices in response: $responseBody") - currentCall = null - currentContinuation = null - cont.resume(Result.failure(Exception("No response from API"))) - return - } - - 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"))) - return - } - - val message = firstChoice.getAsJsonObject("message") - 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(ChatResponse(content, usedModel, toolCalls))) - } catch (e: Exception) { - currentCall = null - currentContinuation = null - cont.resume(Result.failure(e)) - } - } - }) - - continuation.invokeOnCancellation { - currentCall?.cancel() - currentCall = null - currentContinuation = null - client = createNewClient() - } + val response = currentCall!!.execute() + + if (response.code == 0 || response.code == -1) { + return@withContext Result.failure(Exception("Request cancelled")) } - result + + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "Unknown error" + return@withContext Result.failure(Exception("API error: ${response.code} - $errorBody")) + } + + val responseBody = response.body?.string() ?: "" + + if (onChunk != null) { + onChunk(responseBody) + } + + 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 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) } } - - 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/api/NotificationTool.kt b/app/src/main/java/com/mistral/chat/api/NotificationTool.kt deleted file mode 100644 index 9c117ae..0000000 --- a/app/src/main/java/com/mistral/chat/api/NotificationTool.kt +++ /dev/null @@ -1,82 +0,0 @@ -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/api/TimeTools.kt b/app/src/main/java/com/mistral/chat/api/TimeTools.kt deleted file mode 100644 index 8aa0c89..0000000 --- a/app/src/main/java/com/mistral/chat/api/TimeTools.kt +++ /dev/null @@ -1,488 +0,0 @@ -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/data/ChatDatabase.kt b/app/src/main/java/com/mistral/chat/data/ChatDatabase.kt deleted file mode 100644 index 1a23bc7..0000000 --- a/app/src/main/java/com/mistral/chat/data/ChatDatabase.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.mistral.chat.data - -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( - 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 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() - securePrefs.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/Message.kt b/app/src/main/java/com/mistral/chat/data/Message.kt index febd899..9cd48e6 100644 --- a/app/src/main/java/com/mistral/chat/data/Message.kt +++ b/app/src/main/java/com/mistral/chat/data/Message.kt @@ -1,52 +1,27 @@ 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: Long = 0, - val sessionId: Long = 0, + val id: String = System.currentTimeMillis().toString(), val content: String, val isUser: Boolean, val timestamp: Long = System.currentTimeMillis(), val senderName: String? = null ) -fun MessageEntity.toMessage(): Message = Message( - id = id, - sessionId = sessionId, - content = content, - isUser = isUser, - timestamp = timestamp +data class ChatRequest( + val model: String, + val messages: List, + val temperature: Double = 0.7, + val stream: Boolean = false ) -fun Message.toEntity(): MessageEntity = MessageEntity( - 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 ) \ 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 deleted file mode 100644 index a4bedba..0000000 --- a/app/src/main/java/com/mistral/chat/data/MessageDao.kt +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index cd2fd59..0000000 --- a/app/src/main/java/com/mistral/chat/data/Profile.kt +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 4941ba5..0000000 --- a/app/src/main/java/com/mistral/chat/data/ProfileDao.kt +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 28faa9e..0000000 --- a/app/src/main/java/com/mistral/chat/data/Session.kt +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 63bdaab..0000000 --- a/app/src/main/java/com/mistral/chat/data/SessionDao.kt +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 7530abd..0000000 --- a/app/src/main/java/com/mistral/chat/data/Setting.kt +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 0b06554..0000000 --- a/app/src/main/java/com/mistral/chat/data/SettingDao.kt +++ /dev/null @@ -1,22 +0,0 @@ -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/data/UserProfile.kt b/app/src/main/java/com/mistral/chat/data/UserProfile.kt new file mode 100644 index 0000000..61605c7 --- /dev/null +++ b/app/src/main/java/com/mistral/chat/data/UserProfile.kt @@ -0,0 +1,18 @@ +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/DrawerSubmenuAdapter.kt b/app/src/main/java/com/mistral/chat/ui/DrawerSubmenuAdapter.kt deleted file mode 100644 index 0a6ab8a..0000000 --- a/app/src/main/java/com/mistral/chat/ui/DrawerSubmenuAdapter.kt +++ /dev/null @@ -1,54 +0,0 @@ -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 7759c13..9d51756 100644 --- a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt +++ b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt @@ -1,70 +1,36 @@ 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 import android.os.Bundle -import android.os.PowerManager 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.app.NotificationCompat -import androidx.core.view.GravityCompat 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.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.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 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 -import com.mistral.chat.data.toEntity -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.isActive +import com.mistral.chat.data.UserProfile 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 { +class MainActivity : AppCompatActivity() { private lateinit var recyclerView: RecyclerView private lateinit var adapter: MessageAdapter @@ -74,51 +40,23 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte 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 private var client: MistralClient? = null - private var toolExecutor: ToolExecutor? = null private val messages = mutableListOf() private var availableModels: List> = emptyList() - private var selectedModelName: String = "mistral-medium-latest" + private var selectedModelName: String = "mistral-small-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() - 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 - 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 API_KEY = "YW0IjDBRLuyEBcgNjVeVUFlMI6fcZYLA" private const val PREFS_NAME = "mistral_chat_prefs" - 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 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 - private const val CHANNEL_ID_AI_RESPONSE = "ai_response_channel" - private const val NOTIFICATION_ID_AI_RESPONSE = 1001 + 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" } override fun onCreate(savedInstanceState: Bundle?) { @@ -131,74 +69,22 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte gson = Gson() prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + client = MistralClient(API_KEY) - // Запрос разрешения на уведомления (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 - } - - 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() - } - - database = ChatDatabase.getInstance(this) - memoryRepository = MemoryRepository(database.memoryDao()) - - client = MistralClient(getApiKey()) - toolExecutor = ToolExecutor(memoryRepository, this, getDefaultTimezone(), getDefaultCity()) - client?.setToolExecutor(toolExecutor!!) + 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) - + setupToolbar() setupRecyclerView() - setupDrawer() - setupRightPanel() loadModels() setupInput() - 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 @@ -207,740 +93,44 @@ 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) - - 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_appearance -> { - showThemeDialog() - } - R.id.action_session -> { - showSettingsDialog() - } - R.id.action_location -> { - showLocationDialog() - } - R.id.action_calendar -> { - showCalendarDialog() - } - 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 - memoryRepository.setCurrentProfile(null) - memoryRepository.deleteAnonymous() - 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.session_menu_title) - .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 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) - .setMessage(R.string.about_text) - .setPositiveButton(R.string.ok, null) - .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) - - // Российские города - 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) - .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 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 - logoButton.setOnClickListener { showModelSelectorDialog() } menuButton.setOnClickListener { view -> - 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 + val popup = PopupMenu(this, view) + popup.menuInflater.inflate(R.menu.main_menu, popup.menu) + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.action_profile -> { + showProfileDialog() + true } - memoryRepository.setCurrentProfile(currentProfileId) - 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()) - } - isFirstLoad = false - } else if (currentSessionId != null && !sessions.any { it.id == currentSessionId }) { - if (sessions.isNotEmpty()) { - selectSession(sessions.first()) + R.id.action_about -> { + showAboutDialog() + true } + else -> false } } - } - } - - 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 - 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 - 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 { - memoryRepository.deleteByProfile(profile.id) - database.profileDao().delete(profile) - if (currentProfileId == profile.id) { - currentProfileId = null - memoryRepository.setCurrentProfile(null) - } - } - } - .setNegativeButton(R.string.no, null) - .show() - } - - 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) { - 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) - } - } - } - - 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) + popup.show() } } private fun showModelSelectorDialog() { - 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) + val modelNames = availableModels.map { it.second }.toTypedArray() + val currentModelId = availableModels.find { it.first == selectedModelName }?.second ?: modelNames.firstOrNull() ?: "" + val currentIndex = modelNames.indexOf(currentModelId).coerceAtLeast(0) AlertDialog.Builder(this) .setTitle(R.string.select_model) .setSingleChoiceItems(modelNames, currentIndex) { dialog, which -> - selectedModelName = uniqueModels[which].first - prefs.edit().putString(KEY_SELECTED_MODEL, selectedModelName).apply() + selectedModelName = availableModels[which].first dialog.dismiss() } .setNegativeButton(R.string.cancel, null) @@ -948,76 +138,20 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } private fun setupRecyclerView() { - adapter = MessageAdapter( - messages, - onMessageEdit = { position, message -> editMessage(position, message) }, - onMessageDelete = { position, message -> deleteMessage(position, message) } - ) + adapter = MessageAdapter(messages) recyclerView.layoutManager = LinearLayoutManager(this).apply { 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 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() + if (currentJob?.isActive == true) { + cancelRequest() + } else { + sendInput() + } } inputField.setOnEditorActionListener { _, actionId, event -> @@ -1034,639 +168,166 @@ 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 - } - - // Отменяем предыдущий запрос перед новым - currentJob?.cancel() - - if (currentSessionId == null) { - createNewSessionAndSend(userInput) - return - } - - addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()), currentSessionId) - + addMessage(Message(content = userInput, isUser = true)) inputField.text?.clear() - - userScrolledAfterSend = false - lastUserMessagePosition = messages.size - 1 - - recyclerView.postDelayed({ - 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, - title = "Новая сессия" - ) - 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()), sessionId) - 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 - val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL) - if (!hasUserSelectedModel) { - runOnUiThread { - // По умолчанию выбираем 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 - } + 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 - val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL) - if (!hasUserSelectedModel) { - runOnUiThread { - selectedModelName = MistralClient.DEFAULT_MODEL - } + runOnUiThread { + selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-small-latest" } } } } - private fun addMessage(message: Message, expectedSessionId: Long? = null) { - val targetSessionId = expectedSessionId ?: currentSessionId - + private fun addMessage(message: Message) { messages.add(message) - val newPosition = messages.size - 1 + adapter.notifyItemInserted(messages.size - 1) + saveMessages() - adapter.notifyItemInserted(newPosition) - - if (!message.isUser && !userScrolledAfterSend) { - recyclerView.postDelayed({ - if (!userScrolledAfterSend) { - val layoutManager = recyclerView.layoutManager as LinearLayoutManager - layoutManager.scrollToPositionWithOffset(newPosition, 0) - } - }, 150) - } - - if (targetSessionId != null) { - lifecycleScope.launch { - val entity = MessageEntity( - sessionId = targetSessionId, - content = message.content, - isUser = message.isUser, - timestamp = message.timestamp - ) - database.messageDao().insert(entity) - database.sessionDao().updateTimestamp(targetSessionId) + 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 getApiKey(): String { - return encryptedPrefs.getString(KEY_API_KEY, null) ?: "" - } - - private fun getDefaultTimezone(): String { - return prefs.getString(KEY_DEFAULT_TIMEZONE, "Asia/Irkutsk") ?: "Asia/Irkutsk" - } - - 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) - } - - 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() - } - - private fun showApiKeyDialog() { - val currentKey = getApiKey() - val hasCustomKey = currentKey.isNotEmpty() - 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()) { - 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) - client?.setToolExecutor(toolExecutor!!) - 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() - } - } - .apply { - if (hasCustomKey) { - setNegativeButton(R.string.cancel) { dialog, _ -> - dialog.dismiss() - } - setNeutralButton(R.string.delete) { _, _ -> - deleteApiKey() - } - } - } - .setCancelable(false) - .create() - .also { apiKeyDialog = it } - .show() + private fun saveMessages() { + val json = gson.toJson(messages) + prefs.edit().putString(KEY_MESSAGES, json).apply() } private fun sendMessage(userInput: String) { val selectedModel = selectedModelName - val sessionIdAtStart = currentSessionId - - // Сбрасываем флаг в начале нового запроса - leftAppDuringApiCall = false - notificationSessionId = currentSessionId ?: -1L sendButton.isEnabled = false + sendButton.setImageResource(R.drawable.ic_stop) 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() - val systemPrompt = getSelectedSystemPrompt() - val memoryContext = memoryRepository.buildMemoryContext() - val tools = toolExecutor?.getToolsSchema() + val userProfile = loadUserProfile() + + val profileContext = if (!userProfile.isEmpty()) { + userProfile.toContextString() + } else "" - // Автоматически получаем текущую дату - 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(Message(content = profileContext, isUser = true, role = "user")) - } - - if (memoryContext.isNotEmpty()) { - apiMessages.add(Message(content = memoryContext, isUser = true, role = "user")) - } - - apiMessages.addAll(messages.map { msg -> + val apiMessages = messages.map { msg -> Message( content = msg.content, - isUser = msg.isUser, - role = if (msg.isUser) "user" else "assistant" + isUser = msg.isUser ) - }) + }.toMutableList() - // Tool loop - до 15 итераций - var iteration = 0 - var repeatCount = 0 - var lastToolCalls: List = emptyList() - val maxIterations = 15 - var finalResponse: String? = null - - while (iteration < maxIterations) { - iteration++ - - 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(apiTimeout) { - 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++ - 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 -> - - 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"}""" - - // Все результаты добавляем в историю - 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 = "Готово! Событие добавлено в календарь." - } - } - - // Если write-операция выполнена - выходим из цикла - if (writeOperationCompleted) { - finalResponse = writeOperationMessage - // Выходим из while цикла - return@onSuccess - } - - // Продолжаем цикл только если не было write-операции - } else { - // Нет tool calls - это финальный ответ - finalResponse = chatResponse.content - } - }.onFailure { error -> - 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) { - break - } + if (profileContext.isNotEmpty()) { + apiMessages.add(0, Message(content = profileContext, isUser = true)) } - // Если прошли все итерации без ответа - if (finalResponse == null && iteration >= maxIterations) { - finalResponse = "Превышен лимит итераций (${maxIterations}). Попробуйте более конкретный запрос." - } + val result = client?.chat(selectedModel, apiMessages) - // Показываем финальный ответ - if (finalResponse.isNullOrEmpty()) { - finalResponse = "Не удалось получить ответ. Попробуйте ещё раз." - } - - val responseToShow = finalResponse!! - - // Проверяем что sessionId не изменился пока работал запрос - if (currentSessionId == sessionIdAtStart) { - // НЕ добавляем сообщения об ошибках в БД - они портят контекст - // Проверяем более строго - только явные ошибки, а не просто упоминание в тексте - 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) - - // Прокрутка к началу нового сообщения ИИ - recyclerView.post { - if (!userScrolledAfterSend) { - val layoutManager = recyclerView.layoutManager as LinearLayoutManager - layoutManager.scrollToPositionWithOffset(messages.size - 1, 0) - } - } - - // Генерируем название сессии после второго сообщения - userMessageCount++ - if (userMessageCount == 2) { - generateSessionTitle() - } - } else { - // Показываем ошибку пользователю через Toast - Toast.makeText(this@MainActivity, responseToShow, Toast.LENGTH_LONG).show() - } - } - - // Если пользователь ушел из приложения пока готовился ответ - показываем пуш - if (leftAppDuringApiCall) { - showBackgroundNotification(responseToShow) - } - - // Сбрасываем флаг после обработки - leftAppDuringApiCall = false - - 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) { - addMessage(Message(content = "Произошла ошибка: ${e.message}", isUser = false, senderName = "Error"), sessionIdAtStart) + 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")) } + } catch (e: kotlinx.coroutines.CancellationException) { + addMessage(Message(content = "❌ Отменено пользователем", isUser = false, senderName = "Cancelled")) + } finally { sendButton.isEnabled = true + sendButton.setImageResource(R.drawable.ic_mistral_logo) progressIndicator.isVisible = false } } } - - 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 cancelRequest() { + currentJob?.cancel() + client?.cancelRequest() } - 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 loadUserProfile(): UserProfile { + return UserProfile( + name = prefs.getString(KEY_USER_NAME, "") ?: "", + bio = prefs.getString(KEY_USER_BIO, "") ?: "", + preferences = prefs.getString(KEY_USER_PREFS, "") ?: "" + ) } - private fun getCurrentProfileName(): String { - if (currentProfileId == null) return "Вы" - val profileName = profiles.find { it.id == currentProfileId }?.name - return if (profileName.isNullOrBlank()) "Вы" else profileName + 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 showProfileDialog(existingProfile: Profile? = null) { - if (existingProfile == null && profiles.size >= MAX_PROFILES) { - Toast.makeText(this, "Максимум $MAX_PROFILES профилей", Toast.LENGTH_SHORT).show() - return - } - + 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() 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) - 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)) - } - } + nameInput.setText(profile.name) + bioInput.setText(profile.bio) + preferencesInput.setText(profile.preferences) - val dialog = AlertDialog.Builder(this) - .setTitle(if (existingProfile != null) R.string.profile_title else R.string.new_profile) + AlertDialog.Builder(this) .setView(dialogView) .setPositiveButton(R.string.save) { _, _ -> - 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() ?: "", - 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() ?: "", - systemPrompt = systemPromptInput.text?.toString()?.ifEmpty { defaultSystemPrompt } ?: defaultSystemPrompt - )) - if (currentProfileId == null) { - currentProfileId = newId - memoryRepository.setCurrentProfile(newId) - } - } - } - } + val newProfile = UserProfile( + name = nameInput.text?.toString() ?: "", + bio = bioInput.text?.toString() ?: "", + preferences = preferencesInput.text?.toString() ?: "" + ) + saveUserProfile(newProfile) } .setNegativeButton(R.string.cancel, null) - - if (existingProfile != null) { - dialog.setNeutralButton(R.string.delete) { _, _ -> - lifecycleScope.launch { - database.profileDao().delete(existingProfile) - if (currentProfileId == existingProfile.id) { - currentProfileId = null - memoryRepository.setCurrentProfile(null) - } - } + .setNeutralButton(R.string.delete) { _, _ -> + deleteUserProfile() } - } - - dialog.show() + .show() } private fun showClearChatDialog() { @@ -1675,11 +336,19 @@ if (!isActive) return@launch .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) || @@ -1726,65 +395,4 @@ 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 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 e62d424..85d8557 100644 --- a/app/src/main/java/com/mistral/chat/ui/MessageAdapter.kt +++ b/app/src/main/java/com/mistral/chat/ui/MessageAdapter.kt @@ -6,7 +6,6 @@ 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 @@ -47,11 +46,6 @@ 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/java/com/mistral/chat/ui/ProfilesAdapter.kt b/app/src/main/java/com/mistral/chat/ui/ProfilesAdapter.kt deleted file mode 100644 index a7a1a3d..0000000 --- a/app/src/main/java/com/mistral/chat/ui/ProfilesAdapter.kt +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 9947898..0000000 --- a/app/src/main/java/com/mistral/chat/ui/SessionsAdapter.kt +++ /dev/null @@ -1,45 +0,0 @@ -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/bg_send_button.xml b/app/src/main/res/drawable/bg_send_button.xml index 44e1f70..f2b1576 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/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml deleted file mode 100644 index 1cc0ebc..0000000 --- a/app/src/main/res/drawable/ic_add.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 3acb8a8..0000000 --- a/app/src/main/res/drawable/ic_arrow_back.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - \ 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 deleted file mode 100644 index 67fd43c..0000000 --- a/app/src/main/res/drawable/ic_check.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 4fd798e..0000000 --- a/app/src/main/res/drawable/ic_delete.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 64c725a..0000000 --- a/app/src/main/res/drawable/ic_edit.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 6513bf2..0000000 --- a/app/src/main/res/drawable/ic_info.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 12e9933..0000000 --- a/app/src/main/res/drawable/ic_key.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 9b9c873..0000000 --- a/app/src/main/res/drawable/ic_menu_hamburger.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 26cece3..0000000 --- a/app/src/main/res/drawable/ic_person.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 4fb4dc2..0000000 --- a/app/src/main/res/drawable/ic_settings.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ 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 b2e085d..22e5f48 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,173 +1,135 @@ - - + android:layout_height="wrap_content" + android:layout_alignParentTop="true"> - + + + + + + + + + + + + + + + + + android:indeterminate="true" + android:visibility="gone" + android:layout_marginBottom="8dp" /> + + + android:paddingEnd="0dp" + android:paddingTop="4dp" + android:paddingBottom="4dp"> - - - - - + android:background="@android:color/transparent" + android:hint="@string/enter_message" + android:imeOptions="actionSend" + android:inputType="textMultiLine" + android:maxLines="5" + android:minHeight="56dp" + android:paddingStart="8dp" + android:paddingEnd="8dp" + android:paddingTop="12dp" + android:paddingBottom="12dp" /> - - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_api_key.xml b/app/src/main/res/layout/dialog_api_key.xml deleted file mode 100644 index 0d50567..0000000 --- a/app/src/main/res/layout/dialog_api_key.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - \ 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 deleted file mode 100644 index 5060023..0000000 --- a/app/src/main/res/layout/dialog_clear_all.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - \ 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 fe3e696..38d8258 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|textCapSentences" + android:inputType="textPersonName" android:maxLines="1" /> @@ -41,7 +41,7 @@ android:id="@+id/bioInput" android:layout_width="match_parent" android:layout_height="wrap_content" - android:inputType="textMultiLine|textCapSentences" + android:inputType="textMultiLine" 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|textCapSentences" + android:inputType="textMultiLine" android:minLines="2" android:maxLines="4" /> diff --git a/app/src/main/res/layout/dialog_profile_edit.xml b/app/src/main/res/layout/dialog_profile_edit.xml deleted file mode 100644 index d61571e..0000000 --- a/app/src/main/res/layout/dialog_profile_edit.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_profiles.xml b/app/src/main/res/layout/dialog_profiles.xml deleted file mode 100644 index 42bd6e6..0000000 --- a/app/src/main/res/layout/dialog_profiles.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/drawer_main_menu.xml b/app/src/main/res/layout/drawer_main_menu.xml deleted file mode 100644 index 0771fac..0000000 --- a/app/src/main/res/layout/drawer_main_menu.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/drawer_profile_edit.xml b/app/src/main/res/layout/drawer_profile_edit.xml deleted file mode 100644 index 696ff31..0000000 --- a/app/src/main/res/layout/drawer_profile_edit.xml +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ 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 deleted file mode 100644 index e20facf..0000000 --- a/app/src/main/res/layout/drawer_settings_menu.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/drawer_submenu.xml b/app/src/main/res/layout/drawer_submenu.xml deleted file mode 100644 index e6e6c96..0000000 --- a/app/src/main/res/layout/drawer_submenu.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - \ 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 deleted file mode 100644 index 4f0e9b7..0000000 --- a/app/src/main/res/layout/item_drawer.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_assistant.xml b/app/src/main/res/layout/item_message_assistant.xml index a7b5a26..c10501a 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="350dp" + android:maxWidth="280dp" 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 969a526..92292b3 100644 --- a/app/src/main/res/layout/item_message_user.xml +++ b/app/src/main/res/layout/item_message_user.xml @@ -6,34 +6,14 @@ android:layout_height="wrap_content" android:padding="8dp"> - - - - + app:layout_constraintTop_toTopOf="parent" /> \ 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 deleted file mode 100644 index 3c12f6e..0000000 --- a/app/src/main/res/layout/item_profile.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - \ 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 deleted file mode 100644 index 04bc00c..0000000 --- a/app/src/main/res/layout/item_session.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - \ 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 deleted file mode 100644 index b3fdd97..0000000 --- a/app/src/main/res/layout/nav_header.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - \ 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 deleted file mode 100644 index d32b743..0000000 --- a/app/src/main/res/layout/panel_right.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - \ 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 deleted file mode 100644 index 5ba01f3..0000000 --- a/app/src/main/res/layout/screen_api_key.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - \ 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 deleted file mode 100644 index 43136ee..0000000 --- a/app/src/main/res/layout/screen_appearance.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - \ 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 deleted file mode 100644 index a83b50c..0000000 --- a/app/src/main/res/layout/screen_base.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - - - - \ 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 deleted file mode 100644 index 82b8e6e..0000000 --- a/app/src/main/res/layout/screen_clear_history.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - \ 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 deleted file mode 100644 index 0077274..0000000 --- a/app/src/main/res/layout/screen_profile_edit.xml +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ 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 deleted file mode 100644 index 1d6a8d2..0000000 --- a/app/src/main/res/layout/screen_profiles.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - \ 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 deleted file mode 100644 index 9092799..0000000 --- a/app/src/main/res/layout/screen_session.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - \ 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 deleted file mode 100644 index 54b2f49..0000000 --- a/app/src/main/res/menu/drawer_menu.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml index d0dc9c6..af13832 100644 --- a/app/src/main/res/menu/main_menu.xml +++ b/app/src/main/res/menu/main_menu.xml @@ -2,10 +2,6 @@ - - diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 380ab31..5e617cf 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -29,6 +29,6 @@ \ 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 0e8bc96..6486dbd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,69 +11,6 @@ Имя О себе Предпочтения - Системный промт - Инструкции для 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="киберспорт") -- "Что ты обо мне знаешь?" → выведи всю память Сохранить Отмена Удалить @@ -88,44 +25,4 @@ Расскажите о себе... Профиль не установлен Настройки - Mistral API - Mistral API ключ - Введите API ключ - API ключ сохранён - API ключ удалён - API ключ не установлен - Текущий ключ: %s - Введите API ключ - Требуется API ключ Mistral - Очистить всю историю - Удалить все сессии и сообщения? - История очищена - Сессии - Новая сессия - Нет сессий - OK - Профили - Управление профилями - ПРОФИЛИ - Новый профиль - Редактировать - Выбрано - Удалить все профили - Профиль: %s - Внешний вид - Сессия при запуске - Местоположение - Настройки местоположения - Часовой пояс - Город по умолчанию - Например: Москва, Санкт-Петербург - Настройки сохранены - Очистить историю - Удалить все сессии и сообщения? - Открывать последнюю сессию - Начинать новую сессию - Светлая - Тёмная - Назад - Системная \ 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 6d25ccd..5e89a57 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 diff --git a/build.gradle b/build.gradle index 788c34b..79bc0f9 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,5 @@ // Top-level build file plugins { id 'com.android.application' version '8.2.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 + id 'org.jetbrains.kotlin.android' version '1.9.0' apply false } \ No newline at end of file