Compare commits

..

10 commits

Author SHA1 Message Date
Алексей Будаев
16720a035a Add background notification with clickable action 2026-04-15 19:11:04 +08:00
Алексей Будаев
dc461ad5dc Add Foreground Service for background work, clean up logging 2026-04-15 17:08:36 +08:00
Алексей Будаев
cabc6b8d85 Fix timeout issues: increased to 60s, add retry on CANCEL, default model Medium 2026-04-10 20:14:17 +08:00
Алексей Будаев
ae5907c45f Add RSS parsing for news, update system prompt with RSS URLs, add current year dynamic substitution, update AGENTS.md with context optimization discussion 2026-04-10 00:04:25 +08:00
Алексей Будаев
5d59c5e351 Add API key validation, secure DB key storage, input field padding, scroll fixes 2026-04-07 22:31:28 +08:00
Алексей Будаев
7eadec669c Add drawer menu with flat structure, unified panel styling, Room/SQLCipher setup 2026-04-07 19:23:52 +08:00
Алексей Будаев
21505aae75 Clean up unused code and fix profile selection
- Removed unused UserProfile.kt, ApiModels.kt
- Fixed profile checkmark showing for all profiles (using lambda)
- Increased text bubble width to 350dp
- Auto-scroll to beginning of AI messages
- Auto-select profile on creation
- Model selection persists across restarts
- Removed null-unsafe !! operators
- Added Russian language support for UI strings
2026-04-06 20:14:32 +08:00
Алексей Будаев
a5fe4bc29e Remove hardcoded API key, add encrypted storage with AES-256 2026-04-05 18:02:58 +08:00
Алексей Будаев
e98cd8b8e7 Unify corner radius across all UI elements to Material Design 3 standard 2026-04-05 15:12:36 +08:00
Алексей Будаев
d18d4d4de0 Add API key settings with save/delete options 2026-04-05 14:56:29 +08:00
62 changed files with 4384 additions and 371 deletions

279
AGENTS.md Normal file
View file

@ -0,0 +1,279 @@
# 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)
- Прокрутка к новым сообщениям
- **Долгий тап на сообщение** - меню Копировать/Редактировать/Удалить
- **Скролл в поле system prompt** - multiline text field scrollable
- **Адаптивные цвета AlertDialog** - Material Design 3 colors
### ✅ Bug Fixes
- Исправлена ошибка "ответ в Toast вместо чата" - теперь только явные ошибки показываются через Toast
- Сортировка drawer меню - профили, настройки, остальное
- **WakeLock** - приложение остаётся активным при выключенном экране (ожидание ответа API)
- **Timeout 120 сек** - увеличен с 60 до 120 секунд для больших ответов
- **Foreground Service** - приложение продолжает работу в фоне при выключенном экране (ожидание ответа API)
- **Уведомление о ответе ИИ** - при получении ответа в фоне показывается системное уведомление с текстом ответа и звуком/вибрацией, нажатие открывает сессию с ответом
### ✅ Security
- API ключ: EncryptedSharedPreferences (AES-256-GCM)
- Ключ БД: EncryptedSharedPreferences (AES-256-SIV + AES-256-GCM)
- Профили, сессии, сообщения: SQLCipher
- CalDAV данные: зашифрованы (url, username, password)
**⚠️ ВАЖНО: Все чувствительные данные должны храниться в EncryptedSharedPreferences:**
- API ключи, пароли (CalDAV, email), ключи БД, токены
---
## Tools
### 🌐 Web Search
- ✅ Russian Wikipedia API (бесплатно, без API ключа)
- ✅ English Wikipedia API
- Ограничение: 4000 символов на ответ
### 🌤️ Weather
- ✅ Open-Meteo API (полностью бесплатно)
- Geocoding API + Weather API
- Текущая погода + прогноз на 7 дней
### 🔗 OpenUrlTool
- ✅ HTTP GET к любому URL
- ✅ RSS/Atom парсинг (lenta.ru, kommersant.ru)
- Ограничение: 4000 символов, таймаут 10 сек
### ⏰ Time Tools
- ✅ get_local_time - возвращает timestamp в миллисекундах
- ✅ get_date - текущая дата
### 📅 CalDAV Calendar
- ✅ calendar_add_event - создание событий с VALARM (уведомления)
- ✅ calendar_get_events - получение списка событий
- ✅ calendar_delete_event - удаление событий
- Баikal сервер интеграция
### 💾 Memory Tools
- ✅ memory_store, memory_learn, memory_forget
- ✅ memory_reinforce, memory_preference
---
## Tool Execution Parameters
| Параметр | Значение |
|----------|----------|
| Max iterations | 15 |
| Timeout на итерацию | 120 сек |
| Retry при CANCEL | до 2 раз |
| Result truncation | 2000 символов |
| WakeLock | ✅ для длительных запросов |
---
## Active Plan
### Phase 3: CalDAV Calendar + Local Reminders (✅ В основном готово)
**Два режима:**
1. **CalDAV** — синхронизация с Baikal сервером
2. **Local** — автономная напоминалка в памяти AI
**CalDAV Status:**
| Задача | Статус |
|--------|--------|
| Подключение к Baikal | ✅ |
| calendar_add_event | ✅ Работает (с VALARM) |
| calendar_get_events | ✅ Работает (лимит 100 событий) |
| calendar_delete_event | ✅ Работает (только свои события) |
| VALARM (уведомления) | ✅ Добавляются к событиям |
| UID consistency | ✅ Исправлено |
| Timestamp (time_string) | ✅ AI передаёт строку, сервер парсит |
**Нерешённые проблемы:**
- При переустановке app старые события становятся "чужими" (новый UUID)
**Как помочь AI правильно работать с календарём:**
1. Обязательно вызвать get_local_time для получения текущего UTC timestamp
2. Использовать формулу: new_timestamp = current_timestamp + (часы * 3600000) + (минуты * 60000)
3. НЕ добавлять случайные минуты!
### Phase 4: Heartbeat (⏳ В очереди)
- WorkManager задача (каждые 30 минут)
- Active hours (8:00-22:00)
### Phase 5: Email (⏳ В очереди)
- IMAP/SMTP клиент (без OAuth)
### Phase 6: API Key Rotation (📋 Запланировано)
**Проблема:**
- При достижении лимита токенов или блокировке ключа приложение перестаёт работать
- Нужна система ротации для отказоустойчивости
**Механизм:**
1. **Хранение нескольких ключей:**
- До 5 API ключей в EncryptedSharedPreferences
- Каждый ключ имеет статус: active, disabled, blocked
- Приоритет использования (порядок)
2. **Автоматическая ротация:**
- При ошибке 429 (rate limit) → переключить на следующий ключ
- При ошибке 401/403 (blocked) → пометить ключ как blocked
- При успешном ответе → ключ working
3. **Логика переключения:**
```
При ошибке:
- 429 (Too Many Requests) → nextKey()
- 401/403 (Unauthorized) → markKeyBlocked(), nextKey()
- 500+ → markKeyDisabled(), nextKey()
При успехе:
- workingCount++ (счётчик успешных использований)
```
4. **Ручное управление:**
- UI для добавления/удаления ключей
- Просмотр статуса каждого ключа
- Ручное переключение
**UI реализация:**
- Настройки профиля → "API ключи" → список ключей
- Статус: ✅ рабочий, ⚠️ лимит, ❌ заблокирован
- Возможность добавить/удалить/переключить
**Files to modify:**
- `EncryptedPrefs.kt` - хранение нескольких ключей
- `MistralClient.kt` - логика ротации
- UI: dialog_settings.xml или новое диалоговое окно
---
## 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/api/CalDavClient.kt` — CalDAV клиент
- `app/src/main/java/com/mistral/chat/data/ChatDatabase.kt` — база данных
### Model Selection
- **Default:** mistral-medium-latest
- **OkHttp timeouts:** connect 60s, read 120s, write 60s
---
## ⚠️ ВАЖНЫЕ ПРАВИЛА РАЗРАБОТКИ
### Запрет на удаление реализованных функций
**НИКОГДА не удаляй уже реализованные функции!**
### Запрет на хардкодинг
**НИКОГДА не хардкодь значения, которые должны быть динамическими!**
### Сборка APK
```bash
JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew assembleDebug
# Путь: app/build/outputs/apk/debug/app-debug.apk
```
---
## 📋 Контекст сессии и оптимизация (📋 Запланировано)
### Текущая реализация
При каждом запросе отправляется полный контекст. При росте сессии возможны 503 ошибки.
### План реализации (Kai 9000 style)
**Источник:** https://kai9000.com/docs/features/tools/
**Часть 1: Trimming (меж-итеративный)**
- Обрезать историю ПОСЛЕ КАЖДОГО tool вызова
- После каждого tool execution - проверить размер контекста
- Если > MAX_CONTEXT - удалить старые сообщения (кроме system prompt)
- MAX_CONTEXT = ~16000 токенов (50% от лимита mistral-medium)
**Часть 2: Compaction (AI summary)**
- При 70% лимита (~22000 токенов) - запустить AI summary
- Последние 4 user обмена - оставить verbatim
- Остальное - одно summary message
- Сохранить summary в БД для персистентности
### Реализация
ToolExecutor.kt → модифицировать loop:
```kotlin
while (toolCalls.isNotEmpty()) {
result = executeTool()
messages.add(result)
// Trimming после каждого tool
if (getTokenCount(messages) > MAX_CONTEXT) {
trimOldMessages()
}
}
```
### Files to modify
- ToolExecutor.kt - добавить trimming в loop
- MistralClient.kt - добавить getTokenCount, trimOldMessages
---
## Conversation Context (for AI Agent)
**При начале новой сессии:**
Прочитай файл AGENTS.md для понимания текущего контекста.
**При запросе "продолжаем":**
Мы работаем над Phase 3 - CalDAV календарь. Тестируем: создание событий, получение списка, исправление timezone.
**Важно:**
- Пушить в GitHub только после подтверждения пользователя
- Не делать push автоматически
---
*Last updated: 2026-04-12*
*Version: 1.11*

