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:
Алексей Будаев 2026-04-10 00:04:25 +08:00
parent 5d59c5e351
commit ae5907c45f
4 changed files with 1323 additions and 52 deletions

452
AGENTS.md Normal file
View 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*

View 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&current=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("&quot;", "\"")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
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("&nbsp;", " ")
.replace("&quot;", "\"")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&mdash;", "")
.replace("&ndash;", "")
.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}"
}
}
}

View file

@ -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,8 +137,13 @@ 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)
hamburgerButton = findViewById(R.id.hamburgerButton) hamburgerButton = findViewById(R.id.hamburgerButton)
@ -141,8 +155,6 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
drawerLayout = findViewById(R.id.drawerLayout) drawerLayout = findViewById(R.id.drawerLayout)
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()
@ -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"))
} }
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) -> // Tool loop - до 15 итераций
val displayModel = usedModel.ifEmpty { "Assistant" } var iteration = 0
addMessage(Message(content = response, isUser = false, senderName = displayModel)) val maxIterations = 15
lifecycleScope.launch { var finalResponse: String? = null
saveMessageToDatabase(currentSessionId, response, false, displayModel)
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 if (!isActive) return@launch
val errorMessage = error.message ?: "Unknown error"
if (!errorMessage.contains("cancelled", ignoreCase = true)) { result.onSuccess { chatResponse ->
val userFriendlyMessage = getUserFriendlyError(errorMessage) if (chatResponse.toolCalls.isNotEmpty()) {
addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error")) // Выполняем все 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 val responseToShow = finalResponse!!
progressIndicator.isVisible = false
} catch (e: kotlinx.coroutines.CancellationException) { // Проверяем что sessionId не изменился пока работал запрос
if (!isActive) return@launch if (currentSessionId == sessionIdAtStart) {
addMessage(Message(content = "Запрос отменён", isUser = false, senderName = "System")) 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 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)
} }
} }
} }

View file

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