From dc461ad5dcda19655c222cf64a0313f8d5e06671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=91=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=B5=D0=B2?= Date: Wed, 15 Apr 2026 17:08:36 +0800 Subject: [PATCH] Add Foreground Service for background work, clean up logging --- AGENTS.md | 535 ++++++------------ .../com/mistral/chat/api/MistralClient.kt | 10 - .../com/mistral/chat/api/NotificationTool.kt | 82 +++ .../java/com/mistral/chat/ui/MainActivity.kt | 356 +++++++++++- 4 files changed, 567 insertions(+), 416 deletions(-) create mode 100644 app/src/main/java/com/mistral/chat/api/NotificationTool.kt diff --git a/AGENTS.md b/AGENTS.md index 1f988a9..3975605 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,454 +44,235 @@ Android-приложение для чата с Mistral AI. Перспектив - Отступы в поле ввода (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), ключи БД, токены --- -## Current Issues & Architecture +## Tools -### ⚠️ Важное: Назначение web_search +### 🌐 Web Search +- ✅ Russian Wikipedia API (бесплатно, без API ключа) +- ✅ English Wikipedia API +- Ограничение: 4000 символов на ответ -web_search НЕ является интерфейсом поисковика или Wikipedia. Это инструмент для AI-агента: +### 🌤️ Weather +- ✅ Open-Meteo API (полностью бесплатно) +- Geocoding API + Weather API +- Текущая погода + прогноз на 7 дней -**Правильная логика работы (Kai-style):** -``` -1. AI получает вопрос пользователя -2. AI решает что нужен поиск → вызывает web_search -3. Выполняются ВСЕ tool_calls параллельно -4. Результаты НЕ показываются пользователю - только отправляются AI -5. AI интерпретирует результаты → выдаёт ОДИН финальный ответ -``` +### 🔗 OpenUrlTool +- ✅ HTTP GET к любому URL +- ✅ RSS/Atom парсинг (lenta.ru, kommersant.ru) +- Ограничение: 4000 символов, таймаут 10 сек -**Проблемы с текущей реализацией:** -- ❌ Показываем промежуточные ответы пользователю (каждый tool result = сообщение) -- ❌ AI получает результаты и отвечает после КАЖДОГО tool_calls -- ❌ AI выводит куски данных вместо интерпретации +### ⏰ Time Tools +- ✅ get_local_time - возвращает timestamp в миллисекундах +- ✅ get_date - текущая дата -**Требуется исправление:** -- ✅ Выполнить ВСЕ tool_calls за один проход (уже делаем) -- ✅ Результаты НЕ показывать пользователю (только AI видит) -- ✅ AI интерпретирует и выдаёт ОДИН ответ +### 📅 CalDAV Calendar +- ✅ calendar_add_event - создание событий с VALARM (уведомления) +- ✅ calendar_get_events - получение списка событий +- ✅ calendar_delete_event - удаление событий +- Баikal сервер интеграция -### 🔍 Web Search (Текущая реализация - БЕСПЛАТНОЕ решение) - -**Используется:** Russian Wikipedia API (бесплатно, без API ключа) -- **API:** `https://ru.wikipedia.org/w/api.php` -- **Метод:** `query/list/search` - поиск статей по заголовкам -- **Ограничение результатов:** до 10 статей (параметр `num_results`) -- **Ограничение символов:** 4000 символов на ответ -- **ПРИМЕЧАНИЕ:** Это временное решение! Позже можно добавить платный API для полноценного поиска (новости, погода, актуальная информация) - -**Логика работы:** -1. AI вызывает `web_search` с текстовым запросом -2. Выполняется поиск по Wikipedia API -3. Результаты (заголовки + сниппеты) обрезаются до 4000 символов -4. Результаты отправляются AI для интерпретации -5. AI выдаёт ОДИН финальный ответ пользователю - -**Tool Loop (MainActivity):** -- Максимум итераций: 15 -- Timeout на итерацию: 60 секунд -- Retry (до 2 попыток) при ошибке "stream was reset: CANCEL" -- AI может сделать несколько последовательных поисков если нужно - -### 🌤️ Weather Tool (БЕСПЛАТНОЕ решение) - -**Используется:** Open-Meteo API (полностью бесплатно, без API ключа) -- **Geocoding API:** `https://geocoding-api.open-meteo.com/v1/search` - определение координат города -- **Weather API:** `https://api.open-meteo.com/v1/forecast` - текущая погода + прогноз на 7 дней -- **Параметры:** температура, ветер, погодные коды, осадки - -**Логика работы:** -1. AI вызывает `get_weather` с названием города -2. Определяются координаты через Geocoding API -3. Запрашивается погода по координатам -4. Возвращается текущая погода + прогноз на 7 дней - -### 🔗 OpenUrlTool (Часть Phase 3) -**Статус:** ✅ Реализовано | **Оценка:** 1 день - -**Назначение:** Позволяет AI парсить любую веб-страницу по URL. - -**Гибридная схема (AI сам решает откуда взять URL):** -1. **RSS-ленты новостей** (рекомендуется): - - lenta.ru → https://lenta.ru/rss/ - - kommersant.ru → https://www.kommersant.ru/rss/news.xml -2. **Из памяти** - AI помнит рабочие URL -3. **Через web_search** - находит URL в интернете -4. **От пользователя** - пользователь может передать URL - -**Логика работы с новостями:** -1. Получив запрос о новостях → сначала проверь память пользователя (предпочтения по темам) -2. Открой RSS-ленту через open_url (самый эффективный способ) -3. Составь сводку с учётом интересов пользователя -4. Если источники недоступны → используй web_search -5. Проверь память приложения -6. Выдай ответ на основе всех доступных источников - -**Реализация:** -- HTTP GET запрос к любому URL -- Возврат ТОЛЬКО текста (удаляются HTML теги) -- Ограничение: 4000 символов -- Таймаут: 10 секунд -- Блокировка опасных URL (javascript:, file:, data:) +### 💾 Memory Tools +- ✅ memory_store, memory_learn, memory_forget +- ✅ memory_reinforce, memory_preference --- -### 📚 Изучено из Kai (open-source AI assistant) +## Tool Execution Parameters -Kai имеет отличную документацию по tools: https://kai9000.com/docs/features/tools/ - -**Ключевые решения из Kai:** - -1. **Execution Flow:** - - Все tool calls выполняются параллельно (coroutine async/await) - - TOOL_EXECUTING показывается в UI как "пульсирующий индикатор" - - Результаты НЕ показываются пользователю - только отправляются AI - - AI может вызвать еще tool calls → цикл повторяется - - Когда AI отвечает без tool_calls → финальный текст показан пользователю - -2. **Safety Guards (важно!):** - - Iteration limit: максимум 15 итераций - - Repeated call detection: если одинаковый tool с одинаковыми аргументами вызывается 3 раза подряд → остановка - - Timeout: 30 секунд по умолчанию - - Result truncation: результаты > 8000 символов обрезаются - - Context trimming: между итерациями обрезается история сообщений - -3. **Web Search в Kai:** - - Есть встроенный web_search tool - - Работает (вероятно использует платный API или свой парсинг) +| Параметр | Значение | +|----------|----------| +| Max iterations | 15 | +| Timeout на итерацию | 120 сек | +| Retry при CANCEL | до 2 раз | +| Result truncation | 2000 символов | +| WakeLock | ✅ для длительных запросов | --- -## Active Plan (Phases 1-3) +## Active Plan -### Phase 1: Расширенные профили (Extended Profiles) -**Статус:** ✅ Завершена | **Оценка:** 1-2 дня - -Добавлено поле `systemPrompt` в профиль для отправки как role: "system". - -| Задача | Статус | -|--------|--------| -| Profile entity | ✅ Добавлено поле systemPrompt | -| Profile dialog UI | ✅ Добавлен EditText с maxLength=4000 | -| ProfileDao | ✅ CRUD работает | -| MainActivity | ✅ Инжектирует systemPrompt как role: "system" | -| MistralClient | ✅ Использует msg.role | - ---- - -### Phase 2: Система памяти (Memory System) -**Статус:** ✅ Завершена | **Оценка:** 2-3 дня - -Система запоминания информации с категориями и hitCount. - -| Задача | Статус | -|--------|--------| -| Memory entity | ✅ key, value, category, hitCount, timestamps | -| MemoryDao | ✅ CRUD + getByCategory, incrementHitCount, getPromotionCandidates | -| ChatDatabase | ✅ Добавлен MemoryDao, version=2 | -| MemoryRepository | ✅ buildMemoryContext() для инжекции в prompt | - -**Memory categories:** -- GENERAL — общие факты -- LEARNING — выводы и паттерны -- ERROR — известные ошибки -- PREFERENCE — предпочтения пользователя -- REMINDER_CAL — напоминания календаря (local mode) -- CALENDAR_ERROR — ошибки подключения CalDAV - -**Prompt injection:** -``` -=== Важная информация === -[Факты] -- ключ: значение - -[Выводы] -- ключ: значение (N использований) - -[Предпочтения пользователя] -- ключ: значение -``` - ---- - -### Phase 3: Tools / Tool Execution -**Статус:** ✅ Завершена (тестирование) | **Оценка:** 3-4 дня - -Инструменты для AI (function calling) для выполнения действий. - -| Задача | Статус | -|--------|--------| -| Tool abstract class | ✅ name, description, inputSchema, executor | -| GetTimeTool | ✅ get_local_time с timezone | -| GetDateTool | ✅ get_date с timezone | -| WebSearchTool | ✅ Протестировано (только Wikipedia) | -| GetWeatherTool | ✅ Протестировано (Open-Meteo API) | -| NotificationTool | ✅ send_notification | -| MemoryStoreTool | ✅ Протестировано | -| MemoryLearnTool | ✅ Протестировано | -| MemoryForgetTool | ✅ Протестировано | -| MemoryReinforceTool | ✅ Протестировано | -| MemoryPreferenceTool | ✅ Протестировано | -| ToolExecutor | ✅ управление всеми tools, updateSettings() | -| MistralClient | ✅ tools в chat completion, обработка tool_calls | -| Safety | ✅ Max iterations (15), timeout (30s), result truncation (2000 chars) | -| **OpenUrlTool (RSS)** | ✅ Автоматическое определение и парсинг RSS/Atom | - -**CalDAV Calendar + Local Reminders (Phase 3 extension):** -| Задача | Статус | -|--------|--------| -| iCalDAV зависимость (Apache 2.0) | ⏳ | -| CalDavRepository (CRUD) | ⏳ | -| UI: drawer_menu → диалог настроек CalDAV | ⏳ | -| Настройка синхронизации (15м-сутки) | ⏳ | -| caldav_get_events, create, update, delete | ⏳ | -| Memory category REMINDER_CAL | ⏳ | -| calendar_add_reminder tool | ⏳ | -| calendar_get_reminders tool | ⏳ | -| Напоминания (любой период: 5мин, 13мин, 2ч, 24ч) | ⏳ | -| Счётчик ошибок CalDAV → memory | ⏳ | -| UnifiedPush (опционально, на потом) | ⏳ | - -**Memory REMINDER_CAL fields:** -- key: название напоминания -- value: описание -- triggerTime: unix timestamp когда напомнить -- status: pending / triggered / expired - -**Trigger logic:** AI проверяет pending напоминания при каждом запросе - -**RSS-ленты (протестировано):** -- lenta.ru/rss/ ✅ -- kommersant.ru/rss/news.xml ✅ - -**Тестирование Phase 3:** -- ✅ web_search (Wikipedia) - работает -- ✅ get_weather (Open-Meteo) - работает -- ✅ Memory tools - работает, изолирована по профилям (протестировано) - -**Location Settings (в рамках Phase 3):** -| Задача | Статус | -|--------|--------| -| Preferences keys | ✅ KEY_DEFAULT_TIMEZONE, KEY_DEFAULT_CITY | -| dialog_location.xml | ✅ UI для ввода timezone/city | -| showLocationDialog() | ✅ Реализована в MainActivity | -| drawer_menu.xml | ✅ Добавлен item action_location | -| ic_location.xml | ✅ Создан vector drawable | -| ToolExecutor.updateSettings() | ✅ Принимает timezone/city при сохранении | - -**Defaults:** -- Timezone: Asia/Irkutsk -- City: Иркутск - ---- - -## Active Plan (Phases 1-5) - -### Phase 3 (Active): CalDAV Calendar + Local Reminders -**Статус:** 🔄 В разработке | **Оценка:** 4-5 дней +### Phase 3: CalDAV Calendar + Local Reminders (✅ В основном готово) **Два режима:** -1. CalDAV — синхронизация с Baikal сервером -2. Local — автономная напоминалка в памяти AI (работает БЕЗ интернета) +1. **CalDAV** — синхронизация с Baikal сервером +2. **Local** — автономная напоминалка в памяти AI -**Подробнее:** см. таблицу в разделе Phase 3 выше +**CalDAV Status:** +| Задача | Статус | +|--------|--------| +| Подключение к Baikal | ✅ | +| calendar_add_event | ✅ Работает (с VALARM) | +| calendar_get_events | ✅ Работает (лимит 100 событий) | +| calendar_delete_event | ✅ Работает (только свои события) | +| VALARM (уведомления) | ✅ Добавляются к событиям | +| UID consistency | ✅ Исправлено | +| Timestamp (time_string) | ✅ AI передаёт строку, сервер парсит | ---- +**Нерешённые проблемы:** +- При переустановке app старые события становятся "чужими" (новый UUID) -### Phase 4: Heartbeat (Scheduled) -**Оценка:** 2-3 дня +**Как помочь 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) -- Обработка ответа (молча vs уведомление) -### Phase 5: Email (IMAP/SMTP) -**Оценка:** 4-5 дней +### Phase 5: Email (⏳ В очереди) +- IMAP/SMTP клиент (без OAuth) -Интеграция с email без OAuth: -- IMAP клиент (чтение писем) -- SMTP клиент (отправка) -- UI настройки ящика (сервер, порт, логин, пароль) -- Email tools для AI +### 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 или новое диалоговое окно --- -## Technical Context - -### ⚠️ ВАЖНО: Сборка APK после каждого изменения -**После каждого исправления или добавления функций НЕОБХОДИМО собирать APK!** - -Пользователь должен иметь возможность сразу протестировать изменения. - -```bash -# Сборка APK -JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug - -# Путь к APK -app/build/outputs/apk/debug/app-debug.apk -``` - -### Key Files +## 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` — база данных -- `app/src/main/java/com/mistral/chat/data/Profile.kt` — профиль -- `app/src/main/java/com/mistral/chat/data/Memory.kt` — память -- `app/src/main/res/layout/dialog_location.xml` — настройки местоположения - -### Current Issues -- Кнопка STOP не работает (требует streaming mode) ### Model Selection -- **Default:** mistral-medium-latest (быстрее, меньше ошибок) -- **Доступные модели:** Large, Medium, Codestral, Pixtral +- **Default:** mistral-medium-latest - **OkHttp timeouts:** connect 60s, read 120s, write 60s -### Error Handling (Исправлено) -- Ошибки tool execution (таймауты, network errors) НЕ сохраняются в БД -- Показываются пользователю через Toast -- Предотвращает "отравление" контекста сообщениями об ошибках - --- ## ⚠️ ВАЖНЫЕ ПРАВИЛА РАЗРАБОТКИ ### Запрет на удаление реализованных функций -**НИКОГДА не удаляй уже реализованные функции!** Даже если они кажутся неидеальными: -- Если нужно изменить поведение - исправь, а не удаляй -- Если что-то сломалось - почини, а не упрощай удалением -- При удалении функций (даже "неиспользуемых") всегда согласовывай с пользователем +**НИКОГДА не удаляй уже реализованные функции!** -### Запрет на хардкодинг переменных +### Запрет на хардкодинг **НИКОГДА не хардкодь значения, которые должны быть динамическими!** -- Даты, года, время,地名, названия - всё должно подставляться из системы/контекста -- Если что-то не получается реализовать без хардкода - ОБСУДИ с пользователем перед реализацией -- Пример правильного подхода: `{CURRENT_YEAR}` → подставляется через `SimpleDateFormat` -### Сборка APK после каждого изменения -**После каждого исправления или добавления функций ОБЯЗАТЕЛЬНО собирай APK!** -- Пользователь должен иметь возможность сразу протестировать изменения -- Команда: `JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug` -- Расположение: `app/build/outputs/apk/debug/app-debug.apk` - -### Дублирование сообщений при переключении сессий (BUG FIX) -**Проблема:** При переходе из второй сессии в первую (или любую другую) сообщения дублировались. - -**Причина:** Асинхронная загрузка сообщений без проверки актуальности sessionId. - -**Решение в MainActivity.kt:** -1. Очищаем список СРАЗУ при переключении (до асинхронной загрузки) -2. Используем `loadMessagesJob` для отмены предыдущей загрузки сообщений -3. Проверяем sessionId внутри async загрузки (несколько раз) -4. Передаём `expectedSessionId` в `addMessage` для правильного сохранения в БД -5. Прокрутка к последнему сообщению после загрузки - -### ⚠️ ВАЖНО: Логика прокрутки чата -**Правильная реализация:** -1. **К концу сообщения пользователя** - прокрутка к концу (scrollToPosition) через 100мс после добавления -2. **К началу ответа ИИ** - прокрутка к НАЧАЛУ (scrollToPositionWithOffset) через 150мс после добавления сообщения ИИ - -**Техническая реализация:** -- В `addMessage()`: для сообщений ИИ (`!message.isUser`) - прокрутка к началу через 150мс -- Используй `layoutManager.scrollToPositionWithOffset(position, 0)` для прокрутки к началу элемента -- Используй `scrollToPosition(position)` для прокрутки к концу элемента -- Проверяй `!userScrolledAfterSend` перед прокруткой к ответу ИИ - -### Удаление debug логирования -После отладки и подтверждения что баг исправлен - удали все `android.util.Log.d("DEBUG", ...)` из кода. - -### Порядок действий при работе с багом -1. Проанализируй код и найди причину -2. Исправь проблему, а не симптомы -3. Не удаляй существующий функционал -4. Проверь что исправление не ломает другие сценарии -5. Документируй исправление в agents.md +### Сборка APK +```bash +JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug +# Путь: app/build/outputs/apk/debug/app-debug.apk +``` --- -## Выводы и предмет для обсуждения +## 📋 Контекст сессии и оптимизация (📋 Запланировано) -### WebSearchTool -- **Wikipedia API** - работает, но содержит только энциклопедические статьи (нет погоды, новостей) -- **DuckDuckGo Instant Answer API** - возвращает 0 результатов для большинства запросов (ограничение бесплатного API) -- **Вывод:** Текущая реализация web_search не может полноценно заменить поисковик +### Текущая реализация +При каждом запросе отправляется полный контекст. При росте сессии возможны 503 ошибки. -### OpenUrlTool (предложено, отложено) -- AI не знает все URL наизусть - нужен либо справочник в system prompt, либо web_search для нахождения URL -- При гибридном подходе: web_search находит URL → open_url парсит страницу -- Проблема: в system prompt не влезет список URL для всех типичных запросов (погода, новости, курсы валют и т.д.) -- **Вывод:** Реализация отложена до починки web_search +### План реализации (Kai 9000 style) -### Tool Execution Loop -- Предыдущая реализация: 1 итерация → результаты → финальный запрос без tools -- **Проблема:** Не даёт AI сделать несколько последовательных поисков (web_search → получить URL → open_url) -- Новая реализация: до 15 итераций, как в Kai - AI сам решает сколько поисков нужно -- Лимит iteration: 15 -- Timeout на итерацию: 30 сек -- Если API Mistral не выдержит - снизим до 10 или 5 +**Источник:** 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() + } +} +``` -При каждом запросе отправляется полный контекст: -1. System prompt (профиль) -2. Текущая дата и время -3. Часовой пояс + город -4. Контекст профиля (имя, о себе) -5. Контекст памяти (факты, выводы, предпочтения) -6. **ВСЕ сообщения сессии** -7. Результаты tool calls (полностью, до 2000 символов каждый) - -**Проблемы:** -- При 2-3 tool calls (RSS + статья) добавляется 4000-6000 символов в контекст -- При росте сессии (100+ сообщений) запрос станет слишком большим -- 503 ошибки чаще происходят при больших запросах -- Превышение лимита токенов контекста - -### Варианты решения - -**1. Trimming (простое)** -- Оставлять только последние N сообщений + память + system prompt -- Просто реализовать, но теряется история - -**2. Свёртывание tool results** -- Не добавлять полный результат open_url в историю -- Добавлять краткую выжимку: "Найдено 5 новостей о [тема]" -- Сложнее реализовать, сохраняет суть - -**3. Контекстное окно (гибкое)** -- Оставлять последние N сообщений + summary предыдущих -- ИИ сам решает что важно -- Сложная реализация - -**Статус:** Не решено, требует обсуждения с пользователем +### Files to modify +- ToolExecutor.kt - добавить trimming в loop +- MistralClient.kt - добавить getTokenCount, trimOldMessages --- ## Conversation Context (for AI Agent) **При начале новой сессии:** -Прочитай файл AGENTS.md для понимания текущего контекста разработки. +Прочитай файл AGENTS.md для понимания текущего контекста. **При запросе "продолжаем":** -Мы работаем над Phase 3 (Tools). Последняя завершённая задача — добавление настроек location (timezone/city) в drawer menu. +Мы работаем над Phase 3 - CalDAV календарь. Тестируем: создание событий, получение списка, исправление timezone. **Важно:** -- Пушить в GitHub только после тестирования и подтверждения пользователя -- Не делать push автоматически после каждого изменения +- Пушить в GitHub только после подтверждения пользователя +- Не делать push автоматически --- -*Last updated: 2026-04-10* -*Version: 1.10* \ No newline at end of file +*Last updated: 2026-04-12* +*Version: 1.11* \ 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 7db75a8..f7125f2 100644 --- a/app/src/main/java/com/mistral/chat/api/MistralClient.kt +++ b/app/src/main/java/com/mistral/chat/api/MistralClient.kt @@ -168,14 +168,6 @@ class MistralClient(private val apiKey: String) { val json = gson.toJson(jsonObject) val body = json.toRequestBody(jsonMediaType) - Log.d("MistralClient", "Request JSON size: ${json.length} chars") - Log.d("MistralClient", "Request: model=$model, msgs=${messages.size}, tools=${tools?.size ?: 0}") - - // Логируем все сообщения - messages.forEachIndexed { idx, msg -> - Log.d("MistralClient", "Msg[$idx] role=${msg.role}, len=${msg.content.length}, content=${msg.content.take(100)}...") - } - val request = Request.Builder() .url("$BASE_URL/chat/completions") .addHeader("Authorization", "Bearer $apiKey") @@ -223,8 +215,6 @@ class MistralClient(private val apiKey: String) { val responseBody = response.body?.string() ?: "" - Log.d("MistralClient", "Response: code=${response.code}, len=${responseBody.length}") - if (onChunk != null) { onChunk(responseBody) } diff --git a/app/src/main/java/com/mistral/chat/api/NotificationTool.kt b/app/src/main/java/com/mistral/chat/api/NotificationTool.kt new file mode 100644 index 0000000..9c117ae --- /dev/null +++ b/app/src/main/java/com/mistral/chat/api/NotificationTool.kt @@ -0,0 +1,82 @@ +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/ui/MainActivity.kt b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt index 08b1224..36cddc8 100644 --- a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt +++ b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt @@ -5,6 +5,7 @@ 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 @@ -33,7 +34,10 @@ import com.google.android.material.textfield.TextInputEditText import com.google.android.material.button.MaterialButton import com.google.gson.Gson import com.mistral.chat.R +import com.mistral.chat.api.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 @@ -173,6 +177,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte loadModels() setupInput() loadProfilesAndSessions() + restoreCalDavConnection() inputField.postDelayed({ val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager @@ -181,6 +186,36 @@ 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) @@ -228,6 +263,9 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte R.id.action_location -> { showLocationDialog() } + R.id.action_calendar -> { + showCalendarDialog() + } R.id.action_about -> { showAboutDialog() } @@ -321,7 +359,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val selectedIndex = if (currentSetting) 1 else 0 AlertDialog.Builder(this) - .setTitle(R.string.settings) + .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() @@ -367,11 +405,50 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte 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 timezoneInput = dialogView.findViewById(R.id.timezoneInput) + val cityInput = dialogView.findViewById(R.id.cityInput) - timezoneInput.setText(getDefaultTimezone()) - cityInput.setText(getDefaultCity()) + // Российские города + 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) @@ -394,6 +471,167 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte .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 @@ -960,7 +1198,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } private fun getDefaultTimezone(): String { - return prefs.getString(KEY_DEFAULT_TIMEZONE, "Europe/Moscow") ?: "Europe/Moscow" + return prefs.getString(KEY_DEFAULT_TIMEZONE, "Asia/Irkutsk") ?: "Asia/Irkutsk" } private fun setDefaultTimezone(timezone: String) { @@ -968,7 +1206,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } private fun getDefaultCity(): String { - return prefs.getString(KEY_DEFAULT_CITY, "Москва") ?: "Москва" + return prefs.getString(KEY_DEFAULT_CITY, "Улан-Удэ") ?: "Улан-Удэ" } private fun setDefaultCity(city: String) { @@ -1055,6 +1293,15 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte sendButton.isEnabled = false 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() @@ -1108,6 +1355,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte // Tool loop - до 15 итераций var iteration = 0 + var repeatCount = 0 + var lastToolCalls: List = emptyList() val maxIterations = 15 var finalResponse: String? = null @@ -1117,10 +1366,11 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte 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(60000L) { + result = withTimeout(apiTimeout) { client?.chat(selectedModel, apiMessages, tools) ?: Result.failure(Exception("Client not initialized")) } @@ -1130,39 +1380,67 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val errorMsg = result?.exceptionOrNull()?.message ?: "" if ((errorMsg.contains("CANCEL") || errorMsg.contains("stream was reset")) && retryCount < maxRetries) { retryCount++ - android.util.Log.w("MainActivity", "Retry $retryCount after CANCEL, iteration $iteration") kotlinx.coroutines.delay(2000L) } else { break } } - if (!isActive) return@launch - - // Handle nullable result - выходим если null - if (result == null) { - finalResponse = "Ошибка: Не удалось получить ответ от API" - } else { - val chatResult = result - chatResult.onSuccess { chatResponse -> - android.util.Log.d("MainActivity", "API response: toolCalls=${chatResponse.toolCalls.size}") +if (!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"}""" - // Если tool вернул ошибку - добавляем, но не накапливаем - if (!toolResult.contains("error")) { - apiMessages.add(Message( - content = """[${toolCall.name}] result: $toolResult""", - isUser = true, - role = "user" - )) + // Все результаты добавляем в историю + 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 = "Готово! Событие добавлено в календарь." } } - // Продолжаем цикл - AI решит нужен ли еще поиск + + // Если write-операция выполнена - выходим из цикла + if (writeOperationCompleted) { + finalResponse = writeOperationMessage + // Выходим из while цикла + return@onSuccess + } + + // Продолжаем цикл только если не было write-операции } else { // Нет tool calls - это финальный ответ finalResponse = chatResponse.content @@ -1197,9 +1475,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte // Проверяем что sessionId не изменился пока работал запрос if (currentSessionId == sessionIdAtStart) { // НЕ добавляем сообщения об ошибках в БД - они портят контекст - val isError = responseToShow.contains("Timed out") || - responseToShow.contains("таймаут") || - responseToShow.startsWith("Ошибка:") + // Проверяем более строго - только явные ошибки, а не просто упоминание в тексте + 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) @@ -1225,7 +1507,23 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte 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) {