View file

@ -1,6 +1,7 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'com.google.devtools.ksp' version '1.9.22-1.0.17'
}
android {
@ -40,8 +41,20 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.google.code.gson:gson:2.10.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'io.noties.markwon:core:4.6.2'
implementation 'io.noties.markwon:ext-strikethrough:4.6.2'
implementation 'io.noties.markwon:ext-tables:4.6.2'
implementation 'io.noties.markwon:ext-tasklist:4.6.2'
implementation 'androidx.room:room-runtime:2.6.1'
implementation 'androidx.room:room-ktx:2.6.1'
ksp 'androidx.room:room-compiler:2.6.1'
implementation 'net.zetetic:android-database-sqlcipher:4.5.4'
implementation 'androidx.sqlite:sqlite-ktx:2.4.0'
}

View file

@ -0,0 +1,66 @@
package com.mistral.chat.api
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.mistral.chat.R
class ApiForegroundService : Service() {
companion object {
const val CHANNEL_ID = "api_service_channel"
const val NOTIFICATION_ID = 1002 // Different from AI response notification
fun start(context: Context) {
val intent = Intent(context, ApiForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
val intent = Intent(context, ApiForegroundService::class.java)
context.stopService(intent)
}
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Mistral Chat")
.setContentText("Получение ответа от AI...")
.setSmallIcon(android.R.drawable.ic_menu_send)
.setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification)
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
stopForeground(STOP_FOREGROUND_REMOVE)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"API Service",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Уведомление о работе API"
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
}

View file

@ -1,54 +1,109 @@
package com.mistral.chat.api
import com.google.gson.JsonObject
import android.util.Log
import com.google.gson.Gson
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.mistral.chat.data.Message
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.util.concurrent.TimeUnit
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class MistralClient(private val apiKey: String) {
data class ChatResponse(
val content: String,
val usedModel: String,
val toolCalls: List<ToolCall>
)
private val client = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
data class ToolCall(
val id: String,
val name: String,
val arguments: JsonObject
)
class MistralClient(private val apiKey: String) {
private var client = createNewClient()
private var toolExecutor: ToolExecutor? = null
private fun createNewClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.retryOnConnectionFailure(false)
.build()
}
private val gson = Gson()
private val jsonMediaType = "application/json".toMediaType()
private var currentCall: Call? = null
private var currentContinuation: Continuation<Result<ChatResponse>>? = null
companion object {
private const val BASE_URL = "https://api.mistral.ai/v1"
val AVAILABLE_MODELS = listOf(
"mistral-small-latest" to "Mistral Small",
"mistral-medium-latest" to "Mistral Medium",
"mistral-large-latest" to "Mistral Large",
"codestral-latest" to "Codestral"
private val SUPPORTED_MODELS = setOf(
"mistral-small-latest",
"mistral-medium-latest",
"mistral-large-latest",
"mistral-small",
"mistral-medium",
"mistral-large",
"codestral-latest",
"codestral",
"pixtral-large-latest",
"pixtral-12b-2409"
)
val AVAILABLE_MODELS = listOf(
"mistral-large-latest" to "Mistral Large",
"mistral-medium-latest" to "Mistral Medium",
"codestral-latest" to "Codestral",
"pixtral-large-latest" to "Pixtral Large"
)
const val DEFAULT_MODEL = "mistral-medium-latest"
}
suspend fun getModels(): Result<List<Pair<String, String>>> = withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url("$BASE_URL/models")
.addHeader("Authorization", "Bearer $apiKey")
.get()
.build()
fun setToolExecutor(executor: ToolExecutor) {
this.toolExecutor = executor
}
val response = client.newCall(request).execute()
fun cancelRequest() {
val call = currentCall
val continuation = currentContinuation
call?.cancel()
currentCall = null
continuation?.resume(Result.failure(Exception("Request cancelled")))
currentContinuation = null
client = createNewClient()
}
suspend fun getModels(): Result<List<Pair<String, String>>> = try {
val request = Request.Builder()
.url("$BASE_URL/models")
.addHeader("Authorization", "Bearer $apiKey")
.get()
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext Result.failure(Exception("API error: ${response.code}"))
return Result.failure(Exception("API error: ${response.code}"))
}
val responseBody = response.body?.string() ?: ""
@ -57,53 +112,62 @@ import java.util.concurrent.TimeUnit
val models = responseJson
.getAsJsonArray("data")
?.mapNotNull { obj ->
val jsonObj = obj.asJsonObject
val id = jsonObj.get("id")?.asString
val created = jsonObj.get("created")?.asLong ?: 0L
if (id != null && created > 0 && id.endsWith("-latest")) {
val displayName = id
.replace("-latest", "")
.replace("-", " ")
.split(" ")
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } }
id to displayName
} else null
} ?: emptyList()
try {
val jsonObj = obj.asJsonObject
val id = jsonObj.get("id")?.asString ?: return@mapNotNull null
if (id in SUPPORTED_MODELS) {
val displayName = id
.replace("-latest", "")
.replace("-12b-2409", "")
.replace("-", " ")
.split(" ")
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } }
id to displayName
} else null
} catch (e: Exception) {
null
}
}
?.distinctBy { it.first }
?: emptyList()
Result.success(models)
} catch (e: Exception) {
Result.failure(e)
}
}
fun cancelRequest() {
currentCall?.cancel()
currentCall = null
} catch (e: Exception) {
Result.failure(e)
}
suspend fun chat(
model: String,
messages: List<Message>,
tools: List<JsonObject>? = null,
onChunk: ((String) -> Unit)? = null
): Result<Pair<String, String>> = withContext(Dispatchers.IO) {
): Result<ChatResponse> = withContext(Dispatchers.IO) {
try {
val jsonObject = JsonObject()
jsonObject.addProperty("model", model)
jsonObject.addProperty("temperature", 0.7)
jsonObject.addProperty("stream", onChunk != null)
jsonObject.addProperty("stream", false)
val messagesArray = JsonArray()
messages.forEach { msg ->
val msgObj = JsonObject()
msgObj.addProperty("role", if (msg.isUser) "user" else "assistant")
msgObj.addProperty("role", msg.role)
msgObj.addProperty("content", msg.content)
messagesArray.add(msgObj)
}
jsonObject.add("messages", messagesArray)
if (!tools.isNullOrEmpty()) {
val toolsArray = JsonArray()
tools.forEach { toolsArray.add(it) }
jsonObject.add("tools", toolsArray)
jsonObject.addProperty("tool_choice", "auto")
}
val json = gson.toJson(jsonObject)
val body = json.toRequestBody(jsonMediaType)
val request = Request.Builder()
.url("$BASE_URL/chat/completions")
.addHeader("Authorization", "Bearer $apiKey")
@ -112,43 +176,129 @@ import java.util.concurrent.TimeUnit
.build()
currentCall = client.newCall(request)
val response = currentCall!!.execute()
if (response.code == 0 || response.code == -1) {
return@withContext Result.failure(Exception("Request cancelled"))
}
if (!response.isSuccessful) {
val errorBody = response.body?.string() ?: "Unknown error"
return@withContext Result.failure(Exception("API error: ${response.code} - $errorBody"))
}
val responseBody = response.body?.string() ?: ""
if (onChunk != null) {
onChunk(responseBody)
}
val result = suspendCancellableCoroutine<Result<ChatResponse>> { continuation ->
currentContinuation = continuation
val responseJson = gson.fromJson(responseBody, JsonObject::class.java)
val choices = responseJson.getAsJsonArray("choices")
if (choices == null || choices.size() == 0) {
return@withContext Result.failure(Exception("No response from API"))
currentCall?.enqueue(object : okhttp3.Callback {
override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
Log.e("MistralClient", "onFailure: ${e.message}", e)
val cont = currentContinuation
currentCall = null
currentContinuation = null
if (cont != null) {
if (call.isCanceled()) {
cont.resume(Result.failure(Exception("Request cancelled")))
} else {
cont.resume(Result.failure(e))
}
}
}
override fun onResponse(call: okhttp3.Call, response: Response) {
val cont = currentContinuation
if (cont == null) {
response.close()
currentCall = null
return
}
try {
if (!response.isSuccessful) {
val errorBody = response.body?.string() ?: "Unknown error"
currentCall = null
currentContinuation = null
cont.resume(Result.failure(Exception("API error: ${response.code} - $errorBody")))
return
}
val responseBody = response.body?.string() ?: ""
if (onChunk != null) {
onChunk(responseBody)
}
val responseJson = try {
gson.fromJson(responseBody, JsonObject::class.java)
} catch (e: Exception) {
currentCall = null
currentContinuation = null
cont.resume(Result.failure(Exception("Invalid JSON response")))
return
}
val choices = responseJson.getAsJsonArray("choices")
if (choices == null || choices.size() == 0) {
Log.e("MistralClient", "No choices in response: $responseBody")
currentCall = null
currentContinuation = null
cont.resume(Result.failure(Exception("No response from API")))
return
}
val firstChoice = choices.get(0)?.asJsonObject
if (firstChoice == null) {
Log.e("MistralClient", "First choice is null")
currentCall = null
currentContinuation = null
cont.resume(Result.failure(Exception("Empty choice")))
return
}
val message = firstChoice.getAsJsonObject("message")
val content = message?.get("content")?.asString ?: ""
val usedModel = responseJson.get("model")?.asString ?: model
val toolCalls = mutableListOf<ToolCall>()
val toolCallsElement = message?.get("tool_calls")
if (toolCallsElement != null && !toolCallsElement.isJsonNull) {
val toolCallsArray = toolCallsElement.asJsonArray
for ((index, tcElement) in toolCallsArray.withIndex()) {
val tc = tcElement.asJsonObject
if (tc != null) {
val tcId = tc.get("id")?.asString ?: "call_${System.currentTimeMillis()}_$index"
val tcName = tc.get("function")?.asJsonObject?.get("name")?.asString ?: ""
val tcArgs = tc.get("function")?.asJsonObject?.get("arguments")?.asString
if (tcName.isNotEmpty() && tcArgs != null) {
try {
val argsJson = gson.fromJson(tcArgs, JsonObject::class.java)
toolCalls.add(ToolCall(tcId, tcName, argsJson))
} catch (e: Exception) {
// Skip invalid arguments
}
}
}
}
}
currentCall = null
currentContinuation = null
cont.resume(Result.success(ChatResponse(content, usedModel, toolCalls)))
} catch (e: Exception) {
currentCall = null
currentContinuation = null
cont.resume(Result.failure(e))
}
}
})
continuation.invokeOnCancellation {
currentCall?.cancel()
currentCall = null
currentContinuation = null
client = createNewClient()
}
}
val content = choices
.get(0)
?.asJsonObject
?.getAsJsonObject("message")
?.get("content")
?.asString ?: ""
val usedModel = responseJson.get("model")?.asString ?: model
currentCall = null
Result.success(content to usedModel)
result
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun executeTool(name: String, arguments: JsonObject): String {
return toolExecutor?.executeTool(name, arguments)
?: """{"status": "error", "message": "Tool executor not initialized"}"""
}
}

View file

@ -0,0 +1,82 @@
package com.mistral.chat.api
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import com.google.gson.JsonObject
class NotificationTool(private val context: Context) : Tool(
name = "send_notification",
description = "Отправить уведомление пользователю. Используй когда нужно сообщить важную информацию или напомнить о чём-то.",
inputSchema = JsonObject().apply {
add("type", com.google.gson.JsonPrimitive("object"))
add("properties", JsonObject().apply {
add("title", JsonObject().apply {
add("type", com.google.gson.JsonPrimitive("string"))
add("description", com.google.gson.JsonPrimitive("Заголовок уведомления"))
})
add("message", JsonObject().apply {
add("type", com.google.gson.JsonPrimitive("string"))
add("description", com.google.gson.JsonPrimitive("Текст уведомления"))
})
})
add("required", com.google.gson.JsonArray().apply {
add("title")
add("message")
})
}
) {
companion object {
private const val CHANNEL_ID = "mistral_chat_notifications"
private const val CHANNEL_NAME = "Chat Notifications"
}
override suspend fun execute(arguments: JsonObject): String {
val title = arguments.get("title")?.asString ?: "Уведомление"
val message = arguments.get("message")?.asString ?: ""
if (message.isEmpty()) {
return """{"status": "error", "message": "Message cannot be empty"}"""
}
// Проверка разрешения для Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = android.Manifest.permission.POST_NOTIFICATIONS
if (context.checkSelfPermission(permission) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
return """{"status": "error", "message": "permission_denied: Уведомления отключены в настройках. Попроси пользователя включить их в настройках приложения."}"""
}
}
return try {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Уведомления от Mistral Chat"
enableVibration(true)
}
notificationManager.createNotificationChannel(channel)
}
val notification = android.app.Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(message)
.setStyle(android.app.Notification.BigTextStyle().bigText(message))
.setPriority(android.app.Notification.PRIORITY_HIGH)
.setAutoCancel(true)
.build()
notificationManager.notify(System.currentTimeMillis().toInt(), notification)
"""{"status": "success", "message": "Уведомление отправлено: $title"}"""
} catch (e: Exception) {
"""{"status": "error", "message": "${e.message}"}"""
}
}
}

