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 0000000..5a1b3f8 Binary files /dev/null and b/src/jvmTest/resources/voice/raz-dva-tri.wav differ