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