View file

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

@ -0,0 +1,77 @@
package com.mistral.chat.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import net.sqlcipher.database.SupportFactory
@Database(
entities = [Profile::class, Session::class, MessageEntity::class, Setting::class],
version = 1,
exportSchema = false
)
abstract class ChatDatabase : RoomDatabase() {
abstract fun profileDao(): ProfileDao
abstract fun sessionDao(): SessionDao
abstract fun messageDao(): MessageDao
abstract fun settingDao(): SettingDao
companion object {
private const val DATABASE_NAME = "mistral_chat.db"
@Volatile
private var INSTANCE: ChatDatabase? = null
fun getInstance(context: Context): ChatDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: buildDatabase(context).also { INSTANCE = it }
}
}
private fun buildDatabase(context: Context): ChatDatabase {
val passphrase = getOrCreatePassphrase(context)
val factory = SupportFactory(passphrase)
return Room.databaseBuilder(
context.applicationContext,
ChatDatabase::class.java,
DATABASE_NAME
)
.openHelperFactory(factory)
.fallbackToDestructiveMigration()
.build()
}
private fun getOrCreatePassphrase(context: Context): ByteArray {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val securePrefs = EncryptedSharedPreferences.create(
context,
"db_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
val existingKey = securePrefs.getString("db_key", null)
return if (existingKey != null) {
existingKey.toByteArray(Charsets.UTF_8)
} else {
val newKey = generateSecureKey()
securePrefs.edit().putString("db_key", newKey).apply()
newKey.toByteArray(Charsets.UTF_8)
}
}
private fun generateSecureKey(): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"
return (1..32).map { chars.random() }.joinToString("")
}
}
}

