Десктоп-клиент: Compose-UI, сессии, SQLite-кэш, голос, живые тесты
- settings/: settings.json (агенты, тема, clientId), атомарная запись - persistence/: ConversationMetaRepository — группы, lastSeen, превью, watermark синхронизации (synced_at через PRAGMA table_info) - session/: AgentRegistry (параллельное подключение, деградация в офлайн), AgentConnection (журнал + мета + живой список диалогов conversationsFlow, refresh по SSE-потоку), ChatSession (history + backfill + live + preview, дедуп по id, 1 мс шаг watermark, syncMutex), LiveTurn - UI: рейка агентов, список диалогов (живое обновление), чат со стримингом, композер (Enter отправляет, Shift+Enter переносит, Ctrl+M — голос), окна «Агенты»/«Группы»/«Новый диалог», тёмная и светлая темы - voice/: mic-api → Silero VAD → Vosk на одном выделенном потоке; VoiceNative.warmUp() до skia (иначе SIGSEGV), модель в артефакте - markdown/: вендоренный рендер из ai/assistent - ошибки в баннер через AppState.reportError (CancellationException игнорируется) - тесты 103 (реальная SQLite + фейковый транспорт); против живого агента — RealServerIT, RealAppStateIT, RealSpeechIT - LIVE-RUN-NOTES.md: живой прогон в GUI и найденные/починенные баги
This commit is contained in:
@@ -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'
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MarkdownSegment> {
|
||||||
|
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<MarkdownSegment>()
|
||||||
|
|
||||||
|
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<TextStyleSpan>()
|
||||||
|
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<String>()
|
||||||
|
val rows = mutableListOf<List<String>>()
|
||||||
|
val rowCells = mutableListOf<String>()
|
||||||
|
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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TextStyleSpan>,
|
||||||
|
) : MarkdownSegment()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Блок кода (fenced code block).
|
||||||
|
*/
|
||||||
|
data class CodeBlock(
|
||||||
|
val language: String?,
|
||||||
|
val code: String,
|
||||||
|
) : MarkdownSegment()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Таблица с заголовками и строками данных.
|
||||||
|
*/
|
||||||
|
data class Table(
|
||||||
|
val headers: List<String>,
|
||||||
|
val rows: List<List<String>>,
|
||||||
|
) : 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()
|
||||||
|
}
|
||||||
@@ -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/<agentId>.<ext>` и
|
||||||
|
* запоминаем путь в [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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MessageRecord>.toUiMessages(): List<UiMessage> {
|
||||||
|
val out = ArrayList<UiMessage>(size)
|
||||||
|
// Индекс UiMessage.Tool по toolCallId — чтобы приклеить результат.
|
||||||
|
val toolsByCallId = HashMap<String, Int>()
|
||||||
|
|
||||||
|
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<UiMessage> {
|
||||||
|
val live = phase.isStreaming
|
||||||
|
val result = ArrayList<UiMessage>(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,
|
||||||
|
)
|
||||||
@@ -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<UiContent>,
|
||||||
|
) : UiMessage
|
||||||
|
|
||||||
|
data class Assistant(
|
||||||
|
override val id: String,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
val content: List<UiContent>,
|
||||||
|
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 = "Новый диалог"
|
||||||
@@ -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<CheckStep>,
|
||||||
|
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<CheckStep>()
|
||||||
|
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
|
||||||
|
}
|
||||||
+458
@@ -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<Group> = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
listGroupsStmt.reset()
|
||||||
|
listGroupsStmt.clearBindings()
|
||||||
|
val out = mutableListOf<Group>()
|
||||||
|
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<MetaRow> = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
listMetasStmt.reset()
|
||||||
|
listMetasStmt.clearBindings()
|
||||||
|
val out = mutableListOf<MetaRow>()
|
||||||
|
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,
|
||||||
|
)
|
||||||
@@ -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<List<UiConversation>>(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<List<UiConversation>> = 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<UiConversation> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AppSettings> = _settings.asStateFlow()
|
||||||
|
|
||||||
|
private val _statuses = MutableStateFlow<Map<String, AgentStatus>>(emptyMap())
|
||||||
|
val statuses: StateFlow<Map<String, AgentStatus>> = _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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<UiMessage>>`.
|
||||||
|
* - **Бэкфилл** — `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<List<UiMessage>>(emptyList())
|
||||||
|
|
||||||
|
/** История ходов из локального кэша, готовая к рендеру. */
|
||||||
|
val history: StateFlow<List<UiMessage>> = _history.asStateFlow()
|
||||||
|
|
||||||
|
/** Live-блоки активного хода (пусто, когда хода нет). */
|
||||||
|
private val _live = MutableStateFlow<List<UiMessage>>(emptyList())
|
||||||
|
val live: StateFlow<List<UiMessage>> = _live.asStateFlow()
|
||||||
|
|
||||||
|
/** Фаза текущего хода — для спиннера «агент думает» до первого токена. */
|
||||||
|
private val _phase = MutableStateFlow(TurnPhase.IDLE)
|
||||||
|
val phase: StateFlow<TurnPhase> = _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<Content>) {
|
||||||
|
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<UiContent>.firstText(): String? =
|
||||||
|
firstNotNullOfOrNull { (it as? UiContent.Text)?.body }
|
||||||
|
|
||||||
|
private fun List<UiContent>.imagePlaceholder(): String? =
|
||||||
|
if (any { it is UiContent.Image }) "[изображение]" else null
|
||||||
@@ -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<LiveBlock>()
|
||||||
|
|
||||||
|
/** Блоки в порядке появления. Живой список — не копия. */
|
||||||
|
val blocks: List<LiveBlock> 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<LiveBlock.Tool>().lastOrNull { it.callId == callId }
|
||||||
|
|
||||||
|
private fun fire() {
|
||||||
|
onChange?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текст ответа целиком (все Answer-блоки). Используется для превью
|
||||||
|
* в списке диалогов и для «склеенного» рендера.
|
||||||
|
*/
|
||||||
|
fun answerText(): String =
|
||||||
|
_blocks.filterIsInstance<LiveBlock.Answer>().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() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/<safe id>/`.
|
||||||
|
*
|
||||||
|
* **Почему отдельный каталог/база на агента:** 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" }
|
||||||
|
}
|
||||||
@@ -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<AgentConfig> = 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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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<AppDialog?>(null) }
|
||||||
|
var selectedGroupId by remember { mutableStateOf<Long?>(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("Добавить агента") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Map<String, AgentStatus>>(emptyMap())
|
||||||
|
private set
|
||||||
|
|
||||||
|
var activeAgentId by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var activeConversationId by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var conversations by mutableStateOf<List<UiConversation>>(emptyList())
|
||||||
|
private set
|
||||||
|
|
||||||
|
var groups by mutableStateOf<List<Group>>(emptyList())
|
||||||
|
private set
|
||||||
|
|
||||||
|
var history by mutableStateOf<List<UiMessage>>(emptyList())
|
||||||
|
private set
|
||||||
|
|
||||||
|
var live by mutableStateOf<List<UiMessage>>(emptyList())
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** true, пока идёт активный ход (в т.ч. до первого токена — спиннер). */
|
||||||
|
var thinking by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Последняя ошибка для баннера. Сбрасывается [clearError]. */
|
||||||
|
var error by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Состояние голосового ввода (кнопка микрофона). */
|
||||||
|
var voiceState by mutableStateOf(VoiceState.IDLE)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Промежуточный текст распознавания — показываем под композером. */
|
||||||
|
var voicePartial by mutableStateOf<String?>(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<AgentConfig> 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ImageBitmap?>(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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MarkdownSegment> = parseMarkdown(markdown)
|
||||||
|
|
||||||
|
fun openUrl(url: String) {
|
||||||
|
try {
|
||||||
|
Desktop.getDesktop().browse(URI(url))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Int>,
|
||||||
|
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<String>,
|
||||||
|
widths: List<Int>,
|
||||||
|
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<TextLayoutResult?>(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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AvatarStorage> { 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<ByteArray?>(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()))
|
||||||
@@ -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>(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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>(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("Сохранить") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<UiMessage>,
|
||||||
|
live: List<UiMessage>,
|
||||||
|
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<UiContent>, 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<UiContent>, 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)}"
|
||||||
@@ -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("Отмена") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Color> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ByteArray>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Голосовой ввод: микрофон → детектор речи → распознавание → текст.
|
||||||
|
*
|
||||||
|
* Одно нажатие:
|
||||||
|
* - [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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MarkdownSegment.Text>().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 =
|
||||||
|
"""
|
||||||
|
|Текст
|
||||||
|
|
|
||||||
|
|-----
|
||||||
|
|
|
||||||
|
|<https://example.com>
|
||||||
|
|
|
||||||
|
|<div>hello</div>
|
||||||
|
""".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<MarkdownSegment.Table>().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("""""")
|
||||||
|
val image = segments.filterIsInstance<MarkdownSegment.Image>().single()
|
||||||
|
assertEquals("data:image/png;base64,QUJD", image.src)
|
||||||
|
assertEquals("альт", image.alt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `image с http-адресом эмитится как отдельный сегмент`() {
|
||||||
|
val segments = parseMarkdown("""""")
|
||||||
|
val image = segments.filterIsInstance<MarkdownSegment.Image>().single()
|
||||||
|
assertEquals("http://example.com/img.png", image.src)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `текст вокруг картинки не теряется`() {
|
||||||
|
val segments = parseMarkdown("""текст до  текст после""")
|
||||||
|
assertEquals(
|
||||||
|
listOf("текст до ", " текст после"),
|
||||||
|
segments.filterIsInstance<MarkdownSegment.Text>().map { it.text },
|
||||||
|
)
|
||||||
|
assertEquals(1, segments.filterIsInstance<MarkdownSegment.Image>().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")}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<Byte>(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<Byte>(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("/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>(Content.Text("привет")),
|
||||||
|
createdAt = at(1),
|
||||||
|
),
|
||||||
|
MessageRecord.AssistantMessage(
|
||||||
|
id = "a1",
|
||||||
|
conversationId = "c",
|
||||||
|
content = listOf<Content>(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>(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>(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>(
|
||||||
|
MessageRecord.AssistantMessage(
|
||||||
|
id = "a",
|
||||||
|
conversationId = "c",
|
||||||
|
content = listOf<Content>(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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+243
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, EchoConversation>()
|
||||||
|
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<Message> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun send(content: List<Content>, 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<Content.Text>().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<Content>.toJournal(): List<pw.binom.agentik.journal.Content> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>(JournalContent.Text(body)),
|
||||||
|
createdAt = at(ms),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun user(id: String, ms: Long, body: String) = MessageRecord.UserMessage(
|
||||||
|
id = id,
|
||||||
|
conversationId = convId,
|
||||||
|
content = listOf<JournalContent>(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<MessageRecord> = emptyList(),
|
||||||
|
) : JournalStore {
|
||||||
|
override suspend fun list(
|
||||||
|
conversationId: String,
|
||||||
|
after: Instant,
|
||||||
|
offset: Int,
|
||||||
|
limit: Int,
|
||||||
|
): List<MessageRecord> = 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<CommonEvent>(replay = 256, extraBufferCapacity = 256)
|
||||||
|
|
||||||
|
override fun events(after: Instant?): Flow<CommonEvent> = 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<List<Content>>()
|
||||||
|
var interruptCount = 0
|
||||||
|
|
||||||
|
override suspend fun rename(title: String) {
|
||||||
|
this.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun send(content: List<Content>, context: MessageContext?) {
|
||||||
|
sent += content
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun interrupt() {
|
||||||
|
interruptCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> = emptyList()
|
||||||
|
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
@@ -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<LiveBlock.Reasoning>(blocks[0])
|
||||||
|
assertEquals("сначала подумаем", reasoning.text.toString())
|
||||||
|
val answer = assertIs<LiveBlock.Answer>(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<LiveBlock.Tool>()
|
||||||
|
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<LiveBlock.Tool>().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<LiveBlock.Tool>().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<LiveBlock.Failure>().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<LiveBlock.Answer>()
|
||||||
|
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<LiveBlock.Answer>().isEmpty())
|
||||||
|
val image = turn.blocks.filterIsInstance<LiveBlock.Image>().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("…"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AgentConnection>()
|
||||||
|
|
||||||
|
@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<UiContent.Text>().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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<UiMessage.Assistant>().last()
|
||||||
|
val assistantText = assistant.content.filterIsInstance<pw.binom.agentik.desktop.model.UiContent.Text>()
|
||||||
|
.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<UiMessage.Assistant>().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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Color, Int>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<Float>>()
|
||||||
|
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<FloatArray>()
|
||||||
|
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<FloatArray>()
|
||||||
|
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<FloatArray>()
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-чанка")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> = emptyList(),
|
||||||
|
private val finalText: String = "",
|
||||||
|
override val speechDetected: Boolean = true,
|
||||||
|
) : VoiceRecognizer {
|
||||||
|
val fed = mutableListOf<ByteArray>()
|
||||||
|
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<VoiceState>()
|
||||||
|
val partials = mutableListOf<String>()
|
||||||
|
val finals = mutableListOf<String>()
|
||||||
|
val errors = mutableListOf<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ByteArray> { 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}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Reference in New Issue
Block a user