Skip to content

Latest commit

 

History

History
756 lines (589 loc) · 29.7 KB

File metadata and controls

756 lines (589 loc) · 29.7 KB

Этот файл является частью проекта Victor AI

Проект распространяется под лицензией GNU Affero General Public License v3.0 (AGPL-3.0).

Подробности лицензии: https://www.gnu.org/licenses/agpl-3.0.html
Полный текст: LICENSE.txt в корне репозитория.

Copyright © 2026 Olga Kalinina


Экран "Чат" (Chat)

Чат с потоковым выводом ответов, пагинацией истории, поиском и оптимизациями производительности

Сообщения отображаются в реальном времени с эффектом печати, поддерживается редактирование истории и эмодзи-реакции.

Основные функции:

  • Стриминг ответов - AI печатает ответ символ за символом
  • Бесконечная пагинация - загрузка старых сообщений при скролле вверх
  • Поиск по истории - быстрый поиск с контекстом и навигацией
  • Редактирование - изменение любого сообщения в истории
  • Эмодзи-реакции - установка эмодзи на сообщения
  • Голосовой ввод - отправка голосовых сообщений

Как открыть:

Нажатие на Presence Placeholder (глазки Victor и текст после них) на главном экране (MainScreen)

API эндпоинты:

Сообщения:

  • POST /assistant/message/stream - отправка сообщения с потоковым ответом

    • Server-Sent Events (SSE) для стриминга текста
    • Возвращает ответ символ за символом
  • GET /chat/get_history - загрузка истории с пагинацией

    • Параметры: account_id, limit, before_id
    • Возвращает: messages, has_more, oldest_id, newest_id

Управление историей:

  • PUT /chat/update_history - редактирование сообщений

    • Обновление текста существующего сообщения
    • Синхронизация всей истории с бэкендом
  • POST /chat/update_emoji - установка эмодзи-реакции

    • Параметры: account_id, backend_id, emoji

Поиск:

  • GET /chat/history/search - поиск по истории
    • Параметры: account_id, query, offset, context_before, context_after
    • Возвращает контекст вокруг найденного + навигацию по результатам

📸 UI


Главный экран чата

Стриминг ответа

Поиск по истории

Редактирование

Эмодзи-реакции

📁 Структура модуля

ui/screens/
└── ChatScreen.kt              # Главный экран чата (интеграция)

ui/chat/
├── ChatViewModel.kt           # Бизнес-логика и состояние чата
├── ChatBox.kt                 # Основной UI-контейнер чата
├── components/
│   ├── MessageItem.kt        # Отдельное сообщение (user/assistant)
│   ├── ChatHeader.kt         # Шапка с поиском и меню
│   ├── ChatInputPanel.kt     # Панель ввода сообщения
│   ├── ChatMessagesList.kt   # Список сообщений (LazyColumn)
│   ├── ChatModeMenu.kt       # Меню режимов (production/edit)
│   ├── EmojiPicker.kt        # Диалог выбора emoji-реакций
│   ├── SearchOverlay.kt      # Оверлей поиска с результатами
│   ├── ScrollToBottomButton.kt # Кнопка для скролла вниз
│   └── ModeMenuItem.kt       # Элемент меню режима
├── utils/
│   └── MarkdownUtils.kt      # Парсинг markdown в AnnotatedString
└── README.md                 # Внутренняя документация модуля

data/repository/
├── ChatRepository.kt          # Репозиторий чата (Room + API)
└── [другие репозитории]

data/network/
└── ChatApi.kt                 # Retrofit API для чата

data/local/dao/
└── ChatMessageDao.kt          # Room DAO для сообщений

domain/model/
└── ChatMessage.kt             # Domain модель сообщения

💬 Техническая документация

📑 Содержание

  1. Архитектура
  2. Поток данных
  3. Управление состоянием
  4. Оптимизации производительности
  5. Стриминг сообщений
  6. Пагинация
  7. Поиск
  8. Compose оптимизации
  9. Управление жизненным циклом
  10. Сетевые запросы
  11. UX фичи
  12. Типичные задачи
  13. Отладка
  14. Тестирование
  15. Технологический стек
  16. Связанные документы
  17. Будущие улучшения

🏗️ Архитектура

