Compare commits

...

8 Commits

Author SHA1 Message Date
subochev 6b7b5ee57b Изменение выносит типы контента в :content-api и разделяет потоки событий. 2026-09-30 12:41:10 +03:00
subochev 26f94ea83d Изменения вводят live-канал OnlineOutbox и фоновую синхронизацию диалогов. 2026-09-30 11:43:49 +03:00
subochev 2ad2d65f06 Добавлен тест на вертикальное центрирование подписи «Временный диалог» относительно квадрата чекбокса и исправлена стилизация текста в компонентах диалога. 2026-09-29 11:02:28 +03:00
subochev 7a9f60274b Добавлен экран информации о диалоге: шапка с кнопкой ⋯, окно с редактированием названия, отображением агента, даты создания/обновления, признака временности и кнопкой удаления. 2026-09-29 10:35:19 +03:00
subochev e9f3fe96fe Добавлены тесты рендера и стилизации Markdown, реализован MarkdownStyle для самостоятельного управления стилями. 2026-09-28 16:29:21 +03:00
subochev 9c2e794a73 Перенос темы с AgentikTheme на ProvideAgentikTheme, рефакторинг стилизации Markdown 2026-09-28 16:28:02 +03:00
subochev 9aaf0c3a46 Упрощение проверки связи: удалён временный диалог, оставлен запрос информации об агенте 2026-09-28 11:40:03 +03:00
subochev 7ad3f55b57 Обновление: ksqlite до версии 0.1.4 2026-09-28 10:48:05 +03:00
33 changed files with 2802 additions and 426 deletions
+5 -3
View File
@@ -81,9 +81,11 @@ pw.binom.agentik.desktop
- **Проверка связи** — «ответ сервера» + создание/удаление временного диалога, - **Проверка связи** — «ответ сервера» + создание/удаление временного диалога,
с таймингами. с таймингами.
- **UI (Compose)** — рейка агентов, список диалогов (поиск, папки, бейджи), - **UI (Compose)** — рейка агентов, список диалогов (поиск, папки, бейджи),
чат с живым стримингом, композер, окна «Агенты», «Группы» и «Новый диалог» чат с живым стримингом, композер, окна «Агенты», «Группы», «Новый диалог»
(при нескольких агентах — выбор, при одном — сразу), пустые состояния, (при нескольких агентах — выбор, при одном — сразу) и «Параметры диалога»
адаптивная раскладка (узкое окно — список или чат). (кнопка `⋯` в шапке чата: имя, обслуживающий агент, признак «Временный»,
даты создания/обновления, удаление), пустые состояния, адаптивная раскладка
(узкое окно — список или чат).
- **Голос** — микрофон → VAD → распознавание (`voice/`): кнопка микрофона - **Голос** — микрофон → VAD → распознавание (`voice/`): кнопка микрофона
или `Ctrl+M` из чата запускает запись (`mic-api`), Silero VAD (`vad-kmp`) отмечает наличие речи, или `Ctrl+M` из чата запускает запись (`mic-api`), Silero VAD (`vad-kmp`) отмечает наличие речи,
Vosk (`asr-vosk`, модель внутри артефакта) отдаёт промежуточный текст над Vosk (`asr-vosk`, модель внутри артефакта) отдаёт промежуточный текст над
+32 -2
View File
@@ -82,6 +82,14 @@
## 4. Открытый диалог ## 4. Открытый диалог
- **R10.** Показ сообщений: мои и ассистента, с временем. - **R10.** Показ сообщений: мои и ассистента, с временем.
- **R10.1.** В шапке открытого диалога — **кнопка `⋯` (три вертикальные точки)**,
без визуальных границ, как в Telegram. Открывает окно «Параметры диалога».
- **R10.2.** Окно «Параметры диалога»: название (с редактированием по ✎),
карточка обслуживающего агента (имя + URL + статус «на связи»/«не отвечает»),
признак «Временный диалог» (только просмотр — задаётся только при создании),
«Создан» и «Обновлён» (`dd.MM.yyyy HH:mm`, системная тайм-зона),
«Опасное» с кнопкой «Удалить диалог». Создание идёт через модалку
выбора агента (R14.x), а не через это окно.
- **R11.** Ответ ассистента можно прервать — на видном месте кнопка «Стоп». - **R11.** Ответ ассистента можно прервать — на видном месте кнопка «Стоп».
- **R12.** Сообщения приходят вживую, по мере генерации: текст дотекает куском - **R12.** Сообщения приходят вживую, по мере генерации: текст дотекает куском
за куском, а не появляется целиком в конце. Отображать именно так. за куском, а не появляется целиком в конце. Отображать именно так.
@@ -294,6 +302,30 @@
- **R43.** Полезный признак, что что-то не вынесено: **в экране накопилась логика - **R43.** Полезный признак, что что-то не вынесено: **в экране накопилась логика
длиннее пары десятков строк.** Значит, это место жить в экране не должно. длиннее пары десятков строк.** Значит, это место жить в экране не должно.
### 8.4. Кэш наполняется фоново, а не по факту открытия
**Задача:** превью и история должны быть у **всех** диалогов, а не только у тех,
что открывали в этом окне. Раньше «…» в списке стояло именно потому, что запись
появлялась лишь при открытии диалога.
**Схема (на каждого агента):**
1. Одна подписка на **все** события-сообщения
(`outbox.conversationEvents(after=null, conversationId=null)`).
2. На `End`/`Interrupted` любого диалога — догон журнала по watermark'у и
пересчёт превью; данные кладутся в локальный кэш.
3. На старте — догон диалогов, чей `updatedAt` с сервера **новее** нашего
watermark'а, включая **никогда не открытые**.
- **R44.** **Источник истины — журнал, не SSE.** Outbox — короткий bounded tail:
события за время, пока клиент был выключен, из него выпадают. Поэтому догон
идёт по журналу (`updatedAt` / `synced_at`), а SSE лишь триггерит его.
- **R45.** **Открытый диалог фоновый ingest не трогает.** Пока диалог открыт,
его сообщения ведёт сессия; engine пропускает этот диалог, чтобы не дублировать
работу. Общий мьютекс на диалог сериализует запись на транзишене
«открыли / закрыли».
- **R46.** **API менять не нужно** (снимает открытый вопрос из §9): превью
считается локально из журнала, число непрочитанных — `journal.count(after=lastSeen)`.
## 9. Решения, которые ещё не приняты ## 9. Решения, которые ещё не приняты
- Запись: по нажатию или на удержание. - Запись: по нажатию или на удержание.
@@ -307,8 +339,6 @@
- **Группы — только на устройстве или на сервере?** Сервер про них не знает - **Группы — только на устройстве или на сервере?** Сервер про них не знает
(`STORAGE.md`, §7). Если только у нас — раскладка не поедет между десктопом (`STORAGE.md`, §7). Если только у нас — раскладка не поедет между десктопом
и телефоном. и телефоном.
- **Меняем ли API: превью последнего сообщения и число непрочитанных.**
Взять неоткуда, в макете они нарисованы (`STORAGE.md`, §6).
- **Бейдж — число или точка.** Точка сервер не трогает вообще (`STORAGE.md`, §6.2). - **Бейдж — число или точка.** Точка сервер не трогает вообще (`STORAGE.md`, §6.2).
- **Где каталог клиента** — `~/.agentik/` или системный («Документы»). - **Где каталог клиента** — `~/.agentik/` или системный («Документы»).
На Android понятие «домашний каталог» своё. На Android понятие «домашний каталог» своё.
+1 -1
View File
@@ -2,7 +2,7 @@
kotlin = "2.4.20" kotlin = "2.4.20"
kotlinx-coroutines = "1.11.0" kotlinx-coroutines = "1.11.0"
kotlinx-serialization = "1.11.0" kotlinx-serialization = "1.11.0"
ksqlite = "0.1.3" ksqlite = "0.1.4"
# Ktor — версия из agentik (там же живут `:client`-модули, engine выбирает # Ktor — версия из agentik (там же живут `:client`-модули, engine выбирает
# потребитель; CIO — то, что agentik использует в собственных тестах). # потребитель; CIO — то, что agentik использует в собственных тестах).
ktor = "3.1.3" ktor = "3.1.3"
+252 -89
View File
@@ -169,7 +169,7 @@
</div> </div>
<div class="composer"> <div class="composer">
<div class="composer-row"> <div class="composer-row">
<div class="field">Сообщение агенту…</div> <div class="composer-input">Сообщение агенту…</div>
<button class="mic" title="Голос">🎤</button> <button class="mic" title="Голос">🎤</button>
<button class="send off" title="Отправить">➤</button> <button class="send off" title="Отправить">➤</button>
</div> </div>
@@ -253,92 +253,6 @@
</div> </div>
</section> </section>
<!-- ============ 5 · модалка нового диалога ============ -->
<section class="screen">
<div class="lbl"><b>5 · модалка нового диалога</b> выбор агента для нового диалога</div>
<div class="win" style="position:relative">
<section class="list">
<div class="l-head"><h1>Диалоги</h1></div>
<div class="chips">
<div class="chip on">Все чаты<b>3</b></div>
<div class="chip">Работа</div>
<div class="chip">Дом</div>
<button class="chip-act" title="Группы"><svg viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></button>
</div>
<div class="q">🔍 Поиск…</div>
<div class="rows">
<div class="row">
<span class="strip" style="background:var(--a1)"></span>
<div class="ava" style="background:var(--a1)">С</div>
<div class="mid">
<div class="l1"><span class="n">agentik · рабочий</span><span class="t">14:22</span></div>
<div class="l2"><span class="p">Собрал отчёт, жду правок</span></div>
</div>
</div>
</div>
</section>
<main class="main">
<div class="empty">
<div class="big">💬</div>
<h3>Выберите диалог слева</h3>
<p>Или нажмите <b>+</b> в шапке списка, чтобы создать новый.</p>
</div>
</main>
<div class="scrim">
<div class="modal">
<h2>Новый диалог</h2>
<div class="sub">Выберите ассистента — он отвечает на «/agentik» на своём адресе</div>
<div class="body">
<div class="agent on">
<div class="sign" style="background:var(--a1)">С</div>
<div class="mid">
<div class="nm">Строитель</div>
<div class="ad">http://192.168.88.135:8080/agentik</div>
</div>
<div class="radio"></div>
</div>
<div class="agent">
<div class="sign" style="background:var(--a2)">К</div>
<div class="mid">
<div class="nm">Книги</div>
<div class="ad">http://192.168.76.199:8080/agentik</div>
</div>
<div class="radio"></div>
</div>
<div class="agent">
<div class="sign" style="background:var(--a3)">Р</div>
<div class="mid">
<div class="nm">Разбор логов <span class="bad">· не отвечает</span></div>
<div class="ad">http://192.168.10.42:8080/agentik</div>
</div>
<div class="radio"></div>
</div>
<div class="agent">
<div class="sign" style="background:var(--a4)">S</div>
<div class="mid">
<div class="nm">Solana-бэкенд</div>
<div class="ad">http://192.168.88.24:8080/agentik</div>
</div>
<div class="radio"></div>
</div>
<div class="agent">
<div class="sign" style="background:var(--a5)">Д</div>
<div class="mid">
<div class="nm">DevOps · деплой</div>
<div class="ad">http://192.168.5.10:8080/agentik</div>
</div>
<div class="radio"></div>
</div>
</div>
<div class="foot">
<button class="btn">Отмена</button>
<button class="btn prim">Создать →</button>
</div>
</div>
</div>
</div>
</section>
<!-- ============ 6 · управление группами ============ --> <!-- ============ 6 · управление группами ============ -->
<section class="screen"> <section class="screen">
<div class="lbl"><b>6 · управление группами</b> модалка со списком групп + добавить</div> <div class="lbl"><b>6 · управление группами</b> модалка со списком групп + добавить</div>
@@ -967,7 +881,7 @@
<div class="head-name">agentik · рабочий</div> <div class="head-name">agentik · рабочий</div>
<div class="head-sub"><span class="dot">●</span> на связи</div> <div class="head-sub"><span class="dot">●</span> на связи</div>
</div> </div>
<button class="icon-btn" title="Параметры диалога">⋯</button> <button class="icon-btn" title="Параметры диалога"><svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button>
</header> </header>
<div class="messages"> <div class="messages">
@@ -1040,7 +954,7 @@
<div class="avatar pic"></div> <div class="avatar pic"></div>
<div class="ag-mid"> <div class="ag-mid">
<div class="ag-name">Кот-ассистент</div> <div class="ag-name">Кот-ассистент</div>
<div class="ag-addr">http://192.168.88.24:8080/agentik</div> <div class="ag-addr">http://192.168.24.8080:8080/agentik</div>
<div class="ag-state err"><span class="d"></span>не отвечает</div> <div class="ag-state err"><span class="d"></span>не отвечает</div>
</div> </div>
<div class="pick"></div> <div class="pick"></div>
@@ -1051,6 +965,20 @@
<span>Ассистент сейчас не отвечает. Диалог создать можно, но <span>Ассистент сейчас не отвечает. Диалог создать можно, но
сообщение не уйдёт, пока он не поднимется.</span> сообщение не уйдёт, пока он не поднимется.</span>
</div> </div>
<div class="field">
<label>Название</label>
<input type="text" value="" placeholder="как назвать диалог">
<div class="note">Можно оставить пустым — диалог покажется как «Без названия». Переименуете в ⋯ в шапке чата.</div>
</div>
<div class="checkbox-row on">
<div class="box"></div>
<div class="mid">
<div class="lbl">Временный диалог</div>
<div class="desc">История не сохраняется на сервере. Подходит для коротких проверок и отладки.</div>
</div>
</div>
</div> </div>
<div class="d-foot"> <div class="d-foot">
<button class="btn" style="flex:1">Отмена</button> <button class="btn" style="flex:1">Отмена</button>
@@ -1422,5 +1350,240 @@
</div> </div>
</section> </section>
<!-- ============ вход в окно информации о диалоге ============ -->
<section class="screen">
<div class="lbl"><b>вход в окно информации о диалоге</b> кнопка <code>⋯</code> в шапке открытого чата с подсветкой</div>
<div class="window">
<aside class="sidebar">
<div class="sb-head"><div class="sb-title"><span>Диалоги</span><button class="icon-btn" title="Новый диалог">+</button></div></div>
<input class="search" placeholder="Поиск диалога…" style="margin:0 16px 10px;width:calc(100% - 32px);">
<div class="chat-list">
<div class="chat-row active">
<div class="avatar">АГ</div>
<div class="chat-mid">
<div class="chat-name"><span>Spring Boot · настройка логов</span><span class="chat-time">14:22</span></div>
<div class="chat-prev">Логи теперь пишутся в отдельный файл…</div>
</div>
</div>
</div>
</aside>
<main class="main">
<header class="chat-head">
<div class="avatar">АГ</div>
<div class="head-meta">
<div class="head-name">Spring Boot · настройка логов</div>
<div class="head-sub"><span class="dot">●</span> на связи · agentik</div>
</div>
<button class="icon-btn hi-btn" title="Параметры диалога"><svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button>
</header>
</main>
</div>
</section>
<!-- ============ 20 · параметры диалога · обычный ============ -->
<section class="screen">
<div class="lbl"><b>20 · параметры диалога · обычный</b> чекбокс выключен и задизейблен (<code>isTemporal=false</code>). Открывается по <code>⋯</code> в шапке чата</div>
<div class="dialog">
<div class="d-head">
<h2>Spring Boot · настройка логов</h2>
<button class="x-btn">✕</button>
</div>
<div class="d-body">
<div class="field">
<label>Название</label>
<input type="text" value="Spring Boot · настройка логов">
<div class="note">Пустое имя — диалог покажется как «Без названия».</div>
</div>
<label style="display:block;font-size:12px;color:var(--dim);margin:0 0 6px">Агент</label>
<div class="agent-card" style="margin-bottom:0">
<div class="avatar" style="background:var(--accent);color:var(--on-accent)">АГ</div>
<div class="ag-mid">
<div class="ag-name">agentik</div>
<div class="ag-addr">http://192.168.88.135:8080/agentik</div>
</div>
<div class="state"><span class="d"></span>на связи</div>
</div>
<div class="checkbox-row off" style="margin-top:14px">
<div class="box"></div>
<div class="mid">
<div class="lbl">Временный диалог</div>
<div class="desc">Задаётся только при создании. Изменить временность существующего диалога нельзя.</div>
</div>
</div>
<div class="probe-list">
<div><span>Создан</span><b>1 окт 2026, 22:07</b></div>
<div><span>Обновлён</span><b>5 окт 2026, 11:30</b></div>
</div>
<div class="danger-zone">
<div class="lbl">Опасное</div>
<button class="btn dang" style="width:100%">Удалить диалог</button>
</div>
</div>
<div class="d-foot">
<button class="btn" style="flex:1">Закрыть</button>
<button class="btn prim" style="flex:1">Сохранить</button>
</div>
</div>
</section>
<!-- ============ 21 · параметры диалога · временный ============ -->
<section class="screen">
<div class="lbl"><b>21 · параметры диалога · временный</b> чекбокс включён и задизейблен (<code>isTemporal=true</code>)</div>
<div class="dialog">
<div class="d-head">
<h2>Черновик ТЗ</h2>
<button class="x-btn">✕</button>
</div>
<div class="d-body">
<div class="field">
<label>Название</label>
<input type="text" value="Черновик ТЗ">
</div>
<label style="display:block;font-size:12px;color:var(--dim);margin:0 0 6px">Агент</label>
<div class="agent-card" style="margin-bottom:0">
<div class="avatar" style="background:#C9A886;color:var(--on-accent)">КО</div>
<div class="ag-mid">
<div class="ag-name">coder</div>
<div class="ag-addr">http://192.168.76.199:8080/agentik</div>
</div>
<div class="state"><span class="d"></span>на связи</div>
</div>
<div class="checkbox-row on off" style="margin-top:14px">
<div class="box"></div>
<div class="mid">
<div class="lbl">Временный диалог</div>
<div class="desc">История не сохраняется на сервере.</div>
</div>
</div>
<div class="probe-list">
<div><span>Создан</span><b>только что</b></div>
</div>
<div class="danger-zone">
<div class="lbl">Опасное</div>
<button class="btn dang" style="width:100%">Удалить диалог</button>
</div>
</div>
<div class="d-foot">
<button class="btn" style="flex:1">Закрыть</button>
<button class="btn prim" style="flex:1">Сохранить</button>
</div>
</div>
</section>
<!-- ============ 22 · параметры диалога · без названия ============ -->
<section class="screen">
<div class="lbl"><b>22 · параметры диалога · без названия</b> пустое имя</div>
<div class="dialog">
<div class="d-head">
<h2>Без названия</h2>
<button class="x-btn">✕</button>
</div>
<div class="d-body">
<div class="field">
<label>Название</label>
<input type="text" value="" placeholder="как назвать диалог">
<div class="note">Пока имя не задано, диалог показывается как «Без названия».</div>
</div>
<label style="display:block;font-size:12px;color:var(--dim);margin:0 0 6px">Агент</label>
<div class="agent-card" style="margin-bottom:0">
<div class="avatar" style="background:#86C99A;color:var(--on-accent)">ДМ</div>
<div class="ag-mid">
<div class="ag-name">home-assistant</div>
<div class="ag-addr">http://192.168.76.199:8080/agentik</div>
</div>
<div class="state err"><span class="d"></span>не отвечает</div>
</div>
<div class="checkbox-row off" style="margin-top:14px">
<div class="box"></div>
<div class="mid">
<div class="lbl">Временный диалог</div>
<div class="desc">Задаётся только при создании. Изменить временность существующего диалога нельзя.</div>
</div>
</div>
<div class="probe-list">
<div><span>Создан</span><b>сегодня, 09:11</b></div>
</div>
<div class="danger-zone">
<div class="lbl">Опасное</div>
<button class="btn dang" style="width:100%">Удалить диалог</button>
</div>
</div>
<div class="d-foot">
<button class="btn" style="flex:1">Закрыть</button>
<button class="btn prim" style="flex:1">Сохранить</button>
</div>
</div>
</section>
<!-- ============ 23 · параметры диалога · ошибка сохранения ============ -->
<section class="screen">
<div class="lbl"><b>23 · параметры диалога · ошибка</b> сервер не принял имя — warn + «Повторить»</div>
<div class="dialog">
<div class="d-head">
<h2>Spring Boot · настройка логов</h2>
<button class="x-btn">✕</button>
</div>
<div class="d-body">
<div class="field">
<label>Название</label>
<input type="text" value="Spring Boot · настройка логов">
</div>
<div class="warn">
<span class="i">⚠</span>
<span>Не удалось сохранить имя: агент не отвечает. Проверьте связь и попробуйте снова.</span>
</div>
<div class="danger-zone">
<div class="lbl">Опасное</div>
<button class="btn dang" style="width:100%">Удалить диалог</button>
</div>
</div>
<div class="d-foot">
<button class="btn" style="flex:1">Закрыть</button>
<button class="btn prim" style="flex:1">Повторить</button>
</div>
</div>
</section>
<!-- ============ 24 · удаление диалога · подтверждение ============ -->
<section class="screen">
<div class="lbl"><b>24 · удаление диалога · подтверждение</b> открывается по «Удалить диалог» из 20–23. Действие необратимо — warn + красный CTA</div>
<div class="dialog">
<div class="d-head">
<h2>Удалить диалог?</h2>
<button class="x-btn">✕</button>
</div>
<div class="d-body">
<div class="warn">
<span class="i">⚠</span>
<span>Диалог <b>«Spring Boot · настройка логов»</b> и вся его история будут
безвозвратно удалены с сервера. Это действие нельзя отменить.</span>
</div>
<div class="info">
<span>ℹ</span>
<span>Корзины нет. Если передумаете — диалог придётся создавать заново, и
контекст будет потерян.</span>
</div>
</div>
<div class="d-foot">
<button class="btn" style="flex:1">Отмена</button>
<button class="btn dang solid" style="flex:1">Удалить</button>
</div>
</div>
</section>
</body> </body>
</html> </html>
+44 -17
View File
@@ -144,12 +144,12 @@ body {
.rec-btn.rec-send:hover { background: #82BEF5; } .rec-btn.rec-send:hover { background: #82BEF5; }
.composer-row { display: flex; align-items: center; gap: 10px; } .composer-row { display: flex; align-items: center; gap: 10px; }
.field { .composer-input {
flex: 1; min-width: 0; height: 46px; border-radius: 13px; background: var(--bg); flex: 1; min-width: 0; height: 46px; border-radius: 13px; background: var(--bg);
border: 1px solid var(--line); display: flex; align-items: center; padding: 0 14px; border: 1px solid var(--line); display: flex; align-items: center; padding: 0 14px;
font-size: 13.5px; color: var(--dim); font-size: 13.5px; color: var(--dim);
} }
.field.focus { border-color: var(--accent); } .composer-input.focus { border-color: var(--accent); }
.mic { .mic {
width: 52px; height: 52px; flex: 0 0 52px; border-radius: 50%; width: 52px; height: 52px; flex: 0 0 52px; border-radius: 50%;
border: 2px solid var(--accent); background: var(--bg); color: var(--accent); border: 2px solid var(--accent); background: var(--bg); color: var(--accent);
@@ -359,15 +359,6 @@ body {
.state.err { color: var(--err); } .state.err .d { background: var(--err); } .state.err { color: var(--err); } .state.err .d { background: var(--err); }
.card-acts { display: flex; gap: 4px; flex: 0 0 auto; } .card-acts { display: flex; gap: 4px; flex: 0 0 auto; }
.field { margin-bottom: 15px; }
.field label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 6px; }
.field input {
width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); font-size: 13.5px; outline: none; font-family: inherit;
}
.field input:focus { border-color: var(--accent); }
.field input.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; }
.field .note { font-size: 11px; color: var(--dim); margin-top: 6px; line-height: 1.5; }
.note code, .hint-line code { background: var(--panel2); padding: 1px 4px; border-radius: 4px; .note code, .hint-line code { background: var(--panel2); padding: 1px 4px; border-radius: 4px;
font-size: 10.5px; } font-size: 10.5px; }
.addr-row { display: flex; gap: 8px; } .addr-row { display: flex; gap: 8px; }
@@ -441,7 +432,9 @@ body {
.icon-btn { width: 30px; height: 30px; border-radius: 8px; border: none; .icon-btn { width: 30px; height: 30px; border-radius: 8px; border: none;
background: transparent; color: var(--dim); cursor: pointer; display: grid; place-items: center; background: transparent; color: var(--dim); cursor: pointer; display: grid; place-items: center;
font-size: 17px; } font-size: 17px; }
.icon-btn svg { width: 18px; height: 18px; fill: currentColor; }
.icon-btn:hover { background: var(--panel2); color: var(--fg); } .icon-btn:hover { background: var(--panel2); color: var(--fg); }
.hi-btn { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 8px; }
.search { padding: 8px 12px; border-radius: 9px; border: 1px solid var(--line); .search { padding: 8px 12px; border-radius: 9px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); font-size: 13px; outline: none; font-family: inherit; } background: var(--bg); color: var(--fg); font-size: 13px; outline: none; font-family: inherit; }
.search:focus { border-color: var(--accent); } .search:focus { border-color: var(--accent); }
@@ -556,6 +549,34 @@ kbd { background: var(--panel2); border: 1px solid var(--line); border-radius: 4
margin-bottom: 8px; padding-left: 2px; } margin-bottom: 8px; padding-left: 2px; }
.asr-note .live { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); } .asr-note .live { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
/* === чекбокс (используется в новом диалоге и в параметрах диалога) === */
.checkbox-row {
display: flex; align-items: center; gap: 11px;
padding: 11px 13px; border-radius: 10px;
background: var(--panel2); margin-bottom: 9px;
cursor: pointer; user-select: none;
}
.checkbox-row:hover { background: #202C38; }
.checkbox-row .box {
width: 20px; height: 20px; flex: 0 0 20px; border-radius: 5px;
border: 1.5px solid var(--line); background: var(--bg);
display: grid; place-items: center; position: relative;
}
.checkbox-row.on .box { background: var(--accent); border-color: var(--accent); }
.checkbox-row.on .box::after {
content: ""; position: absolute; left: 5px; top: 2px;
width: 6px; height: 11px;
border: solid var(--on-accent); border-width: 0 2px 2px 0;
transform: rotate(42deg);
}
.checkbox-row .mid { flex: 1; min-width: 0; }
.checkbox-row .lbl { font-size: 13px; color: var(--fg); }
.checkbox-row .desc { font-size: 11.5px; color: var(--dim); margin-top: 2px; line-height: 1.45; }
.checkbox-row.off { cursor: default; }
.checkbox-row.off:hover { background: var(--panel2); }
.checkbox-row.off .lbl { color: var(--dim); }
.checkbox-row.off .box { opacity: .55; }
/* === 005 · выбор ассистента (agent-opt) === */ /* === 005 · выбор ассистента (agent-opt) === */
.d-lead { font-size: 12.5px; color: var(--dim); margin-bottom: 12px; } .d-lead { font-size: 12.5px; color: var(--dim); margin-bottom: 12px; }
.agent-opt { display: flex; align-items: center; gap: 12px; padding: 11px 12px; .agent-opt { display: flex; align-items: center; gap: 12px; padding: 11px 12px;
@@ -602,9 +623,15 @@ kbd { background: var(--panel2); border: 1px solid var(--line); border-radius: 4
.grp.sys { background: transparent; border: 1px dashed var(--line); } .grp.sys { background: transparent; border: 1px dashed var(--line); }
.grp.sys .nm { color: var(--fg); } .grp.sys .nm { color: var(--fg); }
.grp .lock { font-size: 11px; color: var(--dim); flex: 0 0 auto; } .grp .lock { font-size: 11px; color: var(--dim); flex: 0 0 auto; }
.field { margin-bottom: 15px; } .field { margin-bottom: 14px; }
.field label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 6px; } .field label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 4px; }
.field input { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line); .field input {
background: var(--bg); color: var(--fg); font-size: 13.5px; outline: none; font-family: inherit; } width: 100%; padding: 6px 0; background: transparent; color: var(--fg);
.field input:focus { border-color: var(--accent); } font-size: 14px; outline: none; font-family: inherit;
.field .note { font-size: 11px; color: var(--dim); margin-top: 6px; } border: 0; border-bottom: 1px solid var(--line);
transition: border-color .12s ease;
}
.field input::placeholder { color: var(--dim); opacity: .55; }
.field input:focus { border-bottom-color: var(--accent); border-bottom-width: 2px; }
.field input.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; }
.field .note { font-size: 11px; color: var(--dim); margin-top: 6px; line-height: 1.5; }
@@ -3,7 +3,7 @@ package pw.binom.agentik.desktop.model
import pw.binom.agentik.desktop.persistence.MetaRow import pw.binom.agentik.desktop.persistence.MetaRow
import pw.binom.agentik.desktop.session.LiveBlock import pw.binom.agentik.desktop.session.LiveBlock
import pw.binom.agentik.desktop.session.LiveTurn import pw.binom.agentik.desktop.session.LiveTurn
import pw.binom.agentik.journal.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.journal.MessageRecord
import kotlin.time.Instant import kotlin.time.Instant
@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.desktop.settings.AgentConfig import pw.binom.agentik.desktop.settings.AgentConfig
import pw.binom.agentik.proto.Agent
/** Один шаг проверки связи. */ /** Один шаг проверки связи. */
data class CheckStep( data class CheckStep(
@@ -28,58 +29,56 @@ data class ConnectionReport(
/** /**
* Проверка связи с агентом без открытия полноценного [AgentConnection]: * Проверка связи с агентом без открытия полноценного [AgentConnection]:
* отдельное «одноразовое» подключение, которое закрывается сразу после * отдельное «одноразовое» подключение, которое закрывается сразу после
* прогона шагов. * прогона шага.
* *
* Шаги: * Единственный шаг — **запрос информации об агенте**: конструктор
* 1. **Ответ сервера** — `GET {baseUrl}` за `AgentInfo` (имя/описание). * [AgentikAgent] синхронно делает `GET {baseUrl}` за `AgentInfo`
* 2. **Диалог** — создать временный диалог и удалить его (проверяет * (имя/описание). Успешный ответ и есть «сервер жив и отвечает на своём
* conversation-роуты, не тратит токены LLM). * API» — без единого побочного эффекта на стороне сервера.
* *
* Сознательно **не** отправляем реальный ход: это дёргает модель (время, * Сознательно **не** создаём временный диалог и **не** отправляем реальный
* стоимость, побочные эффекты в истории). Проверки маршрутов достаточно, * ход: временный диалог — это мусор в истории сервера (и лишний визит к БД),
* чтобы отличить «связи нет» от «связь есть». * а ход ещё и дёргает модель (время, стоимость, след в журнале). Для ответа
* «связь есть / связи нет» достаточно ответа на запрос информации.
*/ */
class ConnectionChecker( class ConnectionChecker(
private val engineFactory: HttpClientEngineFactory<*> = CIO, private val engineFactory: HttpClientEngineFactory<*> = CIO,
private val timeoutMillis: Long = 15_000,
) { ) {
suspend fun check(config: AgentConfig, clientId: String): ConnectionReport = withContext(Dispatchers.IO) { 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() val t0 = System.nanoTime()
try { val agent: Agent = try {
agent = AgentikAgent( AgentikAgent(
id = clientId, id = clientId,
baseUrl = config.baseUrl, baseUrl = config.baseUrl,
engineFactory = engineFactory, engineFactory = engineFactory,
token = config.token, token = config.token,
) )
name = agent.info.name
steps += CheckStep("Ответ сервера", true, elapsed(t0), runCatching { agent.info.description }.getOrNull())
} catch (t: Throwable) { } catch (t: Throwable) {
steps += CheckStep("Ответ сервера", false, elapsed(t0), t.message ?: t::class.simpleName) return@withContext ConnectionReport(
return@withContext ConnectionReport(config.baseUrl, steps, name) baseUrl = config.baseUrl,
steps = listOf(CheckStep(STEP_AGENT_INFO, false, elapsed(t0), t.message ?: t::class.simpleName)),
)
} }
val a = agent
val t1 = System.nanoTime()
try { try {
val conv = a.createConversation(temp = true) val info = agent.info
val id = conv.id ConnectionReport(
conv.close() baseUrl = config.baseUrl,
a.deleteConversation(id) steps = listOf(
steps += CheckStep("Создание диалога", true, elapsed(t1)) CheckStep(STEP_AGENT_INFO, true, elapsed(t0), runCatching { info.description }.getOrNull()),
} catch (t: Throwable) { ),
steps += CheckStep("Создание диалога", false, elapsed(t1), t.message ?: t::class.simpleName) agentName = info.name,
)
} finally { } finally {
runCatching { a.close() } runCatching { agent.close() }
} }
ConnectionReport(config.baseUrl, steps, name)
} }
private fun elapsed(fromNanos: Long): Long = (System.nanoTime() - fromNanos) / 1_000_000 private fun elapsed(fromNanos: Long): Long = (System.nanoTime() - fromNanos) / 1_000_000
companion object {
/** Заголовок единственного шага проверки в отчёте/UI. */
const val STEP_AGENT_INFO = "Информация об агенте"
}
} }
@@ -20,7 +20,7 @@ import kotlin.time.Instant
* `journal.count(convId, after = lastSeen)` и здесь не лежит. * `journal.count(convId, after = lastSeen)` и здесь не лежит.
* - **`preview`** — последний фрагмент текста хода, денормализованный * - **`preview`** — последний фрагмент текста хода, денормализованный
* в строку. Сервер такого поля не отдаёт (STORAGE.md §6.1), клиент * в строку. Сервер такого поля не отдаёт (STORAGE.md §6.1), клиент
* поддерживает сам на `Event.AppendText` / `Event.End`. * поддерживает сам на `OnlineEvent.AppendText` / `Event.AssistantMessage`.
* *
* **Почему НЕ наследуется от `KsqliteJournalStore`:** разные таблицы * **Почему НЕ наследуется от `KsqliteJournalStore`:** разные таблицы
* (`conversation_meta` + `chat_group` против `message`) и разные контракты * (`conversation_meta` + `chat_group` против `message`) и разные контракты
@@ -2,6 +2,7 @@ package pw.binom.agentik.desktop.session
import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -24,6 +25,7 @@ import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.MutableJournalStore import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
import pw.binom.agentik.outbox.OnlineOutbox
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentInfo import pw.binom.agentik.proto.AgentInfo
@@ -107,6 +109,25 @@ class AgentConnection private constructor(
/** Live-события (SSE). */ /** Live-события (SSE). */
val outbox: OutboxStore get() = agent.outbox val outbox: OutboxStore get() = agent.outbox
/** Live-стриминг ответа (дельты текста/картинок), без сохранения. */
val onlineOutbox: OnlineOutbox get() = agent.onlineOutbox
/** Общие мьютексы бэкфилла: сессия + фоновый ingest не пишут один диалог разом. */
private val locks = ConversationLocks()
/** Фоновый ingest ВСЕХ conversation-событий: локальный кэш сообщений + превью. */
private val syncEngine = AgentSyncEngine(
remoteJournal = agent.journal,
journalCache = journalCache,
meta = meta,
outbox = agent.outbox,
locks = locks,
scope = watchScope,
)
/** Диалоги с открытой сессией — их ведёт [ChatSession], engine их пропускает. */
private val openSessions = ConcurrentHashMap.newKeySet<String>()
init { init {
startConversationWatch() startConversationWatch()
} }
@@ -120,6 +141,13 @@ class AgentConnection private constructor(
delay(SEED_RETRY_DELAY) delay(SEED_RETRY_DELAY)
} }
refreshConversations() refreshConversations()
// Глобальный ingest: события всех диалогов → кэш + превью. Отдельной
// подписки на сообщения у UI-списка больше нет — превью неоткрытых
// диалогов догоняются журналом, а не только по факту открытия.
syncEngine.start(
conversations = { agent.conversationStore.list(offset = 0, limit = 500) },
onChanged = { refreshConversations() },
)
runCatching { runCatching {
agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { refreshConversations() } agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { refreshConversations() }
} }
@@ -205,6 +233,8 @@ class AgentConnection private constructor(
*/ */
suspend fun openSession(id: String): ChatSession? { suspend fun openSession(id: String): ChatSession? {
val conv = agent.getConversation(id) ?: return null val conv = agent.getConversation(id) ?: return null
openSessions.add(id)
syncEngine.openConversationIds = openSessions.toSet()
return ChatSession( return ChatSession(
conversationId = id, conversationId = id,
conversation = conv, conversation = conv,
@@ -212,11 +242,18 @@ class AgentConnection private constructor(
journalCache = journalCache, journalCache = journalCache,
meta = meta, meta = meta,
outbox = agent.outbox, outbox = agent.outbox,
onlineOutbox = agent.onlineOutbox,
scope = scope, scope = scope,
locks = locks,
onClosed = {
openSessions.remove(id)
syncEngine.openConversationIds = openSessions.toSet()
},
) )
} }
override fun close() { override fun close() {
syncEngine.close()
watchScope.cancel() watchScope.cancel()
runCatching { agent.close() } runCatching { agent.close() }
runCatching { journalCache.close() } runCatching { journalCache.close() }
@@ -0,0 +1,110 @@
package pw.binom.agentik.desktop.session
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import pw.binom.agentik.desktop.persistence.ConversationMetaRepository
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OutboxStore
/**
* Фоновый ingest одного агента: **одна** подписка на все conversation-события
* (`conversationEvents(after = null, conversationId = null)`) держит локальный
* кэш сообщений и превью списка в актуальном состоянии.
*
* **Зачем.** Раньше сообщения диалога попадали в кэш только когда диалог
* открывали ([ChatSession]) — у неоткрытых диалогов не было ни истории, ни
* превью («…»). Теперь:
* - на старте engine догоняет журнал для диалогов, изменившихся с прошлого
* раза (`updatedAt > syncedAt`), включая никогда не открытые — кэш и превью
* заполняются сразу;
* - на каждый `End`/`Interrupted` любого диалога — инкрементальный догон
* журнала + пересчёт превью;
* - `updatedAt`/`createdAt` берутся у сервера, дубли отсекаются по id.
*
* **Почему не полагаемся только на SSE.** Outbox — bounded TTL tail: события
* старше буфера (пока приложение было выключено) из него выпадают. Поэтому
* источник истины — журнал, а SSE лишь триггерит догон.
*
* **Открытый диалог ведёт [ChatSession]** — engine его пропускает
* ([openConversationIds]); общий [ConversationLocks] сериализует бэкфилл, если
* транзишен «открыли/закрыли» совпал с фоновым догоном.
*/
internal class AgentSyncEngine(
private val remoteJournal: JournalStore,
private val journalCache: MutableJournalStore,
private val meta: ConversationMetaRepository,
private val outbox: OutboxStore,
private val locks: ConversationLocks,
private val scope: CoroutineScope,
) {
/** Диалоги, которые прямо сейчас держит открытая [ChatSession]. */
@Volatile
var openConversationIds: Set<String> = emptySet()
private var job: Job? = null
/**
* Запустить ingest. Идемпотентно.
*
* @param conversations список диалогов агента на момент вызова (для
* стартового догона).
* @param onChanged дёргается, когда локальный кэш/превью изменились —
* чтобы UI перечитал список.
*/
fun start(
conversations: suspend () -> List<ConversationRecord>,
onChanged: suspend () -> Unit,
) {
if (job != null) return
job = scope.launch {
// Подписка — первой: стартовый догон её не задерживает.
launch {
outbox.conversationEvents(after = null, conversationId = null).collect { ev ->
if (ev.conversationId in openConversationIds) return@collect
when (ev.event) {
is Event.AssistantMessage, is Event.Interrupted, is Event.Error -> {
runCatching { syncConversation(ev.conversationId) }
onChanged()
}
else -> Unit
}
}
}
// Стартовый догон изменённых диалогов (включая никогда не открытые).
runCatching {
var changed = false
for (rec in conversations()) {
if (rec.id in openConversationIds) continue
val syncedAt = meta.meta(rec.id)?.syncedAt
if (syncedAt == null || rec.updatedAt > syncedAt) {
syncConversation(rec.id, fallbackWatermark = rec.updatedAt)
changed = true
}
}
if (changed) onChanged()
}
}
}
/** Догнать один диалог (бэкфилл + превью) под общим мьютексом. */
suspend fun syncConversation(conversationId: String, fallbackWatermark: kotlin.time.Instant? = null) =
syncConversationLocked(
remoteJournal = remoteJournal,
journalCache = journalCache,
meta = meta,
locks = locks,
conversationId = conversationId,
fallbackWatermark = fallbackWatermark,
)
fun close() {
job?.cancel()
job = null
}
}
@@ -16,11 +16,11 @@ import pw.binom.agentik.desktop.persistence.ConversationMetaRepository
import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.MutableJournalStore import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OnlineOutbox
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -32,8 +32,9 @@ import kotlin.time.Instant
* старте догоняем то, что появилось с прошлого раза, и кладём в кэш. * старте догоняем то, что появилось с прошлого раза, и кладём в кэш.
* Watermark — `conversation_meta.synced_at`, чтобы не вставлять дубли * Watermark — `conversation_meta.synced_at`, чтобы не вставлять дубли
* (`message.id` — PRIMARY KEY, повторный INSERT бросает). * (`message.id` — PRIMARY KEY, повторный INSERT бросает).
* - **Live-ход** — `OutboxStore.conversationEvents` (SSE): события * - **Live-ход** — два SSE-потока: durable
* `Working → StartReasoning → AppendText → … → End` накапливаются в * `OutboxStore.conversationEvents` (`UserMessage → … → AssistantMessage`) и
* онлайн `OnlineOutbox.onlineEvents` (Working/End + стриминг ответа). Оба копятся в
* [LiveTurn] и рисуются поверх истории, пока ход не закрыт. * [LiveTurn] и рисуются поверх истории, пока ход не закрыт.
* - **Наши поля** — [ConversationMetaRepository]: `lastSeen` (бейдж), * - **Наши поля** — [ConversationMetaRepository]: `lastSeen` (бейдж),
* `preview` (строка списка). * `preview` (строка списка).
@@ -41,7 +42,7 @@ import kotlin.time.Instant
* ## Почему подписка на SSE берётся `after = synced_at` * ## Почему подписка на SSE берётся `after = synced_at`
* *
* `synced_at` — `createdAt` последнего сообщения, уже лежащего в кэше. * `synced_at` — `createdAt` последнего сообщения, уже лежащего в кэше.
* У завершённого хода все события (`Working`/`AppendText`/`End`) имеют * У завершённого хода все события (`UserMessage`/`AppendText`/`AssistantMessage`) имеют
* `date`, не больший `createdAt` итогового сообщения, — значит, они * `date`, не больший `createdAt` итогового сообщения, — значит, они
* отфильтруются и повторно не проиграются. А у **идущего** хода события * отфильтруются и повторно не проиграются. А у **идущего** хода события
* свежее — они и попадут в поток. Так «догоняем» уже начатый ход, не * свежее — они и попадут в поток. Так «догоняем» уже начатый ход, не
@@ -50,7 +51,7 @@ import kotlin.time.Instant
* *
* ## Поток событий vs `sync()` на `End` * ## Поток событий vs `sync()` на `End`
* *
* На `End`/`Interrupted` ход закрыт: подтягиваем авторитетную историю * На `AssistantMessage`/`Interrupted`/`Error` ход закрыт: подтягиваем авторитетную историю
* (`sync()` — инкрементальный бэкфилл + перечитка кэша) и гасим live-блоки, * (`sync()` — инкрементальный бэкфилл + перечитка кэша) и гасим live-блоки,
* чтобы не было дублирования «live + history». * чтобы не было дублирования «live + history».
*/ */
@@ -61,7 +62,15 @@ class ChatSession(
private val journalCache: MutableJournalStore, private val journalCache: MutableJournalStore,
private val meta: ConversationMetaRepository, private val meta: ConversationMetaRepository,
private val outbox: OutboxStore, private val outbox: OutboxStore,
private val onlineOutbox: OnlineOutbox,
private val scope: CoroutineScope, private val scope: CoroutineScope,
/**
* Общие с [AgentSyncEngine] мьютексы по диалогам: сериализуют бэкфилл,
* если фоновый ingest и открытая сессия синхронят один диалог.
*/
private val locks: ConversationLocks = ConversationLocks(),
/** Колбэк на [close] — engine снимает диалог из своего open-набора. */
private val onClosed: () -> Unit = {},
) : AutoCloseable { ) : AutoCloseable {
private val _history = MutableStateFlow<List<UiMessage>>(emptyList()) private val _history = MutableStateFlow<List<UiMessage>>(emptyList())
@@ -88,16 +97,17 @@ class ChatSession(
private val liveTurn = LiveTurn() private val liveTurn = LiveTurn()
private var eventsJob: Job? = null private var eventsJob: Job? = null
private var onlineJob: Job? = null
private var started = false private var started = false
/** /**
* Сериализует [sync]. Без него два параллельных триггера (оптимистичный * Сериализует [sync] через общий [locks]. Без него два параллельных
* `sync()` из [sendContent] и `sync()` из обработчика SSE `End`, который * триггера (оптимистичный `sync()` из [sendContent] и `sync()` из
* приходит почти сразу на быстром ответе) читают один и тот же watermark * обработчика SSE `End`) читают один и тот же watermark `null` →
* `null` → `after = DISTANT_PAST` и оба вставляют user-сообщение → * `after = DISTANT_PAST` и оба вставляют user-сообщение →
* `UNIQUE constraint failed: message.id`. * `UNIQUE constraint failed: message.id`.
*/ */
private val syncMutex = Mutex() private fun syncLock(): Mutex = locks.lock(conversationId)
/** /**
* Догоняет историю и подписывается на live-события. Идемпотентно — * Догоняет историю и подписывается на live-события. Идемпотентно —
@@ -112,6 +122,7 @@ class ChatSession(
} }
sync() sync()
subscribeEvents() subscribeEvents()
subscribeOnline()
} }
/** Отправить текстовый ход. Пустой/пробельный текст игнорируется. */ /** Отправить текстовый ход. Пустой/пробельный текст игнорируется. */
@@ -147,7 +158,10 @@ class ChatSession(
override fun close() { override fun close() {
eventsJob?.cancel() eventsJob?.cancel()
eventsJob = null eventsJob = null
onlineJob?.cancel()
onlineJob = null
runCatching { conversation.close() } runCatching { conversation.close() }
onClosed()
} }
// ───── internals ───── // ───── internals ─────
@@ -159,8 +173,10 @@ class ChatSession(
.map { it.event } .map { it.event }
.collect { event -> .collect { event ->
liveTurn.apply(event) liveTurn.apply(event)
if (event is Event.End || event is Event.Interrupted) { // Ход закрыт durable-событием: успех — готовое
// Ход закрыт: авторитетная история важнее live-блоков. // AssistantMessage, прерывание — Interrupted, провал —
// Error. Авторитетная история важнее live-блоков.
if (event is Event.AssistantMessage || event is Event.Interrupted || event is Event.Error) {
runCatching { sync() } runCatching { sync() }
liveTurn.reset() liveTurn.reset()
} }
@@ -168,44 +184,36 @@ class ChatSession(
} }
} }
/**
* Подписка на **онлайн-поток** — стриминг ответа (`StartReasoning` /
* `StartResponse` / `AppendText` / `AppendImage`). Отдельный live-канал
* без курсора и без catchup: пропущенный при обрыве фрагмент
* невосстановим, но целый результат придёт durable-`End` и/или ляжет
* в журнал (см. [sync]).
*/
private fun subscribeOnline() {
onlineJob = scope.launch {
onlineOutbox.onlineEvents(conversationId).collect { event ->
liveTurn.applyOnline(event)
}
}
}
private suspend fun currentSyncedAt(): Instant? = meta.meta(conversationId)?.syncedAt private suspend fun currentSyncedAt(): Instant? = meta.meta(conversationId)?.syncedAt
/** Инкрементальный бэкфилл + перечитка кэша + превью. Сериализован. */ /** Инкрементальный бэкфилл + перечитка кэша + превью. Сериализован. */
private suspend fun sync() = syncMutex.withLock { private suspend fun sync() = syncLock().withLock {
backfill() backfill()
reloadHistory() reloadHistory()
updatePreview() updatePreview()
} }
/** /**
* Дотягивает сообщения, появившиеся с прошлого watermark'а. * Дотягивает сообщения, появившиеся с прошлого watermark'а (см.
* * [backfillConversation] — там же про идемпотентность по id).
* **Идемпотентность по id, а не по времени.** Watermark — миллисекундный:
* user- и assistant-сообщение быстрого ответа делят одну миллисекунду, и
* фильтр `createdAt > after` просто потерял бы второе. Поэтому watermark
* отступает на 1 мс назад, а повторно пришедшие записи отсекаются по
* множеству уже известных id (никакого `runCatching` — реальные ошибки
* записи должны быть видны).
*/ */
private suspend fun backfill() { private suspend fun backfill() {
val row = meta.meta(conversationId) backfillConversation(remoteJournal, journalCache, 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() { private suspend fun reloadHistory() {
@@ -0,0 +1,101 @@
package pw.binom.agentik.desktop.session
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
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 kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
/**
* Мьютексы по диалогам. Сериализуют инкрементальный бэкфилл одного диалога
* между открытой [ChatSession] и фоновым [AgentSyncEngine]: при транзишене
* «диалог только что открыли / только что закрыли» оба могут дёрнуть sync
* одновременно, а двойной INSERT одного `message.id` бросает
* `UNIQUE constraint failed`.
*/
class ConversationLocks {
private val locks = ConcurrentHashMap<String, Mutex>()
fun lock(conversationId: String): Mutex = locks.getOrPut(conversationId) { Mutex() }
}
/**
* Инкрементальный бэкфилл одного диалога: дотягивает из удалённого журнала
* всё, что появилось после локального watermark'а, и кладёт в кэш.
*
* **Идемпотентность по id, а не по времени.** Watermark — миллисекундный:
* user- и assistant-сообщение быстрого ответа делят одну миллисекунду, и
* фильтр `createdAt > after` потерял бы второе. Поэтому watermark отступает
* на 1 мс назад, а повторно пришедшие записи отсекаются по множеству уже
* известных id (никакого `runCatching` — реальные ошибки записи должны быть
* видны).
*
* @param fallbackWatermark если в журнале не оказалось ни одной записи (пустой
* диалог), watermark ставится по нему, чтобы не перезапрашивать пустой
* диалог на каждом старте.
* @return `createdAt` самой свежей дотянутой записи, или `null`.
*/
internal suspend fun backfillConversation(
remoteJournal: JournalStore,
journalCache: MutableJournalStore,
meta: ConversationMetaRepository,
conversationId: String,
fallbackWatermark: Instant? = null,
): Instant? {
val after = meta.meta(conversationId)?.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
}
val watermark = newest ?: fallbackWatermark
watermark?.let { meta.setSyncedAt(conversationId, it - 1.milliseconds) }
return newest
}
/**
* Пересчитывает денормализованное превью строки списка: последнее
* assistant-сообщение, иначе последнее вообще. Читает из локального кэша.
*/
internal suspend fun updateConversationPreview(
journalCache: MutableJournalStore,
meta: ConversationMetaRepository,
conversationId: String,
) {
val history = journalCache.list(
conversationId = conversationId,
after = Instant.DISTANT_PAST,
offset = 0,
limit = Int.MAX_VALUE,
).toUiMessages()
val last = history.lastOrNull { it is UiMessage.Assistant } ?: history.lastOrNull()
meta.setPreview(conversationId, last?.previewText())
}
/** Бэкфилл + пересчёт превью одного диалога под общим мьютексом. */
internal suspend fun syncConversationLocked(
remoteJournal: JournalStore,
journalCache: MutableJournalStore,
meta: ConversationMetaRepository,
locks: ConversationLocks,
conversationId: String,
fallbackWatermark: Instant? = null,
) = locks.lock(conversationId).withLock {
backfillConversation(remoteJournal, journalCache, meta, conversationId, fallbackWatermark)
updateConversationPreview(journalCache, meta, conversationId)
}
@@ -1,6 +1,7 @@
package pw.binom.agentik.desktop.session package pw.binom.agentik.desktop.session
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OnlineEvent
/** /**
* Фаза текущего хода ассистента. Управляет спиннером и тем, что UI * Фаза текущего хода ассистента. Управляет спиннером и тем, что UI
@@ -10,16 +11,16 @@ enum class TurnPhase {
/** Хода нет — стор чист. */ /** Хода нет — стор чист. */
IDLE, IDLE,
/** `send()` ушёл, ответа ещё нет (`Event.Working`). Спиннер «агент думает». */ /** `send()` ушёл, ответа ещё нет (`OnlineEvent.Working`). Спиннер «агент думает». */
WORKING, WORKING,
/** Идут рассуждения (`StartReasoning` + `AppendText` в reasoning-блок). */ /** Идут рассуждения (`StartReasoning` + `AppendText` в reasoning-блок). Live-канал. */
REASONING, REASONING,
/** Стримится ответ (`StartResponse` + `AppendText`/`AppendImage`/tool'ы). */ /** Стримится ответ (`StartResponse` + `AppendText`/`AppendImage`/tool'ы). Live-канал. */
RESPONDING, RESPONDING,
/** Ход закрыт нормально (`End`). Live-блоки пора заменить историей из кэша. */ /** Ход закрыт нормально (`OnlineEvent.End` / durable `Event.AssistantMessage`). Live-блоки пора заменить историей из кэша. */
DONE, DONE,
/** Ход прерван пользователем (`Interrupted`). Частичный ответ НЕ сохранён. */ /** Ход прерван пользователем (`Interrupted`). Частичный ответ НЕ сохранён. */
@@ -92,8 +93,8 @@ sealed interface LiveBlock {
} }
/** /**
* Аккумулятор live-хода: применяет [Event] по мере SSE-стрима и держит * Аккумулятор live-хода: применяет durable-[Event] и онлайн-[OnlineEvent]
* текущий список [LiveBlock] + [phase]. * по мере двух SSE-стримов и держит текущий список [LiveBlock] + [phase].
* *
* Это **модель представления**, не хранилище: после `End` ход * Это **модель представления**, не хранилище: после `End` ход
* «схлопывается» в обычную `MessageRecord`, и UI переключается на историю * «схлопывается» в обычную `MessageRecord`, и UI переключается на историю
@@ -132,7 +133,7 @@ class LiveTurn {
/** /**
* Начинает новый ход: чистит блоки и переводит в [TurnPhase.WORKING]. * Начинает новый ход: чистит блоки и переводит в [TurnPhase.WORKING].
* Вызывать перед `send()` (или на `Event.Working`) — иначе блоки * Вызывать перед `send()` (или на `OnlineEvent.Working`) — иначе блоки
* предыдущего хода смешаются с новыми. * предыдущего хода смешаются с новыми.
*/ */
fun start() { fun start() {
@@ -151,45 +152,23 @@ class LiveTurn {
} }
/** /**
* Применяет одно событие. Неизвестные/нерелевантные события * Применяет одно **durable** событие ([Event]). Стриминговые события
* (дельты текста/картинок, маркеры фаз) сюда НЕ приходят — они в
* [applyOnline] ([OnlineEvent]). Неизвестные/нерелевантные
* (`ConversationClosing`, `CompactionTriggered`) игнорируются. * (`ConversationClosing`, `CompactionTriggered`) игнорируются.
*/ */
fun apply(event: Event) { fun apply(event: Event) {
when (event) { when (event) {
is Event.Working -> { is Event.UserMessage -> {
// Синхронный маркер старта хода: новый ход — с чистого листа. // Durable-маркер старта хода. Если онлайн-`Working` уже
start() // открыл ход, повторно не чистим блоки (защита от гонки
// двух SSE-потоков).
if (!phase.isStreaming) start()
} }
is Event.StartReasoning -> { is Event.AssistantMessage -> {
phase = TurnPhase.REASONING // Durable-терминатор успешного хода: целый ответ уже в журнале.
_blocks.add(LiveBlock.Reasoning(nextKey("reasoning"))) phase = TurnPhase.DONE
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() fire()
} }
@@ -232,11 +211,6 @@ class LiveTurn {
fire() fire()
} }
is Event.End -> {
phase = TurnPhase.DONE
fire()
}
is Event.Interrupted -> { is Event.Interrupted -> {
phase = TurnPhase.INTERRUPTED phase = TurnPhase.INTERRUPTED
fire() fire()
@@ -253,6 +227,58 @@ class LiveTurn {
} }
} }
/**
* Применяет одно **онлайн** событие ([OnlineEvent]) — live-стриминг
* ответа. Онлайн-события нигде не сохраняются: при обрыве соединения
* потерянный фрагмент невосстановим (целый результат придёт
* durable-[Event.AssistantMessage] и/или ляжет в журнал).
*/
fun applyOnline(event: OnlineEvent) {
when (event) {
is OnlineEvent.Working -> {
// Live-маркер старта хода (эмитится синхронно из send()).
start()
}
is OnlineEvent.End -> {
phase = TurnPhase.DONE
fire()
}
is OnlineEvent.StartReasoning -> {
phase = TurnPhase.REASONING
_blocks.add(LiveBlock.Reasoning(nextKey("reasoning")))
fire()
}
is OnlineEvent.StartResponse -> {
phase = TurnPhase.RESPONDING
if (event.responseType == OnlineEvent.ResponseType.TEXT) {
_blocks.add(LiveBlock.Answer(nextKey("answer")))
}
fire()
}
is OnlineEvent.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 OnlineEvent.AppendImage -> {
phase = TurnPhase.RESPONDING
_blocks.add(LiveBlock.Image(nextKey("image"), event.mime, event.body))
fire()
}
}
}
/** /**
* Дописывает текстовый чанк в последний блок нужного типа. Если * Дописывает текстовый чанк в последний блок нужного типа. Если
* последний блок — другого типа (например, между двумя кусками текста * последний блок — другого типа (например, между двумя кусками текста
@@ -35,14 +35,19 @@ import pw.binom.agentik.desktop.ui.screens.AgentRail
import pw.binom.agentik.desktop.ui.screens.AgentsDialog import pw.binom.agentik.desktop.ui.screens.AgentsDialog
import pw.binom.agentik.desktop.ui.screens.ChatPane import pw.binom.agentik.desktop.ui.screens.ChatPane
import pw.binom.agentik.desktop.ui.screens.ConversationList import pw.binom.agentik.desktop.ui.screens.ConversationList
import pw.binom.agentik.desktop.ui.screens.DialogInfoDialog
import pw.binom.agentik.desktop.ui.screens.GroupsDialog import pw.binom.agentik.desktop.ui.screens.GroupsDialog
import pw.binom.agentik.desktop.ui.screens.NewChatDialog import pw.binom.agentik.desktop.ui.screens.NewChatDialog
import pw.binom.agentik.desktop.ui.theme.AgentikTheme import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
import pw.binom.agentik.desktop.session.AgentStatus
private sealed interface AppDialog { private sealed interface AppDialog {
data object Agents : AppDialog data object Agents : AppDialog
data object Groups : AppDialog data object Groups : AppDialog
data object NewChat : AppDialog data object NewChat : AppDialog
data object DialogInfo : AppDialog
} }
/** /**
@@ -52,7 +57,7 @@ private sealed interface AppDialog {
*/ */
@Composable @Composable
fun App(state: AppState) { fun App(state: AppState) {
AgentikTheme(themeId = state.settingsState.themeId) { ProvideAgentikTheme(theme = state.settingsState.themeId.toAgentikTheme()) {
CompositionLocalProvider(LocalAvatarStorage provides state.avatars) { CompositionLocalProvider(LocalAvatarStorage provides state.avatars) {
LaunchedEffect(Unit) { state.start() } LaunchedEffect(Unit) { state.start() }
Root(state) Root(state)
@@ -66,6 +71,15 @@ private fun Root(state: AppState) {
var dialog by remember { mutableStateOf<AppDialog?>(null) } var dialog by remember { mutableStateOf<AppDialog?>(null) }
var selectedGroupId by remember { mutableStateOf<Long?>(null) } var selectedGroupId by remember { mutableStateOf<Long?>(null) }
// Кнопка ⋯ в шапке чата выставляет infoDialogOpen = true;
// синхронизируем это с роутером модалок.
LaunchedEffect(state.infoDialogOpen, state.activeConversationId) {
if (state.infoDialogOpen && state.activeConversationId != null && dialog == null) {
dialog = AppDialog.DialogInfo
state.consumeDialogInfoRequest()
}
}
BoxWithConstraints(Modifier.fillMaxSize().background(colors.background)) { BoxWithConstraints(Modifier.fillMaxSize().background(colors.background)) {
val wide = maxWidth >= 900.dp val wide = maxWidth >= 900.dp
val hasChat = state.activeConversationId != null val hasChat = state.activeConversationId != null
@@ -147,6 +161,37 @@ private fun Root(state: AppState) {
) )
} }
AppDialog.DialogInfo -> {
val conversation = state.activeConversation
if (conversation != null) {
Overlay(onDismiss = { dialog = null; state.closeDialogInfo() }) {
DialogInfoDialog(
conversation = conversation,
agentName = state.activeAgent?.let { state.agentDisplayName(it) },
agentAddress = state.activeAgent?.baseUrl,
agentColor = state.activeAgent?.let { state.agentColor(it) },
online = state.activeStatus is AgentStatus.Online,
onClose = {
dialog = null
state.closeDialogInfo()
},
onRename = { newTitle, cb ->
val id = conversation.id
state.renameConversation(id, newTitle, cb)
},
onDelete = {
val id = conversation.id
state.deleteConversation(id)
dialog = null
state.closeDialogInfo()
},
)
}
} else {
dialog = null
}
}
null -> Unit null -> Unit
} }
} }
@@ -123,6 +123,29 @@ class AppState(
/** Каталог агентов. */ /** Каталог агентов. */
val agents: List<AgentConfig> get() = settingsState.agents val agents: List<AgentConfig> get() = settingsState.agents
/** Открытый диалог как `UiConversation` (или `null`, если ничего не выбрано). */
val activeConversation: UiConversation?
get() = activeConversationId?.let { id -> conversations.firstOrNull { it.id == id } }
/** Состояние модалки «Информация о диалоге». */
var infoDialogOpen by mutableStateOf(false)
private set
/** Открыть модалку информации по активному диалогу. */
fun openDialogInfo() {
if (activeConversationId != null) infoDialogOpen = true
}
/** Закрыть модалку информации. */
fun closeDialogInfo() {
infoDialogOpen = false
}
/** Сбросить флаг «хотим открыть модалку» (после того как роутер её открыл). */
fun consumeDialogInfoRequest() {
infoDialogOpen = false
}
/** /**
* Отображаемое имя агента: локальное переименование → серверное * Отображаемое имя агента: локальное переименование → серверное
* `AgentInfo.name` → адрес (пока не подключились). * `AgentInfo.name` → адрес (пока не подключились).
@@ -240,7 +263,7 @@ class AppState(
/** Проверка связи для формы агента — вызывается UI из своей корутины. */ /** Проверка связи для формы агента — вызывается UI из своей корутины. */
suspend fun checkConnection(config: AgentConfig): ConnectionReport = suspend fun checkConnection(config: AgentConfig): ConnectionReport =
runCatching { checker.check(config, settingsState.clientId) } runCatching { checker.check(config, settingsState.clientId) }
.getOrElse { ConnectionReport(config.baseUrl, listOf(CheckStep("Ответ сервера", false, 0, it.message))) } .getOrElse { ConnectionReport(config.baseUrl, listOf(CheckStep(ConnectionChecker.STEP_AGENT_INFO, false, 0, it.message))) }
// ───── conversations ───── // ───── conversations ─────
@@ -334,11 +357,21 @@ class AppState(
} }
} }
fun renameConversation(id: String, title: String?) { fun renameConversation(
val conn = activeAgentId?.let { registry.connection(it) } ?: return id: String,
title: String?,
onResult: (Result<Unit>) -> Unit = {},
) {
val conn = activeAgentId?.let { registry.connection(it) }
if (conn == null) {
onResult(Result.failure(IllegalStateException("Нет соединения с агентом")))
return
}
scope.launch { scope.launch {
runCatching { conn.renameConversation(id, title) }.onFailure { reportError(it) } val result = runCatching { conn.renameConversation(id, title) }
result.onFailure { reportError(it, "Не удалось переименовать") }
refresh() refresh()
onResult(result.map { })
} }
} }
@@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import pw.binom.agentik.desktop.ui.ClientImageSupport import pw.binom.agentik.desktop.ui.ClientImageSupport
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import java.util.Base64 import java.util.Base64
private enum class ImageLoadState { Loading, Ready, Error } private enum class ImageLoadState { Loading, Ready, Error }
@@ -41,6 +42,7 @@ fun ChatImage(src: String, alt: String?) {
// Декодер не зарегистрирован — fallback: alt (или ссылка) текстом. // Декодер не зарегистрирован — fallback: alt (или ссылка) текстом.
Text( Text(
text = alt?.takeIf { it.isNotBlank() } ?: src, text = alt?.takeIf { it.isNotBlank() } ?: src,
color = AgentikTheme.colors.text,
modifier = Modifier.widthIn(max = 280.dp), modifier = Modifier.widthIn(max = 280.dp),
) )
return return
@@ -18,12 +18,9 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape 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.Icons
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -38,52 +35,55 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle 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.TextLayoutResult
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp 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.MarkdownSegment
import pw.binom.agentik.desktop.markdown.TextStyleSpan import pw.binom.agentik.desktop.markdown.TextStyleSpan
import pw.binom.agentik.desktop.markdown.parseMarkdown import pw.binom.agentik.desktop.markdown.parseMarkdown
/** Скругление у блоков (код, таблица). Геометрия, а не тема — поэтому константа. */
private val RoundedBlockShape = RoundedCornerShape(8.dp)
/** Ширина колонки таблицы ≈ [TableCharWidth] на символ, но не уже [TableMinColumnWidth]. */
private val TableCharWidth = 9.dp
private val TableMinColumnWidth = 60.dp
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Public rendering entry point // Public rendering entry point
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** /**
* Parse markdown and render segments with the given base text style. * Разбирает markdown и рисует сегменты стилем [style] (по умолчанию — текущий
* стиль темы из [LocalMarkdownStyle]).
*/ */
@Composable @Composable
fun renderMarkdownSegments( fun renderMarkdownSegments(
content: String, content: String,
textColor: Color,
fontSize: TextUnit,
isBubbleScrolled: Boolean = false, isBubbleScrolled: Boolean = false,
onUrlClick: (String) -> Unit = {}, onUrlClick: (String) -> Unit = {},
style: MarkdownStyle = LocalMarkdownStyle.current,
) { ) {
val baseStyle = SpanStyle(color = textColor, fontSize = fontSize) val segments = remember(content) { parseMarkdown(content) }
val colors = AgentikTheme.colors
val segments = parseMarkdown(content)
var previousWasText = false var previousWasText = false
for (segment in segments) { for (segment in segments) {
when (segment) { when (segment) {
is MarkdownSegment.Text -> { is MarkdownSegment.Text -> {
val annotated = segment.toAnnotatedString(baseStyle, colors) val annotated = segment.toAnnotatedString(style)
if (annotated.text.isNotEmpty()) { if (annotated.text.isNotEmpty()) {
// Между соседними абзацами (последовательными текстовыми сегментами) // Между соседними абзацами (последовательными текстовыми сегментами)
// оставляем вертикальный отступ, иначе абзацы склеиваются в сплошной блок. // оставляем вертикальный отступ, иначе абзацы склеиваются в сплошной блок.
if (previousWasText) { if (previousWasText) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(style.paragraphSpacing))
} }
clickableMarkdownText( clickableMarkdownText(
annotatedString = annotated, annotatedString = annotated,
lineHeight = style.bodyLineHeight,
modifier = Modifier modifier = Modifier
.then(if (isBubbleScrolled) Modifier.widthIn(max = 600.dp) else Modifier), .then(if (isBubbleScrolled) Modifier.widthIn(max = style.maxContentWidth) else Modifier),
onUrlClick = onUrlClick, onUrlClick = onUrlClick,
) )
previousWasText = true previousWasText = true
@@ -92,76 +92,79 @@ fun renderMarkdownSegments(
is MarkdownSegment.CodeBlock -> { is MarkdownSegment.CodeBlock -> {
previousWasText = false previousWasText = false
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(style.blockSpacing))
Column( Column(
modifier = Modifier modifier = Modifier
.then( .then(
if (isBubbleScrolled) Modifier.widthIn(max = 600.dp) if (isBubbleScrolled) Modifier.widthIn(max = style.maxContentWidth)
else Modifier.fillMaxWidth() else Modifier.fillMaxWidth()
) )
.clip(RoundedCornerShape(8.dp)) .clip(RoundedBlockShape)
.background(colors.codeBackground), .background(style.codeBackground),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(AgentikTheme.colors.panelElevated) .background(style.codeHeaderBackground)
.padding(horizontal = 12.dp, vertical = 6.dp), .padding(
horizontal = style.codeHeaderPaddingHorizontal,
vertical = style.codeHeaderPaddingVertical,
),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = segment.language ?: "Code", text = segment.language ?: "Code",
fontSize = 12.sp, fontSize = style.labelFontSize,
fontFamily = FontFamily.Monospace, fontFamily = style.monospaceFamily,
color = colors.textDim, color = style.labelColor,
) )
Icon( Icon(
imageVector = Icons.Default.ContentCopy, imageVector = Icons.Default.ContentCopy,
contentDescription = "Скопировать", contentDescription = "Скопировать",
modifier = Modifier modifier = Modifier
.size(16.dp) .size(style.copyIconSize)
.clickable { .clickable {
clipboardManager.setText(AnnotatedString(segment.code)) clipboardManager.setText(AnnotatedString(segment.code))
}, },
tint = colors.textDim, tint = style.labelColor,
) )
} }
Box( Box(
modifier = Modifier modifier = Modifier
.padding(12.dp) .padding(style.codePadding)
.horizontalScroll(rememberScrollState()), .horizontalScroll(rememberScrollState()),
) { ) {
Text( Text(
text = segment.code, text = segment.code,
fontFamily = FontFamily.Monospace, fontFamily = style.monospaceFamily,
fontSize = 13.sp, fontSize = style.codeFontSize,
color = colors.codeText, color = style.codeTextColor,
) )
} }
} }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(style.blockSpacing))
} }
is MarkdownSegment.Table -> { is MarkdownSegment.Table -> {
previousWasText = false previousWasText = false
renderTable(segment, textColor, fontSize, isBubbleScrolled) renderTable(segment, style, isBubbleScrolled)
} }
is MarkdownSegment.Image -> { is MarkdownSegment.Image -> {
previousWasText = false previousWasText = false
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(style.blockSpacing))
ChatImage(src = segment.src, alt = segment.alt) ChatImage(src = segment.src, alt = segment.alt)
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(style.blockSpacing))
} }
is MarkdownSegment.ThematicBreak -> { is MarkdownSegment.ThematicBreak -> {
previousWasText = false previousWasText = false
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(style.blockSpacing))
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(1.dp) .height(1.dp)
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f)), .background(style.divider),
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(style.blockSpacing))
} }
} }
} }
@@ -174,11 +177,10 @@ fun renderMarkdownSegments(
@Composable @Composable
private fun renderTable( private fun renderTable(
segment: MarkdownSegment.Table, segment: MarkdownSegment.Table,
textColor: Color, style: MarkdownStyle,
fontSize: TextUnit,
isBubbleScrolled: Boolean, isBubbleScrolled: Boolean,
) { ) {
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(style.tableSpacing))
val columnWidths = remember(segment) { val columnWidths = remember(segment) {
val columnCount = segment.headers.size val columnCount = segment.headers.size
@@ -203,15 +205,14 @@ private fun renderTable(
Column( Column(
modifier = Modifier modifier = Modifier
.then(if (isBubbleScrolled) Modifier.wrapContentWidth() else Modifier.fillMaxWidth()) .then(if (isBubbleScrolled) Modifier.wrapContentWidth() else Modifier.fillMaxWidth())
.clip(RoundedCornerShape(8.dp)) .clip(RoundedBlockShape)
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.3f)) .background(style.tableSurface)
) { ) {
if (isBubbleScrolled) { if (isBubbleScrolled) {
TableRows( TableRows(
segment = segment, segment = segment,
columnWidths = columnWidths, columnWidths = columnWidths,
textColor = textColor, style = style,
fontSize = fontSize,
) )
} else { } else {
Box( Box(
@@ -223,30 +224,27 @@ private fun renderTable(
TableRows( TableRows(
segment = segment, segment = segment,
columnWidths = columnWidths, columnWidths = columnWidths,
textColor = textColor, style = style,
fontSize = fontSize,
) )
} }
} }
} }
} }
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(style.tableSpacing))
} }
@Composable @Composable
private fun TableRows( private fun TableRows(
segment: MarkdownSegment.Table, segment: MarkdownSegment.Table,
columnWidths: List<Int>, columnWidths: List<Int>,
textColor: Color, style: MarkdownStyle,
fontSize: TextUnit,
) { ) {
// Header row // Header row
TableRow( TableRow(
cells = segment.headers, cells = segment.headers,
widths = columnWidths, widths = columnWidths,
isHeader = true, isHeader = true,
textColor = textColor, style = style,
fontSize = fontSize,
) )
// Data rows // Data rows
segment.rows.forEachIndexed { idx, row -> segment.rows.forEachIndexed { idx, row ->
@@ -254,8 +252,7 @@ private fun TableRows(
cells = row, cells = row,
widths = columnWidths, widths = columnWidths,
isHeader = false, isHeader = false,
textColor = textColor, style = style,
fontSize = fontSize,
rowIndex = idx, rowIndex = idx,
) )
} }
@@ -266,14 +263,13 @@ private fun TableRow(
cells: List<String>, cells: List<String>,
widths: List<Int>, widths: List<Int>,
isHeader: Boolean, isHeader: Boolean,
textColor: Color, style: MarkdownStyle,
fontSize: TextUnit,
rowIndex: Int = 0, rowIndex: Int = 0,
) { ) {
val bgColor = when { val bgColor = when {
isHeader -> MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) isHeader -> style.tableHeaderBackground
rowIndex % 2 == 0 -> Color.Transparent rowIndex % 2 == 0 -> Color.Transparent
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.03f) else -> style.tableStripeBackground
} }
Row( Row(
modifier = Modifier modifier = Modifier
@@ -283,13 +279,19 @@ private fun TableRow(
cells.forEachIndexed { i, cell -> cells.forEachIndexed { i, cell ->
Box( Box(
modifier = Modifier modifier = Modifier
.width((widths.getOrElse(i) { 10 } * 9).dp.coerceAtLeast(60.dp)) .width(
.padding(horizontal = 8.dp, vertical = 6.dp), (TableCharWidth * widths.getOrElse(i) { 10 })
.coerceAtLeast(TableMinColumnWidth)
)
.padding(
horizontal = style.tableCellPaddingHorizontal,
vertical = style.tableCellPaddingVertical,
),
) { ) {
Text( Text(
text = cell, text = cell,
color = textColor, color = style.textColor,
fontSize = fontSize, fontSize = style.tableFontSize,
fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal, fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
softWrap = true, softWrap = true,
) )
@@ -306,6 +308,7 @@ private fun TableRow(
private fun clickableMarkdownText( private fun clickableMarkdownText(
annotatedString: AnnotatedString, annotatedString: AnnotatedString,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
lineHeight: TextUnit = TextUnit.Unspecified,
onUrlClick: (String) -> Unit = {}, onUrlClick: (String) -> Unit = {},
) { ) {
val urlAnnotations = annotatedString.getStringAnnotations("URL", 0, annotatedString.length) val urlAnnotations = annotatedString.getStringAnnotations("URL", 0, annotatedString.length)
@@ -317,6 +320,7 @@ private fun clickableMarkdownText(
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) } var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text( Text(
text = annotatedString, text = annotatedString,
lineHeight = lineHeight,
modifier = modifier.pointerInput(annotatedString) { modifier = modifier.pointerInput(annotatedString) {
detectTapGestures { offset -> detectTapGestures { offset ->
val layout = textLayoutResult ?: return@detectTapGestures val layout = textLayoutResult ?: return@detectTapGestures
@@ -334,7 +338,7 @@ private fun clickableMarkdownText(
onTextLayout = { textLayoutResult = it }, onTextLayout = { textLayoutResult = it },
) )
} else { } else {
Text(text = annotatedString, modifier = modifier) Text(text = annotatedString, lineHeight = lineHeight, modifier = modifier)
} }
} }
@@ -342,16 +346,27 @@ private fun clickableMarkdownText(
// Convert MarkdownSegment.Text to Compose AnnotatedString // Convert MarkdownSegment.Text to Compose AnnotatedString
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/**
* Текст сегмента → [AnnotatedString].
*
* База ([baseStyle], по умолчанию [MarkdownStyle.bodySpan]) накладывается на ВЕСЬ
* текст, а не только на размеченные спаны: парсер заводит [TextStyleSpan] лишь
* для жирного/курсива/кода/ссылок, поэтому «простой» текст без базы оставался
* без цвета и рисовался цветом по умолчанию (чёрным) — это и был баг «текст
* чёрный вместо белого».
*/
fun MarkdownSegment.Text.toAnnotatedString( fun MarkdownSegment.Text.toAnnotatedString(
baseStyle: SpanStyle = SpanStyle(), style: MarkdownStyle,
colors: AgentikColors = darkAgentikColors, baseStyle: SpanStyle = style.bodySpan(),
): AnnotatedString { ): AnnotatedString {
val builder = AnnotatedString.Builder(text) val builder = AnnotatedString.Builder(text)
if (text.isNotEmpty()) {
builder.addStyle(baseStyle, 0, text.length)
}
for (span in spans) { for (span in spans) {
val style = buildSpanStyle(span, colors) val spanStyle = buildSpanStyle(span, style)
val merged = baseStyle.merge(style) builder.addStyle(baseStyle.merge(spanStyle), span.start, span.end)
builder.addStyle(merged, span.start, span.end)
val link = span.link val link = span.link
if (link != null) { if (link != null) {
builder.addStringAnnotation("URL", link, span.start, span.end) builder.addStringAnnotation("URL", link, span.start, span.end)
@@ -360,23 +375,23 @@ fun MarkdownSegment.Text.toAnnotatedString(
return builder.toAnnotatedString() return builder.toAnnotatedString()
} }
private fun buildSpanStyle(span: TextStyleSpan, colors: AgentikColors): SpanStyle { private fun buildSpanStyle(span: TextStyleSpan, style: MarkdownStyle): SpanStyle {
var style = SpanStyle() var out = SpanStyle()
if (span.bold) style = style.copy(fontWeight = FontWeight.Bold) if (span.bold) out = out.copy(fontWeight = FontWeight.Bold)
if (span.italic) style = style.copy(fontStyle = androidx.compose.ui.text.font.FontStyle.Italic) if (span.italic) out = out.copy(fontStyle = FontStyle.Italic)
if (span.code) { if (span.code) {
style = style.copy( out = out.copy(
fontFamily = FontFamily.Monospace, fontFamily = style.monospaceFamily,
fontSize = 13.sp, fontSize = style.inlineCodeFontSize,
background = colors.panelElevated, background = style.inlineCodeBackground,
) )
} }
if (span.strikethrough) style = style.copy(textDecoration = androidx.compose.ui.text.style.TextDecoration.LineThrough) if (span.strikethrough) out = out.copy(textDecoration = TextDecoration.LineThrough)
if (span.link != null) { if (span.link != null) {
style = style.copy( out = out.copy(
color = colors.accent, color = style.linkColor,
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline, textDecoration = TextDecoration.Underline,
) )
} }
return style return out
} }
@@ -0,0 +1,152 @@
package pw.binom.agentik.desktop.ui
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
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.darkAgentikColors
/**
* Стиль отрисовки markdown: цвета, шрифты и отступы, из которых рендер
* ([renderMarkdownSegments]) берёт решительно всё.
*
* Зачем отдельный контракт: рендер не должен знать ни про тему приложения, ни
* про конкретные числа. Раньше он получал только `textColor` + `fontSize`, а
* остальное (фон код-блока, цвет ссылки, отступы, фон таблицы) тянул из
* `AgentikTheme`/`MaterialTheme` напрямую — из-за чего часть слотов жила вне
* темы, а тело сообщения могло уехать в цвет по умолчанию (чёрный).
*
* Реализация под наш клиент — [AgentikMarkdownStyle]; текущий стиль лежит в
* [LocalMarkdownStyle] (его подкладывает `AgentikTheme`), поэтому вызову
* достаточно `renderMarkdownSegments(content)`.
*/
@Immutable
interface MarkdownStyle {
/** Цвет основного текста сообщения (в тёмной теме — светлый). */
val textColor: Color
/** Кегль тела сообщения. */
val bodyFontSize: TextUnit
val bodyFontFamily: FontFamily
/** Интервал строк тела; [TextUnit.Unspecified] — на усмотрение движка. */
val bodyLineHeight: TextUnit
/** Ссылки: цвет + подчёркивание. */
val linkColor: Color
/** Inline-код: `code`. */
val monospaceFamily: FontFamily
val inlineCodeFontSize: TextUnit
val inlineCodeBackground: Color
/** Блок кода (``` … ```). */
val codeBackground: Color
val codeTextColor: Color
val codeHeaderBackground: Color
val codeFontSize: TextUnit
/** Таблица. */
val tableSurface: Color
val tableHeaderBackground: Color
val tableStripeBackground: Color
val tableFontSize: TextUnit
/** Мелкие подписи (язык код-блока, иконка копирования) и разделитель `---`. */
val labelColor: Color
val labelFontSize: TextUnit
val divider: Color
/** Отступы. */
val paragraphSpacing: Dp
val blockSpacing: Dp
val tableSpacing: Dp
val codePadding: Dp
val codeHeaderPaddingHorizontal: Dp
val codeHeaderPaddingVertical: Dp
val tableCellPaddingHorizontal: Dp
val tableCellPaddingVertical: Dp
val copyIconSize: Dp
/** Ширина контента, когда сообщение лежит в горизонтально скроллящемся пузыре. */
val maxContentWidth: Dp
}
/**
* База, накладываемая на ВЕСЬ текстовый сегмент.
*
* Парсер заводит стилевые спаны только для размеченных участков (жирный/код/
* ссылка), поэтому без этой базы «простой» текст остаётся без цвета и рисуется
* цветом по умолчанию (чёрным) — это и был баг «текст чёрный вместо белого».
*
* Именно функция, а не свойство интерфейса: с делегированием
* (`object : MarkdownStyle by otherStyle`) свойство с реализацией по умолчанию
* уезжает в делегат и перекрытый `textColor` в базе не учитывается.
* [bodyLineHeight] сюда не входит — интервал строк задаётся у абзаца
* (`Text(lineHeight = …)`), в `SpanStyle` такого слота нет.
*/
fun MarkdownStyle.bodySpan(): SpanStyle = SpanStyle(
color = textColor,
fontSize = bodyFontSize,
fontFamily = bodyFontFamily,
)
/**
* Наш стиль: цвета — из [AgentikColors] активной темы, типографика и отступы —
* из макетов клиента (`sketches/index.html`).
*/
@Immutable
class AgentikMarkdownStyle(private val colors: AgentikColors) : MarkdownStyle {
override val textColor: Color get() = colors.text
override val bodyFontSize: TextUnit = 13.5.sp
override val bodyFontFamily: FontFamily = FontFamily.Default
override val bodyLineHeight: TextUnit = 20.sp
override val linkColor: Color get() = colors.accent
override val monospaceFamily: FontFamily = FontFamily.Monospace
override val inlineCodeFontSize: TextUnit = 13.sp
override val inlineCodeBackground: Color get() = colors.panelElevated
override val codeBackground: Color get() = colors.codeBackground
override val codeTextColor: Color get() = colors.codeText
override val codeHeaderBackground: Color get() = colors.panelElevated
override val codeFontSize: TextUnit = 13.sp
override val tableSurface: Color get() = colors.panel.copy(alpha = 0.3f)
override val tableHeaderBackground: Color get() = colors.accent.copy(alpha = 0.1f)
override val tableStripeBackground: Color get() = colors.text.copy(alpha = 0.03f)
override val tableFontSize: TextUnit = 13.5.sp
override val labelColor: Color get() = colors.textDim
override val labelFontSize: TextUnit = 12.sp
override val divider: Color get() = colors.text.copy(alpha = 0.25f)
override val paragraphSpacing: Dp = 12.dp
override val blockSpacing: Dp = 4.dp
override val tableSpacing: Dp = 6.dp
override val codePadding: Dp = 12.dp
override val codeHeaderPaddingHorizontal: Dp = 12.dp
override val codeHeaderPaddingVertical: Dp = 6.dp
override val tableCellPaddingHorizontal: Dp = 8.dp
override val tableCellPaddingVertical: Dp = 6.dp
override val copyIconSize: Dp = 16.dp
override val maxContentWidth: Dp = 600.dp
}
/** Стиль по умолчанию — тёмная тема (эталонная); совпадает с дефолтом [AgentikColors]. */
private val DefaultMarkdownStyle: MarkdownStyle = AgentikMarkdownStyle(darkAgentikColors)
/**
* Текущий стиль markdown. Подкладывается `AgentikTheme`, поэтому экраны читают
* его неявно: `renderMarkdownSegments(content)`.
*/
val LocalMarkdownStyle = staticCompositionLocalOf<MarkdownStyle> { DefaultMarkdownStyle }
@@ -420,7 +420,7 @@ internal fun DialogHeader(title: String, onClose: () -> Unit) {
@Composable @Composable
internal fun DialogFooter(content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit) { internal fun DialogFooter(content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp, start = 18.dp, end = 18.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp, start = 18.dp, end = 18.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
) { this.content() } ) { this.content() }
} }
@@ -22,6 +22,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -106,8 +107,34 @@ fun ChatPane(
size = 36.dp, size = 36.dp,
) )
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(agentName, color = colors.text, fontSize = 14.sp) val conversationTitle = state.activeConversation?.title?.takeIf { it.isNotBlank() }
ConnectionLabel(state.activeStatus) ?: "Без названия"
Text(
text = conversationTitle,
color = colors.text,
fontSize = 14.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
ConnectionLabel(state.activeStatus)
if (agentName.isNotEmpty()) {
Text("·", color = colors.textDim, fontSize = 11.5.sp)
Text(agentName, color = colors.textDim, fontSize = 11.5.sp)
}
}
}
IconButton(
onClick = { state.openDialogInfo() },
enabled = state.activeConversation != null,
) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = "Параметры диалога",
tint = colors.textDim,
)
} }
if (state.thinking) { if (state.thinking) {
IconButton(onClick = { state.stop() }) { IconButton(onClick = { state.stop() }) {
@@ -0,0 +1,450 @@
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.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.LocalTextStyle
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.platform.testTag
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.model.UiConversation
import pw.binom.agentik.desktop.model.UNTITLED_CONVERSATION
import pw.binom.agentik.desktop.ui.components.AgentAvatar
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* Модалка «Параметры диалога»: правка имени, просмотр обслуживающего агента
* и его статуса, признака «временный», дат создания/обновления, удаление
* диалога. Создание идёт через [NewChatDialog]. Одобренный макет —
* `sketches/index.html` секции 20–23.
*
* @param conversation диалог, по которому показываем информацию
* @param agentName отображаемое имя обслуживающего агента (или `null`,
* если диалог оказался на офлайн-агенте)
* @param agentAddress базовый URL агента (для подписи в карточке)
* @param agentColor цвет агента для аватара
* @param online жив ли агент прямо сейчас
* @param onClose закрыть без изменений
* @param onRename `onRename(newTitle ?: null, cb)` — `cb(Result)` зовётся,
* когда сервер ответил (или бросил)
* @param onDelete удалить диалог
*/
@Composable
fun DialogInfoDialog(
conversation: UiConversation,
agentName: String?,
agentAddress: String?,
agentColor: Color?,
online: Boolean,
onClose: () -> Unit,
onRename: (String?, (Result<Unit>) -> Unit) -> Unit,
onDelete: () -> Unit,
) {
val colors = AgentikTheme.colors
// Сервер даёт серверный дефолт `UNTITLED_CONVERSATION` («Новый диалог»)
// если пользователь не задал имя; в модалке это эквивалентно «без имени».
val titleInitial = conversation.title?.takeIf { it.isNotBlank() && it != UNTITLED_CONVERSATION }
var editing by remember { mutableStateOf(titleInitial == null) }
var draft by remember { mutableStateOf(titleInitial ?: "") }
var isSaving by remember { mutableStateOf(false) }
var saveError by remember { mutableStateOf<String?>(null) }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(editing) {
if (editing) {
focusRequester.requestFocus()
saveError = null
}
}
DialogCard(width = 480.dp) {
DialogHeader(
title = "Параметры диалога",
onClose = onClose,
)
Column(
modifier = Modifier.padding(horizontal = 18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
// ─── Название (input всегда виден; редактируется по клику на ✎) ───
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "Название",
color = colors.textDim,
fontSize = 12.sp,
)
if (editing) {
OutlinedTextField(
value = draft,
onValueChange = { v ->
val next = v.take(120)
draft = next
saveError = null
},
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
singleLine = true,
placeholder = {
Text(
text = "как назвать диалог",
color = colors.textDim,
fontSize = 13.sp,
)
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
// Пустое имя допустимо — сервер вернёт title=null,
// а клиентский маппер покажет его как «Новый диалог».
// Подсвечиваем красным только реальную ошибку — перебор по длине.
isError = draft.length > 120,
)
Text(
text = "До 120 символов",
color = colors.textDim,
fontSize = 11.sp,
)
} else {
Text(
text = titleInitial ?: "Без названия",
color = if (titleInitial == null) colors.textDim else colors.text,
fontSize = 14.sp,
fontStyle = if (titleInitial == null) FontStyle.Italic else null,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { editing = true },
)
}
saveError?.let { msg ->
Text(text = msg, color = colors.error, fontSize = 11.5.sp)
}
}
if (!editing) {
Spacer(Modifier.width(8.dp))
Text(
text = "✎",
color = colors.accent,
fontSize = 14.sp,
modifier = Modifier
.clip(CircleShape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { editing = true }
.padding(6.dp),
)
}
}
// ─── Карточка агента ───
Text(text = "Агент", color = colors.textDim, fontSize = 12.sp)
if (agentName != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(colors.panelElevated)
.padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
AgentAvatar(
displayName = agentName,
color = agentColor ?: colors.accent,
avatarPath = null,
size = 32.dp,
)
Column(modifier = Modifier.weight(1f)) {
Text(agentName, color = colors.text, fontSize = 13.sp, fontWeight = FontWeight.Medium)
if (agentAddress != null) {
Text(
text = agentAddress,
color = colors.textDim,
fontSize = 11.sp,
)
}
}
Text(
text = if (online) "на связи" else "не отвечает",
color = if (online) colors.ok else colors.error,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
)
}
} else {
Text(
text = "Агент недоступен (диалог будет жить до закрытия приложения).",
color = colors.textDim,
fontSize = 12.sp,
)
}
// ─── Чекбокс «Временный диалог» (всегда readonly — нет операции на бэке) ───
TemporalCheckbox(temporal = conversation.isTemporal)
// ─── Probe-list: даты ───
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
InfoRow(label = "Создан", value = formatDateTime(conversation.createdAt))
InfoRow(label = "Обновлён", value = formatDateTime(conversation.updatedAt))
}
// ─── Опасная зона: удалить диалог ───
Text(
text = "Опасное",
color = colors.error,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
Button(
onClick = onDelete,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = colors.error.copy(alpha = 0.18f),
contentColor = colors.error,
),
) { Text("Удалить диалог") }
}
DialogFooter {
when {
editing -> {
val trimmed = draft.trim()
// Голый Box+clickable вместо Material Button/OutlinedButton —
// Material3 в этой версии Compose (1.7.3) стабильно сжимает
// высоту кнопки внутри Row.weight(1f), и текст обрезается.
// Box с явным height(44dp) + кликабельностью решает это
// начисто.
val btnShape = RoundedCornerShape(10.dp)
// В edit-mode «Отмена» ведёт себя по-разному:
// • если у диалога нет имени (`titleInitial == null`) — мы
// открыли модалку сразу в edit-mode, view-mode здесь
// бессмысленен (там было бы «Без названия» italic).
// Поэтому «Отмена» закрывает модалку через `onClose`.
// • если имя было — пользователь сам зашёл в edit через ✎,
// «Отмена» возвращает в view-mode.
Box(
modifier = Modifier
.weight(1f)
.requiredHeight(44.dp)
.clip(btnShape)
.border(1.dp, colors.line, btnShape)
.clickable(enabled = !isSaving) {
if (titleInitial == null) {
onClose()
} else {
editing = false
draft = titleInitial ?: ""
saveError = null
}
},
contentAlignment = Alignment.Center,
) {
Text(
"Отмена",
color = colors.text,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
}
val saveEnabled = !isSaving && trimmed.length <= 120
val saveLabel = when {
isSaving -> "Сохранение…"
saveError != null -> "Повторить"
else -> "Сохранить"
}
Box(
modifier = Modifier
.weight(1f)
.requiredHeight(44.dp)
.clip(btnShape)
.background(if (saveEnabled) colors.accent else colors.accent.copy(alpha = 0.4f))
.clickable(enabled = saveEnabled) {
if (trimmed.length > 120) {
saveError = "Имя должно быть не длиннее 120 символов"
return@clickable
}
// Имя совпадает с текущим — оптимизация, сети не дёргаем.
if (trimmed == titleInitial) {
editing = false
saveError = null
return@clickable
}
// Пустое имя → null на сервер: клиентский маппер заменит
// null обратно на UNTITLED_CONVERSATION («Новый диалог»).
val next: String? = trimmed.takeIf { it.isNotEmpty() }
isSaving = true
saveError = null
onRename(next) { result ->
isSaving = false
result.onSuccess {
editing = false
}.onFailure { t ->
saveError = t.message ?: "Не удалось сохранить"
}
}
},
contentAlignment = Alignment.Center,
) {
Text(
saveLabel,
color = colors.onAccent,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
}
}
else -> {
val btnShape = RoundedCornerShape(10.dp)
Box(
modifier = Modifier
.weight(1f)
.requiredHeight(44.dp)
.clip(btnShape)
.background(colors.accent)
.clickable(onClick = onClose),
contentAlignment = Alignment.Center,
) {
Text(
"Закрыть",
color = colors.onAccent,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
}
}
}
}
}
}
/**
* Чекбокс «Временный диалог»: значение только для чтения, выставляется при
* создании (на бэке нет операции смены `isTemporal` — см. секцию 20 макета).
*/
@Composable
private fun TemporalCheckbox(temporal: Boolean) {
val colors = AgentikTheme.colors
val accent = colors.accent
val onAccent = colors.onAccent
Row(
modifier = Modifier
.fillMaxWidth()
.testTag("temporal-checkbox")
.clip(RoundedCornerShape(10.dp))
.background(colors.panelElevated)
.padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier
.size(20.dp)
.clip(RoundedCornerShape(5.dp))
.background(if (temporal) accent else colors.background)
.border(1.dp, if (temporal) accent else colors.line, RoundedCornerShape(5.dp)),
contentAlignment = Alignment.Center,
) {
if (temporal) {
Text("✓", color = onAccent, fontSize = 13.sp, fontWeight = FontWeight.Bold)
}
}
// Однострочный текст в Compose может «проседать» вниз, если lineHeight
// не задан явно: LineHeightStyle.Alignment.Center применяется только
// при заданном lineHeight. Рецепт ровного центра: lineHeight == fontSize
// + LineHeightStyle(Center, Trim.Both).
Text(
text = "Временный диалог",
color = colors.text,
fontSize = 13.sp,
lineHeight = 13.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
.weight(1f)
// Типографская компенсация: строчные глифы (х-высота) визуально
// «проседают» на ~1.5px вниз относительно геометрического центра
// строки из-за метрик шрифта (ascent > x-height). Сдвигаем текст
// чуть вверх, чтобы его визуальная масса совпала с центром
// квадратика чекбокса.
.offset(y = (-1.5).dp),
style = LocalTextStyle.current.copy(
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.Both,
),
),
)
}
}
@Composable
private fun InfoRow(label: String, value: String) {
val colors = AgentikTheme.colors
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = label, color = colors.textDim, fontSize = 12.sp)
Text(text = value, color = colors.text, fontSize = 12.sp)
}
}
/**
* Полный формат даты-времени для информационных строк (`dd.MM.yyyy HH:mm`,
* системная тайм-зона).
*/
private val dateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")
/**
* Форматирует [instant] в локальное время пользователя (`dd.MM.yyyy HH:mm`).
* Отделено от [formatMessageTime], чтобы UI-слой не таскал лишние импорты.
*/
fun formatDateTime(instant: kotlin.time.Instant): String {
val zone = ZoneId.systemDefault()
val ldt = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds()), zone)
return dateTimeFormatter.format(ldt)
}
@@ -205,15 +205,10 @@ private fun TextContent(content: List<UiContent>, textColor: Color, fontSize: an
@Composable @Composable
private fun MarkdownContent(content: List<UiContent>, agentColor: Color) { private fun MarkdownContent(content: List<UiContent>, agentColor: Color) {
val colors = AgentikTheme.colors
Column { Column {
content.forEach { part -> content.forEach { part ->
when (part) { when (part) {
is UiContent.Text -> renderMarkdownSegments( is UiContent.Text -> renderMarkdownSegments(content = part.body)
content = part.body,
textColor = colors.text,
fontSize = 13.5.sp,
)
is UiContent.Image -> { is UiContent.Image -> {
ChatImage(src = dataUrl(part), alt = null) ChatImage(src = dataUrl(part), alt = null)
@@ -3,6 +3,7 @@ package pw.binom.agentik.desktop.ui.theme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
@@ -10,6 +11,9 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import pw.binom.agentik.desktop.settings.ThemeId import pw.binom.agentik.desktop.settings.ThemeId
import pw.binom.agentik.desktop.ui.AgentikMarkdownStyle
import pw.binom.agentik.desktop.ui.LocalMarkdownStyle
import pw.binom.agentik.desktop.ui.MarkdownStyle
/** /**
* Семантические цвета интерфейса. **Ни один цвет не задаётся числом вне * Семантические цвета интерфейса. **Ни один цвет не задаётся числом вне
@@ -146,30 +150,83 @@ val lightAgentikColors = AgentikColors(
codeText = Color(0xFF1A1D23), codeText = Color(0xFF1A1D23),
) )
private val LocalAgentikColors = staticCompositionLocalOf { darkAgentikColors } /**
* Контракт темы приложения.
/** Доступ к семантическим цветам из экранов: `AgentikTheme.colors.panel`. */ *
object AgentikTheme { * Все параметры, которые в UI зависят от выбранной темы, живут здесь:
* - [colors] — семантические цвета (`AgentikColors`);
* - [markdown] — стиль отрисовки md (`MarkdownStyle`, см. `MarkdownStyle.kt`);
* - [dark] — для построения Material-схемы (`toMaterialScheme(dark)`).
*
* Чтобы добавить новую тему, достаточно положить ещё один `object` сюда и
* поднять его в настройках через [ThemeId] + [ThemeId.toTheme]. Экраны и
* рендер md не меняются — они читают только [AgentikTheme.current].
*/
interface AgentikTheme {
val colors: AgentikColors val colors: AgentikColors
@Composable val markdown: MarkdownStyle
@ReadOnlyComposable val dark: Boolean
get() = LocalAgentikColors.current
companion object {
/** Активная тема (то, что положили в [ProvideAgentikTheme]). */
val current: AgentikTheme
@Composable
@ReadOnlyComposable
get() = LocalAgentikTheme.current
/** Короткий доступ к [AgentikTheme.colors] — для кода вида `val colors = AgentikTheme.colors`. */
val colors: AgentikColors
@Composable
@ReadOnlyComposable
get() = current.colors
/** Короткий доступ к [AgentikTheme.markdown] — для `renderMarkdownSegments` и т. п. */
val markdown: MarkdownStyle
@Composable
@ReadOnlyComposable
get() = current.markdown
}
} }
/** /**
* Корневой провайдер темы. Оборачивает всё приложение; [themeId] — значение * Локаль, через которую [AgentikTheme.current] доходит до экранов.
* из настроек (`ThemeId.DARK` / `ThemeId.LIGHT`). * Объявлен на уровне файла, чтобы [ProvideAgentikTheme] мог им пользоваться
* напрямую (внутри companion-объекта интерфейса он был бы недоступен снаружи).
*/
private val LocalAgentikTheme = staticCompositionLocalOf<AgentikTheme> { DarkAgentikTheme }
/** Тёмная тема — значения `darkAgentikColors` + её markdown-стиль. */
object DarkAgentikTheme : AgentikTheme {
override val colors: AgentikColors = darkAgentikColors
override val markdown: MarkdownStyle = AgentikMarkdownStyle(darkAgentikColors)
override val dark: Boolean = true
}
/** Светлая тема — значения `lightAgentikColors` + её markdown-стиль. */
object LightAgentikTheme : AgentikTheme {
override val colors: AgentikColors = lightAgentikColors
override val markdown: MarkdownStyle = AgentikMarkdownStyle(lightAgentikColors)
override val dark: Boolean = false
}
/** Преобразует значение [ThemeId] из настроек (`AgentikTheme.colors.themeId` хранится как `String`) в активный [AgentikTheme]. */
fun String.toAgentikTheme(): AgentikTheme = if (this == ThemeId.LIGHT) LightAgentikTheme else DarkAgentikTheme
/**
* Корневой провайдер темы. Оборачивает всё приложение; принимает готовый
* экземпляр [AgentikTheme] (обычно результат [ThemeId.toTheme]).
*/ */
@Composable @Composable
fun AgentikTheme( fun ProvideAgentikTheme(theme: AgentikTheme, content: @Composable () -> Unit) {
themeId: String, CompositionLocalProvider(
content: @Composable () -> Unit, LocalAgentikTheme provides theme,
) { // MaterialTheme сам LocalContentColor не подкладывает: без этого любой
val dark = themeId != ThemeId.LIGHT // Text без явного color рисовался бы чёрным (дефолт локала), а не текстом темы.
val colors = if (dark) darkAgentikColors else lightAgentikColors LocalContentColor provides theme.colors.text,
CompositionLocalProvider(LocalAgentikColors provides colors) { LocalMarkdownStyle provides theme.markdown,
) {
MaterialTheme( MaterialTheme(
colorScheme = colors.toMaterialScheme(dark = dark), colorScheme = theme.colors.toMaterialScheme(dark = theme.dark),
content = content, content = content,
) )
} }
@@ -1,6 +1,6 @@
package pw.binom.agentik.desktop.model package pw.binom.agentik.desktop.model
import pw.binom.agentik.journal.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.journal.MessageRecord
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -0,0 +1,139 @@
package pw.binom.agentik.desktop.net
import io.ktor.server.cio.CIO as ServerCIO
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.desktop.settings.AgentConfig
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore
import pw.binom.agentik.outbox.inmemory.InMemoryOnlineOutbox
import pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentInfo
import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Message
import pw.binom.agentik.content.MessageContext
import pw.binom.agentik.server.agentikAgent
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.Instant
/**
* Проверка связи не должна иметь побочных эффектов: единственный запрос —
* информация об агенте (`GET {baseUrl}`), никаких пробных диалогов.
*
* Регрессия: раньше после ответа сервера чекер создавал временный диалог и
* удалял его — сервер получал мусорную запись `ConversationRecord`, а
* пользователь, проверяя адрес, оставлял след в истории агента.
*/
class ConnectionCheckerTest {
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 `check asks for agent info and creates no conversation`() = runBlocking {
val stub = StubAgent()
startServer(stub)
val report = ConnectionChecker().check(
config = AgentConfig.create(baseUrl = "http://127.0.0.1:$port/agentik"),
clientId = "checker-test",
)
assertTrue(report.ok, "живой сервер обязан пройти проверку: ${report.steps}")
assertEquals(1, report.steps.size, "ровно один шаг — запрос информации: ${report.steps}")
assertEquals(ConnectionChecker.STEP_AGENT_INFO, report.steps.single().title)
assertEquals("Стенд", report.agentName, "имя агента берётся из AgentInfo")
assertEquals(0, stub.created, "проверка связи НЕ должна создавать диалог")
}
@Test
fun `check fails on an unreachable agent`() = runBlocking {
val report = ConnectionChecker().check(
config = AgentConfig.create(baseUrl = "http://127.0.0.1:1/agentik"),
clientId = "checker-test",
)
assertTrue(!report.ok, "мёртвый адрес не должен проходить проверку: $report")
assertEquals(1, report.steps.size)
assertTrue(!report.steps.single().ok, "шаг помечен неуспешным")
assertNull(report.agentName, "имя неизвестно, если агент не ответил")
}
}
/**
* Минимальный агент для стенда: [ConnectionChecker] ходит только за
* [Agent.info], поэтому всё остальное — заглушки, а [createConversation]
* считает вызовы, чтобы тест поймал попытку создать пробный диалог.
*/
private class StubAgent : Agent {
override val id: String = "checker-stub"
override val info: AgentInfo = AgentInfo(
name = "Стенд",
description = "Агент для теста проверки связи",
)
override val journal = InMemoryJournalStore()
override val outbox = InMemoryOutboxStore(maxMessages = null, ttl = null)
override val onlineOutbox = InMemoryOnlineOutbox()
private val store = InMemoryMutableConversationStore()
override val conversationStore: ConversationStore get() = store
/** Сколько раз кто-то пытался создать диалог (должно остаться нулём). */
var created: Int = 0
private set
override fun createConversation(temp: Boolean): Conversation {
created++
val now = Clock.System.now()
return StubConversation("stub-$created", now)
}
override suspend fun getConversation(id: String): Conversation? = null
override suspend fun deleteConversation(id: String): Boolean = false
override suspend fun renameConversation(id: String, title: String?): Instant? = null
override fun close() {}
}
private class StubConversation(override val id: String, createdAt: Instant) : Conversation {
override val isSupportImageInput: Boolean = false
override val isSupportImageOutput: Boolean = false
override val isTemporal: Boolean = false
override val title: String? = null
override val updatedAt: Instant = createdAt
override suspend fun rename(title: String) {}
override suspend fun send(content: List<Content>, context: MessageContext?) {}
override suspend fun interrupt() {}
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> = emptyList()
override fun close() {}
}
@@ -20,13 +20,16 @@ import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.CommonEvent import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.MutableOutboxStore import pw.binom.agentik.outbox.MutableOutboxStore
import pw.binom.agentik.outbox.MutableOnlineOutbox
import pw.binom.agentik.outbox.OnlineEvent
import pw.binom.agentik.outbox.inmemory.InMemoryOnlineOutbox
import pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore import pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentInfo import pw.binom.agentik.proto.AgentInfo
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Message import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext import pw.binom.agentik.content.MessageContext
import pw.binom.agentik.server.agentikAgent import pw.binom.agentik.server.agentikAgent
import kotlin.test.AfterTest import kotlin.test.AfterTest
import kotlin.test.Test import kotlin.test.Test
@@ -198,6 +201,8 @@ private class EchoAgent(private val interruptible: Boolean = false) : Agent {
override val outbox = InMemoryOutboxStore(maxMessages = null, ttl = null) override val outbox = InMemoryOutboxStore(maxMessages = null, ttl = null)
override val onlineOutbox = InMemoryOnlineOutbox()
private val store: MutableConversationStore = InMemoryMutableConversationStore() private val store: MutableConversationStore = InMemoryMutableConversationStore()
override val conversationStore: ConversationStore get() = store override val conversationStore: ConversationStore get() = store
@@ -208,7 +213,7 @@ private class EchoAgent(private val interruptible: Boolean = false) : Agent {
override fun createConversation(temp: Boolean): Conversation { override fun createConversation(temp: Boolean): Conversation {
val id = "conv-${++seq}" val id = "conv-${++seq}"
val now = Clock.System.now() val now = Clock.System.now()
val conv = EchoConversation(id, journal, outbox, store, now, interruptible) val conv = EchoConversation(id, journal, outbox, onlineOutbox, store, now, interruptible)
conversations[id] = conv conversations[id] = conv
runBlocking { runBlocking {
store.upsert(ConversationRecord(id, null, temp, now, now)) store.upsert(ConversationRecord(id, null, temp, now, now))
@@ -239,6 +244,7 @@ private class EchoConversation(
override val id: String, override val id: String,
private val journal: MutableJournalStore, private val journal: MutableJournalStore,
private val outbox: MutableOutboxStore, private val outbox: MutableOutboxStore,
private val onlineOutbox: MutableOnlineOutbox,
private val store: MutableConversationStore, private val store: MutableConversationStore,
createdAt: Instant, createdAt: Instant,
private val interruptible: Boolean, private val interruptible: Boolean,
@@ -277,10 +283,15 @@ private class EchoConversation(
override suspend fun send(content: List<Content>, context: MessageContext?) { override suspend fun send(content: List<Content>, context: MessageContext?) {
val now = Clock.System.now() val now = Clock.System.now()
val userId = nextMsgId(id, "user")
journal.append( journal.append(
MessageRecord.UserMessage(nextMsgId(id, "user"), id, content.toJournal(), now, context = null), MessageRecord.UserMessage(userId, id, content.toJournal(), now, context = null),
) )
emit(now, Event.Working(now)) emit(
now,
Event.UserMessage(date = now, id = userId, content = content.toJournal()),
)
onlineOutbox.tryAppendOnline(id, OnlineEvent.Working(now))
if (interruptible) { if (interruptible) {
// Имитация долгого LLM-цикла: крутимся, пока не придёт interrupt(). // Имитация долгого LLM-цикла: крутимся, пока не придёт interrupt().
@@ -290,19 +301,21 @@ private class EchoConversation(
val text = content.filterIsInstance<Content.Text>().joinToString("") { it.body } val text = content.filterIsInstance<Content.Text>().joinToString("") { it.body }
val reply = "эхо: $text" val reply = "эхо: $text"
emit(now, Event.StartResponse(now, Event.ResponseType.TEXT)) onlineOutbox.tryAppendOnline(id, OnlineEvent.StartResponse(now, OnlineEvent.ResponseType.TEXT))
emit(now, Event.AppendText(now, reply)) onlineOutbox.tryAppendOnline(id, OnlineEvent.AppendText(now, reply))
val endAt = Clock.System.now() val endAt = Clock.System.now()
val assistantId = nextMsgId(id, "assistant")
journal.append( journal.append(
MessageRecord.AssistantMessage( MessageRecord.AssistantMessage(
nextMsgId(id, "assistant"), assistantId,
id, id,
listOf(pw.binom.agentik.journal.Content.Text(reply)), listOf(pw.binom.agentik.content.Content.Text(reply)),
endAt, endAt,
), ),
) )
emit(endAt, Event.End(endAt)) onlineOutbox.tryAppendOnline(id, OnlineEvent.End(endAt))
emit(endAt, Event.AssistantMessage(date = endAt, id = assistantId, content = listOf(Content.Text(reply))))
updated = endAt updated = endAt
store.touch(id, endAt) store.touch(id, endAt)
@@ -317,9 +330,9 @@ private class EchoConversation(
} }
/** `:proto.Content` → `:journal.Content` (в проде это делает storage-impl). */ /** `:proto.Content` → `:journal.Content` (в проде это делает storage-impl). */
private fun List<Content>.toJournal(): List<pw.binom.agentik.journal.Content> = map { c -> private fun List<Content>.toJournal(): List<pw.binom.agentik.content.Content> = map { c ->
when (c) { when (c) {
is Content.Text -> pw.binom.agentik.journal.Content.Text(c.body) is Content.Text -> pw.binom.agentik.content.Content.Text(c.body)
is Content.Image -> pw.binom.agentik.journal.Content.Image(c.data, c.mime) is Content.Image -> pw.binom.agentik.content.Content.Image(c.data, c.mime)
} }
} }
@@ -0,0 +1,264 @@
package pw.binom.agentik.desktop.session
import java.util.concurrent.atomic.AtomicInteger
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.persistence.ConversationMetaRepository
import pw.binom.agentik.content.Content as JournalContent
import pw.binom.agentik.journal.ConversationRecord
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.db.ksqlite.SQLiteConnection
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Тесты [AgentSyncEngine]: фоновый ingest всех диалогов агента. Реальные
* `KsqliteJournalStore` + `ConversationMetaRepository` (in-memory SQLite),
* `JournalStore` / `OutboxStore` — подделки. Сеть не нужна.
*
* `runBlocking` + [await] (не `runTest`): репозиторий под капотом уходит в
* `Dispatchers.Default`, виртуальное время `runTest` его не дожидается.
*/
class AgentSyncEngineTest {
private val convId = "conv-1"
private fun at(ms: Long) = Instant.fromEpochMilliseconds(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 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 rec(updatedAt: Instant) = ConversationRecord(
id = convId,
title = null,
isTemporal = false,
createdAt = at(0),
updatedAt = updatedAt,
)
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 = EngineFakeOutboxStore()
val remote = EngineFakeJournalStore()
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val engine = AgentSyncEngine(
remoteJournal = remote,
journalCache = cache,
meta = meta,
outbox = outbox,
locks = ConversationLocks(),
scope = scope,
)
fun close() {
engine.close()
scope.cancel()
cache.close()
meta.close()
connection.close()
}
}
@Test
fun `start backfills never-opened conversation into cache and preview`() = runBlocking {
val f = Fixture()
try {
f.remote.records = listOf(user("u1", 1, "привет"), assistant("a1", 2, "здравствуй"))
val changes = AtomicInteger(0)
f.engine.start(
conversations = { listOf(rec(updatedAt = at(2))) },
onChanged = { changes.incrementAndGet() },
)
await { f.cache.count(convId) == 2L }
await { f.meta.meta(convId)?.preview == "здравствуй" }
await { changes.get() >= 1 }
} finally {
f.close()
}
}
@Test
fun `start skips conversation already synced past its updatedAt`() = runBlocking {
val f = Fixture()
try {
f.meta.setSyncedAt(convId, at(100))
f.remote.records = listOf(user("u1", 1, "не должно попасть в кэш"))
f.engine.start(
conversations = { listOf(rec(updatedAt = at(50))) },
onChanged = {},
)
delay(150)
assertEquals(0L, f.cache.count(convId))
} finally {
f.close()
}
}
@Test
fun `start skips conversation held by an open session`() = runBlocking {
val f = Fixture()
try {
f.engine.openConversationIds = setOf(convId)
f.remote.records = listOf(user("u1", 1, "привет"))
f.engine.start(
conversations = { listOf(rec(updatedAt = at(2))) },
onChanged = {},
)
delay(150)
assertEquals(0L, f.cache.count(convId))
} finally {
f.close()
}
}
@Test
fun `End event for non-open conversation backfills journal and preview`() = runBlocking {
val f = Fixture()
try {
f.engine.start(conversations = { emptyList() }, onChanged = {})
f.outbox.awaitSubscriber()
f.remote.records = listOf(user("u1", 1, "привет"), assistant("a1", 2, "пока"))
f.outbox.emit(
Event.AssistantMessage(date = at(3), id = "a1", content = listOf<JournalContent>(JournalContent.Text("пока")))
)
await { f.cache.count(convId) == 2L }
await { f.meta.meta(convId)?.preview == "пока" }
} finally {
f.close()
}
}
@Test
fun `End event for open conversation is ignored by the engine`() = runBlocking {
val f = Fixture()
try {
f.engine.openConversationIds = setOf(convId)
f.engine.start(conversations = { emptyList() }, onChanged = {})
f.outbox.awaitSubscriber()
f.remote.records = listOf(user("u1", 1, "привет"))
f.outbox.emit(
Event.AssistantMessage(date = at(3), id = "a1", content = listOf<JournalContent>(JournalContent.Text("привет")))
)
delay(150)
assertEquals(0L, f.cache.count(convId))
} finally {
f.close()
}
}
@Test
fun `second start is a no-op`() = runBlocking {
val f = Fixture()
try {
f.remote.records = listOf(user("u1", 1, "привет"))
val changes = AtomicInteger(0)
f.engine.start(
conversations = { listOf(rec(updatedAt = at(1))) },
onChanged = { changes.incrementAndGet() },
)
await { f.cache.count(convId) == 1L }
await { changes.get() >= 1 }
val afterFirst = changes.get()
f.engine.start(
conversations = { listOf(rec(updatedAt = at(1))) },
onChanged = { changes.incrementAndGet() },
)
delay(100)
assertEquals(afterFirst, changes.get())
assertEquals(1L, f.cache.count(convId))
assertTrue(afterFirst >= 1)
} finally {
f.close()
}
}
}
private class EngineFakeJournalStore(
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 EngineFakeOutboxStore : 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() {}
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))
}
}
@@ -13,17 +13,19 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import pw.binom.agentik.desktop.model.UiMessage import pw.binom.agentik.desktop.model.UiMessage
import pw.binom.agentik.desktop.persistence.ConversationMetaRepository import pw.binom.agentik.desktop.persistence.ConversationMetaRepository
import pw.binom.agentik.journal.Content as JournalContent import pw.binom.agentik.content.Content as JournalContent
import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
import pw.binom.agentik.outbox.CommonEvent import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OnlineEvent
import pw.binom.agentik.outbox.OnlineOutbox
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Content import pw.binom.agentik.content.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Message import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext import pw.binom.agentik.content.MessageContext
import pw.binom.db.ksqlite.SQLiteConnection import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
@@ -74,6 +76,7 @@ class ChatSessionTest {
val cache = KsqliteJournalStore(connection) val cache = KsqliteJournalStore(connection)
val meta = ConversationMetaRepository(connection) val meta = ConversationMetaRepository(connection)
val outbox = FakeOutboxStore() val outbox = FakeOutboxStore()
val online = FakeOnlineOutbox()
var remote = FakeJournalStore() var remote = FakeJournalStore()
val conversation = FakeConversation("conv-1") val conversation = FakeConversation("conv-1")
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -85,6 +88,7 @@ class ChatSessionTest {
journalCache = cache, journalCache = cache,
meta = meta, meta = meta,
outbox = outbox, outbox = outbox,
onlineOutbox = online,
scope = scope, scope = scope,
) )
@@ -157,11 +161,12 @@ class ChatSessionTest {
try { try {
session.start() session.start()
f.outbox.awaitSubscriber() f.outbox.awaitSubscriber()
f.online.awaitSubscriber()
f.outbox.emit(Event.Working(at(10))) f.online.emit(OnlineEvent.Working(at(10)))
f.outbox.emit(Event.StartResponse(at(11), Event.ResponseType.TEXT)) f.online.emit(OnlineEvent.StartResponse(at(11), OnlineEvent.ResponseType.TEXT))
f.outbox.emit(Event.AppendText(at(12), "Hel")) f.online.emit(OnlineEvent.AppendText(at(12), "Hel"))
f.outbox.emit(Event.AppendText(at(13), "lo")) f.online.emit(OnlineEvent.AppendText(at(13), "lo"))
await { session.live.value.size == 1 } await { session.live.value.size == 1 }
assertTrue(session.live.value.single() is UiMessage.Assistant) assertTrue(session.live.value.single() is UiMessage.Assistant)
@@ -169,9 +174,11 @@ class ChatSessionTest {
assertEquals("Hello", (session.live.value.single() as UiMessage.Assistant) assertEquals("Hello", (session.live.value.single() as UiMessage.Assistant)
.let { (it.content.single() as pw.binom.agentik.desktop.model.UiContent.Text).body }) .let { (it.content.single() as pw.binom.agentik.desktop.model.UiContent.Text).body })
// Сервер зафиксировал ход в журнале и прислал End. // Сервер зафиксировал ход в журнале и прислал durable AssistantMessage.
f.remote.records = listOf(user("u1", 9, "вопрос"), assistant("a1", 14, "Hello")) f.remote.records = listOf(user("u1", 9, "вопрос"), assistant("a1", 14, "Hello"))
f.outbox.emit(Event.End(at(14))) f.outbox.emit(
Event.AssistantMessage(at(14), id = "a1", content = listOf<JournalContent>(JournalContent.Text("Hello")))
)
await { session.live.value.isEmpty() && session.history.value.size == 2 } await { session.live.value.isEmpty() && session.history.value.size == 2 }
assertTrue(session.live.value.isEmpty()) assertTrue(session.live.value.isEmpty())
@@ -196,7 +203,9 @@ class ChatSessionTest {
// стоял ровно на 5 мс — отсюда отступ назад на 1 мс + отсечение // стоял ровно на 5 мс — отсюда отступ назад на 1 мс + отсечение
// известных id вместо опоры на время. // известных id вместо опоры на время.
f.remote.records = listOf(user("u1", 5, "вопрос"), assistant("a1", 5, "ответ")) f.remote.records = listOf(user("u1", 5, "вопрос"), assistant("a1", 5, "ответ"))
f.outbox.emit(Event.End(at(5))) f.outbox.emit(
Event.AssistantMessage(at(5), id = "a1", content = listOf<JournalContent>(JournalContent.Text("ответ")))
)
await { session.history.value.size == 2 } await { session.history.value.size == 2 }
// Порядок при равных timestamp'ах задаёт id (контракт SQL // Порядок при равных timestamp'ах задаёт id (контракт SQL
@@ -216,9 +225,10 @@ class ChatSessionTest {
try { try {
session.start() session.start()
f.outbox.awaitSubscriber() f.outbox.awaitSubscriber()
f.online.awaitSubscriber()
f.outbox.emit(Event.StartResponse(at(10), Event.ResponseType.TEXT)) f.online.emit(OnlineEvent.StartResponse(at(10), OnlineEvent.ResponseType.TEXT))
f.outbox.emit(Event.AppendText(at(11), "частичный")) f.online.emit(OnlineEvent.AppendText(at(11), "частичный"))
await { session.live.value.size == 1 } await { session.live.value.size == 1 }
f.outbox.emit(Event.Interrupted(at(12))) f.outbox.emit(Event.Interrupted(at(12)))
@@ -296,6 +306,27 @@ private class FakeOutboxStore : OutboxStore {
} }
} }
private class FakeOnlineOutbox : OnlineOutbox {
private val shared = MutableSharedFlow<OnlineEvent>(replay = 256, extraBufferCapacity = 256)
override fun onlineEvents(): Flow<OnlineEvent> = shared.asSharedFlow()
override fun onlineEvents(conversationId: String): Flow<OnlineEvent> = shared.asSharedFlow()
override fun close() {}
/** Ждёт, пока у потока появится подписчик (ChatSession подписался). */
suspend fun awaitSubscriber() {
withTimeoutOrNull(5_000) {
while (shared.subscriptionCount.value == 0) delay(5)
}
}
suspend fun emit(event: OnlineEvent) {
shared.emit(event)
}
}
private class FakeConversation( private class FakeConversation(
override val id: String, override val id: String,
override val isSupportImageInput: Boolean = true, override val isSupportImageInput: Boolean = true,
@@ -5,6 +5,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertIs import kotlin.test.assertIs
import kotlin.test.assertTrue import kotlin.test.assertTrue
import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OnlineEvent
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -13,22 +14,28 @@ class LiveTurnTest {
private val now = Clock.System.now() private val now = Clock.System.now()
private fun at() = Instant.fromEpochMilliseconds(now.toEpochMilliseconds()) private fun at() = Instant.fromEpochMilliseconds(now.toEpochMilliseconds())
private fun LiveTurn.feed(vararg events: Event) { private fun LiveTurn.feed(vararg events: Any) {
events.forEach { apply(it) } events.forEach {
when (it) {
is Event -> apply(it)
is OnlineEvent -> applyOnline(it)
else -> error("unexpected event: $it")
}
}
} }
@Test @Test
fun `full happy path accumulates reasoning and answer`() { fun `full happy path accumulates reasoning and answer`() {
val turn = LiveTurn() val turn = LiveTurn()
turn.feed( turn.feed(
Event.Working(at()), OnlineEvent.Working(at()),
Event.StartReasoning(at()), OnlineEvent.StartReasoning(at()),
Event.AppendText(at(), "сначала "), OnlineEvent.AppendText(at(), "сначала "),
Event.AppendText(at(), "подумаем"), OnlineEvent.AppendText(at(), "подумаем"),
Event.StartResponse(at(), Event.ResponseType.TEXT), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
Event.AppendText(at(), "Привет, "), OnlineEvent.AppendText(at(), "Привет, "),
Event.AppendText(at(), "мир!"), OnlineEvent.AppendText(at(), "мир!"),
Event.End(at()), OnlineEvent.End(at()),
) )
assertEquals(TurnPhase.DONE, turn.phase) assertEquals(TurnPhase.DONE, turn.phase)
@@ -45,11 +52,11 @@ class LiveTurnTest {
fun `tool call and result are stitched into one block`() { fun `tool call and result are stitched into one block`() {
val turn = LiveTurn() val turn = LiveTurn()
turn.feed( turn.feed(
Event.Working(at()), OnlineEvent.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
Event.ToolCall(at(), id = "t1", title = "grep", toolName = "grep", toolArgs = "{\"q\":\"x\"}"), Event.ToolCall(at(), id = "t1", title = "grep", toolName = "grep", toolArgs = "{\"q\":\"x\"}"),
Event.ToolResult(at(), toolCallId = "t1", toolName = "grep", result = "found 3"), Event.ToolResult(at(), toolCallId = "t1", toolName = "grep", result = "found 3"),
Event.End(at()), OnlineEvent.End(at()),
) )
val tools = turn.blocks.filterIsInstance<LiveBlock.Tool>() val tools = turn.blocks.filterIsInstance<LiveBlock.Tool>()
@@ -65,8 +72,8 @@ class LiveTurnTest {
fun `orphan tool result creates a block`() { fun `orphan tool result creates a block`() {
val turn = LiveTurn() val turn = LiveTurn()
turn.feed( turn.feed(
Event.Working(at()), OnlineEvent.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
Event.ToolResult(at(), toolCallId = "missing", toolName = null, result = "ok"), Event.ToolResult(at(), toolCallId = "missing", toolName = null, result = "ok"),
) )
@@ -80,7 +87,7 @@ class LiveTurnTest {
fun `tool failed marks the block`() { fun `tool failed marks the block`() {
val turn = LiveTurn() val turn = LiveTurn()
turn.feed( turn.feed(
Event.Working(at()), OnlineEvent.Working(at()),
Event.ToolCall(at(), id = "t9", title = null, toolName = "bash", toolArgs = "{}"), Event.ToolCall(at(), id = "t9", title = null, toolName = "bash", toolArgs = "{}"),
Event.ToolFailed(at(), toolCallId = "t9", toolName = "bash", message = "boom", durationMs = 5), Event.ToolFailed(at(), toolCallId = "t9", toolName = "bash", message = "boom", durationMs = 5),
) )
@@ -93,9 +100,9 @@ class LiveTurnTest {
fun `error appends failure block and fails the turn`() { fun `error appends failure block and fails the turn`() {
val turn = LiveTurn() val turn = LiveTurn()
turn.feed( turn.feed(
Event.Working(at()), OnlineEvent.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
Event.AppendText(at(), "partial"), OnlineEvent.AppendText(at(), "partial"),
Event.Error(at(), message = "llm down", code = "E500"), Event.Error(at(), message = "llm down", code = "E500"),
) )
@@ -111,9 +118,9 @@ class LiveTurnTest {
fun `interrupted turn is terminal and keeps partial text`() { fun `interrupted turn is terminal and keeps partial text`() {
val turn = LiveTurn() val turn = LiveTurn()
turn.feed( turn.feed(
Event.Working(at()), OnlineEvent.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
Event.AppendText(at(), "не до конца"), OnlineEvent.AppendText(at(), "не до конца"),
Event.Interrupted(at()), Event.Interrupted(at()),
) )
@@ -126,11 +133,11 @@ class LiveTurnTest {
fun `text after tool call starts a new answer block`() { fun `text after tool call starts a new answer block`() {
val turn = LiveTurn() val turn = LiveTurn()
turn.feed( turn.feed(
Event.Working(at()), OnlineEvent.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
Event.AppendText(at(), "до"), OnlineEvent.AppendText(at(), "до"),
Event.ToolCall(at(), id = "t1", title = null, toolName = "grep", toolArgs = "{}"), Event.ToolCall(at(), id = "t1", title = null, toolName = "grep", toolArgs = "{}"),
Event.AppendText(at(), "после"), OnlineEvent.AppendText(at(), "после"),
) )
val answers = turn.blocks.filterIsInstance<LiveBlock.Answer>() val answers = turn.blocks.filterIsInstance<LiveBlock.Answer>()
@@ -145,10 +152,10 @@ class LiveTurnTest {
val bytes = byteArrayOf(1, 2, 3) val bytes = byteArrayOf(1, 2, 3)
val turn = LiveTurn() val turn = LiveTurn()
turn.feed( turn.feed(
Event.Working(at()), OnlineEvent.Working(at()),
Event.StartResponse(at(), Event.ResponseType.IMAGE), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.IMAGE),
Event.AppendImage(at(), body = bytes, mime = "image/png"), OnlineEvent.AppendImage(at(), body = bytes, mime = "image/png"),
Event.End(at()), OnlineEvent.End(at()),
) )
assertTrue(turn.blocks.filterIsInstance<LiveBlock.Answer>().isEmpty()) assertTrue(turn.blocks.filterIsInstance<LiveBlock.Answer>().isEmpty())
@@ -161,12 +168,12 @@ class LiveTurnTest {
fun `working resets the previous turn`() { fun `working resets the previous turn`() {
val turn = LiveTurn() val turn = LiveTurn()
turn.feed( turn.feed(
Event.Working(at()), OnlineEvent.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
Event.AppendText(at(), "старое"), OnlineEvent.AppendText(at(), "старое"),
Event.End(at()), OnlineEvent.End(at()),
) )
turn.feed(Event.Working(at()), Event.AppendText(at(), "новое")) turn.feed(OnlineEvent.Working(at()), OnlineEvent.AppendText(at(), "новое"))
assertEquals(1, turn.blocks.size) assertEquals(1, turn.blocks.size)
assertEquals("новое", turn.answerText()) assertEquals("новое", turn.answerText())
@@ -175,7 +182,7 @@ class LiveTurnTest {
@Test @Test
fun `append without explicit start response still streams as answer`() { fun `append without explicit start response still streams as answer`() {
val turn = LiveTurn() val turn = LiveTurn()
turn.feed(Event.Working(at()), Event.AppendText(at(), "без старта")) turn.feed(OnlineEvent.Working(at()), OnlineEvent.AppendText(at(), "без старта"))
assertEquals(TurnPhase.RESPONDING, turn.phase) assertEquals(TurnPhase.RESPONDING, turn.phase)
assertEquals("без старта", turn.answerText()) assertEquals("без старта", turn.answerText())
@@ -186,7 +193,7 @@ class LiveTurnTest {
val turn = LiveTurn() val turn = LiveTurn()
var count = 0 var count = 0
turn.onChange = { count++ } turn.onChange = { count++ }
turn.feed(Event.Working(at()), Event.AppendText(at(), "x")) turn.feed(OnlineEvent.Working(at()), OnlineEvent.AppendText(at(), "x"))
assertEquals(2, count) assertEquals(2, count)
} }
@@ -195,13 +202,13 @@ class LiveTurnTest {
fun `preview text collapses whitespace and truncates`() { fun `preview text collapses whitespace and truncates`() {
val turn = LiveTurn() val turn = LiveTurn()
turn.feed( turn.feed(
Event.Working(at()), OnlineEvent.Working(at()),
Event.AppendText(at(), "Первая строка\n\nвторая строка "), OnlineEvent.AppendText(at(), "Первая строка\n\nвторая строка "),
) )
assertEquals("Первая строка вторая строка", turn.previewText()) assertEquals("Первая строка вторая строка", turn.previewText())
val long = LiveTurn() val long = LiveTurn()
long.feed(Event.Working(at()), Event.AppendText(at(), "слово ".repeat(60))) long.feed(OnlineEvent.Working(at()), OnlineEvent.AppendText(at(), "слово ".repeat(60)))
val preview = long.previewText(maxLength = 30) val preview = long.previewText(maxLength = 30)
assertTrue(preview.length <= 31) assertTrue(preview.length <= 31)
assertTrue(preview.endsWith("…")) assertTrue(preview.endsWith("…"))
@@ -0,0 +1,478 @@
package pw.binom.agentik.desktop.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.assertTextEquals
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.runComposeUiTest
import pw.binom.agentik.desktop.media.AvatarStorage
import pw.binom.agentik.desktop.model.UiConversation
import pw.binom.agentik.desktop.model.UNTITLED_CONVERSATION
import pw.binom.agentik.desktop.ui.components.LocalAvatarStorage
import pw.binom.agentik.desktop.ui.screens.DialogInfoDialog
import pw.binom.agentik.desktop.ui.screens.formatDateTime
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Модалка «Параметры диалога»: контракт и регрессии.
*
* Покрывает:
* - формат даты ([formatDateTime] — dd.MM.yyyy HH:mm, в системной тайм-зоне);
* - базовые секции присутствуют (название, агент, тип, даты, опасная зона);
* - кнопка ✎ переключает в режим редактирования, имя сохраняется через cb;
* - «безымянный» диалог (title == UNTITLED_CONVERSATION) сразу в edit-режиме;
* - чекбокс «Временный» отображает состояние, менять нельзя;
* - кнопка «Удалить диалог» триггерит onDelete;
* - ошибка сохранения показывает текст + переименовывает кнопку в «Повторить».
*/
class DialogInfoDialogTest {
// ── формат даты ─────────────────────────────────────────────────────────
@Test
fun `formatDateTime renders dd MM yyyy HH mm`() {
val instant = Instant.parse("2026-10-05T11:30:00Z")
val rendered = formatDateTime(instant)
assertTrue(
rendered.matches(Regex("""\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}""")),
"формат должен быть dd.MM.yyyy HH:mm, а получилось: $rendered",
)
}
@Test
fun `formatDateTime applies system zone offset`() {
// 2026-10-05 11:30 UTC -> в Москве (UTC+3, без перевода часов) 14:30.
// Проверяем, что форматирование учитывает системную тайм-зону — иначе
// час был бы равен UTC-часу в любой системе.
val instant = Instant.parse("2026-10-05T11:30:00Z")
val rendered = formatDateTime(instant)
val zone = java.time.ZoneId.systemDefault()
val offsetSeconds = zone.rules.getOffset(java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds()))
.totalSeconds
val expectedHour = ((11 + offsetSeconds / 3600) + 24) % 24
// Проверяем, что в строке встречается « <час>:» — тогда смещение применено.
assertTrue(
rendered.contains(" ${expectedHour.toString().padStart(2, '0')}:"),
"должно учитываться смещение $zone (UTC${offsetSeconds / 3600} ч): ожидался час $expectedHour, получилось $rendered",
)
}
// ── рендер ─────────────────────────────────────────────────────────────
@OptIn(ExperimentalTestApi::class)
@Test
fun `dialog renders all required sections`() = runComposeUiTest {
val conversation = sampleConversation(title = "Spring Boot · настройка логов")
setContent {
withAvatarStorage {
ProvideAgentikTheme("dark".toAgentikTheme()) {
Box(Modifier.fillMaxSize().background(darkAgentikColors.background)) {
DialogInfoDialog(
conversation = conversation,
agentName = "agentik",
agentAddress = "http://192.168.88.135:8080/agentik",
agentColor = Color(0xFF6AB2F2),
online = true,
onClose = {},
onRename = { _, _ -> },
onDelete = {},
)
}
}
}
}
// Заголовки секций
onNodeWithText("Название").assertTextEquals("Название")
onNodeWithText("Агент").assertTextEquals("Агент")
onNodeWithText("Временный диалог").assertTextEquals("Временный диалог")
onNodeWithText("Создан").assertTextEquals("Создан")
onNodeWithText("Обновлён").assertTextEquals("Обновлён")
onNodeWithText("Опасное").assertTextEquals("Опасное")
// Имя диалога (крупно) и имя агента
onNodeWithText("Spring Boot · настройка логов").assertTextEquals("Spring Boot · настройка логов")
onNodeWithText("agentik").assertTextEquals("agentik")
onNodeWithText("http://192.168.88.135:8080/agentik").assertTextEquals("http://192.168.88.135:8080/agentik")
// Статус «на связи»
onNodeWithText("на связи").assertTextEquals("на связи")
// Кнопка «Закрыть» в футере
onNodeWithText("Закрыть").assertTextEquals("Закрыть")
// Кнопка «Удалить диалог»
onNodeWithText("Удалить диалог").assertTextEquals("Удалить диалог")
}
@OptIn(ExperimentalTestApi::class)
@Test
fun `untitled conversation opens in edit mode by default`() = runComposeUiTest {
// Конвенция: `title == UNTITLED_CONVERSATION` означает «сервер не дал имя»,
// и модалка сразу переходит в edit-режим.
val conversation = sampleConversation(title = UNTITLED_CONVERSATION)
setContent {
withAvatarStorage {
ProvideAgentikTheme("dark".toAgentikTheme()) {
DialogInfoDialog(
conversation = conversation,
agentName = "agentik",
agentAddress = "http://x:8080/agentik",
agentColor = null,
online = true,
onClose = {},
onRename = { _, _ -> },
onDelete = {},
)
}
}
}
// Режим редактирования открыт: видна подсказка «До 120 символов»,
// кнопка «Отмена» (а не «Закрыть»).
onNodeWithText("До 120 символов").assertTextEquals("До 120 символов")
onNodeWithText("Отмена").assertTextEquals("Отмена")
}
@OptIn(ExperimentalTestApi::class)
@Test
fun `pencil button enters edit mode and saving empty title passes null to onRename`() = runComposeUiTest {
val conversation = sampleConversation(title = "Spring Boot")
var renamedTo: String? = "sentinel"
setContent {
withAvatarStorage {
ProvideAgentikTheme("dark".toAgentikTheme()) {
DialogInfoDialog(
conversation = conversation,
agentName = null,
agentAddress = null,
agentColor = null,
online = false,
onClose = {},
onRename = { newTitle, cb ->
renamedTo = newTitle
cb(Result.success(Unit))
},
onDelete = {},
)
}
}
}
// Режим просмотра → жмём ✎ → режим редактирования.
onNodeWithText("✎").performClick()
onNodeWithText("До 120 символов").assertTextEquals("До 120 символов")
// Стираем имя — пустое допустимо: сервер получит null, клиентский
// маппер отрисует его как UNTITLED_CONVERSATION.
onNodeWithText("Spring Boot").performTextReplacement("")
onNodeWithText("Сохранить").performClick()
assertNull(renamedTo, "пустое имя → null в onRename")
}
@OptIn(ExperimentalTestApi::class)
@Test
fun `saving unchanged title does NOT call onRename`() = runComposeUiTest {
val conversation = sampleConversation(title = "Spring Boot")
var renameCalled = false
setContent {
withAvatarStorage {
ProvideAgentikTheme("dark".toAgentikTheme()) {
DialogInfoDialog(
conversation = conversation,
agentName = null,
agentAddress = null,
agentColor = null,
online = false,
onClose = {},
onRename = { _, _ -> renameCalled = true },
onDelete = {},
)
}
}
}
onNodeWithText("✎").performClick()
// Не меняем — жмём «Сохранить»: оптимизация — сетевого вызова быть не должно.
onNodeWithText("Сохранить").performClick()
assertFalse(renameCalled, "если имя не меняли, onRename вызываться не должен")
}
@OptIn(ExperimentalTestApi::class)
@Test
fun `temporal dialog shows short hint`() = runComposeUiTest {
val conversation = sampleConversation(title = "Черновик ТЗ", temporal = true)
setContent {
withAvatarStorage {
ProvideAgentikTheme("dark".toAgentikTheme()) {
DialogInfoDialog(
conversation = conversation,
agentName = "coder",
agentAddress = "http://coder:8080/agentik",
agentColor = Color(0xFFC9A886),
online = true,
onClose = {},
onRename = { _, _ -> },
onDelete = {},
)
}
}
}
onNodeWithText("Временный диалог").assertTextEquals("Временный диалог")
}
@OptIn(ExperimentalTestApi::class)
@Test
fun `regular dialog shows long hint about immutability`() = runComposeUiTest {
val conversation = sampleConversation(title = "Обычный", temporal = false)
setContent {
withAvatarStorage {
ProvideAgentikTheme("dark".toAgentikTheme()) {
DialogInfoDialog(
conversation = conversation,
agentName = null,
agentAddress = null,
agentColor = null,
online = false,
onClose = {},
onRename = { _, _ -> },
onDelete = {},
)
}
}
}
onNodeWithText("Временный диалог").assertTextEquals("Временный диалог")
}
@OptIn(ExperimentalTestApi::class)
@Test
fun `rename failure shows error and retry label`() = runComposeUiTest {
val conversation = sampleConversation(title = "Имя")
setContent {
withAvatarStorage {
ProvideAgentikTheme("dark".toAgentikTheme()) {
var failedOnce by remember { mutableStateOf(false) }
DialogInfoDialog(
conversation = conversation,
agentName = null,
agentAddress = null,
agentColor = null,
online = false,
onClose = {},
onRename = { _, cb ->
if (!failedOnce) {
cb(Result.failure(RuntimeException("агент не отвечает")))
failedOnce = true
} else {
cb(Result.success(Unit))
}
},
onDelete = {},
)
}
}
}
onNodeWithText("✎").performClick()
// Вводим реально новое имя, иначе оптимизация «имя не менялось» не пустит запрос.
onNodeWithText("Имя").performTextReplacement("Новое имя")
onNodeWithText("Сохранить").performClick()
onNodeWithText("агент не отвечает").assertTextEquals("агент не отвечает")
onNodeWithText("Повторить").assertTextEquals("Повторить")
}
@OptIn(ExperimentalTestApi::class)
@Test
fun `delete button fires onDelete`() = runComposeUiTest {
val conversation = sampleConversation(title = "X")
var deleted = false
setContent {
withAvatarStorage {
ProvideAgentikTheme("dark".toAgentikTheme()) {
DialogInfoDialog(
conversation = conversation,
agentName = null,
agentAddress = null,
agentColor = null,
online = false,
onClose = {},
onRename = { _, _ -> },
onDelete = { deleted = true },
)
}
}
}
onNodeWithText("Удалить диалог").performClick()
assertTrue(deleted, "onDelete должен сработать")
}
@OptIn(ExperimentalTestApi::class)
@Test
fun `pixels include red from delete button in dark theme`() = runComposeUiTest {
val conversation = sampleConversation(title = "X")
setContent {
withAvatarStorage {
ProvideAgentikTheme("dark".toAgentikTheme()) {
Box(Modifier.fillMaxSize().background(darkAgentikColors.background)) {
DialogInfoDialog(
conversation = conversation,
agentName = null,
agentAddress = null,
agentColor = null,
online = false,
onClose = {},
onRename = { _, _ -> },
onDelete = {},
)
}
}
}
}
val histogram = pixelHistogram(onRoot().captureToImage().toPixelMap())
val dangerPresent = histogram.keys.any { c ->
c.red > 0.7f && c.green < 0.4f && c.blue < 0.4f
}
assertTrue(
dangerPresent,
"в гистограмме должен быть пиксель красного: ${histogram.entries.sortedByDescending { it.value }.take(3)}",
)
}
/**
* Регрессия на вертикальное центрирование подписи «Временный диалог»
* относительно квадратика чекбокса. Рендерит строку в битмап и сравнивает
* вертикальные центры синего квадратика (accent) и белых глифов текста.
*/
@OptIn(ExperimentalTestApi::class)
@Test
fun `temporal checkbox label is vertically centered with the box`() = runComposeUiTest {
val conversation = sampleConversation(title = "X", temporal = true)
setContent {
withAvatarStorage {
ProvideAgentikTheme("dark".toAgentikTheme()) {
Box(Modifier.fillMaxSize().background(darkAgentikColors.background)) {
DialogInfoDialog(
conversation = conversation,
agentName = null,
agentAddress = null,
agentColor = null,
online = false,
onClose = {},
onRename = { _, _ -> },
onDelete = {},
)
}
}
}
}
val pixels = onNodeWithTag("temporal-checkbox").captureToImage().toPixelMap()
var sqMinY = Int.MAX_VALUE; var sqMaxY = -1
var sqMaxX = -1
var txMinY = Int.MAX_VALUE; var txMaxY = -1
var sawText = false
for (y in 0 until pixels.height) {
for (x in 0 until pixels.width) {
val c = pixels[x, y]
val isAccent = kotlin.math.abs(c.red - 0.416f) < 0.12f &&
kotlin.math.abs(c.green - 0.698f) < 0.12f &&
kotlin.math.abs(c.blue - 0.949f) < 0.12f
if (isAccent) {
if (y < sqMinY) sqMinY = y
if (y > sqMaxY) sqMaxY = y
if (x > sqMaxX) sqMaxX = x
}
}
}
// Текст ищем только правее квадратика, чтобы ✓ внутри квадрата не мешал.
for (y in 0 until pixels.height) {
for (x in (sqMaxX + 3) until pixels.width) {
val c = pixels[x, y]
val isText = c.red > 0.55f && c.green > 0.55f && c.blue > 0.55f &&
(maxOf(c.red, c.green, c.blue) - minOf(c.red, c.green, c.blue)) < 0.2f
if (isText) {
sawText = true
if (y < txMinY) txMinY = y
if (y > txMaxY) txMaxY = y
}
}
}
assertTrue(sawText, "глифы подписи не найдены в битмапе")
val sqCenter = (sqMinY + sqMaxY) / 2f
val txCenter = (txMinY + txMaxY) / 2f
val delta = txCenter - sqCenter
assertTrue(
kotlin.math.abs(delta) <= 1.0f,
"центр подписи (y=$txCenter) должен совпадать с центром квадратика " +
"(y=$sqCenter); смещение=${delta}px (sqY=$sqMinY..$sqMaxY, txY=$txMinY..$txMaxY)",
)
}
// ── helpers ────────────────────────────────────────────────────────────
private fun sampleConversation(
title: String = "Диалог",
temporal: Boolean = false,
): UiConversation = UiConversation(
id = "conv-1",
title = title,
isTemporal = temporal,
createdAt = Instant.parse("2026-10-05T11:30:00Z"),
updatedAt = Instant.parse("2026-10-05T11:31:00Z"),
groupId = null,
preview = null,
unread = 0,
)
/**
* Оборачивает [content] в провайдер [AvatarStorage] с временной папкой.
*/
@Composable
private fun withAvatarStorage(content: @Composable () -> Unit) {
CompositionLocalProvider(LocalAvatarStorage provides AvatarStorage()) {
content()
}
}
private fun pixelHistogram(pixels: androidx.compose.ui.graphics.PixelMap): Map<Color, Int> {
val out = HashMap<Color, Int>()
for (y in 0 until pixels.height) {
for (x in 0 until pixels.width) {
val c = pixels[x, y]
out[c] = (out[c] ?: 0) + 1
}
}
return out
}
}
@@ -0,0 +1,173 @@
package pw.binom.agentik.desktop.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
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.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.markdown.MarkdownSegment
import pw.binom.agentik.desktop.markdown.parseMarkdown
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
import pw.binom.agentik.desktop.ui.theme.lightAgentikColors
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* Стиль отрисовки markdown: [MarkdownStyle] + [AgentikMarkdownStyle].
*
* Регрессия, ради которой всё это: парсер заводит спаны только для размеченных
* участков (жирный/код/ссылка), а база накладывалась ВНУТРИ спанов — поэтому
* «простой» текст оставался без цвета и рисовался чёрным (дефолт
* `LocalContentColor`) вместо цвета темы.
*/
class MarkdownStyleTest {
private val darkStyle = AgentikMarkdownStyle(darkAgentikColors)
// ── цвет текста ────────────────────────────────────────────────────────
@Test
fun `plain text is painted with the style text color`() {
val segment = parseMarkdown("Просто текст без разметки").single() as MarkdownSegment.Text
assertTrue(segment.spans.isEmpty(), "в простом тексте нет стилевых спанов")
val annotated = segment.toAnnotatedString(darkStyle)
val base = annotated.spanStyles.singleOrNull()
assertNotNull(base, "база накладывается на весь текст, даже без спанов")
assertEquals(0, base.start)
assertEquals(segment.text.length, base.end)
assertEquals(darkAgentikColors.text, base.item.color)
}
@Test
fun `colors come from the theme`() {
val light = AgentikMarkdownStyle(lightAgentikColors)
assertEquals(darkAgentikColors.text, darkStyle.textColor)
assertEquals(darkAgentikColors.accent, darkStyle.linkColor)
assertEquals(darkAgentikColors.codeBackground, darkStyle.codeBackground)
assertEquals(darkAgentikColors.codeText, darkStyle.codeTextColor)
assertEquals(darkAgentikColors.panelElevated, darkStyle.inlineCodeBackground)
assertEquals(lightAgentikColors.text, light.textColor)
assertEquals(lightAgentikColors.codeBackground, light.codeBackground)
// стиль реально зависит от темы, а не хардкодит тёмные значения
assertNotEquals(darkStyle.textColor, light.textColor)
assertNotEquals(darkStyle.codeBackground, light.codeBackground)
}
@Test
fun `inline code and links take their slots from the style`() {
val segment = parseMarkdown("код `val x` и [ссылка](http://example.com)")
.single() as MarkdownSegment.Text
val annotated = segment.toAnnotatedString(darkStyle)
val code = annotated.spanStyles.firstOrNull {
it.item.fontFamily == FontFamily.Monospace && it.item.background == darkAgentikColors.panelElevated
}
assertNotNull(code, "inline-код берёт моноширинный шрифт и фон из стиля")
val link = annotated.spanStyles.firstOrNull {
it.item.color == darkAgentikColors.accent && it.item.textDecoration == TextDecoration.Underline
}
assertNotNull(link, "ссылка берёт цвет из стиля")
val url = annotated.getStringAnnotations("URL", 0, annotated.length).singleOrNull()
assertNotNull(url, "ссылка помечается аннотацией URL")
assertEquals("http://example.com", url.item)
}
// ── рендер ─────────────────────────────────────────────────────────────
@OptIn(ExperimentalTestApi::class)
@Test
fun `plain text renders light, never black`() = runComposeUiTest {
// Крупный кегль — чтобы в гистограмме гарантированно были пиксели,
// полностью залитые цветом текста (не только сглаженные края).
val big = object : MarkdownStyle by AgentikMarkdownStyle(darkAgentikColors) {
override val bodyFontSize = 30.sp
}
setContent {
ProvideAgentikTheme("dark".toAgentikTheme()) {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.background)) {
renderMarkdownSegments("Текст сообщения", style = big)
}
}
}
val histogram = onRoot().captureToImage().toPixelMap().histogram()
assertTrue(
histogram.keys.any { it.red > 0.8f && it.green > 0.8f && it.blue > 0.8f },
"текст должен быть светлым (цвет темы ${darkAgentikColors.text}), а не чёрным: ${histogram.top()}",
)
assertFalse(
histogram.containsKey(Color.Black),
"в тёмной теме чёрного текста быть не должно: ${histogram.top()}",
)
}
@OptIn(ExperimentalTestApi::class)
@Test
fun `renderer follows the style from the composition local`() = runComposeUiTest {
val magenta = object : MarkdownStyle by AgentikMarkdownStyle(darkAgentikColors) {
override val textColor = Color(0xFFFF00FF)
}
setContent {
ProvideAgentikTheme("dark".toAgentikTheme()) {
CompositionLocalProvider(LocalMarkdownStyle provides magenta) {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.background)) {
renderMarkdownSegments("Текст сообщения")
}
}
}
}
val histogram = onRoot().captureToImage().toPixelMap().histogram()
assertTrue(
histogram.keys.any { it.red > 0.5f && it.blue > 0.5f && it.green < 0.3f },
"рендер обязан брать стиль из LocalMarkdownStyle: ${histogram.top()}",
)
}
// ── helpers ────────────────────────────────────────────────────────────
private fun androidx.compose.ui.graphics.PixelMap.histogram(): Map<Color, Int> {
val out = HashMap<Color, Int>()
for (y in 0 until height) {
for (x in 0 until width) {
val color = this[x, y]
out[color] = (out[color] ?: 0) + 1
}
}
return out
}
private fun Map<Color, Int>.top(): String =
entries.sortedByDescending { it.value }.take(5)
.joinToString { "#${it.key.toHexArgb()}×${it.value}" }
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)
}
}
@@ -13,11 +13,12 @@ import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp 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.AgentikColors
import pw.binom.agentik.desktop.ui.theme.AgentikTheme import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
import pw.binom.agentik.desktop.ui.theme.lightAgentikColors import pw.binom.agentik.desktop.ui.theme.lightAgentikColors
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
@@ -80,14 +81,10 @@ class ThemeRenderTest {
@Test @Test
fun codeBlockPaintsThemeCodeBackgroundInLightTheme() = runComposeUiTest { fun codeBlockPaintsThemeCodeBackgroundInLightTheme() = runComposeUiTest {
setContent { setContent {
AgentikTheme("light") { ProvideAgentikTheme("light".toAgentikTheme()) {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) { Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) {
Column { Column {
renderMarkdownSegments( renderMarkdownSegments(content = "```kotlin\nval x = 1\n```")
content = "```kotlin\nval x = 1\n```",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
)
} }
} }
} }
@@ -116,14 +113,10 @@ class ThemeRenderTest {
@Test @Test
fun darkThemeStillRendersCodeBlockDark() = runComposeUiTest { fun darkThemeStillRendersCodeBlockDark() = runComposeUiTest {
setContent { setContent {
AgentikTheme("dark") { ProvideAgentikTheme("dark".toAgentikTheme()) {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) { Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) {
Column { Column {
renderMarkdownSegments( renderMarkdownSegments(content = "```kotlin\nval x = 1\n```")
content = "```kotlin\nval x = 1\n```",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
)
} }
} }
} }
@@ -6,9 +6,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest 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.renderMarkdownSegments
import pw.binom.agentik.desktop.ui.theme.AgentikTheme import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertTrue import kotlin.test.assertTrue
@@ -38,12 +39,10 @@ class RealSpeechIT {
// 2. skiko/Compose инициализируется и реально рисует (как окно приложения). // 2. skiko/Compose инициализируется и реально рисует (как окно приложения).
runComposeUiTest { runComposeUiTest {
setContent { setContent {
AgentikTheme("dark") { ProvideAgentikTheme("dark".toAgentikTheme()) {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble)) { Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble)) {
renderMarkdownSegments( renderMarkdownSegments(
content = "проверка: **окно** уже отрисовано", content = "проверка: **окно** уже отрисовано",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
) )
} }
} }