Add Foreground Service for background work, clean up logging

This commit is contained in:
Алексей Будаев 2026-04-15 17:08:36 +08:00
parent cabc6b8d85
commit dc461ad5dc
4 changed files with 567 additions and 416 deletions

535
AGENTS.md
View file

@ -44,454 +44,235 @@ Android-приложение для чата с Mistral AI. Перспектив
- Отступы в поле ввода (12dp) - Отступы в поле ввода (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 ### ✅ Security
- API ключ: EncryptedSharedPreferences (AES-256-GCM) - API ключ: EncryptedSharedPreferences (AES-256-GCM)
- Ключ БД: EncryptedSharedPreferences (AES-256-SIV + AES-256-GCM) - Ключ БД: EncryptedSharedPreferences (AES-256-SIV + AES-256-GCM)
- Профили, сессии, сообщения: SQLCipher - Профили, сессии, сообщения: 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):** ### 🔗 OpenUrlTool
``` - ✅ HTTP GET к любому URL
1. AI получает вопрос пользователя - ✅ RSS/Atom парсинг (lenta.ru, kommersant.ru)
2. AI решает что нужен поиск → вызывает web_search - Ограничение: 4000 символов, таймаут 10 сек
3. Выполняются ВСЕ tool_calls параллельно
4. Результаты НЕ показываются пользователю - только отправляются AI
5. AI интерпретирует результаты → выдаёт ОДИН финальный ответ
```
**Проблемы с текущей реализацией:** ### ⏰ Time Tools
- ❌ Показываем промежуточные ответы пользователю (каждый tool result = сообщение) - ✅ get_local_time - возвращает timestamp в миллисекундах
- ❌ AI получает результаты и отвечает после КАЖДОГО tool_calls - ✅ get_date - текущая дата
- ❌ AI выводит куски данных вместо интерпретации
**Требуется исправление:** ### 📅 CalDAV Calendar
- ✅ Выполнить ВСЕ tool_calls за один проход (уже делаем) - ✅ calendar_add_event - создание событий с VALARM (уведомления)
- ✅ Результаты НЕ показывать пользователю (только AI видит) - ✅ calendar_get_events - получение списка событий
- ✅ AI интерпретирует и выдаёт ОДИН ответ - ✅ calendar_delete_event - удаление событий
- Баikal сервер интеграция
### 🔍 Web Search (Текущая реализация - БЕСПЛАТНОЕ решение) ### 💾 Memory Tools
- ✅ memory_store, memory_learn, memory_forget
**Используется:** Russian Wikipedia API (бесплатно, без API ключа) - ✅ memory_reinforce, memory_preference
- **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:)
--- ---
### 📚 Изучено из Kai (open-source AI assistant) ## Tool Execution Parameters
Kai имеет отличную документацию по tools: https://kai9000.com/docs/features/tools/ | Параметр | Значение |
|----------|----------|
**Ключевые решения из Kai:** | Max iterations | 15 |
| Timeout на итерацию | 120 сек |
1. **Execution Flow:** | Retry при CANCEL | до 2 раз |
- Все tool calls выполняются параллельно (coroutine async/await) | Result truncation | 2000 символов |
- TOOL_EXECUTING показывается в UI как "пульсирующий индикатор" | WakeLock | ✅ для длительных запросов |
- Результаты НЕ показываются пользователю - только отправляются 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 или свой парсинг)
--- ---
## Active Plan (Phases 1-3) ## Active Plan
### Phase 1: Расширенные профили (Extended Profiles) ### Phase 3: CalDAV Calendar + Local Reminders (✅ В основном готово)
**Статус:** ✅ Завершена | **Оценка:** 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 дней
**Два режима:** **Два режима:**
1. CalDAV — синхронизация с Baikal сервером 1. **CalDAV** — синхронизация с Baikal сервером
2. Local — автономная напоминалка в памяти AI (работает БЕЗ интернета) 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) **Как помочь AI правильно работать с календарём:**
**Оценка:** 2-3 дня 1. Обязательно вызвать get_local_time для получения текущего UTC timestamp
2. Использовать формулу: new_timestamp = current_timestamp + (часы * 3600000) + (минуты * 60000)
3. НЕ добавлять случайные минуты!
Автономная периодическая самопроверка: ### Phase 4: Heartbeat (⏳ В очереди)
- WorkManager задача (каждые 30 минут) - WorkManager задача (каждые 30 минут)
- Active hours (8:00-22:00) - Active hours (8:00-22:00)
- Обработка ответа (молча vs уведомление)
### Phase 5: Email (IMAP/SMTP) ### Phase 5: Email (⏳ В очереди)
**Оценка:** 4-5 дней - IMAP/SMTP клиент (без OAuth)
Интеграция с email без OAuth: ### Phase 6: API Key Rotation (📋 Запланировано)
- IMAP клиент (чтение писем)
- SMTP клиент (отправка) **Проблема:**
- UI настройки ящика (сервер, порт, логин, пароль) - При достижении лимита токенов или блокировке ключа приложение перестаёт работать
- Email tools для AI - Нужна система ротации для отказоустойчивости
**Механизм:**
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 ## Key Files
### ⚠️ ВАЖНО: Сборка APK после каждого изменения
**После каждого исправления или добавления функций НЕОБХОДИМО собирать APK!**
Пользователь должен иметь возможность сразу протестировать изменения.
```bash
# Сборка APK
JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug
# Путь к APK
app/build/outputs/apk/debug/app-debug.apk
```
### Key Files
- `app/src/main/java/com/mistral/chat/ui/MainActivity.kt` — главная активность - `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/MistralClient.kt` — API клиент
- `app/src/main/java/com/mistral/chat/api/ToolExecutor.kt` — менеджер tools - `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/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 ### Model Selection
- **Default:** mistral-medium-latest (быстрее, меньше ошибок) - **Default:** mistral-medium-latest
- **Доступные модели:** Large, Medium, Codestral, Pixtral
- **OkHttp timeouts:** connect 60s, read 120s, write 60s - **OkHttp timeouts:** connect 60s, read 120s, write 60s
### Error Handling (Исправлено)
- Ошибки tool execution (таймауты, network errors) НЕ сохраняются в БД
- Показываются пользователю через Toast
- Предотвращает "отравление" контекста сообщениями об ошибках
--- ---
## ⚠️ ВАЖНЫЕ ПРАВИЛА РАЗРАБОТКИ ## ⚠️ ВАЖНЫЕ ПРАВИЛА РАЗРАБОТКИ
### Запрет на удаление реализованных функций ### Запрет на удаление реализованных функций
**НИКОГДА не удаляй уже реализованные функции!** Даже если они кажутся неидеальными: **НИКОГДА не удаляй уже реализованные функции!**
- Если нужно изменить поведение - исправь, а не удаляй
- Если что-то сломалось - почини, а не упрощай удалением
- При удалении функций (даже "неиспользуемых") всегда согласовывай с пользователем
### Запрет на хардкодинг переменных ### Запрет на хардкодинг
**НИКОГДА не хардкодь значения, которые должны быть динамическими!** **НИКОГДА не хардкодь значения, которые должны быть динамическими!**
- Даты, года, время,地名, названия - всё должно подставляться из системы/контекста
- Если что-то не получается реализовать без хардкода - ОБСУДИ с пользователем перед реализацией
- Пример правильного подхода: `{CURRENT_YEAR}` → подставляется через `SimpleDateFormat`
### Сборка APK после каждого изменения ### Сборка APK
**После каждого исправления или добавления функций ОБЯЗАТЕЛЬНО собирай APK!** ```bash
- Пользователь должен иметь возможность сразу протестировать изменения JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug
- Команда: `JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug` # Путь: app/build/outputs/apk/debug/app-debug.apk
- Расположение: `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
--- ---
## Выводы и предмет для обсуждения ## 📋 Контекст сессии и оптимизация (📋 Запланировано)
### WebSearchTool ### Текущая реализация
- **Wikipedia API** - работает, но содержит только энциклопедические статьи (нет погоды, новостей) При каждом запросе отправляется полный контекст. При росте сессии возможны 503 ошибки.
- **DuckDuckGo Instant Answer API** - возвращает 0 результатов для большинства запросов (ограничение бесплатного API)
- **Вывод:** Текущая реализация web_search не может полноценно заменить поисковик
### OpenUrlTool (предложено, отложено) ### План реализации (Kai 9000 style)
- AI не знает все URL наизусть - нужен либо справочник в system prompt, либо web_search для нахождения URL
- При гибридном подходе: web_search находит URL → open_url парсит страницу
- Проблема: в system prompt не влезет список URL для всех типичных запросов (погода, новости, курсы валют и т.д.)
- **Вывод:** Реализация отложена до починки web_search
### Tool Execution Loop **Источник:** https://kai9000.com/docs/features/tools/
- Предыдущая реализация: 1 итерация → результаты → финальный запрос без tools
- **Проблема:** Не даёт AI сделать несколько последовательных поисков (web_search → получить URL → open_url)
- Новая реализация: до 15 итераций, как в Kai - AI сам решает сколько поисков нужно
- Лимит iteration: 15
- Timeout на итерацию: 30 сек
- Если API Mistral не выдержит - снизим до 10 или 5
--- **Часть 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
1. System prompt (профиль) - ToolExecutor.kt - добавить trimming в loop
2. Текущая дата и время - MistralClient.kt - добавить getTokenCount, trimOldMessages
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 предыдущих
- ИИ сам решает что важно
- Сложная реализация
**Статус:** Не решено, требует обсуждения с пользователем
--- ---
## Conversation Context (for AI Agent) ## Conversation Context (for AI Agent)
**При начале новой сессии:** **При начале новой сессии:**
Прочитай файл AGENTS.md для понимания текущего контекста разработки. Прочитай файл AGENTS.md для понимания текущего контекста.
**При запросе "продолжаем":** **При запросе "продолжаем":**
Мы работаем над Phase 3 (Tools). Последняя завершённая задача — добавление настроек location (timezone/city) в drawer menu. Мы работаем над Phase 3 - CalDAV календарь. Тестируем: создание событий, получение списка, исправление timezone.
**Важно:** **Важно:**
- Пушить в GitHub только после тестирования и подтверждения пользователя - Пушить в GitHub только после подтверждения пользователя
- Не делать push автоматически после каждого изменения - Не делать push автоматически
--- ---
*Last updated: 2026-04-10* *Last updated: 2026-04-12*
*Version: 1.10* *Version: 1.11*

View file

@ -168,14 +168,6 @@ class MistralClient(private val apiKey: String) {
val json = gson.toJson(jsonObject) val json = gson.toJson(jsonObject)
val body = json.toRequestBody(jsonMediaType) val body = json.toRequestBody(jsonMediaType)
Log.d("MistralClient", "Request JSON size: ${json.length} chars")
Log.d("MistralClient", "Request: model=$model, msgs=${messages.size}, tools=${tools?.size ?: 0}")
// Логируем все сообщения
messages.forEachIndexed { idx, msg ->
Log.d("MistralClient", "Msg[$idx] role=${msg.role}, len=${msg.content.length}, content=${msg.content.take(100)}...")
}
val request = Request.Builder() val request = Request.Builder()
.url("$BASE_URL/chat/completions") .url("$BASE_URL/chat/completions")
.addHeader("Authorization", "Bearer $apiKey") .addHeader("Authorization", "Bearer $apiKey")
@ -223,8 +215,6 @@ class MistralClient(private val apiKey: String) {
val responseBody = response.body?.string() ?: "" val responseBody = response.body?.string() ?: ""
Log.d("MistralClient", "Response: code=${response.code}, len=${responseBody.length}")
if (onChunk != null) { if (onChunk != null) {
onChunk(responseBody) onChunk(responseBody)
} }

View file

@ -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}"}"""
}
}
}

View file

@ -5,6 +5,7 @@ import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import android.view.View
import android.view.inputmethod.EditorInfo 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.android.material.button.MaterialButton
import com.google.gson.Gson import com.google.gson.Gson
import com.mistral.chat.R import com.mistral.chat.R
import com.mistral.chat.api.CalDavCalendar
import com.mistral.chat.api.CalDavClient
import com.mistral.chat.api.ChatResponse import com.mistral.chat.api.ChatResponse
import com.mistral.chat.api.ApiForegroundService
import com.mistral.chat.api.MistralClient import com.mistral.chat.api.MistralClient
import com.mistral.chat.api.ToolExecutor import com.mistral.chat.api.ToolExecutor
import com.mistral.chat.data.ChatDatabase import com.mistral.chat.data.ChatDatabase
@ -173,6 +177,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
loadModels() loadModels()
setupInput() setupInput()
loadProfilesAndSessions() loadProfilesAndSessions()
restoreCalDavConnection()
inputField.postDelayed({ inputField.postDelayed({
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
@ -181,6 +186,36 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
}, 300) }, 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() { private fun setupDrawer() {
navigationView.setNavigationItemSelectedListener(this) navigationView.setNavigationItemSelectedListener(this)
@ -228,6 +263,9 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
R.id.action_location -> { R.id.action_location -> {
showLocationDialog() showLocationDialog()
} }
R.id.action_calendar -> {
showCalendarDialog()
}
R.id.action_about -> { R.id.action_about -> {
showAboutDialog() showAboutDialog()
} }
@ -321,7 +359,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
val selectedIndex = if (currentSetting) 1 else 0 val selectedIndex = if (currentSetting) 1 else 0
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(R.string.settings) .setTitle(R.string.session_menu_title)
.setSingleChoiceItems(options, selectedIndex) { dialog, which -> .setSingleChoiceItems(options, selectedIndex) { dialog, which ->
val newValue = which == 1 val newValue = which == 1
prefs.edit().putBoolean(KEY_NEW_SESSION_ON_START, newValue).apply() prefs.edit().putBoolean(KEY_NEW_SESSION_ON_START, newValue).apply()
@ -367,11 +405,50 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private fun showLocationDialog() { private fun showLocationDialog() {
val dialogView = layoutInflater.inflate(R.layout.dialog_location, null) val dialogView = layoutInflater.inflate(R.layout.dialog_location, null)
val timezoneInput = dialogView.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.timezoneInput) val timezoneInput = dialogView.findViewById<android.widget.AutoCompleteTextView>(R.id.timezoneInput)
val cityInput = dialogView.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.cityInput) val cityInput = dialogView.findViewById<android.widget.AutoCompleteTextView>(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) AlertDialog.Builder(this)
.setTitle(R.string.location_title) .setTitle(R.string.location_title)
@ -394,6 +471,167 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
.show() .show()
} }
private var calDavClient: CalDavClient? = null
private var calDavCalendars: List<CalDavCalendar> = emptyList()
private fun showCalendarDialog() {
val dialogView = layoutInflater.inflate(R.layout.dialog_calendar, null)
val caldavStatusText = dialogView.findViewById<android.widget.TextView>(R.id.caldavStatusText)
val caldavUrlInput = dialogView.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.caldavUrlInput)
val caldavUsernameInput = dialogView.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.caldavUsernameInput)
val caldavPasswordInput = dialogView.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.caldavPasswordInput)
val syncIntervalSpinner = dialogView.findViewById<android.widget.AutoCompleteTextView>(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() { private fun setupToolbar() {
hamburgerButton.isVisible = true hamburgerButton.isVisible = true
@ -960,7 +1198,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
} }
private fun getDefaultTimezone(): String { 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) { private fun setDefaultTimezone(timezone: String) {
@ -968,7 +1206,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
} }
private fun getDefaultCity(): String { private fun getDefaultCity(): String {
return prefs.getString(KEY_DEFAULT_CITY, "Москва") ?: "Москва" return prefs.getString(KEY_DEFAULT_CITY, "Улан-Удэ") ?: "Улан-Удэ"
} }
private fun setDefaultCity(city: String) { private fun setDefaultCity(city: String) {
@ -1055,6 +1293,15 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
sendButton.isEnabled = false sendButton.isEnabled = false
progressIndicator.isVisible = true 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 { currentJob = lifecycleScope.launch {
try { try {
val profileContext = getSelectedProfileContext() val profileContext = getSelectedProfileContext()
@ -1108,6 +1355,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
// Tool loop - до 15 итераций // Tool loop - до 15 итераций
var iteration = 0 var iteration = 0
var repeatCount = 0
var lastToolCalls: List<String> = emptyList()
val maxIterations = 15 val maxIterations = 15
var finalResponse: String? = null var finalResponse: String? = null
@ -1117,10 +1366,11 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
var result: Result<ChatResponse>? = null var result: Result<ChatResponse>? = null
var retryCount = 0 var retryCount = 0
val maxRetries = 2 val maxRetries = 2
val apiTimeout = 120000L // 120 seconds for large responses
//Retry при CANCEL ошибке //Retry при CANCEL ошибке
while (retryCount <= maxRetries) { while (retryCount <= maxRetries) {
result = withTimeout(60000L) { result = withTimeout(apiTimeout) {
client?.chat(selectedModel, apiMessages, tools) client?.chat(selectedModel, apiMessages, tools)
?: Result.failure(Exception("Client not initialized")) ?: Result.failure(Exception("Client not initialized"))
} }
@ -1130,39 +1380,67 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
val errorMsg = result?.exceptionOrNull()?.message ?: "" val errorMsg = result?.exceptionOrNull()?.message ?: ""
if ((errorMsg.contains("CANCEL") || errorMsg.contains("stream was reset")) && retryCount < maxRetries) { if ((errorMsg.contains("CANCEL") || errorMsg.contains("stream was reset")) && retryCount < maxRetries) {
retryCount++ retryCount++
android.util.Log.w("MainActivity", "Retry $retryCount after CANCEL, iteration $iteration")
kotlinx.coroutines.delay(2000L) kotlinx.coroutines.delay(2000L)
} else { } else {
break break
} }
} }
if (!isActive) return@launch 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}")
// Handle nullable result - выходим если null
if (result == null) {
finalResponse = "Ошибка: Не удалось получить ответ от API"
} else {
val chatResult = result
chatResult.onSuccess { chatResponse ->
if (chatResponse.toolCalls.isNotEmpty()) { 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 и добавляем результаты в историю // Выполняем все tool calls и добавляем результаты в историю
var writeOperationCompleted = false
var writeOperationMessage = ""
for (toolCall in chatResponse.toolCalls) { for (toolCall in chatResponse.toolCalls) {
val toolResult = client?.executeTool(toolCall.name, toolCall.arguments) val toolResult = client?.executeTool(toolCall.name, toolCall.arguments)
?: """{"status": "error", "message": "Tool failed"}""" ?: """{"status": "error", "message": "Tool failed"}"""
// Если tool вернул ошибку - добавляем, но не накапливаем // Все результаты добавляем в историю
if (!toolResult.contains("error")) { apiMessages.add(Message(
apiMessages.add(Message( content = """[${toolCall.name}] result: $toolResult""",
content = """[${toolCall.name}] result: $toolResult""", isUser = true,
isUser = true, role = "user"
role = "user" ))
))
// Для 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 { } else {
// Нет tool calls - это финальный ответ // Нет tool calls - это финальный ответ
finalResponse = chatResponse.content finalResponse = chatResponse.content
@ -1197,9 +1475,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
// Проверяем что sessionId не изменился пока работал запрос // Проверяем что sessionId не изменился пока работал запрос
if (currentSessionId == sessionIdAtStart) { if (currentSessionId == sessionIdAtStart) {
// НЕ добавляем сообщения об ошибках в БД - они портят контекст // НЕ добавляем сообщения об ошибках в БД - они портят контекст
val isError = responseToShow.contains("Timed out") || // Проверяем более строго - только явные ошибки, а не просто упоминание в тексте
responseToShow.contains("таймаут") || val isError = responseToShow.startsWith("Timed out", ignoreCase = true) ||
responseToShow.startsWith("Ошибка:") 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) { if (!isError) {
addMessage(Message(content = responseToShow, isUser = false, senderName = selectedModel), sessionIdAtStart) addMessage(Message(content = responseToShow, isUser = false, senderName = selectedModel), sessionIdAtStart)
@ -1225,7 +1507,23 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
sendButton.isEnabled = true sendButton.isEnabled = true
progressIndicator.isVisible = false progressIndicator.isVisible = false
// Release WakeLock
if (wakeLock.isHeld) {
wakeLock.release()
}
// Stop foreground service
ApiForegroundService.stop(this@MainActivity)
} catch (e: Exception) { } catch (e: Exception) {
// Release WakeLock on error
if (wakeLock.isHeld) {
wakeLock.release()
}
// Stop foreground service on error
ApiForegroundService.stop(activityContext)
if (!isActive) return@launch if (!isActive) return@launch
android.util.Log.e("MainActivity", "Exception: ${e.message}", e) android.util.Log.e("MainActivity", "Exception: ${e.message}", e)
if (currentSessionId == sessionIdAtStart) { if (currentSessionId == sessionIdAtStart) {