From ae5907c45f0f333dc4de05c90f30e2a5f9fa4a43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=91=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=B5=D0=B2?= Date: Fri, 10 Apr 2026 00:04:25 +0800 Subject: [PATCH] Add RSS parsing for news, update system prompt with RSS URLs, add current year dynamic substitution, update AGENTS.md with context optimization discussion --- AGENTS.md | 452 ++++++++++++++++ .../java/com/mistral/chat/api/TimeTools.kt | 488 ++++++++++++++++++ .../java/com/mistral/chat/ui/MainActivity.kt | 366 +++++++++++-- app/src/main/res/values/strings.xml | 69 +++ 4 files changed, 1323 insertions(+), 52 deletions(-) create mode 100644 AGENTS.md create mode 100644 app/src/main/java/com/mistral/chat/api/TimeTools.kt diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d8fa777 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,452 @@ +# Mistral Chat App - Development Context + +## Project Overview + +Android-приложение для чата с Mistral AI. Перспективный проект с развитием в сторону AI-агента с памятью, tools и автономной работой. + +**Основные технологии:** +- Kotlin + Android (minSdk 26, targetSdk 34) +- Room + SQLCipher (encrypted database) +- OkHttp для API +- Material Design 3 +- Russian language UI + +**Расположение проекта:** +``` +/Users/alexabudaev/Documents/Zed/mistral-chat-app/ +``` + +**⚠️ ВАЖНО: Принципы разработки** +- Приложение должно работать БЕЗ платных подписок и инвестиций +- Всегда использовать собственные разработки или бесплатные решения +- Не рассчитывать на имеющиеся платные API при планировании функций +- Сначала находим бесплатное решение, потом реализуем +- **Если что-то невозможно сделать без платных API или ты не можешь понять задачу - говори честно!** +- При планировании любого следующего этапа следует тщательно анализировать возможность реализовать ту или иную функцию не только исходя из программной совместимости, но и с учётом наличия бесплатных версий необходимых сервисов, API и других продуктов (или возможности написать собственное решение) + +--- + +## Completed Work + +### ✅ Core Features +- Чат с Mistral API (Chat Completion) +- Управление профилями (до 10 профилей) +- Управление сессиями (множественные чаты) +- **Генерация названия сессии** - после 2-го сообщения AI генерирует краткое название (3-5 слов) +- Шифрованное хранилище (SQLCipher + EncryptedSharedPreferences) +- Валидация API ключа (32+ символов, A-Z, a-z, 0-9) +- Левое drawer-меню с диалогами +- Тёмная/светлая тема + +### ✅ UI/UX +- Material Design 3 +- Русский язык интерфейса +- Отступы в поле ввода (12dp) +- Прокрутка к новым сообщениям +- **Долгий тап на сообщение** - меню Копировать/Редактировать/Удалить + +### ✅ Security +- API ключ: EncryptedSharedPreferences (AES-256-GCM) +- Ключ БД: EncryptedSharedPreferences (AES-256-SIV + AES-256-GCM) +- Профили, сессии, сообщения: SQLCipher + +--- + +## Current Issues & Architecture + +### ⚠️ Важное: Назначение web_search + +web_search НЕ является интерфейсом поисковика или Wikipedia. Это инструмент для AI-агента: + +**Правильная логика работы (Kai-style):** +``` +1. AI получает вопрос пользователя +2. AI решает что нужен поиск → вызывает web_search +3. Выполняются ВСЕ tool_calls параллельно +4. Результаты НЕ показываются пользователю - только отправляются AI +5. AI интерпретирует результаты → выдаёт ОДИН финальный ответ +``` + +**Проблемы с текущей реализацией:** +- ❌ Показываем промежуточные ответы пользователю (каждый tool result = сообщение) +- ❌ AI получает результаты и отвечает после КАЖДОГО tool_calls +- ❌ AI выводит куски данных вместо интерпретации + +**Требуется исправление:** +- ✅ Выполнить ВСЕ tool_calls за один проход (уже делаем) +- ✅ Результаты НЕ показывать пользователю (только AI видит) +- ✅ AI интерпретирует и выдаёт ОДИН ответ + +### 🔍 Web Search (Текущая реализация - БЕСПЛАТНОЕ решение) + +**Используется:** Russian Wikipedia API (бесплатно, без API ключа) +- **API:** `https://ru.wikipedia.org/w/api.php` +- **Метод:** `query/list/search` - поиск статей по заголовкам +- **Ограничение результатов:** до 10 статей (параметр `num_results`) +- **Ограничение символов:** 4000 символов на ответ +- **ПРИМЕЧАНИЕ:** Это временное решение! Позже можно добавить платный API для полноценного поиска (новости, погода, актуальная информация) + +**Логика работы:** +1. AI вызывает `web_search` с текстовым запросом +2. Выполняется поиск по Wikipedia API +3. Результаты (заголовки + сниппеты) обрезаются до 4000 символов +4. Результаты отправляются AI для интерпретации +5. AI выдаёт ОДИН финальный ответ пользователю + +**Tool Loop (MainActivity):** +- Максимум итераций: 15 +- Timeout на итерацию: 30 секунд +- 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) + +Kai имеет отличную документацию по tools: https://kai9000.com/docs/features/tools/ + +**Ключевые решения из Kai:** + +1. **Execution Flow:** + - Все tool calls выполняются параллельно (coroutine async/await) + - TOOL_EXECUTING показывается в UI как "пульсирующий индикатор" + - Результаты НЕ показываются пользователю - только отправляются AI + - AI может вызвать еще tool calls → цикл повторяется + - Когда AI отвечает без tool_calls → финальный текст показан пользователю + +2. **Safety Guards (важно!):** + - Iteration limit: максимум 15 итераций + - Repeated call detection: если одинаковый tool с одинаковыми аргументами вызывается 3 раза подряд → остановка + - Timeout: 30 секунд по умолчанию + - Result truncation: результаты > 8000 символов обрезаются + - Context trimming: между итерациями обрезается история сообщений + +3. **Web Search в Kai:** + - Есть встроенный web_search tool + - Работает (вероятно использует платный API или свой парсинг) + +--- + +## Active Plan (Phases 1-3) + +### Phase 1: Расширенные профили (Extended Profiles) +**Статус:** ✅ Завершена | **Оценка:** 1-2 дня + +Добавлено поле `systemPrompt` в профиль для отправки как role: "system". + +| Задача | Статус | +|--------|--------| +| Profile entity | ✅ Добавлено поле systemPrompt | +| Profile dialog UI | ✅ Добавлен EditText с maxLength=4000 | +| ProfileDao | ✅ CRUD работает | +| MainActivity | ✅ Инжектирует systemPrompt как role: "system" | +| MistralClient | ✅ Использует msg.role | + +--- + +### Phase 2: Система памяти (Memory System) +**Статус:** ✅ Завершена | **Оценка:** 2-3 дня + +Система запоминания информации с категориями и hitCount. + +| Задача | Статус | +|--------|--------| +| Memory entity | ✅ key, value, category, hitCount, timestamps | +| MemoryDao | ✅ CRUD + getByCategory, incrementHitCount, getPromotionCandidates | +| ChatDatabase | ✅ Добавлен MemoryDao, version=2 | +| MemoryRepository | ✅ buildMemoryContext() для инжекции в prompt | + +**Memory categories:** +- GENERAL — общие факты +- LEARNING — выводы и паттерны +- ERROR — известные ошибки +- PREFERENCE — предпочтения пользователя + +**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 | + +**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: Иркутск + +--- + +## Unconfirmed Phases (Not Approved) + +Следующие фазы требуют дополнительного планирования: + +### Phase 4: Heartbeat +**Оценка:** 2-3 дня + +Автономная периодическая самопроверка: +- WorkManager задача (каждые 30 минут) +- Active hours (8:00-22:00) +- Обработка ответа (молча vs уведомление) + +### Phase 5: Email (IMAP/SMTP) +**Оценка:** 4-5 дней + +Интеграция с email без OAuth: +- IMAP клиент (чтение писем) +- SMTP клиент (отправка) +- UI настройки ящика (сервер, порт, логин, пароль) +- Email tools для AI + +--- + +## Technical Context + +### ⚠️ ВАЖНО: Сборка APK после каждого изменения +**После каждого исправления или добавления функций НЕОБХОДИМО собирать APK!** + +Пользователь должен иметь возможность сразу протестировать изменения. + +```bash +# Сборка APK +JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug + +# Путь к APK +app/build/outputs/apk/debug/app-debug.apk +``` + +### Key Files +- `app/src/main/java/com/mistral/chat/ui/MainActivity.kt` — главная активность +- `app/src/main/java/com/mistral/chat/api/MistralClient.kt` — API клиент +- `app/src/main/java/com/mistral/chat/api/ToolExecutor.kt` — менеджер tools +- `app/src/main/java/com/mistral/chat/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) + +--- + +## ⚠️ ВАЖНЫЕ ПРАВИЛА РАЗРАБОТКИ + +### Запрет на удаление реализованных функций +**НИКОГДА не удаляй уже реализованные функции!** Даже если они кажутся неидеальными: +- Если нужно изменить поведение - исправь, а не удаляй +- Если что-то сломалось - почини, а не упрощай удалением +- При удалении функций (даже "неиспользуемых") всегда согласовывай с пользователем + +### Запрет на хардкодинг переменных +**НИКОГДА не хардкодь значения, которые должны быть динамическими!** +- Даты, года, время,地名, названия - всё должно подставляться из системы/контекста +- Если что-то не получается реализовать без хардкода - ОБСУДИ с пользователем перед реализацией +- Пример правильного подхода: `{CURRENT_YEAR}` → подставляется через `SimpleDateFormat` + +### Сборка APK после каждого изменения +**После каждого исправления или добавления функций ОБЯЗАТЕЛЬНО собирай APK!** +- Пользователь должен иметь возможность сразу протестировать изменения +- Команда: `JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug` +- Расположение: `app/build/outputs/apk/debug/app-debug.apk` + +### Дублирование сообщений при переключении сессий (BUG FIX) +**Проблема:** При переходе из второй сессии в первую (или любую другую) сообщения дублировались. + +**Причина:** Асинхронная загрузка сообщений без проверки актуальности sessionId. + +**Решение в MainActivity.kt:** +1. Очищаем список СРАЗУ при переключении (до асинхронной загрузки) +2. Используем `loadMessagesJob` для отмены предыдущей загрузки сообщений +3. Проверяем sessionId внутри async загрузки (несколько раз) +4. Передаём `expectedSessionId` в `addMessage` для правильного сохранения в БД +5. Прокрутка к последнему сообщению после загрузки + +### ⚠️ ВАЖНО: Логика прокрутки чата +**Правильная реализация:** +1. **К концу сообщения пользователя** - прокрутка к концу (scrollToPosition) через 100мс после добавления +2. **К началу ответа ИИ** - прокрутка к НАЧАЛУ (scrollToPositionWithOffset) через 150мс после добавления сообщения ИИ + +**Техническая реализация:** +- В `addMessage()`: для сообщений ИИ (`!message.isUser`) - прокрутка к началу через 150мс +- Используй `layoutManager.scrollToPositionWithOffset(position, 0)` для прокрутки к началу элемента +- Используй `scrollToPosition(position)` для прокрутки к концу элемента +- Проверяй `!userScrolledAfterSend` перед прокруткой к ответу ИИ + +### Удаление debug логирования +После отладки и подтверждения что баг исправлен - удали все `android.util.Log.d("DEBUG", ...)` из кода. + +### Порядок действий при работе с багом +1. Проанализируй код и найди причину +2. Исправь проблему, а не симптомы +3. Не удаляй существующий функционал +4. Проверь что исправление не ломает другие сценарии +5. Документируй исправление в agents.md + +--- + +## Выводы и предмет для обсуждения + +### WebSearchTool +- **Wikipedia API** - работает, но содержит только энциклопедические статьи (нет погоды, новостей) +- **DuckDuckGo Instant Answer API** - возвращает 0 результатов для большинства запросов (ограничение бесплатного API) +- **Вывод:** Текущая реализация web_search не может полноценно заменить поисковик + +### OpenUrlTool (предложено, отложено) +- AI не знает все URL наизусть - нужен либо справочник в system prompt, либо web_search для нахождения URL +- При гибридном подходе: web_search находит URL → open_url парсит страницу +- Проблема: в system prompt не влезет список URL для всех типичных запросов (погода, новости, курсы валют и т.д.) +- **Вывод:** Реализация отложена до починки web_search + +### Tool Execution Loop +- Предыдущая реализация: 1 итерация → результаты → финальный запрос без tools +- **Проблема:** Не даёт AI сделать несколько последовательных поисков (web_search → получить URL → open_url) +- Новая реализация: до 15 итераций, как в Kai - AI сам решает сколько поисков нужно +- Лимит iteration: 15 +- Timeout на итерацию: 30 сек +- Если API Mistral не выдержит - снизим до 10 или 5 + +--- + +## 📋 Контекст сессии и оптимизация (В ОБСУЖДЕНИИ) + +### Текущая реализация (без оптимизации) + +При каждом запросе отправляется полный контекст: +1. System prompt (профиль) +2. Текущая дата и время +3. Часовой пояс + город +4. Контекст профиля (имя, о себе) +5. Контекст памяти (факты, выводы, предпочтения) +6. **ВСЕ сообщения сессии** +7. Результаты tool calls (полностью, до 2000 символов каждый) + +**Проблемы:** +- При 2-3 tool calls (RSS + статья) добавляется 4000-6000 символов в контекст +- При росте сессии (100+ сообщений) запрос станет слишком большим +- 503 ошибки чаще происходят при больших запросах +- Превышение лимита токенов контекста + +### Варианты решения + +**1. Trimming (простое)** +- Оставлять только последние N сообщений + память + system prompt +- Просто реализовать, но теряется история + +**2. Свёртывание tool results** +- Не добавлять полный результат open_url в историю +- Добавлять краткую выжимку: "Найдено 5 новостей о [тема]" +- Сложнее реализовать, сохраняет суть + +**3. Контекстное окно (гибкое)** +- Оставлять последние N сообщений + summary предыдущих +- ИИ сам решает что важно +- Сложная реализация + +**Статус:** Не решено, требует обсуждения с пользователем + +--- + +## Conversation Context (for AI Agent) + +**При начале новой сессии:** +Прочитай файл AGENTS.md для понимания текущего контекста разработки. + +**При запросе "продолжаем":** +Мы работаем над Phase 3 (Tools). Последняя завершённая задача — добавление настроек location (timezone/city) в drawer menu. + +**Важно:** +- Пушить в GitHub только после тестирования и подтверждения пользователя +- Не делать push автоматически после каждого изменения + +--- + +*Last updated: 2026-04-10* +*Version: 1.9* \ No newline at end of file diff --git a/app/src/main/java/com/mistral/chat/api/TimeTools.kt b/app/src/main/java/com/mistral/chat/api/TimeTools.kt new file mode 100644 index 0000000..8aa0c89 --- /dev/null +++ b/app/src/main/java/com/mistral/chat/api/TimeTools.kt @@ -0,0 +1,488 @@ +package com.mistral.chat.api + +import com.google.gson.JsonObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class GetTimeTool( + private val getDefaultTimezone: () -> String +) : Tool( + name = "get_local_time", + description = "Получить текущую дату и время.", + inputSchema = JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("object")) + add("properties", JsonObject().apply { + add("timezone", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("string")) + add("description", com.google.gson.JsonPrimitive("Часовой пояс (опционально)")) + }) + add("format", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("string")) + add("description", com.google.gson.JsonPrimitive("Формат даты/времени (опционально). Пример: 'dd MMMM yyyy, HH:mm'")) + }) + }) + } +) { + override suspend fun execute(arguments: JsonObject): String { + val timezone = arguments.get("timezone")?.asString ?: getDefaultTimezone() + val format = arguments.get("format")?.asString ?: "dd MMMM yyyy, HH:mm" + + return try { + val sdf = SimpleDateFormat(format, Locale("ru", "RU")) + sdf.timeZone = java.util.TimeZone.getTimeZone(timezone) + val now = Date() + val formatted = sdf.format(now) + """{"status": "success", "time": "$formatted", "timezone": "$timezone"}""" + } catch (e: Exception) { + """{"status": "error", "message": "${e.message}"}""" + } + } +} + +class GetDateTool( + private val getDefaultTimezone: () -> String +) : Tool( + name = "get_date", + description = "Получить текущую дату. Используй когда пользователь спрашивает какое сегодня число.", + inputSchema = JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("object")) + add("properties", JsonObject().apply { + add("format", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("string")) + add("description", com.google.gson.JsonPrimitive("Формат даты (опционально). Пример: 'dd MMMM yyyy'")) + }) + }) + } +) { + override suspend fun execute(arguments: JsonObject): String { + val format = arguments.get("format")?.asString ?: "dd MMMM yyyy" + val timezone = getDefaultTimezone() + + return try { + val sdf = SimpleDateFormat(format, Locale("ru", "RU")) + sdf.timeZone = java.util.TimeZone.getTimeZone(timezone) + val now = Date() + """{"status": "success", "date": "${sdf.format(now)}"}""" + } catch (e: Exception) { + """{"status": "error", "message": "${e.message}"}""" + } + } +} + +class GetWeatherTool( + private val getDefaultCity: () -> String +) : Tool( + name = "get_weather", + description = "Получить текущую погоду и прогноз на 7 дней в указанном городе.", + inputSchema = JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("object")) + add("properties", JsonObject().apply { + add("city", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("string")) + add("description", com.google.gson.JsonPrimitive("Город для прогноза погоды")) + }) + }) + } +) { + private val httpClient = okhttp3.OkHttpClient.Builder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .build() + + override suspend fun execute(arguments: JsonObject): String = withContext(Dispatchers.IO) { + val city = arguments.get("city")?.asString ?: getDefaultCity() + getWeather(city) + } + + private fun getCityCoords(cityName: String): Pair? { + return try { + val encodedCity = java.net.URLEncoder.encode(cityName, "UTF-8") + val request = okhttp3.Request.Builder() + .url("https://geocoding-api.open-meteo.com/v1/search?name=$encodedCity&count=1&language=ru&format=json") + .get() + .build() + val response = httpClient.newCall(request).execute() + val body = response.body?.string() ?: "" + val json = com.google.gson.JsonParser.parseString(body).asJsonObject + val results = json.get("results")?.asJsonArray + if (results != null && results.size() > 0) { + val lat = results[0].asJsonObject.get("latitude").asDouble + val lon = results[0].asJsonObject.get("longitude").asDouble + Pair(lat, lon) + } else null + } catch (e: Exception) { + null + } + } + + private fun getWeather(cityName: String): String { + val coords = getCityCoords(cityName) ?: return """{"status": "error", "message": "Город не найден"}""" + return try { + val (lat, lon) = coords + + // Запрашиваем текущую погоду + прогноз на 7 дней + val request = okhttp3.Request.Builder() + .url("https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon¤t=temperature_2m,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max&timezone=auto&forecast_days=7") + .get() + .build() + val response = httpClient.newCall(request).execute() + val body = response.body?.string() ?: "" + val json = com.google.gson.JsonParser.parseString(body).asJsonObject + + val current = json.get("current")?.asJsonObject + val daily = json.get("daily")?.asJsonObject + + if (current != null && daily != null) { + // Текущая погода + val temp = current.get("temperature_2m")?.asDouble ?: 0.0 + val wind = current.get("wind_speed_10m")?.asDouble ?: 0.0 + val code = current.get("weather_code")?.asInt ?: 0 + val weather = getWeatherDescription(code) + + // Прогноз на 7 дней + val dailyTime = daily.get("time")?.asJsonArray + val dailyMaxTemp = daily.get("temperature_2m_max")?.asJsonArray + val dailyMinTemp = daily.get("temperature_2m_min")?.asJsonArray + val dailyCode = daily.get("weather_code")?.asJsonArray + val dailyPrecip = daily.get("precipitation_sum")?.asJsonArray + val dailyPrecipProb = daily.get("precipitation_probability_max")?.asJsonArray + + val forecastLines = mutableListOf() + + if (dailyTime != null && dailyMaxTemp != null) { + for (i in 0 until minOf(dailyTime.size(), 7)) { + val date = dailyTime.get(i).asString?.takeLast(5) ?: "" + val maxTemp = dailyMaxTemp.get(i).asDouble ?: 0.0 + val minTemp = dailyMinTemp?.get(i)?.asDouble ?: maxTemp + val dayCode = dailyCode?.get(i)?.asInt ?: 0 + val precip = dailyPrecip?.get(i)?.asDouble ?: 0.0 + val precipProb = dailyPrecipProb?.get(i)?.asInt ?: 0 + + val dayWeather = getWeatherDescription(dayCode) + val dayName = getDayName(i) + + forecastLines.add("$dayName ($date): макс $maxTemp°C, мин $minTemp°C, $dayWeather, осадки ${precip}мм ($precipProb%)") + } + } + + val forecastText = if (forecastLines.isNotEmpty()) { + "\n\nПрогноз на 7 дней:\n" + forecastLines.joinToString("\n") + } else { + "" + } + + """Текущая погода в $cityName: $temp°C, $weather, ветер ${wind}km/h$forecastText""" + } else { + """{"status": "error", "message": "Не удалось получить погоду"}""" + } + } catch (e: Exception) { + android.util.Log.e("Weather", "Error: ${e.message}", e) + """{"status": "error", "message": "Ошибка получения погоды: ${e.message}"}""" + } + } + + private fun getDayName(dayIndex: Int): String { + return when (dayIndex) { + 0 -> "Сегодня" + 1 -> "Завтра" + 2 -> "Послезавтра" + else -> "День ${dayIndex + 1}" + } + } + + private fun getWeatherDescription(code: Int): String { + return when (code) { + 0 -> "Ясно" + 1, 2, 3 -> "Облачно" + 45, 48 -> "Туман" + 51, 53, 55 -> "Морось" + 56, 57 -> "Ледяная морось" + 61, 63, 65 -> "Дождь" + 66, 67 -> "Ледяной дождь" + 71, 73, 75 -> "Снег" + 77 -> "Снежные зёрна" + 80, 81, 82 -> "Ливень" + 85, 86 -> "Снегопад" + 95 -> "Гроза" + 96, 99 -> "Гроза с градом" + else -> "Неизвестно" + } + } +} + +class WebSearchTool( + private val getDefaultCity: () -> String +) : Tool( + name = "web_search", + description = "Поиск информации в Wikipedia (русской и английской). Проверяй обе версии для получения актуальной информации.", + inputSchema = JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("object")) + add("properties", JsonObject().apply { + add("query", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("string")) + add("description", com.google.gson.JsonPrimitive("Поисковый запрос")) + }) + add("location", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("string")) + add("description", com.google.gson.JsonPrimitive("Место (опционально). По умолчанию - ${getDefaultCity()}")) + }) + add("num_results", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("number")) + add("description", com.google.gson.JsonPrimitive("Количество результатов (по умолчанию 10)")) + }) + }) + add("required", com.google.gson.JsonArray().apply { + add("query") + }) + } +) { + private val httpClient = okhttp3.OkHttpClient.Builder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .build() + + override suspend fun execute(arguments: JsonObject): String = withContext(Dispatchers.IO) { + val query = arguments.get("query")?.asString + val location = arguments.get("location")?.asString ?: getDefaultCity() + val numResults = arguments.get("num_results")?.asInt ?: 10 + + if (query.isNullOrEmpty()) { + return@withContext """{"status": "error", "message": "Query is required"}""" + } + + try { + // Ищем и в русской, и в английской Wikipedia параллельно + val ruResults = coroutineScope { + async { searchWikipedia(query, numResults, "ru") }.await() + } + val enResults = coroutineScope { + async { searchWikipedia(query, numResults, "en") }.await() + } + + val allResults = mutableListOf() + + if (ruResults.isNotEmpty()) { + allResults.add("=== РУССКАЯ WIKIPEDIA ===") + allResults.addAll(ruResults) + } + + if (enResults.isNotEmpty()) { + allResults.add("=== ENGLISH WIKIPEDIA ===") + allResults.addAll(enResults) + } + + if (allResults.isEmpty()) { + """{"status": "success", "message": "Ничего не найдено по запросу '$query'"}""" + } else { + val responseText = allResults.joinToString("\n\n").take(4000) + """Найденная информация:\n\n$responseText""" + } + } catch (e: Exception) { + """{"status": "error", "message": "Search failed: ${e.message}"}""" + } + } + + private fun searchWikipedia(query: String, limit: Int, lang: String): List { + val results = mutableListOf() + + try { + val encodedQuery = java.net.URLEncoder.encode(query, "UTF-8") + val wikiLang = if (lang == "en") "en" else "ru" + + val searchRequest = okhttp3.Request.Builder() + .url("https://$wikiLang.wikipedia.org/w/api.php?action=query&list=search&srsearch=$encodedQuery&srlimit=$limit&format=json&origin=*") + .header("User-Agent", "MistralChat/1.0") + .header("Accept", "application/json") + .get() + .build() + + val searchResponse = httpClient.newCall(searchRequest).execute() + val searchBody = searchResponse.body?.string() ?: "" + + val json = com.google.gson.JsonParser.parseString(searchBody).asJsonObject + val queryObj = json.get("query")?.asJsonObject + val searchArray = queryObj?.get("search")?.asJsonArray + + if (searchArray != null && searchArray.size() > 0) { + for (i in 0 until minOf(searchArray.size(), limit)) { + val item = searchArray[i].asJsonObject + val title = item.get("title")?.asString ?: "" + val snippet = item.get("snippet")?.asString ?: "" + + if (title.isNotEmpty()) { + val cleanSnippet = snippet + .replace(Regex("<[^>]*>"), "") + .replace(""", "\"") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + + val text = if (cleanSnippet.isNotEmpty()) { + "Статья: $title\nСодержание: $cleanSnippet" + } else { + "Статья: $title" + } + results.add(text) + } + } + } + } catch (e: Exception) { + // Игнорируем ошибки поиска + } + + return results + } +} + +class OpenUrlTool : Tool( + name = "open_url", + description = "Получить текст с веб-страницы или RSS-ленты по URL. Автоматически определяет RSS и парсит заголовки новостей.", + inputSchema = JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("object")) + add("properties", JsonObject().apply { + add("url", JsonObject().apply { + add("type", com.google.gson.JsonPrimitive("string")) + add("description", com.google.gson.JsonPrimitive("URL страницы или RSS-ленты (например: https://lenta.ru/rss/, https://www.kommersant.ru/rss/news.xml)")) + }) + }) + add("required", com.google.gson.JsonArray().apply { + add("url") + }) + } +) { + private val httpClient = okhttp3.OkHttpClient.Builder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .build() + + override suspend fun execute(arguments: JsonObject): String = withContext(Dispatchers.IO) { + val url = arguments.get("url")?.asString + + if (url.isNullOrEmpty()) { + return@withContext """{"status": "error", "message": "URL is required"}""" + } + + val normalizedUrl = url.lowercase() + if (normalizedUrl.startsWith("javascript:") || + normalizedUrl.startsWith("file:") || + normalizedUrl.startsWith("data:") || + normalizedUrl.startsWith("mailto:") || + normalizedUrl.startsWith("tel:")) { + return@withContext """{"status": "error", "message": "Недопустимый тип URL"}""" + } + + try { + val request = okhttp3.Request.Builder() + .url(url) + .header("User-Agent", "Mozilla/5.0 (Android)") + .header("Accept", "application/rss+xml,application/atom+xml,application/xml,text/xml,text/html,application/xhtml+xml") + .get() + .build() + + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext """{"status": "error", "message": "Ошибка HTTP: ${response.code}"}""" + } + + val contentType = response.header("Content-Type") ?: "" + val body = response.body?.string() ?: "" + + val isRss = contentType.contains("xml") || body.trim().startsWith("]*>.*?", RegexOption.DOT_MATCHES_ALL), "") + .replace(Regex("]*>.*?", RegexOption.DOT_MATCHES_ALL), "") + .replace(Regex("<[^>]+>"), " ") + .replace(Regex("\\s+"), " ") + .replace(" ", " ") + .replace(""", "\"") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("—", "—") + .replace("–", "–") + .trim() + } + + val result = textOnly.take(2000) + """{"status": "success", "content": "$result"}""" + } catch (e: Exception) { + """{"status": "error", "message": "Ошибка загрузки: ${e.message}""" + } + } + + private fun parseRssFeed(xml: String): String { + val items = mutableListOf() + + try { + val cleanXml = xml + .replace(Regex("", RegexOption.DOT_MATCHES_ALL), "") + + val titleMatch = Regex("<!\\[CDATA\\[(.*?)\\]\\]>|(.*?)", RegexOption.DOT_MATCHES_ALL).find(cleanXml) + val feedTitle = titleMatch?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } ?: "" + + val itemRegex = Regex( + "|", + RegexOption.DOT_MATCHES_ALL + ) + + val itemMatches = itemRegex.findAll(cleanXml) + + for ((index, match) in itemMatches.withIndex()) { + if (index >= 15) break + + val start = match.range.first + val endRange = if (index + 1 < itemMatches.count()) { + itemRegex.findAll(cleanXml).toList()[index + 1].range.first + } else { + cleanXml.length + } + + val itemXml = cleanXml.substring(start, endRange) + + val itemTitle = Regex("<!\\[CDATA\\[(.*?)\\]\\]>|(.*?)", RegexOption.DOT_MATCHES_ALL) + .find(itemXml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } ?: "" + + val itemLink = Regex("(.*?)").find(itemXml)?.groupValues?.getOrNull(1) ?: "" + + val itemDesc = Regex("|(.*?)", RegexOption.DOT_MATCHES_ALL) + .find(itemXml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } ?: "" + + val itemDate = Regex("|").find(itemXml)?.let { dateMatch -> + val dateStart = dateMatch.range.last + 1 + val dateEnd = minOf(dateStart + 50, cleanXml.length) + val dateSection = cleanXml.substring(dateStart, dateEnd) + Regex("(<[^>]+>)").replace(dateSection, "").trim() + } ?: "" + + if (itemTitle.isNotEmpty()) { + val itemText = buildString { + append("• $itemTitle") + if (itemDate.isNotEmpty()) append(" [$itemDate]") + if (itemLink.isNotEmpty()) append(" | ${itemLink}") + if (itemDesc.isNotEmpty() && itemDesc.length < 150) append(" - $itemDesc") + } + items.add(itemText) + } + } + + if (feedTitle.isNotEmpty()) { + return "=== $feedTitle ===\n\n" + items.joinToString("\n") + } + return items.joinToString("\n") + + } catch (e: Exception) { + return "Ошибка парсинга RSS: ${e.message}" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt index c8038e7..4bfedb0 100644 --- a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt +++ b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt @@ -33,9 +33,11 @@ import com.google.android.material.button.MaterialButton import com.google.gson.Gson import com.mistral.chat.R import com.mistral.chat.api.MistralClient +import com.mistral.chat.api.ToolExecutor import com.mistral.chat.data.ChatDatabase import com.mistral.chat.data.Message import com.mistral.chat.data.MessageEntity +import com.mistral.chat.data.MemoryRepository import com.mistral.chat.data.Profile import com.mistral.chat.data.Session import com.mistral.chat.data.toMessage @@ -47,6 +49,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener { @@ -66,12 +71,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private var currentJob: kotlinx.coroutines.Job? = null private var client: MistralClient? = null + private var toolExecutor: ToolExecutor? = null private val messages = mutableListOf() private var availableModels: List> = emptyList() - private var selectedModelName: String = "mistral-small-latest" + private var selectedModelName: String = "mistral-medium-latest" private lateinit var prefs: SharedPreferences private lateinit var encryptedPrefs: SharedPreferences private lateinit var database: ChatDatabase + private lateinit var memoryRepository: MemoryRepository private val profiles = mutableListOf() private val sessions = mutableListOf() @@ -94,6 +101,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private const val KEY_LAST_PROFILE_ID = "last_profile_id" private const val KEY_SELECTED_MODEL = "selected_model" private const val KEY_THEME_MODE = "theme_mode" + private const val KEY_DEFAULT_TIMEZONE = "default_timezone" + private const val KEY_DEFAULT_CITY = "default_city" private const val MAX_PROFILES = 10 } @@ -128,8 +137,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte showApiKeyDialog() } + database = ChatDatabase.getInstance(this) + memoryRepository = MemoryRepository(database.memoryDao()) + client = MistralClient(getApiKey()) - + toolExecutor = ToolExecutor(memoryRepository, this, getDefaultTimezone(), getDefaultCity()) + client?.setToolExecutor(toolExecutor!!) + logoButton = findViewById(R.id.logoButton) menuButton = findViewById(R.id.menuButton) hamburgerButton = findViewById(R.id.hamburgerButton) @@ -141,8 +155,6 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte drawerLayout = findViewById(R.id.drawerLayout) navigationView = findViewById(R.id.navigationView) rightPanel = findViewById(R.id.rightPanelContainer) - - database = ChatDatabase.getInstance(this) setupToolbar() setupRecyclerView() @@ -203,6 +215,9 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte R.id.action_session -> { showSettingsDialog() } + R.id.action_location -> { + showLocationDialog() + } R.id.action_about -> { showAboutDialog() } @@ -260,6 +275,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte if (deleteProfiles) { database.profileDao().deleteAll() currentProfileId = null + memoryRepository.setCurrentProfile(null) + memoryRepository.deleteAnonymous() prefs.edit().remove(KEY_LAST_PROFILE_ID).apply() profiles.clear() } @@ -338,6 +355,35 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte .show() } + private fun showLocationDialog() { + val dialogView = layoutInflater.inflate(R.layout.dialog_location, null) + val timezoneInput = dialogView.findViewById(R.id.timezoneInput) + val cityInput = dialogView.findViewById(R.id.cityInput) + + timezoneInput.setText(getDefaultTimezone()) + cityInput.setText(getDefaultCity()) + + AlertDialog.Builder(this) + .setTitle(R.string.location_title) + .setView(dialogView) + .setPositiveButton(R.string.save) { _, _ -> + val timezone = timezoneInput.text.toString().trim() + val city = cityInput.text.toString().trim() + + if (timezone.isNotEmpty()) { + setDefaultTimezone(timezone) + } + if (city.isNotEmpty()) { + setDefaultCity(city) + } + + toolExecutor?.updateSettings(timezone = getDefaultTimezone(), city = getDefaultCity()) + Toast.makeText(this, "Настройки сохранены", Toast.LENGTH_SHORT).show() + } + .setNegativeButton(R.string.cancel, null) + .show() + } + private fun setupToolbar() { hamburgerButton.isVisible = true @@ -406,6 +452,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } else { null } + memoryRepository.setCurrentProfile(currentProfileId) val profileId = currentProfileId if (profileId != null) { prefs.edit().putLong(KEY_LAST_PROFILE_ID, profileId).apply() @@ -462,6 +509,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private fun selectProfile(profile: Profile) { currentProfileId = profile.id + memoryRepository.setCurrentProfile(profile.id) prefs.edit().putLong(KEY_LAST_PROFILE_ID, profile.id).apply() profilesAdapter?.refresh() val profileName = profiles.find { it.id == currentProfileId }?.name ?: profile.name @@ -491,7 +539,12 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte .setMessage("Удалить профиль ${profile.name}?") .setPositiveButton(R.string.yes) { _, _ -> lifecycleScope.launch { + memoryRepository.deleteByProfile(profile.id) database.profileDao().delete(profile) + if (currentProfileId == profile.id) { + currentProfileId = null + memoryRepository.setCurrentProfile(null) + } } } .setNegativeButton(R.string.no, null) @@ -499,20 +552,49 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } private fun selectSession(session: Session) { + currentJob?.cancel() + currentSessionId = session.id userMessageCount = 0 + userScrolledAfterSend = false prefs.edit().putLong("last_session_id", session.id).apply() + + messages.clear() + adapter.notifyDataSetChanged() + loadSessionMessages(session.id) updateRightPanel() drawerLayout.closeDrawer(GravityCompat.END) } + private var loadMessagesJob: Job? = null + private fun loadSessionMessages(sessionId: Long) { - lifecycleScope.launch { - val dbMessages = database.messageDao().getMessagesBySessionSync(sessionId) + loadMessagesJob?.cancel() + + loadMessagesJob = lifecycleScope.launch { + val targetSessionId = sessionId + + if (!isActive || currentSessionId != targetSessionId) { + return@launch + } + + val dbMessages = database.messageDao().getMessagesBySessionSync(targetSessionId) + + if (!isActive || currentSessionId != targetSessionId) { + return@launch + } + messages.clear() messages.addAll(dbMessages.map { it.toMessage() }) adapter.notifyDataSetChanged() + + if (messages.isNotEmpty()) { + recyclerView.postDelayed({ + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + layoutManager.scrollToPositionWithOffset(messages.size - 1, 0) + }, 100) + } } } @@ -597,7 +679,11 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } private fun setupRecyclerView() { - adapter = MessageAdapter(messages) + adapter = MessageAdapter( + messages, + onMessageEdit = { position, message -> editMessage(position, message) }, + onMessageDelete = { position, message -> deleteMessage(position, message) } + ) recyclerView.layoutManager = LinearLayoutManager(this).apply { stackFromEnd = true } @@ -617,6 +703,49 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte }) } + private fun editMessage(position: Int, message: Message) { + val editText = EditText(this) + editText.setText(message.content) + AlertDialog.Builder(this) + .setTitle("Редактировать сообщение") + .setView(editText) + .setPositiveButton(R.string.save) { _, _ -> + val newContent = editText.text.toString().trim() + if (newContent.isNotEmpty() && newContent != message.content) { + val sessionId = currentSessionId + val timestamp = message.timestamp + messages[position] = message.copy(content = newContent) + adapter.notifyItemChanged(position) + lifecycleScope.launch { + if (sessionId != null) { + database.messageDao().updateContent(sessionId, timestamp, newContent) + } + } + } + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun deleteMessage(position: Int, message: Message) { + AlertDialog.Builder(this) + .setTitle("Удалить сообщение") + .setMessage("Вы уверены, что хотите удалить это сообщение?") + .setPositiveButton(R.string.yes) { _, _ -> + val sessionId = currentSessionId + val timestamp = message.timestamp + lifecycleScope.launch { + if (sessionId != null) { + database.messageDao().deleteByTimestamp(sessionId, timestamp) + } + } + messages.removeAt(position) + adapter.notifyItemRemoved(position) + } + .setNegativeButton(R.string.no, null) + .show() + } + private fun setupInput() { sendButton.setOnClickListener { sendInput() @@ -643,12 +772,15 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte return } + // Отменяем предыдущий запрос перед новым + currentJob?.cancel() + if (currentSessionId == null) { createNewSessionAndSend(userInput) return } - addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName())) + addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()), currentSessionId) inputField.text?.clear() @@ -656,13 +788,17 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte lastUserMessagePosition = messages.size - 1 recyclerView.postDelayed({ - recyclerView.scrollToPosition(lastUserMessagePosition) + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + layoutManager.scrollToPositionWithOffset(lastUserMessagePosition, 0) }, 100) sendMessage(userInput) } private fun createNewSessionAndSend(userInput: String) { + // Отменяем предыдущий запрос + currentJob?.cancel() + lifecycleScope.launch { val session = Session( profileId = if (currentProfileId != null && profiles.any { it.id == currentProfileId }) currentProfileId else null, @@ -671,11 +807,12 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val sessionId = database.sessionDao().insert(session) currentSessionId = sessionId userMessageCount = 0 + userScrolledAfterSend = false messages.clear() adapter.notifyDataSetChanged() updateRightPanel() - addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName())) + addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()), sessionId) inputField.text?.clear() sendMessage(userInput) } @@ -755,38 +892,40 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL) if (!hasUserSelectedModel) { runOnUiThread { - selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-small-latest" + selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-medium-latest" } } } } } - private fun addMessage(message: Message) { - val newPosition = messages.size - 1 + private fun addMessage(message: Message, expectedSessionId: Long? = null) { + val targetSessionId = expectedSessionId ?: currentSessionId messages.add(message) + val newPosition = messages.size - 1 + adapter.notifyItemInserted(newPosition) if (!message.isUser && !userScrolledAfterSend) { recyclerView.postDelayed({ if (!userScrolledAfterSend) { - recyclerView.scrollToPosition(newPosition) + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + layoutManager.scrollToPositionWithOffset(newPosition, 0) } }, 150) } - val sessionId = currentSessionId - if (sessionId != null) { + if (targetSessionId != null) { lifecycleScope.launch { val entity = MessageEntity( - sessionId = sessionId, + sessionId = targetSessionId, content = message.content, isUser = message.isUser, timestamp = message.timestamp ) database.messageDao().insert(entity) - database.sessionDao().updateTimestamp(sessionId) + database.sessionDao().updateTimestamp(targetSessionId) } } } @@ -809,6 +948,22 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte return encryptedPrefs.getString(KEY_API_KEY, null) ?: "" } + private fun getDefaultTimezone(): String { + return prefs.getString(KEY_DEFAULT_TIMEZONE, "Europe/Moscow") ?: "Europe/Moscow" + } + + private fun setDefaultTimezone(timezone: String) { + prefs.edit().putString(KEY_DEFAULT_TIMEZONE, timezone).apply() + } + + private fun getDefaultCity(): String { + return prefs.getString(KEY_DEFAULT_CITY, "Москва") ?: "Москва" + } + + private fun setDefaultCity(city: String) { + prefs.edit().putString(KEY_DEFAULT_CITY, city).apply() + } + private fun hasApiKey(): Boolean { return encryptedPrefs.contains(KEY_API_KEY) } @@ -816,10 +971,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private fun saveApiKey(apiKey: String) { encryptedPrefs.edit().putString(KEY_API_KEY, apiKey).apply() client = MistralClient(apiKey) + client?.setToolExecutor(toolExecutor!!) } private fun deleteApiKey() { encryptedPrefs.edit().remove(KEY_API_KEY).apply() + client = MistralClient("") + client?.setToolExecutor(toolExecutor!!) Toast.makeText(this, getString(R.string.api_key_deleted), Toast.LENGTH_SHORT).show() showApiKeyDialog() } @@ -856,6 +1014,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte saveApiKey(newKey) client = MistralClient(newKey) + client?.setToolExecutor(toolExecutor!!) apiKeyDialog?.dismiss() Toast.makeText(this, getString(R.string.api_key_saved), Toast.LENGTH_SHORT).show() } else { @@ -880,6 +1039,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private fun sendMessage(userInput: String) { val selectedModel = selectedModelName + val sessionIdAtStart = currentSessionId sendButton.isEnabled = false progressIndicator.isVisible = true @@ -887,56 +1047,137 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte currentJob = lifecycleScope.launch { try { val profileContext = getSelectedProfileContext() + val systemPrompt = getSelectedSystemPrompt() + val memoryContext = memoryRepository.buildMemoryContext() + val tools = toolExecutor?.getToolsSchema() - val apiMessages = messages.map { msg -> - Message( - content = msg.content, - isUser = msg.isUser - ) - }.toMutableList() + // Автоматически получаем текущую дату + val currentDateResult = client?.executeTool("get_date", com.google.gson.JsonObject()) + ?: """{"status": "error", "message": "Tool failed"}""" + + val apiMessages = mutableListOf() + + var finalSystemPrompt = systemPrompt + + if (finalSystemPrompt.isNotEmpty()) { + apiMessages.add(Message(content = finalSystemPrompt, isUser = false, role = "system")) + } + + // Добавляем информацию о текущей дате и местоположении + apiMessages.add(Message( + content = "Текущая дата: $currentDateResult", + isUser = true, + role = "user" + )) + + // Добавляем информацию о часовом поясе и городе + val timezone = getDefaultTimezone() + val city = getDefaultCity() + apiMessages.add(Message( + content = "Мое местоположение: часовой пояс $timezone, город $city", + isUser = true, + role = "user" + )) if (profileContext.isNotEmpty()) { - apiMessages.add(0, Message(content = profileContext, isUser = true)) + apiMessages.add(Message(content = profileContext, isUser = true, role = "user")) } - val result = withTimeout(15000L) { - client?.chat(selectedModel, apiMessages) ?: Result.failure(Exception("Client not initialized")) + if (memoryContext.isNotEmpty()) { + apiMessages.add(Message(content = memoryContext, isUser = true, role = "user")) } - if (!isActive) return@launch + apiMessages.addAll(messages.map { msg -> + Message( + content = msg.content, + isUser = msg.isUser, + role = if (msg.isUser) "user" else "assistant" + ) + }) - result.onSuccess { (response, usedModel) -> - val displayModel = usedModel.ifEmpty { "Assistant" } - addMessage(Message(content = response, isUser = false, senderName = displayModel)) - lifecycleScope.launch { - saveMessageToDatabase(currentSessionId, response, false, displayModel) + // Tool loop - до 15 итераций + var iteration = 0 + val maxIterations = 15 + var finalResponse: String? = null + + while (iteration < maxIterations) { + iteration++ + + val result = withTimeout(30000L) { + client?.chat(selectedModel, apiMessages, tools) + ?: Result.failure(Exception("Client not initialized")) } - - val count = userMessageCount + 1 - userMessageCount = count - if (count == 2 && titleGenerationJob?.isActive != true) { - titleGenerationJob = generateSessionTitle() - } - }.onFailure { error -> + if (!isActive) return@launch - val errorMessage = error.message ?: "Unknown error" - if (!errorMessage.contains("cancelled", ignoreCase = true)) { - val userFriendlyMessage = getUserFriendlyError(errorMessage) - addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error")) + + result.onSuccess { chatResponse -> + if (chatResponse.toolCalls.isNotEmpty()) { + // Выполняем все tool calls и добавляем результаты в историю + for (toolCall in chatResponse.toolCalls) { + val toolResult = client?.executeTool(toolCall.name, toolCall.arguments) + ?: """{"status": "error", "message": "Tool failed"}""" + + apiMessages.add(Message( + content = """[${toolCall.name}] result: $toolResult""", + isUser = true, + role = "user" + )) + } + // Продолжаем цикл - AI решит нужен ли еще поиск + } else { + // Нет tool calls - это финальный ответ + finalResponse = chatResponse.content + } + }.onFailure { error -> + finalResponse = "Ошибка: ${error.message}" } + + // Если есть финальный ответ или превышен лимит - выходим + if (finalResponse != null || iteration >= maxIterations) { + break + } + } + + if (finalResponse == null && iteration >= maxIterations) { + finalResponse = "Превышен лимит итераций (${maxIterations}). Попробуйте более конкретный запрос." + } + + // Показываем финальный ответ + if (finalResponse.isNullOrEmpty()) { + finalResponse = "Не удалось получить ответ. Попробуйте ещё раз." } - sendButton.isEnabled = true - progressIndicator.isVisible = false - } catch (e: kotlinx.coroutines.CancellationException) { - if (!isActive) return@launch - addMessage(Message(content = "Запрос отменён", isUser = false, senderName = "System")) + val responseToShow = finalResponse!! + + // Проверяем что sessionId не изменился пока работал запрос + if (currentSessionId == sessionIdAtStart) { + addMessage(Message(content = responseToShow, isUser = false, senderName = selectedModel), sessionIdAtStart) + + // Прокрутка к началу нового сообщения ИИ + recyclerView.post { + if (!userScrolledAfterSend) { + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + layoutManager.scrollToPositionWithOffset(messages.size - 1, 0) + } + } + + if (!responseToShow.startsWith("Ошибка:")) { + // Генерируем название сессии после второго сообщения + userMessageCount++ + if (userMessageCount == 2) { + generateSessionTitle() + } + } + } + sendButton.isEnabled = true progressIndicator.isVisible = false } catch (e: Exception) { if (!isActive) return@launch - val userFriendlyMessage = getUserFriendlyError(e.message ?: "Unknown error") - addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error")) + android.util.Log.e("MainActivity", "Exception: ${e.message}", e) + if (currentSessionId == sessionIdAtStart) { + addMessage(Message(content = "Произошла ошибка: ${e.message}", isUser = false, senderName = "Error"), sessionIdAtStart) + } sendButton.isEnabled = true progressIndicator.isVisible = false } @@ -956,6 +1197,16 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } } + private fun getSelectedSystemPrompt(): String { + if (currentProfileId == null) return "" + val profile = profiles.find { it.id == currentProfileId } + val profilePrompt = profile?.systemPrompt ?: "" + val defaultPrompt = getString(R.string.profile_system_prompt_default) + val currentYear = SimpleDateFormat("yyyy", Locale.getDefault()).format(Date()) + return if (profilePrompt.isNotEmpty()) profilePrompt + else defaultPrompt.replace("{CURRENT_YEAR}", currentYear) + } + private fun getCurrentProfileName(): String { if (currentProfileId == null) return "Вы" val profileName = profiles.find { it.id == currentProfileId }?.name @@ -973,11 +1224,17 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val nameInput = dialogView.findViewById(R.id.nameInput) val bioInput = dialogView.findViewById(R.id.bioInput) val preferencesInput = dialogView.findViewById(R.id.preferencesInput) + val systemPromptInput = dialogView.findViewById(R.id.systemPromptInput) existingProfile?.let { nameInput.setText(it.name) bioInput.setText(it.bio) preferencesInput.setText(it.preferences) + systemPromptInput.setText(it.systemPrompt) + } ?: run { + if (systemPromptInput.text.isNullOrEmpty()) { + systemPromptInput.setText(getString(R.string.profile_system_prompt_default)) + } } val dialog = AlertDialog.Builder(this) @@ -992,16 +1249,20 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte name = name, bio = bioInput.text?.toString() ?: "", preferences = preferencesInput.text?.toString() ?: "", + systemPrompt = systemPromptInput.text?.toString() ?: "", updatedAt = System.currentTimeMillis() )) } else { + val defaultSystemPrompt = getString(R.string.profile_system_prompt_default) val newId = database.profileDao().insert(Profile( name = name, bio = bioInput.text?.toString() ?: "", - preferences = preferencesInput.text?.toString() ?: "" + preferences = preferencesInput.text?.toString() ?: "", + systemPrompt = systemPromptInput.text?.toString()?.ifEmpty { defaultSystemPrompt } ?: defaultSystemPrompt )) if (currentProfileId == null) { currentProfileId = newId + memoryRepository.setCurrentProfile(newId) } } } @@ -1015,6 +1276,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte database.profileDao().delete(existingProfile) if (currentProfileId == existingProfile.id) { currentProfileId = null + memoryRepository.setCurrentProfile(null) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a459d7c..0e8bc96 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,6 +11,69 @@ Имя О себе Предпочтения + Системный промт + Инструкции для AI (до 4000 символов). Этот текст будет добавлен как system message. + Ты - AI ассистент с долговременной памятью и доступом к интернету. + +=== ТЕКУЩАЯ ДАТА === +При старте сессии ты получаешь актуальную дату автоматически. Используй эту информацию для ответов. +ВНИМАНИЕ: Сейчас {CURRENT_YEAR} год. Если пользователь спрашивает про свежие новости - проверяй даты ВСЕХ событий. Не используй старые статьи (прошлого года и ранее) как "свежие новости". + +=== ТВОИ ВОЗМОЖНОСТИ === + +У тебя есть инструменты: +1. open_url - получить текст с веб-страницы (ОСНОВНОЙ для новостей и статей) +2. web_search - поиск в Wikipedia (для фактов, справок, НЕ для новостей) +3. get_local_time - текущее время +4. get_date - текущая дата +5. get_weather - погода +6. send_notification - уведомление +7. memory_* - управление памятью + +=== ПРИОРИТЕТЫ === + +1. Свежие новости → open_url → RSS-ленты (самый эффективный способ): + - lenta.ru → https://lenta.ru/rss/ + - kommersant.ru → https://www.kommersant.ru/rss/news.xml + - ria.ru → страница (нет RSS) +2. Подробности о новости → open_url → URL статьи (если знаешь URL или видишь в тексте) +3. Факты, справки, "что такое..." → web_search → Wikipedia +4. Погода → get_weather +5. Дата/время → get_date / get_local_time + +=== ПРАВИЛА === + +1. Сейчас {CURRENT_YEAR} год - используй для проверки актуальности +2. Новости - ИСПОЛЬЗУЙ RSS для свежих новостей, это самый быстрый способ +3. Для новостей открывай RSS-ленты, они дают чистые заголовки с датами и ссылками +4. Для статьи подробнее - используй open_url с URL этой статьи +5. Факты/справки - web_search (Wikipedia) +6. Если не можешь найти актуальную информацию - скажи "не могу найти свежие данные" +7. Не придумывай даты - проверяй через open_url на сайте +8. Для предпочтений пользователя - memory_preference +9. Для важных фактов о пользователе - memory_store +10. Для выводов из поведения - memory_learn +11. При подтверждении твоего вывода - memory_reinforce +12. ЕСЛИ в памяти есть информация - используй когда нет результатов поиска +13. "Что ты обо мне знаешь?" - выведи ВСЕ факты И предпочтения +14. НЕ копируй целиком - перескажи своими словами + +=== ПРИМЕРЫ === + +- "Что в новостях?" → open_url → https://lenta.ru/rss/ (или kommersant.ru/rss/news.xml) +- "Что-то про спорт?" → open_url → lenta.ru/rss/ → выбери категорию "Спорт" +- "Подробнее про [заголовок]" → open_url → URL статьи из RSS +- "Что такое X?" → web_search → Wikipedia +- "Какая погода в Москве?" → get_weather +- "Какое сегодня число?" → get_date +- "Предпочитаю новости киберспорта" → memory_preference(key="интересы", value="киберспорт") +- "Что ты обо мне знаешь?" → выведи всю память +- "Подробнее про [заголовок]" → open_url → URL статьи (если знаешь или видишь в тексте) +- "Что такое X?" → web_search → Wikipedia +- "Какая погода в Москве?" → get_weather +- "Какое сегодня число?" → get_date +- "Предпочитаю новости киберспорта" → memory_preference(key="интересы", value="киберспорт") +- "Что ты обо мне знаешь?" → выведи всю память Сохранить Отмена Удалить @@ -51,6 +114,12 @@ Профиль: %s Внешний вид Сессия при запуске + Местоположение + Настройки местоположения + Часовой пояс + Город по умолчанию + Например: Москва, Санкт-Петербург + Настройки сохранены Очистить историю Удалить все сессии и сообщения? Открывать последнюю сессию