Clean Architecture Pattern

┌─────────────────────────────────────────────────────────┐
│                      UI Layer                           │
│  ┌──────────────┐    ┌──────────────┐                  │
│  │ ChatScreen   │───▶│  ChatBox     │                  │
│  └──────────────┘    └──────┬───────┘                  │
│                             │                            │
│                    ┌────────▼────────┐                  │
│                    │ MessageItem     │                  │
│                    │ ChatInputPanel  │                  │
│                    │ ChatHeader      │                  │
│                    └─────────────────┘                  │
└────────────────────────┬────────────────────────────────┘
                         │ State<T> (reactive)
┌────────────────────────▼────────────────────────────────┐
│                  ViewModel Layer                         │
│  ┌──────────────────────────────────────────────────┐   │
│  │           ChatViewModel                          │   │
│  │                                                  │   │
│  │  State:                                          │   │
│  │  • chatMessages: StateFlow<List<ChatMessage>>   │   │
│  │  • isTyping: StateFlow<Boolean>                 │   │
│  │  • isLoadingMore: StateFlow<Boolean>            │   │
│  │  • hasMoreHistory: StateFlow<Boolean>           │   │
│  │  • oldestId: StateFlow<Int?>                    │   │
│  │  • searchResults: StateFlow<List<ChatMessage>>  │   │
│  │  • careBankWebViewUrl: StateFlow<String?>       │   │
│  │                                                  │   │
│  │  Business Logic:                                 │   │
│  │  • sendTextToAssistant()                        │   │
│  │  • loadMoreHistory()                            │   │
│  │  • searchInHistory()                            │   │
│  │  • editMessage()                                │   │
│  │  • updateMessageEmoji()                         │   │
│  └──────────────────────────────────────────────────┘   │
└────────────────────────┬────────────────────────────────┘
                         │
┌────────────────────────▼────────────────────────────────┐
│                  Data Layer                              │
│  ┌──────────────┐    ┌──────────────┐                   │
│  │ChatRepository│───▶│   ChatApi    │──▶ Backend        │
│  │              │    └──────────────┘                   │
│  │      ↕       │                                        │
│  │  ChatMessageDao                                       │
│  │      ↕       │                                        │
│  │  Room Database (offline-first)                       │
│  └──────────────┘                                        │
└─────────────────────────────────────────────────────────┘

🔄 Поток данных

1. Отправка сообщения

User → ChatInputPanel → onSendMessage()
                            ↓
                    ChatViewModel.addUserMessage()
                            ↓
                    Сохранение в Room (локально, isSynced=false)
                            ↓
                    _chatMessages обновляется через Flow (Room)
                            ↓
                    ChatViewModel.sendTextToAssistant()
                            ↓
                    POST /assistant/message/stream [SSE]
                            ↓
                    Channel<Char> → typingJob → обновление UI по символу
                            ↓
                    Сохранение ответа в Room (isSynced=false)
                            ↓
                    syncWithBackend() → обновление всей истории
                            ↓
                    Room обновляет сообщения (isSynced=true)
                            ↓
                    UI автоматически обновляется через Flow

2. Пагинация (загрузка старой истории)

User скроллит вверх → LaunchedEffect(listState)
                            ↓
                    snapshotFlow { lastVisibleIndex }
                            ↓
                    if (lastVisibleIndex >= totalItems - 3)
                            ↓
                    ChatViewModel.loadMoreHistory(oldestId)
                            ↓
                    ChatApi.getChatHistory(before_id=oldestId)
                            ↓
                    Response → сохранение в Room через ChatRepository
                            ↓
                    Room автоматически обновляет Flow
                            ↓
                    _oldestId.value = newOldestId
                            ↓
                    UI обновляется через StateFlow (реактивно)

3. Поиск

User вводит запрос → debounce 500ms
                            ↓
                    ChatViewModel.searchInHistory(query)
                            ↓
                    ChatApi.searchChatHistory(query, offset=0)
                            ↓
                    _searchResults.value = контекст вокруг найденного
                    _searchMatchedMessageId.value = ID найденного
                            ↓
                    Автоскролл к найденному сообщению + подсветка

💾 Управление состоянием

StateFlow'ы в ChatViewModel

