From 2c71542b13280182ee1fd0fe0583e6f34e462572 Mon Sep 17 00:00:00 2001 From: subochev Date: Mon, 28 Sep 2026 10:08:10 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B5=D1=81=D0=BA=D1=82=D0=BE=D0=BF-?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82:=20Compose-UI,=20=D1=81?= =?UTF-8?q?=D0=B5=D1=81=D1=81=D0=B8=D0=B8,=20SQLite-=D0=BA=D1=8D=D1=88,=20?= =?UTF-8?q?=D0=B3=D0=BE=D0=BB=D0=BE=D1=81,=20=D0=B6=D0=B8=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings/: settings.json (агенты, тема, clientId), атомарная запись - persistence/: ConversationMetaRepository — группы, lastSeen, превью, watermark синхронизации (synced_at через PRAGMA table_info) - session/: AgentRegistry (параллельное подключение, деградация в офлайн), AgentConnection (журнал + мета + живой список диалогов conversationsFlow, refresh по SSE-потоку), ChatSession (history + backfill + live + preview, дедуп по id, 1 мс шаг watermark, syncMutex), LiveTurn - UI: рейка агентов, список диалогов (живое обновление), чат со стримингом, композер (Enter отправляет, Shift+Enter переносит, Ctrl+M — голос), окна «Агенты»/«Группы»/«Новый диалог», тёмная и светлая темы - voice/: mic-api → Silero VAD → Vosk на одном выделенном потоке; VoiceNative.warmUp() до skia (иначе SIGSEGV), модель в артефакте - markdown/: вендоренный рендер из ai/assistent - ошибки в баннер через AppState.reportError (CancellationException игнорируется) - тесты 103 (реальная SQLite + фейковый транспорт); против живого агента — RealServerIT, RealAppStateIT, RealSpeechIT - LIVE-RUN-NOTES.md: живой прогон в GUI и найденные/починенные баги --- LIVE-RUN-NOTES.md | 94 ++++ .../kotlin/pw/binom/agentik/desktop/Main.kt | 63 +++ .../desktop/markdown/MarkdownParserImpl.kt | 353 ++++++++++++++ .../desktop/markdown/MarkdownSegment.kt | 59 +++ .../agentik/desktop/media/AvatarStorage.kt | 64 +++ .../pw/binom/agentik/desktop/model/Mappers.kt | 161 ++++++ .../binom/agentik/desktop/model/UiModels.kt | 95 ++++ .../agentik/desktop/net/ConnectionChecker.kt | 85 ++++ .../persistence/ConversationMetaRepository.kt | 458 ++++++++++++++++++ .../desktop/session/AgentConnection.kt | 268 ++++++++++ .../agentik/desktop/session/AgentRegistry.kt | 190 ++++++++ .../agentik/desktop/session/ChatSession.kt | 247 ++++++++++ .../binom/agentik/desktop/session/LiveTurn.kt | 301 ++++++++++++ .../agentik/desktop/settings/AppPaths.kt | 82 ++++ .../agentik/desktop/settings/Settings.kt | 90 ++++ .../desktop/settings/SettingsRepository.kt | 135 ++++++ .../kotlin/pw/binom/agentik/desktop/ui/App.kt | 199 ++++++++ .../pw/binom/agentik/desktop/ui/AppState.kt | 450 +++++++++++++++++ .../pw/binom/agentik/desktop/ui/ChatImage.kt | 123 +++++ .../agentik/desktop/ui/ClientImageSupport.kt | 50 ++ .../agentik/desktop/ui/MarkdownRenderer.kt | 25 + .../agentik/desktop/ui/MarkdownRendering.kt | 382 +++++++++++++++ .../agentik/desktop/ui/components/Overlay.kt | 31 ++ .../binom/agentik/desktop/ui/components/Ui.kt | 127 +++++ .../desktop/ui/screens/AgentsDialog.kt | 445 +++++++++++++++++ .../agentik/desktop/ui/screens/ChatPane.kt | 290 +++++++++++ .../agentik/desktop/ui/screens/DialogsList.kt | 303 ++++++++++++ .../desktop/ui/screens/GroupsDialog.kt | 200 ++++++++ .../desktop/ui/screens/MessageStream.kt | 256 ++++++++++ .../desktop/ui/screens/NewChatDialog.kt | 84 ++++ .../agentik/desktop/ui/theme/AgentColors.kt | 40 ++ .../binom/agentik/desktop/ui/theme/Theme.kt | 176 +++++++ .../pw/binom/agentik/desktop/voice/Audio.kt | 57 +++ .../agentik/desktop/voice/VoiceController.kt | 190 ++++++++ .../agentik/desktop/voice/VoiceNative.kt | 49 ++ .../agentik/desktop/voice/VoiceRecognizer.kt | 90 ++++ .../desktop/markdown/MarkdownParserTest.kt | 196 ++++++++ .../desktop/media/AvatarStorageTest.kt | 84 ++++ .../agentik/desktop/model/MappersTest.kt | 138 ++++++ .../ConversationMetaRepositoryIT.kt | 243 ++++++++++ .../desktop/session/AgentConnectionE2ETest.kt | 325 +++++++++++++ .../desktop/session/AgentRegistryTest.kt | 128 +++++ .../desktop/session/ChatSessionTest.kt | 325 +++++++++++++ .../agentik/desktop/session/LiveTurnTest.kt | 209 ++++++++ .../agentik/desktop/session/RealServerIT.kt | 195 ++++++++ .../settings/SettingsRepositoryTest.kt | 124 +++++ .../agentik/desktop/ui/RealAppStateIT.kt | 162 +++++++ .../agentik/desktop/ui/ThemeRenderTest.kt | 154 ++++++ .../binom/agentik/desktop/voice/AudioTest.kt | 144 ++++++ .../agentik/desktop/voice/RealSpeechIT.kt | 95 ++++ .../desktop/voice/VoiceControllerTest.kt | 277 +++++++++++ .../desktop/voice/VoskVoiceRecognizerIT.kt | 36 ++ src/jvmTest/resources/voice/raz-dva-tri.wav | Bin 0 -> 188494 bytes 53 files changed, 9147 insertions(+) create mode 100644 LIVE-RUN-NOTES.md create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/Main.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/markdown/MarkdownParserImpl.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/markdown/MarkdownSegment.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/media/AvatarStorage.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/model/Mappers.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/model/UiModels.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/net/ConnectionChecker.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/persistence/ConversationMetaRepository.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/session/AgentConnection.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/session/AgentRegistry.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/session/ChatSession.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/session/LiveTurn.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/settings/AppPaths.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/settings/Settings.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/settings/SettingsRepository.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/App.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/AppState.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/ChatImage.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/ClientImageSupport.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/MarkdownRenderer.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/MarkdownRendering.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/components/Overlay.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/components/Ui.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/AgentsDialog.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/ChatPane.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/DialogsList.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/GroupsDialog.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/MessageStream.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/NewChatDialog.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/theme/AgentColors.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/theme/Theme.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/voice/Audio.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/voice/VoiceController.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/voice/VoiceNative.kt create mode 100644 src/jvmMain/kotlin/pw/binom/agentik/desktop/voice/VoiceRecognizer.kt create mode 100644 src/jvmTest/kotlin/pw/binom/agentik/desktop/markdown/MarkdownParserTest.kt create mode 100644 src/jvmTest/kotlin/pw/binom/agentik/desktop/media/AvatarStorageTest.kt create mode 100644 src/jvmTest/kotlin/pw/binom/agentik/desktop/model/MappersTest.kt create mode 100644 src/jvmTest/kotlin/pw/binom/agentik/desktop/persistence/ConversationMetaRepositoryIT.kt create mode 100644 src/jvmTest/kotlin/pw/binom/agentik/desktop/session/AgentConnectionE2ETest.kt create mode 100644 src/jvmTest/kotlin/pw/binom/agentik/desktop/session/AgentRegistryTest.kt create mode 100644 src/jvmTest/kotlin/pw/binom/agentik/desktop/session/ChatSessionTest.kt create mode 100644 src/jvmTest/kotlin/pw/binom/agentik/desktop/session/LiveTurnTest.kt create mode 100644 src/jvmTest/kotlin/pw/binom/agentik/desktop/session/RealServerIT.kt create mode 100644 src/jvmTest/kotlin/pw/binom/agentik/desktop/settings/SettingsRepositoryTest.kt create mode 100644 src/jvmTest/kotlin/pw/binom/agentik/desktop/ui/RealAppStateIT.kt create mode 100644 src/jvmTest/kotlin/pw/binom/agentik/desktop/ui/ThemeRenderTest.kt create mode 100644 src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/AudioTest.kt create mode 100644 src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/RealSpeechIT.kt create mode 100644 src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/VoiceControllerTest.kt create mode 100644 src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/VoskVoiceRecognizerIT.kt create mode 100644 src/jvmTest/resources/voice/raz-dva-tri.wav diff --git a/LIVE-RUN-NOTES.md b/LIVE-RUN-NOTES.md new file mode 100644 index 0000000..9bbf385 --- /dev/null +++ b/LIVE-RUN-NOTES.md @@ -0,0 +1,94 @@ +# Живой прогон: что нашли и починили (28.09.2026) + +Первый прогон десктопа не на фейках, а на живом `:standalone`-агенте +(Qwen через vLLM на 192.168.88.135) с настоящим GUI: окно Compose, +клики/печать через `ydotool`, проверка скриншотов. + +## Что проверено вживую + +| Шаг | Результат | +| --- | --- | +| Приложение подключается к агенту | шапка: «Локальный агент» + зелёное «на связи» | +| Список диалогов | подтягивается с сервера (33 диалога), поиск/время/аватары на месте | +| «+» (новый диалог) | сервер вернул новый диалог, счётчик на сервере 33 → 34 | +| Отправка сообщения | текст ушёл, ответ LLM пришёл в UI, в истории обе реплики | +| Голос (`Ctrl+M`) | «идёт запись» (микрофон красный) → повторное нажатие → «Речь не распознана», **без падения JVM** | + +Ход агента при этом длинный: ответ занял 0.5 с, а один разбор с длинным +промптом — 144 с; это важно для бага №1 ниже. + +## Починенные баги + +### 1. `:client`: `POST /messages` умирал по таймауту 15 с (upstream) + +Сервер отвечает на `POST /conversations/{id}/messages` только по завершении +всего хода агента (LLM + тулы), а это минуты. Клиент шёл с дефолтным +request-timeout Ktor → любая длинная реплика обрывалась на 15-й секунде +(`RealServerIT` показал реальные 144 с). + +Фикс в `agentik/client/.../ConversationClient.kt`: у запроса отправки +снят таймаут чтения (`noReadTimeout()`, тот же приём, что для SSE). +Регрессионный тест — в `RealServerIT`. + +### 2. Конфликт skiko и нативных ASR-библиотек → SIGSEGV + +Найдено при добавлении тестов голоса: если onnxruntime/Vosk инициализируются +**после** skia, JVM падает в C++-логах. В проде это был бы краш при первом +нажатии микрофона (окно-то уже нарисовано). Подробности и таблица порядка +загрузки — в `MIC-ASR-SEARCH.md` §8. Фикс: `VoiceNative.warmUp()` в `main()` +до `application {}`, плюс `forkEvery = 1` в тестах. + +### 3. Список диалогов не обновлялся никогда + +`AgentConnection.listConversations()` читал `agent.conversationStore.list(...)` +один раз — а встроенный кэш либы наполняется асинхронно (`listFlow`), так что +даже «один раз» приходилось на пустой кэш. Итог: сервер отдаёт 33 диалога, а в +UI список пуст, и обновить его было нечем. + +Фикс: `AgentConnection.conversationsFlow` — опрос встроенного кэша с +warmup-циклом на старте + подписка на SSE-поток (`outbox.agentEvents`) для +появления/удаления диалогов, созданных другими клиентами, + явный `refresh()` +после своих create/delete/rename. + +### 4. Enter не отправлял сообщение, хотя подсказка это обещала + +Поле многострочное, Enter вставлял перевод строки. Фикс: `onPreviewKeyEvent` +в композере — Enter отправляет, Shift+Enter переносит строку (тогда подсказка +«отправка Enter» становится правдой). + +### 5. Клик по нижней/верхней части поля ввода не фокусировал его + +`BasicTextField` высотой в строку внутри `Box` с паддингами: клик мимо самой +строки текста фокуса не давал. Фикс: клик по всему боксу запрашивает фокус +(`clickable` + `focusRequester`). + +### 6. Ложный красный баннер «StandaloneCoroutine was cancelled» + +`refresh()` отменяет предыдущий job, а `runCatching { ... }.onFailure { error = it.message }` +показывал отмену корутины как ошибку пользователя. Фикс: `AppState.reportError()` +игнорирует `CancellationException` (все 11 мест переведены на него). + +### 7. Микрофон с клавиатуры + +Мышиную кнопку микрофона удобно не всегда, а для проверки голоса без мыши — +критично: добавлен `Ctrl+M` из чата (и подсказка обновлена). + +## Как повторить + +```bash +# 1. живой агент (в фоне, из папки проекта) +cd /home/subochev/common-projects/WORK/ai/agentik/standalone +AGENTIK_SKILLS_DIR=./agentik/skills AGENTIK_SYSTEM_PROMPT='Ты полезный ассистент.' \ +OPENAI_BASE_URL=http://192.168.88.135:8001/v1 AGENTIK_DB_PATH=./agentik/db.db \ +OPENAI_CONTEXT_WINDOW=115000 AGENTIK_MEMORY_DIR=./agentik/memory \ +OPENAI_LLM_BACKEND=openai AGENTIK_PORT=8080 OPENAI_API_KEY=... \ +OPENAI_MODEL=... AGENTIK_SOUL=./agentik/SOUL.md ./gradlew run + +# 2. десктоп (окно) +cd /home/subochev/common-projects/WORK/ai/agentik-desktop +AGENTIK_DESKTOP_HOME=/tmp/opencode/ak-real ./gradlew run + +# 3. интеграционные тесты против живого агента +AGENTIK_E2E_URL=http://127.0.0.1:8080/agentik ./gradlew jvmTest \ + --tests '*RealServerIT' --tests '*RealAppStateIT' --tests '*RealSpeechIT' +``` diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/Main.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/Main.kt new file mode 100644 index 0000000..fc53b29 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/Main.kt @@ -0,0 +1,63 @@ +package pw.binom.agentik.desktop + +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import pw.binom.agentik.desktop.session.AgentRegistry +import pw.binom.agentik.desktop.settings.AppPaths +import pw.binom.agentik.desktop.settings.SettingsRepository +import pw.binom.agentik.desktop.ui.App +import pw.binom.agentik.desktop.ui.AppState +import pw.binom.agentik.desktop.ui.ClientImageSupport +import pw.binom.agentik.desktop.voice.VoiceNative + +/** + * Точка входа desktop-клиента agentik. + * + * Поднимает окно Compose, реестр агентов и состояние приложения. Регистрирует + * декодер картинок для markdown-рендера (иначе картинки в ответах падают в + * текст) и создаёт каталоги данных. + */ +fun main() { + // Прогрев нативного ASR (Vosk + VAD) обязан произойти ДО инициализации + // skia/Compose, иначе создание модели распознавания после отрисовки окна + // валит JVM (см. [VoiceNative]). Поэтому он здесь, а не в `application {}`. + val voiceAvailable = VoiceNative.warmUp() + + application { + // Картинки из markdown-сегментов декодируются через skia. + ClientImageSupport.decoder = { bytes -> + runCatching { org.jetbrains.skia.Image.makeFromEncoded(bytes).toComposeImageBitmap() }.getOrNull() + } + + val windowState = rememberWindowState(size = DpSize(1240.dp, 780.dp)) + val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Default) } + val state = remember { + AppPaths.ensureAll() + val registry = AgentRegistry.create( + settingsRepo = SettingsRepository.default(), + scope = scope, + ) + AppState(registry = registry, scope = scope, voiceAvailable = voiceAvailable) + } + + Window( + onCloseRequest = { + state.shutdown() + scope.coroutineContext[kotlinx.coroutines.Job]?.cancel() + exitApplication() + }, + state = windowState, + title = "Agentik Desktop", + ) { + App(state) + } + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/markdown/MarkdownParserImpl.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/markdown/MarkdownParserImpl.kt new file mode 100644 index 0000000..9a2a6d6 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/markdown/MarkdownParserImpl.kt @@ -0,0 +1,353 @@ +/** + * Парсер Markdown в [MarkdownSegment]. + * Чистый Kotlin — без Compose, без платформенных зависимостей. + */ +package pw.binom.agentik.desktop.markdown + +import ru.otpbank.devex.markdown.DocumentVisitor +import ru.otpbank.devex.markdown.InlineVisitor +import ru.otpbank.devex.markdown.ListVisitor +import ru.otpbank.devex.markdown.MarkdownNode +import ru.otpbank.devex.markdown.MarkdownParser +import ru.otpbank.devex.markdown.RowVisitor +import ru.otpbank.devex.markdown.TableVisitor + +private val parser = MarkdownParser() + +fun parseMarkdown(markdown: String): List { + if (markdown.isBlank()) return listOf(MarkdownSegment.Text("", emptyList())) + val collector = SegmentCollector() + parser.parse(markdown, collector) + return collector.segments +} + +private class SegmentCollector : DocumentVisitor { + val segments = mutableListOf() + + private val boldStack = mutableListOf(false) + private val italicStack = mutableListOf(false) + private val codeStack = mutableListOf(false) + private val strikethroughStack = mutableListOf(false) + private var currentLink: String? = null + + private val textBuilder = StringBuilder() + private val spans = mutableListOf() + private var hasContent = false + private var spanStart = 0 + + /** Маркер текущего пункта списка ("1. ", "• ", ...), вставляется перед первым текстом пункта. */ + private var pendingItemMarker: String? = null + + private val isBold: Boolean get() = boldStack.any { it } + private val isItalic: Boolean get() = italicStack.any { it } + private val isCode: Boolean get() = codeStack.any { it } + private val isStrikethrough: Boolean get() = strikethroughStack.any { it } + + private fun recordSpan() { + if (!hasContent || spanStart >= textBuilder.length) return + val b = isBold + val i = isItalic + val c = isCode + val s = isStrikethrough + val l = currentLink + if (b || i || c || s || l != null) { + spans.add( + TextStyleSpan( + start = spanStart, + end = textBuilder.length, + bold = b, + italic = i, + code = c, + strikethrough = s, + link = l, + ), + ) + } + // Сдвигаем начало: уже записанный участок повторно не покрываем. + spanStart = textBuilder.length + } + + private fun add(text: String) { + if (text.isEmpty()) return + recordSpan() + pendingItemMarker?.let { + textBuilder.append(it) + pendingItemMarker = null + } + textBuilder.append(text) + hasContent = true + } + + /** + * Сбрасывает накопленный текст в [MarkdownSegment.Text] и очищает буферы. + * Вызывается в конце разбора и перед добавлением не-текстовых сегментов + * (например, картинки). + */ + private fun flushText() { + if (!hasContent) return + recordSpan() + segments.add(MarkdownSegment.Text(textBuilder.toString(), spans.toList())) + textBuilder.clear() + spans.clear() + spanStart = 0 + hasContent = false + } + + override fun visitDocumentEnd() { + flushText() + } + + override fun visitParagraph(): InlineVisitor { + flushText() + return this + } + + override fun closeParagraph() { + flushText() + } + + override fun visitHeading(level: Int): InlineVisitor { + flushText() + boldStack.add(true) + return this + } + + override fun closeHeading() { + boldStack.removeLastOrNull() + flushText() + } + + override fun visitBlockquote(): DocumentVisitor { + flushText() + return this + } + + override fun closeBlockquote() { + flushText() + } + + override fun visitThematicBreak() { + flushText() + segments.add(MarkdownSegment.ThematicBreak) + } + + override fun visitHtmlBlock(html: String) { + // Не выбрасываем содержимое — рендерим как обычный текст. + add(html) + } + + override fun visitAutolink(url: String) { + // Не выбрасываем — показываем URL как текст. + add(url) + } + + override fun visitOrderedList(): ListVisitor = object : ListVisitor { + private var counter = 0 + + override fun visitListItem(checked: Boolean?): DocumentVisitor { + // Начинаем пункт списка: маркер "N. " вставится перед первым текстом пункта. + flushText() + pendingItemMarker = "${++counter}. " + // Возвращаем коллектор, чтобы текст пункта попадал в наш textBuilder. + return this@SegmentCollector + } + + override fun closeListItem() { + add("\n") + } + + override fun closeList() { + flushText() + pendingItemMarker = null + } + } + + override fun visitUnorderedList(): ListVisitor = object : ListVisitor { + override fun visitListItem(checked: Boolean?): DocumentVisitor { + flushText() + pendingItemMarker = + when (checked) { + true -> "- [x] " + false -> "- [ ] " + null -> "• " + } + return this@SegmentCollector + } + + override fun closeListItem() { + add("\n") + } + + override fun closeList() { + flushText() + pendingItemMarker = null + } + } + + override fun visitText(text: String) { + add(text) + } + + override fun visitBold(): InlineVisitor { + recordSpan() + boldStack.add(true) + spanStart = textBuilder.length + return this + } + + override fun closeBold() { + recordSpan() + boldStack.removeLastOrNull() + spanStart = textBuilder.length + } + + override fun visitItalic(): InlineVisitor { + recordSpan() + italicStack.add(true) + spanStart = textBuilder.length + return this + } + + override fun closeItalic() { + recordSpan() + italicStack.removeLastOrNull() + spanStart = textBuilder.length + } + + override fun visitStrikethrough(): InlineVisitor { + recordSpan() + strikethroughStack.add(true) + spanStart = textBuilder.length + return this + } + + override fun closeStrikethrough() { + recordSpan() + strikethroughStack.removeLastOrNull() + spanStart = textBuilder.length + } + + override fun visitInlineCode(code: String) { + recordSpan() + codeStack.add(true) + spanStart = textBuilder.length + textBuilder.append(code) + hasContent = true + recordSpan() + codeStack.removeLastOrNull() + spanStart = textBuilder.length + } + + override fun visitLink( + url: String, + title: String?, + ): InlineVisitor { + recordSpan() + currentLink = url + spanStart = textBuilder.length + return this + } + + override fun closeLink() { + recordSpan() + currentLink = null + spanStart = textBuilder.length + } + + override fun visitImage( + url: String, + alt: String, + title: String?, + ) { + flushText() + segments.add(MarkdownSegment.Image(url, alt)) + } + + override fun visitSoftLineBreak() { + add(" ") + } + + override fun visitHardLineBreak() { + add("\n") + } + + override fun visitCodeBlock( + language: String?, + code: String, + ) { + flushText() + segments.add(MarkdownSegment.CodeBlock(language, code)) + } + + override fun visitTable(columnCount: Int): TableVisitor { + flushText() + val headers = mutableListOf() + val rows = mutableListOf>() + val rowCells = mutableListOf() + var isFirstRow = true + + return object : TableVisitor { + override fun visitTableRow(): RowVisitor { + rowCells.clear() + return object : RowVisitor { + override fun visitTableCell(alignment: MarkdownNode.TableAlignment?): InlineVisitor { + val buf = StringBuilder() + return object : InlineVisitor { + override fun visitText(text: String) { + buf.append(text) + } + + override fun visitInlineCode(code: String) { + buf.append(code) + } + + override fun closeTableCell() { + rowCells.add(buf.toString().trim()) + } + + override fun visitBold(): InlineVisitor = this + + override fun visitItalic(): InlineVisitor = this + + override fun visitStrikethrough(): InlineVisitor = this + + override fun visitLink( + url: String, + title: String?, + ): InlineVisitor = this + + override fun visitImage( + url: String, + alt: String, + title: String?, + ) { + buf.append(alt) + } + + override fun visitSoftLineBreak() { + buf.append(' ') + } + + override fun visitHardLineBreak() { + buf.append(' ') + } + } + } + + override fun closeTableRow() { + if (isFirstRow) { + headers.addAll(rowCells.toList()) + isFirstRow = false + } else { + rows.add(rowCells.toList()) + } + } + } + } + + override fun closeTable() { + segments.add(MarkdownSegment.Table(headers.toList(), rows.toList())) + } + } + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/markdown/MarkdownSegment.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/markdown/MarkdownSegment.kt new file mode 100644 index 0000000..939f17a --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/markdown/MarkdownSegment.kt @@ -0,0 +1,59 @@ +/** + * Общие модели данных для парсинга Markdown. + * Не содержат зависимостей от Compose или платформы. + */ +package pw.binom.agentik.desktop.markdown + +/** + * Описание стилевого участка внутри текстового сегмента. + * Клиентские реализации (Compose Desktop, Android) конвертируют + * их в нативный styled text своей платформы. + */ +data class TextStyleSpan( + val start: Int, + val end: Int, + val bold: Boolean = false, + val italic: Boolean = false, + val code: Boolean = false, + val strikethrough: Boolean = false, + val link: String? = null, +) + +/** + * Сегмент распарсенного Markdown-содержимого. + */ +sealed class MarkdownSegment { + + /** + * Текстовый сегмент с inline-аннотациями стилей. + */ + data class Text( + val text: String, + val spans: List, + ) : MarkdownSegment() + + /** + * Блок кода (fenced code block). + */ + data class CodeBlock( + val language: String?, + val code: String, + ) : MarkdownSegment() + + /** + * Таблица с заголовками и строками данных. + */ + data class Table( + val headers: List, + val rows: List>, + ) : MarkdownSegment() + + /** Картинка: src — data URL (`data:image/...;base64,...`) или http(s) URL. */ + data class Image( + val src: String, + val alt: String? = null, + ) : MarkdownSegment() + + /** Разделитель (горизонтальная линия `---`, `***`, ...). */ + data object ThematicBreak : MarkdownSegment() +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/media/AvatarStorage.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/media/AvatarStorage.kt new file mode 100644 index 0000000..e8f34a5 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/media/AvatarStorage.kt @@ -0,0 +1,64 @@ +package pw.binom.agentik.desktop.media + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import pw.binom.agentik.desktop.settings.AppPaths +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import kotlin.io.path.exists +import kotlin.io.path.extension + +/** + * Хранилище аватаров ассистентов. Сервер картинку не отдаёт + * (REQUIREMENTS R23.3.2) — пользователь выбирает файл локально, мы + * **копируем** его в `~/.agentik-desktop/avatars/.` и + * запоминаем путь в [pw.binom.agentik.desktop.settings.AgentConfig.avatarPath]. + * + * Копия, а не ссылка: оригинал пользователь может удалить/переместить, + * а аватар должен пережить это. Имя — по локальному agent id, поэтому + * замена аватара перезаписывает прежний (без мусора). + */ +class AvatarStorage( + private val dir: Path = AppPaths.avatars, +) { + + /** + * Копирует [source] как аватар агента [agentId]. Возвращает абсолютный + * путь сохранённой копии (его и кладём в настройки). + */ + suspend fun save(agentId: String, source: Path): String = withContext(Dispatchers.IO) { + require(source.exists()) { "Файл аватара не найден: $source" } + AppPaths.ensure(dir) + val ext = source.extension.lowercase().takeIf { it.isNotBlank() } ?: "img" + val target = dir.resolve("${AppPaths.safeDirName(agentId)}.$ext") + // Убираем прежний аватар с другим расширением, чтобы не остался мусор. + delete(agentId) + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING) + target.toAbsolutePath().toString() + } + + /** Удаляет текущий аватар агента (любое расширение). `true`, если что-то удалили. */ + suspend fun delete(agentId: String): Boolean = withContext(Dispatchers.IO) { + if (!dir.exists()) return@withContext false + val prefix = AppPaths.safeDirName(agentId) + "." + var removed = false + Files.newDirectoryStream(dir) { path -> path.fileName.toString().startsWith(prefix) } + .use { stream -> + for (path in stream) { + runCatching { + Files.deleteIfExists(path) + removed = true + } + } + } + removed + } + + /** Читает байты аватара по пути из настроек, или `null`, если файла нет. */ + suspend fun read(path: String?): ByteArray? = withContext(Dispatchers.IO) { + if (path.isNullOrBlank()) return@withContext null + val p = Path.of(path) + if (!p.exists()) null else runCatching { Files.readAllBytes(p) }.getOrNull() + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/model/Mappers.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/model/Mappers.kt new file mode 100644 index 0000000..4883b62 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/model/Mappers.kt @@ -0,0 +1,161 @@ +package pw.binom.agentik.desktop.model + +import pw.binom.agentik.desktop.persistence.MetaRow +import pw.binom.agentik.desktop.session.LiveBlock +import pw.binom.agentik.desktop.session.LiveTurn +import pw.binom.agentik.journal.Content +import pw.binom.agentik.journal.ConversationRecord +import pw.binom.agentik.journal.MessageRecord +import kotlin.time.Instant + +/** + * Мапперы «слой хранения/стрима → UI-модель». + * + * Ручные, без рефлексии/библиотек: типов мало, а неявные конвертеры + * затрудняют отладку. Все мапперы — чистые функции. + */ + +fun Content.toUiContent(): UiContent = when (this) { + is Content.Text -> UiContent.Text(body) + is Content.Image -> UiContent.Image(data, mime) +} + +/** + * Разворачивает историю из журнала в плоский список для рендера. + * + * Отдельно склеивает `ToolCall` + `ToolResult` в один [UiMessage.Tool]: + * в журнале это две записи с общим `toolCallId` (денормализованный + * `toolName` в результате), в UI — одна карточка. Сиротливый + * `ToolResult` (без парного `ToolCall`) тоже становится карточкой. + * + * Порядок записей сохраняется. + */ +fun List.toUiMessages(): List { + val out = ArrayList(size) + // Индекс UiMessage.Tool по toolCallId — чтобы приклеить результат. + val toolsByCallId = HashMap() + + for (record in this) { + when (record) { + is MessageRecord.UserMessage -> out += UiMessage.User( + id = record.id, + createdAt = record.createdAt, + content = record.content.map { it.toUiContent() }, + ) + + is MessageRecord.AssistantMessage -> out += UiMessage.Assistant( + id = record.id, + createdAt = record.createdAt, + content = record.content.map { it.toUiContent() }, + ) + + is MessageRecord.ToolCall -> { + val index = out.size + out += UiMessage.Tool( + id = record.id, + createdAt = record.createdAt, + toolName = record.toolName, + title = record.toolTitle, + argsJson = record.toolArgsJson, + ) + toolsByCallId[record.id] = index + } + + is MessageRecord.ToolResult -> { + val existing = toolsByCallId[record.toolCallId] + if (existing != null) { + val tool = out[existing] as UiMessage.Tool + out[existing] = tool.copy(result = record.result) + } else { + out += UiMessage.Tool( + id = record.toolCallId, + createdAt = record.createdAt, + toolName = record.toolName ?: "(unknown)", + title = null, + argsJson = "", + result = record.result, + ) + } + } + + is MessageRecord.Error -> out += UiMessage.Error( + id = record.id, + createdAt = record.createdAt, + message = record.message, + code = record.code, + ) + } + } + return out +} + +/** + * Превращает активный live-ход в список UI-сообщений. Ключи блоков + * переиспользуются как id — они стабильны в рамках хода, значит + * `LazyColumn` не перезапускает элементы на каждом токене. + * + * [lastBlockStreaming] = true только у последнего блока, если ход ещё + * активен: это маркер для курсора/мигающей каретки. + */ +fun LiveTurn.toUiMessages(now: Instant): List { + val live = phase.isStreaming + val result = ArrayList(blocks.size) + blocks.forEachIndexed { index, block -> + val last = live && index == blocks.lastIndex + result += when (block) { + is LiveBlock.Reasoning -> UiMessage.Reasoning( + id = block.key, + createdAt = now, + text = block.text.toString(), + streaming = last, + ) + + is LiveBlock.Answer -> UiMessage.Assistant( + id = block.key, + createdAt = now, + content = listOf(UiContent.Text(block.text.toString())), + streaming = last, + ) + + is LiveBlock.Image -> UiMessage.Assistant( + id = block.key, + createdAt = now, + content = listOf(UiContent.Image(block.data, block.mime)), + streaming = last, + ) + + is LiveBlock.Tool -> UiMessage.Tool( + id = block.key, + createdAt = now, + toolName = block.toolName, + title = block.title, + argsJson = block.argsJson, + result = block.result, + failed = block.failed, + ) + + is LiveBlock.Failure -> UiMessage.Error( + id = block.key, + createdAt = now, + message = block.message, + code = block.code, + ) + } + } + return result +} + +/** + * Строка списка диалогов. [meta] — локальная запись (может отсутствовать, + * если диалог ещё ни разу не открывали), [unread] — посчитанный счётчик. + */ +fun ConversationRecord.toUiConversation(meta: MetaRow?, unread: Int): UiConversation = UiConversation( + id = id, + title = title?.takeIf { it.isNotBlank() } ?: UNTITLED_CONVERSATION, + isTemporal = isTemporal, + createdAt = createdAt, + updatedAt = updatedAt, + groupId = meta?.groupId, + preview = meta?.preview, + unread = unread, +) diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/model/UiModels.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/model/UiModels.kt new file mode 100644 index 0000000..a5cf2bb --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/model/UiModels.kt @@ -0,0 +1,95 @@ +package pw.binom.agentik.desktop.model + +import kotlin.time.Instant + +/** + * Контент внутри сообщения для отрисовки. Живёт отдельно от + * journal-`Content` / proto-`Content`, чтобы UI не зависел от слоя + * хранения/транспорта. + */ +sealed interface UiContent { + data class Text(val body: String) : UiContent + data class Image(val data: ByteArray, val mime: String) : UiContent { + override fun equals(other: Any?): Boolean = + this === other || (other is Image && mime == other.mime && data.contentEquals(other.data)) + + override fun hashCode(): Int = 31 * mime.hashCode() + data.contentHashCode() + } +} + +/** + * Сообщение в том виде, в каком его рисует чат. Один тип и для истории + * (из `KsqliteJournalStore`), и для «живого» хода (из `LiveTurn`) — так + * рендерер не раздваивается на «до End» и «после End». + * + * - [User] / [Assistant] — реплики. + * - [Reasoning] — рассуждения; в журнал не пишутся, бывают только live. + * - [Tool] — вызов инструмента, результат пристёгнут к тому же элементу. + * - [Error] — провал хода. + * + * [streaming] = true у последнего блока активного хода: UI показывает + * курсор/анимацию и, при необходимости, рендерит markdown не полностью. + */ +sealed interface UiMessage { + val id: String + val createdAt: Instant + + data class User( + override val id: String, + override val createdAt: Instant, + val content: List, + ) : UiMessage + + data class Assistant( + override val id: String, + override val createdAt: Instant, + val content: List, + val streaming: Boolean = false, + ) : UiMessage + + data class Reasoning( + override val id: String, + override val createdAt: Instant, + val text: String, + val streaming: Boolean = false, + ) : UiMessage + + data class Tool( + override val id: String, + override val createdAt: Instant, + val toolName: String, + val title: String?, + val argsJson: String, + val result: String? = null, + val failed: String? = null, + ) : UiMessage + + data class Error( + override val id: String, + override val createdAt: Instant, + val message: String, + val code: String? = null, + ) : UiMessage +} + +/** + * Строка списка диалогов: серверный snapshot + локальные украшения + * (группа, превью, счётчик непрочитанного). + * + * [unread] вычислен из `journal.count(convId, after = meta.lastSeen)` — + * сам счётчик в БД не хранится (STORAGE.md §6.2). [preview] — локальная + * денормализация (STORAGE.md §6.1). + */ +data class UiConversation( + val id: String, + val title: String, + val isTemporal: Boolean, + val createdAt: Instant, + val updatedAt: Instant, + val groupId: Long?, + val preview: String?, + val unread: Int, +) + +/** Заголовок диалога, если сервер не дал title. */ +const val UNTITLED_CONVERSATION = "Новый диалог" diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/net/ConnectionChecker.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/net/ConnectionChecker.kt new file mode 100644 index 0000000..8e730dc --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/net/ConnectionChecker.kt @@ -0,0 +1,85 @@ +package pw.binom.agentik.desktop.net + +import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.engine.cio.CIO +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import pw.binom.agentik.client.AgentikAgent +import pw.binom.agentik.desktop.settings.AgentConfig + +/** Один шаг проверки связи. */ +data class CheckStep( + val title: String, + val ok: Boolean, + val millis: Long, + val detail: String? = null, +) + +/** Итог проверки связи с агентом (R23.4 — диалог со спиннером и таймингами). */ +data class ConnectionReport( + val baseUrl: String, + val steps: List, + val agentName: String? = null, +) { + val ok: Boolean get() = steps.all { it.ok } + val totalMillis: Long get() = steps.sumOf { it.millis } +} + +/** + * Проверка связи с агентом без открытия полноценного [AgentConnection]: + * отдельное «одноразовое» подключение, которое закрывается сразу после + * прогона шагов. + * + * Шаги: + * 1. **Ответ сервера** — `GET {baseUrl}` за `AgentInfo` (имя/описание). + * 2. **Диалог** — создать временный диалог и удалить его (проверяет + * conversation-роуты, не тратит токены LLM). + * + * Сознательно **не** отправляем реальный ход: это дёргает модель (время, + * стоимость, побочные эффекты в истории). Проверки маршрутов достаточно, + * чтобы отличить «связи нет» от «связь есть». + */ +class ConnectionChecker( + private val engineFactory: HttpClientEngineFactory<*> = CIO, + private val timeoutMillis: Long = 15_000, +) { + + suspend fun check(config: AgentConfig, clientId: String): ConnectionReport = withContext(Dispatchers.IO) { + val steps = mutableListOf() + var agent: pw.binom.agentik.proto.Agent? = null + var name: String? = null + + val t0 = System.nanoTime() + try { + agent = AgentikAgent( + id = clientId, + baseUrl = config.baseUrl, + engineFactory = engineFactory, + token = config.token, + ) + name = agent.info.name + steps += CheckStep("Ответ сервера", true, elapsed(t0), runCatching { agent.info.description }.getOrNull()) + } catch (t: Throwable) { + steps += CheckStep("Ответ сервера", false, elapsed(t0), t.message ?: t::class.simpleName) + return@withContext ConnectionReport(config.baseUrl, steps, name) + } + + val a = agent + val t1 = System.nanoTime() + try { + val conv = a.createConversation(temp = true) + val id = conv.id + conv.close() + a.deleteConversation(id) + steps += CheckStep("Создание диалога", true, elapsed(t1)) + } catch (t: Throwable) { + steps += CheckStep("Создание диалога", false, elapsed(t1), t.message ?: t::class.simpleName) + } finally { + runCatching { a.close() } + } + + ConnectionReport(config.baseUrl, steps, name) + } + + private fun elapsed(fromNanos: Long): Long = (System.nanoTime() - fromNanos) / 1_000_000 +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/persistence/ConversationMetaRepository.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/persistence/ConversationMetaRepository.kt new file mode 100644 index 0000000..c3ec149 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/persistence/ConversationMetaRepository.kt @@ -0,0 +1,458 @@ +package pw.binom.agentik.desktop.persistence + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import pw.binom.db.ksqlite.SQLiteConnection +import pw.binom.db.ksqlite.SQLitePreparedStatement +import kotlin.time.Instant + +/** + * Локальное хранилище клиентских «украшений» поверх списка диалогов. + * Не часть протокола — сервер про эти данные не знает. + * + * **Что хранит:** + * - **Группы** — раскладка диалогов по категориям («Работа», «Дом», ...). + * Сервер про групп не знает (STORAGE.md §7), они целиком наши. + * - **`last_seen`** — отметка «докуда дочитано» на диалог, для бейджа + * «есть новое» (STORAGE.md §6.2). Число непрочитанных берётся как + * `journal.count(convId, after = lastSeen)` и здесь не лежит. + * - **`preview`** — последний фрагмент текста хода, денормализованный + * в строку. Сервер такого поля не отдаёт (STORAGE.md §6.1), клиент + * поддерживает сам на `Event.AppendText` / `Event.End`. + * + * **Почему НЕ наследуется от `KsqliteJournalStore`:** разные таблицы + * (`conversation_meta` + `chat_group` против `message`) и разные контракты + * (`MutableJournalStore` мы не реализуем — у нас другой набор операций). + * Общее с journal-store — только `SQLiteConnection`; они прекрасно живут + * на одном соединении, каждый создаёт свою часть схемы в `init`. Это + * та же модель, что между `KsqliteJournalStore` и + * `KsqliteMutableConversationStore` внутри `:journal-ksqlite` — они + * тоже не наследуются друг от друга, просто делят connection. + * + * **Не отдаёт JOIN с `conversation`-таблицей** — UI делает мерж сам: + * `agent.conversationStore.list(...)` + `metaRepo.listMetas()` + + * `metaRepo.listGroups()` → склейка в Kotlin. Связь ослаблена намеренно: + * мета-репо не знает названий колонок в чужой таблице. + * + * **Cascade удаления группы:** в [deleteGroup] сначала зануляем `group_id` + * в `conversation_meta` (R9.7 — диалоги переживают группу), потом удаляем + * саму группу. Одна транзакция. + * + * **Тред-безопасность:** один `Mutex` на все операции (как в + * `KsqliteJournalStore`). SQLite сам сериализует writer'ов, но mutex + * держит explicit ordering и предотвращает перемежающиеся read/write + * одних и тех же строк под гонкой (иначе `markSeen` мог бы прочитать + * старый `last_seen` параллельно с `setPreview`). + * + * **UPSERT** в `setGroup` / `markSeen` / `setPreview`: + * SQLite `INSERT ... ON CONFLICT(conv_id) DO UPDATE SET ...` — + * атомарно обновляет одно поле, не трогая остальные. Требует SQLite 3.24+ + * (2018); текущие сборки ksqlite не ниже. + * + * **Схема встроена внутрь класса** — таблицы `chat_group` + + * `conversation_meta` существуют только для этого репо, выносить в + * отдельный `Schema`-object незачем. Имя `chat_group` потому что SQL + * `GROUP` — зарезервированное слово. DDL — `CREATE TABLE/INDEX IF NOT EXISTS` + * в одной транзакции, idempotent. + */ +class ConversationMetaRepository private constructor( + private val connection: SQLiteConnection, + private val ownsConnection: Boolean, +) : AutoCloseable { + + /** + * Внешнее соединение — репо НЕ закрывает его в [close]. Для + * shared-connection сценария, когда на одном `SQLiteConnection` сидят + * `KsqliteJournalStore`, `KsqliteMutableConversationStore` и этот репо. + */ + constructor(connection: SQLiteConnection) : this(connection, ownsConnection = false) + + /** + * Открывает файловое соединение через `SQLiteConnection.open` и берёт + * на себя его закрытие в [close]. Для standalone использования. + */ + constructor(path: String) : this( + connection = SQLiteConnection.open(path = path), + ownsConnection = true, + ) + + private companion object { + // ───── Schema (приватно — никому кроме этого класса не нужно) ───── + + const val TABLE_GROUP = "chat_group" + const val TABLE_CONV_META = "conversation_meta" + + const val COL_GROUP_ID = "id" + const val COL_GROUP_NAME = "name" + + const val COL_CONV_ID = "conv_id" + const val COL_GROUP_FK = "group_id" + const val COL_LAST_SEEN = "last_seen" + const val COL_PREVIEW = "preview" + + /** Докуда локальный кэш сообщений синхронизирован с сервером. */ + const val COL_SYNCED_AT = "synced_at" + + const val IDX_META_GROUP = "idx_conv_meta_group" + + private val V1_GROUP_DDL = """ + CREATE TABLE IF NOT EXISTS $TABLE_GROUP ( + $COL_GROUP_ID INTEGER PRIMARY KEY AUTOINCREMENT, + $COL_GROUP_NAME TEXT NOT NULL UNIQUE + ); + """ + + private val V1_CONV_META_DDL = """ + CREATE TABLE IF NOT EXISTS $TABLE_CONV_META ( + $COL_CONV_ID TEXT NOT NULL PRIMARY KEY, + $COL_GROUP_FK INTEGER, + $COL_LAST_SEEN INTEGER, + $COL_PREVIEW TEXT + ); + """ + + private val V2_CONV_META_SYNCED_AT_DDL = + "ALTER TABLE $TABLE_CONV_META ADD COLUMN $COL_SYNCED_AT INTEGER" + + private val V1_INDEXES_DDL = """ + CREATE INDEX IF NOT EXISTS $IDX_META_GROUP + ON $TABLE_CONV_META($COL_GROUP_FK); + """ + + /** + * Прогоняет миграцию. Идемпотентна и НЕ использует `PRAGMA user_version`: + * на одном connection с нами живут `KsqliteJournalStore` / + * `KsqliteMutableConversationStore` из `:journal-ksqlite`, а + * user_version — глобальный на файл, и несколько модулей, дёргающих + * его, затирали бы друг друга (см. комментарий в их `Schema.kt`). + * Вместо этого наличие колонки `synced_at` проверяется напрямую через + * `PRAGMA table_info`. Всё в одной транзакции. + */ + fun migrate(conn: SQLiteConnection) { + conn.exec("BEGIN") + try { + conn.exec(V1_GROUP_DDL) + conn.exec(V1_CONV_META_DDL) + conn.exec(V1_INDEXES_DDL) + if (!hasColumn(conn, TABLE_CONV_META, COL_SYNCED_AT)) { + conn.exec(V2_CONV_META_SYNCED_AT_DDL) + } + conn.exec("COMMIT") + } catch (t: Throwable) { + runCatching { conn.exec("ROLLBACK") } + throw t + } + } + + private fun hasColumn(conn: SQLiteConnection, table: String, column: String): Boolean { + conn.prepare("PRAGMA table_info($table)").use { stmt -> + stmt.executeQuery().use { rs -> + while (rs.next()) { + // table_info: cid, name, type, notnull, dflt_value, pk + if (rs.getText(1) == column) return true + } + } + } + return false + } + } + + init { + migrate(connection) + } + + private val mutex = Mutex() + + // ───── Prepared statements ───── + + // groups + private val listGroupsStmt = connection.prepare( + "SELECT $COL_GROUP_ID, $COL_GROUP_NAME " + + "FROM $TABLE_GROUP ORDER BY $COL_GROUP_NAME ASC" + ) + private val insertGroupStmt = connection.prepare( + "INSERT INTO $TABLE_GROUP($COL_GROUP_NAME) VALUES (?)" + ) + private val lastInsertRowIdStmt = connection.prepare("SELECT last_insert_rowid()") + private val renameGroupStmt = connection.prepare( + "UPDATE $TABLE_GROUP SET $COL_GROUP_NAME=? WHERE $COL_GROUP_ID=?" + ) + private val groupExistsStmt = connection.prepare( + "SELECT 1 FROM $TABLE_GROUP WHERE $COL_GROUP_ID=?" + ) + private val clearMetaGroupStmt = connection.prepare( + "UPDATE $TABLE_CONV_META SET $COL_GROUP_FK=NULL WHERE $COL_GROUP_FK=?" + ) + private val deleteGroupStmt = connection.prepare( + "DELETE FROM $TABLE_GROUP WHERE $COL_GROUP_ID=?" + ) + + // per-conv meta + private val listMetasStmt = connection.prepare( + "SELECT $COL_CONV_ID, $COL_GROUP_FK, $COL_LAST_SEEN, $COL_PREVIEW, $COL_SYNCED_AT " + + "FROM $TABLE_CONV_META" + ) + private val getMetaStmt = connection.prepare( + "SELECT $COL_CONV_ID, $COL_GROUP_FK, $COL_LAST_SEEN, $COL_PREVIEW, $COL_SYNCED_AT " + + "FROM $TABLE_CONV_META WHERE $COL_CONV_ID=?" + ) + private val upsertGroupFkStmt = connection.prepare( + "INSERT INTO $TABLE_CONV_META($COL_CONV_ID, $COL_GROUP_FK) " + + "VALUES(?, ?) " + + "ON CONFLICT($COL_CONV_ID) DO UPDATE SET " + + "$COL_GROUP_FK=excluded.$COL_GROUP_FK" + ) + private val upsertLastSeenStmt = connection.prepare( + "INSERT INTO $TABLE_CONV_META($COL_CONV_ID, $COL_LAST_SEEN) " + + "VALUES(?, ?) " + + "ON CONFLICT($COL_CONV_ID) DO UPDATE SET " + + "$COL_LAST_SEEN=excluded.$COL_LAST_SEEN" + ) + private val upsertPreviewStmt = connection.prepare( + "INSERT INTO $TABLE_CONV_META($COL_CONV_ID, $COL_PREVIEW) " + + "VALUES(?, ?) " + + "ON CONFLICT($COL_CONV_ID) DO UPDATE SET " + + "$COL_PREVIEW=excluded.$COL_PREVIEW" + ) + private val upsertSyncedAtStmt = connection.prepare( + "INSERT INTO $TABLE_CONV_META($COL_CONV_ID, $COL_SYNCED_AT) " + + "VALUES(?, ?) " + + "ON CONFLICT($COL_CONV_ID) DO UPDATE SET " + + "$COL_SYNCED_AT=excluded.$COL_SYNCED_AT" + ) + private val deleteMetaStmt = connection.prepare( + "DELETE FROM $TABLE_CONV_META WHERE $COL_CONV_ID=?" + ) + + // ───── Group API ───── + + /** Все группы, отсортированы по имени. */ + suspend fun listGroups(): List = withContext(Dispatchers.Default) { + mutex.withLock { + listGroupsStmt.reset() + listGroupsStmt.clearBindings() + val out = mutableListOf() + listGroupsStmt.executeQuery().use { rs -> + while (rs.next()) { + out.add(Group(id = rs.getLong(0)!!, name = rs.getText(1)!!)) + } + } + out + } + } + + /** + * Создать группу. UNIQUE на имени — конфликт пробрасывается исключением + * SQLite. `name` валидируется UI заранее (не пустое, не дубликат). + */ + suspend fun createGroup(name: String): Group = withContext(Dispatchers.Default) { + mutex.withLock { + insertGroupStmt.reset() + insertGroupStmt.clearBindings() + insertGroupStmt.bindText(1, name) + insertGroupStmt.executeUpdate() + val id = readLastInsertRowId() + Group(id = id, name = name) + } + } + + /** Переименовать группу. Возвращает `true` если группа с таким id существовала. */ + suspend fun renameGroup(id: Long, name: String): Boolean = withContext(Dispatchers.Default) { + mutex.withLock { + renameGroupStmt.reset() + renameGroupStmt.clearBindings() + renameGroupStmt.bindText(1, name) + renameGroupStmt.bindLong(2, id) + renameGroupStmt.executeUpdate() > 0 + } + } + + /** + * Удалить группу. **Диалоги переживают удаление** (R9.7) — сначала + * зануляем `group_id` в `conversation_meta`, потом удаляем саму группу. + * Одна транзакция. Возвращает `false` если группы не было. + */ + suspend fun deleteGroup(id: Long): Boolean = withContext(Dispatchers.Default) { + mutex.withLock { + if (!groupExists(id)) return@withContext false + connection.exec("BEGIN") + try { + clearMetaGroupStmt.reset() + clearMetaGroupStmt.clearBindings() + clearMetaGroupStmt.bindLong(1, id) + clearMetaGroupStmt.executeUpdate() + + deleteGroupStmt.reset() + deleteGroupStmt.clearBindings() + deleteGroupStmt.bindLong(1, id) + deleteGroupStmt.executeUpdate() + + connection.exec("COMMIT") + true + } catch (t: Throwable) { + runCatching { connection.exec("ROLLBACK") } + throw t + } + } + } + + // ───── Per-conv meta API ───── + + /** Все мета-записи разом — для UI при старте (100–1000 строк, грузим в память). */ + suspend fun listMetas(): List = withContext(Dispatchers.Default) { + mutex.withLock { + listMetasStmt.reset() + listMetasStmt.clearBindings() + val out = mutableListOf() + listMetasStmt.executeQuery().use { rs -> + while (rs.next()) { + out.add(rs.toMetaRow()) + } + } + out + } + } + + /** Мета для одного диалога, или `null` если записи нет. */ + suspend fun meta(convId: String): MetaRow? = withContext(Dispatchers.Default) { + mutex.withLock { + getMetaStmt.reset() + getMetaStmt.clearBindings() + getMetaStmt.bindText(1, convId) + getMetaStmt.executeQuery().use { rs -> + if (!rs.next()) null else rs.toMetaRow() + } + } + } + + /** Положить диалог в группу. `groupId = null` — без группы (= «Все чаты»). */ + suspend fun setGroup(convId: String, groupId: Long?) = withContext(Dispatchers.Default) { + mutex.withLock { + upsertGroupFkStmt.reset() + upsertGroupFkStmt.clearBindings() + upsertGroupFkStmt.bindText(1, convId) + if (groupId != null) upsertGroupFkStmt.bindLong(2, groupId) + else upsertGroupFkStmt.bindNull(2) + upsertGroupFkStmt.executeUpdate() + } + } + + /** Отметить диалог прочитанным до момента [at]. UPSERT — не трогает group/preview. */ + suspend fun markSeen(convId: String, at: Instant) = withContext(Dispatchers.Default) { + mutex.withLock { + upsertLastSeenStmt.reset() + upsertLastSeenStmt.clearBindings() + upsertLastSeenStmt.bindText(1, convId) + upsertLastSeenStmt.bindLong(2, at.toEpochMilliseconds()) + upsertLastSeenStmt.executeUpdate() + } + } + + /** Обновить превью строки в списке. UPSERT — не трогает group/last_seen. */ + suspend fun setPreview(convId: String, preview: String?) = withContext(Dispatchers.Default) { + mutex.withLock { + upsertPreviewStmt.reset() + upsertPreviewStmt.clearBindings() + upsertPreviewStmt.bindText(1, convId) + if (preview != null) upsertPreviewStmt.bindText(2, preview) + else upsertPreviewStmt.bindNull(2) + upsertPreviewStmt.executeUpdate() + } + } + + /** + * Сдвинуть watermark синхронизации локального кэша сообщений: всё, + * что сервер отдал **до** [at] включительно, уже лежит в + * `KsqliteJournalStore`. Следующий бэкфилл идёт строго `after = at` + * — это защищает от повторного `INSERT` уже сохранённых записей + * (у message-таблицы `id` — PRIMARY KEY, дубль бросает исключение). + * UPSERT — не трогает group/last_seen/preview. + */ + suspend fun setSyncedAt(convId: String, at: Instant) = withContext(Dispatchers.Default) { + mutex.withLock { + upsertSyncedAtStmt.reset() + upsertSyncedAtStmt.clearBindings() + upsertSyncedAtStmt.bindText(1, convId) + upsertSyncedAtStmt.bindLong(2, at.toEpochMilliseconds()) + upsertSyncedAtStmt.executeUpdate() + } + } + + /** + * Удалить всю локальную мету диалога. Вызывается при удалении самого + * диалога (`agent.deleteConversation`), чтобы не копить сиротские строки. + * Возвращает `true`, если запись была. + */ + suspend fun deleteMeta(convId: String): Boolean = withContext(Dispatchers.Default) { + mutex.withLock { + deleteMetaStmt.reset() + deleteMetaStmt.clearBindings() + deleteMetaStmt.bindText(1, convId) + deleteMetaStmt.executeUpdate() > 0 + } + } + + override fun close() { + listGroupsStmt.close() + insertGroupStmt.close() + lastInsertRowIdStmt.close() + renameGroupStmt.close() + groupExistsStmt.close() + clearMetaGroupStmt.close() + deleteGroupStmt.close() + listMetasStmt.close() + getMetaStmt.close() + upsertGroupFkStmt.close() + upsertLastSeenStmt.close() + upsertPreviewStmt.close() + upsertSyncedAtStmt.close() + deleteMetaStmt.close() + if (ownsConnection) connection.close() + } + + private fun readLastInsertRowId(): Long { + lastInsertRowIdStmt.reset() + lastInsertRowIdStmt.clearBindings() + lastInsertRowIdStmt.executeQuery().use { rs -> + check(rs.next()) { "last_insert_rowid() returned no rows" } + return rs.getLong(0) ?: error("last_insert_rowid() returned NULL") + } + } + + private fun groupExists(id: Long): Boolean { + groupExistsStmt.reset() + groupExistsStmt.clearBindings() + groupExistsStmt.bindLong(1, id) + groupExistsStmt.executeQuery().use { rs -> return rs.next() } + } + + private fun pw.binom.db.ksqlite.SQLiteResultSet.toMetaRow(): MetaRow = MetaRow( + convId = getText(0)!!, + groupId = getLong(1), + lastSeen = getLong(2)?.let { Instant.fromEpochMilliseconds(it) }, + preview = getText(3), + syncedAt = getLong(4)?.let { Instant.fromEpochMilliseconds(it) }, + ) +} + +/** Группа диалогов. Только имя и id — R9.5 (без цвета/иконки/порядка). */ +data class Group(val id: Long, val name: String) + +/** + * Per-conv локальное состояние. + * - `groupId == null` → диалог в «Все чаты» (без группы). + * - `lastSeen == null` → диалог ещё ни разу не открывали; бейдж «есть новое» + * по `localUpdatedAt > lastSeen` (lastSeen=null → бейдж всегда). + * - `preview == null` → ещё ни одного сообщения, в строке рисуем плейсхолдер. + * - `syncedAt == null` → локальный кэш сообщений ещё не догоняли с сервером; + * бэкфилл начинается с `Instant.DISTANT_PAST`. + */ +data class MetaRow( + val convId: String, + val groupId: Long?, + val lastSeen: Instant?, + val preview: String?, + val syncedAt: Instant? = null, +) diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/AgentConnection.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/AgentConnection.kt new file mode 100644 index 0000000..abc7bab --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/AgentConnection.kt @@ -0,0 +1,268 @@ +package pw.binom.agentik.desktop.session + +import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.engine.cio.CIO +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import pw.binom.agentik.client.AgentikAgent +import pw.binom.agentik.desktop.model.UiConversation +import pw.binom.agentik.desktop.model.toUiConversation +import pw.binom.agentik.desktop.persistence.ConversationMetaRepository +import pw.binom.agentik.desktop.persistence.MetaRow +import pw.binom.agentik.desktop.settings.AgentConfig +import pw.binom.agentik.desktop.settings.AppPaths +import pw.binom.agentik.journal.ConversationRecord +import pw.binom.agentik.journal.ConversationStore +import pw.binom.agentik.journal.MessageRecord +import pw.binom.agentik.journal.MutableJournalStore +import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore +import pw.binom.agentik.outbox.OutboxStore +import pw.binom.agentik.proto.Agent +import pw.binom.agentik.proto.AgentInfo +import pw.binom.agentik.proto.Conversation +import pw.binom.db.ksqlite.SQLiteConnection +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +/** + * Подключение к одному агенту: HTTP-транспорт (`:client`) + локальное + * хранилище этого агента. + * + * **Что внутри:** + * - [agent] — `AgentikAgent(...)` из `:client`: HTTP/JSON/SSE/Bearer, кэш + * списка диалогов, авто-reconnect, всё «из коробки» (README `:client`). + * - `SQLiteConnection` — ОДНО соединение на агента, на нём сидят: + * - [journalCache] (`KsqliteJournalStore`) — все сообщения (offline); + * - [meta] (`ConversationMetaRepository`) — группы / lastSeen / + * preview / watermark. + * Отдельная БД на агента (а не общая): серверные id диалогов могут + * пересечься между агентами, а группы и так локальны для агента. + * + * **Почему не `KsqliteMutableConversationStore` для списка диалогов:** + * `AgentikAgent` уже отдаёт готовый кэш `conversationStore` (seed из + * сервера + live-refresh по outbox), но in-memory — `wrapWithLocalConversationCache` + * жёстко зашит на `InMemoryMutableConversationStore`. Персистить список — + * отдельная задача; сейчас offline-история сообщений есть, а список при + * старте подтягивается с сервера (при отсутствии сети — пуст). + * + * ## Создание + * + * [open] синхронно конструирует `AgentikAgent`, который внутри делает + * `GET {baseUrl}` за [AgentInfo]. Нет сети — [open] бросит; вызывающий + * (см. `AgentRegistry`) должен поймать и показать «нет связи». + */ +class AgentConnection private constructor( + val config: AgentConfig, + private val agent: Agent, + private val connection: SQLiteConnection, + private val scope: CoroutineScope, +) : AutoCloseable { + + /** Локальный кэш сообщений (append-only, как audit log). */ + val journalCache: MutableJournalStore = KsqliteJournalStore(connection) + + /** Группы / lastSeen / preview / watermark синхронизации. */ + val meta: ConversationMetaRepository = ConversationMetaRepository(connection) + + /** Стабильный id агента (opaque). */ + val id: String get() = config.id + + /** Имя/описание от сервера. */ + val info: AgentInfo get() = agent.info + + /** Кэш списка диалогов (`:client`): seed + live-refresh. */ + val conversationStore: ConversationStore get() = agent.conversationStore + + private val conversationsState = MutableStateFlow>(emptyList()) + + /** + * Собственный scope для внутренних подписок (список диалогов). + * Не используем [scope] вызывающего: подписка живёт до [close], а у + * вызывающего (в тестах — `runBlocking`) не должно оставаться вечных + * детей, иначе он никогда не завершится. + */ + private val watchScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + /** + * Живой список диалогов для UI. + * + * Seed внутри `AgentikAgent` асинхронный (`listFlow` в фоновом job'е), + * поэтому одноразовое чтение сразу после [open] даёт пустой список. + * Здесь мы сами доводим список до сервера и поддерживаем живым: + * - ждём первый непустой снапшот (ограниченное число попыток); + * - дальше перечитываем на каждое outbox-событие списка + * (Created / Deleted / Renamed / Touched). + */ + val conversationsFlow: StateFlow> = conversationsState.asStateFlow() + + /** Live-события (SSE). */ + val outbox: OutboxStore get() = agent.outbox + + init { + startConversationWatch() + } + + private fun startConversationWatch() { + watchScope.launch { + var attempts = 0 + while (isActive && attempts < SEED_ATTEMPTS) { + if (refreshConversations()) break + attempts++ + delay(SEED_RETRY_DELAY) + } + refreshConversations() + runCatching { + agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { refreshConversations() } + } + } + } + + /** + * Перечитать список из кэша либы. При ошибке (нет сети) оставляем + * предыдущее значение, а не обнуляем список. + * + * @return true, если снапшот непустой. + */ + private suspend fun refreshConversations(): Boolean { + val snapshot = runCatching { uiConversations() }.getOrNull() ?: return false + conversationsState.value = snapshot + return snapshot.isNotEmpty() + } + + /** + * Список диалогов в UI-виде: склейка серверного snapshot'а с локальными + * метами (группа, превью) и счётчиком непрочитанных. + * + * Счётчик берём у сервера (`journal.count(after=lastSeen)`) — точный и + * не требует полного бэкфилла сообщений; если сеть отвалилась, тихо + * падаем на локальный кэш (там может быть 0, если диалог не открывали). + */ + suspend fun uiConversations(limit: Int = 500): List { + val records = agent.conversationStore.list(offset = 0, limit = limit) + val metas = meta.listMetas().associateBy { it.convId } + return records.map { rec -> rec.toUi(metas[rec.id]) } + } + + private suspend fun ConversationRecord.toUi(metaRow: MetaRow?): UiConversation { + val seen = metaRow?.lastSeen + val convId = this.id + val unread = if (seen == null) { + 0 + } else { + runCatching { agent.journal.count(convId, after = seen) } + .getOrElse { runCatching { journalCache.count(convId, after = seen) }.getOrDefault(0L) } + .toInt() + } + return toUiConversation(metaRow, unread) + } + + /** Диалог по id или `null`. */ + suspend fun conversation(id: String): Conversation? = agent.getConversation(id) + + /** + * Создать диалог. Сразу помечаем его прочитанным, чтобы только что + * созданный пустой чат не мигал бейджем. + */ + suspend fun createConversation(temp: Boolean = false): Conversation? { + val conv = runCatching { agent.createConversation(temp) }.getOrNull() ?: return null + meta.markSeen(conv.id, Clock.System.now()) + watchScope.launch { refreshConversations() } + return conv + } + + /** + * Удалить диалог на сервере и вычистить локальные следы (кэш сообщений + + * мета). Если сервер не подтвердил удаление — локальные данные не трогаем. + */ + suspend fun deleteConversation(id: String): Boolean { + val ok = runCatching { agent.deleteConversation(id) }.getOrDefault(false) + if (ok) { + runCatching { journalCache.clear(id) } + runCatching { meta.deleteMeta(id) } + watchScope.launch { refreshConversations() } + } + return ok + } + + /** Переименовать диалог. `title = null` сбрасывает заголовок. */ + suspend fun renameConversation(id: String, title: String?): Instant? = + runCatching { agent.renameConversation(id, title) } + .onSuccess { watchScope.launch { refreshConversations() } } + .getOrNull() + + /** + * Открыть сессию диалога. Возвращает `null`, если диалог не найден. + * Сессию нужно закрыть вызывающему (`ChatSession.close`). + */ + suspend fun openSession(id: String): ChatSession? { + val conv = agent.getConversation(id) ?: return null + return ChatSession( + conversationId = id, + conversation = conv, + remoteJournal = agent.journal, + journalCache = journalCache, + meta = meta, + outbox = agent.outbox, + scope = scope, + ) + } + + override fun close() { + watchScope.cancel() + runCatching { agent.close() } + runCatching { journalCache.close() } + runCatching { meta.close() } + runCatching { connection.close() } + } + + companion object { + /** Сколько раз ждать асинхронный seed списка в `AgentikAgent`. */ + private const val SEED_ATTEMPTS = 20 + + /** Пауза между попытками дождаться seed (20 × 250 мс = 5 с). */ + private val SEED_RETRY_DELAY = 250.milliseconds + + /** + * Открывает подключение к агенту. [clientId] — идентификатор + * установки клиента (X-Client-Id на сервере), общий для всех агентов + * одного пользователя. + * + * @param dbPath путь к SQLite-файлу этого агента. По умолчанию — + * `AppPaths.ensureAgentDatabase(config.id)`; параметр существует, + * чтобы тест мог увести БД в свою temp-папку и не трогать + * пользовательские данные. + * @throws Throwable если сервер недоступен (см. KDoc класса). + */ + fun open( + config: AgentConfig, + clientId: String, + scope: CoroutineScope, + engineFactory: HttpClientEngineFactory<*> = CIO, + dbPath: String? = null, + ): AgentConnection { + val resolvedPath = dbPath ?: AppPaths.ensureAgentDatabase(config.id).toString() + val connection = SQLiteConnection.open(path = resolvedPath) + try { + val agent = AgentikAgent( + id = clientId, + baseUrl = config.baseUrl, + engineFactory = engineFactory, + token = config.token, + ) + return AgentConnection(config, agent, connection, scope) + } catch (t: Throwable) { + runCatching { connection.close() } + throw t + } + } + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/AgentRegistry.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/AgentRegistry.kt new file mode 100644 index 0000000..722711d --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/AgentRegistry.kt @@ -0,0 +1,190 @@ +package pw.binom.agentik.desktop.session + +import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.engine.cio.CIO +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import pw.binom.agentik.desktop.settings.AgentConfig +import pw.binom.agentik.desktop.settings.AppSettings +import pw.binom.agentik.desktop.settings.SettingsRepository + +/** Состояние подключения к агенту. */ +sealed interface AgentStatus { + /** Ещё не пробовали подключиться (или идёт попытка). */ + data object Connecting : AgentStatus + + /** Подключение установлено, есть живая [connection]. */ + data class Online(val connection: AgentConnection) : AgentStatus + + /** Сервер недоступен; текст ошибки — для диалога «проверка связи». */ + data class Offline(val error: String) : AgentStatus +} + +/** Фабрика подключения — вынесена, чтобы UI-тесты не ходили в сеть. */ +fun interface AgentConnector { + fun open(config: AgentConfig, clientId: String): AgentConnection +} + +/** + * Реестр агентов: настройки + живые подключения. + * + * Владеет [AppSettings] (persist через [SettingsRepository]) и держит по + * одному [AgentConnection] на агента. Соединения открываются лениво и + * терпимо: если сервер недоступен, агент получает статус + * [AgentStatus.Offline] с текстом ошибки, но само приложение и остальные + * агенты живут (агент можно переподключить кнопкой). + * + * Все мутации сериализованы одним [Mutex]: UI может дёргать их из разных + * мест, а `settings.json` переписывается целиком. + */ +class AgentRegistry( + private val settingsRepo: SettingsRepository, + private val scope: CoroutineScope, + private val connector: AgentConnector, +) : AutoCloseable { + + private val mutex = Mutex() + + private val _settings = MutableStateFlow(AppSettings()) + val settings: StateFlow = _settings.asStateFlow() + + private val _statuses = MutableStateFlow>(emptyMap()) + val statuses: StateFlow> = _statuses.asStateFlow() + + private var loaded = false + + /** Загружает настройки (однократно) и размечает агентов как [AgentStatus.Connecting]. */ + suspend fun load(): AppSettings = mutex.withLock { + if (loaded) return@withLock _settings.value + val s = settingsRepo.load() + _settings.value = s + _statuses.value = s.agents.associate { it.id to AgentStatus.Connecting } + loaded = true + s + } + + /** Пробует подключиться ко всем агентам (параллельно). */ + suspend fun connectAll() { + load() + val ids = _settings.value.agents.map { it.id } + coroutineScope { + ids.map { id -> async { launchConnect(id) } }.awaitAll() + } + } + + /** Переподключить одного агента. */ + suspend fun reconnect(agentId: String): AgentStatus { + load() + launchConnect(agentId) + return _statuses.value[agentId] ?: AgentStatus.Connecting + } + + private suspend fun launchConnect(agentId: String) { + val (config, clientId) = mutex.withLock { + val config = _settings.value.agents.firstOrNull { it.id == agentId } ?: return + closeStatus(agentId) + _statuses.value = _statuses.value + (agentId to AgentStatus.Connecting) + config to _settings.value.clientId + } + val status = withContext(Dispatchers.IO) { + runCatching { connector.open(config, clientId) } + .fold( + onSuccess = { AgentStatus.Online(it) }, + onFailure = { AgentStatus.Offline(it.message ?: it::class.simpleName ?: "ошибка подключения") }, + ) + } + mutex.withLock { + _statuses.value = _statuses.value + (agentId to status) + } + } + + /** Добавить агента: persist + попытка подключения. */ + suspend fun addAgent(config: AgentConfig): AgentStatus { + load() + mutex.withLock { + _settings.value = persist(_settings.value.copy(agents = _settings.value.agents + config)) + _statuses.value = _statuses.value + (config.id to AgentStatus.Connecting) + } + launchConnect(config.id) + return _statuses.value[config.id] ?: AgentStatus.Connecting + } + + /** Обновить конфиг агента (адрес/имя/цвет/аватар). Переподключает. */ + suspend fun updateAgent(config: AgentConfig): AgentStatus { + load() + var reconnect = false + mutex.withLock { + val before = _settings.value.agents.firstOrNull { it.id == config.id } + if (before == null) return AgentStatus.Offline("агент не найден") + val agents = _settings.value.agents.map { if (it.id == config.id) config else it } + _settings.value = persist(_settings.value.copy(agents = agents)) + reconnect = before.baseUrl != config.baseUrl || before.token != config.token + } + if (reconnect) { + launchConnect(config.id) + } + return _statuses.value[config.id] ?: AgentStatus.Connecting + } + + /** Удалить агента: закрыть соединение, вычистить из настроек. */ + suspend fun removeAgent(agentId: String) { + load() + mutex.withLock { + closeStatus(agentId) + _statuses.value = _statuses.value - agentId + _settings.value = persist(_settings.value.copy(agents = _settings.value.agents.filterNot { it.id == agentId })) + } + } + + /** Текущее подключение агента, если он онлайн. */ + fun connection(agentId: String): AgentConnection? = + (_statuses.value[agentId] as? AgentStatus.Online)?.connection + + /** Обновить выбранную тему / группу / последний диалог. */ + suspend fun updateSettings(transform: (AppSettings) -> AppSettings) { + load() + mutex.withLock { + _settings.value = persist(transform(_settings.value)) + } + } + + override fun close() { + _statuses.value.values.forEach { (it as? AgentStatus.Online)?.connection?.close() } + _statuses.value = emptyMap() + } + + private suspend fun persist(next: AppSettings): AppSettings { + settingsRepo.save(next) + return next + } + + private fun closeStatus(agentId: String) { + (_statuses.value[agentId] as? AgentStatus.Online)?.connection?.close() + } + + companion object { + /** + * Стандартный реестр: подключения через `AgentikAgent` (CIO-движок). + */ + fun create( + settingsRepo: SettingsRepository, + scope: CoroutineScope, + engineFactory: HttpClientEngineFactory<*> = CIO, + ): AgentRegistry = AgentRegistry( + settingsRepo = settingsRepo, + scope = scope, + connector = { config, clientId -> + AgentConnection.open(config = config, clientId = clientId, scope = scope, engineFactory = engineFactory) + }, + ) + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/ChatSession.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/ChatSession.kt new file mode 100644 index 0000000..983ee20 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/ChatSession.kt @@ -0,0 +1,247 @@ +package pw.binom.agentik.desktop.session + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import pw.binom.agentik.desktop.model.UiContent +import pw.binom.agentik.desktop.model.UiMessage +import pw.binom.agentik.desktop.model.toUiMessages +import pw.binom.agentik.desktop.persistence.ConversationMetaRepository +import pw.binom.agentik.journal.JournalStore +import pw.binom.agentik.journal.MutableJournalStore +import pw.binom.agentik.outbox.Event +import pw.binom.agentik.outbox.OutboxStore +import pw.binom.agentik.proto.Content +import pw.binom.agentik.proto.Conversation +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +/** + * Один открытый диалог: склейка четырёх источников. + * + * - **История** — `KsqliteJournalStore` (локальный кэш `MessageRecord`), + * наружу отдаётся как `StateFlow>`. + * - **Бэкфилл** — `JournalStore` (HTTP `GET /journal/...messages`): при + * старте догоняем то, что появилось с прошлого раза, и кладём в кэш. + * Watermark — `conversation_meta.synced_at`, чтобы не вставлять дубли + * (`message.id` — PRIMARY KEY, повторный INSERT бросает). + * - **Live-ход** — `OutboxStore.conversationEvents` (SSE): события + * `Working → StartReasoning → AppendText → … → End` накапливаются в + * [LiveTurn] и рисуются поверх истории, пока ход не закрыт. + * - **Наши поля** — [ConversationMetaRepository]: `lastSeen` (бейдж), + * `preview` (строка списка). + * + * ## Почему подписка на SSE берётся `after = synced_at` + * + * `synced_at` — `createdAt` последнего сообщения, уже лежащего в кэше. + * У завершённого хода все события (`Working`/`AppendText`/`End`) имеют + * `date`, не больший `createdAt` итогового сообщения, — значит, они + * отфильтруются и повторно не проиграются. А у **идущего** хода события + * свежее — они и попадут в поток. Так «догоняем» уже начатый ход, не + * переигрывая все прошлые (bounded tail outbox этого не запрещает, но + * переигрыш = лишние `sync()` на каждый чужой `End`). + * + * ## Поток событий vs `sync()` на `End` + * + * На `End`/`Interrupted` ход закрыт: подтягиваем авторитетную историю + * (`sync()` — инкрементальный бэкфилл + перечитка кэша) и гасим live-блоки, + * чтобы не было дублирования «live + history». + */ +class ChatSession( + val conversationId: String, + private val conversation: Conversation, + private val remoteJournal: JournalStore, + private val journalCache: MutableJournalStore, + private val meta: ConversationMetaRepository, + private val outbox: OutboxStore, + private val scope: CoroutineScope, +) : AutoCloseable { + + private val _history = MutableStateFlow>(emptyList()) + + /** История ходов из локального кэша, готовая к рендеру. */ + val history: StateFlow> = _history.asStateFlow() + + /** Live-блоки активного хода (пусто, когда хода нет). */ + private val _live = MutableStateFlow>(emptyList()) + val live: StateFlow> = _live.asStateFlow() + + /** Фаза текущего хода — для спиннера «агент думает» до первого токена. */ + private val _phase = MutableStateFlow(TurnPhase.IDLE) + val phase: StateFlow = _phase.asStateFlow() + + /** true, пока идёт активный ход (спиннер / стриминг). */ + val isStreaming: Boolean get() = liveTurn.isStreaming + + /** Поддерживает ли агент приём картинок от пользователя. */ + val supportsImageInput: Boolean get() = conversation.isSupportImageInput + + /** Поддерживает ли агент картинки в ответе. */ + val supportsImageOutput: Boolean get() = conversation.isSupportImageOutput + + private val liveTurn = LiveTurn() + private var eventsJob: Job? = null + private var started = false + + /** + * Сериализует [sync]. Без него два параллельных триггера (оптимистичный + * `sync()` из [sendContent] и `sync()` из обработчика SSE `End`, который + * приходит почти сразу на быстром ответе) читают один и тот же watermark + * `null` → `after = DISTANT_PAST` и оба вставляют user-сообщение → + * `UNIQUE constraint failed: message.id`. + */ + private val syncMutex = Mutex() + + /** + * Догоняет историю и подписывается на live-события. Идемпотентно — + * повторный вызов no-op. Вызывать из [scope]. + */ + suspend fun start() { + if (started) return + started = true + liveTurn.onChange = { + _phase.value = liveTurn.phase + _live.value = liveTurn.toUiMessages(Clock.System.now()) + } + sync() + subscribeEvents() + } + + /** Отправить текстовый ход. Пустой/пробельный текст игнорируется. */ + suspend fun send(text: String) { + val body = text.trim() + if (body.isEmpty()) return + sendContent(listOf(Content.Text(body))) + } + + /** + * Отправить ход с произвольным контентом (текст/картинки). Оптимистично + * помечаем диалог прочитанным и сразу подтягиваем user-сообщение из + * журнала, чтобы оно появилось до ответа ассистента. + */ + suspend fun sendContent(content: List) { + if (content.isEmpty()) return + liveTurn.start() + conversation.send(content) + markSeen() + runCatching { sync() } + } + + /** Прервать активный ход (best-effort на стороне сервера). */ + suspend fun interrupt() { + conversation.interrupt() + } + + /** Пометить диалог прочитанным до «сейчас». */ + suspend fun markSeen() { + meta.markSeen(conversationId, Clock.System.now()) + } + + override fun close() { + eventsJob?.cancel() + eventsJob = null + runCatching { conversation.close() } + } + + // ───── internals ───── + + private suspend fun subscribeEvents() { + val after = currentSyncedAt() ?: Instant.DISTANT_PAST + eventsJob = scope.launch { + outbox.conversationEvents(after = after, conversationId = conversationId) + .map { it.event } + .collect { event -> + liveTurn.apply(event) + if (event is Event.End || event is Event.Interrupted) { + // Ход закрыт: авторитетная история важнее live-блоков. + runCatching { sync() } + liveTurn.reset() + } + } + } + } + + private suspend fun currentSyncedAt(): Instant? = meta.meta(conversationId)?.syncedAt + + /** Инкрементальный бэкфилл + перечитка кэша + превью. Сериализован. */ + private suspend fun sync() = syncMutex.withLock { + backfill() + reloadHistory() + updatePreview() + } + + /** + * Дотягивает сообщения, появившиеся с прошлого watermark'а. + * + * **Идемпотентность по id, а не по времени.** Watermark — миллисекундный: + * user- и assistant-сообщение быстрого ответа делят одну миллисекунду, и + * фильтр `createdAt > after` просто потерял бы второе. Поэтому watermark + * отступает на 1 мс назад, а повторно пришедшие записи отсекаются по + * множеству уже известных id (никакого `runCatching` — реальные ошибки + * записи должны быть видны). + */ + private suspend fun backfill() { + val row = meta.meta(conversationId) + val after = row?.syncedAt ?: Instant.DISTANT_PAST + val known = journalCache.list( + conversationId = conversationId, + after = Instant.DISTANT_PAST, + offset = 0, + limit = Int.MAX_VALUE, + ).mapTo(mutableSetOf()) { it.id } + + var newest: Instant? = null + remoteJournal.listFlow(conversationId = conversationId, after = after).collect { rec -> + if (known.add(rec.id)) { + journalCache.append(rec) + } + val n = newest + if (n == null || rec.createdAt > n) newest = rec.createdAt + } + newest?.let { meta.setSyncedAt(conversationId, it - 1.milliseconds) } + } + + private suspend fun reloadHistory() { + val records = journalCache.list( + conversationId = conversationId, + after = Instant.DISTANT_PAST, + offset = 0, + limit = Int.MAX_VALUE, + ) + _history.value = records.toUiMessages() + } + + private suspend fun updatePreview() { + val last = _history.value.lastOrNull { it is UiMessage.Assistant } ?: _history.value.lastOrNull() + meta.setPreview(conversationId, last?.previewText()) + } +} + +/** Короткое однострочное превью сообщения для строки списка диалогов. */ +internal fun UiMessage.previewText(maxLength: Int = 120): String? { + val raw = when (this) { + is UiMessage.User -> content.firstText() + is UiMessage.Assistant -> content.firstText() ?: content.imagePlaceholder() + is UiMessage.Reasoning -> text + is UiMessage.Tool -> title ?: toolName + is UiMessage.Error -> message + }?.replace(Regex("\\s+"), " ")?.trim() + if (raw.isNullOrEmpty()) return null + if (raw.length <= maxLength) return raw + val cut = raw.take(maxLength) + val lastSpace = cut.lastIndexOf(' ') + return (if (lastSpace > maxLength / 2) cut.take(lastSpace) else cut).trimEnd() + "…" +} + +private fun List.firstText(): String? = + firstNotNullOfOrNull { (it as? UiContent.Text)?.body } + +private fun List.imagePlaceholder(): String? = + if (any { it is UiContent.Image }) "[изображение]" else null diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/LiveTurn.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/LiveTurn.kt new file mode 100644 index 0000000..49a824d --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/LiveTurn.kt @@ -0,0 +1,301 @@ +package pw.binom.agentik.desktop.session + +import pw.binom.agentik.outbox.Event + +/** + * Фаза текущего хода ассистента. Управляет спиннером и тем, что UI + * показывает вместо ещё не завершённого ответа. + */ +enum class TurnPhase { + /** Хода нет — стор чист. */ + IDLE, + + /** `send()` ушёл, ответа ещё нет (`Event.Working`). Спиннер «агент думает». */ + WORKING, + + /** Идут рассуждения (`StartReasoning` + `AppendText` в reasoning-блок). */ + REASONING, + + /** Стримится ответ (`StartResponse` + `AppendText`/`AppendImage`/tool'ы). */ + RESPONDING, + + /** Ход закрыт нормально (`End`). Live-блоки пора заменить историей из кэша. */ + DONE, + + /** Ход прерван пользователем (`Interrupted`). Частичный ответ НЕ сохранён. */ + INTERRUPTED, + + /** Ход упал (`Error`). */ + FAILED; + + /** true для DONE/INTERRUPTED/FAILED/IDLE — т.е. стриминг уже не идёт. */ + val isTerminal: Boolean get() = this == IDLE || this == DONE || this == INTERRUPTED || this == FAILED + + /** true, пока идёт активный ход (WORKING/REASONING/RESPONDING). */ + val isStreaming: Boolean get() = !isTerminal +} + +/** + * Блок «живого» (ещё не сохранённого в журнал) хода. + * + * Ключ [key] стабилен в пределах одного хода и нужен как `key` в + * `LazyColumn`: без него Compose перезапускает анимации/скролл при каждом + * новом токене. + * + * Текстовые блоки ([Reasoning], [Answer]) держат [StringBuilder] — append + * на токен O(1). Наружу отдаётся как есть; UI вызывает `.toString()` в + * момент отрисовки (парсер markdown всё равно строит строку). + */ +sealed interface LiveBlock { + val key: String + + /** Рассуждения ассистента (`StartReasoning` → `AppendText`). Рендерятся приглушённо. */ + class Reasoning(override val key: String) : LiveBlock { + val text: StringBuilder = StringBuilder() + } + + /** Текст ответа (`StartResponse(TEXT)` → `AppendText`). Основной markdown-блок. */ + class Answer(override val key: String) : LiveBlock { + val text: StringBuilder = StringBuilder() + } + + /** Картинка, пришедшая целиком (`AppendImage`). Стриминга байтов нет. */ + class Image( + override val key: String, + val mime: String, + val data: ByteArray, + ) : LiveBlock + + /** + * Вызов инструмента. [callId] = id из `Event.ToolCall` и, после `End`, + * id `MessageRecord.ToolCall`. Результат может придти позже + * (`ToolResult`) или упасть (`ToolFailed`) — тогда заполняется + * [result] / [failed]. + */ + class Tool( + override val key: String, + val callId: String, + val title: String?, + val toolName: String, + val argsJson: String, + ) : LiveBlock { + var result: String? = null + var failed: String? = null + } + + /** `Event.Error` — ход провален. Терминальный блок. */ + class Failure( + override val key: String, + val message: String, + val code: String?, + ) : LiveBlock +} + +/** + * Аккумулятор live-хода: применяет [Event] по мере SSE-стрима и держит + * текущий список [LiveBlock] + [phase]. + * + * Это **модель представления**, не хранилище: после `End` ход + * «схлопывается» в обычную `MessageRecord`, и UI переключается на историю + * из `KsqliteJournalStore`. См. `ChatSession`. + * + * **Мутабельность намеренная.** Ход растёт токен за токеном; создавать + * новый иммутабельный список на каждый токен — это лишние аллокации и + * потеря стабильных ключей. UI подписывается на [onChange] и перерисовывает + * кадр (Compose coalesces изменения за кадр). + * + * **Чистота логики.** Метод [apply] — единственная точка входа; никаких + * сайд-эффектов кроме изменения собственного состояния. Юнит-тестируется + * синтетическими событиями без сети и БД. + */ +class LiveTurn { + + var phase: TurnPhase = TurnPhase.IDLE + private set + + private val _blocks = mutableListOf() + + /** Блоки в порядке появления. Живой список — не копия. */ + val blocks: List get() = _blocks + + /** + * Хук «состояние изменилось» — UI вешает сюда `onChange = { revision++ }`. + * Вызывается после каждого [apply], который реально что-то поменял. + */ + var onChange: (() -> Unit)? = null + + private var keyCounter: Int = 0 + private fun nextKey(kind: String): String = "$kind-${keyCounter++}" + + /** true, пока ход активен (аналог `phase.isStreaming`). */ + val isStreaming: Boolean get() = phase.isStreaming + + /** + * Начинает новый ход: чистит блоки и переводит в [TurnPhase.WORKING]. + * Вызывать перед `send()` (или на `Event.Working`) — иначе блоки + * предыдущего хода смешаются с новыми. + */ + fun start() { + _blocks.clear() + keyCounter = 0 + phase = TurnPhase.WORKING + fire() + } + + /** Полный сброс в [TurnPhase.IDLE]. */ + fun reset() { + _blocks.clear() + keyCounter = 0 + phase = TurnPhase.IDLE + fire() + } + + /** + * Применяет одно событие. Неизвестные/нерелевантные события + * (`ConversationClosing`, `CompactionTriggered`) игнорируются. + */ + fun apply(event: Event) { + when (event) { + is Event.Working -> { + // Синхронный маркер старта хода: новый ход — с чистого листа. + start() + } + + is Event.StartReasoning -> { + phase = TurnPhase.REASONING + _blocks.add(LiveBlock.Reasoning(nextKey("reasoning"))) + fire() + } + + is Event.StartResponse -> { + phase = TurnPhase.RESPONDING + if (event.responseType == Event.ResponseType.TEXT) { + _blocks.add(LiveBlock.Answer(nextKey("answer"))) + } + fire() + } + + is Event.AppendText -> { + when (phase) { + TurnPhase.REASONING -> appendText(event.body, reasoning = true) + // AppendText без предшествующего StartResponse — + // трактуем как ответ (forward-compat). + else -> { + if (phase != TurnPhase.RESPONDING) phase = TurnPhase.RESPONDING + appendText(event.body, reasoning = false) + } + } + } + + is Event.AppendImage -> { + phase = TurnPhase.RESPONDING + _blocks.add(LiveBlock.Image(nextKey("image"), event.mime, event.body)) + fire() + } + + is Event.ToolCall -> { + _blocks.add( + LiveBlock.Tool( + key = nextKey("tool"), + callId = event.id, + title = event.title, + toolName = event.toolName, + argsJson = event.toolArgs, + ) + ) + fire() + } + + is Event.ToolResult -> { + val tool = findTool(event.toolCallId) + ?: LiveBlock.Tool( + key = nextKey("tool"), + callId = event.toolCallId, + title = null, + toolName = event.toolName ?: "(unknown)", + argsJson = "", + ).also { _blocks.add(it) } + tool.result = event.result + fire() + } + + is Event.ToolFailed -> { + val tool = findTool(event.toolCallId) + ?: LiveBlock.Tool( + key = nextKey("tool"), + callId = event.toolCallId, + title = null, + toolName = event.toolName ?: "(unknown)", + argsJson = "", + ).also { _blocks.add(it) } + tool.failed = event.message + fire() + } + + is Event.End -> { + phase = TurnPhase.DONE + fire() + } + + is Event.Interrupted -> { + phase = TurnPhase.INTERRUPTED + fire() + } + + is Event.Error -> { + _blocks.add(LiveBlock.Failure(nextKey("error"), event.message, event.code)) + phase = TurnPhase.FAILED + fire() + } + + // Не влияют на представление хода. + is Event.ConversationClosing, is Event.CompactionTriggered -> Unit + } + } + + /** + * Дописывает текстовый чанк в последний блок нужного типа. Если + * последний блок — другого типа (например, между двумя кусками текста + * вклинился tool-call), заводит новый текстовый блок: это корректно — + * ответ продолжался после результата инструмента. + */ + private fun appendText(chunk: String, reasoning: Boolean) { + val last = _blocks.lastOrNull() + if (reasoning) { + val block = last as? LiveBlock.Reasoning + ?: LiveBlock.Reasoning(nextKey("reasoning")).also { _blocks.add(it) } + block.text.append(chunk) + } else { + val block = last as? LiveBlock.Answer + ?: LiveBlock.Answer(nextKey("answer")).also { _blocks.add(it) } + block.text.append(chunk) + } + fire() + } + + private fun findTool(callId: String): LiveBlock.Tool? = + _blocks.filterIsInstance().lastOrNull { it.callId == callId } + + private fun fire() { + onChange?.invoke() + } + + /** + * Текст ответа целиком (все Answer-блоки). Используется для превью + * в списке диалогов и для «склеенного» рендера. + */ + fun answerText(): String = + _blocks.filterIsInstance().joinToString("") { it.text.toString() } + + /** + * Короткое превью последнего ответа для строки списка диалогов. + * Обрезает по границе слова и добавляет многоточие. + */ + fun previewText(maxLength: Int = 120): String { + val raw = answerText().replace(Regex("\\s+"), " ").trim() + if (raw.length <= maxLength) return raw + val cut = raw.take(maxLength) + val lastSpace = cut.lastIndexOf(' ') + return (if (lastSpace > maxLength / 2) cut.take(lastSpace) else cut).trimEnd() + "…" + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/settings/AppPaths.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/settings/AppPaths.kt new file mode 100644 index 0000000..5140729 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/settings/AppPaths.kt @@ -0,0 +1,82 @@ +package pw.binom.agentik.desktop.settings + +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.createDirectories + +/** + * Каталоги данных десктоп-клиента. + * + * Всё локальное живёт в одном каталоге, чтобы пользователь мог снести + * состояние одной командой (`rm -rf ~/.agentik-desktop`) и чтобы при + * переносе на другую машину было понятно, что копировать. + * + * Корень можно переопределить переменной окружения `AGENTIK_DESKTOP_HOME` + * (нужно тестам, чтобы не трогать реальный `$HOME`). + */ +object AppPaths { + + const val ENV_HOME = "AGENTIK_DESKTOP_HOME" + + /** Корень данных: `$AGENTIK_DESKTOP_HOME` или `~/.agentik-desktop`. */ + val root: Path by lazy { + val env = System.getenv(ENV_HOME)?.takeIf { it.isNotBlank() } + if (env != null) { + Path(env) + } else { + val home = System.getProperty("user.home") ?: "." + Path(home).resolve(".agentik-desktop") + } + } + + /** Каталог данных всех агентов: `root/agents/`. */ + val agentsDir: Path get() = root.resolve("agents") + + /** + * Каталог данных одного агента: `root/agents//`. + * + * **Почему отдельный каталог/база на агента:** id диалогов выдаёт + * сервер, и два разных агента теоретически могут выдать одинаковый id — + * в общей таблице `message` их сообщения перемешались бы. Плюс группы + * (наша локальная сущность) логически принадлежат конкретному агенту. + * Отдельный файл на агента убирает и коллизии, и необходимость + * неймспейсить id. + */ + fun agentDir(agentId: String): Path = agentsDir.resolve(safeDirName(agentId)) + + /** SQLite-база одного агента: сообщения, диалоги, группы, меты. */ + fun agentDatabase(agentId: String): Path = agentDir(agentId).resolve("agentik.db") + + /** Скопированные пользователем аватары агентов (общий каталог). */ + val avatars: Path get() = root.resolve("avatars") + + /** Гарантирует существование каталога и возвращает его. */ + fun ensure(dir: Path): Path { + dir.createDirectories() + return dir + } + + /** + * Гарантирует существование каталога агента и возвращает путь к его БД. + * Вызывается перед открытием `SQLiteConnection`. + */ + fun ensureAgentDatabase(agentId: String): Path { + ensure(agentDir(agentId)) + return agentDatabase(agentId) + } + + /** Гарантирует существование корня и всех общих подкаталогов. */ + fun ensureAll() { + ensure(root) + ensure(agentsDir) + ensure(avatars) + } + + /** + * Приводит произвольный agent id к безопасному имени каталога: + * оставляет `[A-Za-z0-9._-]`, остальное — в `_`. Формат URL/hostname + * может содержать `:`/`/`, их в имени файла быть не должно. + */ + fun safeDirName(id: String): String = + id.replace(Regex("[^A-Za-z0-9._-]"), "_").take(80).ifBlank { "agent" } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/settings/Settings.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/settings/Settings.kt new file mode 100644 index 0000000..5fda429 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/settings/Settings.kt @@ -0,0 +1,90 @@ +package pw.binom.agentik.desktop.settings + +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * Один подключённый агент в настройках клиента. + * + * **Что здесь есть и почему:** + * - [id] — локальный стабильный ключ (UUID), генерируется один раз. Нужен + * как ключ для аватара/групп/«последнего диалога», чтобы переименование + * или смена адреса не теряла привязку. Сервер про него не знает. + * - [baseUrl] — полный адрес фасада агента, обязательно с path-префиксом: + * `http://192.168.1.10:8080/agentik`. Не просто хост — `AgentikAgent` + * ходит по этому URL как есть. + * - [token] — Bearer. `null` = анонимно (сервер обычно так и разрешает, + * см. REQUIREMENTS R23.7 — аутентификации у сервера нет; поле оставлено + * на будущее и прокидывается в `AgentikAgent(token = ...)`). + * - [nameOverride] — локальное переименование ассистента. Сервер отдаёт + * своё имя через `Agent.info.name` (AgentInfo); это поле — на случай, + * если пользователь хочет звать его иначе только у себя. `null` → + * показываем серверное имя. + * - [avatarPath] — абсолютный путь к локально скопированному аватару + * (см. `AvatarStorage`). `null` → инициалы/цвет. Сервер аватары не + * отдаёт (REQUIREMENTS R23.3.2 — картинку выбирает пользователь). + * - [colorIndex] — индекс в палитре акцентов (0..4), для бейджа/полоски + * агента, когда аватара нет. Расширение палитры из Android (a1..a5). + */ +@Serializable +data class AgentConfig( + val id: String, + val baseUrl: String, + val token: String? = null, + val nameOverride: String? = null, + val avatarPath: String? = null, + val colorIndex: Int = 0, +) { + companion object { + /** + * Новый конфиг с сгенерированным локальным id. Вызывается UI'ом + * при добавлении агента; отдельного `create()` без id нет — + * id обязан существовать до первого сохранения. + */ + fun create( + baseUrl: String, + token: String? = null, + nameOverride: String? = null, + colorIndex: Int = 0, + ): AgentConfig = AgentConfig( + id = UUID.randomUUID().toString(), + baseUrl = baseUrl.trim(), + token = token?.trim()?.takeIf { it.isNotBlank() }, + nameOverride = nameOverride?.trim()?.takeIf { it.isNotBlank() }, + colorIndex = colorIndex, + ) + } +} + +/** + * Корневой документ `settings.json`. + * + * Хранится целиком (файл небольшой) и переписывается атомарно. Поля: + * - [clientId] — идентичность клиента в логах сервера (X-Client-Id). + * Генерируется один раз при первом запуске и **никогда не меняется** + * (см. `client/README.md`: иначе ломается log multiplexing на сервере). + * - [agents] — список подключённых агентов; пустой список = первый запуск, + * UI показывает экран добавления агента. + * - [themeId] — id выбранной темы (`ThemeId`). + * - [selectedGroupId] — выбранная группа в сайдбаре (`null` = «Все чаты»). + * `-1` зарезервирован под «Все чаты» в UI, поэтому тут только реальные id + * групп из `ConversationMetaRepository` (Long, автоинкремент). + * - [lastConversationId] — последний открытый диалог (для восстановления + * при старте). Не гарантируется живым — UI проверяет существование. + */ +@Serializable +data class AppSettings( + val clientId: String = "", + val agents: List = emptyList(), + val themeId: String = ThemeId.DARK, + val selectedGroupId: Long? = null, + val lastConversationId: String? = null, +) + +/** Идентификаторы тем. Значения — стабильные строки для JSON. */ +object ThemeId { + const val DARK = "dark" + const val LIGHT = "light" + + val all = listOf(DARK, LIGHT) +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/settings/SettingsRepository.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/settings/SettingsRepository.kt new file mode 100644 index 0000000..155eabc --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/settings/SettingsRepository.kt @@ -0,0 +1,135 @@ +package pw.binom.agentik.desktop.settings + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.UUID +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.readText + +/** + * Персистентность [AppSettings] в JSON-файл. + * + * Намеренно **не** в `:client` (см. `client/README.md`: либа не навязывает + * формат настроек) — это зона десктоп-клиента. + * + * **Гарантии:** + * - [load] никогда не бросает на отсутствующем/битом файле: нет файла → + * дефолт с только что сгенерированным `clientId`; битый JSON → тоже + * дефолт (старый файл не трогаем, перезапишем при следующем [save]). + * - [load] генерирует `clientId`, если он пуст, и **сразу сохраняет** — + * чтобы один и тот же id жил между запусками (иначе сервер увидит + * каждый старт как нового клиента). + * - [save] атомарен: пишем во временный файл рядом и делаем + * `ATOMIC_MOVE`. Падение процесса посреди записи не оставит обрубок. + * + * **Тред-безопасность:** один `Mutex` на чтение/запись файла. Репозиторий + * не кэширует состояние в памяти — источник истины всегда файл (UI держит + * свой снимок в Compose-состоянии и зовёт [update] при изменениях). + */ +class SettingsRepository(private val file: Path) { + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + } + + private val mutex = Mutex() + + /** + * Читает настройки. Отсутствие файла = первый запуск: создаём дефолт, + * генерируем `clientId` и сохраняем. Битый файл тоже трактуется как + * первый запуск (не роняем приложение из-за настроек). + */ + suspend fun load(): AppSettings = mutex.withLock { + loadLocked() + } + + /** Полная перезапись файла. Возвращает то, что записали. */ + suspend fun save(settings: AppSettings): AppSettings = mutex.withLock { + val normalized = settings.normalized() + writeLocked(normalized) + normalized + } + + /** + * Атомарный read-modify-write: читает актуальные настройки, применяет + * [transform], сохраняет результат. Возвращает новое значение. Все + * изменения UI'а должны идти через этот метод — иначе два параллельных + * «прочитал-изменил-записал» затирают друг друга. + */ + suspend fun update(transform: (AppSettings) -> AppSettings): AppSettings = mutex.withLock { + val updated = transform(loadLocked()).normalized() + writeLocked(updated) + updated + } + + private fun loadLocked(): AppSettings { + val existing = readFileOrNull() + if (existing != null) { + val normalized = existing.normalized() + // Нормализация могла поправить пустой clientId или неизвестную + // тему — фиксируем результат на диске, чтобы не повторять + // починку при каждом старте. + if (normalized != existing) writeLocked(normalized) + return normalized + } + val fresh = AppSettings(clientId = newClientId()) + writeLocked(fresh) + return fresh + } + + private fun readFileOrNull(): AppSettings? { + if (!file.exists()) return null + return try { + json.decodeFromString(AppSettings.serializer(), file.readText()) + } catch (_: Throwable) { + null + } + } + + private fun writeLocked(settings: AppSettings) { + val parent = file.parent + if (parent != null) parent.createDirectories() + val text = json.encodeToString(AppSettings.serializer(), settings) + // Atomic move требует, чтобы tmp был на той же файловой системе — + // поэтому кладём рядом с целевым файлом, а не в /tmp. + val tmp = parent?.resolve(file.fileName.toString() + ".tmp") + ?: Files.createTempFile("agentik-settings", ".json") + Files.writeString(tmp, text) + try { + Files.move(tmp, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (_: java.nio.file.AtomicMoveNotSupportedException) { + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING) + } + } + + companion object { + /** Имя файла настроек внутри каталога приложения. */ + const val FILE_NAME = "settings.json" + + /** Префикс clientId. Сервер его не интерпретирует — только логи. */ + private const val CLIENT_ID_PREFIX = "agentik-desktop-" + + fun newClientId(): String = CLIENT_ID_PREFIX + UUID.randomUUID() + + /** Репозиторий в каталоге [dir] (обычно `AppPaths.root()`). */ + fun inDirectory(dir: Path): SettingsRepository = SettingsRepository(dir.resolve(FILE_NAME)) + + /** Репозиторий в стандартном каталоге данных приложения. */ + fun default(): SettingsRepository = inDirectory(AppPaths.root) + } +} + +private fun AppSettings.normalized(): AppSettings = copy( + clientId = clientId.ifBlank { SettingsRepository.newClientId() }, + themeId = if (themeId in ThemeId.all) themeId else ThemeId.DARK, +) diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/App.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/App.kt new file mode 100644 index 0000000..27da795 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/App.kt @@ -0,0 +1,199 @@ +package pw.binom.agentik.desktop.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import pw.binom.agentik.desktop.ui.components.LocalAvatarStorage +import pw.binom.agentik.desktop.ui.components.Overlay +import pw.binom.agentik.desktop.ui.screens.AgentRail +import pw.binom.agentik.desktop.ui.screens.AgentsDialog +import pw.binom.agentik.desktop.ui.screens.ChatPane +import pw.binom.agentik.desktop.ui.screens.ConversationList +import pw.binom.agentik.desktop.ui.screens.GroupsDialog +import pw.binom.agentik.desktop.ui.screens.NewChatDialog +import pw.binom.agentik.desktop.ui.theme.AgentikTheme + +private sealed interface AppDialog { + data object Agents : AppDialog + data object Groups : AppDialog + data object NewChat : AppDialog +} + +/** + * Корень приложения: тема, рейка ассистентов, список диалогов, чат и + * модальные окна. Раскладка адаптивная: широкое окно — три колонки, + * узкое — список и чат по очереди (с кнопкой «назад»). + */ +@Composable +fun App(state: AppState) { + AgentikTheme(themeId = state.settingsState.themeId) { + CompositionLocalProvider(LocalAvatarStorage provides state.avatars) { + LaunchedEffect(Unit) { state.start() } + Root(state) + } + } +} + +@Composable +private fun Root(state: AppState) { + val colors = AgentikTheme.colors + var dialog by remember { mutableStateOf(null) } + var selectedGroupId by remember { mutableStateOf(null) } + + BoxWithConstraints(Modifier.fillMaxSize().background(colors.background)) { + val wide = maxWidth >= 900.dp + val hasChat = state.activeConversationId != null + + // При нескольких ассистентах спрашиваем, в каком создавать диалог. + val newChat: () -> Unit = { + if (state.agents.size > 1) { + dialog = AppDialog.NewChat + } else { + state.agents.firstOrNull()?.let { state.newConversation(it.id) } + } + } + + Column(Modifier.fillMaxSize()) { + state.error?.let { message -> + ErrorBanner(message = message, onDismiss = { state.clearError() }) + } + Row(Modifier.weight(1f)) { + AgentRail( + state = state, + onOpenAgents = { dialog = AppDialog.Agents }, + ) + Divider() + if (state.agents.isEmpty()) { + Welcome(onOpenAgents = { dialog = AppDialog.Agents }, modifier = Modifier.weight(1f)) + } else if (wide) { + ConversationList( + state = state, + selectedGroupId = selectedGroupId, + onSelectGroup = { selectedGroupId = it }, + onOpenConversation = { state.openConversation(it) }, + onOpenGroups = { dialog = AppDialog.Groups }, + onNewConversation = newChat, + modifier = Modifier.width(300.dp), + ) + Divider() + ChatPane(state = state, onBack = null, modifier = Modifier.weight(1f)) + } else { + if (hasChat) { + ChatPane( + state = state, + onBack = { + state.closeActiveConversation() + }, + modifier = Modifier.weight(1f), + ) + } else { + ConversationList( + state = state, + selectedGroupId = selectedGroupId, + onSelectGroup = { selectedGroupId = it }, + onOpenConversation = { state.openConversation(it) }, + onOpenGroups = { dialog = AppDialog.Groups }, + onNewConversation = newChat, + modifier = Modifier.weight(1f), + ) + } + } + } + } + + when (dialog) { + AppDialog.Agents -> Overlay(onDismiss = { dialog = null }) { + AgentsDialog(state = state, onClose = { dialog = null }) + } + + AppDialog.Groups -> Overlay(onDismiss = { dialog = null }) { + GroupsDialog(state = state, onClose = { dialog = null }) + } + + AppDialog.NewChat -> Overlay(onDismiss = { dialog = null }) { + NewChatDialog( + state = state, + onPick = { agentId -> + dialog = null + state.newConversation(agentId) + }, + onClose = { dialog = null }, + ) + } + + null -> Unit + } + } +} + +@Composable +private fun Divider() { + val colors = AgentikTheme.colors + Box(Modifier.width(1.dp).fillMaxHeight().background(colors.line)) +} + +@Composable +private fun ErrorBanner(message: String, onDismiss: () -> Unit) { + val colors = AgentikTheme.colors + Row( + modifier = Modifier + .fillMaxWidth() + .background(colors.error) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(message, color = colors.onAccent, fontSize = 12.5.sp, modifier = Modifier.weight(1f)) + TextButton(onClick = onDismiss) { Text("Скрыть", color = colors.onAccent) } + } +} + +@Composable +private fun Welcome(onOpenAgents: () -> Unit, modifier: Modifier = Modifier) { + val colors = AgentikTheme.colors + Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(colors.panel) + .padding(28.dp), + ) { + Text("💬", fontSize = 34.sp) + Text("Добро пожаловать", color = colors.text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + Text( + "Добавьте адрес сервера agentik, чтобы начать общение с ассистентом.", + color = colors.textDim, + fontSize = 13.sp, + ) + Spacer(Modifier.height(6.dp)) + TextButton(onClick = onOpenAgents) { Text("Добавить агента") } + } + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/AppState.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/AppState.kt new file mode 100644 index 0000000..1351a98 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/AppState.kt @@ -0,0 +1,450 @@ +package pw.binom.agentik.desktop.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import pw.binom.agentik.desktop.media.AvatarStorage +import pw.binom.agentik.desktop.model.UiConversation +import pw.binom.agentik.desktop.model.UiMessage +import pw.binom.agentik.desktop.net.CheckStep +import pw.binom.agentik.desktop.net.ConnectionChecker +import pw.binom.agentik.desktop.net.ConnectionReport +import pw.binom.agentik.desktop.persistence.Group +import pw.binom.agentik.desktop.session.AgentRegistry +import pw.binom.agentik.desktop.session.AgentStatus +import pw.binom.agentik.desktop.session.ChatSession +import pw.binom.agentik.desktop.settings.AgentConfig +import pw.binom.agentik.desktop.settings.AppSettings +import pw.binom.agentik.desktop.voice.VoiceController +import pw.binom.agentik.desktop.voice.VoiceState + +/** + * Состояние приложения для UI: выбранный агент и диалог, список диалогов, + * группы, история открытого диалога, live-ход. Держит один [ChatSession] и + * один job-коллектор на него; при переключении диалога сессия закрывается. + * + * Класс намеренно «толстый»: это единственная точка, где UI-слой знает про + * [AgentRegistry] и [ChatSession]. Экраны читают [androidx.compose.runtime.State] + * и зовут методы — ничего не собирают сами. + * + * Все действия запускаются в переданном [scope] и не бросают наверх: + * ошибки складываются в [error] для баннера/тоста. + */ +class AppState( + private val registry: AgentRegistry, + private val scope: CoroutineScope, + val avatars: AvatarStorage = AvatarStorage(), + private val checker: ConnectionChecker = ConnectionChecker(), + /** Доступен ли голосовой ввод: результат прогрева нативного ASR до skia. */ + val voiceAvailable: Boolean = true, +) { + + /** + * Настройки, зеркалируемые в Compose-состояние (чтобы UI перерисовывался + * при добавлении/удалении агента, смене темы и т.п.). + */ + var settingsState by mutableStateOf(AppSettings()) + private set + + /** Статусы подключений, зеркалируемые в Compose-состояние. */ + var statusesState by mutableStateOf>(emptyMap()) + private set + + var activeAgentId by mutableStateOf(null) + private set + + var activeConversationId by mutableStateOf(null) + private set + + var conversations by mutableStateOf>(emptyList()) + private set + + var groups by mutableStateOf>(emptyList()) + private set + + var history by mutableStateOf>(emptyList()) + private set + + var live by mutableStateOf>(emptyList()) + private set + + /** true, пока идёт активный ход (в т.ч. до первого токена — спиннер). */ + var thinking by mutableStateOf(false) + private set + + /** Последняя ошибка для баннера. Сбрасывается [clearError]. */ + var error by mutableStateOf(null) + private set + + /** Состояние голосового ввода (кнопка микрофона). */ + var voiceState by mutableStateOf(VoiceState.IDLE) + private set + + /** Промежуточный текст распознавания — показываем под композером. */ + var voicePartial by mutableStateOf(null) + private set + + /** Черновик композера: сюда же дописывается распознанная речь. */ + var composerDraft by mutableStateOf("") + private set + + private var session: ChatSession? = null + private var sessionJob: Job? = null + private var listJob: Job? = null + private var conversationsJob: Job? = null + + /** + * Голосовой ввод. Итоговый текст дописывается в черновик композера + * (не отправляется сам — решает пользователь). + */ + private val voice = VoiceController( + scope = scope, + onState = { voiceState = it }, + onPartial = { voicePartial = it }, + onFinal = { text -> + voicePartial = null + composerDraft = appendToDraft(composerDraft, text) + }, + onError = { error = it }, + ) + + /** Конфиг активного агента. */ + val activeAgent: AgentConfig? + get() = settingsState.agents.firstOrNull { it.id == activeAgentId } + + /** Статус активного агента. */ + val activeStatus: AgentStatus? + get() = activeAgentId?.let { statusesState[it] } + + /** Каталог агентов. */ + val agents: List get() = settingsState.agents + + /** + * Отображаемое имя агента: локальное переименование → серверное + * `AgentInfo.name` → адрес (пока не подключились). + */ + fun agentDisplayName(config: AgentConfig): String = + config.nameOverride + ?: registry.connection(config.id)?.info?.name + ?: config.baseUrl + + /** Цвет агента для полоски/аватара (пользовательский, не из темы). */ + fun agentColor(config: AgentConfig): androidx.compose.ui.graphics.Color = + pw.binom.agentik.desktop.ui.theme.AgentColors.ofIndex(config.colorIndex) + + + // ───── lifecycle ───── + + /** Загружает настройки, подключает агентов и выбирает первого. */ + fun start() { + scope.launch { registry.settings.collect { settingsState = it } } + scope.launch { registry.statuses.collect { statusesState = it } } + scope.launch { + val s = registry.load() + registry.connectAll() + s.agents.firstOrNull()?.let { selectAgent(it.id) } + } + } + + fun clearError() { + error = null + } + + /** + * Показать ошибку в баннере. Отмену корутин (`CancellationException`) + * молча игнорируем: она приходит штатно, когда мы сами отменяем + * предыдущий job (например, [refresh] поверх [refresh]), и + * «StandaloneCoroutine was cancelled» в баннере — не ошибка пользователя. + */ + private fun reportError(t: Throwable, prefix: String? = null) { + if (t is CancellationException) return + val text = t.message ?: t::class.simpleName ?: "Ошибка" + error = if (prefix == null) text else "$prefix: $text" + } + + // ───── agents ───── + + fun selectAgent(agentId: String) { + if (activeAgentId == agentId) return + closeSession() + activeAgentId = agentId + activeConversationId = null + conversations = emptyList() + groups = emptyList() + history = emptyList() + live = emptyList() + watchConversations(agentId) + refresh() + } + + /** + * Подписаться на живой список диалогов агента. Одноразового чтения мало: + * seed в `AgentikAgent` асинхронный, а новые/переименованные диалоги + * приходят outbox-событиями (см. [AgentConnection.conversationsFlow]). + */ + private fun watchConversations(agentId: String) { + conversationsJob?.cancel() + val conn = registry.connection(agentId) + if (conn == null) { + conversations = emptyList() + return + } + conversationsJob = scope.launch { + conn.conversationsFlow.collect { conversations = it } + } + } + + fun addAgent(baseUrl: String, nameOverride: String? = null, colorIndex: Int = 0) { + if (baseUrl.isBlank()) { + error = "Укажите адрес агента" + return + } + scope.launch { + val cfg = AgentConfig.create(baseUrl = baseUrl, nameOverride = nameOverride, colorIndex = colorIndex) + runCatching { registry.addAgent(cfg) }.onFailure { reportError(it) } + selectAgent(cfg.id) + } + } + + fun updateAgent(config: AgentConfig) { + scope.launch { + runCatching { registry.updateAgent(config) }.onFailure { reportError(it) } + refresh() + } + } + + fun removeAgent(agentId: String) { + scope.launch { + if (activeAgentId == agentId) { + closeSession() + conversationsJob?.cancel() + activeAgentId = null + conversations = emptyList() + } + runCatching { registry.removeAgent(agentId) }.onFailure { reportError(it) } + settingsState.agents.firstOrNull()?.let { selectAgent(it.id) } + } + } + + fun reconnect(agentId: String) { + scope.launch { + runCatching { registry.reconnect(agentId) }.onFailure { reportError(it) } + refresh() + } + } + + /** Проверка связи для формы агента — вызывается UI из своей корутины. */ + suspend fun checkConnection(config: AgentConfig): ConnectionReport = + runCatching { checker.check(config, settingsState.clientId) } + .getOrElse { ConnectionReport(config.baseUrl, listOf(CheckStep("Ответ сервера", false, 0, it.message))) } + + // ───── conversations ───── + + fun refresh() { + val conn = activeAgentId?.let { registry.connection(it) } ?: return + listJob?.cancel() + listJob = scope.launch { + runCatching { + conversations = conn.uiConversations() + groups = conn.meta.listGroups() + }.onFailure { reportError(it) } + } + } + + fun openConversation(id: String) { + val conn = activeAgentId?.let { registry.connection(it) } ?: return + closeSession() + // Черновик и голос — принадлежат открытому диалогу, не переносим. + composerDraft = "" + cancelVoice() + activeConversationId = id + history = emptyList() + live = emptyList() + thinking = false + sessionJob = scope.launch { + val s = runCatching { conn.openSession(id) }.getOrNull() + if (s == null) { + error = "Диалог не найден на агенте" + return@launch + } + session = s + launch { s.history.collect { history = it } } + launch { s.live.collect { live = it } } + launch { s.phase.collect { thinking = it.isStreaming } } + runCatching { s.start() } + runCatching { s.markSeen() } + registry.updateSettings { it.copy(lastConversationId = id) } + refresh() + } + } + + /** Закрыть открытый диалог (кнопка «назад» в узкой раскладке). */ + fun closeActiveConversation() { + closeSession() + activeConversationId = null + history = emptyList() + live = emptyList() + thinking = false + composerDraft = "" + cancelVoice() + } + + /** + * Создать диалог у конкретного агента (модалка «Новый диалог» при + * нескольких ассистентах). Переключает активного агента, если нужно. + */ + fun newConversation(agentId: String) { + if (agentId != activeAgentId) selectAgent(agentId) + val conn = registry.connection(agentId) ?: return + scope.launch { + val conv = runCatching { conn.createConversation() }.getOrNull() + if (conv == null) { + error = "Не удалось создать диалог" + return@launch + } + refresh() + openConversation(conv.id) + } + } + + /** Создать диалог у активного агента (быстрый путь при одном ассистенте). */ + fun newConversation() { + activeAgentId?.let { newConversation(it) } + } + + fun deleteConversation(id: String) { + val conn = activeAgentId?.let { registry.connection(it) } ?: return + scope.launch { + val ok = runCatching { conn.deleteConversation(id) }.getOrDefault(false) + if (!ok) { + error = "Не удалось удалить диалог" + return@launch + } + if (activeConversationId == id) { + closeSession() + activeConversationId = null + history = emptyList() + live = emptyList() + } + refresh() + } + } + + fun renameConversation(id: String, title: String?) { + val conn = activeAgentId?.let { registry.connection(it) } ?: return + scope.launch { + runCatching { conn.renameConversation(id, title) }.onFailure { reportError(it) } + refresh() + } + } + + // ───── groups ───── + + fun createGroup(name: String) { + val conn = activeAgentId?.let { registry.connection(it) } ?: return + if (name.isBlank()) return + scope.launch { + runCatching { conn.meta.createGroup(name.trim()) }.onFailure { reportError(it, "Группа не создана") } + refresh() + } + } + + fun renameGroup(id: Long, name: String) { + val conn = activeAgentId?.let { registry.connection(it) } ?: return + if (name.isBlank()) return + scope.launch { + runCatching { conn.meta.renameGroup(id, name.trim()) }.onFailure { reportError(it) } + refresh() + } + } + + fun deleteGroup(id: Long) { + val conn = activeAgentId?.let { registry.connection(it) } ?: return + scope.launch { + runCatching { conn.meta.deleteGroup(id) }.onFailure { reportError(it) } + refresh() + } + } + + /** Положить диалог в группу (`null` — убрать из группы). */ + fun setConversationGroup(convId: String, groupId: Long?) { + val conn = activeAgentId?.let { registry.connection(it) } ?: return + scope.launch { + runCatching { conn.meta.setGroup(convId, groupId) }.onFailure { reportError(it) } + refresh() + } + } + + // ───── chat actions ───── + + fun send(text: String) { + val s = session ?: return + if (text.isBlank()) return + scope.launch { + runCatching { s.send(text) }.onFailure { reportError(it, "Не отправлено") } + refresh() + } + } + + // ───── composer / voice ───── + + fun onDraftChange(text: String) { + composerDraft = text + } + + /** Отправить то, что набрано в композере (кнопка/Enter). */ + fun sendDraft() { + val body = composerDraft.trim() + if (body.isEmpty()) return + composerDraft = "" + voicePartial = null + voice.cancel() + send(body) + } + + /** Кнопка микрофона: начать/остановить голосовой ввод. */ + fun toggleVoice() { + if (!voiceAvailable) { + error = "Голосовой ввод недоступен: не удалось загрузить модель распознавания" + return + } + voice.toggle() + } + + /** Прервать запись, не отдавая текст. */ + fun cancelVoice() { + voicePartial = null + voice.cancel() + } + + /** Дописать распознанное к черновику (без склейки слов). */ + private fun appendToDraft(draft: String, add: String): String { + val left = draft.trimEnd() + return if (left.isEmpty()) add else "$left $add" + } + + fun stop() { + val s = session ?: return + scope.launch { runCatching { s.interrupt() } } + } + + private fun closeSession() { + sessionJob?.cancel() + sessionJob = null + session?.let { runCatching { it.close() } } + session = null + } + + /** Закрыть сессию и подключения (при выходе из приложения). */ + fun shutdown() { + conversationsJob?.cancel() + voice.close() + closeSession() + listJob?.cancel() + registry.close() + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/ChatImage.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/ChatImage.kt new file mode 100644 index 0000000..ff417f9 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/ChatImage.kt @@ -0,0 +1,123 @@ +package pw.binom.agentik.desktop.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BrokenImage +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import pw.binom.agentik.desktop.ui.ClientImageSupport +import java.util.Base64 + +private enum class ImageLoadState { Loading, Ready, Error } + +/** + * Картинка в сообщении: src — data URL (`data:image/...;base64,...`), + * относительный путь к нашему серверу (`/media/zulip/...` — Zulip-прокси) + * или http(s) URL. Загрузка/декодирование — через [ClientImageSupport]; + * если decoder не зарегистрирован — показываем alt/ссылку текстом. + */ +@Composable +fun ChatImage(src: String, alt: String?) { + val decoder = ClientImageSupport.decoder + if (decoder == null) { + // Декодер не зарегистрирован — fallback: alt (или ссылка) текстом. + Text( + text = alt?.takeIf { it.isNotBlank() } ?: src, + modifier = Modifier.widthIn(max = 280.dp), + ) + return + } + + var bitmap by remember(src) { mutableStateOf(null) } + var state by remember(src) { mutableStateOf(ImageLoadState.Loading) } + + LaunchedEffect(src) { + state = ImageLoadState.Loading + bitmap = null + val bytes = withContext(Dispatchers.IO) { loadImageBytes(src) } + val decoded = bytes?.let { decoder(it) } + if (decoded != null) { + bitmap = decoded + state = ImageLoadState.Ready + } else { + state = ImageLoadState.Error + } + } + + when (state) { + ImageLoadState.Loading -> + Box( + modifier = Modifier.size(120.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + + ImageLoadState.Error -> + Box( + modifier = Modifier.size(120.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.BrokenImage, + contentDescription = "Ошибка загрузки картинки", + ) + } + + ImageLoadState.Ready -> + bitmap?.let { bmp -> + Image( + bitmap = bmp, + contentDescription = alt, + modifier = + Modifier + .widthIn(max = 280.dp) + .heightIn(max = 280.dp), + contentScale = ContentScale.Fit, + ) + } + } +} + +/** + * Возвращает байты картинки: для data URL — декодирует base64 после + * `base64,`; для относительного пути (`/media/zulip/...`) достраивает + * полный URL через [ClientImageSupport.serverBaseUrl]; для http(s) — + * качает через [ClientImageSupport.fetcher] (или дефолтный загрузчик). + */ +private fun loadImageBytes(src: String): ByteArray? { + if (src.startsWith("data:image/")) { + val base64Idx = src.indexOf("base64,") + if (base64Idx < 0) return null + val data = src.substring(base64Idx + "base64,".length) + return runCatching { Base64.getDecoder().decode(data) }.getOrNull() + } + val resolved = + if (src.startsWith("/")) { + val base = ClientImageSupport.serverBaseUrl + if (base == null) return null + base.trimEnd('/') + src + } else { + src + } + val fetcher = ClientImageSupport.fetcher ?: ClientImageSupport::defaultFetcher + return fetcher(resolved) +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/ClientImageSupport.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/ClientImageSupport.kt new file mode 100644 index 0000000..7daaa69 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/ClientImageSupport.kt @@ -0,0 +1,50 @@ +package pw.binom.agentik.desktop.ui + +import androidx.compose.ui.graphics.ImageBitmap +import java.net.HttpURLConnection +import java.net.URL + +/** + * Регистрируемые реализации декодирования и загрузки картинок для общего UI. + * Платформенные клиенты регистрируют декодер при старте: + * - android-app: `BitmapFactory.decodeByteArray(...)?.asImageBitmap()`; + * - client (десктоп): `org.jetbrains.skia.Image.makeFromEncoded(...)?.toComposeImageBitmap()`. + * + * Если decoder не зарегистрирован — картинки не показываются (fallback на текст). + */ +object ClientImageSupport { + /** Декодирует байты картинки в ImageBitmap. Регистрируется клиентом. */ + var decoder: ((ByteArray) -> ImageBitmap?)? = null + + /** + * Скачивает байты по URL. Регистрируется клиентом (или дефолт на + * java.net.HttpURLConnection — работает на JVM и Android). + */ + var fetcher: ((String) -> ByteArray?)? = null + + /** + * Base URL чат-сервера, видимый клиентом (например `http://10.0.2.2:8643` + * на эмуляторе). Заполняется клиентом при старте. Нужен для относительных + * src картинок (`/media/zulip/...`) — клиент достраивает полный URL. + */ + var serverBaseUrl: String? = null + + /** Дефолтный загрузчик: GET, таймаут 15 c, readBytes. */ + fun defaultFetcher(url: String): ByteArray? = try { + val conn = (URL(url).openConnection() as? HttpURLConnection) ?: return null + conn.connectTimeout = 15_000 + conn.readTimeout = 15_000 + conn.requestMethod = "GET" + try { + if (conn.responseCode in 200..299) { + conn.inputStream.use { it.readBytes() } + } else { + null + } + } finally { + conn.disconnect() + } + } catch (_: Exception) { + null + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/MarkdownRenderer.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/MarkdownRenderer.kt new file mode 100644 index 0000000..73d7d99 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/MarkdownRenderer.kt @@ -0,0 +1,25 @@ +package pw.binom.agentik.desktop.ui + +import androidx.compose.ui.text.SpanStyle +import pw.binom.agentik.desktop.markdown.MarkdownSegment +import pw.binom.agentik.desktop.markdown.parseMarkdown +import java.awt.Desktop +import java.net.URI + +/** + * Desktop-specific markdown utilities. + * Compose rendering lives in [pw.binom.agentik.desktop.ui.renderMarkdownSegments]. + */ +object MarkdownRenderer { + fun parseMultiline( + markdown: String, + baseStyle: SpanStyle = SpanStyle(), + ): List = parseMarkdown(markdown) + + fun openUrl(url: String) { + try { + Desktop.getDesktop().browse(URI(url)) + } catch (_: Exception) { + } + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/MarkdownRendering.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/MarkdownRendering.kt new file mode 100644 index 0000000..a4956f6 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/MarkdownRendering.kt @@ -0,0 +1,382 @@ +package pw.binom.agentik.desktop.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import pw.binom.agentik.desktop.ui.theme.AgentikColors +import pw.binom.agentik.desktop.ui.theme.AgentikTheme +import pw.binom.agentik.desktop.ui.theme.darkAgentikColors +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import pw.binom.agentik.desktop.markdown.MarkdownSegment +import pw.binom.agentik.desktop.markdown.TextStyleSpan +import pw.binom.agentik.desktop.markdown.parseMarkdown + +// ───────────────────────────────────────────────────────────── +// Public rendering entry point +// ───────────────────────────────────────────────────────────── + +/** + * Parse markdown and render segments with the given base text style. + */ +@Composable +fun renderMarkdownSegments( + content: String, + textColor: Color, + fontSize: TextUnit, + isBubbleScrolled: Boolean = false, + onUrlClick: (String) -> Unit = {}, +) { + val baseStyle = SpanStyle(color = textColor, fontSize = fontSize) + val colors = AgentikTheme.colors + val segments = parseMarkdown(content) + var previousWasText = false + for (segment in segments) { + when (segment) { + is MarkdownSegment.Text -> { + val annotated = segment.toAnnotatedString(baseStyle, colors) + if (annotated.text.isNotEmpty()) { + // Между соседними абзацами (последовательными текстовыми сегментами) + // оставляем вертикальный отступ, иначе абзацы склеиваются в сплошной блок. + if (previousWasText) { + Spacer(modifier = Modifier.height(12.dp)) + } + clickableMarkdownText( + annotatedString = annotated, + modifier = Modifier + .then(if (isBubbleScrolled) Modifier.widthIn(max = 600.dp) else Modifier), + onUrlClick = onUrlClick, + ) + previousWasText = true + } + } + is MarkdownSegment.CodeBlock -> { + previousWasText = false + val clipboardManager = LocalClipboardManager.current + Spacer(modifier = Modifier.height(4.dp)) + Column( + modifier = Modifier + .then( + if (isBubbleScrolled) Modifier.widthIn(max = 600.dp) + else Modifier.fillMaxWidth() + ) + .clip(RoundedCornerShape(8.dp)) + .background(colors.codeBackground), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(AgentikTheme.colors.panelElevated) + .padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = segment.language ?: "Code", + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = colors.textDim, + ) + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Скопировать", + modifier = Modifier + .size(16.dp) + .clickable { + clipboardManager.setText(AnnotatedString(segment.code)) + }, + tint = colors.textDim, + ) + } + Box( + modifier = Modifier + .padding(12.dp) + .horizontalScroll(rememberScrollState()), + ) { + Text( + text = segment.code, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + color = colors.codeText, + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + } + is MarkdownSegment.Table -> { + previousWasText = false + renderTable(segment, textColor, fontSize, isBubbleScrolled) + } + is MarkdownSegment.Image -> { + previousWasText = false + Spacer(modifier = Modifier.height(4.dp)) + ChatImage(src = segment.src, alt = segment.alt) + Spacer(modifier = Modifier.height(4.dp)) + } + is MarkdownSegment.ThematicBreak -> { + previousWasText = false + Spacer(modifier = Modifier.height(4.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f)), + ) + Spacer(modifier = Modifier.height(4.dp)) + } + } + } +} + +// ───────────────────────────────────────────────────────────── +// Table rendering +// ───────────────────────────────────────────────────────────── + +@Composable +private fun renderTable( + segment: MarkdownSegment.Table, + textColor: Color, + fontSize: TextUnit, + isBubbleScrolled: Boolean, +) { + Spacer(modifier = Modifier.height(6.dp)) + + val columnWidths = remember(segment) { + val columnCount = segment.headers.size + val widths = MutableList(columnCount) { 0 } + segment.headers.forEachIndexed { i, header -> widths[i] = maxOf(widths[i], header.length) } + segment.rows.forEach { row -> + row.forEachIndexed { i, cell -> + if (i < columnCount) widths[i] = maxOf(widths[i], cell.length) + } + } + widths + } + + // Широкие колонки (длинный текст в ячейках, напр. «Почему это оно» ~200 символов) + // не должны обрезаться. Решение зависит от того, есть ли внешний горизонтальный + // скролл (десктоп оборачивает пузырь с таблицей в Modifier.horizontalScroll): + // - isBubbleScrolled=true: таблица получает ширину по содержимому, а колонки, + // выходящие за край, прокручивает ВНЕШНИЙ скролл пузыря; + // - isBubbleScrolled=false (Android): добавляем горизонтальный скролл внутрь таблицы. + // ВАЖНО: нельзя класть Box(horizontalScroll) внутрь контейнера с бесконечным maxWidth + // (wrapContentWidth) — Compose падает с "measured with an infinity maximum width". + Column( + modifier = Modifier + .then(if (isBubbleScrolled) Modifier.wrapContentWidth() else Modifier.fillMaxWidth()) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.3f)) + ) { + if (isBubbleScrolled) { + TableRows( + segment = segment, + columnWidths = columnWidths, + textColor = textColor, + fontSize = fontSize, + ) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + ) { + Column { + TableRows( + segment = segment, + columnWidths = columnWidths, + textColor = textColor, + fontSize = fontSize, + ) + } + } + } + } + Spacer(modifier = Modifier.height(6.dp)) +} + +@Composable +private fun TableRows( + segment: MarkdownSegment.Table, + columnWidths: List, + textColor: Color, + fontSize: TextUnit, +) { + // Header row + TableRow( + cells = segment.headers, + widths = columnWidths, + isHeader = true, + textColor = textColor, + fontSize = fontSize, + ) + // Data rows + segment.rows.forEachIndexed { idx, row -> + TableRow( + cells = row, + widths = columnWidths, + isHeader = false, + textColor = textColor, + fontSize = fontSize, + rowIndex = idx, + ) + } +} + +@Composable +private fun TableRow( + cells: List, + widths: List, + isHeader: Boolean, + textColor: Color, + fontSize: TextUnit, + rowIndex: Int = 0, +) { + val bgColor = when { + isHeader -> MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + rowIndex % 2 == 0 -> Color.Transparent + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.03f) + } + Row( + modifier = Modifier + .fillMaxWidth() + .background(bgColor) + ) { + cells.forEachIndexed { i, cell -> + Box( + modifier = Modifier + .width((widths.getOrElse(i) { 10 } * 9).dp.coerceAtLeast(60.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp), + ) { + Text( + text = cell, + color = textColor, + fontSize = fontSize, + fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal, + softWrap = true, + ) + } + } + } +} + +// ───────────────────────────────────────────────────────────── +// Clickable markdown text (detects URL and inline-code taps) +// ───────────────────────────────────────────────────────────── + +@Composable +private fun clickableMarkdownText( + annotatedString: AnnotatedString, + modifier: Modifier = Modifier, + onUrlClick: (String) -> Unit = {}, +) { + val urlAnnotations = annotatedString.getStringAnnotations("URL", 0, annotatedString.length) + val codeAnnotations = annotatedString.getStringAnnotations("INLINE_CODE", 0, annotatedString.length) + val hasClickables = urlAnnotations.isNotEmpty() || codeAnnotations.isNotEmpty() + + if (hasClickables) { + val clipboardManager = LocalClipboardManager.current + var textLayoutResult by remember { mutableStateOf(null) } + Text( + text = annotatedString, + modifier = modifier.pointerInput(annotatedString) { + detectTapGestures { offset -> + val layout = textLayoutResult ?: return@detectTapGestures + val position = layout.getOffsetForPosition(offset) + annotatedString + .getStringAnnotations("INLINE_CODE", position, position) + .firstOrNull() + ?.let { clipboardManager.setText(AnnotatedString(it.item)) } + ?: annotatedString + .getStringAnnotations("URL", position, position) + .firstOrNull() + ?.let { onUrlClick(it.item) } + } + }, + onTextLayout = { textLayoutResult = it }, + ) + } else { + Text(text = annotatedString, modifier = modifier) + } +} + +// ───────────────────────────────────────────────────────────── +// Convert MarkdownSegment.Text to Compose AnnotatedString +// ───────────────────────────────────────────────────────────── + +fun MarkdownSegment.Text.toAnnotatedString( + baseStyle: SpanStyle = SpanStyle(), + colors: AgentikColors = darkAgentikColors, +): AnnotatedString { + val builder = AnnotatedString.Builder(text) + + for (span in spans) { + val style = buildSpanStyle(span, colors) + val merged = baseStyle.merge(style) + builder.addStyle(merged, span.start, span.end) + val link = span.link + if (link != null) { + builder.addStringAnnotation("URL", link, span.start, span.end) + } + } + return builder.toAnnotatedString() +} + +private fun buildSpanStyle(span: TextStyleSpan, colors: AgentikColors): SpanStyle { + var style = SpanStyle() + if (span.bold) style = style.copy(fontWeight = FontWeight.Bold) + if (span.italic) style = style.copy(fontStyle = androidx.compose.ui.text.font.FontStyle.Italic) + if (span.code) { + style = style.copy( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + background = colors.panelElevated, + ) + } + if (span.strikethrough) style = style.copy(textDecoration = androidx.compose.ui.text.style.TextDecoration.LineThrough) + if (span.link != null) { + style = style.copy( + color = colors.accent, + textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline, + ) + } + return style +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/components/Overlay.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/components/Overlay.kt new file mode 100644 index 0000000..6ee5b4b --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/components/Overlay.kt @@ -0,0 +1,31 @@ +package pw.binom.agentik.desktop.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import pw.binom.agentik.desktop.ui.theme.AgentikTheme + +/** + * Модальный слой поверх окна (не отдельное окно ОС): затемнение + карточка + * по центру. Клик по затемнению закрывает; клики внутри карточки — нет. + */ +@Composable +fun Overlay(onDismiss: () -> Unit, content: @Composable () -> Unit) { + val colors = AgentikTheme.colors + Box( + modifier = Modifier + .fillMaxSize() + .background(colors.scrim) + .pointerInput(Unit) { detectTapGestures { onDismiss() } }, + contentAlignment = Alignment.Center, + ) { + Box(modifier = Modifier.pointerInput(Unit) { detectTapGestures { } }) { + content() + } + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/components/Ui.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/components/Ui.kt new file mode 100644 index 0000000..60beba0 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/components/Ui.kt @@ -0,0 +1,127 @@ +package pw.binom.agentik.desktop.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import pw.binom.agentik.desktop.media.AvatarStorage +import pw.binom.agentik.desktop.ui.theme.AgentColors +import java.time.Instant as JavaInstant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.time.Instant + +/** Доступ к [AvatarStorage] из глубины дерева — чтобы не тащить его параметром. */ +val LocalAvatarStorage = staticCompositionLocalOf { error("AvatarStorage не предоставлен") } + +/** + * Кружок ассистента/диалога: картинка из настроек, иначе инициалы на + * пользовательском [color]. Цвет — не из темы (THEMES.md), поэтому текст + * на нём подбирается по яркости ([AgentColors.contrastText]). + */ +@Composable +fun AgentAvatar( + displayName: String, + color: Color, + avatarPath: String?, + modifier: Modifier = Modifier, + size: Dp = 36.dp, +) { + val storage = LocalAvatarStorage.current + val bytes by produceState(initialValue = null, avatarPath) { + value = if (avatarPath.isNullOrBlank()) null else storage.read(avatarPath) + } + val bitmap: ImageBitmap? = remember(bytes) { + bytes?.let { runCatching { org.jetbrains.skia.Image.makeFromEncoded(it).toComposeImageBitmap() }.getOrNull() } + } + + if (bitmap != null) { + Image( + bitmap = bitmap, + contentDescription = displayName, + contentScale = ContentScale.Crop, + modifier = modifier.size(size).clip(CircleShape), + ) + } else { + Box( + modifier = modifier.size(size).clip(CircleShape).background(color), + contentAlignment = Alignment.Center, + ) { + Text( + text = initials(displayName), + color = AgentColors.contrastText(color), + fontWeight = FontWeight.Bold, + fontSize = (size.value * 0.34f).sp, + ) + } + } +} + +/** Кружок непрочитанных с числом. */ +@Composable +fun UnreadBadge(count: Int, background: Color, contentColor: Color) { + if (count <= 0) return + Box( + modifier = Modifier + .clip(RoundedCornerShape(11.dp)) + .background(background) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) { + Text(text = count.toString(), color = contentColor, fontSize = 10.5.sp, fontWeight = FontWeight.Bold) + } +} + +/** Первые буквы слов имени, максимум две. */ +fun initials(name: String): String { + val parts = name.trim().split(Regex("[\\s·._-]+")).filter { it.isNotBlank() } + return when { + parts.isEmpty() -> "?" + parts.size == 1 -> parts[0].take(2).uppercase() + else -> (parts[0].take(1) + parts[1].take(1)).uppercase() + } +} + +private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") +private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM") +private val weekdays = listOf("пн", "вт", "ср", "чт", "пт", "сб", "вс") + +/** + * Подпись времени в строке списка диалогов: сегодня — `HH:mm`, + * вчера — «вчера», на этой неделе — короткий день недели, иначе — `dd.MM`. + */ +fun formatListTime(instant: Instant, now: Instant = kotlin.time.Clock.System.now()): String { + val zone = ZoneId.systemDefault() + val dt = JavaInstant.ofEpochMilli(instant.toEpochMilliseconds()).atZone(zone) + val today = JavaInstant.ofEpochMilli(now.toEpochMilliseconds()).atZone(zone).toLocalDate() + val date = dt.toLocalDate() + return when { + date == today -> timeFormatter.format(dt) + date == today.minusDays(1) -> "вчера" + date.isAfter(today.minusDays(7)) -> weekdays[date.dayOfWeek.value - 1] + else -> dateFormatter.format(dt) + } +} + +/** Короткое время сообщения (`HH:mm`). */ +fun formatMessageTime(instant: Instant): String = + timeFormatter.format(JavaInstant.ofEpochMilli(instant.toEpochMilliseconds()).atZone(ZoneId.systemDefault())) diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/AgentsDialog.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/AgentsDialog.kt new file mode 100644 index 0000000..abe7d8a --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/AgentsDialog.kt @@ -0,0 +1,445 @@ +package pw.binom.agentik.desktop.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import pw.binom.agentik.desktop.net.ConnectionReport +import pw.binom.agentik.desktop.session.AgentStatus +import pw.binom.agentik.desktop.settings.AgentConfig +import pw.binom.agentik.desktop.ui.AppState +import pw.binom.agentik.desktop.ui.components.AgentAvatar +import pw.binom.agentik.desktop.ui.theme.AgentColors +import pw.binom.agentik.desktop.ui.theme.AgentikTheme + +private sealed interface AgentMode { + data object List : AgentMode + data object Add : AgentMode + data class Edit(val config: AgentConfig) : AgentMode + data class Check(val config: AgentConfig, val report: ConnectionReport? = null) : AgentMode +} + +/** + * Окно «Агенты»: список карточек (создать/изменить/удалить), формы + * добавления/правки и поповер проверки связи. Имя и описание берутся с + * сервера (`AgentInfo`), на клиенте настраивается только адрес и метка — + * см. `sketches/index.html` секции 10–13. + */ +@Composable +fun AgentsDialog(state: AppState, onClose: () -> Unit) { + val colors = AgentikTheme.colors + var mode by remember { mutableStateOf(AgentMode.List) } + + DialogCard(width = 520.dp) { + when (val m = mode) { + AgentMode.List -> ListMode( + state = state, + onClose = onClose, + onAdd = { mode = AgentMode.Add }, + onEdit = { mode = AgentMode.Edit(it) }, + onCheck = { mode = AgentMode.Check(it) }, + ) + + AgentMode.Add -> AgentForm( + title = "Новый агент", + hint = "Имя и описание берутся с сервера агента автоматически. " + + "Здесь настраивается только адрес и визуальная метка.", + confirmLabel = "Добавить агента", + initial = null, + onCancel = { mode = AgentMode.List }, + onCheck = { mode = AgentMode.Check(it) }, + onConfirm = { config -> + state.addAgent( + baseUrl = config.baseUrl, + nameOverride = config.nameOverride, + colorIndex = config.colorIndex, + ) + onClose() + }, + ) + + is AgentMode.Edit -> AgentForm( + title = "Изменить агента", + hint = "Имя и описание задаются сервером и здесь недоступны. " + + "Можно изменить адрес и визуальную метку.", + confirmLabel = "Сохранить", + initial = m.config, + onCancel = { mode = AgentMode.List }, + onCheck = { mode = AgentMode.Check(it) }, + onConfirm = { config -> + state.updateAgent(config) + mode = AgentMode.List + }, + ) + + is AgentMode.Check -> CheckMode( + state = state, + config = m.config, + initialReport = m.report, + onBack = { mode = AgentMode.List }, + ) + } + } +} + +// ───────── list ───────── + +@Composable +private fun ListMode( + state: AppState, + onClose: () -> Unit, + onAdd: () -> Unit, + onEdit: (AgentConfig) -> Unit, + onCheck: (AgentConfig) -> Unit, +) { + val colors = AgentikTheme.colors + DialogHeader("Агенты", onClose) + Column( + modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 18.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + state.agents.forEach { config -> + AgentCard( + state = state, + config = config, + onCheck = { onCheck(config) }, + onEdit = { onEdit(config) }, + onDelete = { state.removeAgent(config.id) }, + ) + } + if (state.agents.isEmpty()) { + Text( + "Пока ни одного агента. Добавьте адрес сервера agentik.", + color = colors.textDim, + fontSize = 13.sp, + modifier = Modifier.padding(vertical = 12.dp), + ) + } + TextButton(onClick = onAdd, modifier = Modifier.fillMaxWidth()) { Text("+ Добавить агента") } + Spacer(Modifier.height(4.dp)) + } + DialogFooter { + Button(onClick = onClose, modifier = Modifier.weight(1f)) { Text("Готово") } + } +} + +@Composable +private fun AgentCard( + state: AppState, + config: AgentConfig, + onCheck: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + val colors = AgentikTheme.colors + val status = state.statusesState[config.id] + val desc = state.registryInfoDescription(config) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(colors.panelElevated) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + AgentAvatar( + displayName = state.agentDisplayName(config), + color = state.agentColor(config), + avatarPath = config.avatarPath, + size = 42.dp, + ) + Column(Modifier.weight(1f)) { + Text(state.agentDisplayName(config), color = colors.text, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold) + if (desc != null) { + Text(desc, color = colors.textDim, fontSize = 11.5.sp, maxLines = 2) + } + Text(config.baseUrl, color = colors.textDim, fontSize = 11.sp, fontFamily = FontFamily.Monospace) + Spacer(Modifier.height(2.dp)) + StatusLine(status) + } + IconButton(onClick = onCheck) { + Icon(Icons.Default.Refresh, contentDescription = "Проверить связь", tint = colors.textDim, modifier = Modifier.size(18.dp)) + } + IconButton(onClick = onEdit) { + Icon(Icons.Default.Edit, contentDescription = "Изменить", tint = colors.textDim, modifier = Modifier.size(18.dp)) + } + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, contentDescription = "Удалить", tint = colors.error, modifier = Modifier.size(18.dp)) + } + } +} + +@Composable +private fun StatusLine(status: AgentStatus?) { + val colors = AgentikTheme.colors + val (text, color) = when (status) { + is AgentStatus.Online -> "на связи" to colors.ok + is AgentStatus.Offline -> "не отвечает" to colors.error + AgentStatus.Connecting -> "подключение…" to colors.textDim + null -> "не проверялся" to colors.textDim + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Box(Modifier.size(6.dp).clip(CircleShape).background(color)) + Text(text, color = color, fontSize = 11.sp) + } +} + +// ───────── form ───────── + +@Composable +private fun AgentForm( + title: String, + hint: String, + confirmLabel: String, + initial: AgentConfig?, + onCancel: () -> Unit, + onCheck: (AgentConfig) -> Unit, + onConfirm: (AgentConfig) -> Unit, +) { + val colors = AgentikTheme.colors + var baseUrl by remember { mutableStateOf(initial?.baseUrl ?: "") } + var nameOverride by remember { mutableStateOf(initial?.nameOverride ?: "") } + var colorIndex by remember { mutableStateOf(initial?.colorIndex ?: 0) } + + DialogHeader(title, onCancel) + Column( + modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Hint(hint) + + Text("Адрес сервера агента", color = colors.textDim, fontSize = 11.5.sp) + OutlinedTextField( + value = baseUrl, + onValueChange = { baseUrl = it }, + placeholder = { Text("http://хост:порт/agentik") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { + onCheck( + AgentConfig( + id = initial?.id ?: "probe", + baseUrl = baseUrl.trim(), + nameOverride = nameOverride.ifBlank { null }, + colorIndex = colorIndex, + ) + ) + }, + enabled = baseUrl.isNotBlank(), + ) { Text("Проверить связь") } + } + + Text("Как отличать в списке", color = colors.textDim, fontSize = 11.5.sp) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + AgentColors.defaults.forEachIndexed { index, color -> + Box( + modifier = Modifier + .size(26.dp) + .clip(CircleShape) + .background(color) + .border( + width = if (index == colorIndex) 2.dp else 0.dp, + color = if (index == colorIndex) colors.text else Color.Transparent, + shape = CircleShape, + ) + .clickable { colorIndex = index }, + ) + } + } + + OutlinedTextField( + value = nameOverride, + onValueChange = { nameOverride = it }, + label = { Text("Своё имя (необязательно)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(2.dp)) + } + DialogFooter { + TextButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("Отмена") } + Button( + onClick = { + onConfirm( + AgentConfig( + id = initial?.id ?: "", + baseUrl = baseUrl.trim(), + nameOverride = nameOverride.trim().ifBlank { null }, + colorIndex = colorIndex, + ) + ) + }, + enabled = baseUrl.isNotBlank(), + modifier = Modifier.weight(1f), + ) { Text(confirmLabel) } + } +} + +// ───────── check ───────── + +@Composable +private fun CheckMode( + state: AppState, + config: AgentConfig, + initialReport: ConnectionReport?, + onBack: () -> Unit, +) { + val colors = AgentikTheme.colors + val scope = rememberCoroutineScope() + var report by remember(config) { mutableStateOf(initialReport) } + + fun run() { + report = null + scope.launch { report = state.checkConnection(config) } + } + + androidx.compose.runtime.LaunchedEffect(config) { if (report == null) run() } + + DialogHeader("Проверка связи", onBack) + Column( + modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 18.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + val current = report + if (current == null) { + Row( + modifier = Modifier.padding(vertical = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = colors.accent) + Text("Проверяю…", color = colors.textDim, fontSize = 13.sp) + } + } else { + Text( + text = config.baseUrl, + color = colors.text, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + ) + current.steps.forEach { step -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(if (step.ok) "✓" else "✕", color = if (step.ok) colors.ok else colors.error, fontSize = 13.sp) + Spacer(Modifier.width(8.dp)) + Text(step.title, color = colors.text, fontSize = 12.5.sp, modifier = Modifier.weight(1f)) + Text("${step.millis} мс", color = colors.textDim, fontSize = 11.sp) + } + step.detail?.let { Text(it, color = colors.textDim, fontSize = 11.sp, modifier = Modifier.padding(start = 22.dp)) } + } + Hint( + if (current.ok) "Адрес рабочий. Можно добавлять этого агента." + else "Агент не ответил. Проверьте адрес и доступность сервера." + ) + } + Spacer(Modifier.height(2.dp)) + } + DialogFooter { + TextButton(onClick = { run() }, modifier = Modifier.weight(1f)) { Text("Проверить снова") } + Button(onClick = onBack, modifier = Modifier.weight(1f)) { Text("Готово") } + } +} + +// ───────── shared bits ───────── + +@Composable +internal fun DialogCard(width: androidx.compose.ui.unit.Dp, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) { + val colors = AgentikTheme.colors + Surface( + color = colors.panel, + shape = RoundedCornerShape(14.dp), + modifier = Modifier.widthIn(min = width, max = width).heightIn(max = 640.dp), + ) { + Column(modifier = Modifier.padding(vertical = 14.dp)) { this.content() } + } +} + +@Composable +internal fun DialogHeader(title: String, onClose: () -> Unit) { + val colors = AgentikTheme.colors + Row( + modifier = Modifier.fillMaxWidth().padding(start = 18.dp, end = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, color = colors.text, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = "Закрыть", tint = colors.textDim, modifier = Modifier.size(18.dp)) + } + } +} + +@Composable +internal fun DialogFooter(content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp, start = 18.dp, end = 18.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { this.content() } +} + +@Composable +internal fun Hint(text: String) { + val colors = AgentikTheme.colors + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(colors.panelElevated) + .padding(10.dp), + ) { + Text(text, color = colors.textDim, fontSize = 11.5.sp) + } +} + +/** Описание агента с сервера, если он онлайн. */ +@Composable +private fun AppState.registryInfoDescription(config: AgentConfig): String? = + (statusesState[config.id] as? AgentStatus.Online)?.connection?.info?.description diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/ChatPane.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/ChatPane.kt new file mode 100644 index 0000000..5ed4eaf --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/ChatPane.kt @@ -0,0 +1,290 @@ +package pw.binom.agentik.desktop.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material3.Icon +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import pw.binom.agentik.desktop.session.AgentStatus +import pw.binom.agentik.desktop.voice.VoiceState +import pw.binom.agentik.desktop.ui.AppState +import pw.binom.agentik.desktop.ui.components.AgentAvatar +import pw.binom.agentik.desktop.ui.theme.AgentikTheme + +/** + * Правая зона: шапка диалога, лента сообщений, композер. + * + * Голос: кнопка микрофона запускает запись (`mic-api`), распознавание идёт + * потоком (`asr-vosk` + `vad-kmp`), промежуточный текст показывается над полем, + * финал дописывается в черновик. + */ +@Composable +fun ChatPane( + state: AppState, + onBack: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + val colors = AgentikTheme.colors + val config = state.activeAgent + val agentColor = config?.let { state.agentColor(it) } ?: colors.accent + val agentName = config?.let { state.agentDisplayName(it) } ?: "" + + Column( + modifier = modifier + .fillMaxSize() + .background(colors.background) + // Ctrl+M — голосовой ввод, то же, что кнопка микрофона. + .onPreviewKeyEvent { e -> + if (e.type == KeyEventType.KeyDown && e.isCtrlPressed && e.key == Key.M) { + state.toggleVoice() + true + } else { + false + } + }, + ) { + // ── шапка ── + Row( + modifier = Modifier + .fillMaxWidth() + .height(60.dp) + .background(colors.panel) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (onBack != null) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад", tint = colors.textDim) + } + } + AgentAvatar( + displayName = agentName, + color = agentColor, + avatarPath = config?.avatarPath, + size = 36.dp, + ) + Column(Modifier.weight(1f)) { + Text(agentName, color = colors.text, fontSize = 14.sp) + ConnectionLabel(state.activeStatus) + } + if (state.thinking) { + IconButton(onClick = { state.stop() }) { + Text("Стоп", color = colors.error, fontSize = 12.5.sp) + } + } + } + HorizontalDivider(color = colors.line) + + // ── лента ── + if (config == null) { + CenteredHint("Выберите ассистента", colors.textDim) + } else { + MessageStream( + history = state.history, + live = state.live, + thinking = state.thinking, + agentColor = agentColor, + modifier = Modifier.weight(1f), + ) + Composer( + enabled = true, + draft = state.composerDraft, + onDraftChange = { state.onDraftChange(it) }, + onSend = { state.sendDraft() }, + voiceState = state.voiceState, + voicePartial = state.voicePartial, + voiceAvailable = state.voiceAvailable, + onMic = { state.toggleVoice() }, + ) + } + } +} + +@Composable +private fun ConnectionLabel(status: AgentStatus?) { + val colors = AgentikTheme.colors + val (text, color) = when (status) { + is AgentStatus.Online -> "на связи" to colors.ok + is AgentStatus.Offline -> "не отвечает" to colors.error + AgentStatus.Connecting -> "подключение…" to colors.textDim + null -> "не проверялся" to colors.textDim + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Box(Modifier.size(6.dp).clip(RoundedCornerShape(3.dp)).background(color)) + Text(text, color = color, fontSize = 11.5.sp) + } +} + +@Composable +private fun Composer( + enabled: Boolean, + draft: String, + onDraftChange: (String) -> Unit, + onSend: () -> Unit, + voiceState: VoiceState, + voicePartial: String?, + voiceAvailable: Boolean, + onMic: () -> Unit, +) { + val colors = AgentikTheme.colors + val text = draft + // Клик в любом месте бокса фокусирует поле: сам BasicTextField по высоте + // равен строке, и клик по его padding'у иначе уходит в Box. + val focusRequester = remember { FocusRequester() } + + fun submit() { + if (text.isNotBlank()) onSend() + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(colors.panel) + .padding(horizontal = 18.dp, vertical = 12.dp), + ) { + // Распознаваемая речь — над полем ввода, серым, пока не станет финалом. + if (voicePartial != null) { + Text( + voicePartial, + color = colors.textDim, + fontSize = 12.sp, + modifier = Modifier.padding(bottom = 6.dp), + ) + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(11.dp)) + .background(colors.background) + .border(1.dp, colors.line, RoundedCornerShape(11.dp)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { focusRequester.requestFocus() } + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + if (text.isEmpty()) { + Text("Написать агенту…", color = colors.textDim, fontSize = 13.sp) + } + BasicTextField( + value = text, + onValueChange = onDraftChange, + enabled = enabled, + textStyle = TextStyle(color = colors.text, fontSize = 13.sp), + cursorBrush = SolidColor(colors.accent), + maxLines = 6, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { submit() }), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + // Enter отправляет (подсказка под полем это обещает), + // Shift+Enter — перевод строки. В многострочном поле + // Enter по умолчанию вставляет \n и никуда не отправляет. + .onPreviewKeyEvent { e -> + if (e.type == KeyEventType.KeyDown && e.key == Key.Enter && !e.isShiftPressed) { + submit() + true + } else { + false + } + }, + ) + } + MicButton(voiceState, voiceAvailable, onMic) + IconButton( + onClick = { submit() }, + enabled = enabled && text.isNotBlank(), + ) { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = "Отправить", + tint = if (text.isNotBlank()) colors.accent else colors.textDim, + ) + } + } + Spacer(Modifier.height(6.dp)) + Text(composerHint(voiceState, voiceAvailable), color = colors.textDim, fontSize = 10.5.sp) + } +} + +/** + * Кнопка микрофона: цвет и подсказка зависят от состояния ввода. Если нативный + * ASR не прогрелся (нет библиотек/модели) — кнопка заблокирована. + */ +@Composable +private fun MicButton(voiceState: VoiceState, voiceAvailable: Boolean, onMic: () -> Unit) { + val colors = AgentikTheme.colors + val (tint, description) = when { + !voiceAvailable -> colors.textDim to "Голосовой ввод недоступен" + voiceState == VoiceState.IDLE -> colors.textDim to "Голосовой ввод" + voiceState == VoiceState.PREPARING -> colors.textDim to "Готовлю распознавание…" + voiceState == VoiceState.RECORDING -> colors.error to "Остановить запись" + else -> colors.accent to "Распознаю…" + } + IconButton( + onClick = onMic, + // Во время дочитывания финала повторное нажатие бессмысленно. + enabled = voiceAvailable && voiceState != VoiceState.RECOGNIZING, + ) { + Icon(Icons.Default.Mic, contentDescription = description, tint = tint) + } +} + +private fun composerHint(voiceState: VoiceState, voiceAvailable: Boolean): String = when { + !voiceAvailable -> "голос недоступен · отправка Enter" + voiceState == VoiceState.IDLE -> "голос — микрофон (Ctrl+M) · отправка Enter" + voiceState == VoiceState.PREPARING -> "голос: загружаю модель распознавания…" + voiceState == VoiceState.RECORDING -> "голос: идёт запись — нажмите микрофон, чтобы закончить" + else -> "голос: распознаю…" +} + +@Composable +private fun CenteredHint(text: String, color: androidx.compose.ui.graphics.Color) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text, color = color, fontSize = 14.sp) + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/DialogsList.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/DialogsList.kt new file mode 100644 index 0000000..e1dde65 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/DialogsList.kt @@ -0,0 +1,303 @@ +package pw.binom.agentik.desktop.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import pw.binom.agentik.desktop.model.UiConversation +import pw.binom.agentik.desktop.session.AgentStatus +import pw.binom.agentik.desktop.ui.AppState +import pw.binom.agentik.desktop.ui.components.AgentAvatar +import pw.binom.agentik.desktop.ui.components.UnreadBadge +import pw.binom.agentik.desktop.ui.components.formatListTime +import pw.binom.agentik.desktop.ui.theme.AgentikTheme + +/** + * Узкая вертикальная рейка ассистентов: переключение агента и вход в + * настройки агентов (`+`). Цвет каждого кружка — пользовательский цвет + * агента, не из темы. + */ +@Composable +fun AgentRail( + state: AppState, + onOpenAgents: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = AgentikTheme.colors + Column( + modifier = modifier + .fillMaxHeight() + .width(60.dp) + .background(colors.background) + .padding(vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.agents.forEach { config -> + val online = state.statusesState[config.id] is AgentStatus.Online + Box { + AgentAvatar( + displayName = state.agentDisplayName(config), + color = state.agentColor(config), + avatarPath = config.avatarPath, + size = 40.dp, + modifier = Modifier.clickable { state.selectAgent(config.id) }, + ) + if (config.id == state.activeAgentId) { + Box( + Modifier + .align(Alignment.BottomCenter) + .size(width = 22.dp, height = 3.dp) + .clip(RoundedCornerShape(2.dp)) + .background(state.agentColor(config)), + ) + } + if (!online) { + Box( + Modifier + .align(Alignment.TopEnd) + .size(9.dp) + .clip(RoundedCornerShape(5.dp)) + .background(colors.error), + ) + } + } + } + Spacer(Modifier.weight(1f)) + IconButton(onClick = onOpenAgents) { + Icon(Icons.Default.Settings, contentDescription = "Агенты", tint = colors.textDim) + } + } +} + +/** + * Панель диалогов: шапка с `+`, поиск, папки-группы и список диалогов. + * Показываются диалоги активного агента (groups — локальные для агента). + */ +@Composable +fun ConversationList( + state: AppState, + selectedGroupId: Long?, + onSelectGroup: (Long?) -> Unit, + onOpenConversation: (String) -> Unit, + onOpenGroups: () -> Unit, + onNewConversation: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = AgentikTheme.colors + var query by remember { mutableStateOf("") } + + val filtered = state.conversations + .filter { selectedGroupId == null || it.groupId == selectedGroupId } + .filter { + query.isBlank() || + it.title.contains(query, ignoreCase = true) || + (it.preview?.contains(query, ignoreCase = true) == true) + } + + Column(modifier = modifier.fillMaxHeight().background(colors.panel)) { + Row( + modifier = Modifier.fillMaxWidth().height(52.dp).padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Диалоги", color = colors.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.weight(1f)) + IconButton(onClick = onNewConversation) { + Icon(Icons.Default.Add, contentDescription = "Новый диалог", tint = colors.textDim) + } + } + + // поиск + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp) + .height(34.dp) + .clip(RoundedCornerShape(10.dp)) + .background(colors.background), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Spacer(Modifier.width(9.dp)) + Icon(Icons.Default.Search, contentDescription = null, tint = colors.textDim, modifier = Modifier.size(15.dp)) + Box(Modifier.weight(1f)) { + if (query.isEmpty()) { + Text("Поиск…", color = colors.textDim, fontSize = 12.sp) + } + androidx.compose.foundation.text.BasicTextField( + value = query, + onValueChange = { query = it }, + textStyle = androidx.compose.ui.text.TextStyle(color = colors.text, fontSize = 12.sp), + cursorBrush = androidx.compose.ui.graphics.SolidColor(colors.accent), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + Spacer(Modifier.height(8.dp)) + + // папки + FolderRow( + title = "Все чаты", + count = state.conversations.count { it.unread > 0 }.takeIf { it > 0 }, + active = selectedGroupId == null, + onClick = { onSelectGroup(null) }, + trailing = { + IconButton(onClick = onOpenGroups, modifier = Modifier.size(26.dp)) { + Icon(Icons.Default.Settings, contentDescription = "Группы", tint = colors.textDim, modifier = Modifier.size(15.dp)) + } + }, + ) + state.groups.forEach { group -> + FolderRow( + title = group.name, + count = state.conversations.count { it.groupId == group.id && it.unread > 0 }.takeIf { it > 0 }, + active = selectedGroupId == group.id, + onClick = { onSelectGroup(group.id) }, + ) + } + + Spacer(Modifier.height(6.dp)) + HorizontalDivider(color = colors.line) + + LazyColumn(Modifier.weight(1f)) { + items(items = filtered, key = { it.id }) { conv -> + ConversationRow( + conversation = conv, + agentColor = state.activeAgent?.let { state.agentColor(it) } ?: colors.accent, + avatarPath = state.activeAgent?.avatarPath, + active = conv.id == state.activeConversationId, + onClick = { onOpenConversation(conv.id) }, + ) + } + } + } +} + +@Composable +private fun FolderRow( + title: String, + count: Int?, + active: Boolean, + onClick: () -> Unit, + trailing: (@Composable () -> Unit)? = null, +) { + val colors = AgentikTheme.colors + Row( + modifier = Modifier + .fillMaxWidth() + .height(32.dp) + .background(if (active) colors.panelElevated else Color.Transparent) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + title, + color = if (active) colors.text else colors.textDim, + fontSize = 13.sp, + fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal, + ) + if (count != null) { + Spacer(Modifier.width(6.dp)) + Text(count.toString(), color = colors.badge, fontSize = 11.sp, fontWeight = FontWeight.Bold) + } + if (trailing != null) { + Spacer(Modifier.weight(1f)) + trailing() + } + } +} + +@Composable +private fun ConversationRow( + conversation: UiConversation, + agentColor: Color, + avatarPath: String?, + active: Boolean, + onClick: () -> Unit, +) { + val colors = AgentikTheme.colors + Row( + modifier = Modifier + .fillMaxWidth() + .background(if (active) colors.panelElevated else Color.Transparent) + .clickable(onClick = onClick) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.width(3.dp).height(40.dp).clip(RoundedCornerShape(2.dp)).background(agentColor)) + Spacer(Modifier.width(9.dp)) + AgentAvatar( + displayName = conversation.title, + color = agentColor, + avatarPath = avatarPath, + size = 34.dp, + ) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + conversation.title, + color = colors.text, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(6.dp)) + Text(formatListTime(conversation.updatedAt), color = colors.textDim, fontSize = 10.5.sp) + } + Spacer(Modifier.height(2.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + conversation.preview ?: "…", + color = colors.textDim, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (conversation.unread > 0) { + Spacer(Modifier.width(6.dp)) + UnreadBadge(conversation.unread, background = colors.badge, contentColor = colors.onAccent) + } + } + } + Spacer(Modifier.width(12.dp)) + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/GroupsDialog.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/GroupsDialog.kt new file mode 100644 index 0000000..39d7a6c --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/GroupsDialog.kt @@ -0,0 +1,200 @@ +package pw.binom.agentik.desktop.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import pw.binom.agentik.desktop.persistence.Group +import pw.binom.agentik.desktop.ui.AppState +import pw.binom.agentik.desktop.ui.theme.AgentikTheme + +private sealed interface GroupMode { + data object List : GroupMode + data object Add : GroupMode + data class Edit(val group: Group) : GroupMode +} + +/** + * Окно «Группы»: список, создание, переименование и удаление. Группы — + * локальные для устройства (сервер про них не знает, `THEMES`/`STORAGE` §7); + * удаление группы не удаляет диалоги (R9.7). В режиме правки можно + * разложить диалоги по группе. + */ +@Composable +fun GroupsDialog(state: AppState, onClose: () -> Unit) { + val colors = AgentikTheme.colors + var mode by remember { mutableStateOf(GroupMode.List) } + + DialogCard(width = 480.dp) { + when (val m = mode) { + GroupMode.List -> { + DialogHeader("Группы", onClose) + Column( + modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 18.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + state.groups.forEach { group -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(colors.panelElevated) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(group.name, color = colors.text, fontSize = 13.sp, modifier = Modifier.weight(1f)) + Text( + "${state.conversations.count { it.groupId == group.id }}", + color = colors.textDim, + fontSize = 11.sp, + ) + IconButton(onClick = { mode = GroupMode.Edit(group) }) { + Icon(Icons.Default.Edit, contentDescription = "Изменить", tint = colors.textDim, modifier = Modifier.size(17.dp)) + } + IconButton(onClick = { state.deleteGroup(group.id) }) { + Icon(Icons.Default.Delete, contentDescription = "Удалить", tint = colors.error, modifier = Modifier.size(17.dp)) + } + } + } + if (state.groups.isEmpty()) { + Text( + "Групп пока нет. Создайте, чтобы разложить диалоги по папкам.", + color = colors.textDim, + fontSize = 13.sp, + modifier = Modifier.padding(vertical = 10.dp), + ) + } + TextButton(onClick = { mode = GroupMode.Add }, modifier = Modifier.fillMaxWidth()) { + Text("+ Новая группа") + } + Spacer(Modifier.height(4.dp)) + } + DialogFooter { + Button(onClick = onClose, modifier = Modifier.weight(1f)) { Text("Готово") } + } + } + + GroupMode.Add -> { + var name by remember { mutableStateOf("") } + DialogHeader("Новая группа", { mode = GroupMode.List }) + Column(Modifier.padding(horizontal = 18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Название группы") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(2.dp)) + } + DialogFooter { + TextButton(onClick = { mode = GroupMode.List }, modifier = Modifier.weight(1f)) { Text("Отмена") } + Button( + onClick = { + state.createGroup(name) + mode = GroupMode.List + }, + enabled = name.isNotBlank(), + modifier = Modifier.weight(1f), + ) { Text("Создать") } + } + } + + is GroupMode.Edit -> { + var name by remember(m.group.id) { mutableStateOf(m.group.name) } + DialogHeader("Изменить группу", { mode = GroupMode.List }) + Column( + modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 18.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Название группы") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Text("Диалоги группы", color = colors.textDim, fontSize = 11.5.sp) + state.conversations.forEach { conv -> + val checked = conv.groupId == m.group.id + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + state.setConversationGroup(conv.id, if (checked) null else m.group.id) + } + .padding(horizontal = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = checked, onCheckedChange = { + state.setConversationGroup(conv.id, if (checked) null else m.group.id) + }) + Text( + conv.title, + color = colors.text, + fontSize = 12.5.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Spacer(Modifier.height(4.dp)) + } + DialogFooter { + IconButton( + onClick = { + state.deleteGroup(m.group.id) + mode = GroupMode.List + }, + ) { + Icon(Icons.Default.Delete, contentDescription = "Удалить группу", tint = colors.error) + } + Spacer(Modifier.width(4.dp)) + Button( + onClick = { + state.renameGroup(m.group.id, name) + mode = GroupMode.List + }, + enabled = name.isNotBlank(), + modifier = Modifier.weight(1f), + ) { Text("Сохранить") } + } + } + } + } +} + diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/MessageStream.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/MessageStream.kt new file mode 100644 index 0000000..48f3b62 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/MessageStream.kt @@ -0,0 +1,256 @@ +package pw.binom.agentik.desktop.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import pw.binom.agentik.desktop.model.UiContent +import pw.binom.agentik.desktop.model.UiMessage +import pw.binom.agentik.desktop.ui.ChatImage +import pw.binom.agentik.desktop.ui.components.formatMessageTime +import pw.binom.agentik.desktop.ui.renderMarkdownSegments +import pw.binom.agentik.desktop.ui.theme.AgentikTheme +import java.util.Base64 + +/** + * Лента сообщений: история из кэша + live-блоки активного хода. Оба + * источника идут через одни и те же пузыри — переход «live → history» на + * `End` не раздваивает вид. + */ +@Composable +fun MessageStream( + history: List, + live: List, + thinking: Boolean, + agentColor: Color, + modifier: Modifier = Modifier, +) { + val colors = AgentikTheme.colors + val listState = rememberLazyListState() + val all = history + live + + LaunchedEffect(all.size, thinking) { + if (all.isNotEmpty()) listState.animateScrollToItem(all.size - 1) + } + + LazyColumn( + state = listState, + modifier = modifier.fillMaxSize().padding(horizontal = 22.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { Spacer(Modifier.height(8.dp)) } + items(items = all, key = { it.id }) { message -> + MessageRow(message = message, agentColor = agentColor) + } + if (thinking && live.isEmpty()) { + item(key = "thinking") { + Row( + modifier = Modifier.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = colors.accent, + ) + Text("Агент думает…", color = colors.textDim, fontSize = 13.sp) + } + } + } + item { Spacer(Modifier.height(8.dp)) } + } +} + +@Composable +private fun MessageRow(message: UiMessage, agentColor: Color) { + val colors = AgentikTheme.colors + when (message) { + is UiMessage.User -> Bubble( + alignEnd = true, + background = colors.userBubble, + ) { + TextContent(message.content, textColor = colors.onUserBubble, fontSize = 13.5.sp) + Timestamp(message.createdAt, colors.onUserBubble.copy(alpha = 0.6f)) + } + + is UiMessage.Assistant -> Bubble( + alignEnd = false, + background = colors.botBubble, + ) { + MarkdownContent(message.content, agentColor) + Timestamp(message.createdAt, colors.textDim) + } + + is UiMessage.Reasoning -> Bubble( + alignEnd = false, + background = colors.panelElevated, + ) { + Text( + text = "Размышления", + color = colors.textDim, + fontSize = 10.5.sp, + fontWeight = FontWeight.SemiBold, + fontStyle = FontStyle.Italic, + ) + Spacer(Modifier.height(3.dp)) + Text( + text = message.text, + color = colors.textDim, + fontSize = 12.5.sp, + fontStyle = FontStyle.Italic, + ) + } + + is UiMessage.Tool -> Bubble(alignEnd = false, background = colors.panelElevated) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text("⚙", color = colors.accent, fontSize = 12.sp) + Text( + text = message.title ?: message.toolName, + color = colors.text, + fontSize = 12.5.sp, + fontWeight = FontWeight.SemiBold, + ) + } + if (message.argsJson.isNotBlank()) { + Spacer(Modifier.height(4.dp)) + CodeBlock(prefix = "$ ", body = message.argsJson, tint = colors.textDim) + } + message.result?.let { + Spacer(Modifier.height(4.dp)) + CodeBlock(prefix = "", body = it, tint = colors.textDim) + } + message.failed?.let { + Spacer(Modifier.height(4.dp)) + Text(it, color = colors.error, fontSize = 12.sp) + } + } + + is UiMessage.Error -> Bubble(alignEnd = false, background = colors.botBubble) { + Text( + text = message.message, + color = colors.error, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + ) + message.code?.let { Text(it, color = colors.textDim, fontSize = 11.sp) } + } + } +} + +@Composable +private fun Bubble(alignEnd: Boolean, background: Color, content: @Composable () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (alignEnd) Arrangement.End else Arrangement.Start, + ) { + Column( + modifier = Modifier + .widthIn(max = 620.dp) + .clip(RoundedCornerShape(12.dp)) + .background(background) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + content() + } + } +} + +@Composable +private fun Timestamp(instant: kotlin.time.Instant, color: Color) { + Spacer(Modifier.height(3.dp)) + Text(formatMessageTime(instant), color = color, fontSize = 10.5.sp) +} + +@Composable +private fun TextContent(content: List, textColor: Color, fontSize: androidx.compose.ui.unit.TextUnit) { + Column { + content.forEach { part -> + when (part) { + is UiContent.Text -> Text(part.body, color = textColor, fontSize = fontSize) + is UiContent.Image -> { + ChatImage(src = dataUrl(part), alt = null) + Spacer(Modifier.height(4.dp)) + } + } + } + } +} + +@Composable +private fun MarkdownContent(content: List, agentColor: Color) { + val colors = AgentikTheme.colors + Column { + content.forEach { part -> + when (part) { + is UiContent.Text -> renderMarkdownSegments( + content = part.body, + textColor = colors.text, + fontSize = 13.5.sp, + ) + + is UiContent.Image -> { + ChatImage(src = dataUrl(part), alt = null) + Spacer(Modifier.height(4.dp)) + } + } + } + } + // Полоска цвета агента — визуальная привязка ответа к ассистенту. + Spacer(Modifier.height(2.dp)) + Box( + Modifier + .size(width = 28.dp, height = 2.dp) + .clip(RoundedCornerShape(1.dp)) + .background(agentColor), + ) +} + +@Composable +private fun CodeBlock(prefix: String, body: String, tint: Color) { + val colors = AgentikTheme.colors + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(colors.panel) + .border(1.dp, colors.line, RoundedCornerShape(8.dp)) + .padding(8.dp), + ) { + Text( + text = prefix + body, + color = tint, + fontSize = 11.5.sp, + fontFamily = FontFamily.Monospace, + ) + } +} + +private fun dataUrl(image: UiContent.Image): String = + "data:${image.mime};base64,${Base64.getEncoder().encodeToString(image.data)}" diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/NewChatDialog.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/NewChatDialog.kt new file mode 100644 index 0000000..54981a4 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/NewChatDialog.kt @@ -0,0 +1,84 @@ +package pw.binom.agentik.desktop.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import pw.binom.agentik.desktop.session.AgentStatus +import pw.binom.agentik.desktop.ui.AppState +import pw.binom.agentik.desktop.ui.components.AgentAvatar +import pw.binom.agentik.desktop.ui.theme.AgentikTheme + +/** + * Модалка «Новый диалог»: при нескольких ассистентах спрашиваем, в каком + * создавать (диалог живёт внутри ассистента). Одобренный макет — + * `sketches/index.html` секция 15. + */ +@Composable +fun NewChatDialog( + state: AppState, + onPick: (String) -> Unit, + onClose: () -> Unit, +) { + val colors = AgentikTheme.colors + DialogCard(width = 440.dp) { + DialogHeader("Новый диалог", onClose) + Column( + modifier = Modifier.padding(horizontal = 18.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.agents.forEach { config -> + val online = state.statusesState[config.id] is AgentStatus.Online + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(colors.panelElevated) + .clickable(enabled = online) { onPick(config.id) } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + AgentAvatar( + displayName = state.agentDisplayName(config), + color = state.agentColor(config), + avatarPath = config.avatarPath, + size = 38.dp, + ) + Column(Modifier.weight(1f)) { + Text( + state.agentDisplayName(config), + color = if (online) colors.text else colors.textDim, + fontSize = 13.5.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + if (online) "на связи" else "не отвечает", + color = if (online) colors.ok else colors.error, + fontSize = 11.sp, + ) + } + } + } + Spacer(Modifier.height(4.dp)) + } + DialogFooter { + Button(onClick = onClose, modifier = Modifier.weight(1f)) { Text("Отмена") } + } + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/theme/AgentColors.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/theme/AgentColors.kt new file mode 100644 index 0000000..fafa206 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/theme/AgentColors.kt @@ -0,0 +1,40 @@ +package pw.binom.agentik.desktop.ui.theme + +import androidx.compose.ui.graphics.Color + +/** + * Цвета ассистентов — **пользовательские**, не часть темы (THEMES.md, + * «Особый случай»). При смене темы они остаются собой, иначе ассистенты + * перестанут отличаться друг от друга. + * + * Значения — расширение палитры `--a1..--a5` из Android-клиента, + * подобраны под тёмную тему. В светлой теме читаемость ещё не проверена + * (открытый вопрос в THEMES.md) — [contrastText] подбирает текст под фон + * по яркости, чтобы хотя бы текст оставался читаемым. + */ +object AgentColors { + + val defaults: List = listOf( + Color(0xFF6AB2F2), + Color(0xFF86C99A), + Color(0xFFC9A886), + Color(0xFFB79AE0), + Color(0xFFE28C8C), + ) + + /** Цвет агента по индексу из [pw.binom.agentik.desktop.settings.AgentConfig.colorIndex]. */ + fun ofIndex(index: Int): Color { + val n = defaults.size + return defaults[((index % n) + n) % n] + } + + /** + * Цвет текста/иконки, читаемый на [background]: тёмный на светлых + * плашках, светлый на тёмных. Считается по относительной яркости, + * а не берётся из темы — подложка-то пользовательская. + */ + fun contrastText(background: Color): Color { + val luminance = 0.2126f * background.red + 0.7152f * background.green + 0.0722f * background.blue + return if (luminance > 0.6f) Color(0xFF10161C) else Color(0xFFF2F5F8) + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/theme/Theme.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/theme/Theme.kt new file mode 100644 index 0000000..0c2b422 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/theme/Theme.kt @@ -0,0 +1,176 @@ +package pw.binom.agentik.desktop.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import pw.binom.agentik.desktop.settings.ThemeId + +/** + * Семантические цвета интерфейса. **Ни один цвет не задаётся числом вне + * этого файла** (см. `THEMES.md`): экраны берут цвета отсюда по смыслу, + * поэтому смена темы не оставит «тёмно-синюю панель на светлом фоне». + * + * Это расширение над `MaterialTheme.colorScheme`: у нас есть слоты, которых + * в Material нет (пузыри, полоска агента, счётчик). Стандартные компоненты + * Material по-прежнему берут цвета из схемы (см. [toMaterialScheme]). + */ +@Immutable +data class AgentikColors( + /** Фон окна. */ + val background: Color, + /** Панели: сайдбар, шапки, композер. */ + val panel: Color, + /** «Приподнятые» элементы на панели: ховер строки, поля ввода, блоки кода. */ + val panelElevated: Color, + /** Разделители и рамки. */ + val line: Color, + /** Основной текст. */ + val text: Color, + /** Приглушённый текст: время, подписи, плейсхолдеры. */ + val textDim: Color, + /** Главный акцент: активная вкладка, курсор, ссылки. */ + val accent: Color, + /** Текст/иконка на акцентной плашке (тёмный — на обоих наших акцентах). */ + val onAccent: Color, + /** Фон кружка непрочитанных. */ + val badge: Color, + /** Ошибки, опасные зоны, кнопка записи. */ + val error: Color, + /** Успех, «на связи». */ + val ok: Color, + /** Пузырь сообщения пользователя. */ + val userBubble: Color, + /** Текст внутри пузыря пользователя. */ + val onUserBubble: Color, + /** Пузырь сообщения ассистента. */ + val botBubble: Color, + /** Текст внутри пузыря ассистента. */ + val onBotBubble: Color, + /** Подложка выбранного элемента (акцент с прозрачностью). */ + val selection: Color, + /** Затемнение под модалкой. */ + val scrim: Color, + /** Фон блока кода в сообщении (шапка блока берёт [panelElevated]). */ + val codeBackground: Color, + /** Текст внутри блока кода. */ + val codeText: Color, +) { + + /** Стандартная Material-схема из наших слотов — для готовых компонентов. */ + fun toMaterialScheme(dark: Boolean): androidx.compose.material3.ColorScheme { + val base = if (dark) darkColorScheme() else lightColorScheme() + return base.copy( + primary = accent, + onPrimary = onAccent, + primaryContainer = accent, + onPrimaryContainer = onAccent, + secondary = accent, + onSecondary = onAccent, + background = background, + onBackground = text, + surface = panel, + onSurface = text, + surfaceVariant = panelElevated, + onSurfaceVariant = textDim, + surfaceTint = accent, + outline = line, + outlineVariant = line, + error = error, + onError = onAccent, + errorContainer = error, + onErrorContainer = onAccent, + scrim = scrim, + inverseSurface = text, + inverseOnSurface = background, + inversePrimary = accent, + ) + } +} + +/** + * Тёмная тема — эталонная, значения согласованы с Android-клиентом + * (`agentik-android/design/BORROW.md`). Числа живут только здесь. + */ +val darkAgentikColors = AgentikColors( + background = Color(0xFF121218), + panel = Color(0xFF17212B), + panelElevated = Color(0xFF1C2733), + line = Color(0xFF232B36), + text = Color(0xFFEBEBEB), + textDim = Color(0xFF768C9E), + accent = Color(0xFF6AB2F2), + onAccent = Color(0xFF0F1922), + badge = Color(0xFF5EB5F7), + error = Color(0xFFEC3942), + ok = Color(0xFF63C88A), + userBubble = Color(0xFF2B5278), + onUserBubble = Color(0xFFEBEBEB), + botBubble = Color(0xFF1C2733), + onBotBubble = Color(0xFFEBEBEB), + selection = Color(0x226AB2F2), + scrim = Color(0x99000000), + codeBackground = Color(0xFF1E1E1E), + codeText = Color(0xFFD4D4D4), +) + +/** + * Светлая тема — предполагается (THEMES.md: «пока известно только: тёмная + * есть, светлая предполагается»). Значения подобраны так, чтобы сохранить + * смысл слотов; визуально её ещё предстоит проверить на всех экранах. + */ +val lightAgentikColors = AgentikColors( + background = Color(0xFFF5F6F8), + panel = Color(0xFFFFFFFF), + panelElevated = Color(0xFFEDEFF3), + line = Color(0xFFD7DBE2), + text = Color(0xFF1A1D23), + textDim = Color(0xFF6B7686), + accent = Color(0xFF2A6FB5), + onAccent = Color(0xFFFFFFFF), + badge = Color(0xFF2A6FB5), + error = Color(0xFFC7262E), + ok = Color(0xFF2E8B57), + userBubble = Color(0xFFD3E3F7), + onUserBubble = Color(0xFF1A1D23), + botBubble = Color(0xFFEDEFF3), + onBotBubble = Color(0xFF1A1D23), + selection = Color(0x222A6FB5), + scrim = Color(0x66000000), + codeBackground = Color(0xFFEAEDF2), + codeText = Color(0xFF1A1D23), +) + +private val LocalAgentikColors = staticCompositionLocalOf { darkAgentikColors } + +/** Доступ к семантическим цветам из экранов: `AgentikTheme.colors.panel`. */ +object AgentikTheme { + val colors: AgentikColors + @Composable + @ReadOnlyComposable + get() = LocalAgentikColors.current +} + +/** + * Корневой провайдер темы. Оборачивает всё приложение; [themeId] — значение + * из настроек (`ThemeId.DARK` / `ThemeId.LIGHT`). + */ +@Composable +fun AgentikTheme( + themeId: String, + content: @Composable () -> Unit, +) { + val dark = themeId != ThemeId.LIGHT + val colors = if (dark) darkAgentikColors else lightAgentikColors + CompositionLocalProvider(LocalAgentikColors provides colors) { + MaterialTheme( + colorScheme = colors.toMaterialScheme(dark = dark), + content = content, + ) + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/voice/Audio.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/voice/Audio.kt new file mode 100644 index 0000000..a4b3dc6 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/voice/Audio.kt @@ -0,0 +1,57 @@ +package pw.binom.agentik.desktop.voice + +/** Частота дискретизации всего голосового тракта (контракт `mic-api`/`asr-api`). */ +const val VOICE_SAMPLE_RATE: Int = 16_000 + +/** Длина чанка, которую просим у микрофона (100 мс = 1600 сэмплов = 3200 байт). */ +const val VOICE_CHUNK_MILLIS: Int = 100 + +/** + * s16le PCM (как отдаёт `mic-api`) → float сэмплы в [-1, 1] (как ждёт `asr-api` + * и VAD). Нечётный хвостовой байт отбрасывается: кадр — 2 байта. + */ +fun pcm16ToFloats(pcm: ByteArray, offset: Int = 0, length: Int = pcm.size - offset): FloatArray { + val count = length / 2 + val out = FloatArray(count) + for (i in 0 until count) { + val lo = pcm[offset + i * 2].toInt() and 0xFF + val hi = pcm[offset + i * 2 + 1].toInt() + out[i] = ((hi shl 8) or lo).toShort() / 32768f + } + return out +} + +/** + * Режет произвольные порции сэмплов на окна фиксированного размера. + * + * Нужно для VAD: Silero принимает строго `windowSizeSamples` (512 на 16 кГц), + * а от микрофона приходят чанки по 1600 сэмплов — не кратные 512. + * Неполное окно держится до следующей порции. + * + * Колбэк получает копию окна, а не внутренний буфер: вызывающий может её + * сохранить. + */ +class PcmWindower( + val windowSize: Int, + private val onWindow: (FloatArray) -> Unit, +) { + private val buf = FloatArray(windowSize) + private var fill = 0 + + /** Сколько сэмплов накоплено в неполном окне (для тестов/диагностики). */ + val pending: Int get() = fill + + fun push(samples: FloatArray) { + var i = 0 + while (i < samples.size) { + val n = minOf(windowSize - fill, samples.size - i) + samples.copyInto(buf, fill, i, i + n) + fill += n + i += n + if (fill == windowSize) { + onWindow(buf.copyOf()) + fill = 0 + } + } + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/voice/VoiceController.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/voice/VoiceController.kt new file mode 100644 index 0000000..83257f1 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/voice/VoiceController.kt @@ -0,0 +1,190 @@ +package pw.binom.agentik.desktop.voice + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import pw.binom.mic.MicAudio + +/** Состояние голосового ввода (для кнопки микрофона). */ +enum class VoiceState { + /** Ничего не происходит. */ + IDLE, + + /** Грузятся модели распознавания (первый раз — секунды). */ + PREPARING, + + /** Микрофон пишет; [VoiceController.onPartial] отдаёт растущий текст. */ + RECORDING, + + /** Ввод закрыт, дочитывается финальный текст. */ + RECOGNIZING, +} + +/** Шов для тестов: источник аудио-чанков (s16le, 16 кГц, моно). */ +fun interface MicSource { + fun record(sampleRate: Int, chunkMillis: Int): Flow +} + +/** + * Голосовой ввод: микрофон → детектор речи → распознавание → текст. + * + * Одно нажатие: + * - [start] → `PREPARING` (грузятся модели) → `RECORDING` (микрофон пишет, + * [onPartial] отдаёт растущий текст); + * - [stop] → `RECOGNIZING` (дочитываем хвост) → `IDLE` + [onFinal] с итогом; + * - [cancel] — выбросить всё, ничего не отдавать; + * - нет микрофона / упала модель → [onError] и `IDLE`. + * + * Тишина не превращается в мусор: если VAD ни разу не увидел речь, финал не + * отдаётся ([onError] «Речь не распознана»). + * + * ## Почему финал читается в отдельной корутине + * + * [stop] отменяет корутину записи, а `VoskStt.finish()` (пролив распознавателя + * + хвостовой результат) — блокирующий нативный вызов, который после отмены + * уже не выполнится. Поэтому завершение запускается из `finally` в **другой** + * корутине ([NonCancellable]), которую отмена записи не задевает; собственно + * нативная работа по-прежнему идёт на одном выделенном потоке. + * + * Всё нативное (Vosk/ONNX) считается на **одном** потоке: эти библиотеки не + * потокобезопасны, а [VoiceRecognizerFactory.create] грузит модели. + */ +class VoiceController( + private val scope: CoroutineScope, + private val recognizers: VoiceRecognizerFactory = VoiceRecognizerFactory { VoskVoiceRecognizer() }, + private val mic: MicSource = MicSource { rate, chunkMillis -> + MicAudio.record(sampleRate = rate, channels = 1, chunkMillis = chunkMillis) + }, + private val onState: (VoiceState) -> Unit = {}, + private val onPartial: (String) -> Unit = {}, + private val onFinal: (String) -> Unit = {}, + private val onError: (String) -> Unit = {}, +) : AutoCloseable { + + private val audioDispatcher = Dispatchers.Default.limitedParallelism(1) + + private var recordJob: Job? = null + private var finishJob: Job? = null + private var discard = false + private var stopping = false + private var lastPartial: String? = null + + /** Текущее состояние; каждое изменение уходит в [onState]. */ + var state: VoiceState = VoiceState.IDLE + private set(value) { + if (field != value) { + field = value + onState(value) + } + } + + /** true, пока пишем/готовимся/дочитываем финал. */ + val isActive: Boolean get() = recordJob?.isActive == true || finishJob?.isActive == true + + /** Кнопка микрофона: нажатие в простое — начать, в работе — остановить. */ + fun toggle() { + if (isActive) stop() else start() + } + + fun start() { + if (isActive) return + discard = false + stopping = false + lastPartial = null + state = VoiceState.PREPARING + recordJob = scope.launch { + var recognizer: VoiceRecognizer? = null + var micError: String? = null + try { + val created: VoiceRecognizer = try { + withContext(audioDispatcher) { recognizers.create() } + } catch (e: CancellationException) { + state = VoiceState.IDLE + return@launch + } catch (e: Throwable) { + state = VoiceState.IDLE + onError("Не удалось запустить распознавание: ${e.message ?: e::class.simpleName}") + return@launch + } + recognizer = created + + // stop() во время подготовки уже выставил RECOGNIZING — не затираем. + if (!stopping) state = VoiceState.RECORDING + + try { + mic.record(VOICE_SAMPLE_RATE, VOICE_CHUNK_MILLIS).collect { chunk -> + if (chunk.isEmpty()) return@collect + withContext(audioDispatcher) { + created.feed(chunk) + val p = created.partial() + if (p != null && p != lastPartial) { + lastPartial = p + onPartial(p) + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + micError = "Микрофон недоступен: ${e.message ?: e::class.simpleName}" + } + } finally { + // Сюда попадаем и при stop()/cancel() (отмена) — а финал должен + // быть прочитан, поэтому отдельная некоррелируемая корутина. + recognizer?.let { finishAsync(it, discardFinal = discard, micError = micError) } + } + } + } + + /** Закрыть ввод и отдать итоговый текст через [onFinal]. */ + fun stop() { + if (recordJob?.isActive != true) return + stopping = true + state = VoiceState.RECOGNIZING + recordJob?.cancel() + } + + /** Прервать без текста. */ + fun cancel() { + if (recordJob?.isActive != true) return + discard = true + stopping = true + recordJob?.cancel() + state = VoiceState.IDLE + } + + private fun finishAsync(recognizer: VoiceRecognizer, discardFinal: Boolean, micError: String?) { + finishJob = scope.launch(NonCancellable) { + val (text, speech) = withContext(audioDispatcher) { + val t = if (discardFinal) "" else runCatching { recognizer.finish() }.getOrDefault("") + t.trim() to recognizer.speechDetected + } + // close() не suspend — выполняется даже при отмене. + runCatching { recognizer.close() } + if (discardFinal) { + state = VoiceState.IDLE + return@launch + } + val err = micError + when { + err != null -> onError(err) + text.isNotEmpty() -> onFinal(text) + !speech -> onError("Речь не распознана") + } + state = VoiceState.IDLE + } + } + + override fun close() { + discard = true + stopping = true + recordJob?.cancel() + finishJob?.cancel() + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/voice/VoiceNative.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/voice/VoiceNative.kt new file mode 100644 index 0000000..b6a4d47 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/voice/VoiceNative.kt @@ -0,0 +1,49 @@ +package pw.binom.agentik.desktop.voice + +import pw.binom.asr.Stt +import pw.binom.asr.vosk.VoskConfig +import pw.binom.asr.vosk.vosk +import pw.binom.voice.vad.VadInstance + +/** + * Прогрев нативного ASR-стека (Vosk/Kaldi + ONNX) **до** инициализации skia. + * + * ## Зачем это обязательно + * + * skiko (нативный рендер Compose) и C++-библиотеки распознавания в одном + * процессе конфликтуют по порядку загрузки: если C++-стек инициализируется + * ПОСЛЕ skiko, первое же создание модели валит JVM с SIGSEGV — падение идёт из + * C++-логов (`kaldi::ParseOptions::RegisterSpecific` → `libstdc++ + * std::ostream::_M_insert` → null-стрим в libc), см. probe-заметки в + * MIC-ASR-SEARCH.md. Если же стек прогрет ДО skiko, то и повторное создание + * модели, и свежий VAD после отрисовки окна работают нормально + * (проверено `RealSpeechIT`). + * + * Поэтому [warmUp] зовётся в `main()` до `application { }`. Побочный плюс: + * модель Vosk (~46 МБ, распаковка ~2 с) грузится на старте, а не при первом + * нажатии микрофона. + * + * Если прогрев не удался (нет нативных библиотек), микрофон отключается: лучше + * показать «голос недоступен», чем уронить приложение на первом клике. + */ +object VoiceNative { + + private var warmed: Boolean? = null + + /** Доступен ли голосовой ввод — результат [warmUp]. */ + val available: Boolean get() = warmed == true + + /** + * Инициализирует нативный ASR (Vosk + VAD) до skiko. Идемпотентно; + * возвращает `true`, если голосовой ввод доступен. + */ + fun warmUp(): Boolean { + warmed?.let { return it } + val ok = runCatching { + Stt.vosk(VoskConfig(VoskVoiceRecognizer.MODEL_ASSET_NAME, VOICE_SAMPLE_RATE)).close() + VadInstance(VOICE_SAMPLE_RATE).close() + }.isSuccess + warmed = ok + return ok + } +} diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/voice/VoiceRecognizer.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/voice/VoiceRecognizer.kt new file mode 100644 index 0000000..5932f88 --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/voice/VoiceRecognizer.kt @@ -0,0 +1,90 @@ +package pw.binom.agentik.desktop.voice + +import pw.binom.asr.Stt +import pw.binom.asr.vosk.VoskConfig +import pw.binom.asr.vosk.vosk +import pw.binom.voice.vad.VadInstance + +/** + * Потоковое распознавание речи: принимает s16le PCM 16 кГц моно, отдаёт текст. + * + * Интерфейс-шов для тестов: реальная реализация тянет нативные модели + * (Vosk + ONNX), в тестах подставляется фейк, который просто считает байты. + * + * Не потокобезопасен: все вызовы должны идти последовательно из одной корутины + * (это обеспечивает [VoiceController] через однопоточный диспетчер). + */ +interface VoiceRecognizer : AutoCloseable { + + /** Подать порцию аудио (s16le, 16 кГц, моно). */ + fun feed(pcm: ByteArray) + + /** Промежуточный текст (растёт по ходу речи), или `null` если его нет. */ + fun partial(): String? + + /** Конец ввода → итоговый текст. */ + fun finish(): String + + /** Была ли в поданном аудио речь (по VAD). */ + val speechDetected: Boolean +} + +/** + * Фабрика распознавателя. Создание **дорогое** (загрузка модели Vosk ~секунды + * на первый раз, распаковка zip в tmp) — звать только с фонового диспетчера. + */ +fun interface VoiceRecognizerFactory { + fun create(): VoiceRecognizer +} + +/** + * Распознаватель по умолчанию: [Stt.vosk] (модель `vosk-model-small-ru-0.22.zip` + * лежит внутри артефакта `asr-vosk-jvm`, качать нечего) + Silero VAD из + * `vad-jvm` (модель `silero_vad.onnx` тоже внутри артефакта). + * + * VAD используется как детектор «говорили ли вообще»: аудио в Vosk идёт целиком + * (он сам умеет endpointer по паузам, обрезать тишину вредно), а вот пустой + * финал при полной тишине даёт [speechDetected] = false — тогда + * [VoiceController] не выдаёт мусорный текст. + */ +class VoskVoiceRecognizer( + private val vad: VadInstance = VadInstance(VOICE_SAMPLE_RATE), + private val stt: Stt = Stt.vosk(VoskConfig(MODEL_ASSET_NAME, VOICE_SAMPLE_RATE)), +) : VoiceRecognizer { + + private val windower = PcmWindower(vad.windowSizeSamples) { window -> + if (vad.processingWindow(window) >= VAD_SPEECH_THRESHOLD) speechSeen = true + } + + private var speechSeen = false + + override val speechDetected: Boolean get() = speechSeen + + override fun feed(pcm: ByteArray) { + if (pcm.isEmpty()) return + val samples = pcm16ToFloats(pcm) + windower.push(samples) + stt.feed(samples) + } + + override fun partial(): String? = + stt.partialText()?.trim()?.takeIf { it.isNotEmpty() } + + override fun finish(): String { + stt.endOfInput() + return stt.finalText().trim() + } + + override fun close() { + runCatching { stt.close() } + runCatching { vad.close() } + } + + companion object { + /** Имя zip-ресурса в classpath — совпадает с вложенным в `asr-vosk-jvm`. */ + const val MODEL_ASSET_NAME: String = "vosk-model-small-ru-0.22.zip" + + /** Порог Silero VAD (вероятность речи 0..1), классические 0.5. */ + const val VAD_SPEECH_THRESHOLD: Float = 0.5f + } +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/markdown/MarkdownParserTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/markdown/MarkdownParserTest.kt new file mode 100644 index 0000000..8e7836f --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/markdown/MarkdownParserTest.kt @@ -0,0 +1,196 @@ +package pw.binom.agentik.desktop.markdown + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class MarkdownParserTest { + + private fun renderedText(md: String): String = + parseMarkdown(md) + .joinToString("\n") { segment -> + when (segment) { + is MarkdownSegment.Text -> segment.text + is MarkdownSegment.CodeBlock -> segment.code + is MarkdownSegment.Table -> + (segment.headers + segment.rows.flatten()).joinToString("|") + is MarkdownSegment.Image -> "[img:${segment.src}]" + is MarkdownSegment.ThematicBreak -> "[---]" + } + } + + /** + * Регрессия: одиночный «!» не должен теряться. + * Было сломано в ru.otpbank.ai:markdown 0.10.0 (ветка IMAGE без else + * проглатывала одиночный «!»), исправлено в 0.51.0. + */ + @Test + fun `exclamation marks are preserved`() { + assertEquals("Привет!", renderedText("Привет!")) + assertEquals("!!", renderedText("!!")) + assertEquals("Привет! Как дела?", renderedText("Привет! Как дела?")) + assertEquals("Внимание!", renderedText("Внимание!")) + } + + @Test + fun `inline styles italic bold strike code are recorded`() { + val md = "*italic* **bold** ~~strike~~ `code` and plain" + val text = parseMarkdown(md).filterIsInstance().single() + assertEquals("italic bold strike code and plain", text.text) + + fun spanFor(prefix: String): TextStyleSpan = text.spans.single { text.text.startsWith(prefix, it.start) } + val italic = spanFor("italic") + assertTrue(italic.italic, "italic span must have italic=true") + val bold = spanFor("bold") + assertTrue(bold.bold, "bold span must have bold=true") + val strike = spanFor("strike") + assertTrue(strike.strikethrough, "strike span must have strikethrough=true") + val code = spanFor("code") + assertTrue(code.code, "code span must have code=true") + } + + @Test + fun `ordered list items are not dropped`() { + val md = + """ + |Конкретно два места: + | + |1. Моя шпаргалка по кластеру (файл k3s-инвентарь). + |2. Ваша заметка задач /root/notes/2026-08-05.md. + | + |А больше ничего. + """.trimMargin() + + val text = renderedText(md) + + assertTrue(text.contains("Конкретно два места"), "Нет вводного абзаца: $text") + assertTrue(text.contains("1. Моя шпаргалка по кластеру"), "Пункт 1 потерян: $text") + assertTrue(text.contains("2. Ваша заметка задач"), "Пункт 2 потерян: $text") + assertTrue(text.contains("А больше ничего"), "Нет завершающего абзаца: $text") + } + + @Test + fun `unordered list items are not dropped`() { + val md = + """ + |- первый пункт + |- второй пункт + """.trimMargin() + + val text = renderedText(md) + + assertTrue(text.contains("• первый пункт"), "Маркер списка не добавлен или пункт потерян: $text") + assertTrue(text.contains("• второй пункт"), "Пункт потерян: $text") + } + + @Test + fun `plain paragraph stays intact`() { + val md = "Просто текст без форматирования." + + assertEquals("Просто текст без форматирования.", renderedText(md).trim()) + } + + @Test + fun `thematic break, autolink and html block are not dropped`() { + val md = + """ + |Текст + | + |----- + | + | + | + |
hello
+ """.trimMargin() + + val segments = parseMarkdown(md) + val text = renderedText(md) + + assertTrue( + segments.any { it is MarkdownSegment.ThematicBreak }, + "Разделитель `---` не распознан: ${segments.joinToString { it.toString() }}", + ) + assertTrue(text.contains("https://example.com"), "Автоссылка потеряна: $text") + assertTrue(text.contains("hello"), "HTML-блок потерян: $text") + } + + @Test + fun `table with bold and comma inside cells is parsed`() { + // Реальный кейс из Hermes: жирный текст и запятая внутри ячеек не должны + // ломать распознавание таблицы и разбиение на ячейки. + val md = + """ + || # | Книга | Автор / изд. | Чтец | Почему это оно | + ||---|-------|-------------|------|----------------| + || 1 | **«Призрак поместья Торнхилл»** (#1 серии) | Альва Морис, Эксмо Freedom | Илья Дементьев | причина | + | + |После таблицы — обычный абзац. + """.trimMargin() + + val segments = parseMarkdown(md) + val table = segments.filterIsInstance().firstOrNull() + assertTrue(table != null, "Таблица не распознана: ${segments.joinToString { it.toString() }}") + assertEquals(listOf("#", "Книга", "Автор / изд.", "Чтец", "Почему это оно"), table!!.headers) + val row = table.rows[0] + assertEquals(5, row.size, "Строка должна иметь 5 ячеек, а имеет ${row.size}: $row") + assertEquals("«Призрак поместья Торнхилл» (#1 серии)", row[1]) + assertEquals("Альва Морис, Эксмо Freedom", row[2]) + } + + @Test + fun `image эмитится как отдельный сегмент с src`() { + // data URL (Hermes). + val segments = parseMarkdown("""![альт](data:image/png;base64,QUJD)""") + val image = segments.filterIsInstance().single() + assertEquals("data:image/png;base64,QUJD", image.src) + assertEquals("альт", image.alt) + } + + @Test + fun `image с http-адресом эмитится как отдельный сегмент`() { + val segments = parseMarkdown("""![x](http://example.com/img.png)""") + val image = segments.filterIsInstance().single() + assertEquals("http://example.com/img.png", image.src) + } + + @Test + fun `текст вокруг картинки не теряется`() { + val segments = parseMarkdown("""текст до ![альт](data:image/png;base64,QUJD) текст после""") + assertEquals( + listOf("текст до ", " текст после"), + segments.filterIsInstance().map { it.text }, + ) + assertEquals(1, segments.filterIsInstance().size) + // Порядок: текст, картинка, текст. + assertEquals( + listOf("Text", "Image", "Text"), + segments.map { it.javaClass.simpleName }, + ) + } + + @Test + fun `original answer from hermes keeps paragraph breaks`() { + val md = + """ + |Сударь, извольте одной строкой: скорость одна и та же. + | + |Свет и радио — это одно и то же явление. + | + |Разница не в скорости, а в свойствах. + """.trimMargin() + + val segments = parseMarkdown(md) + val text = renderedText(md) + + // Что реально отдаёт парсер: + println("=== segment count: ${segments.size}") + segments.forEachIndexed { i, s -> + println("--- [$i] ${s.javaClass.simpleName}: ${(s as? MarkdownSegment.Text)?.text ?: s}") + } + println("=== joined text: [" + text.replace("\n", "\\n") + "]") + + assertTrue(segments.size >= 3, "Ожидалось несколько абзацев, получено ${segments.size}: ${segments.joinToString { it.toString() }}") + assertTrue(text.contains("\n"), "Между абзацами нет перевода строки: ${text.replace("\n", "\\n")}") + } +} + diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/media/AvatarStorageTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/media/AvatarStorageTest.kt new file mode 100644 index 0000000..6e36f75 --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/media/AvatarStorageTest.kt @@ -0,0 +1,84 @@ +package pw.binom.agentik.desktop.media + +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.writeBytes +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Тесты [AvatarStorage] на реальном временном каталоге: копирование, + * замена (старое расширение вычищается), удаление, чтение. + */ +class AvatarStorageTest { + + @TempDir + lateinit var dir: Path + + private fun storage() = AvatarStorage(dir) + + @Test + fun `save copies file and returns absolute path`() = runBlocking { + val src = dir.resolve("src.png") + src.writeBytes(byteArrayOf(1, 2, 3)) + + val saved = storage().save("agent-1", src) + + assertTrue(Path.of(saved).exists()) + assertTrue(Path.of(saved).isAbsolute) + assertEquals(listOf(1, 2, 3), Path.of(saved).toFile().readBytes().toList()) + } + + @Test + fun `save with new extension removes old avatar`() = runBlocking { + val png = dir.resolve("a.png").apply { writeBytes(byteArrayOf(9)) } + val jpg = dir.resolve("b.jpg").apply { writeBytes(byteArrayOf(8)) } + + val storage = storage() + val first = storage.save("agent-1", png) + val second = storage.save("agent-1", jpg) + + assertFalse(Path.of(first).exists(), "старый аватар должен быть удалён") + assertTrue(Path.of(second).exists()) + // В каталоге ровно один файл агента (+ исходники теста). + val owned = dir.toFile().listFiles()!!.filter { it.name.startsWith("agent-1.") } + assertEquals(1, owned.size) + } + + @Test + fun `read returns bytes or null`() = runBlocking { + val src = dir.resolve("x.png").apply { writeBytes(byteArrayOf(4, 5)) } + val storage = storage() + val saved = storage.save("agent-2", src) + + assertEquals(listOf(4, 5), storage.read(saved)!!.toList()) + assertNull(storage.read(null)) + assertNull(storage.read(dir.resolve("missing.png").toString())) + } + + @Test + fun `delete removes avatar`() = runBlocking { + val src = dir.resolve("x.png").apply { writeBytes(byteArrayOf(1)) } + val storage = storage() + val saved = storage.save("agent-3", src) + + assertTrue(storage.delete("agent-3")) + assertFalse(Path.of(saved).exists()) + assertFalse(storage.delete("agent-3")) + } + + @Test + fun `agent ids with unsafe characters are sanitized`() = runBlocking { + val src = dir.resolve("x.png").apply { writeBytes(byteArrayOf(1)) } + val saved = storage().save("http://host:8080/x", src) + + val fileName = Path.of(saved).fileName.toString() + assertFalse(fileName.contains(":"), "двоеточие недопустимо в имени файла: $fileName") + assertFalse(fileName.contains("/")) + } +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/model/MappersTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/model/MappersTest.kt new file mode 100644 index 0000000..42f93ae --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/model/MappersTest.kt @@ -0,0 +1,138 @@ +package pw.binom.agentik.desktop.model + +import pw.binom.agentik.journal.Content +import pw.binom.agentik.journal.MessageRecord +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Тесты мапперов истории журнала в UI-модель. Ключевое — склейка + * `ToolCall` + `ToolResult` в одну карточку и сохранение порядка. + */ +class MappersTest { + + private fun at(ms: Long) = Instant.fromEpochMilliseconds(ms) + + @Test + fun `user and assistant map to their ui types`() { + val ui = listOf( + MessageRecord.UserMessage( + id = "u1", + conversationId = "c", + content = listOf(Content.Text("привет")), + createdAt = at(1), + ), + MessageRecord.AssistantMessage( + id = "a1", + conversationId = "c", + content = listOf(Content.Text("ответ")), + createdAt = at(2), + ), + ).toUiMessages() + + assertEquals(2, ui.size) + val user = ui[0] as UiMessage.User + assertEquals("привет", (user.content.single() as UiContent.Text).body) + val assistant = ui[1] as UiMessage.Assistant + assertEquals("ответ", (assistant.content.single() as UiContent.Text).body) + } + + @Test + fun `tool result is stitched into its tool call`() { + val ui = listOf( + MessageRecord.ToolCall( + id = "call-1", + conversationId = "c", + toolName = "shell", + toolTitle = "Выполнить", + toolArgsJson = "{\"cmd\":\"ls\"}", + createdAt = at(1), + ), + MessageRecord.ToolResult( + id = "res-1", + conversationId = "c", + toolCallId = "call-1", + toolName = "shell", + result = "ok", + createdAt = at(2), + ), + ).toUiMessages() + + assertEquals(1, ui.size, "результат не должен создавать отдельную карточку") + val tool = ui.single() as UiMessage.Tool + assertEquals("call-1", tool.id) + assertEquals("shell", tool.toolName) + assertEquals("Выполнить", tool.title) + assertEquals("ok", tool.result) + } + + @Test + fun `orphan tool result becomes standalone card`() { + val ui = listOf( + MessageRecord.ToolResult( + id = "res-1", + conversationId = "c", + toolCallId = "missing", + toolName = null, + result = "data", + createdAt = at(1), + ), + ).toUiMessages() + + val tool = ui.single() as UiMessage.Tool + assertEquals("missing", tool.id) + assertEquals("(unknown)", tool.toolName) + assertEquals("data", tool.result) + } + + @Test + fun `error maps with code`() { + val ui = listOf( + MessageRecord.Error( + id = "e1", + conversationId = "c", + message = "упало", + code = "E42", + createdAt = at(1), + ), + ).toUiMessages() + + val error = ui.single() as UiMessage.Error + assertEquals("упало", error.message) + assertEquals("E42", error.code) + } + + @Test + fun `order is preserved across mixed records`() { + val ui = listOf( + MessageRecord.UserMessage("u", "c", listOf(Content.Text("1")), at(1)), + MessageRecord.ToolCall("t", "c", "shell", null, "{}", at(2)), + MessageRecord.ToolResult("r", "c", "t", "shell", "out", at(3)), + MessageRecord.AssistantMessage("a", "c", listOf(Content.Text("2")), at(4)), + ).toUiMessages() + + assertEquals(3, ui.size) + assertTrue(ui[0] is UiMessage.User) + assertTrue(ui[1] is UiMessage.Tool) + assertTrue(ui[2] is UiMessage.Assistant) + } + + @Test + fun `image content is preserved`() { + val bytes = byteArrayOf(1, 2, 3) + val ui = listOf( + MessageRecord.AssistantMessage( + id = "a", + conversationId = "c", + content = listOf(Content.Image(bytes, "image/png")), + createdAt = at(1), + ), + ).toUiMessages() + + val image = (ui.single() as UiMessage.Assistant).content.single() as UiContent.Image + assertEquals("image/png", image.mime) + assertTrue(bytes.contentEquals(image.data)) + } +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/persistence/ConversationMetaRepositoryIT.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/persistence/ConversationMetaRepositoryIT.kt new file mode 100644 index 0000000..37b2aa3 --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/persistence/ConversationMetaRepositoryIT.kt @@ -0,0 +1,243 @@ +package pw.binom.agentik.desktop.persistence + +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Интеграционные тесты [ConversationMetaRepository]. Каждый тест получает + * свежий `:memory:` репозиторий в [setUp], закрывается в [tearDown] — + * состояние не пересекается. + * + * Имя с суффиксом `IT` — по конвенции тестов этого проекта (см. + * `REQUIREMENTS.md`/настройки тестов): настоящие интеграционные тесты + * с реальной SQLite под капотом, не моки. + * + * **Что покрыто:** + * - Group CRUD: create с автоинкрементом id, list сортируется по имени, + * rename, delete с каскадом (`group_id → NULL` у диалогов). + * - Meta UPSERT-семантика: `setGroup` / `markSeen` / `setPreview` — + * каждое поле пишется независимо, остальные не затираются (тесты + * `markSeen does not overwrite group or preview` и т.п.). + * - `null`-семантика: `meta(unknown) == null`, `setGroup(c, null)` / + * `setPreview(c, null)` корректно сбрасывают. + * - Schema идемпотентна: повторное открытие файлового репозитория + * сохраняет данные. + */ +class ConversationMetaRepositoryIT { + + private lateinit var repo: ConversationMetaRepository + + @BeforeEach + fun setUp() { + repo = ConversationMetaRepository(":memory:") + } + + @AfterEach + fun tearDown() { + repo.close() + } + + // ───── Group CRUD ───── + + @Test + fun `createGroup assigns auto-incremented ids and list is sorted by name`() = runBlocking { + val z = repo.createGroup("Зима") + val a = repo.createGroup("Альфа") + val m = repo.createGroup("Лето") + + assertTrue(z.id > 0) + assertTrue(a.id > 0) + assertTrue(m.id > 0) + + // Сортировка по имени, не по id + val names = repo.listGroups().map { it.name } + assertEquals(listOf("Альфа", "Зима", "Лето"), names) + } + + @Test + fun `renameGroup updates name and returns true when group exists`() = runBlocking { + val g = repo.createGroup("Работа") + + assertTrue(repo.renameGroup(g.id, "Офис")) + + assertEquals("Офис", repo.listGroups().single { it.id == g.id }.name) + } + + @Test + fun `renameGroup returns false for nonexistent id`() = runBlocking { + assertFalse(repo.renameGroup(999_999L, "Что угодно")) + } + + @Test + fun `deleteGroup cascades group_id to NULL on conversations`() = runBlocking { + val work = repo.createGroup("Работа") + repo.setGroup("conv-1", work.id) + repo.setGroup("conv-2", work.id) + repo.setPreview("conv-1", "что-то") + + assertTrue(repo.deleteGroup(work.id)) + + // Группа исчезла + assertEquals(emptyList(), repo.listGroups()) + + // Диалоги пережили удаление (R9.7) — но без группы + val c1 = repo.meta("conv-1") + assertNotNull(c1) + assertNull(c1.groupId) + assertEquals("что-то", c1.preview) // preview не затерся каскадом + + val c2 = repo.meta("conv-2") + assertNotNull(c2) + assertNull(c2.groupId) + } + + @Test + fun `deleteGroup returns false and does not throw for nonexistent id`() = runBlocking { + assertFalse(repo.deleteGroup(999_999L)) + } + + // ───── Per-conv meta ───── + + @Test + fun `meta returns null for unknown convId`() = runBlocking { + assertNull(repo.meta("nope")) + } + + @Test + fun `setGroup upserts — first call inserts, second updates`() = runBlocking { + val g1 = repo.createGroup("G1") + val g2 = repo.createGroup("G2") + + repo.setGroup("c", g1.id) + assertEquals(g1.id, repo.meta("c")?.groupId) + + repo.setGroup("c", g2.id) + assertEquals(g2.id, repo.meta("c")?.groupId) + } + + @Test + fun `setGroup with null clears group_id but keeps the row`() = runBlocking { + val g = repo.createGroup("G") + repo.setGroup("c", g.id) + repo.setPreview("c", "preview") + + repo.setGroup("c", null) + + val meta = repo.meta("c") + assertNotNull(meta) + assertNull(meta.groupId) + assertEquals("preview", meta.preview) + } + + @Test + fun `markSeen does not overwrite group or preview`() = runBlocking { + val g = repo.createGroup("G") + val ts = Instant.fromEpochMilliseconds(12345L) + + repo.setGroup("c", g.id) + repo.setPreview("c", "preview-text") + repo.markSeen("c", ts) + + val meta = repo.meta("c") + assertEquals(g.id, meta?.groupId) + assertEquals("preview-text", meta?.preview) + assertEquals(ts, meta?.lastSeen) + } + + @Test + fun `setPreview does not overwrite group or lastSeen`() = runBlocking { + val g = repo.createGroup("G") + val ts = Instant.fromEpochMilliseconds(99_999L) + + repo.setGroup("c", g.id) + repo.markSeen("c", ts) + repo.setPreview("c", "first preview") + repo.setPreview("c", "second preview") // overwrite only preview + + val meta = repo.meta("c") + assertEquals(g.id, meta?.groupId) + assertEquals(ts, meta?.lastSeen) + assertEquals("second preview", meta?.preview) + } + + @Test + fun `setPreview with null clears preview but keeps group and lastSeen`() = runBlocking { + val g = repo.createGroup("G") + val ts = Instant.fromEpochMilliseconds(7L) + + repo.setGroup("c", g.id) + repo.markSeen("c", ts) + repo.setPreview("c", "old") + + repo.setPreview("c", null) + + val meta = repo.meta("c") + assertEquals(g.id, meta?.groupId) + assertEquals(ts, meta?.lastSeen) + assertNull(meta?.preview) + } + + @Test + fun `listMetas returns all rows including ones with all-null fields`() = runBlocking { + repo.setGroup("c1", null) // строка c groupId=null, lastSeen=null, preview=null + repo.setGroup("c2", null) + repo.setPreview("c2", "x") // апдейтим только preview + val ts = Instant.fromEpochMilliseconds(1L) + repo.markSeen("c3", ts) // строка только с lastSeen + + val metas = repo.listMetas().associateBy { it.convId } + assertEquals(3, metas.size) + + assertNotNull(metas["c1"]) + assertNull(metas["c1"]?.groupId) + assertNull(metas["c1"]?.lastSeen) + assertNull(metas["c1"]?.preview) + + assertNull(metas["c2"]?.groupId) + assertEquals("x", metas["c2"]?.preview) + + assertEquals(ts, metas["c3"]?.lastSeen) + assertNull(metas["c3"]?.preview) + } + + // ───── Persistence across instances (только для файлового режима) ───── + + @Test + fun `schema migration is idempotent — reopen preserves data`() = runBlocking { + val path = File.createTempFile("agentik-meta-", ".db").also { it.deleteOnExit() }.absolutePath + + ConversationMetaRepository(path).use { r -> + r.createGroup("Persistent") + r.createGroup("Group2") + } + + ConversationMetaRepository(path).use { r -> + val names = r.listGroups().map { it.name }.sorted() + assertEquals(listOf("Group2", "Persistent"), names) + } + } + + @Test + fun `independent instances on different paths do not see each other`() = runBlocking { + val pathA = File.createTempFile("agentik-meta-a-", ".db").also { it.deleteOnExit() }.absolutePath + val pathB = File.createTempFile("agentik-meta-b-", ".db").also { it.deleteOnExit() }.absolutePath + + ConversationMetaRepository(pathA).use { rA -> + rA.createGroup("OnlyInA") + ConversationMetaRepository(pathB).use { rB -> + rB.createGroup("OnlyInB") + assertEquals(listOf("OnlyInB"), rB.listGroups().map { it.name }) + } + assertEquals(listOf("OnlyInA"), rA.listGroups().map { it.name }) + } + } +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/AgentConnectionE2ETest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/AgentConnectionE2ETest.kt new file mode 100644 index 0000000..9557ca0 --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/AgentConnectionE2ETest.kt @@ -0,0 +1,325 @@ +package pw.binom.agentik.desktop.session + +import io.ktor.server.cio.CIO as ServerCIO +import io.ktor.server.engine.embeddedServer +import io.ktor.server.routing.routing +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import pw.binom.agentik.desktop.model.UiContent +import pw.binom.agentik.desktop.model.UiMessage +import pw.binom.agentik.desktop.settings.AgentConfig +import pw.binom.agentik.journal.ConversationRecord +import pw.binom.agentik.journal.ConversationStore +import pw.binom.agentik.journal.MessageRecord +import pw.binom.agentik.journal.MutableConversationStore +import pw.binom.agentik.journal.MutableJournalStore +import pw.binom.agentik.journal.inmemory.InMemoryJournalStore +import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore +import pw.binom.agentik.outbox.AgentEvent +import pw.binom.agentik.outbox.CommonEvent +import pw.binom.agentik.outbox.Event +import pw.binom.agentik.outbox.MutableOutboxStore +import pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore +import pw.binom.agentik.proto.Agent +import pw.binom.agentik.proto.AgentInfo +import pw.binom.agentik.proto.Content +import pw.binom.agentik.proto.Conversation +import pw.binom.agentik.proto.Message +import pw.binom.agentik.proto.MessageContext +import pw.binom.agentik.server.agentikAgent +import kotlin.test.AfterTest +import kotlin.test.Test +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +/** + * Сквозной тест: настоящий `:server` (HTTP/JSON/SSE) поверх in-memory-агента, + * а с другой стороны — наш `AgentConnection` + `ChatSession`. Проверяем, что + * десктопный стек работает по живому протоколу: создать диалог → отправить → + * увидеть стриминг → после `End` история в локальном SQLite → повторное + * открытие не плодит дублей. + * + * Никаких моков протокола: `AgentikAgent` (HTTP) ↔ `embeddedServer` ↔ + * `agentikAgent(fake)`. Именно это ловит расхождения JSON/SSE-контракта, + * которые юнит-тесты с ручными фейками не видят. + */ +class AgentConnectionE2ETest { + + private lateinit var server: io.ktor.server.engine.EmbeddedServer<*, *> + private var port: Int = 0 + + @AfterTest + fun tearDown() { + if (::server.isInitialized) server.stop(100, 300) + } + + private fun startServer(agent: Agent) { + server = embeddedServer(ServerCIO, port = 0, host = "127.0.0.1") { + routing { agentikAgent(agent, path = "/agentik", token = null) } + }.start(wait = false) + port = runBlocking { server.engine.resolvedConnectors().first().port } + } + + @Test + fun `send streams through real server and persists into local sqlite`() = runBlocking { + val fake = EchoAgent() + startServer(fake) + + val conn = AgentConnection.open( + config = AgentConfig.create(baseUrl = "http://127.0.0.1:$port/agentik"), + clientId = "e2e-client", + scope = this, + ) + try { + val conv = assertNotNull(conn.createConversation(temp = false), "createConversation") + val session = assertNotNull(conn.openSession(conv.id), "openSession") + try { + session.start() + + val beforeTurn = Clock.System.now() - 50.milliseconds + session.send("привет") + + await(timeoutMs = 15_000) { + session.history.value.size >= 2 && !session.isStreaming + } + + val history = session.history.value + assertEquals(2, history.size, "user + assistant: $history") + val user = history[0] as UiMessage.User + assertEquals("привет", (user.content.single() as UiContent.Text).body) + val bot = history[1] as UiMessage.Assistant + assertEquals("эхо: привет", (bot.content.single() as UiContent.Text).body) + + // Локальный кэш — на диске (agentik.db), а не в HTTP. + val cached = conn.journalCache.list(conv.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE) + assertEquals(2, cached.size, "cache records") + + // Превью денормализовано из ответа ассистента. + assertEquals("эхо: привет", conn.meta.meta(conv.id)?.preview) + assertNotNull(conn.meta.meta(conv.id)?.syncedAt) + + // Счётчик непрочитанных считается сервером от lastSeen. + // Сначала дожидаемся, пока `:client`-кэш списка диалогов + // подтянет созданный диалог (seed/live-refresh асинхронны). + await(timeoutMs = 10_000) { + conn.conversationStore.list(offset = 0, limit = 500).any { it.id == conv.id } + } + conn.meta.markSeen(conv.id, beforeTurn) + val ui = conn.uiConversations() + val row = ui.first { it.id == conv.id } + assertEquals(2, row.unread, "unread since beforeTurn") + + // Повторное открытие читает из кэша и НЕ дублирует бэкфиллом. + val again = assertNotNull(conn.openSession(conv.id)) + try { + again.start() + assertEquals(2, again.history.value.size, "reopen must not duplicate") + } finally { + again.close() + } + } finally { + session.close() + } + } finally { + conn.close() + } + } + + @Test + fun `interrupt emits interrupted and commits nothing`() = runBlocking { + val fake = EchoAgent(interruptible = true) + startServer(fake) + + val conn = AgentConnection.open( + config = AgentConfig.create(baseUrl = "http://127.0.0.1:$port/agentik"), + clientId = "e2e-client", + scope = this, + ) + try { + val conv = assertNotNull(conn.createConversation(temp = false)) + val session = assertNotNull(conn.openSession(conv.id)) + try { + session.start() + + val sendJob = launch { session.send("прервись") } + // Ждём, пока сервер реально вошёл в send (user-сообщение в журнале). + await(timeoutMs = 10_000) { fake.journal.count(conv.id) >= 1 } + session.interrupt() + await(timeoutMs = 10_000) { !session.isStreaming } + sendJob.join() + + assertTrue( + session.history.value.none { it is UiMessage.Assistant }, + "interrupted turn must not commit an assistant message", + ) + } finally { + session.close() + } + } finally { + conn.close() + } + } + + private suspend fun await(timeoutMs: Long = 5_000, condition: suspend () -> Boolean) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + if (condition()) return + delay(25) + } + assertTrue(condition(), "condition not met within ${timeoutMs}ms") + } +} + +/** + * Эхо-агент: на любой `send` отвечает `эхо: <текст>`, эмитит полный набор + * live-событий и пишет пару user/assistant в журнал — ровно то, что делает + * настоящий ChatAgent, но без LLM. + * + * @param interruptible если true — между `Working` и ответом ждёт + * `Interrupted`; ход закрывается прерыванием, ответ НЕ пишется. + */ +private class EchoAgent(private val interruptible: Boolean = false) : Agent { + + override val id: String = "e2e-echo" + + override val info: AgentInfo = AgentInfo( + name = "Эхо", + description = "Тестовый эхо-агент для сквозного теста", + ) + + override val journal: MutableJournalStore = InMemoryJournalStore() + + override val outbox = InMemoryOutboxStore(maxMessages = null, ttl = null) + + private val store: MutableConversationStore = InMemoryMutableConversationStore() + + override val conversationStore: ConversationStore get() = store + + private val conversations = mutableMapOf() + private var seq = 0 + + override fun createConversation(temp: Boolean): Conversation { + val id = "conv-${++seq}" + val now = Clock.System.now() + val conv = EchoConversation(id, journal, outbox, store, now, interruptible) + conversations[id] = conv + runBlocking { + store.upsert(ConversationRecord(id, null, temp, now, now)) + outbox.append(CommonEvent.Agent(now, AgentEvent.Created(now, id))) + } + return conv + } + + override suspend fun getConversation(id: String): Conversation? = conversations[id] + + override suspend fun deleteConversation(id: String): Boolean { + val conv = conversations.remove(id) ?: return false + conv.close() + store.delete(id) + journal.clear(id) + val now = Clock.System.now() + outbox.append(CommonEvent.Agent(now, AgentEvent.Deleted(now, id))) + return true + } + + override suspend fun renameConversation(id: String, title: String?): Instant? = + store.rename(id, title) + + override fun close() {} +} + +private class EchoConversation( + override val id: String, + private val journal: MutableJournalStore, + private val outbox: MutableOutboxStore, + private val store: MutableConversationStore, + createdAt: Instant, + private val interruptible: Boolean, +) : Conversation { + + override val isSupportImageInput: Boolean = false + override val isSupportImageOutput: Boolean = false + override val isTemporal: Boolean = false + override var title: String? = null + private var updated: Instant = createdAt + override val updatedAt: Instant get() = updated + + private var msgSeq = 0 + + /** + * id сообщения с zero-padded счётчиком В НАЧАЛЕ: журнал сортирует по + * `createdAt ASC, id ASC`, а фейк штампует user- и assistant-сообщение + * в одну и ту же миллисекунду — без padding'а лексикографически + * `...-a2` < `...-u1` и ассистент «уезжает» в начало истории. + */ + private fun nextMsgId(convId: String, role: String): String = + "$convId-${(++msgSeq).toString().padStart(6, '0')}-$role" + private val interrupted = java.util.concurrent.atomic.AtomicBoolean(false) + + override suspend fun rename(title: String) { + this.title = title + } + + override suspend fun interrupt() { + interrupted.set(true) + val now = Clock.System.now() + emit(now, Event.Interrupted(now)) + } + + override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List = emptyList() + + override suspend fun send(content: List, context: MessageContext?) { + val now = Clock.System.now() + journal.append( + MessageRecord.UserMessage(nextMsgId(id, "user"), id, content.toJournal(), now, context = null), + ) + emit(now, Event.Working(now)) + + if (interruptible) { + // Имитация долгого LLM-цикла: крутимся, пока не придёт interrupt(). + while (!interrupted.get()) delay(20) + return + } + + val text = content.filterIsInstance().joinToString("") { it.body } + val reply = "эхо: $text" + emit(now, Event.StartResponse(now, Event.ResponseType.TEXT)) + emit(now, Event.AppendText(now, reply)) + + val endAt = Clock.System.now() + journal.append( + MessageRecord.AssistantMessage( + nextMsgId(id, "assistant"), + id, + listOf(pw.binom.agentik.journal.Content.Text(reply)), + endAt, + ), + ) + emit(endAt, Event.End(endAt)) + + updated = endAt + store.touch(id, endAt) + outbox.append(CommonEvent.Agent(endAt, AgentEvent.Touched(endAt, id, endAt))) + } + + private suspend fun emit(at: Instant, event: Event) { + outbox.append(CommonEvent.Conversation(at, id, event)) + } + + override fun close() {} +} + +/** `:proto.Content` → `:journal.Content` (в проде это делает storage-impl). */ +private fun List.toJournal(): List = map { c -> + when (c) { + is Content.Text -> pw.binom.agentik.journal.Content.Text(c.body) + is Content.Image -> pw.binom.agentik.journal.Content.Image(c.data, c.mime) + } +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/AgentRegistryTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/AgentRegistryTest.kt new file mode 100644 index 0000000..88a5af8 --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/AgentRegistryTest.kt @@ -0,0 +1,128 @@ +package pw.binom.agentik.desktop.session + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.io.TempDir +import pw.binom.agentik.desktop.settings.AgentConfig +import pw.binom.agentik.desktop.settings.SettingsRepository +import pw.binom.agentik.desktop.settings.ThemeId +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Тесты [AgentRegistry]: persist настроек и переходы статусов. Подключение + * подменено [AgentConnector], который всегда падает — так проверяем + * деградацию в [AgentStatus.Offline] без сети. Успешное [AgentStatus.Online] + * потребовало бы настоящего [AgentConnection] (его конструктор приватный), + * поэтому проверяется косвенно — см. подключение к реальному серверу вручную. + */ +class AgentRegistryTest { + + @TempDir + lateinit var dir: Path + + private fun registry(scope: CoroutineScope) = AgentRegistry( + settingsRepo = SettingsRepository.inDirectory(dir), + scope = scope, + connector = { _, _ -> throw RuntimeException("нет сети") }, + ) + + @Test + fun `add agent persists config and goes offline on failure`() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val reg = registry(scope) + try { + reg.load() + val cfg = AgentConfig.create("http://192.168.1.5:8080/agentik", nameOverride = "Тестовый") + val status = reg.addAgent(cfg) + + assertTrue(status is AgentStatus.Offline) + assertEquals(1, reg.settings.value.agents.size) + assertNull(reg.connection(cfg.id)) + + // persist переживает переоткрытие + val reopened = SettingsRepository.inDirectory(dir).load() + assertEquals(listOf(cfg), reopened.agents) + } finally { + reg.close() + scope.cancel() + } + } + + @Test + fun `update agent persists new baseUrl`() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val reg = registry(scope) + try { + reg.load() + val cfg = AgentConfig.create("http://old:8080/agentik") + reg.addAgent(cfg) + + reg.updateAgent(cfg.copy(baseUrl = "http://new:9090/agentik")) + + assertEquals("http://new:9090/agentik", reg.settings.value.agents.single().baseUrl) + assertEquals("http://new:9090/agentik", SettingsRepository.inDirectory(dir).load().agents.single().baseUrl) + } finally { + reg.close() + scope.cancel() + } + } + + @Test + fun `remove agent drops it from settings and statuses`() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val reg = registry(scope) + try { + reg.load() + val cfg = AgentConfig.create("http://host:8080/agentik") + reg.addAgent(cfg) + + reg.removeAgent(cfg.id) + + assertTrue(reg.settings.value.agents.isEmpty()) + assertNull(reg.statuses.value[cfg.id]) + assertTrue(SettingsRepository.inDirectory(dir).load().agents.isEmpty()) + } finally { + reg.close() + scope.cancel() + } + } + + @Test + fun `updateSettings persists theme`() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val reg = registry(scope) + try { + reg.load() + reg.updateSettings { it.copy(themeId = ThemeId.LIGHT) } + + assertEquals(ThemeId.LIGHT, reg.settings.value.themeId) + assertEquals(ThemeId.LIGHT, SettingsRepository.inDirectory(dir).load().themeId) + } finally { + reg.close() + scope.cancel() + } + } + + @Test + fun `update nonexistent agent is offline and does not add it`() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val reg = registry(scope) + try { + reg.load() + val status = reg.updateAgent(AgentConfig.create("http://host:8080/agentik")) + + assertTrue(status is AgentStatus.Offline) + assertTrue(reg.settings.value.agents.isEmpty()) + } finally { + reg.close() + scope.cancel() + } + } +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/ChatSessionTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/ChatSessionTest.kt new file mode 100644 index 0000000..e0bf75f --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/ChatSessionTest.kt @@ -0,0 +1,325 @@ +package pw.binom.agentik.desktop.session + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.jupiter.api.Test +import pw.binom.agentik.desktop.model.UiMessage +import pw.binom.agentik.desktop.persistence.ConversationMetaRepository +import pw.binom.agentik.journal.Content as JournalContent +import pw.binom.agentik.journal.JournalStore +import pw.binom.agentik.journal.MessageRecord +import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore +import pw.binom.agentik.outbox.CommonEvent +import pw.binom.agentik.outbox.Event +import pw.binom.agentik.outbox.OutboxStore +import pw.binom.agentik.proto.Content +import pw.binom.agentik.proto.Conversation +import pw.binom.agentik.proto.Message +import pw.binom.agentik.proto.MessageContext +import pw.binom.db.ksqlite.SQLiteConnection +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Тесты [ChatSession] на фейковом транспорте: реальные `KsqliteJournalStore` + * и `ConversationMetaRepository` (in-memory SQLite), но `JournalStore` / + * `OutboxStore` / `Conversation` — подделки. Проверяют склейку + * «бэкфилл → кэш → live-ход → End → история» без сети. + * + * Используют `runBlocking` (как `ConversationMetaRepositoryIT`), а не + * `runTest`: репозитории под капотом уходят в `Dispatchers.Default`, и + * виртуальное время `runTest` их не дожидается. Вместо этого — [await] + * с реальным `delay`. + */ +class ChatSessionTest { + + private val convId = "conv-1" + + private fun at(ms: Long) = Instant.fromEpochMilliseconds(ms) + + private fun assistant(id: String, ms: Long, body: String) = MessageRecord.AssistantMessage( + id = id, + conversationId = convId, + content = listOf(JournalContent.Text(body)), + createdAt = at(ms), + ) + + private fun user(id: String, ms: Long, body: String) = MessageRecord.UserMessage( + id = id, + conversationId = convId, + content = listOf(JournalContent.Text(body)), + createdAt = at(ms), + ) + + /** Ждёт выполнения условия или падает по таймауту. */ + private suspend fun await(timeoutMs: Long = 5_000, condition: suspend () -> Boolean) { + val ok = withTimeoutOrNull(timeoutMs) { + while (!condition()) delay(5) + true + } + check(ok == true) { "await: условие не выполнено за ${timeoutMs}мс" } + } + + private class Fixture { + val connection: SQLiteConnection = SQLiteConnection.memory() + val cache = KsqliteJournalStore(connection) + val meta = ConversationMetaRepository(connection) + val outbox = FakeOutboxStore() + var remote = FakeJournalStore() + val conversation = FakeConversation("conv-1") + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + fun session() = ChatSession( + conversationId = "conv-1", + conversation = conversation, + remoteJournal = remote, + journalCache = cache, + meta = meta, + outbox = outbox, + scope = scope, + ) + + fun close() { + scope.cancel() + cache.close() + meta.close() + connection.close() + } + } + + @Test + fun `start backfills remote journal into local cache`() = runBlocking { + val f = Fixture() + f.remote.records = listOf(user("u1", 1, "привет"), assistant("a1", 2, "здравствуй")) + val session = f.session() + try { + session.start() + await { session.history.value.size == 2 } + + assertEquals(2, session.history.value.size) + // watermark = createdAt последней записи минус 1 мс (см. backfill: + // на миллисекундной границе assistant-сообщение делит timestamp с + // user-сообщением, и строгий `createdAt > after` его бы потерял). + assertEquals(at(1), f.meta.meta(convId)?.syncedAt) + } finally { + f.close() + } + } + + @Test + fun `second start does not duplicate history`() = runBlocking { + val f = Fixture() + f.remote.records = listOf(user("u1", 1, "привет"), assistant("a1", 2, "здравствуй")) + val session = f.session() + try { + session.start() + await { session.history.value.size == 2 } + session.start() + delay(50) + + assertEquals(2, session.history.value.size) + } finally { + f.close() + } + } + + @Test + fun `legacy cache without watermark tolerates duplicate backfill`() = runBlocking { + val f = Fixture() + // Кэш уже содержит сообщение, но meta.syncedAt пуст (БД до миграции). + f.cache.append(user("u1", 1, "старое")) + f.remote.records = listOf(user("u1", 1, "старое"), assistant("a1", 2, "новое")) + val session = f.session() + try { + session.start() + await { session.history.value.size == 2 } + + // Дубль u1 не уронил вставку, обе записи на месте. + assertEquals(2, session.history.value.size) + } finally { + f.close() + } + } + + @Test + fun `live turn streams then collapses into history on End`() = runBlocking { + val f = Fixture() + val session = f.session() + try { + session.start() + f.outbox.awaitSubscriber() + + f.outbox.emit(Event.Working(at(10))) + f.outbox.emit(Event.StartResponse(at(11), Event.ResponseType.TEXT)) + f.outbox.emit(Event.AppendText(at(12), "Hel")) + f.outbox.emit(Event.AppendText(at(13), "lo")) + await { session.live.value.size == 1 } + + assertTrue(session.live.value.single() is UiMessage.Assistant) + assertTrue(session.isStreaming) + assertEquals("Hello", (session.live.value.single() as UiMessage.Assistant) + .let { (it.content.single() as pw.binom.agentik.desktop.model.UiContent.Text).body }) + + // Сервер зафиксировал ход в журнале и прислал End. + f.remote.records = listOf(user("u1", 9, "вопрос"), assistant("a1", 14, "Hello")) + f.outbox.emit(Event.End(at(14))) + await { session.live.value.isEmpty() && session.history.value.size == 2 } + + assertTrue(session.live.value.isEmpty()) + assertTrue(!session.isStreaming) + } finally { + f.close() + } + } + + @Test + fun `backfill keeps a reply that shares its millisecond with the watermark`() = runBlocking { + val f = Fixture() + // Кэшируем user-сообщение; watermark встанет на его же миллисекунду. + f.remote.records = listOf(user("u1", 5, "вопрос")) + val session = f.session() + try { + session.start() + await { session.history.value.size == 1 } + + // Ответ пришёл в ТУ ЖЕ миллисекунду (быстрый локальный агент). + // Фильтр `createdAt > after` потерял бы его, если бы watermark + // стоял ровно на 5 мс — отсюда отступ назад на 1 мс + отсечение + // известных id вместо опоры на время. + f.remote.records = listOf(user("u1", 5, "вопрос"), assistant("a1", 5, "ответ")) + f.outbox.emit(Event.End(at(5))) + + await { session.history.value.size == 2 } + // Порядок при равных timestamp'ах задаёт id (контракт SQL + // `createdAt ASC, id ASC`) — здесь он не важен, важно что оба + // сообщения доехали: ответ не потерян на границе watermark'а. + assertEquals(1, session.history.value.count { it is UiMessage.User }) + assertEquals(1, session.history.value.count { it is UiMessage.Assistant }) + } finally { + f.close() + } + } + + @Test + fun `interrupt clears live turn without committing`() = runBlocking { + val f = Fixture() + val session = f.session() + try { + session.start() + f.outbox.awaitSubscriber() + + f.outbox.emit(Event.StartResponse(at(10), Event.ResponseType.TEXT)) + f.outbox.emit(Event.AppendText(at(11), "частичный")) + await { session.live.value.size == 1 } + + f.outbox.emit(Event.Interrupted(at(12))) + await { session.live.value.isEmpty() } + + assertTrue(session.live.value.isEmpty()) + assertTrue(session.history.value.isEmpty()) + } finally { + f.close() + } + } + + @Test + fun `preview is denormalized from last assistant message`() = runBlocking { + val f = Fixture() + f.remote.records = listOf( + user("u1", 1, "привет"), + assistant("a1", 2, "Большой ответ\nв несколько строк"), + ) + val session = f.session() + try { + session.start() + await { f.meta.meta(convId)?.preview != null } + + assertEquals("Большой ответ в несколько строк", f.meta.meta(convId)?.preview) + } finally { + f.close() + } + } +} + +// ───────────────────────── fakes ───────────────────────── + +private class FakeJournalStore( + var records: List = emptyList(), +) : JournalStore { + override suspend fun list( + conversationId: String, + after: Instant, + offset: Int, + limit: Int, + ): List = records + .filter { it.conversationId == conversationId && it.createdAt > after } + .sortedWith(compareBy({ it.createdAt }, { it.id })) + .drop(offset) + .take(limit) + + override suspend fun count(conversationId: String): Long = + records.count { it.conversationId == conversationId }.toLong() + + override suspend fun count(conversationId: String, after: Instant): Long = + records.count { it.conversationId == conversationId && it.createdAt > after }.toLong() + + override fun close() {} +} + +private class FakeOutboxStore : OutboxStore { + private val shared = MutableSharedFlow(replay = 256, extraBufferCapacity = 256) + + override fun events(after: Instant?): Flow = shared.asSharedFlow() + + override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST + + override fun close() {} + + /** Ждёт, пока у потока появится подписчик (ChatSession подписался). */ + suspend fun awaitSubscriber() { + withTimeoutOrNull(5_000) { + while (shared.subscriptionCount.value == 0) delay(5) + } + } + + suspend fun emit(event: Event, conversationId: String = "conv-1") { + shared.emit(CommonEvent.Conversation(date = event.date, conversationId = conversationId, event = event)) + } +} + +private class FakeConversation( + override val id: String, + override val isSupportImageInput: Boolean = true, + override val isSupportImageOutput: Boolean = true, + override val isTemporal: Boolean = false, + override var title: String? = null, + override var updatedAt: Instant = Instant.fromEpochMilliseconds(0), +) : Conversation { + val sent = mutableListOf>() + var interruptCount = 0 + + override suspend fun rename(title: String) { + this.title = title + } + + override suspend fun send(content: List, context: MessageContext?) { + sent += content + } + + override suspend fun interrupt() { + interruptCount++ + } + + override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List = emptyList() + + override fun close() {} +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/LiveTurnTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/LiveTurnTest.kt new file mode 100644 index 0000000..238cd09 --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/LiveTurnTest.kt @@ -0,0 +1,209 @@ +package pw.binom.agentik.desktop.session + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import pw.binom.agentik.outbox.Event +import kotlin.time.Clock +import kotlin.time.Instant + +class LiveTurnTest { + + private val now = Clock.System.now() + private fun at() = Instant.fromEpochMilliseconds(now.toEpochMilliseconds()) + + private fun LiveTurn.feed(vararg events: Event) { + events.forEach { apply(it) } + } + + @Test + fun `full happy path accumulates reasoning and answer`() { + val turn = LiveTurn() + turn.feed( + Event.Working(at()), + Event.StartReasoning(at()), + Event.AppendText(at(), "сначала "), + Event.AppendText(at(), "подумаем"), + Event.StartResponse(at(), Event.ResponseType.TEXT), + Event.AppendText(at(), "Привет, "), + Event.AppendText(at(), "мир!"), + Event.End(at()), + ) + + assertEquals(TurnPhase.DONE, turn.phase) + val blocks = turn.blocks + assertEquals(2, blocks.size) + val reasoning = assertIs(blocks[0]) + assertEquals("сначала подумаем", reasoning.text.toString()) + val answer = assertIs(blocks[1]) + assertEquals("Привет, мир!", answer.text.toString()) + assertEquals("Привет, мир!", turn.answerText()) + } + + @Test + fun `tool call and result are stitched into one block`() { + val turn = LiveTurn() + turn.feed( + Event.Working(at()), + Event.StartResponse(at(), Event.ResponseType.TEXT), + Event.ToolCall(at(), id = "t1", title = "grep", toolName = "grep", toolArgs = "{\"q\":\"x\"}"), + Event.ToolResult(at(), toolCallId = "t1", toolName = "grep", result = "found 3"), + Event.End(at()), + ) + + val tools = turn.blocks.filterIsInstance() + assertEquals(1, tools.size) + assertEquals("t1", tools[0].callId) + assertEquals("grep", tools[0].toolName) + assertEquals("{\"q\":\"x\"}", tools[0].argsJson) + assertEquals("found 3", tools[0].result) + assertEquals(null, tools[0].failed) + } + + @Test + fun `orphan tool result creates a block`() { + val turn = LiveTurn() + turn.feed( + Event.Working(at()), + Event.StartResponse(at(), Event.ResponseType.TEXT), + Event.ToolResult(at(), toolCallId = "missing", toolName = null, result = "ok"), + ) + + val tool = turn.blocks.filterIsInstance().single() + assertEquals("missing", tool.callId) + assertEquals("(unknown)", tool.toolName) + assertEquals("ok", tool.result) + } + + @Test + fun `tool failed marks the block`() { + val turn = LiveTurn() + turn.feed( + Event.Working(at()), + Event.ToolCall(at(), id = "t9", title = null, toolName = "bash", toolArgs = "{}"), + Event.ToolFailed(at(), toolCallId = "t9", toolName = "bash", message = "boom", durationMs = 5), + ) + + val tool = turn.blocks.filterIsInstance().single() + assertEquals("boom", tool.failed) + } + + @Test + fun `error appends failure block and fails the turn`() { + val turn = LiveTurn() + turn.feed( + Event.Working(at()), + Event.StartResponse(at(), Event.ResponseType.TEXT), + Event.AppendText(at(), "partial"), + Event.Error(at(), message = "llm down", code = "E500"), + ) + + assertEquals(TurnPhase.FAILED, turn.phase) + val failure = turn.blocks.filterIsInstance().single() + assertEquals("llm down", failure.message) + assertEquals("E500", failure.code) + // Частичный ответ остаётся в блоках — UI решает, показывать ли его. + assertEquals("partial", turn.answerText()) + } + + @Test + fun `interrupted turn is terminal and keeps partial text`() { + val turn = LiveTurn() + turn.feed( + Event.Working(at()), + Event.StartResponse(at(), Event.ResponseType.TEXT), + Event.AppendText(at(), "не до конца"), + Event.Interrupted(at()), + ) + + assertEquals(TurnPhase.INTERRUPTED, turn.phase) + assertTrue(turn.phase.isTerminal) + assertEquals("не до конца", turn.answerText()) + } + + @Test + fun `text after tool call starts a new answer block`() { + val turn = LiveTurn() + turn.feed( + Event.Working(at()), + Event.StartResponse(at(), Event.ResponseType.TEXT), + Event.AppendText(at(), "до"), + Event.ToolCall(at(), id = "t1", title = null, toolName = "grep", toolArgs = "{}"), + Event.AppendText(at(), "после"), + ) + + val answers = turn.blocks.filterIsInstance() + assertEquals(2, answers.size) + assertEquals("до", answers[0].text.toString()) + assertEquals("после", answers[1].text.toString()) + assertEquals("допосле", turn.answerText()) + } + + @Test + fun `image response produces image block`() { + val bytes = byteArrayOf(1, 2, 3) + val turn = LiveTurn() + turn.feed( + Event.Working(at()), + Event.StartResponse(at(), Event.ResponseType.IMAGE), + Event.AppendImage(at(), body = bytes, mime = "image/png"), + Event.End(at()), + ) + + assertTrue(turn.blocks.filterIsInstance().isEmpty()) + val image = turn.blocks.filterIsInstance().single() + assertEquals("image/png", image.mime) + assertEquals(3, image.data.size) + } + + @Test + fun `working resets the previous turn`() { + val turn = LiveTurn() + turn.feed( + Event.Working(at()), + Event.StartResponse(at(), Event.ResponseType.TEXT), + Event.AppendText(at(), "старое"), + Event.End(at()), + ) + turn.feed(Event.Working(at()), Event.AppendText(at(), "новое")) + + assertEquals(1, turn.blocks.size) + assertEquals("новое", turn.answerText()) + } + + @Test + fun `append without explicit start response still streams as answer`() { + val turn = LiveTurn() + turn.feed(Event.Working(at()), Event.AppendText(at(), "без старта")) + + assertEquals(TurnPhase.RESPONDING, turn.phase) + assertEquals("без старта", turn.answerText()) + } + + @Test + fun `onChange fires on mutation`() { + val turn = LiveTurn() + var count = 0 + turn.onChange = { count++ } + turn.feed(Event.Working(at()), Event.AppendText(at(), "x")) + + assertEquals(2, count) + } + + @Test + fun `preview text collapses whitespace and truncates`() { + val turn = LiveTurn() + turn.feed( + Event.Working(at()), + Event.AppendText(at(), "Первая строка\n\nвторая строка "), + ) + assertEquals("Первая строка вторая строка", turn.previewText()) + + val long = LiveTurn() + long.feed(Event.Working(at()), Event.AppendText(at(), "слово ".repeat(60))) + val preview = long.previewText(maxLength = 30) + assertTrue(preview.length <= 31) + assertTrue(preview.endsWith("…")) + } +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/RealServerIT.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/RealServerIT.kt new file mode 100644 index 0000000..8b41d65 --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/RealServerIT.kt @@ -0,0 +1,195 @@ +package pw.binom.agentik.desktop.session + +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assumptions.assumeTrue +import pw.binom.agentik.desktop.model.UiContent +import pw.binom.agentik.desktop.model.UiMessage +import pw.binom.agentik.desktop.settings.AgentConfig +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Instant +import java.nio.file.Files + +/** + * Реальная интеграция с ЖИВЫМ `:standalone`-сервером (Qwen через vLLM), + * а не с in-memory-эхо-агентом из [AgentConnectionE2ETest]. + * + * Адрес берётся из env `AGENTIK_E2E_URL`, по умолчанию + * `http://127.0.0.1:8080/agentik`. Если сервер не поднят — тест + * **пропускается** (`assumeTrue`), поэтому `./gradlew jvmTest` остаётся + * зелёным без запущенного агента, а с ним — проверяет настоящий контракт: + * создать диалог → отправить → дождаться живого ответа LLM → история в + * локальном SQLite → повторное открытие не плодит дублей. + * + * БД уводится в temp-папку, пользовательский `~/.agentik-desktop` не + * трогается. + */ +class RealServerIT { + + private val connection = mutableListOf() + + @AfterTest + fun tearDown() { + connection.forEach { runCatching { it.close() } } + connection.clear() + } + + private fun url(): String = + System.getenv("AGENTIK_E2E_URL")?.takeIf { it.isNotBlank() } + ?: "http://127.0.0.1:8080/agentik" + + private fun connect(scope: kotlinx.coroutines.CoroutineScope, db: String): AgentConnection? = + runCatching { + AgentConnection.open( + config = AgentConfig.create(baseUrl = url()), + clientId = "agentik-desktop-real-e2e", + scope = scope, + dbPath = db, + ) + }.getOrNull() + + @Test + fun `live server answers and the turn lands in the local cache`() = runBlocking { + val dir = Files.createTempDirectory("agentik-real-e2e") + val db = dir.resolve("agentik.db").toString() + val conn = connect(this, db) + assumeTrue(conn != null, "standalone-сервер недоступен на ${url()} — тест пропущен") + conn!! + connection += conn + + val conv = conn.createConversation(temp = false) + assertNotNull(conv, "createConversation") + + val session = assertNotNull(conn.openSession(conv.id), "openSession") + try { + session.start() + session.send("Ответь ровно одним словом: привет") + + await(timeoutMs = 120_000) { + session.history.value.size >= 2 && !session.isStreaming + } + + val history = session.history.value + assertEquals(2, history.size, "user + assistant: $history") + val user = history[0] as UiMessage.User + assertEquals("Ответь ровно одним словом: привет", (user.content.single() as UiContent.Text).body) + + val bot = history[1] as UiMessage.Assistant + val answer = (bot.content.single() as UiContent.Text).body + assertTrue(answer.isNotBlank(), "LLM вернул пустой ответ") + + // Локальный кэш (offline-история) — на диске. + val cached = conn.journalCache.list(conv.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE) + assertEquals(2, cached.size, "cache records") + + // Превью + watermark денормализованы из ответа. + assertNotNull(conn.meta.meta(conv.id)?.preview) + assertNotNull(conn.meta.meta(conv.id)?.syncedAt) + + // Список диалогов от сервера содержит созданный. + await(timeoutMs = 20_000) { + conn.uiConversations().any { it.id == conv.id } + } + + // Переоткрытие читает из кэша и НЕ дублирует бэкфиллом. + val again = assertNotNull(conn.openSession(conv.id)) + try { + again.start() + assertEquals(2, again.history.value.size, "reopen must not duplicate") + } finally { + again.close() + } + } finally { + session.close() + } + } + + /** + * Сценарий «перезапуск клиента»: закрываем соединение и открываем + * заново на той же БД — история должна читаться из локального кэша, + * а не теряться. + */ + @Test + fun `history survives a reconnect on the same database`() = runBlocking { + val dir = Files.createTempDirectory("agentik-real-e2e-reconnect") + val db = dir.resolve("agentik.db").toString() + + val convId: String + run { + val conn = connect(this, db) + assumeTrue(conn != null, "standalone-сервер недоступен на ${url()} — тест пропущен") + conn!! + connection += conn + val conv = assertNotNull(conn.createConversation(temp = false)) + val session = assertNotNull(conn.openSession(conv.id)) + session.start() + session.send("Скажи 'ок'") + await(timeoutMs = 120_000) { session.history.value.size >= 2 && !session.isStreaming } + convId = conv.id + session.close() + conn.close() + connection -= conn + } + + val conn2 = assertNotNull(connect(this, db), "повторное подключение") + connection += conn2 + val session2 = assertNotNull(conn2.openSession(convId), "openSession после перезапуска") + try { + session2.start() + await(timeoutMs = 30_000) { session2.history.value.size >= 2 } + assertEquals(2, session2.history.value.size, "история из локального кэша после реконнекта") + } finally { + session2.close() + } + } + + /** + * Регрессия на реальный баг: `POST /conversations/{id}/messages` на + * сервере отвечает не сразу, а по завершении всего хода агента. Длинный + * ответ LLM (десятки секунд) обрывался дефолтным request-timeout'ом + * клиента (15000 мс) — ход умирал на клиенте, продолжая жить на сервере. + * Клиент обязан ставить на этот запрос infinite-timeout + * (`ConversationClient.send` → `noReadTimeout()`). + */ + @Test + fun `long llm answer outlives the default client request timeout`() = runBlocking { + val dir = Files.createTempDirectory("agentik-real-e2e-long") + val conn = connect(this, dir.resolve("agentik.db").toString()) + assumeTrue(conn != null, "standalone-сервер недоступен на ${url()} — тест пропущен") + conn!! + connection += conn + + val conv = assertNotNull(conn.createConversation(temp = false)) + val session = assertNotNull(conn.openSession(conv.id)) + try { + session.start() + session.send("Напиши связный текст примерно на 300 слов про историю SQLite. Без списков и заголовков.") + + await(timeoutMs = 300_000) { + session.history.value.size >= 2 && !session.isStreaming + } + + val answer = (session.history.value[1] as UiMessage.Assistant) + .content.filterIsInstance().joinToString("\n") { it.body } + assertTrue( + answer.length > 500, + "ожидался длинный ответ (иначе тест не проверяет таймаут), а пришло ${answer.length} символов: $answer", + ) + } finally { + session.close() + } + } + + private suspend fun await(timeoutMs: Long = 5_000, condition: suspend () -> Boolean) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + if (condition()) return + delay(50) + } + assertTrue(condition(), "condition not met within ${timeoutMs}ms") + } +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/settings/SettingsRepositoryTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/settings/SettingsRepositoryTest.kt new file mode 100644 index 0000000..0d9714e --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/settings/SettingsRepositoryTest.kt @@ -0,0 +1,124 @@ +package pw.binom.agentik.desktop.settings + +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Тесты [SettingsRepository]. Работают на реальном файле во временном + * каталоге JUnit ([TempDir]) — не моки, поэтому проверяют и атомарную + * запись, и устойчивость к битому файлу. + */ +class SettingsRepositoryTest { + + @TempDir + lateinit var dir: Path + + private fun repo() = SettingsRepository.inDirectory(dir) + + @Test + fun `first load creates file and generates clientId`() = runBlocking { + val settings = repo().load() + + assertTrue(settings.clientId.startsWith("agentik-desktop-")) + assertEquals(ThemeId.DARK, settings.themeId) + assertTrue(settings.agents.isEmpty()) + assertTrue(dir.resolve(SettingsRepository.FILE_NAME).toFile().exists()) + } + + @Test + fun `clientId is stable across reloads`() = runBlocking { + val first = repo().load().clientId + val second = repo().load().clientId + assertEquals(first, second) + } + + @Test + fun `agents round-trip`() = runBlocking { + val repository = repo() + val agent = AgentConfig.create( + baseUrl = "http://192.168.1.10:8080/agentik", + token = " secret ", + nameOverride = "Порфирий", + colorIndex = 2, + ) + repository.update { it.copy(agents = listOf(agent)) } + + val loaded = repository.load() + assertEquals(1, loaded.agents.size) + val a = loaded.agents.single() + assertEquals(agent.id, a.id) + assertEquals("http://192.168.1.10:8080/agentik", a.baseUrl) + assertEquals("secret", a.token) + assertEquals("Порфирий", a.nameOverride) + assertEquals(2, a.colorIndex) + } + + @Test + fun `create trims blanks to null`() { + val agent = AgentConfig.create(baseUrl = " http://x ", token = " ", nameOverride = "") + assertEquals("http://x", agent.baseUrl) + assertNull(agent.token) + assertNull(agent.nameOverride) + } + + @Test + fun `update applies transform over current value`() = runBlocking { + val repository = repo() + repository.update { it.copy(themeId = ThemeId.LIGHT, selectedGroupId = 7) } + + val loaded = repository.load() + assertEquals(ThemeId.LIGHT, loaded.themeId) + assertEquals(7L, loaded.selectedGroupId) + } + + @Test + fun `corrupt file falls back to defaults with fresh clientId`() = runBlocking { + dir.resolve(SettingsRepository.FILE_NAME).writeText("{ this is not json") + + val loaded = repo().load() + assertTrue(loaded.clientId.startsWith("agentik-desktop-")) + assertEquals(ThemeId.DARK, loaded.themeId) + } + + @Test + fun `unknown theme id is normalized to dark and persisted`() = runBlocking { + dir.resolve(SettingsRepository.FILE_NAME).writeText( + """{"clientId":"agentik-desktop-1","themeId":"neon"}""" + ) + + val loaded = repo().load() + assertEquals(ThemeId.DARK, loaded.themeId) + // Нормализованное значение должно уехать на диск. + assertTrue(dir.resolve(SettingsRepository.FILE_NAME).readText().contains("\"dark\"")) + } + + @Test + fun `blank clientId in file is filled and persisted`() = runBlocking { + dir.resolve(SettingsRepository.FILE_NAME).writeText("""{"clientId":""}""") + + val loaded = repo().load() + assertTrue(loaded.clientId.isNotBlank()) + assertTrue(dir.resolve(SettingsRepository.FILE_NAME).readText().contains(loaded.clientId)) + } + + @Test + fun `two repositories in different dirs keep separate clientIds`() = runBlocking { + val other = Files.createTempDirectory("agentik-settings") + try { + val a = repo().load().clientId + val b = SettingsRepository.inDirectory(other).load().clientId + assertNotEquals(a, b) + } finally { + other.toFile().deleteRecursively() + } + } +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/ui/RealAppStateIT.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/ui/RealAppStateIT.kt new file mode 100644 index 0000000..9cf4bb2 --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/ui/RealAppStateIT.kt @@ -0,0 +1,162 @@ +package pw.binom.agentik.desktop.ui + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.jupiter.api.Assumptions.assumeTrue +import pw.binom.agentik.desktop.model.UiMessage +import pw.binom.agentik.desktop.session.AgentConnection +import pw.binom.agentik.desktop.session.AgentRegistry +import pw.binom.agentik.desktop.session.AgentStatus +import pw.binom.agentik.desktop.settings.AgentConfig +import pw.binom.agentik.desktop.settings.SettingsRepository +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import java.net.HttpURLConnection +import java.net.URI +import java.nio.file.Files + +/** + * Приложение целиком (без пикселей) против ЖИВОГО `:standalone`-агента: + * настройки → реестр → [AppState] → диалог с настоящей LLM. + * + * Это замена GUI-прогону, когда vision-сервис недоступен: UI у нас — чистая + * функция от [AppState], а сам рендер уже проверен `ThemeRenderTest`. + * Проверяем то, что ломается только в жизни: создание диалога на сервере, + * отправку, живой ответ модели, попадание реплик в локальный кэш (историю), + * снятие «непрочитанного» после открытия. + * + * Адрес — `AGENTIK_E2E_URL` или `http://127.0.0.1:8080/agentik`; если сервер + * не поднят, тест **пропускается** (`assumeTrue`), как [RealServerIT]. + */ +class RealAppStateIT { + + private var registry: AgentRegistry? = null + private var state: AppState? = null + + // Свой scope, а не coroutineContext из runBlocking: у реестра/сессии живут + // вечные коллекторы (SSE, listFlow), и runBlocking ждал бы их вечно. + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @AfterTest + fun tearDown() { + runCatching { state?.shutdown() } + runCatching { registry?.close() } + scope.cancel() + } + + private fun url(): String = + System.getenv("AGENTIK_E2E_URL")?.takeIf { it.isNotBlank() } + ?: "http://127.0.0.1:8080/agentik" + + private fun reachable(url: String): Boolean = runCatching { + (URI("$url").toURL().openConnection() as HttpURLConnection).run { + connectTimeout = 1_500 + readTimeout = 1_500 + requestMethod = "GET" + responseCode in 200..499 + } + }.getOrDefault(false) + + private suspend fun await( + what: String, + timeoutMs: Long = 10_000, + condition: suspend () -> Boolean, + ) { + val ok = withTimeoutOrNull(timeoutMs) { + while (!condition()) delay(20) + true + } + check(ok == true) { "не дождались: $what (${timeoutMs}мс)" } + } + + @Test + fun `app state creates a dialog and gets a live answer from the real agent`() = runBlocking { + val base = url() + assumeTrue(reachable(base), "standalone-агент недоступен на $base — тест пропущен") + + val tmp = Files.createTempDirectory("agentik-appstate") + val reg = AgentRegistry.create( + settingsRepo = SettingsRepository(tmp.resolve("settings.json")), + scope = scope, + ) + registry = reg + val st = AppState(registry = reg, scope = scope) + state = st + + st.start() + st.addAgent(baseUrl = base, nameOverride = "Живой агент") + await("агент появился в настройках") { st.agents.isNotEmpty() } + val agentId = st.agents.first().id + + await("агент подключился", timeoutMs = 20_000) { + st.statusesState[agentId] is AgentStatus.Online + } + st.selectAgent(agentId) + await("список диалогов подтянулся с сервера") { st.conversations.isNotEmpty() } + + st.newConversation(agentId) + await("диалог открылся") { st.activeConversationId != null } + val convId = st.activeConversationId!! + + st.send("Ответь ровно одним словом: два плюс два?") + await("пришёл ответ ассистента", timeoutMs = 240_000) { + st.history.any { it is UiMessage.Assistant } + } + + val assistant = st.history.filterIsInstance().last() + val assistantText = assistant.content.filterIsInstance() + .joinToString(" ") { it.body } + assertTrue(assistantText.isNotBlank(), "ответ ассистента пустой") + assertTrue( + st.history.any { it is UiMessage.User }, + "реплика пользователя должна быть в истории (из локального кэша)", + ) + + // Превью денормализовано в локальную мету (для строки списка). + val preview = reg.connection(agentId)!!.meta.meta(convId)?.preview + assertNotNull(preview, "preview диалога должен записаться локально") + + // Открытие диалога помечает его просмотренным: непрочитанных нет. + val lastSeen = reg.connection(agentId)!!.meta.meta(convId)?.lastSeen + assertNotNull(lastSeen, "lastSeen должен выставиться при открытии") + + // История диалога переживает повторное открытие (тот же локальный кэш). + st.openConversation(convId) + await("история перезагрузилась из кэша") { st.history.isNotEmpty() } + assertTrue( + st.history.filterIsInstance().isNotEmpty(), + "после повторного открытия ответ должен остаться в истории", + ) + } + + @Test + fun `live dialog list arrives by itself right after connect`() = runBlocking { + val base = url() + assumeTrue(reachable(base), "standalone-агент недоступен на $base — тест пропущен") + + val tmp = Files.createTempDirectory("agentik-convflow") + val cfg = AgentConfig.create(baseUrl = base, nameOverride = "Живой агент") + val conn = AgentConnection.open( + config = cfg, + clientId = "agentik-desktop-it", + scope = scope, + dbPath = tmp.resolve("agentik.db").toString(), + ) + + // Seed внутри `AgentikAgent` асинхронный: список обязан приехать сам, + // без единого `refresh()` со стороны UI. Именно этого не хватало — + // приложение показывало пустой список диалогов. + await("живой список диалогов доехал сам", timeoutMs = 15_000) { + conn.conversationsFlow.value.isNotEmpty() + } + + conn.close() + } +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/ui/ThemeRenderTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/ui/ThemeRenderTest.kt new file mode 100644 index 0000000..15b5a8b --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/ui/ThemeRenderTest.kt @@ -0,0 +1,154 @@ +package pw.binom.agentik.desktop.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import pw.binom.agentik.desktop.ui.theme.AgentikColors +import pw.binom.agentik.desktop.ui.theme.AgentikTheme +import pw.binom.agentik.desktop.ui.theme.darkAgentikColors +import pw.binom.agentik.desktop.ui.theme.lightAgentikColors +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Палитра и реальный рендер Compose без открытия окна. + * + * Зачем: светлая тема до сих пор проверялась только глазами по скриншоту, а + * markdown-код-блок изначально хардкодил тёмный фон (`#1E1E1E`), из-за чего в + * светлой теме выглядел чужеродно. Здесь тема прогоняется через настоящий + * оффскрин-рендер, а цвета сверяются с проектными токенами. + */ +class ThemeRenderTest { + + // ── токены (THEMES.md / sketches/style.css) ──────────────────────────── + + @Test + fun darkPaletteMatchesDesignTokens() { + val c = darkAgentikColors + assertEquals(Color(0xFF121218), c.background) + assertEquals(Color(0xFF17212B), c.panel) + assertEquals(Color(0xFF1C2733), c.panelElevated) + assertEquals(Color(0xFF6AB2F2), c.accent) + assertEquals(Color(0xFFEBEBEB), c.text) + assertEquals(Color(0xFF768C9E), c.textDim) + assertEquals(Color(0xFFEC3942), c.error) + assertEquals(Color(0xFF2B5278), c.userBubble) + } + + @Test + fun lightPaletteKeepsSemanticSlots() { + val c = lightAgentikColors + assertEquals(Color(0xFFF5F6F8), c.background) + assertEquals(Color(0xFFFFFFFF), c.panel) + assertEquals(Color(0xFF2A6FB5), c.accent) + assertEquals(Color(0xFF1A1D23), c.text) + // светлый фон обязан быть светлым: контраст к тексту + assertTrue(luminance(c.background) > luminance(c.text), "фон светлее текста") + assertTrue(luminance(c.panel) > luminance(c.panelElevated), "panel светлее panelElevated") + assertTrue(luminance(c.botBubble) > luminance(c.text), "бот-бабл светлее текста") + } + + @Test + fun opaqueSlotsStayOpaqueAndScrimTransparent() { + for (c in listOf(darkAgentikColors, lightAgentikColors)) { + assertEquals(1f, c.background.alpha) + assertEquals(1f, c.panel.alpha) + assertEquals(1f, c.text.alpha) + assertEquals(1f, c.accent.alpha) + // scrim/selection — прозрачные по замыслу + assertTrue(c.scrim.alpha < 1f) + assertTrue(c.selection.alpha < 1f) + } + } + + // ── рендер ───────────────────────────────────────────────────────────── + + @OptIn(ExperimentalTestApi::class) + @Test + fun codeBlockPaintsThemeCodeBackgroundInLightTheme() = runComposeUiTest { + setContent { + AgentikTheme("light") { + Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) { + Column { + renderMarkdownSegments( + content = "```kotlin\nval x = 1\n```", + textColor = AgentikTheme.colors.text, + fontSize = 14.sp, + ) + } + } + } + } + val pixels = onRoot().captureToImage().toPixelMap() + val histogram = HashMap() + for (y in 0 until pixels.height) { + for (x in 0 until pixels.width) { + val color = pixels[x, y] + histogram[color] = (histogram[color] ?: 0) + 1 + } + } + val top = histogram.entries.sortedByDescending { it.value }.take(4) + .joinToString { "#${it.key.toHexArgb()}×${it.value}" } + assertTrue( + histogram.containsKey(lightAgentikColors.codeBackground), + "код-блок должен краситься в codeBackground светлой темы, а покрасился в: $top", + ) + assertFalse( + histogram.containsKey(Color(0xFF1E1E1E)), + "тёмный хардкод код-фона протёк в светлую тему: $top", + ) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun darkThemeStillRendersCodeBlockDark() = runComposeUiTest { + setContent { + AgentikTheme("dark") { + Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) { + Column { + renderMarkdownSegments( + content = "```kotlin\nval x = 1\n```", + textColor = AgentikTheme.colors.text, + fontSize = 14.sp, + ) + } + } + } + } + val pixels = onRoot().captureToImage().toPixelMap() + var found = false + for (y in 0 until pixels.height) { + for (x in 0 until pixels.width) { + if (pixels[x, y] == darkAgentikColors.codeBackground) { + found = true + break + } + } + } + assertTrue(found, "в тёмной теме код-блок должен краситься в dark codeBackground") + } + + private fun luminance(color: Color): Float = + 0.2126f * color.red + 0.7152f * color.green + 0.0722f * color.blue + + private fun Color.toHexArgb(): String { + val a = (alpha * 255).toInt() + val r = (red * 255).toInt() + val g = (green * 255).toInt() + val b = (blue * 255).toInt() + return "%02X%02X%02X%02X".format(a, r, g, b) + } +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/AudioTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/AudioTest.kt new file mode 100644 index 0000000..66d626b --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/AudioTest.kt @@ -0,0 +1,144 @@ +package pw.binom.agentik.desktop.voice + +import pw.binom.voice.vad.VadInstance +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Чистые преобразования звука: s16le → float и нарезка на окна VAD. + * Без нативных библиотек — только арифметика. + */ +class AudioTest { + + @Test + fun `s16le bytes become floats in minus one to one`() { + val pcm = byteArrayOf( + 0x00, 0x00, // 0 + 0xFF.toByte(), 0x7F, // +32767 + 0x00, 0x80.toByte(), // -32768 + 0x00, 0x40, // +16384 + ) + val floats = pcm16ToFloats(pcm) + + assertEquals(4, floats.size) + assertEquals(0f, floats[0]) + assertEquals(32767f / 32768f, floats[1]) + assertEquals(-1f, floats[2]) + assertEquals(0.5f, floats[3]) + } + + @Test + fun `negative values keep their sign`() { + // -1 в s16le = 0xFFFF + val floats = pcm16ToFloats(byteArrayOf(0xFF.toByte(), 0xFF.toByte())) + assertEquals(1, floats.size) + assertEquals(-1f / 32768f, floats[0]) + } + + @Test + fun `offset and length select a slice`() { + val pcm = byteArrayOf( + 0x11, 0x11, // пропустить + 0x00, 0x00, // 0 + 0x00, 0x40, // 0.5 + 0x22, 0x22, // пропустить + ) + val floats = pcm16ToFloats(pcm, offset = 2, length = 4) + + assertEquals(2, floats.size) + assertEquals(0f, floats[0]) + assertEquals(0.5f, floats[1]) + } + + @Test + fun `odd trailing byte is dropped`() { + val floats = pcm16ToFloats(byteArrayOf(0x00, 0x40, 0x7F)) + assertEquals(1, floats.size) + assertEquals(0.5f, floats[0]) + } + + @Test + fun `windower emits exact windows and keeps the remainder`() { + val windows = mutableListOf>() + val windower = PcmWindower(windowSize = 4) { windows.add(it.toList()) } + + // 4 → окно + windower.push(floatArrayOf(1f, 2f, 3f, 4f)) + assertEquals(1, windows.size) + assertEquals(listOf(1f, 2f, 3f, 4f), windows[0]) + assertEquals(0, windower.pending) + + // 2 → ждём + windower.push(floatArrayOf(5f, 6f)) + assertEquals(1, windows.size) + assertEquals(2, windower.pending) + + // 5 → добираем окно (5,6,7,8), остаётся (9,10,11) + windower.push(floatArrayOf(7f, 8f, 9f, 10f, 11f)) + assertEquals(2, windows.size) + assertEquals(listOf(5f, 6f, 7f, 8f), windows[1]) + assertEquals(3, windower.pending) + + // 12 → добираем (9,10,11,12) и снова пусто + windower.push(floatArrayOf(12f)) + assertEquals(3, windows.size) + assertEquals(listOf(9f, 10f, 11f, 12f), windows[2]) + assertEquals(0, windower.pending) + } + + @Test + fun `windower gives independent copies not aliased buffer`() { + val windows = mutableListOf() + val windower = PcmWindower(windowSize = 2) { windows.add(it) } + + windower.push(floatArrayOf(1f, 2f)) + windower.push(floatArrayOf(3f, 4f)) + + assertEquals(2, windows.size) + // Если бы отдавали внутренний буфер, первое окно уже было бы (3,4). + assertEquals(listOf(1f, 2f), windows[0].toList()) + assertEquals(listOf(3f, 4f), windows[1].toList()) + } + + @Test + fun `windower accepts a large single push`() { + val windows = mutableListOf() + val windower = PcmWindower(windowSize = 512) { windows.add(it) } + + windower.push(FloatArray(1600) { it.toFloat() }) + + assertEquals(3, windows.size) + assertEquals(1600 - 3 * 512, windower.pending) + assertEquals(512, windows[0].size) + assertEquals(0f, windows[0][0]) + assertEquals(511f, windows[0][511]) + // Второе окно начинается там, где кончилось первое. + assertEquals(512f, windows[1][0]) + } + + @Test + fun `empty push does nothing`() { + val windows = mutableListOf() + val windower = PcmWindower(windowSize = 4) { windows.add(it) } + windower.push(FloatArray(0)) + assertTrue(windows.isEmpty()) + assertEquals(0, windower.pending) + } + + @Test + fun `vad model reports low probability for silence`() { + // Реальная загрузка silero_vad.onnx из артефакта `vad-jvm` (нативная + // onnxruntime). Тишина не должна выглядеть речью. + VadInstance(VOICE_SAMPLE_RATE).use { vad -> + assertEquals(512, vad.windowSizeSamples) + val silenceProbability = vad.processingWindow(FloatArray(vad.windowSizeSamples)) + assertNotNull(silenceProbability) + assertTrue( + silenceProbability < VoskVoiceRecognizer.VAD_SPEECH_THRESHOLD, + "тишина дала вероятность речи $silenceProbability", + ) + } + } +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/RealSpeechIT.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/RealSpeechIT.kt new file mode 100644 index 0000000..bf54472 --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/RealSpeechIT.kt @@ -0,0 +1,95 @@ +package pw.binom.agentik.desktop.voice + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.sp +import pw.binom.agentik.desktop.ui.renderMarkdownSegments +import pw.binom.agentik.desktop.ui.theme.AgentikTheme +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Настоящая речь через весь продуктовый путь, в том порядке, в каком это + * происходит в приложении: + * + * 1. [VoiceNative.warmUp] — нативный ASR (Vosk + ONNX VAD) прогревается ДО skia; + * 2. инициализируется skiko (оффскрин-рендер Compose с текстом); + * 3. создаётся **новый** [VoskVoiceRecognizer] (свежая модель Vosk + свежий VAD) + * уже после skia и распознаёт реальный русский сэмпл. + * + * Шаг 3 — это ровно то, что делает кнопка микрофона в приложении (каждая запись + * создаёт свой распознаватель). Без шага 1 он валит JVM с SIGSEGV: см. + * [VoiceNative]. Реплика в `raz-dva-tri.wav` — «раз два три четыре пять» + * (16 кГц, моно, s16le). + */ +class RealSpeechIT { + + @OptIn(ExperimentalTestApi::class) + @Test + fun `speech is recognized after the compose window has been initialized`() { + // 1. как в main(): прогреваем нативный ASR до skiko. + assertTrue(VoiceNative.warmUp(), "нативный ASR должен прогреться до skiko") + assertTrue(VoiceNative.available) + + // 2. skiko/Compose инициализируется и реально рисует (как окно приложения). + runComposeUiTest { + setContent { + AgentikTheme("dark") { + Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble)) { + renderMarkdownSegments( + content = "проверка: **окно** уже отрисовано", + textColor = AgentikTheme.colors.text, + fontSize = 14.sp, + ) + } + } + } + } + + // 3. обычный путь распознавания — уже после skiko. + val recognizer = VoskVoiceRecognizer() + try { + val pcm = loadPcm("/voice/raz-dva-tri.wav") + assertTrue(pcm.size > VOICE_SAMPLE_RATE, "сэмпл должен быть длиннее секунды") + + var offset = 0 + val chunk = VOICE_SAMPLE_RATE / 10 * 2 + var maxPartial = "" + while (offset < pcm.size) { + val n = minOf(chunk, pcm.size - offset) + recognizer.feed(pcm.copyOfRange(offset, offset + n)) + offset += n + recognizer.partial()?.let { if (it.length > maxPartial.length) maxPartial = it } + } + + val final = recognizer.finish() + assertTrue(recognizer.speechDetected, "VAD должен увидеть речь (partials: '$maxPartial')") + assertTrue( + final.contains("раз") && final.contains("пять"), + "ожидали услышать «раз два три четыре пять», получили: '$final' (partials: '$maxPartial')", + ) + } finally { + recognizer.close() + } + } + + /** PCM-данные из WAV: пропускаем RIFF-заголовок до чанка `data`. */ + private fun loadPcm(resource: String): ByteArray { + val bytes = javaClass.getResourceAsStream(resource)!!.readBytes() + var i = 12 + while (i + 8 <= bytes.size) { + val id = String(bytes, i, 4, Charsets.US_ASCII) + val size = (bytes[i + 4].toInt() and 0xFF) or + ((bytes[i + 5].toInt() and 0xFF) shl 8) or + ((bytes[i + 6].toInt() and 0xFF) shl 16) or + ((bytes[i + 7].toInt() and 0xFF) shl 24) + if (id == "data") return bytes.copyOfRange(i + 8, minOf(i + 8 + size, bytes.size)) + i += 8 + size + (size and 1) + } + error("в $resource нет data-чанка") + } +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/VoiceControllerTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/VoiceControllerTest.kt new file mode 100644 index 0000000..43bc6e4 --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/VoiceControllerTest.kt @@ -0,0 +1,277 @@ +package pw.binom.agentik.desktop.voice + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.fail +import kotlin.test.assertTrue + +/** + * Состояния и колбэки [VoiceController] на фейковых источнике аудио и + * распознавателе — без микрофона и без нативных библиотек. + * + * Время настоящее (`runBlocking` + ожидание состояния), а не `runTest` с + * виртуальным: контроллер считает на выделенном потоке `Dispatchers.Default`. + */ +class VoiceControllerTest { + + private val chunk = ByteArray(4) { it.toByte() } + + private fun finiteMic(vararg chunks: ByteArray): MicSource = + MicSource { _, _ -> flow { chunks.forEach { emit(it) } } } + + private fun endlessMic(chunk: ByteArray = ByteArray(4) { 1 }): MicSource = MicSource { _, _ -> + flow { + while (true) { + emit(chunk) + delay(5) + } + } + } + + private class FakeRecognizer( + private val partials: List = emptyList(), + private val finalText: String = "", + override val speechDetected: Boolean = true, + ) : VoiceRecognizer { + val fed = mutableListOf() + var finished = false + var closed = false + private var partialCalls = 0 + + override fun feed(pcm: ByteArray) { + fed += pcm + } + + override fun partial(): String? { + if (partials.isEmpty()) return null + return partials[minOf(partialCalls++, partials.size - 1)] + } + + override fun finish(): String { + finished = true + return finalText + } + + override fun close() { + closed = true + } + } + + private class Recorder { + val states = mutableListOf() + val partials = mutableListOf() + val finals = mutableListOf() + val errors = mutableListOf() + } + + private fun controller( + scope: CoroutineScope, + mic: MicSource, + recognizer: VoiceRecognizer, + rec: Recorder, + ) = VoiceController( + scope = scope, + recognizers = VoiceRecognizerFactory { recognizer }, + mic = mic, + onState = { rec.states += it }, + onPartial = { rec.partials += it }, + onFinal = { rec.finals += it }, + onError = { rec.errors += it }, + ) + + @Test + fun `stop delivers final text and closes recognizer`() = runBlocking { + val rec = Recorder() + val recognizer = FakeRecognizer(partials = listOf("при"), finalText = "привет") + val voice = controller(this, endlessMic(), recognizer, rec) + + voice.start() + await("микрофон начал писать") { voice.state == VoiceState.RECORDING } + await("пришёл промежуточный текст") { rec.partials.isNotEmpty() } + assertEquals(listOf("при"), rec.partials) + + voice.stop() + awaitWithDump("финал отдан", rec, voice) { rec.finals.isNotEmpty() } + awaitWithDump("вернулись в простой", rec, voice) { voice.state == VoiceState.IDLE } + + assertEquals(listOf("привет"), rec.finals) + assertTrue(recognizer.finished, "finish() должен быть вызван") + assertTrue(recognizer.closed, "recognizer должен быть закрыт") + assertEquals(listOf(VoiceState.PREPARING, VoiceState.RECORDING, VoiceState.RECOGNIZING, VoiceState.IDLE), rec.states) + } + + @Test + fun `cancelled recording emits nothing`() = runBlocking { + val rec = Recorder() + val recognizer = FakeRecognizer(partials = listOf("при"), finalText = "привет") + val voice = controller(this, endlessMic(), recognizer, rec) + + voice.start() + await("микрофон начал писать") { voice.state == VoiceState.RECORDING } + await("пришёл промежуточный текст") { rec.partials.isNotEmpty() } + + voice.cancel() + awaitWithDump("вернулись в простой", rec, voice) { voice.state == VoiceState.IDLE } + delay(50) + + assertTrue(rec.finals.isEmpty(), "после cancel финал не отдаётся: ${rec.finals}") + assertEquals(VoiceState.IDLE, voice.state) + } + + @Test + fun `microphone ending on its own finishes the turn`() = runBlocking { + val rec = Recorder() + val recognizer = FakeRecognizer(finalText = "конец") + val voice = controller(this, finiteMic(chunk, chunk), recognizer, rec) + + voice.start() + awaitWithDump("финал отдан без stop()", rec, voice) { rec.finals.isNotEmpty() } + + assertEquals(listOf("конец"), rec.finals) + assertEquals(VoiceState.IDLE, voice.state) + assertTrue(recognizer.closed) + } + + @Test + fun `microphone chunks reach the recognizer`() = runBlocking { + val rec = Recorder() + val recognizer = FakeRecognizer(finalText = "ок") + val voice = controller(this, finiteMic(chunk, chunk, chunk), recognizer, rec) + + voice.start() + awaitWithDump("финал отдан", rec, voice) { rec.finals.isNotEmpty() } + + assertEquals(3, recognizer.fed.size) + assertEquals(chunk.toList(), recognizer.fed[0].toList()) + } + + @Test + fun `empty chunks are skipped`() = runBlocking { + val rec = Recorder() + val recognizer = FakeRecognizer(finalText = "ок") + val voice = controller(this, finiteMic(ByteArray(0), chunk, ByteArray(0)), recognizer, rec) + + voice.start() + awaitWithDump("финал отдан", rec, voice) { rec.finals.isNotEmpty() } + + assertEquals(1, recognizer.fed.size) + } + + @Test + fun `silence is not turned into text`() = runBlocking { + val rec = Recorder() + val recognizer = FakeRecognizer(finalText = "", speechDetected = false) + val voice = controller(this, finiteMic(chunk, chunk), recognizer, rec) + + voice.start() + awaitWithDump("ошибка про тишину", rec, voice) { rec.errors.isNotEmpty() } + + assertEquals(listOf("Речь не распознана"), rec.errors) + assertTrue(rec.finals.isEmpty()) + assertEquals(VoiceState.IDLE, voice.state) + } + + @Test + fun `empty final with speech detected is silently dropped`() = runBlocking { + val rec = Recorder() + val recognizer = FakeRecognizer(finalText = " ", speechDetected = true) + val voice = controller(this, finiteMic(chunk), recognizer, rec) + + voice.start() + awaitWithDump("вернулись в простой", rec, voice) { voice.state == VoiceState.IDLE && recognizer.finished } + delay(50) + + assertTrue(rec.finals.isEmpty()) + assertTrue(rec.errors.isEmpty(), "речь была, но текста нет — это не ошибка: ${rec.errors}") + } + + @Test + fun `microphone failure surfaces as error`() = runBlocking { + val rec = Recorder() + val recognizer = FakeRecognizer() + val mic = MicSource { _, _ -> + flow { throw IllegalStateException("no line") } + } + val voice = controller(this, mic, recognizer, rec) + + voice.start() + awaitWithDump("ошибка микрофона", rec, voice) { rec.errors.isNotEmpty() } + + assertEquals(listOf("Микрофон недоступен: no line"), rec.errors) + assertEquals(VoiceState.IDLE, voice.state) + assertTrue(recognizer.closed, "распознаватель закрывается и при ошибке микрофона") + } + + @Test + fun `recognizer creation failure surfaces as error`() = runBlocking { + val rec = Recorder() + val voice = VoiceController( + scope = this, + recognizers = VoiceRecognizerFactory { throw IllegalStateException("model not found") }, + mic = finiteMic(chunk), + onState = { rec.states += it }, + onError = { rec.errors += it }, + ) + + voice.start() + await("ошибка создания") { rec.errors.isNotEmpty() } + + assertEquals(listOf("Не удалось запустить распознавание: model not found"), rec.errors) + assertEquals(VoiceState.IDLE, voice.state) + } + + @Test + fun `toggle starts then stops`() = runBlocking { + val rec = Recorder() + val recognizer = FakeRecognizer(finalText = "готово") + val voice = controller(this, endlessMic(), recognizer, rec) + + voice.toggle() + await("пишем") { voice.state == VoiceState.RECORDING } + assertTrue(voice.isActive) + + voice.toggle() + awaitWithDump("финал", rec, voice) { rec.finals.isNotEmpty() } + assertTrue(rec.finals.contains("готово")) + } + + @Test + fun `stop before start does nothing`() = runBlocking { + val rec = Recorder() + val voice = controller(this, endlessMic(), FakeRecognizer(), rec) + + voice.stop() + voice.cancel() + delay(30) + + assertEquals(VoiceState.IDLE, voice.state) + assertTrue(rec.states.isEmpty(), "никаких переходов не ожидается: ${rec.states}") + } + + /** Ожидание условия с реальным временем (см. заметку про ksqlite/Dispatchers). */ + private suspend fun await(what: String, timeoutMs: Long = 5_000, condition: () -> Boolean) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + if (condition()) return + delay(5) + } + fail("не дождались: $what") + } + + /** То же, но с дампом состояния — чтобы падение было диагностируемым. */ + private suspend fun awaitWithDump(what: String, rec: Recorder, voice: VoiceController, condition: () -> Boolean) { + val deadline = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < deadline) { + if (condition()) return + delay(5) + } + fail( + "не дождались: $what; state=${voice.state} active=${voice.isActive} " + + "states=${rec.states} partials=${rec.partials} finals=${rec.finals} errors=${rec.errors}" + ) + } +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/VoskVoiceRecognizerIT.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/VoskVoiceRecognizerIT.kt new file mode 100644 index 0000000..9ea359c --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/VoskVoiceRecognizerIT.kt @@ -0,0 +1,36 @@ +package pw.binom.agentik.desktop.voice + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * Настоящий тракт: модель Vosk из артефакта `asr-vosk-jvm` + Silero VAD из + * `vad-jvm`. Проверяем, что модели находятся, распаковываются и работают, а + * тишина не даёт текста. + * + * Один тест на класс: создание распознавателя грузит нативные модели + * (первый запуск распаковывает zip 46 МБ в tmp), а ONNX-сессия VAD не терпит + * повторного `close()` — поэтому никаких `use {}` вокруг общих объектов. + */ +class VoskVoiceRecognizerIT { + + @Test + fun `silence produces no text and no speech detection`() { + val recognizer = VoskVoiceRecognizer() + try { + // 1 секунда тишины чанками по 100 мс — ровно как от микрофона. + repeat(10) { + recognizer.feed(ByteArray(VOICE_SAMPLE_RATE / 10 * 2)) + } + + // Промежуточного текста на тишине нет… + assertEquals(null, recognizer.partial()) + assertFalse(recognizer.speechDetected, "тишина не должна детектиться как речь") + // …и финал пустой. + assertEquals("", recognizer.finish()) + } finally { + recognizer.close() + } + } +} diff --git a/src/jvmTest/resources/voice/raz-dva-tri.wav b/src/jvmTest/resources/voice/raz-dva-tri.wav new file mode 100644 index 0000000000000000000000000000000000000000..5a1b3f8c30095c50aee0e1deeb0dbd0edbb2762a GIT binary patch literal 188494 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

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