Add Foreground Service for background work, clean up logging
This commit is contained in:
parent
cabc6b8d85
commit
dc461ad5dc
4 changed files with 567 additions and 416 deletions
533
AGENTS.md
533
AGENTS.md
|
|
@ -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
|
||||||
1. System prompt (профиль)
|
if (getTokenCount(messages) > MAX_CONTEXT) {
|
||||||
2. Текущая дата и время
|
trimOldMessages()
|
||||||
3. Часовой пояс + город
|
}
|
||||||
4. Контекст профиля (имя, о себе)
|
}
|
||||||
5. Контекст памяти (факты, выводы, предпочтения)
|
```
|
||||||
6. **ВСЕ сообщения сессии**
|
|
||||||
7. Результаты tool calls (полностью, до 2000 символов каждый)
|
|
||||||
|
|
||||||
**Проблемы:**
|
### Files to modify
|
||||||
- При 2-3 tool calls (RSS + статья) добавляется 4000-6000 символов в контекст
|
- ToolExecutor.kt - добавить trimming в loop
|
||||||
- При росте сессии (100+ сообщений) запрос станет слишком большим
|
- MistralClient.kt - добавить getTokenCount, trimOldMessages
|
||||||
- 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*
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
82
app/src/main/java/com/mistral/chat/api/NotificationTool.kt
Normal file
82
app/src/main/java/com/mistral/chat/api/NotificationTool.kt
Normal 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}"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,7 +1380,6 @@ 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
|
||||||
|
|
@ -1145,24 +1394,53 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
} else {
|
} else {
|
||||||
val chatResult = result
|
val chatResult = result
|
||||||
chatResult.onSuccess { chatResponse ->
|
chatResult.onSuccess { chatResponse ->
|
||||||
android.util.Log.d("MainActivity", "API response: toolCalls=${chatResponse.toolCalls.size}")
|
|
||||||
|
|
||||||
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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue