Десктоп-клиент: 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