View file

@ -1,27 +1,52 @@
package com.mistral.chat.data
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "messages",
foreignKeys = [
ForeignKey(
entity = Session::class,
parentColumns = ["id"],
childColumns = ["sessionId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("sessionId")]
)
data class MessageEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val sessionId: Long,
val content: String,
val isUser: Boolean,
val timestamp: Long = System.currentTimeMillis()
)
data class Message(
val id: String = System.currentTimeMillis().toString(),
val id: Long = 0,
val sessionId: Long = 0,
val content: String,
val isUser: Boolean,
val timestamp: Long = System.currentTimeMillis(),
val senderName: String? = null
)
data class ChatRequest(
val model: String,
val messages: List<Message>,
val temperature: Double = 0.7,
val stream: Boolean = false
fun MessageEntity.toMessage(): Message = Message(
id = id,
sessionId = sessionId,
content = content,
isUser = isUser,
timestamp = timestamp
)
data class ChatResponse(
val id: String,
val choices: List<Choice>,
val model: String
)
data class Choice(
val index: Int,
val message: Message
fun Message.toEntity(): MessageEntity = MessageEntity(
id = id,
sessionId = sessionId,
content = content,
isUser = isUser,
timestamp = timestamp
)

View file

@ -0,0 +1,31 @@
package com.mistral.chat.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE sessionId = :sessionId ORDER BY timestamp ASC")
fun getMessagesBySession(sessionId: Long): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE sessionId = :sessionId ORDER BY timestamp ASC")
suspend fun getMessagesBySessionSync(sessionId: Long): List<MessageEntity>
@Query("SELECT COUNT(*) FROM messages WHERE sessionId = :sessionId")
suspend fun getMessageCount(sessionId: Long): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(message: MessageEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(messages: List<MessageEntity>)
@Delete
suspend fun delete(message: MessageEntity)
@Query("DELETE FROM messages WHERE sessionId = :sessionId")
suspend fun deleteBySession(sessionId: Long)
@Query("DELETE FROM messages")
suspend fun deleteAll()
}

View file

@ -0,0 +1,15 @@
package com.mistral.chat.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "profiles")
data class Profile(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val name: String,
val bio: String = "",
val preferences: String = "",
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis()
)

View file

@ -0,0 +1,28 @@
package com.mistral.chat.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface ProfileDao {
@Query("SELECT * FROM profiles ORDER BY updatedAt DESC")
fun getAllProfiles(): Flow<List<Profile>>
@Query("SELECT * FROM profiles WHERE id = :id")
suspend fun getProfileById(id: Long): Profile?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(profile: Profile): Long
@Update
suspend fun update(profile: Profile)
@Delete
suspend fun delete(profile: Profile)
@Query("DELETE FROM profiles WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("DELETE FROM profiles")
suspend fun deleteAll()
}

View file

@ -0,0 +1,29 @@
package com.mistral.chat.data
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "sessions",
foreignKeys = [
ForeignKey(
entity = Profile::class,
parentColumns = ["id"],
childColumns = ["profileId"],
onDelete = ForeignKey.SET_NULL
)
],
indices = [Index("profileId")]
)
data class Session(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val profileId: Long? = null,
val title: String = "Новая сессия",
val isManuallyRenamed: Boolean = false,
val isTitleGenerated: Boolean = false,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis()
)

View file

@ -0,0 +1,40 @@
package com.mistral.chat.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface SessionDao {
@Query("SELECT * FROM sessions ORDER BY updatedAt DESC")
fun getAllSessions(): Flow<List<Session>>
@Query("SELECT * FROM sessions WHERE profileId = :profileId ORDER BY updatedAt DESC")
fun getSessionsByProfile(profileId: Long): Flow<List<Session>>
@Query("SELECT * FROM sessions WHERE profileId IS NULL ORDER BY updatedAt DESC")
fun getSessionsWithoutProfile(): Flow<List<Session>>
@Query("SELECT * FROM sessions WHERE id = :id")
suspend fun getSessionById(id: Long): Session?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(session: Session): Long
@Update
suspend fun update(session: Session)
@Delete
suspend fun delete(session: Session)
@Query("DELETE FROM sessions WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("DELETE FROM sessions")
suspend fun deleteAll()
@Query("UPDATE sessions SET updatedAt = :timestamp WHERE id = :sessionId")
suspend fun updateTimestamp(sessionId: Long, timestamp: Long = System.currentTimeMillis())
@Query("UPDATE sessions SET title = :title, isManuallyRenamed = 1 WHERE id = :sessionId")
suspend fun updateTitle(sessionId: Long, title: String)
}

View file

@ -0,0 +1,11 @@
package com.mistral.chat.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "settings")
data class Setting(
@PrimaryKey
val key: String,
val value: String
)

View file

@ -0,0 +1,22 @@
package com.mistral.chat.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface SettingDao {
@Query("SELECT * FROM settings WHERE `key` = :key")
suspend fun getSetting(key: String): Setting?
@Query("SELECT value FROM settings WHERE `key` = :key")
suspend fun getValue(key: String): String?
@Query("SELECT value FROM settings WHERE `key` = :key")
fun getValueFlow(key: String): Flow<String?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(setting: Setting)
@Query("DELETE FROM settings WHERE `key` = :key")
suspend fun delete(key: String)
}

View file

@ -1,18 +0,0 @@
package com.mistral.chat.data
data class UserProfile(
val name: String = "",
val bio: String = "",
val preferences: String = ""
) {
fun isEmpty(): Boolean = name.isBlank() && bio.isBlank() && preferences.isBlank()
fun toContextString(): String {
return buildString {
append("[User Profile]\n")
if (name.isNotBlank()) append("Name: $name\n")
if (bio.isNotBlank()) append("Bio: $bio\n")
if (preferences.isNotBlank()) append("Preferences: $preferences\n")
}
}
}

View file

@ -0,0 +1,54 @@
package com.mistral.chat.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.mistral.chat.R
class DrawerSubmenuAdapter(
private val items: List<DrawerMenuItem>,
private val onItemClick: (DrawerMenuItem) -> Unit
) : RecyclerView.Adapter<DrawerSubmenuAdapter.ViewHolder>() {
data class DrawerMenuItem(
val id: String,
val title: String,
val icon: Int? = null,
val isSelected: Boolean = false
)
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val icon: ImageView = itemView.findViewById(R.id.itemIcon)
val title: TextView = itemView.findViewById(R.id.itemTitle)
val check: ImageView = itemView.findViewById(R.id.itemCheck)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_drawer, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
holder.title.text = item.title
if (item.icon != null) {
holder.icon.setImageResource(item.icon)
holder.icon.visibility = View.VISIBLE
} else {
holder.icon.visibility = View.GONE
}
holder.check.visibility = if (item.isSelected) View.VISIBLE else View.GONE
holder.itemView.setOnClickListener {
onItemClick(item)
}
}
override fun getItemCount(): Int = items.size
}

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
@ -46,6 +47,11 @@ class MessageAdapter(private val messages: List<Message>) : RecyclerView.Adapter
class UserMessageHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(message: Message) {
val textView = itemView.findViewById<TextView>(R.id.messageText)
val senderNameView = itemView.findViewById<TextView>(R.id.senderName)
val senderIconView = itemView.findViewById<ImageView>(R.id.senderIcon)
senderIconView.visibility = View.VISIBLE
senderNameView.text = message.senderName ?: "Вы"
textView.text = message.content
textView.setBackgroundResource(R.drawable.bg_message_user)
textView.setTextColor(ContextCompat.getColor(itemView.context, R.color.user_message_text))

View file

@ -0,0 +1,55 @@
package com.mistral.chat.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.mistral.chat.R
import com.mistral.chat.data.Profile
class ProfilesAdapter(
private val profiles: List<Profile>,
private val onProfileClick: (Profile) -> Unit,
private val onProfileLongClick: (Profile) -> Unit,
private val getSelectedProfileId: () -> Long?
) : RecyclerView.Adapter<ProfilesAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val icon: ImageView = view.findViewById(R.id.profileIcon)
val name: TextView = view.findViewById(R.id.profileName)
val checkmark: ImageView = view.findViewById(R.id.profileCheckmark)
}
fun refresh() {
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_profile, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val profile = profiles[position]
val selectedId = getSelectedProfileId()
holder.name.text = if (profile.name.length > 12) {
profile.name.take(12) + "..."
} else {
profile.name
}
holder.name.alpha = if (profile.id == selectedId) 1.0f else 0.7f
holder.checkmark.visibility = if (profile.id == selectedId) View.VISIBLE else View.GONE
holder.itemView.setOnClickListener { onProfileClick(profile) }
holder.itemView.setOnLongClickListener {
onProfileLongClick(profile)
true
}
}
override fun getItemCount(): Int = profiles.size
}