StateFlow Тип Назначение
chatMessages List<ChatMessage> Список всех сообщений (synced + unsynced)
isTyping Boolean Печатает ли ассистент ответ
isLoadingMore Boolean Загружается ли старая история
hasMoreHistory Boolean Есть ли еще сообщения для загрузки
oldestId Int? ID самого старого загруженного сообщения
searchResults List<ChatMessage> Результаты поиска с контекстом
searchMatchedMessageId Int? ID текущего найденного сообщения
careBankWebViewUrl String? URL для WebView банка заботы

Локальное UI-состояние в ChatBox

State Тип Назначение
userInput String Текст в поле ввода
editingMessageKey String? Ключ редактируемого сообщения
attachedImages List<ImageAttachment> Прикрепленные изображения
showMenu Boolean Открыто ли меню режимов
showSearchOverlay Boolean Открыт ли поиск
showWebView Boolean Открыт ли WebView

Правило: Локально храним только чисто UI-состояние. Вся бизнес-логика в ViewModel!


⚡ Оптимизации производительности

1. Key в LazyColumn items

items(
    items = syncedMessages,
    key = { message -> "synced_${message.id}" }  // ✅ Stable key
) { message ->
    MessageItem(...)
}

Эффект: LazyColumn переиспользует элементы вместо пересоздания. 20x быстрее рендеринг.

2. Remember для тяжёлых вычислений

// Фильтрация и сортировка (выполняется ОДИН раз при изменении messages)
val syncedMessages = remember(messages) {
    messages.filter { it.isSynced }.sortedByDescending { it.id }
}

// Парсинг markdown (выполняется ОДИН раз при изменении текста)
val annotatedText = remember(message.text, searchQuery) {
    parseMarkdown(message.text)
}

Эффект: Вычисления выполняются только при изменении зависимостей, а не при каждой рекомпозиции.

3. Уникальные ключи вместо indexOf()

// ✅ O(1) - уникальный ключ
val messageKey = getMessageKey(message)

Эффект: При 100 сообщениях: 10,000 операций → 100 операций. 100x быстрее.

4. Отмена предыдущего стрима

private var currentStreamJob: Job? = null

fun sendTextToAssistant() {
    currentStreamJob?.cancel()  // Отменяем предыдущий!
    
    currentStreamJob = viewModelScope.launch {
        processStreamingMessage(...)
    }
}

Эффект: Нет конфликтов между стримами. Только один активный стрим.

5. Проверка isActive в стриминге

while (isActive) {  // Проверка на каждой итерации
    val line = bufferedReader.readLine() ?: break
    // обработка...
}

Эффект: Стрим мгновенно останавливается при отмене корутины.


🌊 Стриминг сообщений

Архитектура стриминга

sendTextToAssistant()
    ↓
[Job 1] streamJob (Dispatchers.IO)
    │
    ├─▶ processStreamingMessage()
    │       │
    │       ├─▶ Retrofit.sendAssistantRequestStream()
    │       │       │
    │       │       └─▶ Server-Sent Events (SSE)
    │       │
    │       └─▶ Channel<Char> ─────┐
    │                               │
    │                               ▼
    └─▶ [Job 2] typingJob (Main)  
            │
            ├─▶ for (char in charQueue)
            │       │
            │       ├─▶ _chatMessages[index].text += char
            │       ├─▶ soundPlayer.playKeypress()
            │       └─▶ delay (48ms → 16ms progressive)
            │
            └─▶ Визуальный эффект печати ⌨️

Отмена стрима

currentStreamJob?.cancel()  // Отменяет оба Job'а
    ↓
streamJob отменяется
    ↓
isActive = false в processStreamingMessage()
    ↓
while (isActive) прерывается
    ↓
Channel закрывается
    ↓
typingJob завершается (charQueue закрыт)

📜 Пагинация

Механизм пагинации

LaunchedEffect(listState) {
    snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
        .collect { lastVisibleIndex ->
            if (lastVisibleIndex >= totalItems - 3 && !isLoadingMore && hasMoreHistory) {
                loadMoreHistory(oldestId)
            }
        }
}

Состояние пагинации (в ViewModel!)

