Add RSS parsing for news, update system prompt with RSS URLs, add current year dynamic substitution, update AGENTS.md with context optimization discussion
This commit is contained in:
parent
5d59c5e351
commit
ae5907c45f
4 changed files with 1323 additions and 52 deletions
452
AGENTS.md
Normal file
452
AGENTS.md
Normal file
|
|
@ -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*
|
||||||
488
app/src/main/java/com/mistral/chat/api/TimeTools.kt
Normal file
488
app/src/main/java/com/mistral/chat/api/TimeTools.kt
Normal file
|
|
@ -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<Double, Double>? {
|
||||||
|
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<String>()
|
||||||
|
|
||||||
|
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<String>()
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
val results = mutableListOf<String>()
|
||||||
|
|
||||||
|
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("<?xml") || body.trim().startsWith("<rss") || body.trim().startsWith("<feed")
|
||||||
|
|
||||||
|
val textOnly = if (isRss) {
|
||||||
|
parseRssFeed(body)
|
||||||
|
} else {
|
||||||
|
body
|
||||||
|
.replace(Regex("<script[^>]*>.*?</script>", RegexOption.DOT_MATCHES_ALL), "")
|
||||||
|
.replace(Regex("<style[^>]*>.*?</style>", 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<String>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val cleanXml = xml
|
||||||
|
.replace(Regex("<!\\[CDATA\\[", RegexOption.DOT_MATCHES_ALL), "")
|
||||||
|
.replace(Regex("]]>", RegexOption.DOT_MATCHES_ALL), "")
|
||||||
|
|
||||||
|
val titleMatch = Regex("<title><!\\[CDATA\\[(.*?)\\]\\]></title>|<title>(.*?)</title>", RegexOption.DOT_MATCHES_ALL).find(cleanXml)
|
||||||
|
val feedTitle = titleMatch?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } ?: ""
|
||||||
|
|
||||||
|
val itemRegex = Regex(
|
||||||
|
"<item>|<entry>",
|
||||||
|
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("<title><!\\[CDATA\\[(.*?)\\]\\]></title>|<title>(.*?)</title>", RegexOption.DOT_MATCHES_ALL)
|
||||||
|
.find(itemXml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } ?: ""
|
||||||
|
|
||||||
|
val itemLink = Regex("<link>(.*?)</link>").find(itemXml)?.groupValues?.getOrNull(1) ?: ""
|
||||||
|
|
||||||
|
val itemDesc = Regex("<description><!\\[CDATA\\[(.*?)\\]\\]></description>|<description>(.*?)</description>", RegexOption.DOT_MATCHES_ALL)
|
||||||
|
.find(itemXml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } ?: ""
|
||||||
|
|
||||||
|
val itemDate = Regex("<pubDate>|<published>").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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,9 +33,11 @@ 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.MistralClient
|
import com.mistral.chat.api.MistralClient
|
||||||
|
import com.mistral.chat.api.ToolExecutor
|
||||||
import com.mistral.chat.data.ChatDatabase
|
import com.mistral.chat.data.ChatDatabase
|
||||||
import com.mistral.chat.data.Message
|
import com.mistral.chat.data.Message
|
||||||
import com.mistral.chat.data.MessageEntity
|
import com.mistral.chat.data.MessageEntity
|
||||||
|
import com.mistral.chat.data.MemoryRepository
|
||||||
import com.mistral.chat.data.Profile
|
import com.mistral.chat.data.Profile
|
||||||
import com.mistral.chat.data.Session
|
import com.mistral.chat.data.Session
|
||||||
import com.mistral.chat.data.toMessage
|
import com.mistral.chat.data.toMessage
|
||||||
|
|
@ -47,6 +49,9 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
|
class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
|
||||||
|
|
||||||
|
|
@ -66,12 +71,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
private var currentJob: kotlinx.coroutines.Job? = null
|
private var currentJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
private var client: MistralClient? = null
|
private var client: MistralClient? = null
|
||||||
|
private var toolExecutor: ToolExecutor? = null
|
||||||
private val messages = mutableListOf<Message>()
|
private val messages = mutableListOf<Message>()
|
||||||
private var availableModels: List<Pair<String, String>> = emptyList()
|
private var availableModels: List<Pair<String, String>> = emptyList()
|
||||||
private var selectedModelName: String = "mistral-small-latest"
|
private var selectedModelName: String = "mistral-medium-latest"
|
||||||
private lateinit var prefs: SharedPreferences
|
private lateinit var prefs: SharedPreferences
|
||||||
private lateinit var encryptedPrefs: SharedPreferences
|
private lateinit var encryptedPrefs: SharedPreferences
|
||||||
private lateinit var database: ChatDatabase
|
private lateinit var database: ChatDatabase
|
||||||
|
private lateinit var memoryRepository: MemoryRepository
|
||||||
|
|
||||||
private val profiles = mutableListOf<Profile>()
|
private val profiles = mutableListOf<Profile>()
|
||||||
private val sessions = mutableListOf<Session>()
|
private val sessions = mutableListOf<Session>()
|
||||||
|
|
@ -94,6 +101,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
private const val KEY_LAST_PROFILE_ID = "last_profile_id"
|
private const val KEY_LAST_PROFILE_ID = "last_profile_id"
|
||||||
private const val KEY_SELECTED_MODEL = "selected_model"
|
private const val KEY_SELECTED_MODEL = "selected_model"
|
||||||
private const val KEY_THEME_MODE = "theme_mode"
|
private const val KEY_THEME_MODE = "theme_mode"
|
||||||
|
private const val KEY_DEFAULT_TIMEZONE = "default_timezone"
|
||||||
|
private const val KEY_DEFAULT_CITY = "default_city"
|
||||||
private const val MAX_PROFILES = 10
|
private const val MAX_PROFILES = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,7 +137,12 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
showApiKeyDialog()
|
showApiKeyDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
database = ChatDatabase.getInstance(this)
|
||||||
|
memoryRepository = MemoryRepository(database.memoryDao())
|
||||||
|
|
||||||
client = MistralClient(getApiKey())
|
client = MistralClient(getApiKey())
|
||||||
|
toolExecutor = ToolExecutor(memoryRepository, this, getDefaultTimezone(), getDefaultCity())
|
||||||
|
client?.setToolExecutor(toolExecutor!!)
|
||||||
|
|
||||||
logoButton = findViewById(R.id.logoButton)
|
logoButton = findViewById(R.id.logoButton)
|
||||||
menuButton = findViewById(R.id.menuButton)
|
menuButton = findViewById(R.id.menuButton)
|
||||||
|
|
@ -142,8 +156,6 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
navigationView = findViewById(R.id.navigationView)
|
navigationView = findViewById(R.id.navigationView)
|
||||||
rightPanel = findViewById(R.id.rightPanelContainer)
|
rightPanel = findViewById(R.id.rightPanelContainer)
|
||||||
|
|
||||||
database = ChatDatabase.getInstance(this)
|
|
||||||
|
|
||||||
setupToolbar()
|
setupToolbar()
|
||||||
setupRecyclerView()
|
setupRecyclerView()
|
||||||
setupDrawer()
|
setupDrawer()
|
||||||
|
|
@ -203,6 +215,9 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
R.id.action_session -> {
|
R.id.action_session -> {
|
||||||
showSettingsDialog()
|
showSettingsDialog()
|
||||||
}
|
}
|
||||||
|
R.id.action_location -> {
|
||||||
|
showLocationDialog()
|
||||||
|
}
|
||||||
R.id.action_about -> {
|
R.id.action_about -> {
|
||||||
showAboutDialog()
|
showAboutDialog()
|
||||||
}
|
}
|
||||||
|
|
@ -260,6 +275,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
if (deleteProfiles) {
|
if (deleteProfiles) {
|
||||||
database.profileDao().deleteAll()
|
database.profileDao().deleteAll()
|
||||||
currentProfileId = null
|
currentProfileId = null
|
||||||
|
memoryRepository.setCurrentProfile(null)
|
||||||
|
memoryRepository.deleteAnonymous()
|
||||||
prefs.edit().remove(KEY_LAST_PROFILE_ID).apply()
|
prefs.edit().remove(KEY_LAST_PROFILE_ID).apply()
|
||||||
profiles.clear()
|
profiles.clear()
|
||||||
}
|
}
|
||||||
|
|
@ -338,6 +355,35 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showLocationDialog() {
|
||||||
|
val dialogView = layoutInflater.inflate(R.layout.dialog_location, null)
|
||||||
|
val timezoneInput = dialogView.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.timezoneInput)
|
||||||
|
val cityInput = dialogView.findViewById<com.google.android.material.textfield.TextInputEditText>(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() {
|
private fun setupToolbar() {
|
||||||
hamburgerButton.isVisible = true
|
hamburgerButton.isVisible = true
|
||||||
|
|
||||||
|
|
@ -406,6 +452,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
memoryRepository.setCurrentProfile(currentProfileId)
|
||||||
val profileId = currentProfileId
|
val profileId = currentProfileId
|
||||||
if (profileId != null) {
|
if (profileId != null) {
|
||||||
prefs.edit().putLong(KEY_LAST_PROFILE_ID, profileId).apply()
|
prefs.edit().putLong(KEY_LAST_PROFILE_ID, profileId).apply()
|
||||||
|
|
@ -462,6 +509,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
|
|
||||||
private fun selectProfile(profile: Profile) {
|
private fun selectProfile(profile: Profile) {
|
||||||
currentProfileId = profile.id
|
currentProfileId = profile.id
|
||||||
|
memoryRepository.setCurrentProfile(profile.id)
|
||||||
prefs.edit().putLong(KEY_LAST_PROFILE_ID, profile.id).apply()
|
prefs.edit().putLong(KEY_LAST_PROFILE_ID, profile.id).apply()
|
||||||
profilesAdapter?.refresh()
|
profilesAdapter?.refresh()
|
||||||
val profileName = profiles.find { it.id == currentProfileId }?.name ?: profile.name
|
val profileName = profiles.find { it.id == currentProfileId }?.name ?: profile.name
|
||||||
|
|
@ -491,7 +539,12 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
.setMessage("Удалить профиль ${profile.name}?")
|
.setMessage("Удалить профиль ${profile.name}?")
|
||||||
.setPositiveButton(R.string.yes) { _, _ ->
|
.setPositiveButton(R.string.yes) { _, _ ->
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
|
memoryRepository.deleteByProfile(profile.id)
|
||||||
database.profileDao().delete(profile)
|
database.profileDao().delete(profile)
|
||||||
|
if (currentProfileId == profile.id) {
|
||||||
|
currentProfileId = null
|
||||||
|
memoryRepository.setCurrentProfile(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.no, null)
|
.setNegativeButton(R.string.no, null)
|
||||||
|
|
@ -499,20 +552,49 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectSession(session: Session) {
|
private fun selectSession(session: Session) {
|
||||||
|
currentJob?.cancel()
|
||||||
|
|
||||||
currentSessionId = session.id
|
currentSessionId = session.id
|
||||||
userMessageCount = 0
|
userMessageCount = 0
|
||||||
|
userScrolledAfterSend = false
|
||||||
prefs.edit().putLong("last_session_id", session.id).apply()
|
prefs.edit().putLong("last_session_id", session.id).apply()
|
||||||
|
|
||||||
|
messages.clear()
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
|
||||||
loadSessionMessages(session.id)
|
loadSessionMessages(session.id)
|
||||||
updateRightPanel()
|
updateRightPanel()
|
||||||
drawerLayout.closeDrawer(GravityCompat.END)
|
drawerLayout.closeDrawer(GravityCompat.END)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var loadMessagesJob: Job? = null
|
||||||
|
|
||||||
private fun loadSessionMessages(sessionId: Long) {
|
private fun loadSessionMessages(sessionId: Long) {
|
||||||
lifecycleScope.launch {
|
loadMessagesJob?.cancel()
|
||||||
val dbMessages = database.messageDao().getMessagesBySessionSync(sessionId)
|
|
||||||
|
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.clear()
|
||||||
messages.addAll(dbMessages.map { it.toMessage() })
|
messages.addAll(dbMessages.map { it.toMessage() })
|
||||||
adapter.notifyDataSetChanged()
|
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() {
|
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 {
|
recyclerView.layoutManager = LinearLayoutManager(this).apply {
|
||||||
stackFromEnd = true
|
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() {
|
private fun setupInput() {
|
||||||
sendButton.setOnClickListener {
|
sendButton.setOnClickListener {
|
||||||
sendInput()
|
sendInput()
|
||||||
|
|
@ -643,12 +772,15 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Отменяем предыдущий запрос перед новым
|
||||||
|
currentJob?.cancel()
|
||||||
|
|
||||||
if (currentSessionId == null) {
|
if (currentSessionId == null) {
|
||||||
createNewSessionAndSend(userInput)
|
createNewSessionAndSend(userInput)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()))
|
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()), currentSessionId)
|
||||||
|
|
||||||
inputField.text?.clear()
|
inputField.text?.clear()
|
||||||
|
|
||||||
|
|
@ -656,13 +788,17 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
lastUserMessagePosition = messages.size - 1
|
lastUserMessagePosition = messages.size - 1
|
||||||
|
|
||||||
recyclerView.postDelayed({
|
recyclerView.postDelayed({
|
||||||
recyclerView.scrollToPosition(lastUserMessagePosition)
|
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
|
||||||
|
layoutManager.scrollToPositionWithOffset(lastUserMessagePosition, 0)
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|
||||||
sendMessage(userInput)
|
sendMessage(userInput)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNewSessionAndSend(userInput: String) {
|
private fun createNewSessionAndSend(userInput: String) {
|
||||||
|
// Отменяем предыдущий запрос
|
||||||
|
currentJob?.cancel()
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val session = Session(
|
val session = Session(
|
||||||
profileId = if (currentProfileId != null && profiles.any { it.id == currentProfileId }) currentProfileId else null,
|
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)
|
val sessionId = database.sessionDao().insert(session)
|
||||||
currentSessionId = sessionId
|
currentSessionId = sessionId
|
||||||
userMessageCount = 0
|
userMessageCount = 0
|
||||||
|
userScrolledAfterSend = false
|
||||||
messages.clear()
|
messages.clear()
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
updateRightPanel()
|
updateRightPanel()
|
||||||
|
|
||||||
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()))
|
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()), sessionId)
|
||||||
inputField.text?.clear()
|
inputField.text?.clear()
|
||||||
sendMessage(userInput)
|
sendMessage(userInput)
|
||||||
}
|
}
|
||||||
|
|
@ -755,38 +892,40 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL)
|
val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL)
|
||||||
if (!hasUserSelectedModel) {
|
if (!hasUserSelectedModel) {
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-small-latest"
|
selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-medium-latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addMessage(message: Message) {
|
private fun addMessage(message: Message, expectedSessionId: Long? = null) {
|
||||||
val newPosition = messages.size - 1
|
val targetSessionId = expectedSessionId ?: currentSessionId
|
||||||
|
|
||||||
messages.add(message)
|
messages.add(message)
|
||||||
|
val newPosition = messages.size - 1
|
||||||
|
|
||||||
adapter.notifyItemInserted(newPosition)
|
adapter.notifyItemInserted(newPosition)
|
||||||
|
|
||||||
if (!message.isUser && !userScrolledAfterSend) {
|
if (!message.isUser && !userScrolledAfterSend) {
|
||||||
recyclerView.postDelayed({
|
recyclerView.postDelayed({
|
||||||
if (!userScrolledAfterSend) {
|
if (!userScrolledAfterSend) {
|
||||||
recyclerView.scrollToPosition(newPosition)
|
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
|
||||||
|
layoutManager.scrollToPositionWithOffset(newPosition, 0)
|
||||||
}
|
}
|
||||||
}, 150)
|
}, 150)
|
||||||
}
|
}
|
||||||
|
|
||||||
val sessionId = currentSessionId
|
if (targetSessionId != null) {
|
||||||
if (sessionId != null) {
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val entity = MessageEntity(
|
val entity = MessageEntity(
|
||||||
sessionId = sessionId,
|
sessionId = targetSessionId,
|
||||||
content = message.content,
|
content = message.content,
|
||||||
isUser = message.isUser,
|
isUser = message.isUser,
|
||||||
timestamp = message.timestamp
|
timestamp = message.timestamp
|
||||||
)
|
)
|
||||||
database.messageDao().insert(entity)
|
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) ?: ""
|
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 {
|
private fun hasApiKey(): Boolean {
|
||||||
return encryptedPrefs.contains(KEY_API_KEY)
|
return encryptedPrefs.contains(KEY_API_KEY)
|
||||||
}
|
}
|
||||||
|
|
@ -816,10 +971,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
private fun saveApiKey(apiKey: String) {
|
private fun saveApiKey(apiKey: String) {
|
||||||
encryptedPrefs.edit().putString(KEY_API_KEY, apiKey).apply()
|
encryptedPrefs.edit().putString(KEY_API_KEY, apiKey).apply()
|
||||||
client = MistralClient(apiKey)
|
client = MistralClient(apiKey)
|
||||||
|
client?.setToolExecutor(toolExecutor!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteApiKey() {
|
private fun deleteApiKey() {
|
||||||
encryptedPrefs.edit().remove(KEY_API_KEY).apply()
|
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()
|
Toast.makeText(this, getString(R.string.api_key_deleted), Toast.LENGTH_SHORT).show()
|
||||||
showApiKeyDialog()
|
showApiKeyDialog()
|
||||||
}
|
}
|
||||||
|
|
@ -856,6 +1014,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
|
|
||||||
saveApiKey(newKey)
|
saveApiKey(newKey)
|
||||||
client = MistralClient(newKey)
|
client = MistralClient(newKey)
|
||||||
|
client?.setToolExecutor(toolExecutor!!)
|
||||||
apiKeyDialog?.dismiss()
|
apiKeyDialog?.dismiss()
|
||||||
Toast.makeText(this, getString(R.string.api_key_saved), Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, getString(R.string.api_key_saved), Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -880,6 +1039,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
|
|
||||||
private fun sendMessage(userInput: String) {
|
private fun sendMessage(userInput: String) {
|
||||||
val selectedModel = selectedModelName
|
val selectedModel = selectedModelName
|
||||||
|
val sessionIdAtStart = currentSessionId
|
||||||
|
|
||||||
sendButton.isEnabled = false
|
sendButton.isEnabled = false
|
||||||
progressIndicator.isVisible = true
|
progressIndicator.isVisible = true
|
||||||
|
|
@ -887,56 +1047,137 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
currentJob = lifecycleScope.launch {
|
currentJob = lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
val profileContext = getSelectedProfileContext()
|
val profileContext = getSelectedProfileContext()
|
||||||
|
val systemPrompt = getSelectedSystemPrompt()
|
||||||
|
val memoryContext = memoryRepository.buildMemoryContext()
|
||||||
|
val tools = toolExecutor?.getToolsSchema()
|
||||||
|
|
||||||
val apiMessages = messages.map { msg ->
|
// Автоматически получаем текущую дату
|
||||||
Message(
|
val currentDateResult = client?.executeTool("get_date", com.google.gson.JsonObject())
|
||||||
content = msg.content,
|
?: """{"status": "error", "message": "Tool failed"}"""
|
||||||
isUser = msg.isUser
|
|
||||||
)
|
val apiMessages = mutableListOf<Message>()
|
||||||
}.toMutableList()
|
|
||||||
|
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()) {
|
if (profileContext.isNotEmpty()) {
|
||||||
apiMessages.add(0, Message(content = profileContext, isUser = true))
|
apiMessages.add(Message(content = profileContext, isUser = true, role = "user"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = withTimeout(15000L) {
|
if (memoryContext.isNotEmpty()) {
|
||||||
client?.chat(selectedModel, apiMessages) ?: Result.failure(Exception("Client not initialized"))
|
apiMessages.add(Message(content = memoryContext, isUser = true, role = "user"))
|
||||||
|
}
|
||||||
|
|
||||||
|
apiMessages.addAll(messages.map { msg ->
|
||||||
|
Message(
|
||||||
|
content = msg.content,
|
||||||
|
isUser = msg.isUser,
|
||||||
|
role = if (msg.isUser) "user" else "assistant"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isActive) return@launch
|
if (!isActive) return@launch
|
||||||
|
|
||||||
result.onSuccess { (response, usedModel) ->
|
result.onSuccess { chatResponse ->
|
||||||
val displayModel = usedModel.ifEmpty { "Assistant" }
|
if (chatResponse.toolCalls.isNotEmpty()) {
|
||||||
addMessage(Message(content = response, isUser = false, senderName = displayModel))
|
// Выполняем все tool calls и добавляем результаты в историю
|
||||||
lifecycleScope.launch {
|
for (toolCall in chatResponse.toolCalls) {
|
||||||
saveMessageToDatabase(currentSessionId, response, false, displayModel)
|
val toolResult = client?.executeTool(toolCall.name, toolCall.arguments)
|
||||||
}
|
?: """{"status": "error", "message": "Tool failed"}"""
|
||||||
|
|
||||||
val count = userMessageCount + 1
|
apiMessages.add(Message(
|
||||||
userMessageCount = count
|
content = """[${toolCall.name}] result: $toolResult""",
|
||||||
if (count == 2 && titleGenerationJob?.isActive != true) {
|
isUser = true,
|
||||||
titleGenerationJob = generateSessionTitle()
|
role = "user"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
// Продолжаем цикл - AI решит нужен ли еще поиск
|
||||||
|
} else {
|
||||||
|
// Нет tool calls - это финальный ответ
|
||||||
|
finalResponse = chatResponse.content
|
||||||
}
|
}
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
if (!isActive) return@launch
|
finalResponse = "Ошибка: ${error.message}"
|
||||||
val errorMessage = error.message ?: "Unknown error"
|
}
|
||||||
if (!errorMessage.contains("cancelled", ignoreCase = true)) {
|
|
||||||
val userFriendlyMessage = getUserFriendlyError(errorMessage)
|
// Если есть финальный ответ или превышен лимит - выходим
|
||||||
addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error"))
|
if (finalResponse != null || iteration >= maxIterations) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalResponse == null && iteration >= maxIterations) {
|
||||||
|
finalResponse = "Превышен лимит итераций (${maxIterations}). Попробуйте более конкретный запрос."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем финальный ответ
|
||||||
|
if (finalResponse.isNullOrEmpty()) {
|
||||||
|
finalResponse = "Не удалось получить ответ. Попробуйте ещё раз."
|
||||||
|
}
|
||||||
|
|
||||||
|
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: kotlinx.coroutines.CancellationException) {
|
|
||||||
if (!isActive) return@launch
|
|
||||||
addMessage(Message(content = "Запрос отменён", isUser = false, senderName = "System"))
|
|
||||||
sendButton.isEnabled = true
|
sendButton.isEnabled = true
|
||||||
progressIndicator.isVisible = false
|
progressIndicator.isVisible = false
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (!isActive) return@launch
|
if (!isActive) return@launch
|
||||||
val userFriendlyMessage = getUserFriendlyError(e.message ?: "Unknown error")
|
android.util.Log.e("MainActivity", "Exception: ${e.message}", e)
|
||||||
addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error"))
|
if (currentSessionId == sessionIdAtStart) {
|
||||||
|
addMessage(Message(content = "Произошла ошибка: ${e.message}", isUser = false, senderName = "Error"), sessionIdAtStart)
|
||||||
|
}
|
||||||
sendButton.isEnabled = true
|
sendButton.isEnabled = true
|
||||||
progressIndicator.isVisible = false
|
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 {
|
private fun getCurrentProfileName(): String {
|
||||||
if (currentProfileId == null) return "Вы"
|
if (currentProfileId == null) return "Вы"
|
||||||
val profileName = profiles.find { it.id == currentProfileId }?.name
|
val profileName = profiles.find { it.id == currentProfileId }?.name
|
||||||
|
|
@ -973,11 +1224,17 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
val nameInput = dialogView.findViewById<TextInputEditText>(R.id.nameInput)
|
val nameInput = dialogView.findViewById<TextInputEditText>(R.id.nameInput)
|
||||||
val bioInput = dialogView.findViewById<TextInputEditText>(R.id.bioInput)
|
val bioInput = dialogView.findViewById<TextInputEditText>(R.id.bioInput)
|
||||||
val preferencesInput = dialogView.findViewById<TextInputEditText>(R.id.preferencesInput)
|
val preferencesInput = dialogView.findViewById<TextInputEditText>(R.id.preferencesInput)
|
||||||
|
val systemPromptInput = dialogView.findViewById<TextInputEditText>(R.id.systemPromptInput)
|
||||||
|
|
||||||
existingProfile?.let {
|
existingProfile?.let {
|
||||||
nameInput.setText(it.name)
|
nameInput.setText(it.name)
|
||||||
bioInput.setText(it.bio)
|
bioInput.setText(it.bio)
|
||||||
preferencesInput.setText(it.preferences)
|
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)
|
val dialog = AlertDialog.Builder(this)
|
||||||
|
|
@ -992,16 +1249,20 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
name = name,
|
name = name,
|
||||||
bio = bioInput.text?.toString() ?: "",
|
bio = bioInput.text?.toString() ?: "",
|
||||||
preferences = preferencesInput.text?.toString() ?: "",
|
preferences = preferencesInput.text?.toString() ?: "",
|
||||||
|
systemPrompt = systemPromptInput.text?.toString() ?: "",
|
||||||
updatedAt = System.currentTimeMillis()
|
updatedAt = System.currentTimeMillis()
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
|
val defaultSystemPrompt = getString(R.string.profile_system_prompt_default)
|
||||||
val newId = database.profileDao().insert(Profile(
|
val newId = database.profileDao().insert(Profile(
|
||||||
name = name,
|
name = name,
|
||||||
bio = bioInput.text?.toString() ?: "",
|
bio = bioInput.text?.toString() ?: "",
|
||||||
preferences = preferencesInput.text?.toString() ?: ""
|
preferences = preferencesInput.text?.toString() ?: "",
|
||||||
|
systemPrompt = systemPromptInput.text?.toString()?.ifEmpty { defaultSystemPrompt } ?: defaultSystemPrompt
|
||||||
))
|
))
|
||||||
if (currentProfileId == null) {
|
if (currentProfileId == null) {
|
||||||
currentProfileId = newId
|
currentProfileId = newId
|
||||||
|
memoryRepository.setCurrentProfile(newId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1015,6 +1276,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
database.profileDao().delete(existingProfile)
|
database.profileDao().delete(existingProfile)
|
||||||
if (currentProfileId == existingProfile.id) {
|
if (currentProfileId == existingProfile.id) {
|
||||||
currentProfileId = null
|
currentProfileId = null
|
||||||
|
memoryRepository.setCurrentProfile(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,69 @@
|
||||||
<string name="profile_name">Имя</string>
|
<string name="profile_name">Имя</string>
|
||||||
<string name="profile_bio">О себе</string>
|
<string name="profile_bio">О себе</string>
|
||||||
<string name="profile_preferences">Предпочтения</string>
|
<string name="profile_preferences">Предпочтения</string>
|
||||||
|
<string name="profile_system_prompt">Системный промт</string>
|
||||||
|
<string name="profile_system_prompt_hint">Инструкции для AI (до 4000 символов). Этот текст будет добавлен как system message.</string>
|
||||||
|
<string name="profile_system_prompt_default">Ты - 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="киберспорт")
|
||||||
|
- "Что ты обо мне знаешь?" → выведи всю память</string>
|
||||||
<string name="save">Сохранить</string>
|
<string name="save">Сохранить</string>
|
||||||
<string name="cancel">Отмена</string>
|
<string name="cancel">Отмена</string>
|
||||||
<string name="delete">Удалить</string>
|
<string name="delete">Удалить</string>
|
||||||
|
|
@ -51,6 +114,12 @@
|
||||||
<string name="profile_info">Профиль: %s</string>
|
<string name="profile_info">Профиль: %s</string>
|
||||||
<string name="appearance_settings">Внешний вид</string>
|
<string name="appearance_settings">Внешний вид</string>
|
||||||
<string name="session_settings">Сессия при запуске</string>
|
<string name="session_settings">Сессия при запуске</string>
|
||||||
|
<string name="location_settings">Местоположение</string>
|
||||||
|
<string name="location_title">Настройки местоположения</string>
|
||||||
|
<string name="timezone_label">Часовой пояс</string>
|
||||||
|
<string name="city_label">Город по умолчанию</string>
|
||||||
|
<string name="city_hint">Например: Москва, Санкт-Петербург</string>
|
||||||
|
<string name="location_saved">Настройки сохранены</string>
|
||||||
<string name="clear_history">Очистить историю</string>
|
<string name="clear_history">Очистить историю</string>
|
||||||
<string name="clear_history_message">Удалить все сессии и сообщения?</string>
|
<string name="clear_history_message">Удалить все сессии и сообщения?</string>
|
||||||
<string name="open_last_session">Открывать последнюю сессию</string>
|
<string name="open_last_session">Открывать последнюю сессию</string>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue