Десктоп-клиент: 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:
2026-09-28 10:08:10 +03:00
parent 27f349550c
commit 2c71542b13
53 changed files with 9147 additions and 0 deletions
+94
View File
@@ -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
}
@@ -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("""![альт](data:image/png;base64,QUJD)""")
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("""![x](http://example.com/img.png)""")
val image = segments.filterIsInstance<MarkdownSegment.Image>().single()
assertEquals("http://example.com/img.png", image.src)
}
@Test
fun `текст вокруг картинки не теряется`() {
val segments = parseMarkdown("""текст до ![альт](data:image/png;base64,QUJD) текст после""")
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))
}
}
@@ -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.