State Описание
oldestId ID самого старого загруженного сообщения
hasMoreHistory Есть ли еще сообщения в БД
isLoadingMore Идет ли загрузка сейчас

Алгоритм:

  1. User скроллит вверх → lastVisibleIndex >= totalItems - 3
  2. Проверяем: !isLoadingMore && hasMoreHistory && oldestId != null
  3. Вызываем: loadMoreHistory(oldestId)
  4. Backend: GET /chat/get_history?before_id=1039
  5. Response: {messages: [...], oldest_id: 1014, has_more: true}
  6. Обновляем: _oldestId.value = 1014
  7. Мержим сообщения через distinctBy
  8. Следующий запрос: before_id=1014

🔍 Поиск

Архитектура поиска

User вводит запрос
    ↓
Debounce 500ms (LaunchedEffect)
    ↓
ChatViewModel.searchInHistory(query, offset=0)
    ↓
ChatApi.searchChatHistory(query, offset, context_before=10, context_after=10)
    ↓
Response:
  - messages: [контекст вокруг найденного]
  - matched_message_id: ID найденного сообщения
  - total_matches: Всего совпадений
  - current_match_index: Индекс текущего результата
  - has_next: Есть ли еще результаты
    ↓
_searchMatchedMessageId.value = matched_id
    ↓
Автоскролл к найденному + подсветка

Навигация по результатам

User кликает "→"
    ↓
ChatViewModel.searchNext()
    ↓
offset++
    ↓
searchInHistory(query, offset=1)
    ↓
Новый контекст + новый matched_id

🎨 Compose оптимизации

1. Стабильные ключи для LazyColumn

// ✅ ПРАВИЛЬНО: Уникальный ключ для каждого сообщения
val getMessageKey = { message: ChatMessage ->
    if (message.isSynced && message.id != null) {
        "synced_${message.id}"
    } else {
        "unsynced_${message.timestamp}_${if (message.isUser) "user" else "assistant"}"
    }
}

items(items = messages, key = getMessageKey) { message ->
    MessageItem(message)
}

Зачем: Без key LazyColumn перерисовывает ВСЕ элементы при изменении списка.

2. Remember для дорогих операций

// Фильтрация (выполняется ТОЛЬКО при изменении messages)
val syncedMessages = remember(messages) {
    messages.filter { it.isSynced }.sortedByDescending { it.id }
}

// Парсинг markdown (выполняется ТОЛЬКО при изменении текста)
val annotatedText = remember(message.text, searchQuery) {
    parseMarkdown(message.text)
}

Зачем: При стриминге текст обновляется 60 раз/сек. Без remember = 60 парсингов markdown/сек! 💥

3. Избегание indexOf()

// ХОРОШО: O(1)
val editingMessageKey = "synced_123"
items(messages, key = getMessageKey) { message ->
    val messageKey = getMessageKey(message)  // O(1)
    val isEditing = editingMessageKey == messageKey
}

🛡️ Управление жизненным циклом

Отмена ресурсов при уничтожении

override fun onCleared() {
    super.onCleared()
    
    // Отменяем активный стрим
    currentStreamJob?.cancel()
    
    // Можно добавить другую очистку
    Log.d("Chat", "🧹 ViewModel.onCleared(): ресурсы освобождены")
}

viewModelScope для всех корутин

// ✅ Привязка к ViewModel lifecycle
viewModelScope.launch {
    processStreamingMessage(...)
}

🌐 Сетевые запросы

Таймауты

// Обычные API запросы
OkHttpClient.Builder()
    .connectTimeout(15, TimeUnit.SECONDS)
    .readTimeout(30, TimeUnit.SECONDS)
    .callTimeout(45, TimeUnit.SECONDS)  // Общий таймаут

// Стриминг (может идти долго)
OkHttpClient.Builder()
    .connectTimeout(15, TimeUnit.SECONDS)
    .readTimeout(0, TimeUnit.SECONDS)    // Без таймаута на чтение
    .callTimeout(5, TimeUnit.MINUTES)    // Максимум 5 минут

Отмена при закрытии экрана

// В processStreamingMessage()
while (isActive) {  // ✅ Проверка на каждой итерации
    val line = bufferedReader.readLine() ?: break
    // обработка...
}

📱 UX фичи

