Этот файл является частью проекта 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
Чат с потоковым выводом ответов, пагинацией истории, поиском и оптимизациями производительности
Сообщения отображаются в реальном времени с эффектом печати, поддерживается редактирование истории и эмодзи-реакции.
- Стриминг ответов - AI печатает ответ символ за символом
- Бесконечная пагинация - загрузка старых сообщений при скролле вверх
- Поиск по истории - быстрый поиск с контекстом и навигацией
- Редактирование - изменение любого сообщения в истории
- Эмодзи-реакции - установка эмодзи на сообщения
- Голосовой ввод - отправка голосовых сообщений
Нажатие на Presence Placeholder (глазки Victor и текст после них) на главном экране (MainScreen)
-
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/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 модель сообщения
- Архитектура
- Поток данных
- Управление состоянием
- Оптимизации производительности
- Стриминг сообщений
- Пагинация
- Поиск
- Compose оптимизации
- Управление жизненным циклом
- Сетевые запросы
- UX фичи
- Типичные задачи
- Отладка
- Тестирование
- Технологический стек
- Связанные документы
- Будущие улучшения
┌─────────────────────────────────────────────────────────┐
│ 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) │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
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
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 (реактивно)
User вводит запрос → debounce 500ms
↓
ChatViewModel.searchInHistory(query)
↓
ChatApi.searchChatHistory(query, offset=0)
↓
_searchResults.value = контекст вокруг найденного
_searchMatchedMessageId.value = ID найденного
↓
Автоскролл к найденному сообщению + подсветка
| 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 банка заботы |
| State | Тип | Назначение |
|---|---|---|
userInput |
String |
Текст в поле ввода |
editingMessageKey |
String? |
Ключ редактируемого сообщения |
attachedImages |
List<ImageAttachment> |
Прикрепленные изображения |
showMenu |
Boolean |
Открыто ли меню режимов |
showSearchOverlay |
Boolean |
Открыт ли поиск |
showWebView |
Boolean |
Открыт ли WebView |
Правило: Локально храним только чисто UI-состояние. Вся бизнес-логика в ViewModel!
items(
items = syncedMessages,
key = { message -> "synced_${message.id}" } // ✅ Stable key
) { message ->
MessageItem(...)
}Эффект: LazyColumn переиспользует элементы вместо пересоздания. 20x быстрее рендеринг.
// Фильтрация и сортировка (выполняется ОДИН раз при изменении messages)
val syncedMessages = remember(messages) {
messages.filter { it.isSynced }.sortedByDescending { it.id }
}
// Парсинг markdown (выполняется ОДИН раз при изменении текста)
val annotatedText = remember(message.text, searchQuery) {
parseMarkdown(message.text)
}Эффект: Вычисления выполняются только при изменении зависимостей, а не при каждой рекомпозиции.
// ✅ O(1) - уникальный ключ
val messageKey = getMessageKey(message)Эффект: При 100 сообщениях: 10,000 операций → 100 операций. 100x быстрее.
private var currentStreamJob: Job? = null
fun sendTextToAssistant() {
currentStreamJob?.cancel() // Отменяем предыдущий!
currentStreamJob = viewModelScope.launch {
processStreamingMessage(...)
}
}Эффект: Нет конфликтов между стримами. Только один активный стрим.
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)
}
}
}| State | Описание |
|---|---|
oldestId |
ID самого старого загруженного сообщения |
hasMoreHistory |
Есть ли еще сообщения в БД |
isLoadingMore |
Идет ли загрузка сейчас |
- User скроллит вверх →
lastVisibleIndex >= totalItems - 3 - Проверяем:
!isLoadingMore && hasMoreHistory && oldestId != null - Вызываем:
loadMoreHistory(oldestId) - Backend:
GET /chat/get_history?before_id=1039 - Response:
{messages: [...], oldest_id: 1014, has_more: true} - Обновляем:
_oldestId.value = 1014 - Мержим сообщения через
distinctBy - Следующий запрос:
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
// ✅ ПРАВИЛЬНО: Уникальный ключ для каждого сообщения
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 перерисовывает ВСЕ элементы при изменении списка.
// Фильтрация (выполняется ТОЛЬКО при изменении 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/сек! 💥
// ХОРОШО: 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(): ресурсы освобождены")
}// ✅ Привязка к 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
// обработка...
}val unsyncedCount = remember(messages) { messages.count { !it.isSynced } }
LaunchedEffect(unsyncedCount) {
if (unsyncedCount > 0 && searchMatchedMessageId == null) {
delay(100) // Ждём рендеринга
listState.animateScrollToItem(0) // reverseLayout: 0 = внизу
}
}Логика:
- Скроллит только при новых сообщениях (несинхронизированных)
- НЕ скроллит при загрузке старой истории (синхронизированные)
- НЕ скроллит при активном поиске
LaunchedEffect(searchMatchedMessageId) {
searchMatchedMessageId?.let { matchedId ->
val messageIndex = findIndexInList(matchedId)
val centerOffset = -(viewportHeight / 2)
listState.animateScrollToItem(messageIndex, centerOffset)
}
}| Режим | Описание | Жесты |
|---|---|---|
| production | Обычный режим | Tap → закрыть чат LongPress → микрофон |
| edit mode | Режим редактирования | LongPress на сообщение → редактировать |
- Обновить
ChatMessage.kt(domain model) - Обновить маппер в
ChatRepository.kt - Обновить
MessageItem.ktдля отображения - Обновить backend API (если нужно)
- Добавить callback в
MessageItem.kt:onNewAction: () -> Unit
- Пробросить callback через
ChatBox.kt - Реализовать логику в
ChatViewModel.kt
- Изменить
loadMoreHistory()вChatViewModel.kt - Обновить условие триггера в
ChatBox.kt(LaunchedEffect) - При необходимости обновить
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 минут |
- 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! 🚀