View file

@ -0,0 +1,45 @@
package com.mistral.chat.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.mistral.chat.R
import com.mistral.chat.data.Session
class SessionsAdapter(
private val sessions: List<Session>,
private val getCurrentSessionId: () -> Long?,
private val onSessionClick: (Session) -> Unit,
private val onSessionLongClick: (Session) -> Unit
) : RecyclerView.Adapter<SessionsAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val title: TextView = view.findViewById(R.id.sessionTitle)
val checkmark: ImageView = view.findViewById(R.id.sessionCheckmark)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_session, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val session = sessions[position]
val currentId = getCurrentSessionId()
holder.title.text = session.title
holder.title.alpha = if (session.id == currentId) 1.0f else 0.7f
holder.checkmark.visibility = if (session.id == currentId) View.VISIBLE else View.GONE
holder.itemView.setOnClickListener { onSessionClick(session) }
holder.itemView.setOnLongClickListener {
onSessionLongClick(session)
true
}
}
override fun getItemCount(): Int = sessions.size
}

View file

@ -2,5 +2,5 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#00000000"/>
<corners android:radius="20dp"/>
<corners android:radius="12dp"/>
</shape>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zm-2,15l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"/>
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
</vector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zm1,15h-2v-6h2v6zm0,-8h-2V7h2v2z"/>
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M12.65,10C11.83,7.67 9.61,6 7,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6c2.61,0 4.83,-1.67 5.65,-4H17v4h4v-4h2v-4H12.65zM7,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z"/>
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M3,18h18v-2H3v2zm0,-5h18v-2H3v2zm0,-7v2h18V6H3z"/>
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zm0,2c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M19.14,12.94c0.04,-0.31 0.06,-0.63 0.06,-0.94c0,-0.31 -0.02,-0.63 -0.06,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.37 4.8,11.69 4.8,12s0.02,0.63 0.06,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
</vector>

