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)** — рейка агентов, список диалогов (поиск, папки, бейджи),
чат с живым стримингом, композер, окна «Агенты», «Группы» и «Новый диалог»
(при нескольких агентах — выбор, при одном — сразу), пустые состояния,
адаптивная раскладка (узкое окно — список или чат).
чат с живым стримингом, композер, окна «Агенты», «Группы», «Новый диалог»
(при нескольких агентах — выбор, при одном — сразу) и «Параметры диалога»
(кнопка `⋯` в шапке чата: имя, обслуживающий агент, признак «Временный»,
даты создания/обновления, удаление), пустые состояния, адаптивная раскладка
(узкое окно — список или чат).
- **Голос** — микрофон → VAD → распознавание (`voice/`): кнопка микрофона
или `Ctrl+M` из чата запускает запись (`mic-api`), Silero VAD (`vad-kmp`) отмечает наличие речи,
Vosk (`asr-vosk`, модель внутри артефакта) отдаёт промежуточный текст над
+32 -2
View File
@@ -82,6 +82,14 @@
## 4. Открытый диалог
- **R10.** Показ сообщений: мои и ассистента, с временем.
- **R10.1.** В шапке открытого диалога — **кнопка `⋯` (три вертикальные точки)**,
без визуальных границ, как в Telegram. Открывает окно «Параметры диалога».
- **R10.2.** Окно «Параметры диалога»: название (с редактированием по ✎),
карточка обслуживающего агента (имя + URL + статус «на связи»/«не отвечает»),
признак «Временный диалог» (только просмотр — задаётся только при создании),
«Создан» и «Обновлён» (`dd.MM.yyyy HH:mm`, системная тайм-зона),
«Опасное» с кнопкой «Удалить диалог». Создание идёт через модалку
выбора агента (R14.x), а не через это окно.
- **R11.** Ответ ассистента можно прервать — на видном месте кнопка «Стоп».
- **R12.** Сообщения приходят вживую, по мере генерации: текст дотекает куском
за куском, а не появляется целиком в конце. Отображать именно так.
@@ -294,6 +302,30 @@
- **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. Решения, которые ещё не приняты
- Запись: по нажатию или на удержание.
@@ -307,8 +339,6 @@
- **Группы — только на устройстве или на сервере?** Сервер про них не знает
(`STORAGE.md`, §7). Если только у нас — раскладка не поедет между десктопом
и телефоном.
- **Меняем ли API: превью последнего сообщения и число непрочитанных.**
Взять неоткуда, в макете они нарисованы (`STORAGE.md`, §6).
- **Бейдж — число или точка.** Точка сервер не трогает вообще (`STORAGE.md`, §6.2).
- **Где каталог клиента** — `~/.agentik/` или системный («Документы»).
На Android понятие «домашний каталог» своё.
+1 -1
View File
@@ -2,7 +2,7 @@
kotlin = "2.4.20"
kotlinx-coroutines = "1.11.0"
kotlinx-serialization = "1.11.0"
ksqlite = "0.1.3"
ksqlite = "0.1.4"
# Ktor — версия из agentik (там же живут `:client`-модули, engine выбирает
# потребитель; CIO — то, что agentik использует в собственных тестах).
ktor = "3.1.3"
+252 -89
View File
@@ -169,7 +169,7 @@
</div>
<div class="composer">
<div class="composer-row">
<div class="field">Сообщение агенту…</div>
<div class="composer-input">Сообщение агенту…</div>
<button class="mic" title="Голос">🎤</button>
<button class="send off" title="Отправить">➤</button>
</div>
@@ -253,92 +253,6 @@
</div>
</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 · управление группами ============ -->
<section class="screen">
<div class="lbl"><b>6 · управление группами</b> модалка со списком групп + добавить</div>
@@ -967,7 +881,7 @@
<div class="head-name">agentik · рабочий</div>
<div class="head-sub"><span class="dot">●</span> на связи</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>
<div class="messages">
@@ -1040,7 +954,7 @@
<div class="avatar pic"></div>
<div class="ag-mid">
<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>
<div class="pick"></div>
@@ -1051,6 +965,20 @@
<span>Ассистент сейчас не отвечает. Диалог создать можно, но
сообщение не уйдёт, пока он не поднимется.</span>
</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 class="d-foot">
<button class="btn" style="flex:1">Отмена</button>
@@ -1422,5 +1350,240 @@
</div>
</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>
</html>
+44 -17
View File
@@ -144,12 +144,12 @@ body {
.rec-btn.rec-send:hover { background: #82BEF5; }
.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);
border: 1px solid var(--line); display: flex; align-items: center; padding: 0 14px;
font-size: 13.5px; color: var(--dim);
}
.field.focus { border-color: var(--accent); }
.composer-input.focus { border-color: var(--accent); }
.mic {
width: 52px; height: 52px; flex: 0 0 52px; border-radius: 50%;
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); }
.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;
font-size: 10.5px; }
.addr-row { display: flex; gap: 8px; }
@@ -441,7 +432,9 @@ body {
.icon-btn { width: 30px; height: 30px; border-radius: 8px; border: none;
background: transparent; color: var(--dim); cursor: pointer; display: grid; place-items: center;
font-size: 17px; }
.icon-btn svg { width: 18px; height: 18px; fill: currentColor; }
.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);
background: var(--bg); color: var(--fg); font-size: 13px; outline: none; font-family: inherit; }
.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; }
.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) === */
.d-lead { font-size: 12.5px; color: var(--dim); margin-bottom: 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 .nm { color: var(--fg); }
.grp .lock { font-size: 11px; color: var(--dim); 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 .note { font-size: 11px; color: var(--dim); margin-top: 6px; }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 4px; }
.field input {
width: 100%; padding: 6px 0; background: transparent; color: var(--fg);
font-size: 14px; outline: none; font-family: inherit;
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.session.LiveBlock
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.MessageRecord
import kotlin.time.Instant
@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.desktop.settings.AgentConfig
import pw.binom.agentik.proto.Agent
/** Один шаг проверки связи. */
data class CheckStep(
@@ -28,58 +29,56 @@ data class ConnectionReport(
/**
* Проверка связи с агентом без открытия полноценного [AgentConnection]:
* отдельное «одноразовое» подключение, которое закрывается сразу после
* прогона шагов.
* прогона шага.
*
* Шаги:
* 1. **Ответ сервера** — `GET {baseUrl}` за `AgentInfo` (имя/описание).
* 2. **Диалог** — создать временный диалог и удалить его (проверяет
* conversation-роуты, не тратит токены LLM).
* Единственный шаг — **запрос информации об агенте**: конструктор
* [AgentikAgent] синхронно делает `GET {baseUrl}` за `AgentInfo`
* (имя/описание). Успешный ответ и есть «сервер жив и отвечает на своём
* API» — без единого побочного эффекта на стороне сервера.
*
* Сознательно **не** отправляем реальный ход: это дёргает модель (время,
* стоимость, побочные эффекты в истории). Проверки маршрутов достаточно,
* чтобы отличить «связи нет» от «связь есть».
* Сознательно **не** создаём временный диалог и **не** отправляем реальный
* ход: временный диалог — это мусор в истории сервера (и лишний визит к БД),
* а ход ещё и дёргает модель (время, стоимость, след в журнале). Для ответа
* «связь есть / связи нет» достаточно ответа на запрос информации.
*/
class ConnectionChecker(
private val engineFactory: HttpClientEngineFactory<*> = CIO,
private val timeoutMillis: Long = 15_000,
) {
suspend fun check(config: AgentConfig, clientId: String): ConnectionReport = withContext(Dispatchers.IO) {
val steps = mutableListOf<CheckStep>()
var agent: pw.binom.agentik.proto.Agent? = null
var name: String? = null
val t0 = System.nanoTime()
try {
agent = AgentikAgent(
val agent: Agent = try {
AgentikAgent(
id = clientId,
baseUrl = config.baseUrl,
engineFactory = engineFactory,
token = config.token,
)
name = agent.info.name
steps += CheckStep("Ответ сервера", true, elapsed(t0), runCatching { agent.info.description }.getOrNull())
} catch (t: Throwable) {
steps += CheckStep("Ответ сервера", false, elapsed(t0), t.message ?: t::class.simpleName)
return@withContext ConnectionReport(config.baseUrl, steps, name)
return@withContext ConnectionReport(
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 {
val conv = a.createConversation(temp = true)
val id = conv.id
conv.close()
a.deleteConversation(id)
steps += CheckStep("Создание диалога", true, elapsed(t1))
} catch (t: Throwable) {
steps += CheckStep("Создание диалога", false, elapsed(t1), t.message ?: t::class.simpleName)
val info = agent.info
ConnectionReport(
baseUrl = config.baseUrl,
steps = listOf(
CheckStep(STEP_AGENT_INFO, true, elapsed(t0), runCatching { info.description }.getOrNull()),
),
agentName = info.name,
)
} finally {
runCatching { a.close() }
runCatching { agent.close() }
}
ConnectionReport(config.baseUrl, steps, name)
}
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)` и здесь не лежит.
* - **`preview`** — последний фрагмент текста хода, денормализованный
* в строку. Сервер такого поля не отдаёт (STORAGE.md §6.1), клиент
* поддерживает сам на `Event.AppendText` / `Event.End`.
* поддерживает сам на `OnlineEvent.AppendText` / `Event.AssistantMessage`.
*
* **Почему НЕ наследуется от `KsqliteJournalStore`:** разные таблицы
* (`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.cio.CIO
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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.MutableJournalStore
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
import pw.binom.agentik.outbox.OnlineOutbox
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentInfo
@@ -107,6 +109,25 @@ class AgentConnection private constructor(
/** Live-события (SSE). */
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 {
startConversationWatch()
}
@@ -120,6 +141,13 @@ class AgentConnection private constructor(
delay(SEED_RETRY_DELAY)
}
refreshConversations()
// Глобальный ingest: события всех диалогов → кэш + превью. Отдельной
// подписки на сообщения у UI-списка больше нет — превью неоткрытых
// диалогов догоняются журналом, а не только по факту открытия.
syncEngine.start(
conversations = { agent.conversationStore.list(offset = 0, limit = 500) },
onChanged = { refreshConversations() },
)
runCatching {
agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { refreshConversations() }
}
@@ -205,6 +233,8 @@ class AgentConnection private constructor(
*/
suspend fun openSession(id: String): ChatSession? {
val conv = agent.getConversation(id) ?: return null
openSessions.add(id)
syncEngine.openConversationIds = openSessions.toSet()
return ChatSession(
conversationId = id,
conversation = conv,
@@ -212,11 +242,18 @@ class AgentConnection private constructor(
journalCache = journalCache,
meta = meta,
outbox = agent.outbox,
onlineOutbox = agent.onlineOutbox,
scope = scope,
locks = locks,
onClosed = {
openSessions.remove(id)
syncEngine.openConversationIds = openSessions.toSet()
},
)
}
override fun close() {
syncEngine.close()
watchScope.cancel()
runCatching { agent.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.MutableJournalStore
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OnlineOutbox
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 kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
/**
@@ -32,8 +32,9 @@ import kotlin.time.Instant
* старте догоняем то, что появилось с прошлого раза, и кладём в кэш.
* Watermark — `conversation_meta.synced_at`, чтобы не вставлять дубли
* (`message.id` — PRIMARY KEY, повторный INSERT бросает).
* - **Live-ход** — `OutboxStore.conversationEvents` (SSE): события
* `Working → StartReasoning → AppendText → … → End` накапливаются в
* - **Live-ход** — два SSE-потока: durable
* `OutboxStore.conversationEvents` (`UserMessage → … → AssistantMessage`) и
* онлайн `OnlineOutbox.onlineEvents` (Working/End + стриминг ответа). Оба копятся в
* [LiveTurn] и рисуются поверх истории, пока ход не закрыт.
* - **Наши поля** — [ConversationMetaRepository]: `lastSeen` (бейдж),
* `preview` (строка списка).
@@ -41,7 +42,7 @@ import kotlin.time.Instant
* ## Почему подписка на SSE берётся `after = synced_at`
*
* `synced_at` — `createdAt` последнего сообщения, уже лежащего в кэше.
* У завершённого хода все события (`Working`/`AppendText`/`End`) имеют
* У завершённого хода все события (`UserMessage`/`AppendText`/`AssistantMessage`) имеют
* `date`, не больший `createdAt` итогового сообщения, — значит, они
* отфильтруются и повторно не проиграются. А у **идущего** хода события
* свежее — они и попадут в поток. Так «догоняем» уже начатый ход, не
@@ -50,7 +51,7 @@ import kotlin.time.Instant
*
* ## Поток событий vs `sync()` на `End`
*
* На `End`/`Interrupted` ход закрыт: подтягиваем авторитетную историю
* На `AssistantMessage`/`Interrupted`/`Error` ход закрыт: подтягиваем авторитетную историю
* (`sync()` — инкрементальный бэкфилл + перечитка кэша) и гасим live-блоки,
* чтобы не было дублирования «live + history».
*/
@@ -61,7 +62,15 @@ class ChatSession(
private val journalCache: MutableJournalStore,
private val meta: ConversationMetaRepository,
private val outbox: OutboxStore,
private val onlineOutbox: OnlineOutbox,
private val scope: CoroutineScope,
/**
* Общие с [AgentSyncEngine] мьютексы по диалогам: сериализуют бэкфилл,
* если фоновый ingest и открытая сессия синхронят один диалог.
*/
private val locks: ConversationLocks = ConversationLocks(),
/** Колбэк на [close] — engine снимает диалог из своего open-набора. */
private val onClosed: () -> Unit = {},
) : AutoCloseable {
private val _history = MutableStateFlow<List<UiMessage>>(emptyList())
@@ -88,16 +97,17 @@ class ChatSession(
private val liveTurn = LiveTurn()
private var eventsJob: Job? = null
private var onlineJob: Job? = null
private var started = false
/**
* Сериализует [sync]. Без него два параллельных триггера (оптимистичный
* `sync()` из [sendContent] и `sync()` из обработчика SSE `End`, который
* приходит почти сразу на быстром ответе) читают один и тот же watermark
* `null` → `after = DISTANT_PAST` и оба вставляют user-сообщение →
* Сериализует [sync] через общий [locks]. Без него два параллельных
* триггера (оптимистичный `sync()` из [sendContent] и `sync()` из
* обработчика SSE `End`) читают один и тот же watermark `null` →
* `after = DISTANT_PAST` и оба вставляют user-сообщение →
* `UNIQUE constraint failed: message.id`.
*/
private val syncMutex = Mutex()
private fun syncLock(): Mutex = locks.lock(conversationId)
/**
* Догоняет историю и подписывается на live-события. Идемпотентно —
@@ -112,6 +122,7 @@ class ChatSession(
}
sync()
subscribeEvents()
subscribeOnline()
}
/** Отправить текстовый ход. Пустой/пробельный текст игнорируется. */
@@ -147,7 +158,10 @@ class ChatSession(
override fun close() {
eventsJob?.cancel()
eventsJob = null
onlineJob?.cancel()
onlineJob = null
runCatching { conversation.close() }
onClosed()
}
// ───── internals ─────
@@ -159,8 +173,10 @@ class ChatSession(
.map { it.event }
.collect { event ->
liveTurn.apply(event)
if (event is Event.End || event is Event.Interrupted) {
// Ход закрыт: авторитетная история важнее live-блоков.
// Ход закрыт durable-событием: успех — готовое
// AssistantMessage, прерывание — Interrupted, провал —
// Error. Авторитетная история важнее live-блоков.
if (event is Event.AssistantMessage || event is Event.Interrupted || event is Event.Error) {
runCatching { sync() }
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 sync() = syncMutex.withLock {
private suspend fun sync() = syncLock().withLock {
backfill()
reloadHistory()
updatePreview()
}
/**
* Дотягивает сообщения, появившиеся с прошлого watermark'а.
*
* **Идемпотентность по id, а не по времени.** Watermark — миллисекундный:
* user- и assistant-сообщение быстрого ответа делят одну миллисекунду, и
* фильтр `createdAt > after` просто потерял бы второе. Поэтому watermark
* отступает на 1 мс назад, а повторно пришедшие записи отсекаются по
* множеству уже известных id (никакого `runCatching` — реальные ошибки
* записи должны быть видны).
* Дотягивает сообщения, появившиеся с прошлого watermark'а (см.
* [backfillConversation] — там же про идемпотентность по id).
*/
private suspend fun backfill() {
val row = meta.meta(conversationId)
val after = row?.syncedAt ?: Instant.DISTANT_PAST
val known = journalCache.list(
conversationId = conversationId,
after = Instant.DISTANT_PAST,
offset = 0,
limit = Int.MAX_VALUE,
).mapTo(mutableSetOf()) { it.id }
var newest: Instant? = null
remoteJournal.listFlow(conversationId = conversationId, after = after).collect { rec ->
if (known.add(rec.id)) {
journalCache.append(rec)
}
val n = newest
if (n == null || rec.createdAt > n) newest = rec.createdAt
}
newest?.let { meta.setSyncedAt(conversationId, it - 1.milliseconds) }
backfillConversation(remoteJournal, journalCache, meta, conversationId)
}
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
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OnlineEvent
/**
* Фаза текущего хода ассистента. Управляет спиннером и тем, что UI
@@ -10,16 +11,16 @@ enum class TurnPhase {
/** Хода нет — стор чист. */
IDLE,
/** `send()` ушёл, ответа ещё нет (`Event.Working`). Спиннер «агент думает». */
/** `send()` ушёл, ответа ещё нет (`OnlineEvent.Working`). Спиннер «агент думает». */
WORKING,
/** Идут рассуждения (`StartReasoning` + `AppendText` в reasoning-блок). */
/** Идут рассуждения (`StartReasoning` + `AppendText` в reasoning-блок). Live-канал. */
REASONING,
/** Стримится ответ (`StartResponse` + `AppendText`/`AppendImage`/tool'ы). */
/** Стримится ответ (`StartResponse` + `AppendText`/`AppendImage`/tool'ы). Live-канал. */
RESPONDING,
/** Ход закрыт нормально (`End`). Live-блоки пора заменить историей из кэша. */
/** Ход закрыт нормально (`OnlineEvent.End` / durable `Event.AssistantMessage`). Live-блоки пора заменить историей из кэша. */
DONE,
/** Ход прерван пользователем (`Interrupted`). Частичный ответ НЕ сохранён. */
@@ -92,8 +93,8 @@ sealed interface LiveBlock {
}
/**
* Аккумулятор live-хода: применяет [Event] по мере SSE-стрима и держит
* текущий список [LiveBlock] + [phase].
* Аккумулятор live-хода: применяет durable-[Event] и онлайн-[OnlineEvent]
* по мере двух SSE-стримов и держит текущий список [LiveBlock] + [phase].
*
* Это **модель представления**, не хранилище: после `End` ход
* «схлопывается» в обычную `MessageRecord`, и UI переключается на историю
@@ -132,7 +133,7 @@ class LiveTurn {
/**
* Начинает новый ход: чистит блоки и переводит в [TurnPhase.WORKING].
* Вызывать перед `send()` (или на `Event.Working`) — иначе блоки
* Вызывать перед `send()` (или на `OnlineEvent.Working`) — иначе блоки
* предыдущего хода смешаются с новыми.
*/
fun start() {
@@ -151,45 +152,23 @@ class LiveTurn {
}
/**
* Применяет одно событие. Неизвестные/нерелевантные события
* Применяет одно **durable** событие ([Event]). Стриминговые события
* (дельты текста/картинок, маркеры фаз) сюда НЕ приходят — они в
* [applyOnline] ([OnlineEvent]). Неизвестные/нерелевантные
* (`ConversationClosing`, `CompactionTriggered`) игнорируются.
*/
fun apply(event: Event) {
when (event) {
is Event.Working -> {
// Синхронный маркер старта хода: новый ход — с чистого листа.
start()
is Event.UserMessage -> {
// Durable-маркер старта хода. Если онлайн-`Working` уже
// открыл ход, повторно не чистим блоки (защита от гонки
// двух SSE-потоков).
if (!phase.isStreaming) start()
}
is Event.StartReasoning -> {
phase = TurnPhase.REASONING
_blocks.add(LiveBlock.Reasoning(nextKey("reasoning")))
fire()
}
is Event.StartResponse -> {
phase = TurnPhase.RESPONDING
if (event.responseType == Event.ResponseType.TEXT) {
_blocks.add(LiveBlock.Answer(nextKey("answer")))
}
fire()
}
is Event.AppendText -> {
when (phase) {
TurnPhase.REASONING -> appendText(event.body, reasoning = true)
// AppendText без предшествующего StartResponse —
// трактуем как ответ (forward-compat).
else -> {
if (phase != TurnPhase.RESPONDING) phase = TurnPhase.RESPONDING
appendText(event.body, reasoning = false)
}
}
}
is Event.AppendImage -> {
phase = TurnPhase.RESPONDING
_blocks.add(LiveBlock.Image(nextKey("image"), event.mime, event.body))
is Event.AssistantMessage -> {
// Durable-терминатор успешного хода: целый ответ уже в журнале.
phase = TurnPhase.DONE
fire()
}
@@ -232,11 +211,6 @@ class LiveTurn {
fire()
}
is Event.End -> {
phase = TurnPhase.DONE
fire()
}
is Event.Interrupted -> {
phase = TurnPhase.INTERRUPTED
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.ChatPane
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.NewChatDialog
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 {
data object Agents : AppDialog
data object Groups : AppDialog
data object NewChat : AppDialog
data object DialogInfo : AppDialog
}
/**
@@ -52,7 +57,7 @@ private sealed interface AppDialog {
*/
@Composable
fun App(state: AppState) {
AgentikTheme(themeId = state.settingsState.themeId) {
ProvideAgentikTheme(theme = state.settingsState.themeId.toAgentikTheme()) {
CompositionLocalProvider(LocalAvatarStorage provides state.avatars) {
LaunchedEffect(Unit) { state.start() }
Root(state)
@@ -66,6 +71,15 @@ private fun Root(state: AppState) {
var dialog by remember { mutableStateOf<AppDialog?>(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)) {
val wide = maxWidth >= 900.dp
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
}
}
@@ -123,6 +123,29 @@ class AppState(
/** Каталог агентов. */
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` → адрес (пока не подключились).
@@ -240,7 +263,7 @@ class AppState(
/** Проверка связи для формы агента — вызывается UI из своей корутины. */
suspend fun checkConnection(config: AgentConfig): ConnectionReport =
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 ─────
@@ -334,11 +357,21 @@ class AppState(
}
}
fun renameConversation(id: String, title: String?) {
val conn = activeAgentId?.let { registry.connection(it) } ?: return
fun renameConversation(
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 {
runCatching { conn.renameConversation(id, title) }.onFailure { reportError(it) }
val result = runCatching { conn.renameConversation(id, title) }
result.onFailure { reportError(it, "Не удалось переименовать") }
refresh()
onResult(result.map { })
}
}
@@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import pw.binom.agentik.desktop.ui.ClientImageSupport
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import java.util.Base64
private enum class ImageLoadState { Loading, Ready, Error }
@@ -41,6 +42,7 @@ fun ChatImage(src: String, alt: String?) {
// Декодер не зарегистрирован — fallback: alt (или ссылка) текстом.
Text(
text = alt?.takeIf { it.isNotBlank() } ?: src,
color = AgentikTheme.colors.text,
modifier = Modifier.widthIn(max = 280.dp),
)
return
@@ -18,12 +18,9 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -38,52 +35,55 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import pw.binom.agentik.desktop.ui.theme.AgentikColors
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
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.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.markdown.MarkdownSegment
import pw.binom.agentik.desktop.markdown.TextStyleSpan
import pw.binom.agentik.desktop.markdown.parseMarkdown
/** Скругление у блоков (код, таблица). Геометрия, а не тема — поэтому константа. */
private val RoundedBlockShape = RoundedCornerShape(8.dp)
/** Ширина колонки таблицы ≈ [TableCharWidth] на символ, но не уже [TableMinColumnWidth]. */
private val TableCharWidth = 9.dp
private val TableMinColumnWidth = 60.dp
// ─────────────────────────────────────────────────────────────
// Public rendering entry point
// ─────────────────────────────────────────────────────────────
/**
* Parse markdown and render segments with the given base text style.
* Разбирает markdown и рисует сегменты стилем [style] (по умолчанию — текущий
* стиль темы из [LocalMarkdownStyle]).
*/
@Composable
fun renderMarkdownSegments(
content: String,
textColor: Color,
fontSize: TextUnit,
isBubbleScrolled: Boolean = false,
onUrlClick: (String) -> Unit = {},
style: MarkdownStyle = LocalMarkdownStyle.current,
) {
val baseStyle = SpanStyle(color = textColor, fontSize = fontSize)
val colors = AgentikTheme.colors
val segments = parseMarkdown(content)
val segments = remember(content) { parseMarkdown(content) }
var previousWasText = false
for (segment in segments) {
when (segment) {
is MarkdownSegment.Text -> {
val annotated = segment.toAnnotatedString(baseStyle, colors)
val annotated = segment.toAnnotatedString(style)
if (annotated.text.isNotEmpty()) {
// Между соседними абзацами (последовательными текстовыми сегментами)
// оставляем вертикальный отступ, иначе абзацы склеиваются в сплошной блок.
if (previousWasText) {
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(style.paragraphSpacing))
}
clickableMarkdownText(
annotatedString = annotated,
lineHeight = style.bodyLineHeight,
modifier = Modifier
.then(if (isBubbleScrolled) Modifier.widthIn(max = 600.dp) else Modifier),
.then(if (isBubbleScrolled) Modifier.widthIn(max = style.maxContentWidth) else Modifier),
onUrlClick = onUrlClick,
)
previousWasText = true
@@ -92,76 +92,79 @@ fun renderMarkdownSegments(
is MarkdownSegment.CodeBlock -> {
previousWasText = false
val clipboardManager = LocalClipboardManager.current
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(style.blockSpacing))
Column(
modifier = Modifier
.then(
if (isBubbleScrolled) Modifier.widthIn(max = 600.dp)
if (isBubbleScrolled) Modifier.widthIn(max = style.maxContentWidth)
else Modifier.fillMaxWidth()
)
.clip(RoundedCornerShape(8.dp))
.background(colors.codeBackground),
.clip(RoundedBlockShape)
.background(style.codeBackground),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(AgentikTheme.colors.panelElevated)
.padding(horizontal = 12.dp, vertical = 6.dp),
.background(style.codeHeaderBackground)
.padding(
horizontal = style.codeHeaderPaddingHorizontal,
vertical = style.codeHeaderPaddingVertical,
),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = segment.language ?: "Code",
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = colors.textDim,
fontSize = style.labelFontSize,
fontFamily = style.monospaceFamily,
color = style.labelColor,
)
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Скопировать",
modifier = Modifier
.size(16.dp)
.size(style.copyIconSize)
.clickable {
clipboardManager.setText(AnnotatedString(segment.code))
},
tint = colors.textDim,
tint = style.labelColor,
)
}
Box(
modifier = Modifier
.padding(12.dp)
.padding(style.codePadding)
.horizontalScroll(rememberScrollState()),
) {
Text(
text = segment.code,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
color = colors.codeText,
fontFamily = style.monospaceFamily,
fontSize = style.codeFontSize,
color = style.codeTextColor,
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(style.blockSpacing))
}
is MarkdownSegment.Table -> {
previousWasText = false
renderTable(segment, textColor, fontSize, isBubbleScrolled)
renderTable(segment, style, isBubbleScrolled)
}
is MarkdownSegment.Image -> {
previousWasText = false
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(style.blockSpacing))
ChatImage(src = segment.src, alt = segment.alt)
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(style.blockSpacing))
}
is MarkdownSegment.ThematicBreak -> {
previousWasText = false
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(style.blockSpacing))
Box(
modifier = Modifier
.fillMaxWidth()
.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
private fun renderTable(
segment: MarkdownSegment.Table,
textColor: Color,
fontSize: TextUnit,
style: MarkdownStyle,
isBubbleScrolled: Boolean,
) {
Spacer(modifier = Modifier.height(6.dp))
Spacer(modifier = Modifier.height(style.tableSpacing))
val columnWidths = remember(segment) {
val columnCount = segment.headers.size
@@ -203,15 +205,14 @@ private fun renderTable(
Column(
modifier = Modifier
.then(if (isBubbleScrolled) Modifier.wrapContentWidth() else Modifier.fillMaxWidth())
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.3f))
.clip(RoundedBlockShape)
.background(style.tableSurface)
) {
if (isBubbleScrolled) {
TableRows(
segment = segment,
columnWidths = columnWidths,
textColor = textColor,
fontSize = fontSize,
style = style,
)
} else {
Box(
@@ -223,30 +224,27 @@ private fun renderTable(
TableRows(
segment = segment,
columnWidths = columnWidths,
textColor = textColor,
fontSize = fontSize,
style = style,
)
}
}
}
}
Spacer(modifier = Modifier.height(6.dp))
Spacer(modifier = Modifier.height(style.tableSpacing))
}
@Composable
private fun TableRows(
segment: MarkdownSegment.Table,
columnWidths: List<Int>,
textColor: Color,
fontSize: TextUnit,
style: MarkdownStyle,
) {
// Header row
TableRow(
cells = segment.headers,
widths = columnWidths,
isHeader = true,
textColor = textColor,
fontSize = fontSize,
style = style,
)
// Data rows
segment.rows.forEachIndexed { idx, row ->
@@ -254,8 +252,7 @@ private fun TableRows(
cells = row,
widths = columnWidths,
isHeader = false,
textColor = textColor,
fontSize = fontSize,
style = style,
rowIndex = idx,
)
}
@@ -266,14 +263,13 @@ private fun TableRow(
cells: List<String>,
widths: List<Int>,
isHeader: Boolean,
textColor: Color,
fontSize: TextUnit,
style: MarkdownStyle,
rowIndex: Int = 0,
) {
val bgColor = when {
isHeader -> MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
isHeader -> style.tableHeaderBackground
rowIndex % 2 == 0 -> Color.Transparent
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.03f)
else -> style.tableStripeBackground
}
Row(
modifier = Modifier
@@ -283,13 +279,19 @@ private fun TableRow(
cells.forEachIndexed { i, cell ->
Box(
modifier = Modifier
.width((widths.getOrElse(i) { 10 } * 9).dp.coerceAtLeast(60.dp))
.padding(horizontal = 8.dp, vertical = 6.dp),
.width(
(TableCharWidth * widths.getOrElse(i) { 10 })
.coerceAtLeast(TableMinColumnWidth)
)
.padding(
horizontal = style.tableCellPaddingHorizontal,
vertical = style.tableCellPaddingVertical,
),
) {
Text(
text = cell,
color = textColor,
fontSize = fontSize,
color = style.textColor,
fontSize = style.tableFontSize,
fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
softWrap = true,
)
@@ -306,6 +308,7 @@ private fun TableRow(
private fun clickableMarkdownText(
annotatedString: AnnotatedString,
modifier: Modifier = Modifier,
lineHeight: TextUnit = TextUnit.Unspecified,
onUrlClick: (String) -> Unit = {},
) {
val urlAnnotations = annotatedString.getStringAnnotations("URL", 0, annotatedString.length)
@@ -317,6 +320,7 @@ private fun clickableMarkdownText(
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = annotatedString,
lineHeight = lineHeight,
modifier = modifier.pointerInput(annotatedString) {
detectTapGestures { offset ->
val layout = textLayoutResult ?: return@detectTapGestures
@@ -334,7 +338,7 @@ private fun clickableMarkdownText(
onTextLayout = { textLayoutResult = it },
)
} 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
// ─────────────────────────────────────────────────────────────
/**
* Текст сегмента → [AnnotatedString].
*
* База ([baseStyle], по умолчанию [MarkdownStyle.bodySpan]) накладывается на ВЕСЬ
* текст, а не только на размеченные спаны: парсер заводит [TextStyleSpan] лишь
* для жирного/курсива/кода/ссылок, поэтому «простой» текст без базы оставался
* без цвета и рисовался цветом по умолчанию (чёрным) — это и был баг «текст
* чёрный вместо белого».
*/
fun MarkdownSegment.Text.toAnnotatedString(
baseStyle: SpanStyle = SpanStyle(),
colors: AgentikColors = darkAgentikColors,
style: MarkdownStyle,
baseStyle: SpanStyle = style.bodySpan(),
): AnnotatedString {
val builder = AnnotatedString.Builder(text)
if (text.isNotEmpty()) {
builder.addStyle(baseStyle, 0, text.length)
}
for (span in spans) {
val style = buildSpanStyle(span, colors)
val merged = baseStyle.merge(style)
builder.addStyle(merged, span.start, span.end)
val spanStyle = buildSpanStyle(span, style)
builder.addStyle(baseStyle.merge(spanStyle), span.start, span.end)
val link = span.link
if (link != null) {
builder.addStringAnnotation("URL", link, span.start, span.end)
@@ -360,23 +375,23 @@ fun MarkdownSegment.Text.toAnnotatedString(
return builder.toAnnotatedString()
}
private fun buildSpanStyle(span: TextStyleSpan, colors: AgentikColors): SpanStyle {
var style = SpanStyle()
if (span.bold) style = style.copy(fontWeight = FontWeight.Bold)
if (span.italic) style = style.copy(fontStyle = androidx.compose.ui.text.font.FontStyle.Italic)
private fun buildSpanStyle(span: TextStyleSpan, style: MarkdownStyle): SpanStyle {
var out = SpanStyle()
if (span.bold) out = out.copy(fontWeight = FontWeight.Bold)
if (span.italic) out = out.copy(fontStyle = FontStyle.Italic)
if (span.code) {
style = style.copy(
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
background = colors.panelElevated,
out = out.copy(
fontFamily = style.monospaceFamily,
fontSize = style.inlineCodeFontSize,
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) {
style = style.copy(
color = colors.accent,
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
out = out.copy(
color = style.linkColor,
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
internal fun DialogFooter(content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp, start = 18.dp, end = 18.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp, start = 18.dp, end = 18.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) { this.content() }
}
@@ -22,6 +22,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.Icon
import androidx.compose.material3.HorizontalDivider
@@ -106,8 +107,34 @@ fun ChatPane(
size = 36.dp,
)
Column(Modifier.weight(1f)) {
Text(agentName, color = colors.text, fontSize = 14.sp)
val conversationTitle = state.activeConversation?.title?.takeIf { it.isNotBlank() }
?: "Без названия"
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) {
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
private fun MarkdownContent(content: List<UiContent>, agentColor: Color) {
val colors = AgentikTheme.colors
Column {
content.forEach { part ->
when (part) {
is UiContent.Text -> renderMarkdownSegments(
content = part.body,
textColor = colors.text,
fontSize = 13.5.sp,
)
is UiContent.Text -> renderMarkdownSegments(content = part.body)
is UiContent.Image -> {
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.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
@@ -10,6 +11,9 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
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),
)
private val LocalAgentikColors = staticCompositionLocalOf { darkAgentikColors }
/**
* Контракт темы приложения.
*
* Все параметры, которые в 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 markdown: MarkdownStyle
val dark: Boolean
/** Доступ к семантическим цветам из экранов: `AgentikTheme.colors.panel`. */
object AgentikTheme {
companion object {
/** Активная тема (то, что положили в [ProvideAgentikTheme]). */
val current: AgentikTheme
@Composable
@ReadOnlyComposable
get() = LocalAgentikTheme.current
/** Короткий доступ к [AgentikTheme.colors] — для кода вида `val colors = AgentikTheme.colors`. */
val colors: AgentikColors
@Composable
@ReadOnlyComposable
get() = LocalAgentikColors.current
get() = current.colors
/** Короткий доступ к [AgentikTheme.markdown] — для `renderMarkdownSegments` и т. п. */
val markdown: MarkdownStyle
@Composable
@ReadOnlyComposable
get() = current.markdown
}
}
/**
* Корневой провайдер темы. Оборачивает всё приложение; [themeId] — значение
* из настроек (`ThemeId.DARK` / `ThemeId.LIGHT`).
* Локаль, через которую [AgentikTheme.current] доходит до экранов.
* Объявлен на уровне файла, чтобы [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
fun AgentikTheme(
themeId: String,
content: @Composable () -> Unit,
) {
val dark = themeId != ThemeId.LIGHT
val colors = if (dark) darkAgentikColors else lightAgentikColors
CompositionLocalProvider(LocalAgentikColors provides colors) {
fun ProvideAgentikTheme(theme: AgentikTheme, content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalAgentikTheme provides theme,
// MaterialTheme сам LocalContentColor не подкладывает: без этого любой
// Text без явного color рисовался бы чёрным (дефолт локала), а не текстом темы.
LocalContentColor provides theme.colors.text,
LocalMarkdownStyle provides theme.markdown,
) {
MaterialTheme(
colorScheme = colors.toMaterialScheme(dark = dark),
colorScheme = theme.colors.toMaterialScheme(dark = theme.dark),
content = content,
)
}
@@ -1,6 +1,6 @@
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 kotlin.test.Test
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.Event
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.proto.Agent
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.Message
import pw.binom.agentik.proto.MessageContext
import pw.binom.agentik.content.MessageContext
import pw.binom.agentik.server.agentikAgent
import kotlin.test.AfterTest
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 onlineOutbox = InMemoryOnlineOutbox()
private val store: MutableConversationStore = InMemoryMutableConversationStore()
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 {
val id = "conv-${++seq}"
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
runBlocking {
store.upsert(ConversationRecord(id, null, temp, now, now))
@@ -239,6 +244,7 @@ private class EchoConversation(
override val id: String,
private val journal: MutableJournalStore,
private val outbox: MutableOutboxStore,
private val onlineOutbox: MutableOnlineOutbox,
private val store: MutableConversationStore,
createdAt: Instant,
private val interruptible: Boolean,
@@ -277,10 +283,15 @@ private class EchoConversation(
override suspend fun send(content: List<Content>, context: MessageContext?) {
val now = Clock.System.now()
val userId = nextMsgId(id, "user")
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) {
// Имитация долгого LLM-цикла: крутимся, пока не придёт interrupt().
@@ -290,19 +301,21 @@ private class EchoConversation(
val text = content.filterIsInstance<Content.Text>().joinToString("") { it.body }
val reply = "эхо: $text"
emit(now, Event.StartResponse(now, Event.ResponseType.TEXT))
emit(now, Event.AppendText(now, reply))
onlineOutbox.tryAppendOnline(id, OnlineEvent.StartResponse(now, OnlineEvent.ResponseType.TEXT))
onlineOutbox.tryAppendOnline(id, OnlineEvent.AppendText(now, reply))
val endAt = Clock.System.now()
val assistantId = nextMsgId(id, "assistant")
journal.append(
MessageRecord.AssistantMessage(
nextMsgId(id, "assistant"),
assistantId,
id,
listOf(pw.binom.agentik.journal.Content.Text(reply)),
listOf(pw.binom.agentik.content.Content.Text(reply)),
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
store.touch(id, endAt)
@@ -317,9 +330,9 @@ private class EchoConversation(
}
/** `: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) {
is Content.Text -> pw.binom.agentik.journal.Content.Text(c.body)
is Content.Image -> pw.binom.agentik.journal.Content.Image(c.data, c.mime)
is Content.Text -> pw.binom.agentik.content.Content.Text(c.body)
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 pw.binom.agentik.desktop.model.UiMessage
import pw.binom.agentik.desktop.persistence.ConversationMetaRepository
import pw.binom.agentik.journal.Content as JournalContent
import pw.binom.agentik.content.Content as JournalContent
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OnlineEvent
import pw.binom.agentik.outbox.OnlineOutbox
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.Message
import pw.binom.agentik.proto.MessageContext
import pw.binom.agentik.content.MessageContext
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@@ -74,6 +76,7 @@ class ChatSessionTest {
val cache = KsqliteJournalStore(connection)
val meta = ConversationMetaRepository(connection)
val outbox = FakeOutboxStore()
val online = FakeOnlineOutbox()
var remote = FakeJournalStore()
val conversation = FakeConversation("conv-1")
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -85,6 +88,7 @@ class ChatSessionTest {
journalCache = cache,
meta = meta,
outbox = outbox,
onlineOutbox = online,
scope = scope,
)
@@ -157,11 +161,12 @@ class ChatSessionTest {
try {
session.start()
f.outbox.awaitSubscriber()
f.online.awaitSubscriber()
f.outbox.emit(Event.Working(at(10)))
f.outbox.emit(Event.StartResponse(at(11), Event.ResponseType.TEXT))
f.outbox.emit(Event.AppendText(at(12), "Hel"))
f.outbox.emit(Event.AppendText(at(13), "lo"))
f.online.emit(OnlineEvent.Working(at(10)))
f.online.emit(OnlineEvent.StartResponse(at(11), OnlineEvent.ResponseType.TEXT))
f.online.emit(OnlineEvent.AppendText(at(12), "Hel"))
f.online.emit(OnlineEvent.AppendText(at(13), "lo"))
await { session.live.value.size == 1 }
assertTrue(session.live.value.single() is UiMessage.Assistant)
@@ -169,9 +174,11 @@ class ChatSessionTest {
assertEquals("Hello", (session.live.value.single() as UiMessage.Assistant)
.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.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 }
assertTrue(session.live.value.isEmpty())
@@ -196,7 +203,9 @@ class ChatSessionTest {
// стоял ровно на 5 мс — отсюда отступ назад на 1 мс + отсечение
// известных id вместо опоры на время.
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 }
// Порядок при равных timestamp'ах задаёт id (контракт SQL
@@ -216,9 +225,10 @@ class ChatSessionTest {
try {
session.start()
f.outbox.awaitSubscriber()
f.online.awaitSubscriber()
f.outbox.emit(Event.StartResponse(at(10), Event.ResponseType.TEXT))
f.outbox.emit(Event.AppendText(at(11), "частичный"))
f.online.emit(OnlineEvent.StartResponse(at(10), OnlineEvent.ResponseType.TEXT))
f.online.emit(OnlineEvent.AppendText(at(11), "частичный"))
await { session.live.value.size == 1 }
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(
override val id: String,
override val isSupportImageInput: Boolean = true,
@@ -5,6 +5,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OnlineEvent
import kotlin.time.Clock
import kotlin.time.Instant
@@ -13,22 +14,28 @@ class LiveTurnTest {
private val now = Clock.System.now()
private fun at() = Instant.fromEpochMilliseconds(now.toEpochMilliseconds())
private fun LiveTurn.feed(vararg events: Event) {
events.forEach { apply(it) }
private fun LiveTurn.feed(vararg events: Any) {
events.forEach {
when (it) {
is Event -> apply(it)
is OnlineEvent -> applyOnline(it)
else -> error("unexpected event: $it")
}
}
}
@Test
fun `full happy path accumulates reasoning and answer`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartReasoning(at()),
Event.AppendText(at(), "сначала "),
Event.AppendText(at(), "подумаем"),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.AppendText(at(), "Привет, "),
Event.AppendText(at(), "мир!"),
Event.End(at()),
OnlineEvent.Working(at()),
OnlineEvent.StartReasoning(at()),
OnlineEvent.AppendText(at(), "сначала "),
OnlineEvent.AppendText(at(), "подумаем"),
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
OnlineEvent.AppendText(at(), "Привет, "),
OnlineEvent.AppendText(at(), "мир!"),
OnlineEvent.End(at()),
)
assertEquals(TurnPhase.DONE, turn.phase)
@@ -45,11 +52,11 @@ class LiveTurnTest {
fun `tool call and result are stitched into one block`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
OnlineEvent.Working(at()),
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
Event.ToolCall(at(), id = "t1", title = "grep", toolName = "grep", toolArgs = "{\"q\":\"x\"}"),
Event.ToolResult(at(), toolCallId = "t1", toolName = "grep", result = "found 3"),
Event.End(at()),
OnlineEvent.End(at()),
)
val tools = turn.blocks.filterIsInstance<LiveBlock.Tool>()
@@ -65,8 +72,8 @@ class LiveTurnTest {
fun `orphan tool result creates a block`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
OnlineEvent.Working(at()),
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
Event.ToolResult(at(), toolCallId = "missing", toolName = null, result = "ok"),
)
@@ -80,7 +87,7 @@ class LiveTurnTest {
fun `tool failed marks the block`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
OnlineEvent.Working(at()),
Event.ToolCall(at(), id = "t9", title = null, toolName = "bash", toolArgs = "{}"),
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`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.AppendText(at(), "partial"),
OnlineEvent.Working(at()),
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
OnlineEvent.AppendText(at(), "partial"),
Event.Error(at(), message = "llm down", code = "E500"),
)
@@ -111,9 +118,9 @@ class LiveTurnTest {
fun `interrupted turn is terminal and keeps partial text`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.AppendText(at(), "не до конца"),
OnlineEvent.Working(at()),
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
OnlineEvent.AppendText(at(), "не до конца"),
Event.Interrupted(at()),
)
@@ -126,11 +133,11 @@ class LiveTurnTest {
fun `text after tool call starts a new answer block`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.AppendText(at(), "до"),
OnlineEvent.Working(at()),
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
OnlineEvent.AppendText(at(), "до"),
Event.ToolCall(at(), id = "t1", title = null, toolName = "grep", toolArgs = "{}"),
Event.AppendText(at(), "после"),
OnlineEvent.AppendText(at(), "после"),
)
val answers = turn.blocks.filterIsInstance<LiveBlock.Answer>()
@@ -145,10 +152,10 @@ class LiveTurnTest {
val bytes = byteArrayOf(1, 2, 3)
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.IMAGE),
Event.AppendImage(at(), body = bytes, mime = "image/png"),
Event.End(at()),
OnlineEvent.Working(at()),
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.IMAGE),
OnlineEvent.AppendImage(at(), body = bytes, mime = "image/png"),
OnlineEvent.End(at()),
)
assertTrue(turn.blocks.filterIsInstance<LiveBlock.Answer>().isEmpty())
@@ -161,12 +168,12 @@ class LiveTurnTest {
fun `working resets the previous turn`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.AppendText(at(), "старое"),
Event.End(at()),
OnlineEvent.Working(at()),
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
OnlineEvent.AppendText(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("новое", turn.answerText())
@@ -175,7 +182,7 @@ class LiveTurnTest {
@Test
fun `append without explicit start response still streams as answer`() {
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("без старта", turn.answerText())
@@ -186,7 +193,7 @@ class LiveTurnTest {
val turn = LiveTurn()
var count = 0
turn.onChange = { count++ }
turn.feed(Event.Working(at()), Event.AppendText(at(), "x"))
turn.feed(OnlineEvent.Working(at()), OnlineEvent.AppendText(at(), "x"))
assertEquals(2, count)
}
@@ -195,13 +202,13 @@ class LiveTurnTest {
fun `preview text collapses whitespace and truncates`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.AppendText(at(), "Первая строка\n\nвторая строка "),
OnlineEvent.Working(at()),
OnlineEvent.AppendText(at(), "Первая строка\n\nвторая строка "),
)
assertEquals("Первая строка вторая строка", turn.previewText())
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)
assertTrue(preview.length <= 31)
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.runComposeUiTest
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.ui.theme.AgentikColors
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
import pw.binom.agentik.desktop.ui.theme.lightAgentikColors
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -80,14 +81,10 @@ class ThemeRenderTest {
@Test
fun codeBlockPaintsThemeCodeBackgroundInLightTheme() = runComposeUiTest {
setContent {
AgentikTheme("light") {
ProvideAgentikTheme("light".toAgentikTheme()) {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) {
Column {
renderMarkdownSegments(
content = "```kotlin\nval x = 1\n```",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
)
renderMarkdownSegments(content = "```kotlin\nval x = 1\n```")
}
}
}
@@ -116,14 +113,10 @@ class ThemeRenderTest {
@Test
fun darkThemeStillRendersCodeBlockDark() = runComposeUiTest {
setContent {
AgentikTheme("dark") {
ProvideAgentikTheme("dark".toAgentikTheme()) {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) {
Column {
renderMarkdownSegments(
content = "```kotlin\nval x = 1\n```",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
)
renderMarkdownSegments(content = "```kotlin\nval x = 1\n```")
}
}
}
@@ -6,9 +6,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.ui.renderMarkdownSegments
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
import kotlin.test.Test
import kotlin.test.assertTrue
@@ -38,12 +39,10 @@ class RealSpeechIT {
// 2. skiko/Compose инициализируется и реально рисует (как окно приложения).
runComposeUiTest {
setContent {
AgentikTheme("dark") {
ProvideAgentikTheme("dark".toAgentikTheme()) {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble)) {
renderMarkdownSegments(
content = "проверка: **окно** уже отрисовано",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
)
}
}