1. Автоскролл к новому сообщению

val unsyncedCount = remember(messages) { messages.count { !it.isSynced } }

LaunchedEffect(unsyncedCount) {
    if (unsyncedCount > 0 && searchMatchedMessageId == null) {
        delay(100)  // Ждём рендеринга
        listState.animateScrollToItem(0)  // reverseLayout: 0 = внизу
    }
}

Логика:

  • Скроллит только при новых сообщениях (несинхронизированных)
  • НЕ скроллит при загрузке старой истории (синхронизированные)
  • НЕ скроллит при активном поиске

2. Автоскролл к результату поиска

LaunchedEffect(searchMatchedMessageId) {
    searchMatchedMessageId?.let { matchedId ->
        val messageIndex = findIndexInList(matchedId)
        val centerOffset = -(viewportHeight / 2)
        listState.animateScrollToItem(messageIndex, centerOffset)
    }
}

3. Режимы работы

Режим Описание Жесты
production Обычный режим Tap → закрыть чат
LongPress → микрофон
edit mode Режим редактирования LongPress на сообщение → редактировать

🔧 Типичные задачи

Добавить новое поле в сообщение

  1. Обновить ChatMessage.kt (domain model)
  2. Обновить маппер в ChatRepository.kt
  3. Обновить MessageItem.kt для отображения
  4. Обновить backend API (если нужно)

Добавить новую кнопку в сообщение

  1. Добавить callback в MessageItem.kt:
    onNewAction: () -> Unit
  2. Пробросить callback через ChatBox.kt
  3. Реализовать логику в ChatViewModel.kt

Изменить логику пагинации

  1. Изменить loadMoreHistory() в ChatViewModel.kt
  2. Обновить условие триггера в ChatBox.kt (LaunchedEffect)
  3. При необходимости обновить ChatApi.kt

🐛 Отладка

Логирование

// В ChatViewModel
Log.d("Chat", "...")         // Бизнес-логика

// В ChatBox
Log.d("ChatBox", "...")      // UI события

// В ChatRepository
Log.d("ChatRepository", "...") // Сетевые запросы

🧪 Тестирование

Чек-лист перед релизом:

  • Отправка сообщения → автоскролл вниз
  • Скролл вверх → загрузка старой истории
  • Отправка второго сообщения → первый стрим отменяется
  • Закрытие чата → стрим останавливается
  • Поиск → находит и центрирует результат
  • Навигация по результатам → работает
  • Редактирование сообщения → обновляется на бэкенде
  • Emoji-реакция → сохраняется на бэкенде
  • Rotation device → состояние сохраняется
  • Backgrounding app → стрим отменяется

📚 Полезные ссылки


🎯 Метрики производительности

Метрика Значение
Рекомпозиций при стриминге ~50 / сообщение
Время рендеринга 100 сообщений < 100ms
Память на 1000 сообщений ~5MB
Таймаут обычных запросов 45 секунд
Таймаут стриминга 5 минут

🛠️ Технологический стек

UI

  • Jetpack Compose - современный UI toolkit
  • Material 3 - дизайн-система
  • LazyColumn - эффективный рендеринг списков
  • AnnotatedString - форматированный текст с markdown

Архитектура

  • MVVM - Model-View-ViewModel паттерн
  • Hilt - Dependency Injection
  • Kotlin Coroutines - асинхронность
  • StateFlow - reactive state management
  • Channel - межпоточная коммуникация для стриминга

Хранение данных

  • Room Database - локальное хранение истории (offline-first)
  • ChatMessageDao - Data Access Object для CRUD
  • Flow - реактивное чтение из БД

Сеть

  • Retrofit + Moshi - HTTP клиент и JSON
  • Server-Sent Events (SSE) - стриминг ответов от AI
  • OkHttp - низкоуровневый HTTP клиент

Производительность

  • remember - мемоизация вычислений
  • key в LazyColumn - эффективная рекомпозиция
  • Mutex - защита от race conditions
  • Job cancellation - управление корутинами

📚 Связанные документы

  • places.md - Экран "Места" с игровой картой
  • calendar.md - Экран "Расписание"
  • README.md - Обзор всей документации

Made with ❤️ and lots of refactoring! 🚀