View file

@ -1,135 +1,173 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
<androidx.drawerlayout.widget.DrawerLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".ui.MainActivity">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true">
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<ImageView
android:id="@+id/logoButton"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/ic_mistral_logo"
android:contentDescription="@string/select_model"
android:clickable="true"
android:focusable="true" />
<TextView
android:id="@+id/toolbarTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
android:text="Le Chat"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface" />
<ImageButton
android:id="@+id/menuButton"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_menu_dots"
android:contentDescription="@string/settings"
android:clickable="true"
android:focusable="true" />
</LinearLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@+id/inputContainer"
android:layout_below="@+id/appBarLayout"
android:padding="16dp"
android:clipToPadding="false" />
<LinearLayout
android:id="@+id/inputContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:orientation="vertical"
android:background="?attr/colorSurface"
android:padding="16dp">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressIndicator"
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"
android:layout_marginBottom="8dp" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/inputCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="28dp"
app:cardElevation="4dp">
android:layout_alignParentTop="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:layout_height="?attr/actionBarSize"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="4dp"
android:paddingEnd="0dp"
android:paddingTop="4dp"
android:paddingBottom="4dp">
android:paddingEnd="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/inputField"
<ImageButton
android:id="@+id/hamburgerButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_menu_hamburger"
android:contentDescription="@string/settings"
android:clickable="true"
android:focusable="true" />
<ImageView
android:id="@+id/logoButton"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/ic_mistral_logo"
android:contentDescription="@string/select_model"
android:clickable="true"
android:focusable="true" />
<TextView
android:id="@+id/toolbarTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@android:color/transparent"
android:hint="@string/enter_message"
android:imeOptions="actionSend"
android:inputType="textMultiLine"
android:maxLines="5"
android:minHeight="56dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="12dp"
android:paddingBottom="12dp" />
android:layout_marginStart="12dp"
android:text="Le Chat"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface" />
<ImageView
android:id="@+id/sendButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:padding="8dp"
android:src="@drawable/ic_mistral_logo"
android:background="@drawable/bg_send_button"
android:contentDescription="@string/send"
<ImageButton
android:id="@+id/menuButton"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_menu_dots"
android:contentDescription="@string/settings"
android:clickable="true"
android:focusable="true" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</com.google.android.material.appbar.AppBarLayout>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@+id/inputContainer"
android:layout_below="@+id/appBarLayout"
android:padding="16dp"
android:clipToPadding="false" />
</RelativeLayout>
<LinearLayout
android:id="@+id/inputContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:orientation="vertical"
android:background="?attr/colorSurface"
android:padding="16dp">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressIndicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"
android:layout_marginBottom="8dp" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/inputCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="16dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/inputField"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@android:color/transparent"
android:hint="@string/enter_message"
android:imeOptions="actionSend"
android:inputType="textMultiLine|textCapSentences"
android:maxLines="5"
android:minHeight="56dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="12dp"
android:paddingBottom="12dp" />
<ImageView
android:id="@+id/sendButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="4dp"
android:padding="8dp"
android:src="@drawable/ic_mistral_logo"
android:background="@drawable/bg_send_button"
android:contentDescription="@string/send"
android:clickable="true"
android:focusable="true" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</RelativeLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/navigationView"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:headerLayout="@layout/nav_header"
app:menu="@menu/drawer_menu" />
<FrameLayout
android:id="@+id/rightPanelContainer"
android:layout_width="300dp"
android:layout_height="match_parent"
android:layout_gravity="end"
android:background="?attr/colorSurface">
<include layout="@layout/panel_right" />
</FrameLayout>
</androidx.drawerlayout.widget.DrawerLayout>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/apiKeyLayout"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/api_key_hint"
app:endIconMode="password_toggle">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/apiKeyInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:digits="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
android:maxLength="64"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/clear_all_confirm"
android:textSize="16sp"
android:layout_marginBottom="16dp" />
<CheckBox
android:id="@+id/deleteProfilesCheckbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/clear_all_delete_profiles"
android:textSize="14sp" />
</LinearLayout>

View file

@ -24,7 +24,7 @@
android:id="@+id/nameInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName"
android:inputType="textPersonName|textCapSentences"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
@ -41,7 +41,7 @@
android:id="@+id/bioInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:inputType="textMultiLine|textCapSentences"
android:minLines="3"
android:maxLines="5" />
@ -59,7 +59,7 @@
android:id="@+id/preferencesInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:inputType="textMultiLine|textCapSentences"
android:minLines="2"
android:maxLines="4" />

View file

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/profile_name"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/profileNameInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/profile_bio"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/profileBioInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:minLines="3" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/profile_preferences"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/profilePreferencesInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:minLines="2" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/profilesList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxHeight="300dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/addProfileButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/new_profile"
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout>

View file

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnProfiles"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/profiles"
android:gravity="start|center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:icon="@drawable/ic_person"
app:iconGravity="start"
xmlns:app="http://schemas.android.com/apk/res-auto" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSettings"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings"
android:gravity="start|center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:icon="@drawable/ic_settings"
app:iconGravity="start"
xmlns:app="http://schemas.android.com/apk/res-auto" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnClearHistory"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/clear_history"
android:gravity="start|center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:icon="@drawable/ic_delete"
app:iconGravity="start"
xmlns:app="http://schemas.android.com/apk/res-auto" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnApiKey"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/api_key"
android:gravity="start|center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:icon="@drawable/ic_key"
app:iconGravity="start"
xmlns:app="http://schemas.android.com/apk/res-auto" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAbout"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/about"
android:gravity="start|center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:icon="@drawable/ic_info"
app:iconGravity="start"
xmlns:app="http://schemas.android.com/apk/res-auto" />
</LinearLayout>

View file

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/profile_name"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/nameInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/profile_bio"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/bioInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:minLines="3" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/profile_preferences"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/preferencesInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:minLines="2" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/cancelButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/cancel" />
<com.google.android.material.button.MaterialButton
android:id="@+id/saveButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/save" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/deleteButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/delete"
android:textColor="?attr/colorError"
android:visibility="gone" />
</LinearLayout>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBack"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/back"
android:gravity="start|center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:background="?attr/colorOutline" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAppearance"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/appearance_settings"
android:gravity="start|center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSession"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/session_settings"
android:gravity="start|center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp" />
</LinearLayout>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/drawerSubmenuContainer"
android:layout_width="300dp"
android:layout_height="match_parent"
android:layout_gravity="start"
android:background="?attr/colorSurface"
android:orientation="vertical"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="4dp"
android:paddingEnd="8dp">
<ImageButton
android:id="@+id/backButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_arrow_back"
android:contentDescription="@string/back" />
<TextView
android:id="@+id/submenuTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/submenuRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp" />
</LinearLayout>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="16dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:id="@+id/itemIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_person"
app:tint="?attr/colorOnSurface"
xmlns:app="http://schemas.android.com/apk/res-auto" />
<TextView
android:id="@+id/itemTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="16dp"
android:textSize="16sp"
android:textColor="?attr/colorOnSurface" />
<ImageView
android:id="@+id/itemCheck"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_check"
android:visibility="gone"
app:tint="?attr/colorPrimary"
xmlns:app="http://schemas.android.com/apk/res-auto" />
</LinearLayout>

View file

@ -31,7 +31,7 @@
android:id="@+id/messageText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="280dp"
android:maxWidth="350dp"
android:padding="12dp"
android:textSize="16sp"
app:layout_constraintStart_toStartOf="parent"

View file

@ -7,13 +7,33 @@
android:padding="8dp">
<TextView
android:id="@+id/messageText"
android:id="@+id/senderName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="280dp"
android:padding="12dp"
android:textSize="16sp"
android:layout_marginEnd="4dp"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant"
app:layout_constraintEnd_toStartOf="@id/senderIcon"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/senderIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@drawable/ic_person"
android:contentDescription="@string/profile"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/messageText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="350dp"
android:layout_marginTop="2dp"
android:padding="12dp"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/senderIcon" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="8dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:id="@+id/profileIcon"
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@drawable/ic_person"
android:contentDescription="@string/profile" />
<TextView
android:id="@+id/profileName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface" />
<ImageView
android:id="@+id/profileCheckmark"
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@drawable/ic_check"
android:visibility="gone"
android:contentDescription="@string/selected" />
</LinearLayout>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="8dp"
android:background="?attr/selectableItemBackground">
<TextView
android:id="@+id/sessionTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface"
android:maxLines="2"
android:ellipsize="end" />
<ImageView
android:id="@+id/sessionCheckmark"
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@drawable/ic_check"
android:visibility="gone"
android:contentDescription="@string/save" />
</LinearLayout>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp"
android:background="?attr/colorPrimaryContainer">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="?attr/colorOnPrimaryContainer" />
</LinearLayout>

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/panelRightContent"
android:layout_width="300dp"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="@string/profiles"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurfaceVariant" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/profilesRecyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="?attr/colorOutlineVariant" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="@string/sessions"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurfaceVariant" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/sessionsRecyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:paddingHorizontal="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/newSessionButton"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/new_session"
app:icon="@drawable/ic_add" />
</LinearLayout>

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/currentKeyText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="?attr/colorOnSurface"
android:layout_marginBottom="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/changeKeyButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Изменить ключ" />
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/themeSystemButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/system_theme" />
<com.google.android.material.button.MaterialButton
android:id="@+id/themeLightButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/light_theme" />
<com.google.android.material.button.MaterialButton
android:id="@+id/themeDarkButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/dark_theme" />
</LinearLayout>

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/screenBaseContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="4dp"
android:paddingEnd="8dp">
<ImageButton
android:id="@+id/screenBackButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_arrow_back"
android:contentDescription="@string/back" />
<TextView
android:id="@+id/screenTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant" />
<FrameLayout
android:id="@+id/screenContent"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/clear_history_message"
android:textSize="16sp"
android:textColor="?attr/colorOnSurface"
android:layout_marginBottom="24dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/clearSessionsButton"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Удалить сессии" />
<com.google.android.material.button.MaterialButton
android:id="@+id/clearAllButton"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Удалить всё (включая профили)"
android:textColor="?attr/colorError" />
</LinearLayout>

View file

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/profile_name"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/profileNameInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/profile_bio"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/profileBioInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:minLines="3" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/profile_preferences"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/profilePreferencesInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:minLines="2" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/saveProfileButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/save" />
<com.google.android.material.button.MaterialButton
android:id="@+id/deleteProfileButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/delete"
android:textColor="?attr/colorError"
android:visibility="gone" />
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/profilesList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:scrollbars="vertical" />
<com.google.android.material.button.MaterialButton
android:id="@+id/addProfileButton"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/new_profile"
app:icon="@drawable/ic_add" />
</LinearLayout>

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/sessionLastButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/open_last_session" />
<com.google.android.material.button.MaterialButton
android:id="@+id/sessionNewButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/start_new_session" />
</LinearLayout>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_profiles"
android:title="@string/manage_profiles"
android:icon="@drawable/ic_person" />
<item
android:id="@+id/action_appearance"
android:title="@string/appearance_settings"
android:icon="@drawable/ic_settings" />
<item
android:id="@+id/action_session"
android:title="@string/session_settings"
android:icon="@drawable/ic_settings" />
<item
android:id="@+id/action_clear_all"
android:title="@string/clear_history"
android:icon="@drawable/ic_delete" />
<item
android:id="@+id/action_api_key"
android:title="@string/api_key"
android:icon="@drawable/ic_key" />
<item
android:id="@+id/action_about"
android:title="@string/about"
android:icon="@drawable/ic_info" />
</menu>

View file

@ -2,6 +2,10 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_api_key"
android:title="@string/api_key" />
<item
android:id="@+id/action_profile"
android:title="@string/profile" />

View file

@ -29,6 +29,6 @@
<style name="AlertDialogShapeAppearance" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">24dp</item>
<item name="cornerSize">28dp</item>
</style>
</resources>

View file

@ -11,6 +11,69 @@
<string name="profile_name">Имя</string>
<string name="profile_bio">О себе</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="cancel">Отмена</string>
<string name="delete">Удалить</string>
@ -25,4 +88,44 @@
<string name="bio_hint">Расскажите о себе...</string>
<string name="no_profile">Профиль не установлен</string>
<string name="settings">Настройки</string>
<string name="api_key">Mistral API</string>
<string name="api_key_title">Mistral API ключ</string>
<string name="api_key_hint">Введите API ключ</string>
<string name="api_key_saved">API ключ сохранён</string>
<string name="api_key_deleted">API ключ удалён</string>
<string name="no_api_key">API ключ не установлен</string>
<string name="api_key_current">Текущий ключ: %s</string>
<string name="enter_api_key">Введите API ключ</string>
<string name="api_key_required">Требуется API ключ Mistral</string>
<string name="clear_all_history">Очистить всю историю</string>
<string name="clear_all_confirm">Удалить все сессии и сообщения?</string>
<string name="history_cleared">История очищена</string>
<string name="sessions">Сессии</string>
<string name="new_session">Новая сессия</string>
<string name="no_sessions">Нет сессий</string>
<string name="ok">OK</string>
<string name="profiles">Профили</string>
<string name="manage_profiles">Управление профилями</string>
<string name="profiles_uppercase">ПРОФИЛИ</string>
<string name="new_profile">Новый профиль</string>
<string name="edit">Редактировать</string>
<string name="selected">Выбрано</string>
<string name="clear_all_delete_profiles">Удалить все профили</string>
<string name="profile_info">Профиль: %s</string>
<string name="appearance_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_message">Удалить все сессии и сообщения?</string>
<string name="open_last_session">Открывать последнюю сессию</string>
<string name="start_new_session">Начинать новую сессию</string>
<string name="light_theme">Светлая</string>
<string name="dark_theme">Тёмная</string>
<string name="back">Назад</string>
<string name="system_theme">Системная</string>
</resources>

View file

@ -29,6 +29,6 @@
<style name="AlertDialogShapeAppearance" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">24dp</item>
<item name="cornerSize">28dp</item>
</style>
</resources>

View file

@ -1,5 +1,6 @@
// Top-level build file
plugins {
id 'com.android.application' version '8.2.0' apply false
id 'org.jetbrains.kotlin.android' version '1.9.0' apply false
id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
id 'com.google.devtools.ksp' version '1.9.22-1.0.17' apply false
}