Compare commits

...

3 Commits

Author SHA1 Message Date
subochev 2c71542b13 Десктоп-клиент: Compose-UI, сессии, SQLite-кэш, голос, живые тесты
- settings/: settings.json (агенты, тема, clientId), атомарная запись
- persistence/: ConversationMetaRepository — группы, lastSeen, превью,
  watermark синхронизации (synced_at через PRAGMA table_info)
- session/: AgentRegistry (параллельное подключение, деградация в офлайн),
  AgentConnection (журнал + мета + живой список диалогов conversationsFlow,
  refresh по SSE-потоку), ChatSession (history + backfill + live + preview,
  дедуп по id, 1 мс шаг watermark, syncMutex), LiveTurn
- UI: рейка агентов, список диалогов (живое обновление), чат со стримингом,
  композер (Enter отправляет, Shift+Enter переносит, Ctrl+M — голос),
  окна «Агенты»/«Группы»/«Новый диалог», тёмная и светлая темы
- voice/: mic-api → Silero VAD → Vosk на одном выделенном потоке;
  VoiceNative.warmUp() до skia (иначе SIGSEGV), модель в артефакте
- markdown/: вендоренный рендер из ai/assistent
- ошибки в баннер через AppState.reportError (CancellationException игнорируется)
- тесты 103 (реальная SQLite + фейковый транспорт); против живого агента —
  RealServerIT, RealAppStateIT, RealSpeechIT
- LIVE-RUN-NOTES.md: живой прогон в GUI и найденные/починенные баги
2026-09-28 10:08:10 +03:00
subochev 27f349550c Макеты — один sketches/index.html; approved/ удалён
- sketches/index.html: все экраны одним документом в порядке 1·… (14–19 — бывшие approved)
- approved/ (4 файла групп + 3 макета + README) удалён как дубль
- стили вынесены в sketches/style.css
- README, THEMES, BORROW-FROM-ASSISTENT, MARKDOWN-SOURCE, MIC-ASR-SEARCH
  приведены к фактическому состоянию
2026-09-28 10:07:57 +03:00
subochev 960cd60120 Каркас сборки десктопа: KMP (jvm), Compose Multiplatform, зависимости из :agentik
- только jvm-таргет; composite includeBuild("../agentik")
- mavenLocal() первым в репозиториях: локальные публикации перекрывают Nexus
- Compose 1.7.3 + Kotlin 2.4.20, compose.desktop.currentOs
- вендоренный markdown (ru.otpbank.ai:markdown), mic-api/asr-vosk/vad
- gradle wrapper, gradle.properties, .gitignore (локальный тулинг)
2026-09-28 10:07:52 +03:00
87 changed files with 12224 additions and 4448 deletions
+30
View File
@@ -0,0 +1,30 @@
.gradle/
build/
# Kotlin/KMP session metadata
.kotlin/
!gradle/wrapper/gradle-wrapper.jar
# IntelliJ
.idea/
*.iml
*.ipr
*.iws
# Eclipse
.classpath
.project
.settings/
bin/
# VS Code
.vscode/
# macOS
.DS_Store
# Local config
local.properties
# Локальный тулинг
.cortexkit/
.veai/
+4 -2
View File
@@ -78,7 +78,9 @@ compose.desktop {
## 2. Отрисовка Markdown
Отдельный документ: **`MARKDOWN-SOURCE.md`** — 4 файла, разобрано подробно.
**Уже скопировано в проект** (28.09.2026): 6 файлов + 11 тестов парсера, пакеты
переименованы под `pw.binom.agentik.desktop.*`. Подробности, карта «источник →
назначение», зависимости и как пересинхронизировать — в **`MARKDOWN-SOURCE.md`**.
## 3. Приёмы отрисовки (самое ценное)
@@ -343,7 +345,7 @@ val DarkClientTheme = ClientTheme(
| Берём | Не берём |
|---|---|
| Версии и плагины, репозитории | Архитектуру и слои |
| Отрисовку Markdown (4 файла) | Экраны целиком (`ChatScreen`, `SettingsDialog`, …) |
| Отрисовку Markdown (6 файлов, уже скопированы) | Экраны целиком (`ChatScreen`, `SettingsDialog`, …) |
| Приёмы: таблицы, ленивый список, автоскролл, полоса прокрутки | Модули `chat-*` и модели сообщений |
| Кликабельные ссылки, копирование кода, выделение текста | `MessageBubble` |
| Приём записи звука (формат 16к/моно, `javax.sound`) | `ChatViewModel`, `config/*`, `cache/*` |
+94
View File
@@ -0,0 +1,94 @@
# Живой прогон: что нашли и починили (28.09.2026)
Первый прогон десктопа не на фейках, а на живом `:standalone`-агенте
(Qwen через vLLM на 192.168.88.135) с настоящим GUI: окно Compose,
клики/печать через `ydotool`, проверка скриншотов.
## Что проверено вживую
| Шаг | Результат |
| --- | --- |
| Приложение подключается к агенту | шапка: «Локальный агент» + зелёное «на связи» |
| Список диалогов | подтягивается с сервера (33 диалога), поиск/время/аватары на месте |
| «+» (новый диалог) | сервер вернул новый диалог, счётчик на сервере 33 → 34 |
| Отправка сообщения | текст ушёл, ответ LLM пришёл в UI, в истории обе реплики |
| Голос (`Ctrl+M`) | «идёт запись» (микрофон красный) → повторное нажатие → «Речь не распознана», **без падения JVM** |
Ход агента при этом длинный: ответ занял 0.5 с, а один разбор с длинным
промптом — 144 с; это важно для бага №1 ниже.
## Починенные баги
### 1. `:client`: `POST /messages` умирал по таймауту 15 с (upstream)
Сервер отвечает на `POST /conversations/{id}/messages` только по завершении
всего хода агента (LLM + тулы), а это минуты. Клиент шёл с дефолтным
request-timeout Ktor → любая длинная реплика обрывалась на 15-й секунде
(`RealServerIT` показал реальные 144 с).
Фикс в `agentik/client/.../ConversationClient.kt`: у запроса отправки
снят таймаут чтения (`noReadTimeout()`, тот же приём, что для SSE).
Регрессионный тест — в `RealServerIT`.
### 2. Конфликт skiko и нативных ASR-библиотек → SIGSEGV
Найдено при добавлении тестов голоса: если onnxruntime/Vosk инициализируются
**после** skia, JVM падает в C++-логах. В проде это был бы краш при первом
нажатии микрофона (окно-то уже нарисовано). Подробности и таблица порядка
загрузки — в `MIC-ASR-SEARCH.md` §8. Фикс: `VoiceNative.warmUp()` в `main()`
до `application {}`, плюс `forkEvery = 1` в тестах.
### 3. Список диалогов не обновлялся никогда
`AgentConnection.listConversations()` читал `agent.conversationStore.list(...)`
один раз — а встроенный кэш либы наполняется асинхронно (`listFlow`), так что
даже «один раз» приходилось на пустой кэш. Итог: сервер отдаёт 33 диалога, а в
UI список пуст, и обновить его было нечем.
Фикс: `AgentConnection.conversationsFlow` — опрос встроенного кэша с
warmup-циклом на старте + подписка на SSE-поток (`outbox.agentEvents`) для
появления/удаления диалогов, созданных другими клиентами, + явный `refresh()`
после своих create/delete/rename.
### 4. Enter не отправлял сообщение, хотя подсказка это обещала
Поле многострочное, Enter вставлял перевод строки. Фикс: `onPreviewKeyEvent`
в композере — Enter отправляет, Shift+Enter переносит строку (тогда подсказка
«отправка Enter» становится правдой).
### 5. Клик по нижней/верхней части поля ввода не фокусировал его
`BasicTextField` высотой в строку внутри `Box` с паддингами: клик мимо самой
строки текста фокуса не давал. Фикс: клик по всему боксу запрашивает фокус
(`clickable` + `focusRequester`).
### 6. Ложный красный баннер «StandaloneCoroutine was cancelled»
`refresh()` отменяет предыдущий job, а `runCatching { ... }.onFailure { error = it.message }`
показывал отмену корутины как ошибку пользователя. Фикс: `AppState.reportError()`
игнорирует `CancellationException` (все 11 мест переведены на него).
### 7. Микрофон с клавиатуры
Мышиную кнопку микрофона удобно не всегда, а для проверки голоса без мыши —
критично: добавлен `Ctrl+M` из чата (и подсказка обновлена).
## Как повторить
```bash
# 1. живой агент (в фоне, из папки проекта)
cd /home/subochev/common-projects/WORK/ai/agentik/standalone
AGENTIK_SKILLS_DIR=./agentik/skills AGENTIK_SYSTEM_PROMPT='Ты полезный ассистент.' \
OPENAI_BASE_URL=http://192.168.88.135:8001/v1 AGENTIK_DB_PATH=./agentik/db.db \
OPENAI_CONTEXT_WINDOW=115000 AGENTIK_MEMORY_DIR=./agentik/memory \
OPENAI_LLM_BACKEND=openai AGENTIK_PORT=8080 OPENAI_API_KEY=... \
OPENAI_MODEL=... AGENTIK_SOUL=./agentik/SOUL.md ./gradlew run
# 2. десктоп (окно)
cd /home/subochev/common-projects/WORK/ai/agentik-desktop
AGENTIK_DESKTOP_HOME=/tmp/opencode/ak-real ./gradlew run
# 3. интеграционные тесты против живого агента
AGENTIK_E2E_URL=http://127.0.0.1:8080/agentik ./gradlew jvmTest \
--tests '*RealServerIT' --tests '*RealAppStateIT' --tests '*RealSpeechIT'
```
+98 -112
View File
@@ -1,150 +1,136 @@
# Откуда брать отрисовку Markdown
# Отрисовка Markdown: откуда взято и где лежит
Готовый рендер Markdown для Compose уже написан и лежит в репозитории **`ai/assistent`**.
Писать своё не надо — надо взять это.
**Статус: УЖЕ СКОПИРОВАНО В ПРОЕКТ (28.09.2026).** Код вендорен в `src/`,
собирается, тесты парсера (11 шт.) зелёные. Своё писать не надо.
**Репозиторий:** https://git.binom.pw/ai/assistent (ветка `main`)
**Проверено на коммите:** `2c5b3f9` (файлы добавлены в `1acced4`, 2026-08-13)
**Важно:** репозиторий приватный. Браузерная ссылка откроется только у залогиненного;
для копирования файлов из скрипта — RAW-адреса с логином/паролем (ниже).
Источник — приватный репозиторий **`ai/assistent`** (https://git.binom.pw/ai/assistent,
ветка `main`, проверено на коммите `2c5b3f9`, файлы добавлены в `1acced4` 2026-08-13).
Пакеты переименованы под наш проект; внешняя зависимость — одна
(`ru.otpbank.ai:markdown`).
---
## Что именно копировать: 4 файла
## Куда что легло
Всё лежит в двух модулях. Модуль `client-shared` — общий, собирается под **JVM + Compose**
(то есть ровно наш случай, десктоп), не под Android.
| В assistent | У нас |
|---|---|
| `client-shared/.../shared/markdown/MarkdownSegment.kt` | `src/jvmMain/kotlin/pw/binom/agentik/desktop/markdown/MarkdownSegment.kt` |
| `client-shared/.../shared/markdown/MarkdownParserImpl.kt` | `src/jvmMain/kotlin/pw/binom/agentik/desktop/markdown/MarkdownParserImpl.kt` |
| `client-shared/.../shared/ui/MarkdownRendering.kt` | `src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/MarkdownRendering.kt` |
| `client-shared/.../shared/ui/ChatImage.kt` | `src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/ChatImage.kt` |
| `client-shared/.../shared/ClientImageSupport.kt` | `src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/ClientImageSupport.kt` |
| `client/.../client/ui/MarkdownRenderer.kt` | `src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/MarkdownRenderer.kt` |
| `client-shared/src/test/.../markdown/MarkdownParserTest.kt` | `src/jvmTest/kotlin/pw/binom/agentik/desktop/markdown/MarkdownParserTest.kt` |
### 1. Модель данных — `MarkdownSegment.kt` (59 строк)
Переименование пакетов:
```
client-shared/src/main/kotlin/pw/binom/client/shared/markdown/MarkdownSegment.kt
```
- `pw.binom.client.shared.markdown` → `pw.binom.agentik.desktop.markdown`
- `pw.binom.client.shared.ui` → `pw.binom.agentik.desktop.ui`
- `pw.binom.client.shared` (`ClientImageSupport`) → `pw.binom.agentik.desktop.ui`
- `pw.binom.client.ui` (`MarkdownRenderer`) → `pw.binom.agentik.desktop.ui`
Что внутри: типы кусков разметки — текст, блок кода, таблица, картинка, разделитель.
Плюс описание стилевого участка: жирный, курсив, код, зачёркнутый, ссылка.
Зависимостей от Compose нет вообще — чистые данные.
### Почему файлов шесть, а не четыре
### 2. Парсер — `MarkdownParserImpl.kt` (353 строки)
Изначально в доке значились 4 файла. При сборке выяснилось, что
`MarkdownRendering.kt` для сегментов-картинок вызывает `ChatImage(...)`, а тот —
`ClientImageSupport`. Поэтому взяты ещё `ChatImage.kt` и `ClientImageSupport.kt`.
Оба независимы от `chat-*` и `MessageBubble`.
```
client-shared/src/main/kotlin/pw/binom/client/shared/markdown/MarkdownParserImpl.kt
```
Точка входа: `parseMarkdown(markdown: String): List<MarkdownSegment>`.
Единственная внешняя зависимость — библиотека парсера `ru.otpbank.ai:markdown` (см. ниже).
От Композа не зависит.
### 3. Отрисовка — `MarkdownRendering.kt` (377 строк)
```
client-shared/src/main/kotlin/pw/binom/client/shared/ui/MarkdownRendering.kt
```
Точка входа:
`renderMarkdownSegments(content, textColor, fontSize, isBubbleScrolled, onUrlClick)`.
Внутри: текст с inline-стилями, кликабельные ссылки, блоки кода с копированием,
таблицы, картинки, разделители. Самый жирный файл и самая нужная часть.
### 4. Десктопная обёртка — `MarkdownRenderer.kt` (25 строк)
```
client/src/main/kotlin/pw/binom/client/ui/MarkdownRenderer.kt
```
Открытие ссылок в системном браузере (`Desktop.getDesktop().browse`) и
`parseMultiline`. Модуль `client` в assistent — это **десктопный** клиент на
Compose Desktop, так что пример использования ровно наш.
---
## Что копировать НЕ надо
- `client-shared/src/main/kotlin/pw/binom/client/shared/ui/MessageBubble.kt` (789 строк) —
пузырь сообщения целиком. Тянет за собой модуль `chat-common` (модели сообщений),
из плюшек — выделение текста и контекстное меню. Для отрисовки разметки не нужен,
но полезен, если понадобится пример.
**Границы заимствования — подробнее в `BORROW-FROM-ASSISTENT.md`.** Коротко:
из assistent берём приёмы отрисовки, плагины и версии; архитектуру, слои и готовые
экраны (`ChatScreen`, `SettingsDialog`, модули `chat-*`) — не берём, у нас своё.
---
## Зависимости
Одна, и она есть в нашем Nexus:
`ClientImageSupport` — это реестр: платформенный клиент при старте регистрирует
`decoder` (байты → `ImageBitmap`) и, при желании, `fetcher` и `serverBaseUrl`.
Пока `decoder` не зарегистрирован, картинки деградируют в текст (alt/ссылку) —
ничего не падает. На десктопе декодер регистрируется так:
```kotlin
implementation("ru.otpbank.ai:markdown:0.51.0")
```
Репозиторий в `build.gradle.kts` (без него не соберётся):
```kotlin
maven {
url = uri("http://192.168.76.117/repository/developerspace-prod-mvn-hosted/")
isAllowInsecureProtocol = true
ClientImageSupport.decoder = { bytes ->
org.jetbrains.skia.Image.makeFromEncoded(bytes).toComposeImageBitmap()
}
```
Проверено: в Nexus лежит, версии с `0.1.0` по `0.51.0`, файл `markdown-0.51.0.pom`
отдаёт `HTTP 200`.
## Точки входа
Из Compose нужны: `compose.material3`, `compose.foundation`, `compose.ui`,
`compose.materialIconsExtended` (иконка копирования в блоках кода).
- Парсинг: `parseMarkdown(markdown: String): List<MarkdownSegment>` (в `...desktop.markdown`).
- Отрисовка: `renderMarkdownSegments(content, textColor, fontSize, isBubbleScrolled, onUrlClick)` (в `...desktop.ui`).
- Открытие ссылок/`parseMultiline`: `...desktop.ui.MarkdownRenderer`.
## Опубликован ли `client-shared` как библиотека
## Зависимости и сборка (уже настроено)
**Нет.** Проверено поиском в Nexus (`pw/binom/client-shared`, `pw/binom/chat-common`) —
`HTTP 404`. Поэтому вариантов два: либо копируем исходники к себе (4 файла выше),
либо сперва публикуем модуль. Копирование проще.
`gradle/libs.versions.toml`:
---
```toml
compose-multiplatform = "1.7.3" # версия из assistent; собирается на нашем Kotlin 2.4.20
markdown = "0.51.0"
## Как копировать (RAW-адреса, работают с логином)
markdown = { module = "ru.otpbank.ai:markdown", version.ref = "markdown" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
compose = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
```
Адрес вида `/raw/branch/main/<путь>` отдаёт файл. Проверено: `HTTP 200`, 1805 байт —
совпадает с локальным размером.
`build.gradle.kts` — плагины `kotlin-compose` + `compose`, зависимости
`compose.foundation` / `compose.ui` / `compose.material3` /
`compose.materialIconsExtended` (иконка копирования) + `libs.markdown`.
`compose.desktop.currentOs` пока НЕ подключён — он понадобится, когда появится
настоящее окно (`compose.desktop { application { mainClass = ... } }`), для
компиляции рендера он не нужен.
`settings.gradle.kts` — репозитории (без них `ru.otpbank.ai:markdown` не найдётся):
```kotlin
maven {
name = "developerspace"
url = uri("http://192.168.76.117/repository/developerspace-prod-mvn-hosted/")
setAllowInsecureProtocol(true)
}
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
maven("https://maven.google.com")
```
Проверено: `./gradlew compileKotlinJvm` — OK; `./gradlew jvmTest` —
`MarkdownParserTest` 11/11, `ConversationMetaRepositoryIT` 14/14.
## Как пересинхронизировать, если в assistent что-то поправят
RAW-адрес: `/raw/branch/main/<путь>` (нужен логин, репозиторий приватный).
```bash
BASE=https://git.binom.pw/ai/assistent/raw/branch/main
AUTH='subochev:ПАРОЛЬ' # подставить перед запуском
AUTH='subochev:ПАРОЛЬ'
for f in \
client-shared/src/main/kotlin/pw/binom/client/shared/markdown/MarkdownSegment.kt \
client-shared/src/main/kotlin/pw/binom/client/shared/markdown/MarkdownParserImpl.kt \
client-shared/src/main/kotlin/pw/binom/client/shared/ui/MarkdownRendering.kt \
client-shared/src/main/kotlin/pw/binom/client/shared/ui/ChatImage.kt \
client-shared/src/main/kotlin/pw/binom/client/shared/ClientImageSupport.kt \
client/src/main/kotlin/pw/binom/client/ui/MarkdownRenderer.kt ; do
curl -sS -u "$AUTH" "$BASE/$f" -o "$(basename "$f")"
curl -sS -u "$AUTH" "$BASE/$f" -o "/tmp/$(basename "$f")"
done
```
Браузерные ссылки для чтения глазами (нужен залогиненный git.binom.pw):
После копирования — тем же `sed`-ом переименовать пакеты (см. таблицу выше),
`MarkdownRenderer` дополнительно сослать `renderMarkdownSegments` на
`pw.binom.agentik.desktop.ui`.
```
https://git.binom.pw/ai/assistent/src/branch/main/client-shared/src/main/kotlin/pw/binom/client/shared/markdown/MarkdownSegment.kt
https://git.binom.pw/ai/assistent/src/branch/main/client-shared/src/main/kotlin/pw/binom/client/shared/markdown/MarkdownParserImpl.kt
https://git.binom.pw/ai/assistent/src/branch/main/client-shared/src/main/kotlin/pw/binom/client/shared/ui/MarkdownRendering.kt
https://git.binom.pw/ai/assistent/src/branch/main/client/src/main/kotlin/pw/binom/client/ui/MarkdownRenderer.kt
```
---
## Что придётся поправить после копирования
1. **Пакеты.** Файлы лежат в `pw.binom.client.shared.*` — переименовать под наш проект
(или оставить как есть, если не смущает).
2. **`openUrl`.** В `MarkdownRenderer` открытие ссылок идёт через `java.awt.Desktop` —
на десктопе это работает, ничего менять не надо.
3. **Стиль.** Цвета текста и блоков кода в `MarkdownRendering` берутся из аргументов и
`MaterialTheme` — под нашу тёмную тему подставить свои значения.
## Что этот рендер умеет (по коду)
## Что рендер умеет (по коду)
- **Текст** с участками: жирный, курсив, код, зачёркнутый, ссылки.
- **Блоки кода** — с названием языка и кнопкой копирования.
- **Таблицы** — заголовки и строки, с горизонтальной прокруткой.
- **Картинки** — `data:`-ссылки и обычные `http(s)`. Первые рисуются сразу из памяти.
- **Блоки кода** — с названием языка и кнопкой копирования (`LocalClipboardManager`).
- **Таблицы** — заголовки/строки, переключаются на горизонтальный скролл по
`isBubbleScrolled` (иначе Compose падает на бесконечной maxWidth).
- **Картинки** — `data:` и `http(s)`; рисуются через `ChatImage`/`ClientImageSupport`.
- **Разделители** (`---`).
- **Кликабельные ссылки** — нажатие уходит в `onUrlClick`, оттуда открываем браузер.
- **Кликабельные ссылки** — нажатие уходит в `onUrlClick`.
## Известный баг на будущее
В assistent в `MessageBubble.kt:153` (НЕ скопирован) `hasTable` вызывает
`parseMarkdown` вторым проходом до основной отрисовки — двойной парс на пузырь.
У нас `MessageBubble` свой, при его написании в 0-й версии просто звать
`renderMarkdownSegments` один раз; кэшировать разбор через
`remember(record.id, record.text)`.
## Что НЕ брали
- `MessageBubble.kt` — тянет `chat-common` и чужие модели сообщений.
- Модули `chat-*`, `ChatScreen`, `ChatViewModel`, `SettingsDialog` и т. п.
- Границы заимствования — в `BORROW-FROM-ASSISTENT.md`.
+81
View File
@@ -1,5 +1,12 @@
# Микрофон и распознавание: результаты поиска
> **Статус: реализовано** (см. `src/jvmMain/.../desktop/voice/`).
> `mic-api` + `asr-vosk` + `vad` подключены в `gradle/libs.versions.toml`,
> кнопка микрофона в чате активна: `VoiceController` (микрофон → VAD →
> распознавание), `VoskVoiceRecognizer` (Vosk-модель и `silero_vad.onnx` лежат
> внутри артефактов — качать нечего). Ниже — исходный поиск, из которого это
> выросло.
**Задача была:** найти готовые библиотеки для работы с микрофоном и распознавания,
прежде чем брать код из `ai/assistent`. **Код не пишем** — только поиск.
@@ -172,3 +179,77 @@ client.post("$base/audio/transcriptions") { ... } // multipart, как у Open
| Нужны ли сторонние зависимости для звука? | **Нет.** Всё на стандартной Java (`javax.sound.sampled`) и sherpa-onnx для нейронок. |
**Код не писан** — только поиск, как и просили.
---
## 7. Что в итоге подключено
| Слой | Артефакт | Где в коде |
|---|---|---|
| Микрофон | `pw.binom.mic:mic-api:1.0.0` | `voice/VoiceController.kt` (`MicSource` → `MicAudio.record`) |
| Детектор речи | `pw.binom.voice.vad:vad:2` | `voice/VoiceRecognizer.kt` (`VadInstance`) |
| Распознавание | `pw.binom.asr:asr-vosk:4` (+ `asr-api:4`) | `voice/VoiceRecognizer.kt` (`VoskVoiceRecognizer`) |
| Сборка тракта | — | `voice/VoiceController.kt` (`VoiceState`: IDLE/PREPARING/RECORDING/RECOGNIZING) |
| Склейка с UI | — | `ui/AppState.kt` (`voiceState`, `voicePartial`, `composerDraft`), `ui/screens/ChatPane.kt` |
| Прогрев до skia | — | `voice/VoiceNative.kt` (`warmUp()` из `main()`) |
Тесты: `voice/AudioTest.kt` (s16le→float, нарезка окон VAD + реальная загрузка
`silero_vad.onnx`), `voice/VoiceControllerTest.kt` (фейковые микрофон и
распознаватель), `voice/VoskVoiceRecognizerIT.kt` (настоящие Vosk + VAD на
тишине), `voice/RealSpeechIT.kt` (настоящая русская речь после инициализации
skia — см. ниже).
---
## 8. ⚠️ Конфликт skiko и нативных библиотек (найдено 28.09.2026)
**Симптом.** Приложение Compose Desktop роняется с SIGSEGV (не исключение —
падение всего JVM) при первом нажатии микрофона. В `hs_err_pid`:
```
C [libc.so.6+0x184fdc]
C [libstdc++.so.6+0x15d820] std::ostream::_M_insert<double>
C [jnaNNNN.tmp+0x6838e7] kaldi::ParseOptions::RegisterSpecific(...)
j org.vosk.LibVosk.vosk_model_new(...)
```
То же самое — с ONNX (`OrtSession.createSession` → `std::ostream::_M_insert`):
C++-библиотека пишет в свой лог-стрим, а стрим оказывается невалидным.
**Причина — порядок загрузки.** skiko (нативный рендер Compose, грузит
`~/.skiko/<hash>/libskiko-linux-x64.so`) и C++-стек распознавания (Vosk/Kaldi,
onnxruntime) конфликтуют, если второй инициализируется ПОСЛЕ первого. Первое
создание модели/сессии после инициализации skia валит JVM.
**Проверено пробниками (позже удалены):**
| Порядок | Результат |
|---|---|
| ONNX → skia (raster) → ONNX | ок |
| skia (Compose-текст) → ONNX | **SIGSEGV** |
| skia (Compose-текст) → Vosk | **SIGSEGV** |
| **Vosk → skia (Compose-текст) → Vosk** | **ок** |
**Фикс.** `VoiceNative.warmUp()` вызывается в `main()` **до** `application {}`:
создаёт и закрывает `Stt.vosk(...)` и `VadInstance(...)`. После этого и свежий
распознаватель, и свежий VAD, созданные уже после отрисовки окна, работают.
Побочный плюс — модель Vosk (~46 МБ, ~2 с) грузится на старте, а не при первом
клике. Если прогрев не удался, `AppState.voiceAvailable = false` — кнопка
микрофона заблокирована (лучше «недоступно», чем краш).
**Следствие для тестов.** `jvmTest` форкает JVM на каждый тест-класс
(`forkEvery = 1`): иначе skiko-тесты (`ThemeRenderTest`) отравляют
нативные тесты голоса в том же JVM. `RealSpeechIT` проверяет ровно
продуктовый порядок: `warmUp()` → рендер Compose → создание распознавателя →
распознавание реального сэмпла (`src/jvmTest/resources/voice/raz-dva-tri.wav`
→ «раз два три четыре пять»).
Заметки на будущее:
- Создание распознавателя стоит секунды (загрузка модели Vosk), поэтому
распаковка zip в tmp кэшируется самим `asr-vosk`, а в UI есть состояние
«загружаю модель».
- `VadInstance.close()` **не идемпотентен** (второй `close()` роняет JVM через
ONNX `double free`) — закрывать ровно один раз.
+129 -107
View File
@@ -1,131 +1,153 @@
# agentik-desktop — дизайн десктопного клиента
# agentik-desktop — десктопный клиент агента agentik
Десктопный клиент агента **agentik** (Jetpack Compose Desktop). UI общается с агентом
через опубликованную библиотеку `pw.binom.agentik:client` — она даёт готовые
`Agent` / `Conversation`, живой поток событий и `interrupt()` для кнопки «Стоп».
Клиент агента **agentik** на Kotlin Multiplatform + **Compose Desktop** (цель сборки —
только JVM). UI общается с агентом через готовую библиотеку `pw.binom.agentik:client`:
она даёт `Agent` / `Conversation`, живой поток событий (SSE), авто-reconnect,
кэш списка диалогов и `interrupt()` для кнопки «Стоп».
Это пока **только дизайн**, не код приложения.
Проект уже **собирается и запускается** (`./gradlew run`), а не только спроектирован.
## Что где лежит
**`approved/` — принятое. Это решение, на него опираемся в реализации.**
```
approved/001-sidebar-utility.html # ОСНОВА: привычный мессенджер
approved/004-settings-agents.html # настройки агентов + проверка связи
approved/005-new-chat-picker.html # модалка «Новый диалог»
approved/006-groups-manage/ # группы: 4 файла по состояниям
approved/006-groups-manage/1-list.html # Группы: изменить, удалить, добавить
approved/006-groups-manage/2-add.html # новая группа
approved/006-groups-manage/3-edit.html # изменение и удаление
approved/006-groups-manage/4-delete.html # удаление: диалоги остаются
approved/README.md # что одобрено и когда
sketches/index.html # ВСЕ экраны одним документом: список диалогов, чат, запись,
# группы, агенты, проверка связи, пустые состояния.
# Это и черновик, и «одобренное» — отдельного approved/ больше нет.
sketches/style.css # палитра и стили макетов (цвета числами — так и надо, это макет)
src/jvmMain/kotlin/... # код приложения
src/jvmTest/kotlin/... # тесты
```
**`sketches/` — предложения. Не решение, а варианты и черновики.**
Документы рядом:
- `REQUIREMENTS.md` — требования (накиданы, ни один пункт не обязателен в текущей форме).
- `STORAGE.md` — что и где хранится на клиенте (SQLite + JSON).
- `CACHE.md` — кэш сообщений: что есть в библиотеке, схема «только новое».
- `THEMES.md` — цвета не пишем в коде, только из семантической схемы.
- `MARKDOWN-SOURCE.md` — откуда взята отрисовка Markdown.
- `BORROW-FROM-ASSISTENT.md` — что берём из `ai/assistent`, а что нет.
- `MIC-ASR-SEARCH.md` — библиотеки микрофона/распознавания (`mic-kmp`, `asr-kmp`, `vad-kmp`).
## Структура кода
```
sketches/002-rail-voice-first/index.html # голос — главное действие
sketches/003-three-pane-command/index.html # три панели + состояние агента
sketches/004-settings-agents/index.html # черновик настроек агентов
sketches/005-new-chat-picker/index.html # черновик модалки нового диалога
pw.binom.agentik.desktop
├── Main.kt # точка входа: окно, реестр, состояние
├── settings/ # AppPaths, AppSettings/AgentConfig, SettingsRepository
├── persistence/ # ConversationMetaRepository (группы/lastSeen/preview/watermark)
├── session/ # LiveTurn, ChatSession, AgentConnection, AgentRegistry
├── model/ # UiModels + Mappers (журнал/стрим → UI)
├── media/ # AvatarStorage (копия картинки агента)
├── net/ # ConnectionChecker (проверка связи)
├── markdown/ # вендоренный парсер Markdown (из assistent)
└── ui/ # App, AppState, theme/, components/, screens/
```
`BORROW-FROM-ASSISTENT.md` — что берём из `ai/assistent` (приёмы, плагины, версии),
а что не берём (архитектура, экраны, дизайн — своё).
`MIC-ASR-SEARCH.md` — библиотеки для микрофона и распознавания: своё готовое
(`mic-kmp`, `asr-kmp`, `vad-kmp`), вместо копирования кода из assistent.
`STORAGE.md` — где что хранится: **хранилище делаем абстракцией, реализация —
SQLite, настройки — JSON.** Три отдельные абстракции (сообщения / настройки /
снимок списка), почему SQLite, что менять в API (превью и число непрочитанных).
`CACHE.md` — кэш сообщений на клиенте: что для него есть в библиотеке, схема
загрузки «только новое», где может порваться.
`THEMES.md` — цветовые темы: цвета не пишем в коде, берём из схемы. Тем будет
несколько, поэтому ни одного цвета числом.
`MARKDOWN-SOURCE.md` — откуда брать готовую отрисовку Markdown (файлы и адреса).
`REQUIREMENTS.md` — требования. Статус: накидываем, ни один пункт не обязателен
к исполнению в том виде, как записан.
Ключевые решения по слоям — в `STORAGE.md`; кратко:
## Одобренное
- **Транспорт целиком в `:client`.** HTTP/JSON/SSE/Bearer, reconnect, кэш списка
диалогов — не переписываем.
- **Сообщения** — `KsqliteJournalStore` из `:journal-ksqlite` (offline-история).
- **Группы, `lastSeen`, превью, watermark синхронизации** — наш
`ConversationMetaRepository` (сервер про это не знает).
- **Отдельная SQLite-база на агента**: id диалогов серверные, у двух агентов
могут совпасть; плюс группы локальны для агента.
`approved/` — макеты, которые пользователь **принял**. Это решение, на него
опираемся в реализации. Отличие от `sketches/`: там предложения, здесь принятое.
## Текущее состояние реализации
- **`001-sidebar-utility.html`** — **основа дизайна**: привычный мессенджер,
список диалогов + чат, папки («Все чаты / Работа / Дом»). Выбран как основа.
- **`004-settings-agents.html`** — настройки агентов: список, форма нового,
проверка связи отдельным окном поверх. Одобрена 2026-09-19.
- **`005-new-chat-picker.html`** — модалка «Новый диалог» при нескольких
ассистентах. Одобрена 2026-09-19.
- **`006-groups-manage/`** — управление группами, четыре файла по состояниям.
Одобрена 2026-09-19. Пояснительной панели над окном в ней нет
(в `sketches/005` была — это была заметка для проверки, не часть интерфейса).
Готово и покрыто тестами:
**Правило:** одобренное живёт **только** в `approved/`. Если макет приняли —
он переезжает туда, а не остаётся «выбранным» среди вариантов.
- **Настройки** — `settings.json` (агенты, тема, выбранная группа, последний диалог),
`clientId` генерируется один раз. Атомарная запись.
- **Живой список диалогов** — `AgentConnection.conversationsFlow` опрашивает
встроенный кэш либы (`conversationStore.listFlow`, warmup-цикл на старте) и
дополнительно подписан на SSE-поток агента: диалог, созданный с другого
клиента (или самим сервером), появляется в списке сам; свои создания и
удаления отражаются сразу. Ошибки сервера не считаются «пустым списком» —
последний известный список сохраняется.
- **Реестр агентов** — добавление/правка/удаление, подключение, деградация в
«не отвечает» без падения приложения.
- **Стриминг** — `LiveTurn` применяет события `Working → StartReasoning →
AppendText → ToolCall/ToolResult → End/Interrupted/Error`; `ChatSession`
догоняет историю (`after = synced_at`), пишет в локальный кэш, склеивает
live + history, обновляет превью и `lastSeen`.
- **История/UI-модели** — мапперы `MessageRecord → UiMessage` со склейкой
`ToolCall`+`ToolResult` в одну карточку.
- **Группы** — CRUD групп + раскладка диалогов (каскад: удаление группы не
удаляет диалоги).
- **Тема** — семантические цвета (`AgentikTheme.colors`), тёмная + светлая
схемы; отдельно пользовательские цвета агентов.
- **Markdown** — вендоренный рендер из `ai/assistent` (см. `MARKDOWN-SOURCE.md`).
- **Аватары** — копирование выбранного файла в `~/.agentik-desktop/avatars/`.
- **Проверка связи** — «ответ сервера» + создание/удаление временного диалога,
с таймингами.
- **UI (Compose)** — рейка агентов, список диалогов (поиск, папки, бейджи),
чат с живым стримингом, композер, окна «Агенты», «Группы» и «Новый диалог»
(при нескольких агентах — выбор, при одном — сразу), пустые состояния,
адаптивная раскладка (узкое окно — список или чат).
- **Голос** — микрофон → VAD → распознавание (`voice/`): кнопка микрофона
или `Ctrl+M` из чата запускает запись (`mic-api`), Silero VAD (`vad-kmp`) отмечает наличие речи,
Vosk (`asr-vosk`, модель внутри артефакта) отдаёт промежуточный текст над
полем ввода, финал дописывается в черновик. Тишина текстом не становится.
Нативные модели считаются на одном выделенном потоке. На старте `main()`
делает [`VoiceNative.warmUp()`] — обязательный прогрев Vosk/ONNX ДО
инициализации skia/Compose (иначе создание модели после отрисовки окна
роняет JVM в C++-логах; подробности в `MIC-ASR-SEARCH.md`).
- **Реальная интеграция** — интеграционные тесты гоняются против живого
`:standalone`-агента (Qwen через vLLM), а не только in-memory-фейков:
`RealServerIT` (диалог → отправка → живой ответ LLM → локальный кэш) и
`RealAppStateIT` (весь `AppState`: реестр → подключение → живой список →
создание диалога → стриминг → превью/lastSeen), `RealSpeechIT` (речь:
прогрев нативных ASR → инициализация Compose → распознавание реального wav
→ «раз два три четыре пять»). См. `AGENTIK_E2E_URL`.
Проверено вживую (окно приложения + живой агент): список диалогов подтягивается
с сервера, кнопка «+» создаёт диалог, сообщение уходит и ответ приходит в UI,
`Ctrl+M` переводит микрофон в запись и обратно без падения JVM.
Не начато / заглушки:
- **Свёрнутый вид хода работы агента** — сейчас живой ход показывается
развёрнуто (reasoning, карточки ToolCall/ToolResult, спиннер). Как его
складывать — вопрос дизайна, не решён (REQUIREMENTS §9).
- **Кусок прерванного ответа в кэше** — R39 выполнен (прерванный ответ в
историю не пишется), а вот хранить ли его обрывок в кэше — вопрос не решён
(REQUIREMENTS §9).
- **Светлая тема визуально не проверялась глазами** (`THEMES.md`); в
`ui/ThemeRenderTest.kt` проверено программно: фон код-блоков берётся из
слота темы, порядок яркостей в светлой палитре корректный.
- **Голос: движок зашит на Vosk** (`VoskVoiceRecognizer`) — Qwen3/Whisper из
`asr-kmp` подключаются заменой `VoiceRecognizerFactory`; выбор движка/модели
в настройках не сделан.
## Как запустить
```bash
./gradlew run # окно приложения
./gradlew jvmTest # тесты (реальная SQLite, фейковый транспорт)
# Интеграционные тесты против живого агента (по умолчанию пропускаются):
AGENTIK_E2E_URL=http://127.0.0.1:8080/agentik ./gradlew jvmTest \
--tests '*RealServerIT' --tests '*RealAppStateIT'
./gradlew jvmJar # собрать jar
```
## Что решено
- **Сначала десктоп, затем Android — по тем же лекалам.** Десктопный клиент
делаем образцом: понятный, удобный и **читаемый по коду**. Когда получится —
Android повторяет те же решения. Поэтому цель — максимально простой код,
который пользователь открывает и понимает.
- **Основа — вариант 1** (`approved/001-sidebar-utility.html`). Остальные остаются рядом как
источник идей.
- Стиль — тёмный, из уже принятой темы клиента assistent: фон `#121218`,
панели `#17212B`, акцент `#6AB2F2`, текст `#EBEBEB`.
- **Тем будет несколько — цвета берём из цветовой схемы, не пишем числом в коде.**
Имена по смыслу («фон», «панель», «акцент»), чтобы смена темы ничего не ломала.
В макетах числа — это нормально. Разбор и список того, что забывают: `THEMES.md`.
- **Узкое окно** — на экране что-то одно: либо список диалогов, либо чат.
Переключение кнопкой «Назад», как в Телеграме. Широкое — обе части сразу.
- **Подсказка о горячих клавишах** под полем ввода убрана.
- **Счётчиков токенов и кольца заполнения не будет.** В библиотеке `client`
таких данных нет вообще: ни модели, ни отправлено/получено, ни предела
контекста. Правило: нет данных — нет цифры.
- **Несколько агентов** — предусмотреть. Различение: **цвет или картинка** на выбор.
Цвет помечает диалоги агента (полоска у строки, точка у группы); картинка
показывается кружком вместо цвета. **Картинку клиент копирует себе**, а не берёт
по исходному адресу — иначе значок пропадёт вместе с файлом.
- **Markdown** — показывать разметкой, а не сырыми символами. Готовый рендер
лежит в репозитории `ai/assistent` — писать свой не надо, брать оттуда.
Точные координаты файлов — в `MARKDOWN-SOURCE.md`.
- **Папки над списком диалогов** («Все чаты», «Работа», «Дом») — часть выбранного
варианта 1, не терять их при доработках.
- **Проверка связи с агентом** — в отдельном окне поверх настроек: идёт → отвечает
→ не отвечает, с подробностями и подсказкой. Добавить агента можно только
после успешной проверки.
- **Новый диалог** — при нескольких ассистентах спрашиваем, в каком создавать
(диалог живёт внутри ассистента, отдельно его не создать). Один ассистент —
окна нет, создаётся сразу. Ноль ассистентов — кнопки «+» нет вообще.
## Заимствования: только приёмы, не архитектура
Из репозитория `ai/assistent` берём **приёмы отрисовки, плагины и версии**.
Архитектуру, слои, готовые экраны и окна — **не берём**: дизайн у нас свой,
нарисованный (см. `sketches/`). Разбор по пунктам — в `BORROW-FROM-ASSISTENT.md`,
там же таблица «берём / не берём».
## Как смотреть
Открыть в браузере любой из файлов — каждый самодостаточный, без сборки.
В четвёртом сверху переключатель состояний: список агентов, форма, проверка
связи (успех и ошибка), список диалогов с двумя агентами.
- **Сначала десктоп, затем Android — по тем же лекалам.** Десктоп делаем
образцом: понятный и читаемый по коду. Потом Android повторяет решения.
- **Несколько агентов** — различение цветом или картинкой (выбирает пользователь).
Картинку клиент **копирует себе**, а не берёт по адресу.
- **Markdown** — показывать разметкой. Готовый рендер взят из `ai/assistent`.
- **Папки над списком диалогов** — сохранены.
- **Проверка связи** — отдельным окном, с таймингами шагов.
- **Ориентир по размеру:** ~9 небольших кусков логики поверх библиотеки, а не
собственная инфраструктура (см. `STORAGE.md`, `CACHE.md`).
## Что предстоит решить
1. **Запись** — по нажатию (нажал — говоришь — нажал) или на удержание.
1. **Запись** — по нажатию или на удержание.
2. Форма показа хода работы агента в свёрнутом виде.
3. Различение агентов: одного цвета может оказаться мало.
4. Где хранится список агентов.
5. Нужны ли папки для диалогов, как в мобильном клиенте, или хватит поиска.
6. **Где хранится кэш сообщений** — файл или лёгкая база (см. `CACHE.md`).
7. **Что делать с куском прерванного ответа** в кэше — иначе схема даст сбой
на первом же нажатии «Стоп» (`CACHE.md`, п. 3.2).
## Что сознательно не решается здесь
- **Откуда берётся распознавание речи.** Отдельная тема, к интерфейсу не
относится: в дизайне показано только место кнопки и что происходит на экране
во время записи. Кто именно превращает голос в текст — решается потом.
Готовые библиотеки уже найдены, см. `MIC-ASR-SEARCH.md`.
4. **Что делать с куском прерванного ответа** в кэше (`CACHE.md`, п. 3.2).
5. Где показывать несколько агентов в одном списке диалогов (сейчас список —
у активного агента).
+33
View File
@@ -131,6 +131,39 @@ Text(
Разбор — `BORROW-FROM-ASSISTENT.md`, п. 3.10.
## Токены тёмной темы (на ней строится палитра)
Это **эталонный набор** — какие цвета и с какими именами есть в тёмной теме.
В макетах (`sketches/`, `approved/`) они стоят числами в `:root { --token: #... }`,
в коде приложения — через `MaterialTheme.colorScheme.*`. Значения **согласованы
с Android-клиентом** (`agentik-android/design/BORROW.md:39`), плюс расширения,
которых в десктопной основе не было.
| Токен | Назначение | Значение | Откуда |
|---|---|---|---|
| `--bg` | фон окна | `#121218` | основа |
| `--panel` | панели (шапки, боковая) | `#17212B` | основа |
| `--panel2` | «приподнятые» элементы на панели | `#1C2733` | Android |
| `--line` | разделители и рамки полей | `#232B36` | Android |
| `--fg` | основной текст | `#EBEBEB` | основа |
| `--dim` | приглушённый текст (время, подписи) | `#768C9E` | основа |
| `--accent` | главный акцент | `#6AB2F2` | основа |
| `--badge` | фон кружка непрочитанных | `#5EB5F7` | Android |
| `--err` | ошибки, опасные зоны, запись | `#EC3942` | основа |
| `--ok` | «на связи», успех проверки | `#63C88A` | Android |
| `--user-bubble` | пузырь сообщения пользователя | `#2B5278` | Android |
| `--bot-bubble` | пузырь сообщения ассистента | `#1C2733` | Android |
| `--a1`..`--a5` | **пользовательские** цвета агентов | `#6AB2F2` `#86C99A` `#C9A886` `#B79AE0` `#E28C8C` | Android |
**Важно про `--a1`..`--a5`:** это **не токены темы**. Они заданы для набора
по умолчанию и подобраны под тёмную тему. В коде это **пользовательские**
значения из настроек агента — кладутся в конфиг и читаются как `agent.color`,
а не через `MaterialTheme.colorScheme`. Иначе при смене темы агенты перестанут
отличаться друг от друга. Подробности — раздел «Особый случай» выше.
**Цвет текста на аксентном фоне:** на `--accent` (`#6AB2F2`) и `--badge`
(`#5EB5F7`) текст берётся **тёмным** (`#0F1922`), иначе не читается.
## Что осталось решить
- **Какие темы будут кроме тёмной.** Пока известно только: тёмная есть, светлая
-413
View File
@@ -1,413 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Вариант 1 — Sidebar / Utility</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #121218; --panel: #17212B; --panel2: #1C2733; --line: #232B36;
--fg: #EBEBEB; --dim: #768C9E; --accent: #6AB2F2; --badge: #5EB5F7;
--err: #EC3942; --user-bubble: #2B5278; --bot-bubble: #1C2733;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: #0A0A0F; color: var(--fg); line-height: 1.45;
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px;
}
.window {
width: 1180px; height: 740px; background: var(--bg); border-radius: 12px;
display: flex; overflow: hidden; border: 1px solid var(--line);
box-shadow: 0 24px 80px rgba(0,0,0,.6);
}
/* ---------- левая колонка: список чатов ---------- */
.sidebar { width: 320px; background: var(--panel); border-right: 1px solid var(--line); display: flex; flex-direction: column; }
.sb-head { padding: 14px 16px 10px; }
.sb-title { font-size: 17px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
.icon-btn {
width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; display: grid; place-items: center; font-size: 17px;
}
.icon-btn:hover { background: var(--panel2); color: var(--fg); }
.search {
margin: 10px 16px 8px; padding: 8px 12px; border-radius: 9px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); font-size: 13px; width: calc(100% - 32px); outline: none;
}
.search:focus { border-color: var(--accent); }
.search::placeholder { color: var(--dim); }
/* лента папок — как в мобильном клиенте */
.folders { display: flex; gap: 6px; padding: 6px 16px 10px; overflow-x: auto; }
.folder {
padding: 5px 11px; border-radius: 14px; font-size: 12px; color: var(--dim);
background: var(--panel2); cursor: pointer; white-space: nowrap; border: 1px solid transparent;
}
.folder.active { color: var(--fg); border-color: var(--accent); background: rgba(106,178,242,.12); }
.folder .cnt { color: var(--badge); font-weight: 600; margin-left: 4px; }
/* кнопка «Группы» — не папка, а действие: держим справа, отдельно от чипов */
.folder.act { margin-left: auto; background: transparent; color: var(--dim);
display: grid; place-items: center; padding: 5px 9px; font-size: 13px; }
.folder.act:hover { color: var(--fg); background: var(--panel2); }
.chat-list { flex: 1; overflow-y: auto; padding: 4px 8px 12px; }
.chat-row {
display: flex; gap: 11px; padding: 9px 10px; border-radius: 10px; cursor: pointer; align-items: flex-start;
}
.chat-row:hover { background: var(--panel2); }
.chat-row.active { background: #233040; }
.avatar {
width: 42px; height: 42px; border-radius: 50%; flex: 0 0 42px; display: grid; place-items: center;
font-size: 15px; font-weight: 600; color: #0F1922; background: var(--accent);
}
.avatar.g2 { background: #86C99A; } .avatar.g3 { background: #C9A886; } .avatar.g4 { background: #B79AE0; }
.chat-mid { flex: 1; min-width: 0; }
.chat-name { font-size: 14px; font-weight: 500; display: flex; justify-content: space-between; gap: 8px; }
.chat-time { font-size: 11px; color: var(--dim); font-weight: 400; flex: 0 0 auto; }
.chat-prev { font-size: 12.5px; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.badge {
background: var(--badge); color: #0F1922; border-radius: 10px; font-size: 11px; font-weight: 700;
padding: 1px 6px; min-width: 19px; text-align: center;
}
.row-tail { display: flex; flex-direction: column; align-items: flex-end; gap: 5px; flex: 0 0 auto; }
/* ---------- правая часть: чат ---------- */
.main { flex: 1; display: flex; flex-direction: column; }
.chat-head {
position: relative;
height: 58px; flex: 0 0 58px; border-bottom: 1px solid var(--line); display: flex; align-items: center;
gap: 8px; padding: 0 16px;
}
.chat-head .avatar { width: 36px; height: 36px; flex: 0 0 36px; font-size: 13px; }
.head-meta { flex: 1; }
.head-name { font-size: 14.5px; font-weight: 600; }
.head-sub { font-size: 11.5px; color: var(--dim); }
.head-sub .dot { color: #63C88A; }
/* кнопка «назад» — нужна только в узком окне */
.back-btn {
display: none; width: 32px; height: 32px; flex: 0 0 32px; border-radius: 8px;
border: none; background: transparent; color: var(--fg); cursor: pointer;
font-size: 17px; place-items: center;
}
.back-btn:hover { background: var(--panel); }
.messages { flex: 1; overflow-y: auto; padding: 18px 24px; display: flex; flex-direction: column; gap: 12px; }
.msg { max-width: 74%; padding: 9px 13px; border-radius: 13px; font-size: 13.5px; }
.msg.user { align-self: flex-end; background: var(--user-bubble); border-bottom-right-radius: 4px; }
.msg.bot { align-self: flex-start; background: var(--bot-bubble); border-bottom-left-radius: 4px; }
.msg .meta { font-size: 10.5px; color: var(--dim); margin-top: 5px; }
/* блок «рассуждения» и «инструмент» — это специфика агента, не мессенджера */
.think {
align-self: flex-start; max-width: 80%; border-left: 2px solid var(--accent);
background: rgba(106,178,242,.07); padding: 8px 12px; border-radius: 0 10px 10px 0;
font-size: 12.5px; color: var(--dim); cursor: pointer;
}
.think b { color: var(--accent); font-weight: 600; }
.tool {
align-self: flex-start; max-width: 80%; background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: 8px 12px; font-size: 12.5px;
display: flex; align-items: center; gap: 9px;
}
.tool .gear { color: var(--accent); }
.tool code { color: var(--dim); font-size: 11.5px; }
/* ---------- панель ввода с кнопкой записи ---------- */
.composer { border-top: 1px solid var(--line); padding: 12px 16px 14px; background: var(--panel); }
.attach-strip { display: flex; gap: 8px; margin-bottom: 9px; }
.attach-strip:empty { display: none; }
.chip {
background: var(--panel2); border: 1px solid var(--line); border-radius: 8px;
padding: 5px 9px; font-size: 11.5px; color: var(--dim); display: flex; align-items: center; gap: 7px;
}
.chip .x { cursor: pointer; color: var(--err); font-weight: 700; }
.input-row { display: flex; align-items: flex-end; gap: 9px; }
.input-wrap { flex: 1; display: flex; align-items: flex-end; background: var(--bg); border: 1px solid var(--line);
border-radius: 11px; padding: 9px 12px; gap: 9px; }
.input-wrap:focus-within { border-color: var(--accent); }
textarea {
flex: 1; background: transparent; border: none; outline: none; resize: none; color: var(--fg);
font-family: inherit; font-size: 13.5px; line-height: 1.45; max-height: 120px; height: 22px;
}
textarea::placeholder { color: var(--dim); }
/* круглая кнопка записи */
.rec-btn {
width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--line); background: var(--bg);
color: var(--dim); cursor: pointer; display: grid; place-items: center; font-size: 17px; flex: 0 0 40px;
transition: all .18s;
}
.rec-btn:hover { color: var(--fg); border-color: var(--dim); }
.rec-btn.recording { background: var(--err); border-color: var(--err); color: #fff; animation: pulse 1.4s infinite; }
@keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(236,57,66,.55); } 50% { box-shadow: 0 0 0 9px rgba(236,57,66,0); } }
.send-btn {
height: 40px; padding: 0 18px; border-radius: 11px; border: none; background: var(--accent);
color: #0F1922; font-size: 13px; font-weight: 600; cursor: pointer;
}
.send-btn:disabled { background: var(--panel2); color: var(--dim); cursor: default; }
.hint { font-size: 11px; color: var(--dim); margin-top: 8px; display: flex; justify-content: space-between; }
kbd { background: var(--panel2); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; font-size: 10.5px; }
/* запись: полоса над полем */
.rec-panel { display: none; align-items: center; gap: 12px; margin-bottom: 10px;
background: var(--bg); border: 1px solid var(--err); border-radius: 11px; padding: 9px 13px; }
.rec-panel.on { display: flex; }
.wave { display: flex; align-items: center; gap: 3px; height: 22px; flex: 1; }
.wave i { width: 3px; background: var(--err); border-radius: 2px; height: 30%; animation: wv .9s infinite ease-in-out; }
.wave i:nth-child(2n) { animation-delay: .12s; } .wave i:nth-child(3n) { animation-delay: .24s; }
.wave i:nth-child(4n) { animation-delay: .36s; }
@keyframes wv { 0%,100% { height: 22%; } 50% { height: 100%; } }
.rec-time { font-variant-numeric: tabular-nums; font-size: 13px; color: var(--err); font-weight: 600; }
.rec-actions { display: flex; gap: 8px; }
.mini { border: 1px solid var(--line); background: var(--panel2); color: var(--fg); border-radius: 8px;
padding: 5px 11px; font-size: 12px; cursor: pointer; }
.mini.ok { background: var(--accent); border-color: var(--accent); color: #0F1922; font-weight: 600; }
.mini.cancel { color: var(--err); }
/* всплывающее состояние: расшифровка распознанного текста */
.asr-note {
display: none; align-items: center; gap: 8px; font-size: 11.5px; color: var(--dim);
margin-bottom: 8px; padding-left: 2px;
}
.asr-note.on { display: flex; }
.asr-note .live { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
/* ---------- узкое окно: либо список, либо чат ---------- */
.backdrop {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 90;
}
.backdrop.on { display: block; }
@media (max-width: 880px) {
body { padding: 0; }
.window { width: 100vw; height: 100vh; border-radius: 0; border: none; transform: none !important; }
.window.mode-chat .sidebar { display: none; }
.window.mode-list .main { display: none; }
.sidebar { width: 100%; flex: 1 1 auto; border-right: none; }
.back-btn { display: grid; }
}
/* окно целиком влезает на любой экран — не режем картинку */
@media (max-height: 820px) { body { padding: 8px; } .window, .win { transform: scale(.78); } }
@media (max-height: 700px) { .window, .win { transform: scale(.66); } }
@media (max-height: 600px) { .window, .win { transform: scale(.56); } }
</style>
</head>
<body>
<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="Поиск диалога…">
<div class="folders">
<div class="folder active">Все чаты <span class="cnt">3</span></div>
<div class="folder">Работа</div>
<div class="folder">Дом</div>
<div class="folder act" title="Группы: добавить, изменить, удалить">✎</div>
</div>
<div class="chat-list" id="chatList">
<div class="chat-row active">
<div class="avatar">АГ</div>
<div class="chat-mid">
<div class="chat-name"><span>agentik · рабочий</span><span class="chat-time">14:22</span></div>
<div class="chat-prev">Собрал отчёт, жду правок</div>
</div>
<div class="row-tail"><span class="badge">2</span></div>
</div>
<div class="chat-row">
<div class="avatar g2">ОЧ</div>
<div class="chat-mid">
<div class="chat-name"><span>Отчёт за квартал</span><span class="chat-time">12:05</span></div>
<div class="chat-prev">Файл лежит на диске</div>
</div>
</div>
<div class="chat-row">
<div class="avatar g3">РЗ</div>
<div class="chat-mid">
<div class="chat-name"><span>Разбор логов nginx</span><span class="chat-time">вчера</span></div>
<div class="chat-prev">Нашёл 3 ошибки 502</div>
</div>
</div>
<div class="chat-row">
<div class="avatar g4">ПО</div>
<div class="chat-mid">
<div class="chat-name"><span>Помощь по Solana</span><span class="chat-time">вчера</span></div>
<div class="chat-prev">Девайс-нет стартовал</div>
</div>
</div>
</div>
</aside>
<!-- ================= ЧАТ ================= -->
<main class="main">
<header class="chat-head">
<button class="back-btn" id="backBtn" title="Назад к диалогам">←</button>
<div class="avatar">АГ</div>
<div class="head-meta">
<div class="head-name">agentik · рабочий</div>
<div class="head-sub"><span class="dot">●</span> на связи</div>
</div>
<button class="icon-btn" title="Параметры диалога">⋯</button>
</header>
<div class="messages" id="messages">
<div class="msg user">Проверь, почему падает CI в agentik<span class="meta">14:18</span></div>
<div class="think" onclick="this.querySelector('.rest').style.display = this.querySelector('.rest').style.display==='none'?'inline':'none'">
<b>Думает:</b> 42 прогона, красных 40. Смотрю имена тестов… <span class="rest">похоже на проблему кодировки при создании файлов классов.</span>
</div>
<div class="tool"><span class="gear">⚙</span> <code>grep -rnP 'fun `[^`]*[^\x00-\x7F]' --include='*.kt'</code></div>
<div class="msg bot">Нашёл причину: в именах тестов стоит длинное тире «—», а сборка работает в ASCII-кодировке. Отсюда падение компилятора. Починил, CI зелёный.<span class="meta">14:22</span></div>
<div class="msg user">Отлично. Сделай релиз «3»<span class="meta">14:23</span></div>
<div class="think" onclick="this.querySelector('.rest').style.display = this.querySelector('.rest').style.display==='none'?'inline':'none'">
<b>Думает:</b> создаю тег, запускаю публикацию… <span class="rest">проверю, что в Nexus появилась версия 3.</span>
</div>
</div>
<!-- ===== панель ввода ===== -->
<div class="composer">
<div class="attach-strip" id="chips"></div>
<div class="asr-note" id="asrNote">
<span class="live"></span> распознаю: <span id="asrText" style="color:var(--fg)"></span>
</div>
<div class="rec-panel" id="recPanel">
<button class="mini cancel" onclick="stopRec(true)">✕</button>
<div class="wave" id="wave"></div>
<span class="rec-time" id="recTime">0:00</span>
<div class="rec-actions">
<button class="mini ok" onclick="stopRec(false)">Остановить и отправить</button>
</div>
</div>
<div class="input-row" id="inputRow">
<div class="input-wrap">
<textarea id="ta" rows="1" placeholder="Написать агенту…"></textarea>
</div>
<button class="rec-btn" id="recBtn" title="Записать голосовое (Ctrl+R)">🎙</button>
<button class="send-btn" id="sendBtn" disabled>Отправить</button>
</div>
<div class="hint">
<span id="statusHint"></span>
</div>
</div>
</main>
</div>
<script>
const ta = document.getElementById('ta');
const sendBtn = document.getElementById('sendBtn');
const recBtn = document.getElementById('recBtn');
const recPanel = document.getElementById('recPanel');
const inputRow = document.getElementById('inputRow');
const messages = document.getElementById('messages');
const asrNote = document.getElementById('asrNote');
const asrText = document.getElementById('asrText');
const statusHint = document.getElementById('statusHint');
// волна из 28 столбиков
document.getElementById('wave').innerHTML = Array.from({length: 28}, () => '<i></i>').join('');
ta.addEventListener('input', () => {
ta.style.height = '22px';
ta.style.height = Math.min(ta.scrollHeight, 120) + 'px';
sendBtn.disabled = ta.value.trim() === '';
});
function send() {
const t = ta.value.trim(); if (!t) return;
addMsg(t, 'user');
ta.value = ''; ta.style.height = '22px'; sendBtn.disabled = true;
statusHint.textContent = 'агент думает…';
setTimeout(() => {
addMsg('Принято. Выполняю: «' + t + '» — покажу ход в этом же диалоге.', 'bot');
statusHint.textContent = 'готов';
}, 900);
}
function addMsg(text, who) {
const d = document.createElement('div');
d.className = 'msg ' + who;
const now = new Date();
d.innerHTML = text + '<span class="meta">' + now.getHours() + ':' + String(now.getMinutes()).padStart(2,'0') + '</span>';
messages.appendChild(d); messages.scrollTop = messages.scrollHeight;
}
sendBtn.onclick = send;
ta.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
// ---------- запись ----------
let recTimer = null, recSec = 0;
const WORDS = ['проверь','логи','сборки','за','вчерашний','день','и','сделай','вывод'];
function startRec() {
recSec = 0; recPanel.classList.add('on'); recBtn.classList.add('recording');
inputRow.style.opacity = '.45'; statusHint.textContent = 'запись…';
asrNote.classList.add('on'); asrText.textContent = '';
let i = 0;
recTimer = setInterval(() => {
recSec++;
document.getElementById('recTime').textContent = '0:' + String(recSec).padStart(2,'0');
if (i < WORDS.length && recSec % 1 === 0) { // живая расшифровка
asrText.textContent = WORDS.slice(0, ++i).join(' ') + (i < WORDS.length ? ' …' : '');
}
}, 1000);
}
function stopRec(cancel) {
clearInterval(recTimer);
recPanel.classList.remove('on'); recBtn.classList.remove('recording');
inputRow.style.opacity = '1'; asrNote.classList.remove('on');
if (cancel) { statusHint.textContent = 'запись отменена'; return; }
if (recSec < 1) { statusHint.textContent = 'слишком коротко'; return; }
ta.value = WORDS.join(' ') + '.'; // распознанный текст попадает в поле
ta.dispatchEvent(new Event('input'));
statusHint.textContent = 'распознано, ' + recSec + ' с — проверь и отправь';
ta.focus();
}
recBtn.onclick = () => recPanel.classList.contains('on') ? stopRec(false) : startRec();
document.addEventListener('keydown', e => {
if (e.ctrlKey && e.key.toLowerCase() === 'r') { e.preventDefault(); recBtn.click(); }
});
// ---------- широкое / узкое окно ----------
const win = document.querySelector('.window');
const NARROW = 880;
function applyMode() {
if (innerWidth > NARROW) { // широкое: видно и то, и другое
win.classList.remove('mode-chat', 'mode-list');
return;
}
// узкое: показываем что-то одно, по умолчанию список
if (!win.classList.contains('mode-chat') && !win.classList.contains('mode-list')) {
win.classList.add('mode-list');
}
}
document.querySelectorAll('.chat-row').forEach(r => r.addEventListener('click', () => {
document.querySelectorAll('.chat-row').forEach(x => x.classList.remove('active'));
r.classList.add('active');
if (innerWidth <= NARROW) win.classList.remove('mode-list'), win.classList.add('mode-chat');
}));
document.getElementById('backBtn').onclick = () => win.classList.remove('mode-chat'), win.classList.add('mode-list');
addEventListener('resize', applyMode);
applyMode();
</script>
</body>
</html>
-404
View File
@@ -1,404 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Одобрено: Агенты — список, форма, проверка связи</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #121218; --panel: #17212B; --panel2: #1C2733; --line: #232B36;
--fg: #EBEBEB; --dim: #768C9E; --accent: #6AB2F2;
--err: #EC3942; --ok: #63C88A;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: #0A0A0F; color: var(--fg); line-height: 1.45;
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px;
position: relative;
}
/* ============ затемнение: чуть гасит окно под окном проверки ============ */
.scrim { position: fixed; inset: 0; background: rgba(0,0,0,.42); z-index: 200; display: none; }
.scrim.on { display: block; }
/* ============ ОКНО 1: НАСТРОЙКИ ============ */
.dialog {
width: 620px; min-width: 620px; flex: 0 0 auto; max-height: 620px; background: var(--panel);
border: 1px solid var(--line); border-radius: 14px; display: flex; flex-direction: column;
overflow: hidden; box-shadow: 0 26px 80px rgba(0,0,0,.7); position: relative; z-index: 150;
}
.d-head { display: flex; align-items: center; gap: 11px; padding: 16px 18px 14px; border-bottom: 1px solid var(--line); }
.d-head h2 { font-size: 16px; font-weight: 600; flex: 1; }
.x-btn { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 17px; }
.x-btn:hover { background: var(--panel2); color: var(--fg); }
.d-body { flex: 1; overflow-y: auto; padding: 14px 18px 6px; }
.d-foot { padding: 13px 18px 16px; border-top: 1px solid var(--line); display: flex; gap: 9px; }
/* ── список агентов ── */
.agent-card {
display: flex; align-items: center; gap: 12px; padding: 11px 12px; border-radius: 11px;
background: var(--panel2); margin-bottom: 9px; border: 1px solid transparent;
}
.agent-card:hover { border-color: var(--line); }
.avatar {
width: 40px; height: 40px; flex: 0 0 40px; border-radius: 50%; display: grid; place-items: center;
font-size: 13px; font-weight: 600; color: #0F1922; background: var(--accent); overflow: hidden;
}
.avatar.g2 { background: #86C99A; }
.avatar.g3 { background: #C9A886; }
/* значок-картинка: клиент показывает свою копию файла */
.avatar.pic { background: transparent; padding: 0; }
.avatar.pic img { width: 100%; height: 100%; object-fit: cover; display: block; }
.ag-mid { flex: 1; min-width: 0; }
.ag-name { font-size: 14px; font-weight: 550; }
.ag-addr { font-size: 11.5px; color: var(--dim); font-family: ui-monospace, "SF Mono", Menlo, monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
.state { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--ok); flex: 0 0 auto; }
.state .d { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
.state.off { color: var(--dim); } .state.off .d { background: var(--dim); }
.state.err { color: var(--err); } .state.err .d { background: var(--err); }
.card-acts { display: flex; gap: 4px; flex: 0 0 auto; }
.mini-ic { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 14px; }
.mini-ic:hover { background: var(--panel); color: var(--fg); }
.mini-ic.del:hover { color: var(--err); }
/* ── форма ── */
.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; }
.note code { background: var(--panel2); padding: 1px 4px; border-radius: 4px; font-size: 10.5px; }
.addr-row { display: flex; gap: 8px; }
.addr-row input { flex: 1; }
/* ── цвет ИЛИ картинка ── */
.appearance-tabs { display: flex; gap: 6px; margin-bottom: 11px; }
.appearance-tabs .tab {
padding: 6px 13px; border-radius: 9px; font-size: 12.5px; color: var(--dim);
background: var(--panel2); cursor: pointer; border: 1px solid transparent;
}
.appearance-tabs .tab.on { color: var(--fg); border-color: var(--accent); background: rgba(106,178,242,.12); }
.pane { display: none; } .pane.on { display: block; }
.colors { display: flex; gap: 9px; }
.swatch { width: 30px; height: 30px; border-radius: 50%; cursor: pointer; border: 2px solid transparent; }
.swatch.on { border-color: var(--fg); }
.pic-row { display: flex; align-items: center; gap: 13px; }
.pic-preview {
width: 54px; height: 54px; border-radius: 50%; flex: 0 0 54px; background: var(--bg);
border: 1px dashed var(--line); display: grid; place-items: center; color: var(--dim);
font-size: 20px; overflow: hidden;
}
.pic-preview.filled { border-style: solid; }
.pic-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pic-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* ── кнопки ── */
.btn { border-radius: 10px; padding: 10px 17px; font-size: 13px; cursor: pointer; border: 1px solid var(--line);
background: transparent; color: var(--fg); font-family: inherit; white-space: nowrap; }
.btn:hover { border-color: var(--dim); }
.btn.prim { background: var(--accent); border-color: var(--accent); color: #0F1922; font-weight: 650; }
.btn.prim:disabled { background: var(--panel2); border-color: var(--line); color: var(--dim); cursor: default; }
.btn.sm { padding: 8px 13px; font-size: 12.5px; }
.btn.wide { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
border-style: dashed; color: var(--dim); }
.btn.wide:hover { color: var(--fg); border-color: var(--dim); }
/* ============ ОКНО 2: ПРОВЕРКА СВЯЗИ (поверх настроек) ============ */
.layer { position: fixed; inset: 0; display: none; align-items: center; justify-content: center;
z-index: 250; pointer-events: none; }
.layer.on { display: flex; }
.layer > * { pointer-events: auto; }
.dialog.small { width: 480px; min-width: 480px; max-height: 420px; }
.spin { width: 17px; height: 17px; border-radius: 50%; border: 2px solid var(--line);
border-top-color: var(--accent); animation: sp .7s linear infinite; flex: 0 0 17px; }
@keyframes sp { to { transform: rotate(360deg); } }
.probe-head { display: flex; align-items: center; gap: 13px; padding: 4px 0 14px; }
.probe-head .big { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center;
font-size: 19px; flex: 0 0 40px; }
.probe-head.ok .big { background: rgba(99,200,138,.15); color: var(--ok); }
.probe-head.err .big { background: rgba(236,57,66,.15); color: var(--err); }
.probe-head.busy .big { background: transparent; padding: 0; }
.probe-head .txt { flex: 1; }
.probe-head .ttl { font-size: 14px; font-weight: 600; }
.probe-head .sub { font-size: 12px; color: var(--dim); margin-top: 2px; }
.probe-list { font-size: 12px; }
.probe-list div { display: flex; justify-content: space-between; gap: 14px; padding: 7px 0;
border-bottom: 1px solid var(--line); }
.probe-list div:last-child { border-bottom: none; }
.probe-list span { color: var(--dim); }
.probe-list b { font-weight: 550; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11.5px; }
.hint-line { font-size: 11.5px; color: var(--dim); margin-top: 12px; padding: 9px 11px;
background: var(--bg); border-radius: 9px; border: 1px solid var(--line); }
</style>
</head>
<body>
<!-- ================= ОКНО 1: НАСТРОЙКИ ================= -->
<div class="dialog">
<div class="d-head">
<h2 id="dTitle">Агенты</h2>
<button class="x-btn">✕</button>
</div>
<!-- ── вид 1: список ── -->
<div class="d-body" id="viewList">
<div class="agent-card">
<div class="avatar">АГ</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 class="card-acts">
<button class="mini-ic" title="Проверить связь" onclick="openCheck('list','http://192.168.88.135:8080/agentik',true)">⇄</button>
<button class="mini-ic" title="Изменить">✎</button>
<button class="mini-ic del" title="Удалить">🗑</button>
</div>
</div>
<div class="agent-card">
<div class="avatar g2">ДМ</div>
<div class="ag-mid">
<div class="ag-name">домашний</div>
<div class="ag-addr">http://192.168.76.199:8080/agentik</div>
</div>
<div class="state err"><span class="d"></span>не отвечает</div>
<div class="card-acts">
<button class="mini-ic" title="Проверить связь" onclick="openCheck('list','http://192.168.76.199:8080/agentik',false)">⇄</button>
<button class="mini-ic" title="Изменить">✎</button>
<button class="mini-ic del" title="Удалить">🗑</button>
</div>
</div>
<div class="agent-card">
<div class="avatar pic"><img src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Crect width='40' height='40' fill='%232F4A63'/%3E%3Ccircle cx='20' cy='15' r='6.5' fill='%23DCE6F0'/%3E%3Cpath d='M7 40c0-7.5 5.8-12 13-12s13 4.5 13 12z' fill='%23DCE6F0'/%3E%3C/svg%3E" alt=""></div>
<div class="ag-mid">
<div class="ag-name">Кот-ассистент</div>
<div class="ag-addr">http://192.168.88.24:8080/agentik</div>
</div>
<div class="state off"><span class="d"></span>не проверялся</div>
<div class="card-acts">
<button class="mini-ic" title="Проверить связь" onclick="openCheck('list','http://192.168.88.24:8080/agentik',true)">⇄</button>
<button class="mini-ic" title="Изменить">✎</button>
<button class="mini-ic del" title="Удалить">🗑</button>
</div>
</div>
<button class="btn wide" onclick="show('add')">+ Добавить агента</button>
</div>
<!-- ── вид 2: форма нового ── -->
<div class="d-body" id="viewAdd" style="display:none">
<div class="field">
<label>Название</label>
<input type="text" value="рабочий" placeholder="как вы его будете звать">
<div class="note">Как называть этого агента в списке. Придумываете сами — сервер своего имени не сообщает.</div>
</div>
<div class="field">
<label>Адрес сервера агента</label>
<div class="addr-row">
<input type="text" class="mono" id="addrField" value="http://192.168.88.135:8080/agentik"
placeholder="http://хост:порт/agentik">
<button class="btn sm" onclick="openCheck('form', document.getElementById('addrField').value, true)">Проверить связь</button>
</div>
<div class="note">Адрес целиком, вместе с путём <code>/agentik</code>. Если агент
на этой же машине — <code>http://localhost:8080/agentik</code>.</div>
</div>
<div class="field">
<label>Как отличать в списке</label>
<div class="appearance-tabs">
<div class="tab on" data-tab="color">Цвет</div>
<div class="tab" data-tab="pic">Картинка</div>
</div>
<div class="pane on" id="paneColor">
<div class="colors">
<div class="swatch on" style="background:#6AB2F2"></div>
<div class="swatch" style="background:#86C99A"></div>
<div class="swatch" style="background:#C9A886"></div>
<div class="swatch" style="background:#B79AE0"></div>
<div class="swatch" style="background:#E28C8C"></div>
</div>
<div class="note">Цветом помечаются диалоги агента в списке — полоска у строки
и точка у группы.</div>
</div>
<div class="pane" id="panePic">
<div class="pic-row">
<div class="pic-preview" id="picPreview">🖼</div>
<div class="pic-actions">
<button class="btn sm" onclick="pickPic()">Выбрать файл…</button>
<button class="btn sm" id="picClear" onclick="clearPic()" style="display:none">Убрать</button>
</div>
</div>
<div class="note">Картинка копируется <b>внутрь клиента</b> — по исходному адресу
не подтягивается. Иначе значок пропадёт, если файл удалят, флешку вынут
или папку переименуют.</div>
</div>
</div>
</div>
<div class="d-foot" id="footList">
<button class="btn prim" style="flex:1">Готово</button>
</div>
<div class="d-foot" id="footAdd" style="display:none">
<button class="btn" style="flex:1" onclick="show('list')">Отмена</button>
<button class="btn prim" style="flex:1">Добавить агента</button>
</div>
</div>
<div class="scrim" id="scrim"></div>
<!-- ================= ОКНО 2: ПРОВЕРКА СВЯЗИ ================= -->
<div class="layer" id="layerCheck">
<div class="dialog small">
<div class="d-head">
<h2>Проверка связи</h2>
<button class="x-btn" onclick="closeCheck()">✕</button>
</div>
<div class="d-body">
<div class="probe-head" id="probeHead">
<div class="big" id="probeIcon"></div>
<div class="txt">
<div class="ttl" id="probeTitle">Спрашиваю агента…</div>
<div class="sub" id="probeSub">http://192.168.88.135:8080/agentik</div>
</div>
</div>
<div class="probe-list" id="probeList" style="display:none">
<div><span>Ответ на запрос</span><b id="p1">—</b></div>
<div><span>Отвечает по адресу</span><b id="p2">—</b></div>
<div><span>Диалогов у агента</span><b id="p3">—</b></div>
<div><span>Отклик</span><b id="p4">—</b></div>
</div>
<div class="hint-line" id="probeHint" style="display:none"></div>
</div>
<div class="d-foot">
<button class="btn" style="flex:1" id="probeAgain" onclick="runCheck()">Проверить снова</button>
<button class="btn prim" style="flex:1" id="probeOk" disabled>Добавить агента</button>
</div>
</div>
</div>
<script>
function el(id){ return document.getElementById(id); }
// ── переключение список / форма ──
function show(v) {
const isList = (v === 'list');
el('viewList').style.display = isList ? 'block' : 'none';
el('viewAdd').style.display = isList ? 'none' : 'block';
el('footList').style.display = isList ? 'flex' : 'none';
el('footAdd').style.display = isList ? 'none' : 'flex';
el('dTitle').textContent = isList ? 'Агенты' : 'Новый агент';
}
// ── цвет ИЛИ картинка ──
document.querySelectorAll('.appearance-tabs .tab').forEach(tab => tab.onclick = () => {
document.querySelectorAll('.appearance-tabs .tab').forEach(x => x.classList.remove('on'));
tab.classList.add('on');
const pic = (tab.dataset.tab === 'pic');
el('paneColor').classList.toggle('on', !pic);
el('panePic').classList.toggle('on', pic);
});
document.querySelectorAll('.swatch').forEach(s => s.onclick = () => {
document.querySelectorAll('.swatch').forEach(x => x.classList.remove('on'));
s.classList.add('on');
});
// выбор картинки: в настоящем клиенте — системный диалог выбора файла,
// а выбранный файл КОПИРУЕТСЯ в папку клиента (не ссылка на исходный путь)
function pickPic() {
setPic('data:image/svg+xml;utf8,' + encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="54" height="54">
<rect width="54" height="54" fill="#86C99A"/>
<circle cx="27" cy="21" r="8" fill="#121218"/>
<path d="M10 50 Q27 30 44 50 Z" fill="#121218"/>
</svg>`));
}
function setPic(src) {
const p = el('picPreview');
p.innerHTML = `<img src="${src}" alt="">`;
p.classList.add('filled');
el('picClear').style.display = '';
}
function clearPic() {
const p = el('picPreview');
p.innerHTML = '🖼';
p.classList.remove('filled');
el('picClear').style.display = 'none';
}
// ── ОКНО ПРОВЕРКИ: открывается из формы или из списка ──
let checkAddr = '', checkOk = true;
function openCheck(from, addr, ok) {
checkAddr = addr; checkOk = ok;
el('probeOk').textContent = (from === 'form') ? 'Добавить агента' : 'Готово';
el('scrim').classList.add('on');
el('layerCheck').classList.add('on');
runCheck();
}
function closeCheck() {
el('layerCheck').classList.remove('on');
el('scrim').classList.remove('on');
}
function runCheck() {
const list = el('probeList'), hint = el('probeHint');
el('probeHead').className = 'probe-head busy';
el('probeIcon').outerHTML = '<span class="spin" id="probeIcon"></span>';
el('probeTitle').textContent = 'Спрашиваю агента…';
el('probeSub').textContent = checkAddr;
list.style.display = 'none';
hint.style.display = 'none';
el('probeOk').disabled = true;
setTimeout(() => {
document.getElementById('probeIcon').outerHTML = '<div class="big" id="probeIcon"></div>';
el('probeSub').textContent = checkAddr;
if (!checkOk) {
el('probeHead').className = 'probe-head err';
el('probeIcon').textContent = '✕';
el('probeTitle').textContent = 'Агент не отвечает';
el('p1').textContent = 'нет ответа'; el('p2').textContent = '—';
el('p3').textContent = '—'; el('p4').textContent = '5000 мс (истекло)';
list.style.display = 'block'; hint.style.display = 'block';
hint.textContent = 'Проверьте адрес и что агент запущен. Убедитесь, что порт открыт и путь /agentik указан целиком.';
el('probeOk').disabled = true;
} else {
el('probeHead').className = 'probe-head ok';
el('probeIcon').textContent = '✓';
el('probeTitle').textContent = 'Агент отвечает';
el('p1').textContent = 'ok'; el('p2').textContent = checkAddr;
el('p3').textContent = '4'; el('p4').textContent = '38 мс';
list.style.display = 'block'; hint.style.display = 'block';
hint.textContent = 'Адрес рабочий. Можно добавлять этого агента.';
el('probeOk').disabled = false;
}
}, 1400);
}
</script>
</body>
</html>
-144
View File
@@ -1,144 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Одобрено: Новый диалог — выбор ассистента</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #121218; --panel: #17212B; --panel2: #1C2733; --line: #232B36;
--fg: #EBEBEB; --dim: #768C9E; --accent: #6AB2F2; --err: #EC3942; --ok: #63C88A;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: #0A0A0F; color: var(--fg); line-height: 1.45;
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px;
}
.dialog {
width: 480px; min-width: 480px; flex: 0 0 auto; max-height: 600px; background: var(--panel);
border: 1px solid var(--line); border-radius: 14px; display: flex; flex-direction: column;
overflow: hidden; box-shadow: 0 26px 80px rgba(0,0,0,.7);
}
.d-head { display: flex; align-items: center; gap: 11px; padding: 16px 18px 14px; border-bottom: 1px solid var(--line); }
.d-head h2 { font-size: 16px; font-weight: 600; flex: 1; }
.x-btn { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 17px; }
.x-btn:hover { background: var(--panel2); color: var(--fg); }
.d-body { flex: 1; overflow-y: auto; padding: 14px 18px 8px; }
.d-foot { padding: 13px 18px 16px; border-top: 1px solid var(--line); display: flex; gap: 9px; }
.d-lead { font-size: 12.5px; color: var(--dim); margin-bottom: 12px; }
.agent-opt {
display: flex; align-items: center; gap: 12px; padding: 11px 12px; border-radius: 11px;
background: var(--panel2); margin-bottom: 9px; cursor: pointer; border: 1.5px solid transparent;
}
.agent-opt:hover { border-color: #33414F; }
.agent-opt.on { border-color: var(--accent); background: rgba(106,178,242,.10); }
.avatar {
width: 40px; height: 40px; flex: 0 0 40px; border-radius: 50%; display: grid; place-items: center;
font-size: 13px; font-weight: 600; color: #0F1922; background: var(--accent);
}
.avatar.g2 { background: #86C99A; }
/* аватар-картинка: клиент показывает свою копию файла */
.avatar.pic {
background: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Crect width='40' height='40' fill='%232F4A63'/%3E%3Ccircle cx='20' cy='15' r='6.5' fill='%23DCE6F0'/%3E%3Cpath d='M7 40c0-7.5 5.8-12 13-12s13 4.5 13 12z' fill='%23DCE6F0'/%3E%3C/svg%3E") center/cover;
}
.ag-mid { flex: 1; min-width: 0; }
.ag-name { font-size: 14px; font-weight: 550; }
.ag-addr { font-size: 11.5px; color: var(--dim); font-family: ui-monospace, "SF Mono", Menlo, monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
.ag-state { font-size: 11px; display: flex; align-items: center; gap: 5px; margin-top: 4px; }
.ag-state .d { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
.ag-state.ok { color: var(--ok); }
.ag-state.off { color: var(--dim); } .ag-state.off .d { background: var(--dim); }
.ag-state.err { color: var(--err); } .ag-state.err .d { background: var(--err); }
.pick { width: 20px; height: 20px; flex: 0 0 20px; border-radius: 50%; border: 2px solid #3D4A58; }
.agent-opt.on .pick { border-color: var(--accent); background: var(--accent); position: relative; }
.agent-opt.on .pick::after {
content: ""; position: absolute; left: 5px; top: 1.5px; width: 5px; height: 9px;
border: solid #0F1922; border-width: 0 2px 2px 0; transform: rotate(42deg);
}
.warn {
display: none; gap: 8px; align-items: flex-start; margin-top: 4px; padding: 10px 12px;
border-radius: 10px; background: rgba(236,57,66,.09); border: 1px solid rgba(236,57,66,.32);
font-size: 12px; color: #F0A6AA;
}
.warn.on { display: flex; }
.btn {
padding: 10px 16px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel2);
color: var(--fg); font-size: 13.5px; cursor: pointer; font-family: inherit;
}
.btn:hover { border-color: #3D4A58; }
.btn.prim { background: var(--accent); border-color: var(--accent); color: #0F1922; font-weight: 600; }
</style>
</head>
<body>
<div class="dialog">
<div class="d-head">
<h2>Новый диалог</h2>
<button class="x-btn">✕</button>
</div>
<div class="d-body">
<div class="d-lead">В каком ассистенте создать диалог?</div>
<div class="agent-opt on" data-ok="1">
<div class="avatar">АГ</div>
<div class="ag-mid">
<div class="ag-name">agentik · рабочий</div>
<div class="ag-addr">http://192.168.88.135:8080/agentik</div>
<div class="ag-state ok"><span class="d"></span>на связи</div>
</div>
<div class="pick"></div>
</div>
<div class="agent-opt" data-ok="1">
<div class="avatar g2">ДМ</div>
<div class="ag-mid">
<div class="ag-name">домашний</div>
<div class="ag-addr">http://192.168.76.199:8080/agentik</div>
<div class="ag-state ok"><span class="d"></span>на связи</div>
</div>
<div class="pick"></div>
</div>
<div class="agent-opt" data-ok="0">
<div class="avatar pic"></div>
<div class="ag-mid">
<div class="ag-name">Кот-ассистент</div>
<div class="ag-addr">http://192.168.88.24:8080/agentik</div>
<div class="ag-state err"><span class="d"></span>не отвечает</div>
</div>
<div class="pick"></div>
</div>
<div class="warn" id="warn">
<span>⚠</span>
<span>Ассистент сейчас не отвечает. Диалог создать можно, но
сообщение не уйдёт, пока он не поднимется.</span>
</div>
</div>
<div class="d-foot">
<button class="btn" style="flex:1">Отмена</button>
<button class="btn prim" style="flex:1">Создать диалог</button>
</div>
</div>
<script>
// Выбор ассистента. Первый отмечен сразу — создать диалог можно одним нажатием.
document.querySelectorAll('.agent-opt').forEach(o => o.onclick = () => {
document.querySelectorAll('.agent-opt').forEach(x => x.classList.remove('on'));
o.classList.add('on');
document.getElementById('warn').classList.toggle('on', o.dataset.ok === '0');
});
</script>
</body>
</html>
-296
View File
@@ -1,296 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Группы — список</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #121218; --panel: #17212B; --panel2: #1C2733; --line: #232B36;
--fg: #EBEBEB; --dim: #768C9E; --accent: #6AB2F2; --badge: #5EB5F7;
--err: #EC3942; --ok: #63C88A; --user-bubble: #2B5278; --bot-bubble: #1C2733;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: #0A0A0F; color: var(--fg); line-height: 1.45;
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px;
}
/* ============ окно клиента на фоне ============ */
.window {
width: 1180px; height: 740px; background: var(--bg); border-radius: 12px;
display: flex; overflow: hidden; border: 1px solid var(--line); position: relative;
box-shadow: 0 24px 80px rgba(0,0,0,.6);
}
.sidebar { width: 320px; background: var(--panel); border-right: 1px solid var(--line); display: flex; flex-direction: column; }
.sb-head { padding: 14px 16px 10px; }
.sb-title { font-size: 17px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
.icon-btn { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; display: grid; place-items: center; font-size: 17px; }
.icon-btn:hover { background: var(--panel2); color: var(--fg); }
.search {
margin: 0 16px 10px; padding: 8px 11px; border-radius: 9px; border: 1px solid transparent;
background: var(--panel2); color: var(--fg); font-size: 13px; outline: none; font-family: inherit;
}
.search::placeholder { color: var(--dim); }
/* ── лента групп ── */
.folders { display: flex; gap: 6px; padding: 0 16px 10px; overflow-x: auto; }
.folder {
padding: 5px 11px; border-radius: 14px; font-size: 12px; color: var(--dim);
background: var(--panel2); cursor: pointer; white-space: nowrap; border: 1px solid transparent;
}
.folder.active { color: var(--fg); border-color: var(--accent); background: rgba(106,178,242,.12); }
.folder .cnt { color: var(--badge); font-weight: 600; margin-left: 4px; }
/* кнопка «Группы» — не папка, а действие: справа, отдельно от чипов */
.folder.act { margin-left: auto; background: transparent; color: var(--dim);
display: grid; place-items: center; padding: 5px 9px; font-size: 13px; }
.folder.act:hover { color: var(--fg); background: var(--panel2); }
.chat-row { display: flex; gap: 11px; padding: 9px 10px; border-radius: 10px; cursor: pointer; align-items: flex-start; }
.chat-row:hover { background: var(--panel2); }
.chat-row.active { background: #233040; }
.chat-list { flex: 1; overflow-y: auto; padding: 2px 8px 12px; }
.avatar {
width: 42px; height: 42px; border-radius: 50%; flex: 0 0 42px; display: grid; place-items: center;
font-size: 15px; font-weight: 600; color: #0F1922; background: var(--accent);
}
.avatar.g2 { background: #86C99A; }
.avatar.g3 { background: #C9A886; }
.avatar.g4 { background: #B79AE0; }
.chat-mid { flex: 1; min-width: 0; }
.chat-name { font-size: 14px; font-weight: 500; display: flex; justify-content: space-between; gap: 8px; }
.chat-time { font-size: 11px; color: var(--dim); font-weight: 400; }
.chat-prev { font-size: 12.5px; color: var(--dim); white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; margin-top: 2px; }
.badge { background: var(--badge); color: #0F1922; font-size: 11px; font-weight: 700;
border-radius: 10px; padding: 1px 7px; }
.main { flex: 1; display: flex; flex-direction: column; }
.chat-head { height: 58px; flex: 0 0 58px; border-bottom: 1px solid var(--line); display: flex;
align-items: center; gap: 11px; padding: 0 16px; }
.chat-head .avatar { width: 36px; height: 36px; flex: 0 0 36px; font-size: 13px; }
.head-name { font-size: 14.5px; font-weight: 600; }
.head-sub { font-size: 11.5px; color: var(--dim); }
.head-sub .dot { color: var(--ok); }
.messages { flex: 1; overflow-y: auto; padding: 18px 24px; display: flex; flex-direction: column; gap: 12px; }
.msg { max-width: 74%; padding: 9px 13px; border-radius: 13px; font-size: 13.5px; }
.msg.user { align-self: flex-end; background: var(--user-bubble); border-bottom-right-radius: 4px; }
.msg.bot { align-self: flex-start; background: var(--bot-bubble); border-bottom-left-radius: 4px; }
/* ============ затемнение и модалка ============ */
.scrim { position: absolute; inset: 0; background: rgba(0,0,0,.62); z-index: 100; }
.layer { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 150; }
.dialog {
width: 520px; min-width: 520px; flex: 0 0 auto; max-height: 600px; background: var(--panel);
border: 1px solid var(--line); border-radius: 14px; display: flex; flex-direction: column;
overflow: hidden; box-shadow: 0 26px 80px rgba(0,0,0,.7);
}
.d-head { display: flex; align-items: center; gap: 11px; padding: 16px 18px 14px; border-bottom: 1px solid var(--line); }
.d-head h2 { font-size: 16px; font-weight: 600; flex: 1; }
.x-btn { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 17px; }
.x-btn:hover { background: var(--panel2); color: var(--fg); }
.d-body { flex: 1; overflow-y: auto; padding: 14px 18px 8px; }
.d-foot { padding: 13px 18px 16px; border-top: 1px solid var(--line); display: flex; gap: 9px; }
/* ── строка группы ── */
.grp {
display: flex; align-items: center; gap: 12px; padding: 11px 12px; border-radius: 11px;
background: var(--panel2); margin-bottom: 9px; border: 1px solid transparent;
}
.grp:hover { border-color: var(--line); }
.grp .mid { flex: 1; min-width: 0; }
.grp .nm { font-size: 14px; font-weight: 550; }
.grp .sub { font-size: 11.5px; color: var(--dim); margin-top: 3px; }
.grp .acts { display: flex; gap: 4px; flex: 0 0 auto; }
.mini-ic { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 14px; }
.mini-ic:hover { background: var(--panel); color: var(--fg); }
.mini-ic.del:hover { color: var(--err); }
/* служебная группа — «Все чаты»: не правится и не удаляется */
.grp.sys { background: transparent; border: 1px dashed var(--line); }
.grp.sys .nm { color: var(--fg); }
.grp .lock { font-size: 11px; color: var(--dim); flex: 0 0 auto; }
.btn { border-radius: 10px; padding: 10px 17px; font-size: 13px; cursor: pointer; border: 1px solid var(--line);
background: transparent; color: var(--fg); font-family: inherit; white-space: nowrap; }
.btn:hover { border-color: var(--dim); }
.btn.prim { background: var(--accent); border-color: var(--accent); color: #0F1922; font-weight: 650; }
.btn.dang { color: var(--err); border-color: rgba(236,57,66,.4); }
.btn.dang:hover { border-color: var(--err); background: rgba(236,57,66,.09); }
.btn.dang.solid { background: var(--err); border-color: var(--err); color: #FFF; font-weight: 650; }
.btn.sm { padding: 8px 13px; font-size: 12.5px; }
.btn.wide { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
border-style: dashed; color: var(--dim); }
.btn.wide:hover { color: var(--fg); border-color: var(--dim); }
/* ── форма ── */
.field { margin-bottom: 15px; }
.field label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 6px; }
.field input {
width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); font-size: 13.5px; outline: none; font-family: inherit;
}
.field input:focus { border-color: var(--accent); }
.field .note { font-size: 11px; color: var(--dim); margin-top: 6px; }
/* ── предупреждение ── */
.warn {
display: flex; gap: 9px; align-items: flex-start; margin-bottom: 15px; padding: 11px 13px;
border-radius: 10px; background: rgba(236,57,66,.09); border: 1px solid rgba(236,57,66,.32);
font-size: 12px; color: #F0A6AA; line-height: 1.5;
}
.warn .i { flex: 0 0 auto; font-size: 13px; }
.info {
display: flex; gap: 9px; align-items: flex-start; margin-top: 4px; padding: 11px 13px;
border-radius: 10px; background: var(--bg); border: 1px solid var(--line);
font-size: 11.5px; color: var(--dim); line-height: 1.5;
}
.danger-zone { margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--line); }
.danger-zone .lbl { font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
color: var(--dim); font-weight: 700; margin-bottom: 9px; }
@media (max-height: 820px) { body { padding: 8px; } .window { transform: scale(.82); } }
</style>
</head>
<body>
<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="Поиск диалога…">
<div class="folders">
<div class="folder active">Все чаты <span class="cnt">3</span></div>
<div class="folder">Работа</div>
<div class="folder">Дом</div>
<div class="folder">Учёба</div>
<div class="folder">Личное</div>
<div class="folder act" title="Группы: добавить, изменить, удалить">✎</div>
</div>
<div class="chat-list">
<div class="chat-row active">
<div class="avatar">АГ</div>
<div class="chat-mid">
<div class="chat-name"><span>agentik · рабочий</span><span class="chat-time">14:22</span></div>
<div class="chat-prev">Собрал отчёт, жду правок</div>
</div>
<div class="row-tail"><span class="badge">2</span></div>
</div>
<div class="chat-row">
<div class="avatar g2">ОЧ</div>
<div class="chat-mid">
<div class="chat-name"><span>Отчёт за квартал</span><span class="chat-time">12:05</span></div>
<div class="chat-prev">Файл лежит на диске</div>
</div>
</div>
<div class="chat-row">
<div class="avatar g3">РЗ</div>
<div class="chat-mid">
<div class="chat-name"><span>Разбор логов nginx</span><span class="chat-time">вчера</span></div>
<div class="chat-prev">Нашёл 3 ошибки 502</div>
</div>
</div>
<div class="chat-row">
<div class="avatar g4">ПО</div>
<div class="chat-mid">
<div class="chat-name"><span>Помощь по Solana</span><span class="chat-time">вчера</span></div>
<div class="chat-prev">Девайт-нет стартовал</div>
</div>
</div></div>
</aside>
<main class="main">
<header class="chat-head">
<div class="avatar">АГ</div>
<div>
<div class="head-name">agentik · рабочий</div>
<div class="head-sub"><span class="dot">●</span> на связи</div>
</div>
</header>
<div class="messages">
<div class="msg user">Проверь, почему падает CI</div>
<div class="msg bot">Нашёл: длинное тире в именах тестов ломает сборку. Починил, CI зелёный.</div>
</div>
</main>
<div class="scrim"></div>
<div class="layer">
<div class="dialog">
<div class="d-head">
<h2>Группы</h2>
<button class="x-btn">✕</button>
</div>
<div class="d-body">
<div class="grp sys">
<div class="mid">
<div class="nm">Все чаты</div>
<div class="sub">Показывает все диалоги, из любой группы</div>
</div>
<div class="lock">встроенная</div>
</div>
<div class="grp">
<div class="mid">
<div class="nm">Работа</div>
<div class="sub">12 диалогов</div>
</div>
<div class="acts">
<button class="mini-ic" title="Изменить">✎</button>
<button class="mini-ic del" title="Удалить">🗑</button>
</div>
</div>
<div class="grp">
<div class="mid">
<div class="nm">Дом</div>
<div class="sub">4 диалога</div>
</div>
<div class="acts">
<button class="mini-ic" title="Изменить">✎</button>
<button class="mini-ic del" title="Удалить">🗑</button>
</div>
</div>
<div class="grp">
<div class="mid">
<div class="nm">Учёба</div>
<div class="sub">2 диалога</div>
</div>
<div class="acts">
<button class="mini-ic" title="Изменить">✎</button>
<button class="mini-ic del" title="Удалить">🗑</button>
</div>
</div>
<div class="grp">
<div class="mid">
<div class="nm">Личное</div>
<div class="sub">пустая</div>
</div>
<div class="acts">
<button class="mini-ic" title="Изменить">✎</button>
<button class="mini-ic del" title="Удалить">🗑</button>
</div>
</div>
<button class="btn wide">+ Добавить группу</button>
</div>
<div class="d-foot">
<button class="btn prim" style="flex:1">Готово</button>
</div>
</div>
</div>
</div>
</body>
</html>
-255
View File
@@ -1,255 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Группы — новая</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #121218; --panel: #17212B; --panel2: #1C2733; --line: #232B36;
--fg: #EBEBEB; --dim: #768C9E; --accent: #6AB2F2; --badge: #5EB5F7;
--err: #EC3942; --ok: #63C88A; --user-bubble: #2B5278; --bot-bubble: #1C2733;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: #0A0A0F; color: var(--fg); line-height: 1.45;
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px;
}
/* ============ окно клиента на фоне ============ */
.window {
width: 1180px; height: 740px; background: var(--bg); border-radius: 12px;
display: flex; overflow: hidden; border: 1px solid var(--line); position: relative;
box-shadow: 0 24px 80px rgba(0,0,0,.6);
}
.sidebar { width: 320px; background: var(--panel); border-right: 1px solid var(--line); display: flex; flex-direction: column; }
.sb-head { padding: 14px 16px 10px; }
.sb-title { font-size: 17px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
.icon-btn { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; display: grid; place-items: center; font-size: 17px; }
.icon-btn:hover { background: var(--panel2); color: var(--fg); }
.search {
margin: 0 16px 10px; padding: 8px 11px; border-radius: 9px; border: 1px solid transparent;
background: var(--panel2); color: var(--fg); font-size: 13px; outline: none; font-family: inherit;
}
.search::placeholder { color: var(--dim); }
/* ── лента групп ── */
.folders { display: flex; gap: 6px; padding: 0 16px 10px; overflow-x: auto; }
.folder {
padding: 5px 11px; border-radius: 14px; font-size: 12px; color: var(--dim);
background: var(--panel2); cursor: pointer; white-space: nowrap; border: 1px solid transparent;
}
.folder.active { color: var(--fg); border-color: var(--accent); background: rgba(106,178,242,.12); }
.folder .cnt { color: var(--badge); font-weight: 600; margin-left: 4px; }
/* кнопка «Группы» — не папка, а действие: справа, отдельно от чипов */
.folder.act { margin-left: auto; background: transparent; color: var(--dim);
display: grid; place-items: center; padding: 5px 9px; font-size: 13px; }
.folder.act:hover { color: var(--fg); background: var(--panel2); }
.chat-row { display: flex; gap: 11px; padding: 9px 10px; border-radius: 10px; cursor: pointer; align-items: flex-start; }
.chat-row:hover { background: var(--panel2); }
.chat-row.active { background: #233040; }
.chat-list { flex: 1; overflow-y: auto; padding: 2px 8px 12px; }
.avatar {
width: 42px; height: 42px; border-radius: 50%; flex: 0 0 42px; display: grid; place-items: center;
font-size: 15px; font-weight: 600; color: #0F1922; background: var(--accent);
}
.avatar.g2 { background: #86C99A; }
.avatar.g3 { background: #C9A886; }
.avatar.g4 { background: #B79AE0; }
.chat-mid { flex: 1; min-width: 0; }
.chat-name { font-size: 14px; font-weight: 500; display: flex; justify-content: space-between; gap: 8px; }
.chat-time { font-size: 11px; color: var(--dim); font-weight: 400; }
.chat-prev { font-size: 12.5px; color: var(--dim); white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; margin-top: 2px; }
.badge { background: var(--badge); color: #0F1922; font-size: 11px; font-weight: 700;
border-radius: 10px; padding: 1px 7px; }
.main { flex: 1; display: flex; flex-direction: column; }
.chat-head { height: 58px; flex: 0 0 58px; border-bottom: 1px solid var(--line); display: flex;
align-items: center; gap: 11px; padding: 0 16px; }
.chat-head .avatar { width: 36px; height: 36px; flex: 0 0 36px; font-size: 13px; }
.head-name { font-size: 14.5px; font-weight: 600; }
.head-sub { font-size: 11.5px; color: var(--dim); }
.head-sub .dot { color: var(--ok); }
.messages { flex: 1; overflow-y: auto; padding: 18px 24px; display: flex; flex-direction: column; gap: 12px; }
.msg { max-width: 74%; padding: 9px 13px; border-radius: 13px; font-size: 13.5px; }
.msg.user { align-self: flex-end; background: var(--user-bubble); border-bottom-right-radius: 4px; }
.msg.bot { align-self: flex-start; background: var(--bot-bubble); border-bottom-left-radius: 4px; }
/* ============ затемнение и модалка ============ */
.scrim { position: absolute; inset: 0; background: rgba(0,0,0,.62); z-index: 100; }
.layer { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 150; }
.dialog {
width: 520px; min-width: 520px; flex: 0 0 auto; max-height: 600px; background: var(--panel);
border: 1px solid var(--line); border-radius: 14px; display: flex; flex-direction: column;
overflow: hidden; box-shadow: 0 26px 80px rgba(0,0,0,.7);
}
.d-head { display: flex; align-items: center; gap: 11px; padding: 16px 18px 14px; border-bottom: 1px solid var(--line); }
.d-head h2 { font-size: 16px; font-weight: 600; flex: 1; }
.x-btn { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 17px; }
.x-btn:hover { background: var(--panel2); color: var(--fg); }
.d-body { flex: 1; overflow-y: auto; padding: 14px 18px 8px; }
.d-foot { padding: 13px 18px 16px; border-top: 1px solid var(--line); display: flex; gap: 9px; }
/* ── строка группы ── */
.grp {
display: flex; align-items: center; gap: 12px; padding: 11px 12px; border-radius: 11px;
background: var(--panel2); margin-bottom: 9px; border: 1px solid transparent;
}
.grp:hover { border-color: var(--line); }
.grp .mid { flex: 1; min-width: 0; }
.grp .nm { font-size: 14px; font-weight: 550; }
.grp .sub { font-size: 11.5px; color: var(--dim); margin-top: 3px; }
.grp .acts { display: flex; gap: 4px; flex: 0 0 auto; }
.mini-ic { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 14px; }
.mini-ic:hover { background: var(--panel); color: var(--fg); }
.mini-ic.del:hover { color: var(--err); }
/* служебная группа — «Все чаты»: не правится и не удаляется */
.grp.sys { background: transparent; border: 1px dashed var(--line); }
.grp.sys .nm { color: var(--fg); }
.grp .lock { font-size: 11px; color: var(--dim); flex: 0 0 auto; }
.btn { border-radius: 10px; padding: 10px 17px; font-size: 13px; cursor: pointer; border: 1px solid var(--line);
background: transparent; color: var(--fg); font-family: inherit; white-space: nowrap; }
.btn:hover { border-color: var(--dim); }
.btn.prim { background: var(--accent); border-color: var(--accent); color: #0F1922; font-weight: 650; }
.btn.dang { color: var(--err); border-color: rgba(236,57,66,.4); }
.btn.dang:hover { border-color: var(--err); background: rgba(236,57,66,.09); }
.btn.dang.solid { background: var(--err); border-color: var(--err); color: #FFF; font-weight: 650; }
.btn.sm { padding: 8px 13px; font-size: 12.5px; }
.btn.wide { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
border-style: dashed; color: var(--dim); }
.btn.wide:hover { color: var(--fg); border-color: var(--dim); }
/* ── форма ── */
.field { margin-bottom: 15px; }
.field label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 6px; }
.field input {
width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); font-size: 13.5px; outline: none; font-family: inherit;
}
.field input:focus { border-color: var(--accent); }
.field .note { font-size: 11px; color: var(--dim); margin-top: 6px; }
/* ── предупреждение ── */
.warn {
display: flex; gap: 9px; align-items: flex-start; margin-bottom: 15px; padding: 11px 13px;
border-radius: 10px; background: rgba(236,57,66,.09); border: 1px solid rgba(236,57,66,.32);
font-size: 12px; color: #F0A6AA; line-height: 1.5;
}
.warn .i { flex: 0 0 auto; font-size: 13px; }
.info {
display: flex; gap: 9px; align-items: flex-start; margin-top: 4px; padding: 11px 13px;
border-radius: 10px; background: var(--bg); border: 1px solid var(--line);
font-size: 11.5px; color: var(--dim); line-height: 1.5;
}
.danger-zone { margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--line); }
.danger-zone .lbl { font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
color: var(--dim); font-weight: 700; margin-bottom: 9px; }
@media (max-height: 820px) { body { padding: 8px; } .window { transform: scale(.82); } }
</style>
</head>
<body>
<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="Поиск диалога…">
<div class="folders">
<div class="folder active">Все чаты <span class="cnt">3</span></div>
<div class="folder">Работа</div>
<div class="folder">Дом</div>
<div class="folder">Учёба</div>
<div class="folder">Личное</div>
<div class="folder act" title="Группы: добавить, изменить, удалить">✎</div>
</div>
<div class="chat-list">
<div class="chat-row active">
<div class="avatar">АГ</div>
<div class="chat-mid">
<div class="chat-name"><span>agentik · рабочий</span><span class="chat-time">14:22</span></div>
<div class="chat-prev">Собрал отчёт, жду правок</div>
</div>
<div class="row-tail"><span class="badge">2</span></div>
</div>
<div class="chat-row">
<div class="avatar g2">ОЧ</div>
<div class="chat-mid">
<div class="chat-name"><span>Отчёт за квартал</span><span class="chat-time">12:05</span></div>
<div class="chat-prev">Файл лежит на диске</div>
</div>
</div>
<div class="chat-row">
<div class="avatar g3">РЗ</div>
<div class="chat-mid">
<div class="chat-name"><span>Разбор логов nginx</span><span class="chat-time">вчера</span></div>
<div class="chat-prev">Нашёл 3 ошибки 502</div>
</div>
</div>
<div class="chat-row">
<div class="avatar g4">ПО</div>
<div class="chat-mid">
<div class="chat-name"><span>Помощь по Solana</span><span class="chat-time">вчера</span></div>
<div class="chat-prev">Девайт-нет стартовал</div>
</div>
</div></div>
</aside>
<main class="main">
<header class="chat-head">
<div class="avatar">АГ</div>
<div>
<div class="head-name">agentik · рабочий</div>
<div class="head-sub"><span class="dot">●</span> на связи</div>
</div>
</header>
<div class="messages">
<div class="msg user">Проверь, почему падает CI</div>
<div class="msg bot">Нашёл: длинное тире в именах тестов ломает сборку. Починил, CI зелёный.</div>
</div>
</main>
<div class="scrim"></div>
<div class="layer">
<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>
<div class="info">
<span>ℹ</span>
<span>Диалоги в группу попадают потом — из самого диалога. Здесь группа
только создаётся, диалог в неё не переносится.</span>
</div>
</div>
<div class="d-foot">
<button class="btn" style="flex:1">Отмена</button>
<button class="btn prim" style="flex:1">Создать группу</button>
</div>
</div>
</div>
</div>
</body>
</html>
-260
View File
@@ -1,260 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Группы — изменение</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #121218; --panel: #17212B; --panel2: #1C2733; --line: #232B36;
--fg: #EBEBEB; --dim: #768C9E; --accent: #6AB2F2; --badge: #5EB5F7;
--err: #EC3942; --ok: #63C88A; --user-bubble: #2B5278; --bot-bubble: #1C2733;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: #0A0A0F; color: var(--fg); line-height: 1.45;
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px;
}
/* ============ окно клиента на фоне ============ */
.window {
width: 1180px; height: 740px; background: var(--bg); border-radius: 12px;
display: flex; overflow: hidden; border: 1px solid var(--line); position: relative;
box-shadow: 0 24px 80px rgba(0,0,0,.6);
}
.sidebar { width: 320px; background: var(--panel); border-right: 1px solid var(--line); display: flex; flex-direction: column; }
.sb-head { padding: 14px 16px 10px; }
.sb-title { font-size: 17px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
.icon-btn { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; display: grid; place-items: center; font-size: 17px; }
.icon-btn:hover { background: var(--panel2); color: var(--fg); }
.search {
margin: 0 16px 10px; padding: 8px 11px; border-radius: 9px; border: 1px solid transparent;
background: var(--panel2); color: var(--fg); font-size: 13px; outline: none; font-family: inherit;
}
.search::placeholder { color: var(--dim); }
/* ── лента групп ── */
.folders { display: flex; gap: 6px; padding: 0 16px 10px; overflow-x: auto; }
.folder {
padding: 5px 11px; border-radius: 14px; font-size: 12px; color: var(--dim);
background: var(--panel2); cursor: pointer; white-space: nowrap; border: 1px solid transparent;
}
.folder.active { color: var(--fg); border-color: var(--accent); background: rgba(106,178,242,.12); }
.folder .cnt { color: var(--badge); font-weight: 600; margin-left: 4px; }
/* кнопка «Группы» — не папка, а действие: справа, отдельно от чипов */
.folder.act { margin-left: auto; background: transparent; color: var(--dim);
display: grid; place-items: center; padding: 5px 9px; font-size: 13px; }
.folder.act:hover { color: var(--fg); background: var(--panel2); }
.chat-row { display: flex; gap: 11px; padding: 9px 10px; border-radius: 10px; cursor: pointer; align-items: flex-start; }
.chat-row:hover { background: var(--panel2); }
.chat-row.active { background: #233040; }
.chat-list { flex: 1; overflow-y: auto; padding: 2px 8px 12px; }
.avatar {
width: 42px; height: 42px; border-radius: 50%; flex: 0 0 42px; display: grid; place-items: center;
font-size: 15px; font-weight: 600; color: #0F1922; background: var(--accent);
}
.avatar.g2 { background: #86C99A; }
.avatar.g3 { background: #C9A886; }
.avatar.g4 { background: #B79AE0; }
.chat-mid { flex: 1; min-width: 0; }
.chat-name { font-size: 14px; font-weight: 500; display: flex; justify-content: space-between; gap: 8px; }
.chat-time { font-size: 11px; color: var(--dim); font-weight: 400; }
.chat-prev { font-size: 12.5px; color: var(--dim); white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; margin-top: 2px; }
.badge { background: var(--badge); color: #0F1922; font-size: 11px; font-weight: 700;
border-radius: 10px; padding: 1px 7px; }
.main { flex: 1; display: flex; flex-direction: column; }
.chat-head { height: 58px; flex: 0 0 58px; border-bottom: 1px solid var(--line); display: flex;
align-items: center; gap: 11px; padding: 0 16px; }
.chat-head .avatar { width: 36px; height: 36px; flex: 0 0 36px; font-size: 13px; }
.head-name { font-size: 14.5px; font-weight: 600; }
.head-sub { font-size: 11.5px; color: var(--dim); }
.head-sub .dot { color: var(--ok); }
.messages { flex: 1; overflow-y: auto; padding: 18px 24px; display: flex; flex-direction: column; gap: 12px; }
.msg { max-width: 74%; padding: 9px 13px; border-radius: 13px; font-size: 13.5px; }
.msg.user { align-self: flex-end; background: var(--user-bubble); border-bottom-right-radius: 4px; }
.msg.bot { align-self: flex-start; background: var(--bot-bubble); border-bottom-left-radius: 4px; }
/* ============ затемнение и модалка ============ */
.scrim { position: absolute; inset: 0; background: rgba(0,0,0,.62); z-index: 100; }
.layer { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 150; }
.dialog {
width: 520px; min-width: 520px; flex: 0 0 auto; max-height: 600px; background: var(--panel);
border: 1px solid var(--line); border-radius: 14px; display: flex; flex-direction: column;
overflow: hidden; box-shadow: 0 26px 80px rgba(0,0,0,.7);
}
.d-head { display: flex; align-items: center; gap: 11px; padding: 16px 18px 14px; border-bottom: 1px solid var(--line); }
.d-head h2 { font-size: 16px; font-weight: 600; flex: 1; }
.x-btn { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 17px; }
.x-btn:hover { background: var(--panel2); color: var(--fg); }
.d-body { flex: 1; overflow-y: auto; padding: 14px 18px 8px; }
.d-foot { padding: 13px 18px 16px; border-top: 1px solid var(--line); display: flex; gap: 9px; }
/* ── строка группы ── */
.grp {
display: flex; align-items: center; gap: 12px; padding: 11px 12px; border-radius: 11px;
background: var(--panel2); margin-bottom: 9px; border: 1px solid transparent;
}
.grp:hover { border-color: var(--line); }
.grp .mid { flex: 1; min-width: 0; }
.grp .nm { font-size: 14px; font-weight: 550; }
.grp .sub { font-size: 11.5px; color: var(--dim); margin-top: 3px; }
.grp .acts { display: flex; gap: 4px; flex: 0 0 auto; }
.mini-ic { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 14px; }
.mini-ic:hover { background: var(--panel); color: var(--fg); }
.mini-ic.del:hover { color: var(--err); }
/* служебная группа — «Все чаты»: не правится и не удаляется */
.grp.sys { background: transparent; border: 1px dashed var(--line); }
.grp.sys .nm { color: var(--fg); }
.grp .lock { font-size: 11px; color: var(--dim); flex: 0 0 auto; }
.btn { border-radius: 10px; padding: 10px 17px; font-size: 13px; cursor: pointer; border: 1px solid var(--line);
background: transparent; color: var(--fg); font-family: inherit; white-space: nowrap; }
.btn:hover { border-color: var(--dim); }
.btn.prim { background: var(--accent); border-color: var(--accent); color: #0F1922; font-weight: 650; }
.btn.dang { color: var(--err); border-color: rgba(236,57,66,.4); }
.btn.dang:hover { border-color: var(--err); background: rgba(236,57,66,.09); }
.btn.dang.solid { background: var(--err); border-color: var(--err); color: #FFF; font-weight: 650; }
.btn.sm { padding: 8px 13px; font-size: 12.5px; }
.btn.wide { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
border-style: dashed; color: var(--dim); }
.btn.wide:hover { color: var(--fg); border-color: var(--dim); }
/* ── форма ── */
.field { margin-bottom: 15px; }
.field label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 6px; }
.field input {
width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); font-size: 13.5px; outline: none; font-family: inherit;
}
.field input:focus { border-color: var(--accent); }
.field .note { font-size: 11px; color: var(--dim); margin-top: 6px; }
/* ── предупреждение ── */
.warn {
display: flex; gap: 9px; align-items: flex-start; margin-bottom: 15px; padding: 11px 13px;
border-radius: 10px; background: rgba(236,57,66,.09); border: 1px solid rgba(236,57,66,.32);
font-size: 12px; color: #F0A6AA; line-height: 1.5;
}
.warn .i { flex: 0 0 auto; font-size: 13px; }
.info {
display: flex; gap: 9px; align-items: flex-start; margin-top: 4px; padding: 11px 13px;
border-radius: 10px; background: var(--bg); border: 1px solid var(--line);
font-size: 11.5px; color: var(--dim); line-height: 1.5;
}
.danger-zone { margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--line); }
.danger-zone .lbl { font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
color: var(--dim); font-weight: 700; margin-bottom: 9px; }
@media (max-height: 820px) { body { padding: 8px; } .window { transform: scale(.82); } }
</style>
</head>
<body>
<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="Поиск диалога…">
<div class="folders">
<div class="folder">Все чаты <span class="cnt">3</span></div>
<div class="folder active">Работа</div>
<div class="folder">Дом</div>
<div class="folder">Учёба</div>
<div class="folder">Личное</div>
<div class="folder act" title="Группы: добавить, изменить, удалить">✎</div>
</div>
<div class="chat-list">
<div class="chat-row active">
<div class="avatar">АГ</div>
<div class="chat-mid">
<div class="chat-name"><span>agentik · рабочий</span><span class="chat-time">14:22</span></div>
<div class="chat-prev">Собрал отчёт, жду правок</div>
</div>
<div class="row-tail"><span class="badge">2</span></div>
</div>
<div class="chat-row">
<div class="avatar g2">ОЧ</div>
<div class="chat-mid">
<div class="chat-name"><span>Отчёт за квартал</span><span class="chat-time">12:05</span></div>
<div class="chat-prev">Файл лежит на диске</div>
</div>
</div>
<div class="chat-row">
<div class="avatar g3">РЗ</div>
<div class="chat-mid">
<div class="chat-name"><span>Разбор логов nginx</span><span class="chat-time">вчера</span></div>
<div class="chat-prev">Нашёл 3 ошибки 502</div>
</div>
</div>
<div class="chat-row">
<div class="avatar g4">ПО</div>
<div class="chat-mid">
<div class="chat-name"><span>Помощь по Solana</span><span class="chat-time">вчера</span></div>
<div class="chat-prev">Девайт-нет стартовал</div>
</div>
</div></div>
</aside>
<main class="main">
<header class="chat-head">
<div class="avatar">АГ</div>
<div>
<div class="head-name">agentik · рабочий</div>
<div class="head-sub"><span class="dot">●</span> на связи</div>
</div>
</header>
<div class="messages">
<div class="msg user">Проверь, почему падает CI</div>
<div class="msg bot">Нашёл: длинное тире в именах тестов ломает сборку. Починил, CI зелёный.</div>
</div>
</main>
<div class="scrim"></div>
<div class="layer">
<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>
<div class="info">
<span>ℹ</span>
<span>В группе сейчас 12 диалогов. Переименование их не затрагивает —
это только название группы.</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>
</div>
</div>
</body>
</html>
-267
View File
@@ -1,267 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Группы — удаление</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #121218; --panel: #17212B; --panel2: #1C2733; --line: #232B36;
--fg: #EBEBEB; --dim: #768C9E; --accent: #6AB2F2; --badge: #5EB5F7;
--err: #EC3942; --ok: #63C88A; --user-bubble: #2B5278; --bot-bubble: #1C2733;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: #0A0A0F; color: var(--fg); line-height: 1.45;
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px;
}
/* ============ окно клиента на фоне ============ */
.window {
width: 1180px; height: 740px; background: var(--bg); border-radius: 12px;
display: flex; overflow: hidden; border: 1px solid var(--line); position: relative;
box-shadow: 0 24px 80px rgba(0,0,0,.6);
}
.sidebar { width: 320px; background: var(--panel); border-right: 1px solid var(--line); display: flex; flex-direction: column; }
.sb-head { padding: 14px 16px 10px; }
.sb-title { font-size: 17px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
.icon-btn { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; display: grid; place-items: center; font-size: 17px; }
.icon-btn:hover { background: var(--panel2); color: var(--fg); }
.search {
margin: 0 16px 10px; padding: 8px 11px; border-radius: 9px; border: 1px solid transparent;
background: var(--panel2); color: var(--fg); font-size: 13px; outline: none; font-family: inherit;
}
.search::placeholder { color: var(--dim); }
/* ── лента групп ── */
.folders { display: flex; gap: 6px; padding: 0 16px 10px; overflow-x: auto; }
.folder {
padding: 5px 11px; border-radius: 14px; font-size: 12px; color: var(--dim);
background: var(--panel2); cursor: pointer; white-space: nowrap; border: 1px solid transparent;
}
.folder.active { color: var(--fg); border-color: var(--accent); background: rgba(106,178,242,.12); }
.folder .cnt { color: var(--badge); font-weight: 600; margin-left: 4px; }
/* кнопка «Группы» — не папка, а действие: справа, отдельно от чипов */
.folder.act { margin-left: auto; background: transparent; color: var(--dim);
display: grid; place-items: center; padding: 5px 9px; font-size: 13px; }
.folder.act:hover { color: var(--fg); background: var(--panel2); }
.chat-row { display: flex; gap: 11px; padding: 9px 10px; border-radius: 10px; cursor: pointer; align-items: flex-start; }
.chat-row:hover { background: var(--panel2); }
.chat-row.active { background: #233040; }
.chat-list { flex: 1; overflow-y: auto; padding: 2px 8px 12px; }
.avatar {
width: 42px; height: 42px; border-radius: 50%; flex: 0 0 42px; display: grid; place-items: center;
font-size: 15px; font-weight: 600; color: #0F1922; background: var(--accent);
}
.avatar.g2 { background: #86C99A; }
.avatar.g3 { background: #C9A886; }
.avatar.g4 { background: #B79AE0; }
.chat-mid { flex: 1; min-width: 0; }
.chat-name { font-size: 14px; font-weight: 500; display: flex; justify-content: space-between; gap: 8px; }
.chat-time { font-size: 11px; color: var(--dim); font-weight: 400; }
.chat-prev { font-size: 12.5px; color: var(--dim); white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; margin-top: 2px; }
.badge { background: var(--badge); color: #0F1922; font-size: 11px; font-weight: 700;
border-radius: 10px; padding: 1px 7px; }
.main { flex: 1; display: flex; flex-direction: column; }
.chat-head { height: 58px; flex: 0 0 58px; border-bottom: 1px solid var(--line); display: flex;
align-items: center; gap: 11px; padding: 0 16px; }
.chat-head .avatar { width: 36px; height: 36px; flex: 0 0 36px; font-size: 13px; }
.head-name { font-size: 14.5px; font-weight: 600; }
.head-sub { font-size: 11.5px; color: var(--dim); }
.head-sub .dot { color: var(--ok); }
.messages { flex: 1; overflow-y: auto; padding: 18px 24px; display: flex; flex-direction: column; gap: 12px; }
.msg { max-width: 74%; padding: 9px 13px; border-radius: 13px; font-size: 13.5px; }
.msg.user { align-self: flex-end; background: var(--user-bubble); border-bottom-right-radius: 4px; }
.msg.bot { align-self: flex-start; background: var(--bot-bubble); border-bottom-left-radius: 4px; }
/* ============ затемнение и модалка ============ */
.scrim { position: absolute; inset: 0; background: rgba(0,0,0,.62); z-index: 100; }
.layer { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 150; }
.dialog {
width: 520px; min-width: 520px; flex: 0 0 auto; max-height: 600px; background: var(--panel);
border: 1px solid var(--line); border-radius: 14px; display: flex; flex-direction: column;
overflow: hidden; box-shadow: 0 26px 80px rgba(0,0,0,.7);
}
.d-head { display: flex; align-items: center; gap: 11px; padding: 16px 18px 14px; border-bottom: 1px solid var(--line); }
.d-head h2 { font-size: 16px; font-weight: 600; flex: 1; }
.x-btn { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 17px; }
.x-btn:hover { background: var(--panel2); color: var(--fg); }
.d-body { flex: 1; overflow-y: auto; padding: 14px 18px 8px; }
.d-foot { padding: 13px 18px 16px; border-top: 1px solid var(--line); display: flex; gap: 9px; }
/* ── строка группы ── */
.grp {
display: flex; align-items: center; gap: 12px; padding: 11px 12px; border-radius: 11px;
background: var(--panel2); margin-bottom: 9px; border: 1px solid transparent;
}
.grp:hover { border-color: var(--line); }
.grp .mid { flex: 1; min-width: 0; }
.grp .nm { font-size: 14px; font-weight: 550; }
.grp .sub { font-size: 11.5px; color: var(--dim); margin-top: 3px; }
.grp .acts { display: flex; gap: 4px; flex: 0 0 auto; }
.mini-ic { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 14px; }
.mini-ic:hover { background: var(--panel); color: var(--fg); }
.mini-ic.del:hover { color: var(--err); }
/* служебная группа — «Все чаты»: не правится и не удаляется */
.grp.sys { background: transparent; border: 1px dashed var(--line); }
.grp.sys .nm { color: var(--fg); }
.grp .lock { font-size: 11px; color: var(--dim); flex: 0 0 auto; }
.btn { border-radius: 10px; padding: 10px 17px; font-size: 13px; cursor: pointer; border: 1px solid var(--line);
background: transparent; color: var(--fg); font-family: inherit; white-space: nowrap; }
.btn:hover { border-color: var(--dim); }
.btn.prim { background: var(--accent); border-color: var(--accent); color: #0F1922; font-weight: 650; }
.btn.dang { color: var(--err); border-color: rgba(236,57,66,.4); }
.btn.dang:hover { border-color: var(--err); background: rgba(236,57,66,.09); }
.btn.dang.solid { background: var(--err); border-color: var(--err); color: #FFF; font-weight: 650; }
.btn.sm { padding: 8px 13px; font-size: 12.5px; }
.btn.wide { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
border-style: dashed; color: var(--dim); }
.btn.wide:hover { color: var(--fg); border-color: var(--dim); }
/* ── форма ── */
.field { margin-bottom: 15px; }
.field label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 6px; }
.field input {
width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); font-size: 13.5px; outline: none; font-family: inherit;
}
.field input:focus { border-color: var(--accent); }
.field .note { font-size: 11px; color: var(--dim); margin-top: 6px; }
/* ── предупреждение ── */
.warn {
display: flex; gap: 9px; align-items: flex-start; margin-bottom: 15px; padding: 11px 13px;
border-radius: 10px; background: rgba(236,57,66,.09); border: 1px solid rgba(236,57,66,.32);
font-size: 12px; color: #F0A6AA; line-height: 1.5;
}
.warn .i { flex: 0 0 auto; font-size: 13px; }
.info {
display: flex; gap: 9px; align-items: flex-start; margin-top: 4px; padding: 11px 13px;
border-radius: 10px; background: var(--bg); border: 1px solid var(--line);
font-size: 11.5px; color: var(--dim); line-height: 1.5;
}
.danger-zone { margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--line); }
.danger-zone .lbl { font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
color: var(--dim); font-weight: 700; margin-bottom: 9px; }
@media (max-height: 820px) { body { padding: 8px; } .window { transform: scale(.82); } }
</style>
</head>
<body>
<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="Поиск диалога…">
<div class="folders">
<div class="folder">Все чаты <span class="cnt">3</span></div>
<div class="folder active">Работа</div>
<div class="folder">Дом</div>
<div class="folder">Учёба</div>
<div class="folder">Личное</div>
<div class="folder act" title="Группы: добавить, изменить, удалить">✎</div>
</div>
<div class="chat-list">
<div class="chat-row active">
<div class="avatar">АГ</div>
<div class="chat-mid">
<div class="chat-name"><span>agentik · рабочий</span><span class="chat-time">14:22</span></div>
<div class="chat-prev">Собрал отчёт, жду правок</div>
</div>
<div class="row-tail"><span class="badge">2</span></div>
</div>
<div class="chat-row">
<div class="avatar g2">ОЧ</div>
<div class="chat-mid">
<div class="chat-name"><span>Отчёт за квартал</span><span class="chat-time">12:05</span></div>
<div class="chat-prev">Файл лежит на диске</div>
</div>
</div>
<div class="chat-row">
<div class="avatar g3">РЗ</div>
<div class="chat-mid">
<div class="chat-name"><span>Разбор логов nginx</span><span class="chat-time">вчера</span></div>
<div class="chat-prev">Нашёл 3 ошибки 502</div>
</div>
</div>
<div class="chat-row">
<div class="avatar g4">ПО</div>
<div class="chat-mid">
<div class="chat-name"><span>Помощь по Solana</span><span class="chat-time">вчера</span></div>
<div class="chat-prev">Девайт-нет стартовал</div>
</div>
</div></div>
</aside>
<main class="main">
<header class="chat-head">
<div class="avatar">АГ</div>
<div>
<div class="head-name">agentik · рабочий</div>
<div class="head-sub"><span class="dot">●</span> на связи</div>
</div>
</header>
<div class="messages">
<div class="msg user">Проверь, почему падает CI</div>
<div class="msg bot">Нашёл: длинное тире в именах тестов ломает сборку. Починил, CI зелёный.</div>
</div>
</main>
<div class="scrim"></div>
<div class="layer">
<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>«Работа»</b> исчезнет из ленты. <b>Диалоги не удалятся</b> —
они останутся и будут видны в «Все чаты». Вернуть группу можно, создав её
заново, но диалоги придётся разложить руками.</span>
</div>
<div class="field">
<label>Что сделать с диалогами группы</label>
<div class="grp" style="margin-bottom:9px">
<div class="mid">
<div class="nm">Оставить без группы</div>
<div class="sub">12 диалогов вернутся в «Все чаты»</div>
</div>
<div class="lock">✓</div>
</div>
</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>
</div>
</div>
</body>
</html>
-100
View File
@@ -1,100 +0,0 @@
# 006 — Управление группами
**Иконка в ленте.** Над списком диалогов, справа от чипов групп, стоит **карандаш**
(✎) — вместо прежнего «+». Он означает **«Группы»**: открывает окно, где группы
можно добавить, переименовать и удалить. Раньше там был «+» — он читался как
«создать диалог», хотя диалог создаётся кнопкой в шапке списка.
Почему карандаш: рядом стоят чипы самих групп, и **всё, что похоже на «+», рядом
с ними читается как «добавить группу»**, а не «управлять ими». Карандаш говорит:
«здесь правят список групп целиком». Кнопка стоит отдельно от чипов — прижата
к правому краю, чтобы её не приняли за шестую группу.
---
## Файлы — по состояниям, без интерактивности
Интерактивность решено не делать: вместо неё **отдельный файл на каждое состояние**.
Открываются по одному, каждый показывает ровно одно окно.
| Файл | Что показывает |
|---|---|
| `1-list.html` | Список групп: каждая строка с «изменить» и «удалить», внизу «+ Добавить группу» |
| `2-add.html` | Новая группа: только название |
| `3-edit.html` | Изменение группы: переименование и удаление |
| `4-delete.html` | Удаление группы: что станет с диалогами |
---
## Окно «Группы» (1-list)
Строка группы: **название**, под ним — **сколько диалогов**, справа кнопки
**изменить (✎)** и **удалить (🗑)**. Внизу пунктирная **«+ Добавить группу»**.
**«Все чаты» — встроенная группа.** Она есть всегда, показана пунктирной рамкой
и помечена «встроенная»: её нельзя переименовать или удалить. Она нужна как
надежное место, где видно все диалоги сразу, — иначе при удалении группы диалоги
могли бы пропасть из виду. Кнопок правки у неё нет — не потому что забыли,
а потому что нечего править.
**Пустая группа не запрещена** — «Личное» в макете пустая, помечена словом
«пустая». Группу можно создать заранее и разложить диалоги потом.
---
## Новая группа (2-add)
**Одно поле — название.** Никаких настроек: цвет группы, порядок, значки —
не выдумываем, пока не попросили.
Под полем честная подсказка: **диалоги в группу попадают потом, из самого диалога.**
Здесь группа только создаётся — диалог в неё не переносится. Иначе пользователь
ждёт, что после создания в группе что-то появится, и не понимает, почему пусто.
---
## Изменение группы (3-edit)
**Переименование** — то же поле, что при создании, плюс подсказка, сколько
диалогов в группе сейчас (**переименование диалогов не касается**).
**Удаление — внизу, отдельным блоком «ОПАСНОЕ».** Так сделано нарочно: удаление
не должно стоять в одном ряду с «Сохранить», иначе промахнуться легко. Кнопка
красная, но не залитая — заливка появляется на следующем шаге, где спрашивают
подтверждение.
---
## Удаление группы (4-delete)
**Главное, что говорит окно: диалоги не удалятся.** Группа исчезнет из ленты,
диалоги останутся и будут видны в «Все чаты». Это первое, что написано
в предупреждении, — потому что именно этого боятся, нажимая «удалить».
Отдельный вопрос — **что сделать с диалогами**: оставить без группы (вернутся
в «Все чаты»). Другого пути нет: диалог обязан быть где-то виден, «корзины»
у диалогов не существует. Так и написано в окне — чтобы не выглядело, будто
выбор есть, а его нет.
Кнопка подтверждения — **красная залитая**, в тексте прямо «Удалить группу».
---
## Что решено и почему
- **Карандаш вместо «+».** Иначе две кнопки подряд означают «создать» — и в шапке
списка, и в ленте групп.
- **«Все чаты» не удаляется.** Нужна точка опоры, где собран весь список.
- **Диалоги не удаляются вместе с группой.** Иначе одно нажатие стирает работу.
- **Группа без диалогов допустима.** Создать заранее — нормальный сценарий.
- **Никаких настроек у группы, кроме названия.** Цвет, значок, порядок — не
просили, не делаем.
## Открытые вопросы
- **Куда диалог кладётся при создании** — в текущую открытую группу или без
группы? Сейчас в макете не показано.
- **Можно ли диалог быть в нескольких группах** (как ярлык в почте) или только
в одной? От этого зависит, что писать в удалении.
- **Порядок групп** — по алфавиту, по времени создания или перетаскиванием?
- **Что если удалить группу, в которой открыт диалог.** Сейчас не показано.
-145
View File
@@ -1,145 +0,0 @@
# Одобренные макеты
**Здесь только принятое.** Это не «вариант для сравнения» и не черновик — это
решение, на которое опираемся в реализации. Правило простое: одобренный макет
**живёт здесь**, а не остаётся «выбранным» среди вариантов в `sketches/`.
| Файл | Что это | Одобрено |
|---|---|---|
| `001-sidebar-utility.html` | **Основа дизайна**: привычный мессенджер | 2026-09-18 |
| `004-settings-agents.html` | Настройки агентов: список, форма, проверка связи | 2026-09-19 |
| `005-new-chat-picker.html` | Модалка «Новый диалог»: выбор ассистента | 2026-09-19 |
| `006-groups-manage/` | Управление группами — 4 файла по состояниям | 2026-09-19 |
`sketches/` — предложения: `002-rail-voice-first`, `003-three-pane-command`,
`004-settings-agents`, `005-new-chat-picker` (черновики, из них выросли одобренные).
---
## 001-sidebar-utility.html — основа дизайна
**Это база всего клиента.** Привычный мессенджер: список диалогов слева (320 px),
чат справа. Узкое окно — как в Телеграме: на экране что-то одно, у открытого
диалога сверху кнопка «Назад».
**Что в нём есть:**
- **лента папок** — «Все чаты / Работа / Дом / +», со счётчиком непрочитанных;
- поиск диалога;
- список диалогов: значок, название, последняя строка, время, кружок непрочитанных;
- шапка чата со значком, именем и состоянием связи;
- сообщения: пользователь справа, ассистент слева;
- поле ввода с кнопкой отправки и круглой кнопкой записи голоса.
**Из него убрано при одобрении:**
- подсказка под полем ввода;
- кольцо токенов — в библиотеке `client` таких данных нет, а «нет данных — нет цифры».
Стиль: фон `#121218`, панели `#17212B`, акцент `#6AB2F2`, текст `#EBEBEB`.
---
## 005-new-chat-picker.html — Новый диалог
**Модалка появляется по «+» (новый диалог), когда ассистентов больше одного.**
Состоит из одного окна 480 px и больше ничего: заголовок «Новый диалог»,
подсказка-вопрос, список ассистентов, две кнопки внизу.
**Что в карточке ассистента:**
- значок — цвет или картинка (как настроено в настройках агентов);
- имя;
- адрес сервера;
- состояние связи: «на связи» зелёным, «не отвечает» красным;
- кружок отметки справа.
**Поведение:**
- **первый отмечен сразу** — создать диалог можно одним нажатием;
- выбран ассистент без связи → под списком появляется предупреждение,
но выбор не запрещается (связь может пропасть на секунду);
- окно маленькое и без вкладок: одно действие — один выбор.
**Когда этой модалки не должно быть:**
- **ассистент один** — диалог создаётся сразу в нём, спрашивать не о чем;
- **ассистентов ноль** — кнопки «+» нет вообще (создавать диалог не в ком),
вместо списка диалогов подсказка «добавьте ассистента в настройках».
**Отвергнуто при одобрении:** пояснительная панель над окном клиента — она
попала в макет случайно, это была заметка для проверки, а не часть интерфейса.
В одобренном файле её нет и быть не должно.
---
## 004-settings-agents.html — Настройки агентов
**Два окна друг над другом.** Первое — настройки (620 px): список агентов и форма
нового. Второе, поверх — проверка связи (480 px).
**Окно 1, список агентов.** У каждого: знак (цвет или картинка), имя, адрес,
состояние — «на связи» зелёным, «не отвечает» красным, «не проверялся» серым.
Кнопки в строке: **проверить связь (⇄)**, **изменить (✎)**, **удалить (🗑)**.
Внизу пунктирная «+ Добавить агента».
**Окно 1, форма нового** — три поля:
- **Название.** Пояснение: имя придумывает пользователь, сервер своего имени
не сообщает (это из исходников, не догадка).
- **Адрес сервера** — целиком, вместе с путём `/agentik`. Прямо в строке адреса
кнопка «Проверить связь».
- **Как отличать в списке** — вкладки **Цвет / Картинка**, выбирается что-то одно.
Цвет — из пяти. Картинка — файл с диска, круглый превью, «Выбрать файл…» и «Убрать».
⚠️ Картинка **копируется внутрь клиента**, не берётся по исходному адресу.
**Окно 2, проверка связи — отдельное окно поверх настроек**, три состояния
в одном окне: идёт (крутится кольцо) → отвечает (зелёная галочка, подробности:
ответ, адрес, число диалогов, отклик) → не отвечает (красный крестик, подсказка
что проверить). Внизу всегда «Проверить снова». Кнопка «Добавить агента»
доступна только при успехе. Работает и для добавленных агентов — кнопкой «⇄».
**Почему проверка отдельным окном:** места больше, основную форму не дёргает,
одно окно годится и для новой формы, и для уже добавленного агента.
**Честно о непроверенном:** живого агента на момент макета не поднято — на порту
8080 отвечал чужой сервер. Адреса запросов взяты из исходников (`GET /agentik/health`
и список диалогов, оба ничего не меняют), но замеры времени и число диалогов
в макете условные. На настоящем агенте проверка ещё не гонялась.
## 006-groups-manage/ — Управление группами
**Четыре файла, по одному состоянию в каждом.** Интерактивности нет намеренно:
вместо неё отдельный файл на каждое состояние.
| Файл | Что показывает |
|---|---|
| `1-list.html` | список групп: «изменить» и «удалить» у каждой, внизу «+ Добавить группу» |
| `2-add.html` | новая группа: только название |
| `3-edit.html` | изменение: переименование, удаление отдельным блоком «ОПАСНОЕ» |
| `4-delete.html` | подтверждение: диалоги не удаляются, вернутся в «Все чаты» |
**Как открывается:** кнопкой-карандашом (✎) в ленте групп над списком диалогов —
справа, отдельно от чипов. Прежний «+» на этом месте убран: рядом стоят чипы
самих групп, и всё похожее на «+» читается как «добавить группу», хотя диалог
создаётся кнопкой в шапке списка.
**Решения внутри:**
- **«Все чаты» — встроенная группа**, не удаляется и не переименовывается
(пунктирная рамка, пометка «встроенная»). Нужна как надёжное место, где виден
весь список.
- **Удаление группы диалоги не удаляет** — в подтверждении это сказано первым
делом. Диалоги останутся и будут видны в «Все чаты», «корзины» у них нет.
- **У группы только название.** Цвет, значок, порядок — не делаем, пока не попросили.
- **Пустая группа допустима** — можно создать заранее, диалоги разложить потом.
- **Подсказка при создании:** диалоги в группу попадают позже, из самого диалога.
## Про цвета в макетах
Цвета в макетах стоят числами — так и надо, иначе макет не будет выглядеть
задуманным. **В коде так нельзя:** цвет берётся из цветовой схемы по смыслу,
потому что тем будет несколько. Разбор — `THEMES.md`.
## Как смотреть
Открыть файл в браузере — самодостаточный, без сборки и без зависимостей.
- в `001` переключается узкий/широкий вид и видны состояния сообщений;
- в `004` переключаются список/форма, вкладки Цвет/Картинка; кнопкой «⇄» или
«Проверить связь» открывается окно проверки (оба состояния — успех и отказ);
- в `005` кликом по карточке меняется выбор и появляется предупреждение;
- `006` — четыре файла, открывать по одному, по состоянию.
+128
View File
@@ -0,0 +1,128 @@
// `binaries {}` — экспериментальный API KGP, `compose.uiTest` — экспериментальная
// библиотека Compose (компилятор требует явного opt-in в скрипте).
@file:OptIn(
org.jetbrains.compose.ExperimentalComposeLibrary::class,
org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class,
)
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
// UI — Compose Multiplatform. `kotlin-compose` — компиляторный плагин
// (@Composable), `compose` — рантайм/DSL (`compose.material3`, ...).
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.compose)
// `application` plugin (он тянет `java`) **несовместим** с
// `kotlin("multiplatform")` — проверка `checkKotlinGradlePluginConfigurationErrors`
// падает на старте. В KMP executable настраивается через
// `kotlin { jvm { binaries { executable { ... } } } }` ниже — он создаёт
// `run` / `installDist` / jar с mainClass автоматически.
}
group = "pw.binom.agentik.desktop"
version = "0.1.0-SNAPSHOT"
kotlin {
// KMP-проект с **одной** целью — JVM. Структура каталогов
// `src/jvmMain/kotlin/...` (а не `src/main/kotlin/...`), чтобы при
// расширении целями (iOS / Native) не пришлось переименовывать.
// Toolchain 21 = как у agentik (`:client` / `:journal-ksqlite` тоже
// jvmToolchain(21)) — иначе Gradle тащит второй JDK через foojay.
jvmToolchain(21)
jvm {
binaries {
executable {
mainClass.set("pw.binom.agentik.desktop.MainKt")
// `jvmJar` создаётся автоматически; `run` запускает main.
}
}
}
sourceSets {
jvmMain.dependencies {
// --- Agentik (композитная сборка, см. settings.gradle.kts) ---
// `AgentikAgent` / `wrapWithLocalConversationCache` /
// `ReconnectingOutbox` — всё что нужно для клиента уже здесь.
implementation(libs.agentik.client)
// Контракты `MessageRecord` / `ConversationRecord` /
// `MutableJournalStore` / `MutableConversationStore`.
implementation(libs.agentik.journal.api)
// SQLite-реализации обоих store'ов из `:journal-ksqlite`.
implementation(libs.agentik.journal.ksqlite)
// `Event` / `AgentEvent` / `CommonEvent` / `OutboxStore` (live-поток).
implementation(libs.agentik.outbox.api)
// `Agent` / `Conversation` / `AgentInfo` / `Content` — proto-контракты.
implementation(libs.agentik.proto)
// --- Ktor client engine (передаётся в AgentikAgent как engineFactory) ---
implementation(libs.ktor.client.cio)
// --- Низкоуровневая БД (`SQLiteConnection` / prepared statements) ---
implementation(libs.ksqlite)
// --- Kotlinx ---
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
// --- UI: Compose Multiplatform (markdown-рендер + экраны) ---
implementation(compose.foundation)
implementation(compose.ui)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
// Оконный рантайм Compose Desktop (Window/application) + skiko
// для текущей ОС. Без него `compose.ui.window.application` не резолвится.
implementation(compose.desktop.currentOs)
// --- Markdown: парсер (ru.otpbank.ai:markdown, обёртка над
// org.intellij.markdown) + вендоренный рендер сегментов. ---
implementation(libs.markdown)
// --- Голос: микрофон → VAD → распознавание (см. MIC-ASR-SEARCH.md).
// JVM-реализация микрофона — `javax.sound.sampled`. ---
implementation(libs.mic.api)
implementation(libs.asr.api)
implementation(libs.asr.vosk)
implementation(libs.vad)
// --- Логирование ---
implementation(libs.kotlin.logging)
implementation(libs.logback.classic)
}
jvmTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.junit.jupiter)
// Оффскрин-рендер Compose: прогоняем реальную тему/компоненты
// в ImageComposeScene и проверяем палитру по пикселям.
implementation(compose.uiTest)
// E2E: поднимаем настоящий `:server` (HTTP/SSE) поверх
// in-memory-агента и гоняем десктопный стек по живому протоколу.
implementation(libs.agentik.server)
implementation(libs.agentik.journal.inmemory)
implementation(libs.agentik.outbox.inmemory)
implementation(libs.ktor.server.cio)
}
}
}
// `application` плагин в KMP конфликтует с `kotlin("multiplatform")`,
// executable объявлен выше в `kotlin { jvm { binaries { executable { ... } } } }`
// — он автоматически создаёт `run` / `installDist` и jvmJar.
tasks.withType<Test>().configureEach {
useJUnitPlatform()
// Один JVM на тест-класс. Причина не в изоляции данных, а в нативных
// библиотеках: skiko (Compose) и C++-стек распознавания (Vosk/Kaldi, ONNX)
// конфликтуют по порядку загрузки — тот, кто грузится ПОСЛЕ skiko, падает
// с SIGSEGV. Классы, которым нужен нативный ASR, обязаны прогревать его
// первым делом (VoiceNative / RealSpeechIT), а skiko-классы — не мешать им.
// forkEvery = 1 гарантирует, что порядок классов не важен.
forkEvery = 1
// Изолируем данные приложения от реального $HOME разработчика:
// AppPaths читает AGENTIK_DESKTOP_HOME, тесты пишут в build/.
environment(
"AGENTIK_DESKTOP_HOME",
layout.buildDirectory.dir("test-agentik-home").get().asFile.absolutePath,
)
}
+11
View File
@@ -0,0 +1,11 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configureondemand=false
# Kotlin
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx2g
# KMP — на каждом таргете пересобираем только при изменении источников этого таргета
kotlin.incremental=true
+88
View File
@@ -0,0 +1,88 @@
[versions]
kotlin = "2.4.20"
kotlinx-coroutines = "1.11.0"
kotlinx-serialization = "1.11.0"
ksqlite = "0.1.3"
# Ktor — версия из agentik (там же живут `:client`-модули, engine выбирает
# потребитель; CIO — то, что agentik использует в собственных тестах).
ktor = "3.1.3"
kotlin-logging = "3.0.5"
logback = "1.5.18"
junit = "5.10.2"
# Compose Multiplatform — версия взята из assistent (`compose-multiplatform`,
# там с ней собирается клиент), совместима с Kotlin 2.4.x.
compose-multiplatform = "1.7.3"
# Обёртка над JetBrains org.intellij.markdown (GFM), живёт в корпоративном
# developerspace Nexus (см. settings.gradle.kts), не в Maven Central.
markdown = "0.51.0"
# Голос: всё опубликовано в caffeine Nexus (см. MIC-ASR-SEARCH.md).
# mic-api — поток микрофона (JVM: javax.sound.sampled), версия 1.0.0.
mic = "1.0.0"
# asr-api — интерфейс Stt (feed/partialText/finalText), движки в отдельных
# артефактах; версия 4 — та, что использует agentik-android.
asr = "4"
# VAD (Silero) — модель внутри артефакта, ничего качать не надо.
vad = "2"
[libraries]
# --- Agentik модули (composite build ../agentik, group = "pw.binom.agentik") ---
# Эти артефакты резолвятся через `includeBuild("../agentik")` в settings.gradle.kts —
# agentik публикует свои модули с group = "pw.binom.agentik", артефакт-id =
# имени Gradle-модуля. Подключаем точечно только то что реально нужно десктопу.
agentik-client = { module = "pw.binom.agentik:client" }
agentik-journal-api = { module = "pw.binom.agentik:journal-api" }
agentik-journal-ksqlite = { module = "pw.binom.agentik:journal-ksqlite" }
agentik-outbox-api = { module = "pw.binom.agentik:outbox-api" }
agentik-proto = { module = "pw.binom.agentik:proto" }
# Только для тестов: поднять настоящий HTTP/SSE-фасад `:server` поверх
# in-memory-агента и прогнать десктопный стек против живого протокола.
agentik-server = { module = "pw.binom.agentik:server" }
agentik-journal-inmemory = { module = "pw.binom.agentik:journal-inmemory" }
agentik-outbox-inmemory = { module = "pw.binom.agentik:outbox-inmemory" }
# --- Ktor client engine: нужен AgentikAgent(...) как `engineFactory`. ---
# `:client` объявляет ktor-client-core как `api`, но engine — выбор потребителя.
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
# --- Ktor server engine (только тесты): embeddedServer(CIO) для E2E. ---
ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
# --- ksqlite (pw.binom.db) — KMP SQLite с sqlite-vec.
# Резолвится из caffeine Nexus (см. settings.gradle.kts); GMM выбирает jvm-variant.
ksqlite = { module = "pw.binom.db:ksqlite", version.ref = "ksqlite" }
# --- Kotlinx (KMP-артефакты, Gradle сам разруливает per-target variants) ---
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
# --- Логирование: kotlin-logging + logback (binding) ---
kotlin-logging = { module = "io.github.microutils:kotlin-logging", version.ref = "kotlin-logging" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
# --- Markdown: парсер/модель сегментов (обёртка над org.intellij.markdown) ---
markdown = { module = "ru.otpbank.ai:markdown", version.ref = "markdown" }
# --- Голос: микрофон → VAD → распознавание (всё из caffeine Nexus) ---
# mic-api публикует KMP-метаданные + `-jvm`/`-android` варианты; GMM выберет jvm.
mic-api = { module = "pw.binom.mic:mic-api", version.ref = "mic" }
# asr-api — только интерфейс `Stt`; конкретный движок подключаем отдельно.
asr-api = { module = "pw.binom.asr:asr-api", version.ref = "asr" }
# asr-vosk — Vosk: модель распаковывается из артефакта, скачивать нечего.
asr-vosk = { module = "pw.binom.asr:asr-vosk", version.ref = "asr" }
# VAD — детектор речи (Silero), модель внутри артефакта.
vad = { module = "pw.binom.voice.vad:vad", version.ref = "vad" }
# --- Тесты ---
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
[plugins]
# Kotlin Multiplatform — фундамент; конкретные цели объявляются в build.gradle.kts
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
# Compose Compiler plugin — обязателен для @Composable. Версионируется вместе
# с Kotlin; без него @Composable-лямбды ломаются (Function0 вместо Function2).
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
# Compose Multiplatform plugin — приносит `compose.material3` / `compose.ui` /
# `compose.materialIconsExtended` и desktop-инфраструктуру.
compose = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+249
View File
@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+56
View File
@@ -0,0 +1,56 @@
pluginManagement {
repositories {
// Корпоративный Nexus-прокси для gradle-плагинов (как в assistent) —
// подстраховка, если Gradle Plugin Portal недоступен из сети.
maven {
name = "pluginsgradle-proxy"
url = uri("http://192.168.76.117/repository/pluginsgradle-mvn-proxy/")
setAllowInsecureProtocol(true)
}
gradlePluginPortal()
mavenCentral()
google()
}
}
dependencyResolutionManagement {
// Сначала mavenLocal — локально опубликованные артефакты (через
// `publishToMavenLocal`) перекрывают caffeine.
repositories {
mavenLocal()
mavenCentral()
google()
// Caffeine Nexus: pw.binom.* (ksqlite до v0.1.1-SNAPSHOT — в Maven
// Central, после — здесь). Allow-insecure потому что корпоративный
// Nexus без TLS. Аналогичная строчка в `:agentik/settings.gradle.kts`
// для согласованности.
maven {
name = "caffeine"
url = uri("http://192.168.76.117/repository/caffeine")
setAllowInsecureProtocol(true)
}
// Корпоративный Nexus (OTP developerspace): здесь лежит
// `ru.otpbank.ai:markdown` (в Maven Central его нет). Тот же repo,
// что в assistent/client-shared.
maven {
name = "developerspace"
url = uri("http://192.168.76.117/repository/developerspace-prod-mvn-hosted/")
setAllowInsecureProtocol(true)
}
// JetBrains Compose dev-канал — на случай dev-сборок CMP.
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
// androidx-транзитивы Compose Multiplatform.
maven("https://maven.google.com")
}
}
rootProject.name = "agentik-desktop"
// Композитная сборка: agentik-модули подтягиваются прямо из соседнего
// репозитория (../agentik), без публикации в mavenCentral. Любая правка
// в :client / :journal-ksqlite / :ksqlite видна сразу после `./gradlew build`.
//
// Gradle сам мапит артефакты `pw.binom.agentik:<module-name>` на
// `includeBuild("../agentik"):<module-name>`. Подмодуль должен быть
// подключён в `:agentik/settings.gradle.kts` через `include(":client")` и т.д.
includeBuild("../agentik")
-25
View File
@@ -1,25 +0,0 @@
# Вариант 2 — Rail / Voice-first
## Суть
Голос — главное действие, а не дополнение к клавиатуре. Огромная кнопка микрофона
слева в панели ввода, живая расшифровка на экране, пока говоришь.
## Ключевые решения
- **Раскладка:** рейка иконок (76) + диалоги (296) + чат. Рейка — вход в память агента
и инструменты, это не просто мессенджер.
- **Голос:** кнопка 58 px, при записи краснеет и пульсирует. Над полем раскрывается
панель: волна, таймер и **текст, который появляется по мере речи**.
- **Три выхода из записи:** «Отправить» / «В поле ввода» (поправить руками) / «Отменить».
Голосовое сообщение в ленте рисуется с волной и кнопкой проигрывания.
- **Горячие клавиши:** Ctrl+R — запись, удержание пробела — говорить, Esc — отмена.
## Что хорошо
- Голосом пользоваться реально удобно: видно, что тебя услышали, до отправки.
- Ошибку в тексте видно до отправки — можно поправить.
## Что слабо
- Больше элементов на экране, надо разбираться.
- «Пробел на удержание» конфликтует с обычным набором текста — нужна аккуратность.
## Кому
Тому, кто в основном диктует, а не печатает.
-342
View File
@@ -1,342 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Вариант 2 — Rail / Voice-first</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0E1418; --panel: #141E24; --panel2: #1B2830; --line: #22323C;
--fg: #E8EEF2; --dim: #7A93A3; --accent: #4FD1C5; --hot: #FF6B6B;
--bot-bubble: #1B2830; --user-bubble: #1F4B54;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: #070B0D; color: var(--fg); line-height: 1.45;
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px;
}
.window {
width: 1240px; height: 750px; background: var(--bg); border-radius: 14px; display: flex;
overflow: hidden; border: 1px solid var(--line); box-shadow: 0 24px 80px rgba(0,0,0,.65);
}
/* ---- узкая колонка-рейка: чаты иконками ---- */
.rail {
width: 76px; flex: 0 0 76px; background: var(--panel); border-right: 1px solid var(--line);
display: flex; flex-direction: column; align-items: center; padding: 12px 0; gap: 10px;
}
.rail-btn {
width: 46px; height: 46px; border-radius: 13px; border: none; background: var(--panel2);
color: var(--dim); cursor: pointer; display: grid; place-items: center; font-size: 17px;
position: relative; transition: .15s;
}
.rail-btn:hover { color: var(--fg); }
.rail-btn.active { background: var(--accent); color: #07201D; }
.rail-btn .dot-badge {
position: absolute; top: -3px; right: -3px; background: var(--accent); color: #07201D;
font-size: 10px; font-weight: 800; border-radius: 8px; padding: 1px 5px; min-width: 16px;
}
.rail-btn .label {
position: absolute; bottom: -14px; font-size: 9px; color: var(--dim); white-space: nowrap;
}
.rail-sep { width: 28px; height: 1px; background: var(--line); margin: 8px 0; }
.rail-spacer { flex: 1; }
/* ---- средний столбец: список диалогов ---- */
.list { width: 296px; flex: 0 0 296px; background: var(--panel); border-right: 1px solid var(--line);
display: flex; flex-direction: column; }
.l-head { padding: 16px 18px 12px; }
.l-head h1 { font-size: 16.5px; font-weight: 650; letter-spacing: .2px; }
.l-head p { font-size: 11.5px; color: var(--dim); margin-top: 3px; }
.search {
margin: 0 18px 10px; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); font-size: 13px; outline: none;
}
.search:focus { border-color: var(--accent); }
.search::placeholder { color: var(--dim); }
.rows { flex: 1; overflow-y: auto; padding: 0 10px 12px; }
.row {
display: flex; gap: 11px; padding: 10px 11px; border-radius: 11px; cursor: pointer;
border-left: 2px solid transparent;
}
.row:hover { background: var(--panel2); }
.row.active { background: var(--panel2); border-left-color: var(--accent); }
.row .av {
width: 38px; height: 38px; flex: 0 0 38px; border-radius: 11px; display: grid; place-items: center;
font-size: 13px; font-weight: 700; background: rgba(79,209,197,.16); color: var(--accent);
}
.row .mid { flex: 1; min-width: 0; }
.row .nm { font-size: 13.5px; font-weight: 550; display: flex; justify-content: space-between; }
.row .tm { font-size: 10.5px; color: var(--dim); font-weight: 400; }
.row .pv { font-size: 12px; color: var(--dim); white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; margin-top: 2px; }
/* ---- основная зона чата ---- */
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.m-head { height: 60px; flex: 0 0 60px; display: flex; align-items: center; gap: 12px;
padding: 0 22px; border-bottom: 1px solid var(--line); }
.m-head .t { font-size: 14.5px; font-weight: 600; }
.m-head .s { font-size: 11.5px; color: var(--dim); }
.ghost { margin-left: auto; display: flex; gap: 6px; }
.ghost button { background: transparent; border: 1px solid var(--line); color: var(--dim);
border-radius: 8px; padding: 5px 11px; font-size: 11.5px; cursor: pointer; }
.ghost button:hover { color: var(--fg); border-color: var(--dim); }
.thread { flex: 1; overflow-y: auto; padding: 22px 26px; display: flex; flex-direction: column; gap: 13px; }
.b { max-width: 76%; padding: 10px 14px; border-radius: 14px; font-size: 13.5px; }
.b.u { align-self: flex-end; background: var(--user-bubble); border-bottom-right-radius: 4px; }
.b.a { align-self: flex-start; background: var(--bot-bubble); border-bottom-left-radius: 4px; }
.b .tm { font-size: 10.5px; color: var(--dim); margin-top: 5px; }
/* голосовое сообщение — с волной прямо в пузыре */
.voice-b {
align-self: flex-end; background: var(--user-bubble); border-radius: 14px 14px 4px 14px;
padding: 10px 14px; display: flex; align-items: center; gap: 11px; min-width: 250px;
}
.play { width: 30px; height: 30px; border-radius: 50%; background: var(--accent); color: #07201D;
border: none; display: grid; place-items: center; cursor: pointer; font-size: 12px; }
.vwave { flex: 1; display: flex; align-items: center; gap: 2px; height: 24px; }
.vwave i { flex: 1; background: rgba(232,238,242,.5); border-radius: 2px; }
.vdur { font-size: 11px; color: var(--dim); font-variant-numeric: tabular-nums; }
/* ---- КОМПОЗЕР: голос — главный ---- */
.composer { border-top: 1px solid var(--line); background: var(--panel); padding: 16px 22px 14px; }
.mic-zone { display: flex; align-items: center; gap: 16px; }
.mic-big {
width: 58px; height: 58px; flex: 0 0 58px; border-radius: 50%; border: none; cursor: pointer;
background: var(--accent); color: #07201D; font-size: 23px; display: grid; place-items: center;
transition: .2s;
}
.mic-big:hover { transform: scale(1.05); }
.mic-big.on { background: var(--hot); color: #fff; animation: ring 1.5s infinite; }
@keyframes ring { 0%,100% { box-shadow: 0 0 0 0 rgba(255,107,107,.6); } 50% { box-shadow: 0 0 0 13px rgba(255,107,107,0); } }
.mic-side { flex: 1; min-width: 0; }
.mic-hint { font-size: 12px; color: var(--dim); margin-bottom: 7px; }
.mic-hint b { color: var(--fg); font-weight: 550; }
.type-row { display: flex; align-items: center; gap: 9px; }
.type-row textarea {
flex: 1; background: var(--bg); border: 1px solid var(--line); border-radius: 10px; padding: 10px 13px;
color: var(--fg); font-family: inherit; font-size: 13.5px; outline: none; resize: none;
height: 40px; max-height: 110px; line-height: 1.4;
}
.type-row textarea:focus { border-color: var(--accent); }
.type-row textarea::placeholder { color: var(--dim); }
.go { height: 40px; padding: 0 17px; border-radius: 10px; border: 1px solid var(--line);
background: transparent; color: var(--dim); font-size: 12.5px; cursor: pointer; }
.go.active { background: var(--accent); border-color: var(--accent); color: #07201D; font-weight: 650; }
/* расшифровка в реальном времени */
.live-asr { margin-top: 11px; display: none; background: var(--bg); border: 1px solid var(--hot);
border-radius: 11px; padding: 11px 14px; }
.live-asr.on { display: block; }
.live-asr .top { display: flex; align-items: center; gap: 9px; margin-bottom: 8px; font-size: 11.5px; color: var(--hot); font-weight: 600; }
.blink { width: 7px; height: 7px; border-radius: 50%; background: var(--hot); animation: bl .9s infinite; }
@keyframes bl { 50% { opacity: .15; } }
.big-wave { display: flex; align-items: center; gap: 3px; height: 30px; margin-bottom: 8px; }
.big-wave i { width: 3px; background: var(--accent); border-radius: 2px; height: 25%; animation: bw .8s infinite ease-in-out; }
.big-wave i:nth-child(2n) { animation-delay: .1s; } .big-wave i:nth-child(3n) { animation-delay: .2s; }
@keyframes bw { 0%,100% { height: 18%; } 50% { height: 100%; } }
.asr-line { font-size: 14px; color: var(--fg); min-height: 20px; }
.asr-line .cursor { display: inline-block; width: 2px; height: 15px; background: var(--accent);
vertical-align: middle; animation: bl .8s infinite; }
.live-actions { display: flex; gap: 8px; margin-top: 11px; }
.live-actions button { border-radius: 9px; padding: 7px 14px; font-size: 12.5px; cursor: pointer; border: 1px solid var(--line); background: transparent; color: var(--dim); }
.live-actions .prim { background: var(--accent); border-color: var(--accent); color: #07201D; font-weight: 620; }
.live-actions .dang { color: var(--hot); }
.foot-note { font-size: 10.5px; color: var(--dim); margin-top: 9px; }
kbd { background: var(--panel2); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; font-size: 10px; }
/* окно целиком влезает на любой экран — не режем картинку */
@media (max-height: 820px) { body { padding: 8px; } .window, .win { transform: scale(.78); } }
@media (max-height: 700px) { .window, .win { transform: scale(.66); } }
@media (max-height: 600px) { .window, .win { transform: scale(.56); } }
</style>
</head>
<body>
<div class="window">
<!-- ============ РЕЙКА ============ -->
<nav class="rail">
<button class="rail-btn active" title="Диалоги">💬<span class="dot-badge">3</span></button>
<button class="rail-btn" title="Память агента">🧠</button>
<button class="rail-btn" title="Инструменты">⚙</button>
<div class="rail-sep"></div>
<button class="rail-btn" title="Новый диалог">+</button>
<div class="rail-spacer"></div>
<button class="rail-btn" title="Настройки">⚙︎</button>
</nav>
<!-- ============ СПИСОК ДИАЛОГОВ ============ -->
<section class="list">
<div class="l-head">
<h1>Диалоги</h1>
<p>локальный агент · 3 непрочитанных</p>
</div>
<input class="search" placeholder="Поиск…">
<div class="rows">
<div class="row active">
<div class="av">АГ</div>
<div class="mid">
<div class="nm"><span>agentik</span><span class="tm">14:22</span></div>
<div class="pv">Собрал отчёт, жду правок</div>
</div>
</div>
<div class="row">
<div class="av">ОЧ</div>
<div class="mid">
<div class="nm"><span>Отчёт за квартал</span><span class="tm">12:05</span></div>
<div class="pv">Файл лежит на диске</div>
</div>
</div>
<div class="row">
<div class="av">РЗ</div>
<div class="mid">
<div class="nm"><span>Разбор логов</span><span class="tm">вчера</span></div>
<div class="pv">Нашёл 3 ошибки 502</div>
</div>
</div>
<div class="row">
<div class="av">ПО</div>
<div class="mid">
<div class="nm"><span>Solana-бэкенд</span><span class="tm">вчера</span></div>
<div class="pv">Девайс-нет стартовал</div>
</div>
</div>
<div class="row">
<div class="av">КН</div>
<div class="mid">
<div class="nm"><span>Книги</span><span class="tm">пн</span></div>
<div class="pv">Разложил по полкам</div>
</div>
</div>
</div>
</section>
<!-- ============ ЧАТ ============ -->
<main class="main">
<header class="m-head">
<div>
<div class="t">agentik</div>
<div class="s">на связи · codding-big</div>
</div>
<div class="ghost">
<button>Стоп</button>
<button>Очистить</button>
</div>
</header>
<div class="thread" id="thread">
<div class="b u">Посмотри логи за вчера<span class="tm">14:10</span></div>
<div class="b a">Проверил: 3 ошибки 502 около 04:20, все — обрыв соединения с upstream. Виновник — таймаут прокси. Поправить?<span class="tm">14:12</span></div>
<div class="voice-b">
<button class="play" onclick="togglePlay(this)">▶</button>
<div class="vwave" id="pastWave"></div>
<span class="vdur">0:07</span>
</div>
<div class="b a">Понял: увеличить таймаут до 60 секунд и перезапустить прокси. Сделал — прокси поднят заново, ошибок за последний час нет.<span class="tm">14:20</span></div>
</div>
<div class="composer">
<div class="live-asr" id="liveAsr">
<div class="top"><span class="blink"></span> ЗАПИСЬ · <span id="liveTime">0:00</span></div>
<div class="big-wave" id="bigWave"></div>
<div class="asr-line"><span id="asrLine"></span><span class="cursor"></span></div>
<div class="live-actions">
<button class="prim" onclick="stopRec(false)">Отправить</button>
<button onclick="insertAsText()">В поле ввода</button>
<button class="dang" onclick="stopRec(true)">Отменить</button>
</div>
</div>
<div class="mic-zone">
<button class="mic-big" id="micBtn" title="Запись (Ctrl+R, удержание пробела)">🎙</button>
<div class="mic-side">
<div class="mic-hint"><b>Говорите</b> — текст появится в поле ниже. Можно править перед отправкой.</div>
<div class="type-row">
<textarea id="ta" placeholder="Или напишите руками…"></textarea>
<button class="go" id="goBtn" disabled>Отправить</button>
</div>
</div>
</div>
<div class="foot-note">
<kbd>Ctrl</kbd>+<kbd>R</kbd> — начать/стоп · <kbd>Пробел</kbd> (удержание) — говорить · <kbd>Enter</kbd> — отправить · <kbd>Esc</kbd> — отменить запись
</div>
</div>
</main>
</div>
<script>
const ta = document.getElementById('ta'), goBtn = document.getElementById('goBtn');
const micBtn = document.getElementById('micBtn'), liveAsr = document.getElementById('liveAsr');
const thread = document.getElementById('thread');
document.getElementById('pastWave').innerHTML =
Array.from({length: 34}, () => '<i style="height:' + (18 + Math.random()*80) + '%"></i>').join('');
document.getElementById('bigWave').innerHTML = Array.from({length: 40}, () => '<i></i>').join('');
ta.addEventListener('input', () => {
goBtn.disabled = ta.value.trim() === '';
goBtn.classList.toggle('active', ta.value.trim() !== '');
ta.style.height = '40px'; ta.style.height = Math.min(ta.scrollHeight, 110) + 'px';
});
function send() {
const t = ta.value.trim(); if (!t) return;
add(t, 'u'); ta.value = ''; ta.style.height = '40px';
goBtn.disabled = true; goBtn.classList.remove('active');
setTimeout(() => add('Выполняю: «' + t + '».', 'a'), 800);
}
function add(text, who) {
const d = document.createElement('div'); d.className = 'b ' + who;
const n = new Date();
d.innerHTML = text + '<span class="tm">' + n.getHours() + ':' + String(n.getMinutes()).padStart(2,'0') + '</span>';
thread.appendChild(d); thread.scrollTop = thread.scrollHeight;
}
goBtn.onclick = send;
ta.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } });
// ---- запись с живой расшифровкой ----
const PHRASE = 'посмотри логи за вчерашний день и скажи что там с ошибками';
let tmr = null, sec = 0, wordIdx = 0;
function startRec() {
sec = 0; wordIdx = 0;
liveAsr.classList.add('on'); micBtn.classList.add('on');
document.getElementById('asrLine').textContent = '';
tmr = setInterval(() => {
sec++;
document.getElementById('liveTime').textContent = '0:' + String(sec).padStart(2,'0');
const words = PHRASE.split(' ');
if (wordIdx < words.length) {
document.getElementById('asrLine').textContent = words.slice(0, ++wordIdx).join(' ');
}
}, 450);
}
function stopRec(cancel) {
clearInterval(tmr);
liveAsr.classList.remove('on'); micBtn.classList.remove('on');
if (cancel) return;
const text = document.getElementById('asrLine').textContent;
if (!text) return;
if (sec >= 2) { // короткие — сразу в чат, длинные — в поле
add(text, 'u');
setTimeout(() => add('Записал голосом, разбираю…', 'a'), 700);
}
}
function insertAsText() {
ta.value = (ta.value ? ta.value + ' ' : '') + document.getElementById('asrLine').textContent;
ta.dispatchEvent(new Event('input')); ta.focus(); stopRec(true);
}
function togglePlay(btn) { btn.textContent = btn.textContent === '▶' ? '❚❚' : '▶'; }
micBtn.onclick = () => liveAsr.classList.contains('on') ? stopRec(false) : startRec();
document.addEventListener('keydown', e => {
if (e.ctrlKey && e.key.toLowerCase() === 'r') { e.preventDefault(); micBtn.click(); }
if (e.key === 'Escape' && liveAsr.classList.contains('on')) stopRec(true);
});
</script>
</body>
</html>
-25
View File
@@ -1,25 +0,0 @@
# Вариант 3 — Three-pane / Command center
## Суть
Три панели: диалоги, чат и **состояние агента**. Работа агента видна не как текст
в переписке, а как отдельные раскрывающиеся шаги.
## Ключевые решения
- **Раскладка:** 268 + центр + 306. Третья панель — модель, контекст, инструменты,
память агента, файлы сессии.
- **Шаг агента** — сворачиваемый блок: что делал, сколько заняло, детали по клику.
Это то, чем агент отличается от собеседника, и здесь оно вынесено в структуру.
- **Кнопка записи:** компактная рядом с «Отправить»; при записи снизу появляется
строка: квадрат «стоп», индикатор уровня, текущая расшифровка, таймер.
- **Короткая диктовка** (от 2 с) уходит сразу, текст остаётся в поле.
## Что хорошо
- Видно, что агент делает прямо сейчас и сколько это стоит.
- Удобно на широком экране, ничего не надо прятать.
## Что слабо
- На узком экране (ноутбук 1366) третью панель придётся убирать.
- Настройки сессии — не то, на что хочется смотреть постоянно.
## Кому
Тому, кто держит агента включённым весь день и следит за расходом.
-345
View File
@@ -1,345 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Вариант 3 — Three-pane / Command center</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0F1115; --panel: #161A21; --panel2: #1E242D; --panel3: #252C37; --line: #2A323D;
--fg: #ECEFF3; --dim: #808FA0; --accent: #7C8CF8; --ok: #4ADE80; --warn: #FBBF24; --hot: #F87171;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: #06080A; color: var(--fg); line-height: 1.45;
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 22px;
}
.win { width: 1360px; height: 780px; background: var(--bg); border-radius: 13px; overflow: hidden;
border: 1px solid var(--line); display: flex; flex-direction: column; box-shadow: 0 26px 90px rgba(0,0,0,.7); }
/* ---- строка меню-заголовка ---- */
.titlebar { height: 42px; flex: 0 0 42px; display: flex; align-items: center; gap: 12px;
padding: 0 16px; background: var(--panel); border-bottom: 1px solid var(--line); }
.lights { display: flex; gap: 7px; }
.lights i { width: 11px; height: 11px; border-radius: 50%; }
.lights i:nth-child(1) { background: #FF5F57; } .lights i:nth-child(2) { background: #FEBC2E; }
.lights i:nth-child(3) { background: #28C840; }
.tb-title { font-size: 12.5px; color: var(--dim); }
.tb-title b { color: var(--fg); font-weight: 550; }
.tb-right { margin-left: auto; display: flex; align-items: center; gap: 14px; font-size: 11.5px; color: var(--dim); }
.pill { background: var(--panel2); border: 1px solid var(--line); border-radius: 20px; padding: 3px 11px;
display: flex; align-items: center; gap: 6px; }
.pill .d { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
.body { flex: 1; display: flex; min-height: 0; }
/* ========== ПАНЕЛЬ 1: чаты ========== */
.p1 { width: 268px; flex: 0 0 268px; background: var(--panel); border-right: 1px solid var(--line);
display: flex; flex-direction: column; }
.p-head { padding: 14px 15px 9px; display: flex; align-items: center; justify-content: space-between; }
.p-head h2 { font-size: 11px; letter-spacing: 1.3px; text-transform: uppercase; color: var(--dim); font-weight: 650; }
.p-head button { background: var(--accent); border: none; color: #fff; border-radius: 7px;
width: 24px; height: 24px; cursor: pointer; font-size: 15px; line-height: 1; }
.f-tabs { display: flex; gap: 4px; padding: 0 12px 10px; flex-wrap: wrap; }
.f-tab { font-size: 11px; padding: 4px 9px; border-radius: 7px; color: var(--dim); background: var(--panel2);
cursor: pointer; border: 1px solid transparent; }
.f-tab.on { color: var(--fg); border-color: var(--accent); background: rgba(124,140,248,.14); }
.q { margin: 0 12px 9px; padding: 7px 11px; border-radius: 8px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); font-size: 12.5px; outline: none; }
.q:focus { border-color: var(--accent); } .q::placeholder { color: var(--dim); }
.cf { flex: 1; overflow-y: auto; padding: 0 8px 12px; }
.c { display: flex; gap: 10px; padding: 9px 10px; border-radius: 9px; cursor: pointer; }
.c:hover { background: var(--panel2); }
.c.on { background: var(--panel2); box-shadow: inset 2px 0 0 var(--accent); }
.c .a { width: 32px; height: 32px; flex: 0 0 32px; border-radius: 9px; display: grid; place-items: center;
font-size: 11.5px; font-weight: 700; background: var(--panel3); color: var(--accent); }
.c .m { flex: 1; min-width: 0; }
.c .n { font-size: 13px; font-weight: 550; display: flex; justify-content: space-between; gap: 6px; }
.c .n span.t { font-size: 10px; color: var(--dim); font-weight: 400; }
.c .p { font-size: 11.5px; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
.c.new .n { color: var(--accent); }
/* ========== ПАНЕЛЬ 2: диалог ========== */
.p2 { flex: 1; display: flex; flex-direction: column; min-width: 0; border-right: 1px solid var(--line); }
.p2h { height: 54px; flex: 0 0 54px; display: flex; align-items: center; gap: 11px; padding: 0 18px;
border-bottom: 1px solid var(--line); }
.p2h .nm { font-size: 14px; font-weight: 600; }
.p2h .sub { font-size: 11px; color: var(--dim); }
.p2h .acts { margin-left: auto; display: flex; gap: 6px; }
.p2h .acts button { background: var(--panel2); border: 1px solid var(--line); color: var(--dim);
border-radius: 7px; padding: 5px 11px; font-size: 11.5px; cursor: pointer; }
.p2h .acts button:hover { color: var(--fg); }
.flow { flex: 1; overflow-y: auto; padding: 18px 22px; display: flex; flex-direction: column; gap: 11px; }
.u { align-self: flex-end; max-width: 78%; background: #2A3350; padding: 9px 13px; border-radius: 12px 12px 3px 12px;
font-size: 13.5px; }
.a { align-self: flex-start; max-width: 78%; background: var(--panel2); padding: 9px 13px;
border-radius: 12px 12px 12px 3px; font-size: 13.5px; }
.tm { font-size: 10px; color: var(--dim); margin-top: 4px; }
/* свёрнутый шаг работы агента — то, что отличает агента от чата */
.step { align-self: flex-start; width: 100%; max-width: 92%; background: var(--panel);
border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
.step-h { display: flex; align-items: center; gap: 9px; padding: 8px 12px; cursor: pointer; font-size: 12.5px; }
.step-h:hover { background: var(--panel2); }
.step-h .ico { color: var(--accent); }
.step-h .nm { color: var(--fg); font-weight: 550; }
.step-h .stat { margin-left: auto; font-size: 10.5px; color: var(--ok); }
.step-h .chev { color: var(--dim); font-size: 10px; }
.step-b { display: none; padding: 0 12px 11px 34px; font-size: 12px; color: var(--dim); }
.step.open .step-b { display: block; }
.step-b code { color: #A8B6C8; background: var(--bg); border: 1px solid var(--line); border-radius: 5px;
padding: 1px 6px; font-size: 11px; }
/* ========== ПАНЕЛЬ 3: состояние агента ========== */
.p3 { width: 306px; flex: 0 0 306px; background: var(--panel); display: flex; flex-direction: column; }
.sec { border-bottom: 1px solid var(--line); padding: 13px 15px; }
.sec h3 { font-size: 10.5px; letter-spacing: 1.2px; text-transform: uppercase; color: var(--dim);
font-weight: 650; margin-bottom: 10px; display: flex; align-items: center; gap: 7px; }
.kv { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 7px; }
.kv span:first-child { color: var(--dim); }
.kv span:last-child { font-weight: 550; }
.bar { height: 5px; border-radius: 3px; background: var(--panel3); overflow: hidden; margin-top: 4px; }
.bar i { display: block; height: 100%; background: var(--accent); }
.tool-chip { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 6px 0; }
.tool-chip .s { width: 7px; height: 7px; border-radius: 50%; }
.tool-chip .s.run { background: var(--warn); animation: p 1s infinite; }
.tool-chip .s.ok { background: var(--ok); }
@keyframes p { 50% { opacity: .3; } }
.mem { font-size: 11.5px; color: var(--dim); padding: 7px 9px; background: var(--panel2);
border-radius: 8px; margin-bottom: 6px; line-height: 1.4; }
.mem b { color: var(--fg); font-weight: 550; display: block; font-size: 11.5px; margin-bottom: 2px; }
/* ========== КОМПОЗЕР (низ панели 2) ========== */
.comp { border-top: 1px solid var(--line); background: var(--panel); padding: 13px 18px 14px; }
.rec-state { display: none; align-items: center; gap: 12px; background: var(--bg); border: 1px solid var(--hot);
border-radius: 10px; padding: 10px 13px; margin-bottom: 10px; }
.rec-state.on { display: flex; }
.stop-rec { width: 34px; height: 34px; flex: 0 0 34px; border-radius: 50%; background: var(--hot);
border: none; color: #fff; cursor: pointer; display: grid; place-items: center; font-size: 12px; }
.sq { width: 11px; height: 11px; background: #fff; border-radius: 2px; }
.meter { flex: 1; display: flex; align-items: center; gap: 3px; height: 26px; }
.meter i { width: 3px; background: var(--hot); border-radius: 2px; height: 25%; animation: mn .9s infinite ease-in-out; }
.meter i:nth-child(2n) { animation-delay: .12s; } .meter i:nth-child(3n) { animation-delay: .24s; }
@keyframes mn { 0%,100% { height: 20%; } 50% { height: 100%; } }
.rt { font-variant-numeric: tabular-nums; font-size: 12.5px; color: var(--hot); font-weight: 600; }
.rec-state .live-txt { font-size: 11.5px; color: var(--dim); max-width: 260px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.crow { display: flex; align-items: flex-end; gap: 8px; }
.cbox { flex: 1; display: flex; align-items: flex-end; background: var(--bg); border: 1px solid var(--line);
border-radius: 10px; padding: 9px 12px; }
.cbox:focus-within { border-color: var(--accent); }
.cbox textarea { flex: 1; background: transparent; border: none; outline: none; color: var(--fg);
font-family: inherit; font-size: 13.5px; resize: none; height: 21px; max-height: 110px; line-height: 1.45; }
.cbox textarea::placeholder { color: var(--dim); }
.ic { width: 36px; height: 36px; border-radius: 9px; border: 1px solid var(--line); background: var(--bg);
color: var(--dim); cursor: pointer; display: grid; place-items: center; font-size: 15px; flex: 0 0 36px; }
.ic:hover { color: var(--fg); }
.ic.rec { border-color: var(--accent); color: var(--accent); }
.ic.rec.live { background: var(--hot); border-color: var(--hot); color: #fff; animation: rp 1.4s infinite; }
@keyframes rp { 0%,100% { box-shadow: 0 0 0 0 rgba(248,113,113,.5); } 50% { box-shadow: 0 0 0 9px rgba(248,113,113,0); } }
.send { height: 36px; padding: 0 16px; border-radius: 9px; border: none; background: var(--accent);
color: #fff; font-size: 12.5px; font-weight: 600; cursor: pointer; }
.send:disabled { background: var(--panel2); color: var(--dim); cursor: default; }
.keys { font-size: 10.5px; color: var(--dim); margin-top: 9px; display: flex; gap: 14px; }
kbd { background: var(--panel2); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; font-size: 10px; }
/* окно целиком влезает на любой экран — не режем картинку */
@media (max-height: 820px) { body { padding: 8px; } .window, .win { transform: scale(.78); } }
@media (max-height: 700px) { .window, .win { transform: scale(.66); } }
@media (max-height: 600px) { .window, .win { transform: scale(.56); } }
</style>
</head>
<body>
<div class="win">
<div class="titlebar">
<div class="lights"><i></i><i></i><i></i></div>
<div class="tb-title">agentik-desktop — <b>agentik · рабочий</b></div>
<div class="tb-right">
<span class="pill"><span class="d"></span> агент на связи</span>
<span>Память: 128 заметок</span>
</div>
</div>
<div class="body">
<!-- ======== ПАНЕЛЬ 1 ======== -->
<aside class="p1">
<div class="p-head"><h2>Диалоги</h2><button title="Новый">+</button></div>
<div class="f-tabs">
<div class="f-tab on">Все</div><div class="f-tab">Работа</div>
<div class="f-tab">Инфра</div><div class="f-tab">Дом</div><div class="f-tab">+</div>
</div>
<input class="q" placeholder="Поиск диалога…">
<div class="cf">
<div class="c on"><div class="a">АГ</div><div class="m">
<div class="n"><span>agentik · рабочий</span><span class="t">14:22</span></div>
<div class="p">Собрал отчёт, жду правок</div></div></div>
<div class="c new"><div class="a">ОЧ</div><div class="m">
<div class="n"><span>Отчёт за квартал</span><span class="t">12:05</span></div>
<div class="p">Файл лежит на диске</div></div></div>
<div class="c"><div class="a">РЗ</div><div class="m">
<div class="n"><span>Разбор логов nginx</span><span class="t">вчера</span></div>
<div class="p">Нашёл 3 ошибки 502</div></div></div>
<div class="c"><div class="a">ПО</div><div class="m">
<div class="n"><span>Solana-бэкенд</span><span class="t">вчера</span></div>
<div class="p">Девайс-нет стартовал</div></div></div>
<div class="c"><div class="a">КН</div><div class="m">
<div class="n"><span>Книги</span><span class="t">пн</span></div>
<div class="p">Разложил по полкам</div></div></div>
</div>
</aside>
<!-- ======== ПАНЕЛЬ 2 ======== -->
<section class="p2">
<div class="p2h">
<div>
<div class="nm">agentik · рабочий</div>
<div class="sub">codding-big · контекст 34%</div>
</div>
<div class="acts"><button>Стоп</button><button>Очистить</button></div>
</div>
<div class="flow" id="flow">
<div class="u">Разберись, почему CI красный, и выпусти релиз 3<span class="tm">14:05</span></div>
<div class="step open" onclick="this.classList.toggle('open')">
<div class="step-h"><span class="ico">🔍</span><span class="nm">Поиск причины</span>
<span class="stat">готово · 4 с</span><span class="chev">▼</span></div>
<div class="step-b">Нашёл: в именах тестов длинное тире, сборка идёт в ASCII.<br>
<code>InvalidPathException: unmappable characters</code></div>
</div>
<div class="step" onclick="this.classList.toggle('open')">
<div class="step-h"><span class="ico">✎</span><span class="nm">Правка 16 имён тестов</span>
<span class="stat">готово · 12 с</span><span class="chev">▼</span></div>
<div class="step-b">Заменено на обычный дефис в 4 файлах. Ничего другого не тронуто.</div>
</div>
<div class="step" onclick="this.classList.toggle('open')">
<div class="step-h"><span class="ico">⚙</span><span class="nm">Сборка и проверка</span>
<span class="stat">готово · 1 м 10 с</span><span class="chev">▼</span></div>
<div class="step-b">14 модулей, тесты зелёные, jar-ы собраны.</div>
</div>
<div class="a">Готово. CI зелёный, релиз «3» опубликован: 101 артефакт в Nexus.<span class="tm">14:22</span></div>
</div>
<div class="comp">
<div class="rec-state" id="recState">
<button class="stop-rec" onclick="stopRec(false)"><span class="sq"></span></button>
<div class="meter" id="meter"></div>
<span class="live-txt" id="liveTxt">слушаю…</span>
<span class="rt" id="rt">0:00</span>
<button class="ic" onclick="stopRec(true)" title="Отменить">✕</button>
</div>
<div class="crow">
<div class="cbox"><textarea id="ta" placeholder="Сообщение агенту…"></textarea></div>
<button class="ic rec" id="recBtn" title="Голосовое (Ctrl+R)">🎙</button>
<button class="send" id="sendBtn" disabled>Отправить</button>
</div>
<div class="keys">
<span><kbd>Ctrl</kbd>+<kbd>R</kbd> запись</span>
<span><kbd>Enter</kbd> отправить</span>
<span><kbd>Shift</kbd>+<kbd>Enter</kbd> перенос</span>
</div>
</div>
</section>
<!-- ======== ПАНЕЛЬ 3 ======== -->
<aside class="p3">
<div class="sec">
<h3>Сессия</h3>
<div class="kv"><span>Модель</span><span>codding-big</span></div>
<div class="kv"><span>Контекст</span><span>34 %</span></div>
<div class="bar"><i style="width:34%"></i></div>
<div class="kv" style="margin-top:9px"><span>Ходов</span><span>18</span></div>
<div class="kv"><span>Стоимость</span><span>0.42 ₽</span></div>
</div>
<div class="sec">
<h3>Инструменты</h3>
<div class="tool-chip"><span class="s ok"></span> shell · <span style="color:var(--dim)">готов</span></div>
<div class="tool-chip"><span class="s ok"></span> read_file · <span style="color:var(--dim)">готов</span></div>
<div class="tool-chip"><span class="s run"></span> write_file · <span style="color:var(--dim)">работает</span></div>
<div class="tool-chip"><span class="s ok"></span> memory · <span style="color:var(--dim)">готов</span></div>
</div>
<div class="sec">
<h3>Память агента</h3>
<div class="mem"><b>Про CI agentik</b>длинное тире в именах тестов ломает сборку в ASCII</div>
<div class="mem"><b>Про Nexus</b>публикация идёт через общий action devops/publish</div>
</div>
<div class="sec" style="border-bottom:none">
<h3>Файлы сессии</h3>
<div class="kv"><span>standalone-3.jar</span><span style="color:var(--dim)">240 МБ</span></div>
<div class="kv"><span>agentik-cli-3.jar</span><span style="color:var(--dim)">7.2 МБ</span></div>
</div>
</aside>
</div>
</div>
<script>
const ta = document.getElementById('ta'), sendBtn = document.getElementById('sendBtn');
const flow = document.getElementById('flow');
const recBtn = document.getElementById('recBtn'), recState = document.getElementById('recState');
const liveTxt = document.getElementById('liveTxt');
document.getElementById('meter').innerHTML = Array.from({length: 34}, () => '<i></i>').join('');
ta.addEventListener('input', () => {
sendBtn.disabled = ta.value.trim() === '';
ta.style.height = '21px'; ta.style.height = Math.min(ta.scrollHeight, 110) + 'px';
});
function send() {
const t = ta.value.trim(); if (!t) return;
add(t, 'u'); ta.value = ''; ta.style.height = '21px'; sendBtn.disabled = true;
setTimeout(() => {
const s = document.createElement('div'); s.className = 'step';
s.onclick = () => s.classList.toggle('open');
s.innerHTML = '<div class="step-h"><span class="ico">⚙</span><span class="nm">Выполняю</span>' +
'<span class="stat">идёт…</span><span class="chev">▼</span></div>' +
'<div class="step-b">Задача: ' + t + '</div>';
flow.appendChild(s);
setTimeout(() => {
s.querySelector('.stat').textContent = 'готово · 3 с';
s.querySelector('.stat').style.color = 'var(--ok)';
s.classList.add('open');
add('Сделано: ' + t, 'a');
}, 1400);
}, 300);
}
function add(text, who) {
const d = document.createElement('div'); d.className = who;
const n = new Date();
d.innerHTML = text + '<span class="tm">' + n.getHours() + ':' + String(n.getMinutes()).padStart(2,'0') + '</span>';
flow.appendChild(d); flow.scrollTop = flow.scrollHeight;
}
sendBtn.onclick = send;
ta.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } });
const P = 'разберись с красным си ай и выпусти новый релиз'.split(' ');
let tm = null, sec = 0, wi = 0;
function startRec() {
sec = 0; wi = 0; recState.classList.add('on'); recBtn.classList.add('live');
tm = setInterval(() => {
sec++; document.getElementById('rt').textContent = '0:' + String(sec).padStart(2,'0');
if (wi < P.length) liveTxt.textContent = P.slice(0, ++wi).join(' ');
}, 500);
}
function stopRec(cancel) {
clearInterval(tm); recState.classList.remove('on'); recBtn.classList.remove('live');
if (cancel) return;
const t = liveTxt.textContent;
if (t && sec >= 2) { add(t, 'u'); setTimeout(() => add('Принято голосом, начинаю.', 'a'), 800); }
}
recBtn.onclick = () => recState.classList.contains('on') ? stopRec(false) : startRec();
document.addEventListener('keydown', e => {
if (e.ctrlKey && e.key.toLowerCase() === 'r') { e.preventDefault(); recBtn.click(); }
if (e.key === 'Escape' && recState.classList.contains('on')) stopRec(true);
});
</script>
</body>
</html>
-73
View File
@@ -1,73 +0,0 @@
# Вариант 4 — Настройки: агенты
> **МАКЕТ ОДОБРЕН 2026-09-19.** Одобренная версия — `approved/004-settings-agents.html`
> (без панели-переключателя и без фона основного окна). Этот файл — черновик
> со всеми состояниями и заметками.
## Суть
Два окна друг над другом. Первое — настройки: список агентов и форма нового.
Второе, поверх — **проверка связи**: нажал «Проверить связь» и она идёт
в отдельном окне, а не строкой внутри формы.
## Окно 1: список агентов
- У каждого агента: имя, адрес, состояние — «на связи» зелёным, «не отвечает» серым,
ошибка красным. Видно сразу, а не спрятано в глубине.
- Кнопки в строке: **проверить связь** (⇄), **изменить** (✎), **удалить** (🗑).
Проверка доступна и для уже добавленного агента — из этого же списка.
- Внизу пунктирная «+ Добавить агента».
## Окно 1, вид 2: форма нового агента
- **Название** — с пояснением, что имя придумывает пользователь: сервер своего
имени не сообщает (это из исходников, не догадка).
- **Адрес сервера** — целиком, вместе с путём `/agentik`. Прямо в строке адреса
кнопка **«Проверить связь»**.
- **Чем отличать в списке: цвет или картинка.** Переключатель, выбирается что-то одно.
- **Цвет** — простой выбор из пяти.
- **Картинка** — файл с диска: круглый превью, «Выбрать файл…» и «Убрать».
Картинка показывается там же, где был бы цвет: в списке агентов, в диалогах,
в шапке чата.
- ⚠️ **Картинку копировать себе, а не брать по исходному адресу.** Выбранный файл
сохраняется внутрь клиента; дальше используется только эта копия. Если держать
ссылку на исходный путь, значок пропадёт, когда файл удалят, переименуют или
вынут флешку. То же для картинки по ссылке: скачать и положить себе.
## Окно 2: проверка связи (отдельное, поверх настроек)
Три состояния, сменяются в одном окне:
1. **Идёт** — крутящееся кольцо, «Спрашиваю агента…», под ним адрес.
2. **Отвечает** — зелёная галочка, «Агент отвечает». Подробности: ответ на запрос,
адрес, число диалогов, отклик в миллисекундах. Подсказка «Адрес рабочий».
Кнопка внизу — «Добавить агента» (доступна).
3. **Не отвечает** — красный крестик. Подробности: нет ответа, отклик истёк.
Подсказка: проверить адрес, что агент запущен, что порт открыт и путь `/agentik`
указан целиком. Кнопка внизу заблокирована — добавить нельзя.
Внизу окна всегда есть **«Проверить снова»**. Закрытие окна возвращает к настройкам,
а не выбрасывает из них.
## Почему проверка отдельным окном — лучше
- Места больше: видно и результат, и подробности, и объяснение, что делать дальше.
- Основную форму не дёргает: пока проверяешь — поля на месте, ничего не сбилось.
- Одно и то же окно годится и для формы, и для уже добавленного агента.
## На чём основана проверка (не выдумка)
У сервера агента есть ответ на `GET /agentik/health` (отдаёт «ok») и запрос
списка диалогов. Оба безопасные — ничего не меняют. Адреса взяты из исходников.
⚠️ **Живого агента сейчас не поднято** — на порту 8080 отвечает чужой сервер (404).
Поэтому на настоящем агенте проверка ещё не гонялась: в макете показаны все три
состояния, но замеры времени и число диалогов — условные.
## Фон: папки вернулись
В первом варианте над списком диалогов есть **папки** — «Все чаты», «Работа», «Дом»,
«+». В прошлой версии этого макета я их потерял. Теперь на фоне видны и папки,
и поиск, и значок непрочитанных — как в одобренном варианте 1.
## Плюс: список диалогов с двумя агентами
Диалоги сгруппированы по агентам, у группы цветная точка, у каждой строки цветная
полоска, цвет аватара совпадает с цветом агента из настроек.
## Что слабо / открытые вопросы
- Одного цвета для различения может оказаться мало.
- Где хранится список агентов — не решено.
- Пароля/ключа доступа у библиотеки нет — поля для него нет.
-625
View File
@@ -1,625 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Настройки: агенты</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #121218; --panel: #17212B; --panel2: #1C2733; --line: #232B36;
--fg: #EBEBEB; --dim: #768C9E; --accent: #6AB2F2; --badge: #5EB5F7;
--err: #EC3942; --ok: #63C88A; --user-bubble: #2B5278; --bot-bubble: #1C2733;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: #0A0A0F; color: var(--fg); line-height: 1.45;
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px;
}
.window {
width: 1180px; height: 740px; background: var(--bg); border-radius: 12px;
display: flex; overflow: hidden; border: 1px solid var(--line); position: relative;
box-shadow: 0 24px 80px rgba(0,0,0,.6);
}
/* ============ фон: обычный клиент ============ */
.sidebar { width: 320px; background: var(--panel); border-right: 1px solid var(--line); display: flex; flex-direction: column; }
.sb-head { padding: 14px 16px 10px; }
.sb-title { font-size: 17px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
.search {
margin: 0 16px 10px; padding: 8px 11px; border-radius: 9px; border: 1px solid transparent;
background: var(--panel2); color: var(--fg); font-size: 13px; outline: none; font-family: inherit;
}
.search::placeholder { color: var(--dim); }
/* ── папки: как в выбранном варианте 1 ── */
.folders { display: flex; gap: 6px; padding: 0 16px 10px; overflow-x: auto; }
.folder {
padding: 5px 11px; border-radius: 14px; font-size: 12px; color: var(--dim);
background: var(--panel2); cursor: pointer; white-space: nowrap; border: 1px solid transparent;
}
.folder.active { color: var(--fg); border-color: var(--accent); background: rgba(106,178,242,.12); }
.folder .cnt { color: var(--badge); font-weight: 600; margin-left: 4px; }
.chat-list { flex: 1; overflow-y: auto; padding: 2px 8px 12px; }
.chat-row { display: flex; gap: 11px; padding: 9px 10px; border-radius: 10px; align-items: flex-start; cursor: pointer; }
.chat-row:hover { background: var(--panel2); }
.chat-row.active { background: #233040; }
.avatar {
width: 42px; height: 42px; border-radius: 50%; flex: 0 0 42px; display: grid; place-items: center;
font-size: 15px; font-weight: 600; color: #0F1922; background: var(--accent);
}
.avatar.g2 { background: #86C99A; }
.avatar.g3 { background: #C9A886; }
.chat-mid { flex: 1; min-width: 0; }
.chat-name { font-size: 14px; font-weight: 500; display: flex; justify-content: space-between; gap: 8px; }
.chat-time { font-size: 11px; color: var(--dim); font-weight: 400; }
.chat-prev { font-size: 12.5px; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.row-tail { flex: 0 0 auto; }
.badge { background: var(--badge); color: #0F1922; font-size: 11px; font-weight: 700;
border-radius: 10px; padding: 1px 7px; }
.main { flex: 1; display: flex; flex-direction: column; }
.chat-head { height: 58px; flex: 0 0 58px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 11px; padding: 0 16px; }
.chat-head .avatar { width: 36px; height: 36px; flex: 0 0 36px; font-size: 13px; }
.head-name { font-size: 14.5px; font-weight: 600; }
.head-sub { font-size: 11.5px; color: var(--dim); }
.head-sub .dot { color: var(--ok); }
.ic-btn { width: 32px; height: 32px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 16px; margin-left: auto; }
.ic-btn:hover { background: var(--panel2); color: var(--fg); }
.messages { flex: 1; overflow-y: auto; padding: 18px 24px; display: flex; flex-direction: column; gap: 12px; }
.msg { max-width: 74%; padding: 9px 13px; border-radius: 13px; font-size: 13.5px; }
.msg.user { align-self: flex-end; background: var(--user-bubble); border-bottom-right-radius: 4px; }
.msg.bot { align-self: flex-start; background: var(--bot-bubble); border-bottom-left-radius: 4px; }
/* ============ ЗАТЕМНЕНИЕ (одно на оба окна) ============ */
.scrim { position: absolute; inset: 0; background: rgba(0,0,0,.62); z-index: 100; display: none; }
.scrim.on { display: block; }
.scrim.top { background: rgba(0,0,0,.42); z-index: 200; }
/* ============ ОКНО НАСТРОЕК ============ */
.layer { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; pointer-events: none; }
.layer.on { display: flex; }
.layer > * { pointer-events: auto; }
/* порядок слоёв: настройки 150, проверка поверх 250 */
#layerSettings { z-index: 150; }
#layerCheck { z-index: 250; }
.dialog {
width: 620px; min-width: 620px; flex: 0 0 auto; max-height: 620px; background: #17212B;
border: 1px solid var(--line); border-radius: 14px; display: flex; flex-direction: column;
overflow: hidden; box-shadow: 0 26px 80px rgba(0,0,0,.7);
}
.dialog.small { width: 480px; min-width: 480px; max-height: 420px; }
.d-head { display: flex; align-items: center; gap: 11px; padding: 16px 18px 14px; border-bottom: 1px solid var(--line); }
.d-head h2 { font-size: 16px; font-weight: 600; flex: 1; }
.x-btn { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 17px; }
.x-btn:hover { background: var(--panel2); color: var(--fg); }
.d-body { flex: 1; overflow-y: auto; padding: 14px 18px 6px; }
.d-foot { padding: 13px 18px 16px; border-top: 1px solid var(--line); display: flex; gap: 9px; }
/* список агентов */
.agent-card {
display: flex; align-items: center; gap: 12px; padding: 11px 12px; border-radius: 11px;
background: var(--panel2); margin-bottom: 9px; border: 1px solid transparent;
}
.agent-card:hover { border-color: var(--line); }
.agent-card .avatar { width: 40px; height: 40px; flex: 0 0 40px; font-size: 13px; }
.ag-mid { flex: 1; min-width: 0; }
.ag-name { font-size: 14px; font-weight: 550; }
.ag-addr { font-size: 11.5px; color: var(--dim); font-family: ui-monospace, "SF Mono", Menlo, monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
.state { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--ok); flex: 0 0 auto; }
.state .d { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
.state.off { color: var(--dim); } .state.off .d { background: var(--dim); }
.state.err { color: var(--err); } .state.err .d { background: var(--err); }
.card-acts { display: flex; gap: 4px; flex: 0 0 auto; }
.mini-ic { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 14px; }
.mini-ic:hover { background: var(--panel); color: var(--fg); }
.mini-ic.del:hover { color: var(--err); }
/* форма */
.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; }
.note code { background: var(--panel2); padding: 1px 4px; border-radius: 4px; font-size: 10.5px; }
/* адрес + кнопка проверки в одной строке */
.addr-row { display: flex; gap: 8px; }
.addr-row input { flex: 1; }
.colors { display: flex; gap: 9px; }
.swatch { width: 30px; height: 30px; border-radius: 50%; cursor: pointer; border: 2px solid transparent; }
.swatch.on { border-color: var(--fg); }
/* цвет ИЛИ картинка */
.appearance-tabs { display: flex; gap: 6px; margin-bottom: 11px; }
.appearance-tabs .tab {
padding: 6px 13px; border-radius: 9px; font-size: 12.5px; color: var(--dim);
background: var(--panel2); cursor: pointer; border: 1px solid transparent;
}
.appearance-tabs .tab.on { color: var(--fg); border-color: var(--accent); background: rgba(106,178,242,.12); }
.pane { display: none; }
.pane.on { display: block; }
.pic-row { display: flex; align-items: center; gap: 13px; }
.pic-preview {
width: 54px; height: 54px; border-radius: 50%; flex: 0 0 54px; background: var(--bg);
border: 1px dashed var(--line); display: grid; place-items: center; color: var(--dim);
font-size: 20px; overflow: hidden;
}
.pic-preview.filled { border-style: solid; border-color: var(--line); }
.pic-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pic-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.avatar.pic { background: transparent; padding: 0; overflow: hidden; }
.avatar.pic img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* кнопки */
.btn { border-radius: 10px; padding: 10px 17px; font-size: 13px; cursor: pointer; border: 1px solid var(--line);
background: transparent; color: var(--fg); font-family: inherit; white-space: nowrap; }
.btn:hover { border-color: var(--dim); }
.btn.prim { background: var(--accent); border-color: var(--accent); color: #0F1922; font-weight: 650; }
.btn.prim:disabled { background: var(--panel2); border-color: var(--line); color: var(--dim); cursor: default; }
.btn.dang { color: var(--err); border-color: rgba(236,57,66,.4); }
.btn.dang:hover { border-color: var(--err); background: rgba(236,57,66,.09); }
.btn.sm { padding: 8px 13px; font-size: 12.5px; }
.btn.wide { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
border-style: dashed; color: var(--dim); }
.btn.wide:hover { color: var(--fg); border-color: var(--dim); }
/* ============ ОКНО ПРОВЕРКИ СВЯЗИ ============ */
.spin { width: 17px; height: 17px; border-radius: 50%; border: 2px solid var(--line);
border-top-color: var(--accent); animation: sp .7s linear infinite; flex: 0 0 17px; }
@keyframes sp { to { transform: rotate(360deg); } }
.probe-head { display: flex; align-items: center; gap: 13px; padding: 4px 0 14px; }
.probe-head .big { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center;
font-size: 19px; flex: 0 0 40px; }
.probe-head.ok .big { background: rgba(99,200,138,.15); color: var(--ok); }
.probe-head.err .big { background: rgba(236,57,66,.15); color: var(--err); }
.probe-head.busy .big { background: transparent; padding: 0; }
.probe-head .txt { flex: 1; }
.probe-head .ttl { font-size: 14px; font-weight: 600; }
.probe-head .sub { font-size: 12px; color: var(--dim); margin-top: 2px; }
.probe-list { font-size: 12px; }
.probe-list div { display: flex; justify-content: space-between; gap: 14px; padding: 7px 0;
border-bottom: 1px solid var(--line); }
.probe-list div:last-child { border-bottom: none; }
.probe-list span { color: var(--dim); }
.probe-list b { font-weight: 550; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11.5px; }
.hint-line { font-size: 11.5px; color: var(--dim); margin-top: 12px; padding: 9px 11px;
background: var(--bg); border-radius: 9px; border: 1px solid var(--line); }
/* переключатель состояний — только для показа */
.switcher {
position: fixed; top: 14px; left: 50%; transform: translateX(-50%); display: flex; gap: 7px;
background: rgba(23,33,43,.96); border: 1px solid var(--line); border-radius: 11px; padding: 7px 9px; z-index: 300;
}
.switcher button { background: transparent; border: none; color: var(--dim); font-size: 12px;
padding: 5px 11px; border-radius: 7px; cursor: pointer; font-family: inherit; }
.switcher button.on { background: var(--accent); color: #0F1922; font-weight: 600; }
/* ============ список диалогов с несколькими агентами ============ */
.group-head { display: flex; align-items: center; gap: 8px; padding: 11px 10px 6px; }
.group-head .dot { width: 9px; height: 9px; border-radius: 50%; }
.group-head .nm { font-size: 10.5px; letter-spacing: 1.1px; text-transform: uppercase; color: var(--dim); font-weight: 700; }
.group-head .cnt { font-size: 10.5px; color: var(--dim); margin-left: auto; }
.accent-bar { width: 3px; border-radius: 2px; flex: 0 0 3px; align-self: stretch; }
@media (max-height: 820px) { body { padding: 8px; } .window { transform: scale(.8); } }
@media (max-height: 700px) { .window { transform: scale(.68); } }
</style>
</head>
<body>
<div class="switcher">
<button data-view="list" class="on">Список агентов</button>
<button data-view="add">Добавить агента</button>
<button data-view="check" data-mode="ok">Проверка: отвечает</button>
<button data-view="check" data-mode="err">Проверка: не отвечает</button>
<button data-view="multi">Список диалогов</button>
</div>
<div class="window">
<!-- ===== фон: клиент с папками ===== -->
<aside class="sidebar">
<div class="sb-head">
<div class="sb-title">
<span>Диалоги</span>
<button class="ic-btn" style="margin:0" title="Новый диалог">+</button>
</div>
</div>
<input class="search" placeholder="Поиск диалога…">
<div class="folders">
<div class="folder active">Все чаты <span class="cnt">3</span></div>
<div class="folder">Работа</div>
<div class="folder">Дом</div>
<div class="folder">+</div>
</div>
<div class="chat-list" id="chatArea"></div>
</aside>
<main class="main">
<header class="chat-head">
<div class="avatar">АГ</div>
<div>
<div class="head-name">agentik · рабочий</div>
<div class="head-sub"><span class="dot">●</span> на связи</div>
</div>
<button class="ic-btn" title="Настройки">⚙</button>
</header>
<div class="messages">
<div class="msg user">Проверь, почему падает CI</div>
<div class="msg bot">Нашёл: длинное тире в именах тестов ломает сборку. Починил, CI зелёный.</div>
</div>
</main>
<!-- ===== затемнение под окном настроек ===== -->
<div class="scrim on" id="scrim1"></div>
<!-- ===== ОКНО 1: настройки ===== -->
<div class="layer on" id="layerSettings">
<div class="dialog">
<div class="d-head">
<h2 id="dTitle">Агенты</h2>
<button class="x-btn" onclick="closeAll()">✕</button>
</div>
<!-- ── вид 1: список агентов ── -->
<div class="d-body" id="viewList">
<div class="agent-card">
<div class="avatar">АГ</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 class="card-acts">
<button class="mini-ic" title="Изменить">✎</button>
<button class="mini-ic del" title="Удалить">🗑</button>
</div>
</div>
<div class="agent-card">
<div class="avatar g2">ДМ</div>
<div class="ag-mid">
<div class="ag-name">домашний</div>
<div class="ag-addr">http://192.168.76.199:8080/agentik</div>
</div>
<div class="state err"><span class="d"></span>не отвечает</div>
<div class="card-acts">
<button class="mini-ic" title="Проверить связь">⇄</button>
<button class="mini-ic" title="Изменить">✎</button>
<button class="mini-ic del" title="Удалить">🗑</button>
</div>
</div>
<button class="btn wide" onclick="show('add')">+ Добавить агента</button>
</div>
<!-- ── вид 2: форма ── -->
<div class="d-body" id="viewAdd" style="display:none">
<div class="field">
<label>Название</label>
<input type="text" value="рабочий" placeholder="как вы его будете звать">
<div class="note">Как называть этого агента в списке. Придумываете сами — сервер своего имени не сообщает.</div>
</div>
<div class="field">
<label>Адрес сервера агента</label>
<div class="addr-row">
<input type="text" class="mono" value="http://192.168.88.135:8080/agentik"
placeholder="http://хост:порт/agentik">
<button class="btn sm" onclick="openCheck('form')">Проверить связь</button>
</div>
<div class="note">Адрес целиком, вместе с путём <code>/agentik</code>. Если агент
на этой же машине — <code>http://localhost:8080/agentik</code>.</div>
</div>
<div class="field">
<label>Как отличать в списке</label>
<div class="appearance-tabs">
<div class="tab on" data-tab="color">Цвет</div>
<div class="tab" data-tab="pic">Картинка</div>
</div>
<!-- цвет -->
<div class="pane on" id="paneColor">
<div class="colors">
<div class="swatch on" style="background:#6AB2F2"></div>
<div class="swatch" style="background:#86C99A"></div>
<div class="swatch" style="background:#C9A886"></div>
<div class="swatch" style="background:#B79AE0"></div>
<div class="swatch" style="background:#E28C8C"></div>
</div>
<div class="note">Цвет остаётся простым выбором: им помечаются диалоги агента
в списке — полоска у строки и точка у группы.</div>
</div>
<!-- картинка -->
<div class="pane" id="panePic">
<div class="pic-row">
<div class="pic-preview" id="picPreview">🖼</div>
<div class="pic-actions">
<button class="btn sm" onclick="pickPic()">Выбрать файл…</button>
<button class="btn sm" id="picClear" onclick="clearPic()" style="display:none">Убрать</button>
</div>
</div>
<div class="note">Картинка копируется <b>внутрь клиента</b> — по исходному
адресу не подтягивается. Иначе значок пропадёт, если файл удалят,
флешку вынут или папку переименуют.</div>
</div>
</div>
</div>
<div class="d-foot" id="footList">
<button class="btn prim" style="flex:1" onclick="closeAll()">Готово</button>
</div>
<div class="d-foot" id="footAdd" style="display:none">
<button class="btn" style="flex:1" onclick="show('list')">Отмена</button>
<button class="btn prim" style="flex:1">Добавить агента</button>
</div>
</div>
</div>
<!-- ===== затемнение под окном проверки ===== -->
<div class="scrim top" id="scrim2"></div>
<!-- ===== ОКНО 2: проверка связи (поверх настроек) ===== -->
<div class="layer" id="layerCheck">
<div class="dialog small">
<div class="d-head">
<h2>Проверка связи</h2>
<button class="x-btn" onclick="closeCheck()">✕</button>
</div>
<div class="d-body">
<div class="probe-head" id="probeHead">
<div class="big" id="probeIcon"></div>
<div class="txt">
<div class="ttl" id="probeTitle">Спрашиваю агента…</div>
<div class="sub" id="probeSub">http://192.168.88.135:8080/agentik</div>
</div>
</div>
<div class="probe-list" id="probeList" style="display:none">
<div><span>Ответ на запрос</span><b id="p1">—</b></div>
<div><span>Отвечает по адресу</span><b id="p2">—</b></div>
<div><span>Диалогов у агента</span><b id="p3">—</b></div>
<div><span>Отклик</span><b id="p4">—</b></div>
</div>
<div class="hint-line" id="probeHint" style="display:none"></div>
</div>
<div class="d-foot">
<button class="btn" style="flex:1" id="probeAgain" onclick="runCheck()">Проверить снова</button>
<button class="btn prim" style="flex:1" id="probeOk" onclick="closeCheck()" disabled>Добавить агента</button>
</div>
</div>
</div>
</div>
<script>
// ---------------- данные списка диалогов ----------------
const CHATS_SINGLE = `
<div class="chat-row active">
<div class="avatar">АГ</div>
<div class="chat-mid">
<div class="chat-name"><span>agentik · рабочий</span><span class="chat-time">14:22</span></div>
<div class="chat-prev">Собрал отчёт, жду правок</div>
</div>
<div class="row-tail"><span class="badge">2</span></div>
</div>
<div class="chat-row">
<div class="avatar g2">ОЧ</div>
<div class="chat-mid">
<div class="chat-name"><span>Отчёт за квартал</span><span class="chat-time">12:05</span></div>
<div class="chat-prev">Файл лежит на диске</div>
</div>
</div>
<div class="chat-row">
<div class="avatar g3">РЗ</div>
<div class="chat-mid">
<div class="chat-name"><span>Разбор логов nginx</span><span class="chat-time">вчера</span></div>
<div class="chat-prev">Нашёл 3 ошибки 502</div>
</div>
</div>`;
const CHATS_MULTI = `
<div class="group-head"><span class="dot" style="background:#6AB2F2"></span>
<span class="nm">рабочий</span><span class="cnt">2 диалога</span></div>
<div class="chat-row active">
<div class="accent-bar" style="background:#6AB2F2"></div>
<div class="avatar">АГ</div>
<div class="chat-mid">
<div class="chat-name"><span>CI и релизы</span><span class="chat-time">14:22</span></div>
<div class="chat-prev">Собрал отчёт, жду правок</div>
</div>
</div>
<div class="chat-row">
<div class="accent-bar" style="background:#6AB2F2"></div>
<div class="avatar">ОЧ</div>
<div class="chat-mid">
<div class="chat-name"><span>Отчёт за квартал</span><span class="chat-time">12:05</span></div>
<div class="chat-prev">Файл лежит на диске</div>
</div>
</div>
<div class="group-head"><span class="dot" style="background:#86C99A"></span>
<span class="nm">домашний</span><span class="cnt">1 диалог</span></div>
<div class="chat-row">
<div class="accent-bar" style="background:#86C99A"></div>
<div class="avatar g2">КН</div>
<div class="chat-mid">
<div class="chat-name"><span>Книги и полки</span><span class="chat-time">вчера</span></div>
<div class="chat-prev">Разложил по авторам</div>
</div>
</div>`;
const chatArea = document.getElementById('chatArea');
chatArea.innerHTML = CHATS_SINGLE;
// ---------------- окна ----------------
function el(id){ return document.getElementById(id); }
function show(v) {
const isList = (v === 'list');
el('viewList').style.display = isList ? 'block' : 'none';
el('viewAdd').style.display = isList ? 'none' : 'block';
el('footList').style.display = isList ? 'flex' : 'none';
el('footAdd').style.display = isList ? 'none' : 'flex';
el('dTitle').textContent = isList ? 'Агенты' : 'Новый агент';
openSettings();
}
function openSettings(){
el('scrim1').classList.add('on');
el('layerSettings').classList.add('on');
}
// закрыть окно настроек (вместе с окном проверки, если оно открыто)
function closeAll(){
el('layerCheck').classList.remove('on');
el('scrim2').classList.remove('on');
el('layerSettings').classList.remove('on');
el('scrim1').classList.remove('on');
}
function closeCheck(){
el('layerCheck').classList.remove('on');
el('scrim2').classList.remove('on');
}
// ---------------- ОКНО ПРОВЕРКИ СВЯЗИ ----------------
// отдельное окно поверх настроек: из формы нового агента или из списка
function openCheck(from, addr) {
el('scrim2').classList.add('on');
el('layerCheck').classList.add('on');
el('probeOk').textContent = (from === 'form') ? 'Добавить агента' : 'Готово';
window.__checkAddr = addr || 'http://192.168.88.135:8080/agentik';
runCheck();
}
function runCheck() {
const head = el('probeHead');
const list = el('probeList');
const hint = el('probeHint');
const icon = el('probeIcon');
const addr = window.__checkAddr || 'http://192.168.88.135:8080/agentik';
head.className = 'probe-head busy';
icon.outerHTML = '<span class="spin" id="probeIcon"></span>';
el('probeTitle').textContent = 'Спрашиваю агента…';
el('probeSub').textContent = addr;
list.style.display = 'none';
hint.style.display = 'none';
el('probeOk').disabled = true;
setTimeout(() => {
const sp = document.getElementById('probeIcon');
sp.outerHTML = '<div class="big" id="probeIcon"></div>';
const fail = (window.__checkMode === 'err');
if (fail) {
el('probeHead').className = 'probe-head err';
el('probeIcon').textContent = '✕';
el('probeTitle').textContent = 'Агент не отвечает';
el('probeSub').textContent = addr;
el('p1').textContent = 'нет ответа'; el('p2').textContent = '—';
el('p3').textContent = '—'; el('p4').textContent = '5000 мс (истекло)';
list.style.display = 'block';
hint.style.display = 'block';
hint.textContent = 'Проверьте адрес и что агент запущен. Убедитесь, что порт открыт и путь /agentik указан целиком.';
el('probeOk').disabled = true;
} else {
el('probeHead').className = 'probe-head ok';
el('probeIcon').textContent = '✓';
el('probeTitle').textContent = 'Агент отвечает';
el('probeSub').textContent = addr;
el('p1').textContent = 'ok'; el('p2').textContent = addr;
el('p3').textContent = '4'; el('p4').textContent = '38 мс';
list.style.display = 'block';
hint.style.display = 'block';
hint.textContent = 'Адрес рабочий. Можно добавлять этого агента.';
el('probeOk').disabled = false;
}
}, 1400);
}
// ---------------- переключатель для показа ----------------
document.querySelectorAll('.switcher button').forEach(b => b.onclick = () => {
document.querySelectorAll('.switcher button').forEach(x => x.classList.remove('on'));
b.classList.add('on');
const v = b.dataset.view;
if (v === 'multi') {
chatArea.innerHTML = CHATS_MULTI;
closeAll();
return;
}
chatArea.innerHTML = CHATS_SINGLE;
if (v === 'list') { show('list'); closeCheck(); }
if (v === 'add') { show('add'); closeCheck(); }
if (v === 'check') {
window.__checkMode = b.dataset.mode || 'ok';
const okAddr = 'http://192.168.88.135:8080/agentik';
const badAddr = 'http://192.168.76.199:8080/agentik';
show(window.__checkMode === 'ok' ? 'add' : 'list');
openCheck('form', window.__checkMode === 'ok' ? okAddr : badAddr);
}
});
document.querySelectorAll('.swatch').forEach(s => s.onclick = () => {
document.querySelectorAll('.swatch').forEach(x => x.classList.remove('on'));
s.classList.add('on');
});
// ---------------- как отличать: цвет ИЛИ картинка ----------------
document.querySelectorAll('.appearance-tabs .tab').forEach(tab => tab.onclick = () => {
document.querySelectorAll('.appearance-tabs .tab').forEach(x => x.classList.remove('on'));
tab.classList.add('on');
const pic = (tab.dataset.tab === 'pic');
el('paneColor').classList.toggle('on', !pic);
el('panePic').classList.toggle('on', pic);
});
// выбор картинки: в настоящем клиенте — системный диалог выбора файла,
// а выбранный файл КОПИРУЕТСЯ в папку клиента (не ссылка на исходный путь)
function pickPic() {
// для макета показываем готовый пример вместо файлового диалога
setPic('data:image/svg+xml;utf8,' + encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="54" height="54">
<rect width="54" height="54" fill="#86C99A"/>
<circle cx="27" cy="21" r="8" fill="#121218"/>
<path d="M10 50 Q27 30 44 50 Z" fill="#121218"/>
</svg>`));
}
function setPic(src) {
const p = el('picPreview');
p.innerHTML = `<img src="${src}" alt="">`;
p.classList.add('filled');
el('picClear').style.display = '';
}
function clearPic() {
const p = el('picPreview');
p.innerHTML = '🖼';
p.classList.remove('filled');
el('picClear').style.display = 'none';
}
</script>
</body>
</html>
-72
View File
@@ -1,72 +0,0 @@
# Вариант 5 — Новый диалог: выбор ассистента
> **МАКЕТ ОДОБРЕН 2026-09-19.** Одобренная версия — `approved/005-new-chat-picker.html`.
> Этот файл — черновик со всеми состояниями.
## Суть
Окно, которое появляется при нажатии «+» (новый диалог), **когда ассистентов
больше одного**. Спрашиваем, в каком ассистенте создать диалог.
**Когда окно показывается и когда нет:**
| Сколько ассистентов | Что происходит по «+» |
|---|---|
| **0** | Кнопки «+» **нет вообще** — создавать диалог не в ком |
| **1** | Окно **не показывается**: диалог создаётся сразу в этом ассистенте |
| **2 и больше** | Окно с выбором — этот макет |
## Состояния (переключатель сверху)
1. **Выбор: 2 ассистента** — основной вид. Сверху подсказка «В каком ассистенте
создать диалог?», ниже список. Первый отмечен **по умолчанию** — чтобы создать
диалог, достаточно одного нажатия.
2. **Третий не отвечает** — выбран ассистент без связи. Окно **предупреждает**,
но выбрать даёт: ассистент мог просто перезапускаться. Из списка не выкидываем.
3. **Ассистент один** — окна нет, затемнения нет.
4. **Ассистентов нет** — кнопки «+» нет, пустой список объясняет, что делать,
и ведёт в настройки.
5. **Диалог создан** — новый диалог первым в списке, шапка чата показывает
выбранного ассистента (его цвет и имя).
## Что видно в списке выбора
- **Значок ассистента** — цвет или картинка, как настроено в `004-settings-agents`.
- **Имя** и **адрес сервера** — чтобы различить похожие имена.
- **Состояние связи**: «на связи» зелёным, «не отвечает» красным.
- **Кружок отметки** справа.
- У ассистента без связи предупреждение появляется **при выборе**, а не сразу:
пока не выбран — шума нет.
## Решения
- **Выбран первый по умолчанию.** Открыл окно → сразу «Создать диалог» работает.
Лишнее движение нужно только если нужен не первый.
- **Ассистента без связи выбирать можно.** Запрещать нечестно: связь может
пропасть на секунду, а диалог человек создаёт осознанно. Окно предупреждает
словами, но не мешает.
- **Кнопку «+» при нуле ассистентов не рисуем.** Нажимать нечего — диалог
создавать не в ком.
- **Окно маленькое (480 px) и без вкладок.** Одно действие — один выбор.
Окно настроек (`004`) — 620 px, оно про управление; здесь просто выбор.
## Что НЕ делаем
- Не даём создавать диалог «без ассистента» — по библиотеке `client` диалог
живёт **внутри** агента, отдельно его не создать.
- Не показываем окно ради одного варианта — это лишнее нажатие.
- Не прячем ассистентов без связи (см. решения выше).
## Открытые вопросы
- Что делать, если **все** ассистенты не отвечают: показывать окно со всеми
красными или сразу писать «никто не отвечает»? Сейчас — показываем список.
- Нужен ли в этом окне шорткат «создать и сразу написать», то есть открывать
диалог с фокусом в поле ввода. Пока просто создаём.
- Нужна ли в окне ссылка «добавить нового ассистента» — сейчас её нет, за
добавлением идём в настройки.
## Из чего собрано
Стиль и классы взяты из `004-settings-agents` — тема та же (`#121218`, `#17212B`,
`#6AB2F2`), окно и затемнение устроены так же. Ничего нового не изобреталось.
-436
View File
@@ -1,436 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Новый диалог — выбор ассистента</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #121218; --panel: #17212B; --panel2: #1C2733; --line: #232B36;
--fg: #EBEBEB; --dim: #768C9E; --accent: #6AB2F2; --badge: #5EB5F7;
--err: #EC3942; --ok: #63C88A; --user-bubble: #2B5278; --bot-bubble: #1C2733;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: #0A0A0F; color: var(--fg); line-height: 1.45;
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 100vh; padding: 24px; gap: 16px;
}
/* ============ переключатель состояний ============ */
.switcher { display: flex; gap: 7px; flex-wrap: wrap; justify-content: center; max-width: 1180px; }
.switcher button {
padding: 7px 13px; border-radius: 9px; border: 1px solid var(--line); background: var(--panel);
color: var(--dim); font-size: 12.5px; cursor: pointer; font-family: inherit;
}
.switcher button:hover { color: var(--fg); }
.switcher button.on { background: var(--accent); border-color: var(--accent); color: #0F1922; font-weight: 600; }
/* ============ окно клиента ============ */
.window {
width: 1180px; height: 740px; background: var(--bg); border-radius: 12px;
display: flex; overflow: hidden; border: 1px solid var(--line); position: relative;
box-shadow: 0 24px 80px rgba(0,0,0,.6);
}
.sidebar { width: 320px; background: var(--panel); border-right: 1px solid var(--line); display: flex; flex-direction: column; }
.sb-head { padding: 14px 16px 10px; }
.sb-title { font-size: 17px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
.search {
margin: 0 16px 10px; padding: 8px 11px; border-radius: 9px; border: 1px solid transparent;
background: var(--panel2); color: var(--fg); font-size: 13px; outline: none; font-family: inherit;
}
.search::placeholder { color: var(--dim); }
.folders { display: flex; gap: 6px; padding: 0 16px 10px; overflow-x: auto; }
.folder {
padding: 5px 11px; border-radius: 14px; font-size: 12px; color: var(--dim);
background: var(--panel2); cursor: pointer; white-space: nowrap; border: 1px solid transparent;
}
.folder.active { color: var(--fg); border-color: var(--accent); background: rgba(106,178,242,.12); }
.folder .cnt { color: var(--badge); font-weight: 600; margin-left: 4px; }
.chat-list { flex: 1; overflow-y: auto; padding: 2px 8px 12px; }
.chat-row { display: flex; gap: 11px; padding: 9px 10px; border-radius: 10px; align-items: flex-start; cursor: pointer; }
.chat-row:hover { background: var(--panel2); }
.chat-row.active { background: #233040; }
.avatar {
width: 42px; height: 42px; border-radius: 50%; flex: 0 0 42px; display: grid; place-items: center;
font-size: 15px; font-weight: 600; color: #0F1922; background: var(--accent);
}
.avatar.g2 { background: #86C99A; }
.avatar.g3 { background: #C9A886; }
/* аватар-картинка: клиент показывает СВОЮ копию файла */
.avatar.pic {
background: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Crect width='40' height='40' fill='%232F4A63'/%3E%3Ccircle cx='20' cy='15' r='6.5' fill='%23DCE6F0'/%3E%3Cpath d='M7 40c0-7.5 5.8-12 13-12s13 4.5 13 12z' fill='%23DCE6F0'/%3E%3C/svg%3E") center/cover;
}
.chat-mid { flex: 1; min-width: 0; }
.chat-name { font-size: 14px; font-weight: 500; display: flex; justify-content: space-between; gap: 8px; }
.chat-time { font-size: 11px; color: var(--dim); font-weight: 400; }
.chat-prev { font-size: 12.5px; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.row-tail { flex: 0 0 auto; }
.badge { background: var(--badge); color: #0F1922; font-size: 11px; font-weight: 700; border-radius: 10px; padding: 1px 7px; }
/* кнопка «+» рядом с заголовком. Состояние .hidden — при нуле ассистентов */
.ic-btn { width: 32px; height: 32px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 16px; }
.ic-btn:hover { background: var(--panel2); color: var(--fg); }
.add-btn { font-size: 19px; color: var(--accent); }
.add-btn.hidden { display: none; }
/* пустой список */
.empty { padding: 44px 26px; text-align: center; }
.empty .big { font-size: 34px; opacity: .28; margin-bottom: 12px; }
.empty .ttl { font-size: 14px; font-weight: 600; margin-bottom: 7px; }
.empty .sub { font-size: 12.5px; color: var(--dim); line-height: 1.55; }
.empty .link { color: var(--accent); cursor: pointer; text-decoration: none; border-bottom: 1px dashed var(--accent); }
.main { flex: 1; display: flex; flex-direction: column; }
.chat-head { height: 58px; flex: 0 0 58px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 11px; padding: 0 16px; }
.chat-head .avatar { width: 36px; height: 36px; flex: 0 0 36px; font-size: 13px; }
.head-name { font-size: 14.5px; font-weight: 600; }
.head-sub { font-size: 11.5px; color: var(--dim); }
.head-sub .dot { color: var(--ok); }
.chat-head .ic-btn { margin-left: auto; }
.messages { flex: 1; overflow-y: auto; padding: 18px 24px; display: flex; flex-direction: column; gap: 12px; }
.msg { max-width: 74%; padding: 9px 13px; border-radius: 13px; font-size: 13.5px; }
.msg.user { align-self: flex-end; background: var(--user-bubble); border-bottom-right-radius: 4px; }
.msg.bot { align-self: flex-start; background: var(--bot-bubble); border-bottom-left-radius: 4px; }
/* ============ затемнение и окно ============ */
.scrim { position: absolute; inset: 0; background: rgba(0,0,0,.62); z-index: 100; display: none; }
.scrim.on { display: block; }
.layer { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; z-index: 150; pointer-events: none; }
.layer.on { display: flex; }
.layer > * { pointer-events: auto; }
.dialog {
width: 480px; min-width: 480px; flex: 0 0 auto; max-height: 600px; background: var(--panel);
border: 1px solid var(--line); border-radius: 14px; display: flex; flex-direction: column;
overflow: hidden; box-shadow: 0 26px 80px rgba(0,0,0,.7);
}
.d-head { display: flex; align-items: center; gap: 11px; padding: 16px 18px 14px; border-bottom: 1px solid var(--line); }
.d-head h2 { font-size: 16px; font-weight: 600; flex: 1; }
.x-btn { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 17px; }
.x-btn:hover { background: var(--panel2); color: var(--fg); }
.d-body { flex: 1; overflow-y: auto; padding: 14px 18px 8px; }
.d-foot { padding: 13px 18px 16px; border-top: 1px solid var(--line); display: flex; gap: 9px; }
.d-lead { font-size: 12.5px; color: var(--dim); margin-bottom: 12px; }
/* ── вариант выбора ассистента ── */
.agent-opt {
display: flex; align-items: center; gap: 12px; padding: 11px 12px; border-radius: 11px;
background: var(--panel2); margin-bottom: 9px; cursor: pointer;
border: 1.5px solid transparent;
}
.agent-opt:hover { border-color: #33414F; }
.agent-opt.on { border-color: var(--accent); background: rgba(106,178,242,.10); }
.agent-opt .avatar { width: 40px; height: 40px; flex: 0 0 40px; font-size: 13px; }
.ag-mid { flex: 1; min-width: 0; }
.ag-name { font-size: 14px; font-weight: 550; }
.ag-addr { font-size: 11.5px; color: var(--dim); font-family: ui-monospace, "SF Mono", Menlo, monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
.ag-state { font-size: 11px; display: flex; align-items: center; gap: 5px; margin-top: 4px; }
.ag-state .d { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
.ag-state.ok { color: var(--ok); }
.ag-state.off { color: var(--dim); } .ag-state.off .d { background: var(--dim); }
.ag-state.err { color: var(--err); } .ag-state.err .d { background: var(--err); }
/* кружок-галочка выбора */
.pick { width: 20px; height: 20px; flex: 0 0 20px; border-radius: 50%; border: 2px solid #3D4A58; }
.agent-opt.on .pick { border-color: var(--accent); background: var(--accent); position: relative; }
.agent-opt.on .pick::after {
content: ""; position: absolute; left: 5px; top: 1.5px; width: 5px; height: 9px;
border: solid #0F1922; border-width: 0 2px 2px 0; transform: rotate(42deg);
}
/* предупреждение под списком */
.warn {
display: none; gap: 8px; align-items: flex-start; margin-top: 4px; padding: 10px 12px;
border-radius: 10px; background: rgba(236,57,66,.09); border: 1px solid rgba(236,57,66,.32);
font-size: 12px; color: #F0A6AA;
}
.warn.on { display: flex; }
.warn .i { flex: 0 0 auto; }
/* кнопки */
.btn {
padding: 10px 16px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel2);
color: var(--fg); font-size: 13.5px; cursor: pointer; font-family: inherit;
}
.btn:hover { border-color: #3D4A58; }
.btn.prim { background: var(--accent); border-color: var(--accent); color: #0F1922; font-weight: 600; }
.btn.prim:disabled { background: #253544; border-color: #253544; color: #5C6B7A; cursor: not-allowed; }
</style>
</head>
<body>
<div class="switcher">
<button data-view="pick" class="on">Выбор: 2 ассистента</button>
<button data-view="offline">Третий не отвечает</button>
<button data-view="one">Ассистент один</button>
<button data-view="zero">Ассистентов нет</button>
<button data-view="made">Диалог создан</button>
</div>
<div class="window">
<!-- ================= СПИСОК ДИАЛОГОВ ================= -->
<aside class="sidebar">
<div class="sb-head">
<div class="sb-title">
<span>Диалоги</span>
<button class="ic-btn add-btn" id="newChatBtn" title="Новый диалог">+</button>
</div>
</div>
<input class="search" placeholder="Поиск диалога…" id="searchBox">
<div class="folders" id="folders">
<div class="folder active">Все чаты <span class="cnt" id="cnt">2</span></div>
<div class="folder">Работа</div>
<div class="folder">Дом</div>
<div class="folder">+</div>
</div>
<div class="chat-list" id="chatList"></div>
</aside>
<!-- ================= ЧАТ ================= -->
<main class="main" id="mainPane">
<header class="chat-head">
<div class="avatar" id="headAv">АГ</div>
<div>
<div class="head-name" id="headName">agentik · рабочий</div>
<div class="head-sub"><span class="dot">●</span> на связи</div>
</div>
<button class="ic-btn" title="Настройки">⚙</button>
</header>
<div class="messages" id="msgs">
<div class="msg user">Проверь, почему падает CI</div>
<div class="msg bot">Нашёл: длинное тире в именах тестов ломает сборку. Починил, CI зелёный.</div>
</div>
</main>
<div class="scrim" id="scrim"></div>
<!-- ================= ОКНО: НОВЫЙ ДИАЛОГ ================= -->
<div class="layer" id="layerNew">
<div class="dialog">
<div class="d-head">
<h2>Новый диалог</h2>
<button class="x-btn" onclick="closeDialog()">✕</button>
</div>
<div class="d-body">
<div class="d-lead" id="lead">В каком ассистенте создать диалог?</div>
<div id="agentOpts">
<div class="agent-opt on" data-id="a1" data-ok="1" data-tag="АГ">
<div class="avatar">АГ</div>
<div class="ag-mid">
<div class="ag-name">agentik · рабочий</div>
<div class="ag-addr">http://192.168.88.135:8080/agentik</div>
<div class="ag-state ok"><span class="d"></span>на связи</div>
</div>
<div class="pick"></div>
</div>
<div class="agent-opt" data-id="a2" data-ok="1" data-tag="ДМ">
<div class="avatar g2">ДМ</div>
<div class="ag-mid">
<div class="ag-name">домашний</div>
<div class="ag-addr">http://192.168.76.199:8080/agentik</div>
<div class="ag-state ok"><span class="d"></span>на связи</div>
</div>
<div class="pick"></div>
</div>
<div class="agent-opt" data-id="a3" data-ok="0" data-tag="КА" data-pic="1">
<div class="avatar pic"></div>
<div class="ag-mid">
<div class="ag-name">Кот-ассистент</div>
<div class="ag-addr">http://192.168.88.24:8080/agentik</div>
<div class="ag-state err"><span class="d"></span>не отвечает</div>
</div>
<div class="pick"></div>
</div>
</div>
<div class="warn" id="warn">
<span class="i">⚠</span>
<span id="warnText">Ассистент сейчас не отвечает. Диалог создать можно, но
сообщение не уйдёт, пока он не поднимется.</span>
</div>
</div>
<div class="d-foot">
<button class="btn" style="flex:1" onclick="closeDialog()">Отмена</button>
<button class="btn prim" style="flex:1" id="createBtn" disabled>Создать диалог</button>
</div>
</div>
</div>
</div>
<script>
const el = id => document.getElementById(id);
// ───────── данные состояний ─────────
const AG_ALL = `
<div class="agent-opt on" data-id="a1" data-ok="1">
<div class="avatar">АГ</div>
<div class="ag-mid">
<div class="ag-name">agentik · рабочий</div>
<div class="ag-addr">http://192.168.88.135:8080/agentik</div>
<div class="ag-state ok"><span class="d"></span>на связи</div>
</div>
<div class="pick"></div>
</div>
<div class="agent-opt" data-id="a2" data-ok="1">
<div class="avatar g2">ДМ</div>
<div class="ag-mid">
<div class="ag-name">домашний</div>
<div class="ag-addr">http://192.168.76.199:8080/agentik</div>
<div class="ag-state ok"><span class="d"></span>на связи</div>
</div>
<div class="pick"></div>
</div>
<div class="agent-opt" data-id="a3" data-ok="0">
<div class="avatar pic"></div>
<div class="ag-mid">
<div class="ag-name">Кот-ассистент</div>
<div class="ag-addr">http://192.168.88.24:8080/agentik</div>
<div class="ag-state err"><span class="d"></span>не отвечает</div>
</div>
<div class="pick"></div>
</div>`;
const CHATS_TWO = `
<div class="chat-row active">
<div class="avatar">АГ</div>
<div class="chat-mid">
<div class="chat-name"><span>agentik · рабочий</span><span class="chat-time">14:22</span></div>
<div class="chat-prev">Собрал отчёт, жду правок</div>
</div>
<div class="row-tail"><span class="badge">2</span></div>
</div>
<div class="chat-row">
<div class="avatar g2">ДМ</div>
<div class="chat-mid">
<div class="chat-name"><span>домашний</span><span class="chat-time">12:05</span></div>
<div class="chat-prev">Файл лежит на диске</div>
</div>
</div>`;
const CHATS_NEW = `
<div class="chat-row active">
<div class="avatar g2">ДМ</div>
<div class="chat-mid">
<div class="chat-name"><span>домашний</span><span class="chat-time">сейчас</span></div>
<div class="chat-prev">Новый диалог</div>
</div>
</div>
<div class="chat-row">
<div class="avatar">АГ</div>
<div class="chat-mid">
<div class="chat-name"><span>agentik · рабочий</span><span class="chat-time">14:22</span></div>
<div class="chat-prev">Собрал отчёт, жду правок</div>
</div>
<div class="row-tail"><span class="badge">2</span></div>
</div>`;
const CHATS_EMPTY = `
<div class="empty">
<div class="big">💬</div>
<div class="ttl">Диалогов пока нет</div>
<div class="sub">Ассистенты ещё не добавлены.<br>
Добавьте первого в <a class="link" onclick="alert('Откроется окно настроек — макет 004-settings-agents')">настройках</a>.</div>
</div>`;
// ───────── управление окном ─────────
function setAgents(html) { el('agentOpts').innerHTML = html; bindOpts(); }
function bindOpts() {
document.querySelectorAll('.agent-opt').forEach(o => o.onclick = () => {
document.querySelectorAll('.agent-opt').forEach(x => x.classList.remove('on'));
o.classList.add('on');
el('createBtn').disabled = false;
el('warn').classList.toggle('on', o.dataset.ok === '0');
});
}
function openDialog() {
el('scrim').classList.add('on');
el('layerNew').classList.add('on');
el('createBtn').disabled = false;
}
function closeDialog() {
el('scrim').classList.remove('on');
el('layerNew').classList.remove('on');
}
el('newChatBtn').onclick = openDialog;
document.querySelectorAll('.switcher button').forEach(b => b.onclick = () => {
document.querySelectorAll('.switcher button').forEach(x => x.classList.remove('on'));
b.classList.add('on');
render(b.dataset.view);
});
function render(v) {
closeDialog();
el('warn').classList.remove('on');
if (v === 'pick') {
el('chatList').innerHTML = CHATS_TWO;
el('folders').style.display = ''; el('searchBox').style.display = '';
el('cnt').textContent = '2';
el('newChatBtn').classList.remove('hidden');
setAgents(AG_ALL);
el('createBtn').disabled = false;
openDialog();
}
if (v === 'offline') {
el('chatList').innerHTML = CHATS_TWO;
el('folders').style.display = ''; el('searchBox').style.display = '';
el('cnt').textContent = '2';
el('newChatBtn').classList.remove('hidden');
setAgents(AG_ALL);
const off = document.querySelector('.agent-opt[data-ok="0"]');
document.querySelectorAll('.agent-opt').forEach(x => x.classList.remove('on'));
off.classList.add('on');
el('warn').classList.add('on');
el('createBtn').disabled = false;
openDialog();
}
if (v === 'one') {
el('chatList').innerHTML = CHATS_TWO;
el('folders').style.display = ''; el('searchBox').style.display = '';
el('cnt').textContent = '2';
el('newChatBtn').classList.remove('hidden');
}
if (v === 'zero') {
el('chatList').innerHTML = CHATS_EMPTY;
el('folders').style.display = 'none'; el('searchBox').style.display = 'none';
el('newChatBtn').classList.add('hidden');
}
if (v === 'made') {
el('chatList').innerHTML = CHATS_NEW;
el('folders').style.display = ''; el('searchBox').style.display = '';
el('cnt').textContent = '2';
el('newChatBtn').classList.remove('hidden');
el('headAv').textContent = 'ДМ'; el('headAv').className = 'avatar g2';
el('headName').textContent = 'домашний';
}
}
bindOpts();
render('pick');
</script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
# sketches — макеты основного окна
**Здесь рабочий черновик основного окна десктопного клиента.** Это не
«утверждено» — `approved/` живёт отдельно. Правило: одобренное живёт только
там, а в `sketches/` — то, что ещё сравниваем и допиливаем.
## Раскладка: rail / voice-first
Узкая рейка иконок слева (диалоги / память / инструменты / новый),
посередине список диалогов, справа чат. Голосовое сообщение — главное
действие: крупная акцентная кнопка микрофона в композере.
**Цветовая схема:** тёмная тема, общий набор токенов из `../THEMES.md`.
Палитра согласована с Android-клиентом — расширения (`--panel2`, `--line`,
`--badge`, `--ok`, `--user-bubble`, `--bot-bubble`, `--a1`..`--a5`) тут
уже есть.
## Файлы
| Файл | Состояние |
|---|---|
| `style.css` | Общий CSS для всех макетов |
| `1-chats.html` | Слева — список диалогов с цветными полосками агентов, справа — пусто («выберите диалог») |
| `2-chat.html` | Открытый диалог: сообщения, ход работы агента, композер с полем + микрофон + отправка |
| `3-recording.html` | Идёт запись — в композере волна + таймер + ⏸ + ✕ + ➤, одна строка 46 px |
| `4-empty.html` | Список пуст, ни одного ассистента не добавлено |
| `5-new-chat.html` | Модалка по центру — выбор ассистента из добавленных |
| `6-groups.html` | Модалка управления группами: список с действиями «изменить» / «удалить» |
| `7-groups-add.html` | Создание группы: имя + пустой список диалогов + кнопка «+ Добавить диалог» |
| `8-groups-edit.html` | Переименование + теги уже добавленных диалогов с `✕` + блок «ОПАСНОЕ» с удалением группы |
| `9-groups-pick-chat.html` | Выбор диалога для добавления в группу: поиск + чекбоксы по диалогам |
## Как смотреть
Открыть файл в браузере — самодостаточный, без сборки и без зависимостей.
+1426
View File
File diff suppressed because it is too large Load Diff
+610
View File
@@ -0,0 +1,610 @@
/* === палитра тёмной темы (согласована с Android, см. ../THEMES.md) === */
:root {
--bg: #121218; --panel: #17212B; --panel2: #1C2733; --line: #232B36;
--fg: #EBEBEB; --dim: #768C9E; --accent: #6AB2F2; --badge: #5EB5F7;
--err: #EC3942; --ok: #63C88A;
--user-bubble: #2B5278; --bot-bubble: #1C2733;
--a1: #6AB2F2; --a2: #86C99A; --a3: #C9A886; --a4: #B79AE0; --a5: #E28C8C;
--on-accent: #0F1922;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: #0A0A0F; color: var(--fg); line-height: 1.45;
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 18px;
}
.win {
width: 1240px; height: 760px; background: var(--bg);
border-radius: 14px; overflow: hidden; border: 1px solid #2A3340;
display: flex; box-shadow: 0 28px 90px rgba(0,0,0,.7);
}
.cap {
position: absolute; margin-top: -22px; font-size: 12px; color: var(--dim);
}
@media (max-height: 820px) { body { padding: 8px; } .win { transform: scale(.78); } }
@media (max-height: 700px) { .win { transform: scale(.66); } }
@media (max-height: 600px) { .win { transform: scale(.56); } }
/* === средняя колонка: список диалогов === */
.list {
width: 296px; flex: 0 0 296px; background: var(--panel); border-right: 1px solid var(--line);
display: flex; flex-direction: column;
}
.l-head { padding: 14px 16px 10px; display: flex; align-items: center; gap: 8px; }
.l-head h1 { font-size: 15.5px; font-weight: 650; letter-spacing: .2px; }
.chips { display: flex; gap: 6px; padding: 0 14px 10px; overflow-x: auto; }
.chip {
padding: 6px 11px; border-radius: 16px; font-size: 12px; color: var(--dim);
background: transparent; white-space: nowrap; border: 1px solid transparent;
}
.chip:hover { background: var(--panel2); }
.chip.on { color: var(--fg); border-color: var(--accent); background: rgba(106,178,242,.13); }
.chip b { color: var(--badge); font-weight: 700; margin-left: 3px; }
.chip-act {
margin-left: auto; flex: 0 0 auto; padding: 6px 9px; border-radius: 11px;
background: transparent; color: var(--dim); font-size: 13px; cursor: pointer;
border: 1px solid transparent; display: inline-flex; align-items: center;
justify-content: center;
}
.chip-act:hover { color: var(--fg); background: var(--panel2); }
.chip-act svg { width: 14px; height: 14px; stroke: currentColor; fill: none;
stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.q {
margin: 0 14px 10px; height: 36px; border-radius: 11px; border: 1px solid var(--line);
background: var(--bg); color: var(--dim); font-size: 12.5px;
display: flex; align-items: center; gap: 8px; padding: 0 12px;
}
.rows { flex: 1; overflow-y: auto; padding: 0 8px 12px; }
.row {
display: flex; gap: 11px; padding: 9px 10px; border-radius: 12px; cursor: pointer;
position: relative;
}
.row:hover { background: var(--panel2); }
.row.on { background: #233040; }
.row .strip {
position: absolute; left: 0; top: 10px; bottom: 10px; width: 3px;
background: var(--a1); border-radius: 3px;
}
.row .ava {
width: 40px; height: 40px; flex: 0 0 40px; border-radius: 50%;
display: grid; place-items: center; font-size: 14px; font-weight: 700; color: var(--on-accent);
}
.row .mid { flex: 1; min-width: 0; }
.row .l1 { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
.row .l1 .n { font-size: 13.5px; font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row .l1 .t { font-size: 10.5px; color: var(--dim); flex: 0 0 auto; }
.row .l2 { display: flex; justify-content: space-between; gap: 8px; align-items: center; margin-top: 2px; }
.row .l2 .p { font-size: 12px; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bd { background: var(--badge); color: var(--on-accent); border-radius: 11px; font-size: 10.5px; font-weight: 700; padding: 1px 6px; flex: 0 0 auto; }
/* === основная зона чата === */
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.m-head {
height: 60px; flex: 0 0 60px; display: flex; align-items: center; gap: 11px;
padding: 0 22px; border-bottom: 1px solid var(--line);
}
.m-head .ava { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; font-size: 14px; font-weight: 700; color: var(--on-accent); }
.m-head .hd .t1 { font-size: 14.5px; font-weight: 600; }
.m-head .hd .t2 { font-size: 11.5px; color: var(--dim); }
.m-head .hd .t2.ok { color: var(--ok); }
.m-head .hd .t2.bad { color: var(--err); }
.m-head .ghost { margin-left: auto; display: flex; gap: 6px; }
.m-head .ghost button {
background: transparent; border: 1px solid var(--line); color: var(--dim);
border-radius: 8px; padding: 5px 11px; font-size: 11.5px; cursor: pointer;
}
.m-head .ghost button:hover { color: var(--fg); border-color: var(--dim); }
.thread { flex: 1; overflow-y: auto; padding: 20px 26px; display: flex; flex-direction: column; gap: 11px; }
.b {
max-width: 76%; padding: 9px 13px; border-radius: 14px; font-size: 13.5px;
}
.b.u { align-self: flex-end; background: var(--user-bubble); border-bottom-right-radius: 5px; }
.b.a { align-self: flex-start; background: var(--bot-bubble); border-bottom-left-radius: 5px; }
.b b { font-weight: 650; }
.b code { background: rgba(255,255,255,.08); border-radius: 4px; padding: 1px 5px; font-size: 12px; font-family: "DejaVu Sans Mono", monospace; }
.b .tm { font-size: 10.5px; color: var(--dim); margin-top: 5px; }
.cur { display: inline-block; width: 7px; height: 13px; background: var(--accent); vertical-align: -2px; margin-left: 2px; }
/* ход работы агента — компактная раскрывающаяся строка */
.act-line {
align-self: flex-start; display: flex; align-items: center; gap: 9px;
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
padding: 7px 12px; font-size: 12.5px; color: var(--dim); max-width: 78%;
}
.act-line .g { color: var(--accent); }
.act-line .nm { color: var(--fg); }
.act-line .ch { margin-left: auto; font-size: 11px; }
/* === композер: voice-first — микрофон главный === */
.composer { border-top: 1px solid var(--line); background: var(--panel); padding: 14px 22px 14px; }
.rec-panel {
display: flex; align-items: center; gap: 10px; background: var(--bg);
border: 1px solid var(--err); border-radius: 13px; padding: 4px 6px 4px 13px; height: 46px;
}
.wave { display: flex; align-items: center; gap: 3px; height: 28px; flex: 1; min-width: 0; }
.wave i { width: 3px; background: var(--err); border-radius: 2px; }
.wave i:nth-child(1) { height: 30%; } .wave i:nth-child(2) { height: 70%; }
.wave i:nth-child(3) { height: 45%; } .wave i:nth-child(4) { height: 100%; }
.wave i:nth-child(5) { height: 55%; } .wave i:nth-child(6) { height: 80%; }
.wave i:nth-child(7) { height: 35%; } .wave i:nth-child(8) { height: 65%; }
.wave i:nth-child(9) { height: 25%; }
.rec-time {
font-variant-numeric: tabular-nums; font-size: 13px; color: var(--err);
font-weight: 700; flex: 0 0 auto;
}
.rec-btn {
width: 34px; height: 34px; flex: 0 0 34px; border-radius: 10px;
background: transparent; border: none; color: var(--dim); font-size: 14px;
display: grid; place-items: center; cursor: pointer;
}
.rec-btn:hover { color: var(--fg); background: var(--panel2); }
.rec-btn.rec-send { background: var(--accent); color: var(--on-accent); font-size: 15px; }
.rec-btn.rec-send:hover { background: #82BEF5; }
.composer-row { display: flex; align-items: center; gap: 10px; }
.field {
flex: 1; min-width: 0; height: 46px; border-radius: 13px; background: var(--bg);
border: 1px solid var(--line); display: flex; align-items: center; padding: 0 14px;
font-size: 13.5px; color: var(--dim);
}
.field.focus { border-color: var(--accent); }
.mic {
width: 52px; height: 52px; flex: 0 0 52px; border-radius: 50%;
border: 2px solid var(--accent); background: var(--bg); color: var(--accent);
font-size: 19px; display: grid; place-items: center; cursor: pointer;
}
.mic:hover { background: rgba(106,178,242,.13); }
.mic.rec { background: var(--err); border-color: var(--err); color: #fff; }
.send {
width: 46px; height: 46px; flex: 0 0 46px; border-radius: 13px; border: none;
background: var(--accent); color: var(--on-accent); font-size: 17px;
display: grid; place-items: center; cursor: pointer;
}
.send.off { background: var(--panel2); color: var(--dim); cursor: default; }
.rec-note { font-size: 11px; color: var(--dim); margin-top: 7px; text-align: center; }
/* стрим: вместо поля — широкая красная кнопка «Стоп» */
.stop-wide {
height: 50px; border-radius: 13px; border: 1px solid rgba(236,57,66,.45);
background: rgba(236,57,66,.10); color: #F0A6AA; font-size: 13.5px; font-weight: 600;
display: flex; align-items: center; justify-content: center; gap: 9px; cursor: pointer;
}
/* === пустое состояние === */
.empty {
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; text-align: center; padding: 30px 34px; gap: 14px;
}
.empty .big {
width: 78px; height: 78px; border-radius: 22px; background: var(--panel);
border: 1px solid var(--line); display: grid; place-items: center; font-size: 32px;
color: var(--dim);
}
.empty h3 { font-size: 17px; font-weight: 600; }
.empty p { font-size: 13px; color: var(--dim); line-height: 1.55; max-width: 340px; }
.btn {
border-radius: 12px; padding: 11px 18px; font-size: 13.5px; cursor: pointer;
border: 1px solid var(--line); background: transparent; color: var(--fg); font-family: inherit;
}
.btn.prim { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 650; }
/* === модалка нового диалога === */
.scrim {
position: absolute; inset: 0; background: rgba(0,0,0,.6);
display: flex; align-items: center; justify-content: center; padding: 22px;
}
.modal {
width: 480px; max-height: 84%; background: var(--panel);
border: 1px solid var(--line); border-radius: 16px; display: flex;
flex-direction: column; overflow: hidden;
}
.modal h2 { font-size: 16px; font-weight: 650; padding: 16px 20px 4px; }
.modal .sub { font-size: 12.5px; color: var(--dim); padding: 0 20px 12px; }
.modal .body { flex: 1; overflow-y: auto; padding: 4px 12px 8px; }
.agent {
display: flex; align-items: center; gap: 12px; padding: 13px 14px;
border-radius: 12px; cursor: pointer; border: 1px solid transparent;
}
.agent:hover { background: var(--panel2); }
.agent.on { border-color: var(--accent); background: rgba(106,178,242,.07); }
.agent .sign { width: 38px; height: 38px; flex: 0 0 38px; border-radius: 50%; display: grid; place-items: center; font-size: 14px; font-weight: 700; color: var(--on-accent); }
.agent .mid { flex: 1; min-width: 0; }
.agent .nm { font-size: 14px; font-weight: 550; }
.agent .ad { font-size: 11.5px; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.agent .ok { color: var(--ok); font-size: 11.5px; }
.agent .bad { color: var(--err); font-size: 11.5px; }
.agent .radio { width: 22px; height: 22px; flex: 0 0 22px; border-radius: 50%; border: 2px solid var(--line); }
.agent.on .radio { border-color: var(--accent); background: radial-gradient(circle, var(--accent) 0 6px, transparent 7px); }
.modal .foot { padding: 12px 16px 16px; display: flex; gap: 10px; border-top: 1px solid var(--line); }
.modal .foot .btn:first-child { flex: 1; }
.modal .warn {
margin: 0 14px 10px; padding: 10px 12px; border-radius: 11px;
background: rgba(236,57,66,.09); border: 1px solid rgba(236,57,66,.32);
font-size: 12px; color: #F0A6AA;
}
/* === модалка управления группами === */
.modal.groups { width: 540px; }
.modal.groups h2 { display: flex; align-items: center; gap: 10px; padding: 14px 20px 6px; }
.modal.groups h2 .x { margin-left: auto; width: 28px; height: 28px; border-radius: 8px; border: none; background: transparent; color: var(--dim); cursor: pointer; font-size: 16px; }
.modal.groups h2 .x:hover { background: var(--panel2); color: var(--fg); }
.modal.groups .body { padding: 6px 14px 10px; }
.grp {
display: flex; align-items: center; gap: 12px; padding: 11px 13px;
border-radius: 11px; background: var(--panel2); margin-bottom: 8px;
border: 1px solid transparent;
}
.grp:hover { border-color: var(--line); }
.grp .mid { flex: 1; min-width: 0; }
.grp .nm { font-size: 14px; font-weight: 550; }
.grp .sub { font-size: 11.5px; color: var(--dim); margin-top: 2px; }
.grp .acts { display: flex; gap: 4px; }
.grp.sys { background: transparent; border: 1px dashed var(--line); }
.grp .lock { font-size: 11px; color: var(--dim); }
.mini-ic { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 14px; display: inline-flex;
align-items: center; justify-content: center; line-height: 1; }
.mini-ic svg, .card-acts svg { width: 16px; height: 16px; stroke: currentColor; fill: none;
stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.mini-ic.del svg, .card-acts.del svg { fill: currentColor; stroke: none; }
.mini-ic:hover { background: var(--panel); color: var(--fg); }
.mini-ic.del:hover { color: var(--err); }
.modal .info {
display: flex; gap: 9px; align-items: flex-start; margin: 4px 14px 12px;
padding: 10px 13px; border-radius: 10px; background: var(--bg); border: 1px solid var(--line);
font-size: 11.5px; color: var(--dim); line-height: 1.5;
}
.modal .danger-zone { margin: 14px 14px 0; padding-top: 14px; border-top: 1px solid var(--line); }
.modal .danger-zone .lbl { font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
color: var(--dim); font-weight: 700; margin-bottom: 8px; }
.modal .form-field { margin: 0 14px 14px; }
.modal .form-field label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 6px; }
.modal .form-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;
}
.modal .form-field input:focus { border-color: var(--accent); }
.modal .form-field .note { font-size: 11px; color: var(--dim); margin-top: 6px; line-height: 1.5; }
.btn.dang { color: var(--err); border-color: rgba(236,57,66,.4); }
.btn.dang:hover { border-color: var(--err); background: rgba(236,57,66,.09); }
.btn.dang.solid { background: var(--err); border-color: var(--err); color: #FFF; font-weight: 650; }
/* === теги диалогов в группе + кнопка «добавить диалог» === */
.modal .tags { margin: 0 14px 12px; }
.modal .tags .lbl { font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
color: var(--dim); font-weight: 700; margin-bottom: 9px; }
.modal .tags .empty { font-size: 12px; color: var(--dim); padding: 9px 12px;
background: var(--bg); border: 1px dashed var(--line); border-radius: 10px; }
.modal .tags-list { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 9px; }
.chat-tag {
display: inline-flex; align-items: center; gap: 7px;
padding: 4px 8px 4px 4px; background: var(--bg);
border: 1px solid var(--line); border-radius: 9px;
}
.chat-tag .ava-sm { width: 22px; height: 22px; flex: 0 0 22px; border-radius: 50%;
display: grid; place-items: center; font-size: 11px; font-weight: 700; color: var(--on-accent); }
.chat-tag .nm { font-size: 12.5px; }
.chat-tag .x { width: 18px; height: 18px; border-radius: 50%; border: none;
background: transparent; color: var(--dim); cursor: pointer;
font-size: 10px; display: grid; place-items: center; }
.chat-tag .x:hover { color: var(--err); background: rgba(236,57,66,.12); }
.add-chat-btn {
display: flex; align-items: center; justify-content: center; gap: 7px;
padding: 9px; width: 100%; cursor: pointer; font-size: 13px; font-family: inherit;
background: transparent; color: var(--dim);
border: 1px dashed var(--line); border-radius: 10px;
}
.add-chat-btn:hover { color: var(--fg); border-color: var(--dim); }
/* === модалка выбора диалога для группы === */
.modal.pick { width: 460px; }
.modal.pick .q {
margin: 4px 14px 8px; padding: 9px 12px; border-radius: 10px;
border: 1px solid var(--line); background: var(--bg);
color: var(--fg); font-size: 13px; outline: none; font-family: inherit;
}
.modal.pick .q:focus { border-color: var(--accent); }
.modal.pick .q::placeholder { color: var(--dim); }
.modal.pick .body { padding: 0 8px 8px; max-height: 360px; }
.pick-row {
display: flex; align-items: center; gap: 11px; padding: 9px 10px;
border-radius: 10px; cursor: pointer; border: 1px solid transparent;
}
.pick-row:hover { background: var(--panel2); }
.pick-row.in { opacity: .42; cursor: default; }
.pick-row .ava-sm { width: 32px; height: 32px; flex: 0 0 32px; border-radius: 50%;
display: grid; place-items: center; font-size: 12px; font-weight: 700; color: var(--on-accent); }
.pick-row .mid { flex: 1; min-width: 0; }
.pick-row .nm { font-size: 13.5px; }
.pick-row .ad { font-size: 11.5px; color: var(--dim); margin-top: 1px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pick-row .check { width: 20px; height: 20px; flex: 0 0 20px; border-radius: 6px;
border: 1.5px solid var(--line); display: grid; place-items: center; color: transparent; font-size: 11px; }
.pick-row .check.on { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
/* === 004 · окно агентов (список + форма + поповер проверки) === */
.dialog {
width: 620px; min-width: 620px; flex: 0 0 auto; max-height: 620px;
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
display: flex; flex-direction: column; overflow: hidden;
box-shadow: 0 26px 80px rgba(0,0,0,.7);
}
.dialog.small { width: 480px; min-width: 480px; max-height: 420px; }
.d-head { display: flex; align-items: center; gap: 11px; padding: 16px 18px 14px;
border-bottom: 1px solid var(--line); }
.d-head h2 { font-size: 16px; font-weight: 600; flex: 1; }
.x-btn { width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; font-size: 17px; }
.x-btn:hover { background: var(--panel2); color: var(--fg); }
.d-body { flex: 1; overflow-y: auto; padding: 14px 18px 6px; }
.d-foot { padding: 13px 18px 16px; border-top: 1px solid var(--line); display: flex; gap: 9px; }
.agent-card {
display: flex; align-items: center; gap: 12px; padding: 11px 12px; border-radius: 11px;
background: var(--panel2); margin-bottom: 9px; border: 1px solid transparent;
}
.agent-card:hover { border-color: var(--line); }
.ag-mid { flex: 1; min-width: 0; }
.ag-name { font-size: 14px; font-weight: 550; }
.ag-desc { font-size: 11.5px; color: var(--fg); opacity: .7;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
.ag-addr { font-size: 11.5px; color: var(--dim); font-family: ui-monospace, "SF Mono", Menlo, monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.state { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--ok); flex: 0 0 auto; }
.state .d { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
.state.off { color: var(--dim); } .state.off .d { background: var(--dim); }
.state.err { color: var(--err); } .state.err .d { background: var(--err); }
.card-acts { display: flex; gap: 4px; flex: 0 0 auto; }
.field { margin-bottom: 15px; }
.field label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 6px; }
.field input {
width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); font-size: 13.5px; outline: none; font-family: inherit;
}
.field input:focus { border-color: var(--accent); }
.field input.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; }
.field .note { font-size: 11px; color: var(--dim); margin-top: 6px; line-height: 1.5; }
.note code, .hint-line code { background: var(--panel2); padding: 1px 4px; border-radius: 4px;
font-size: 10.5px; }
.addr-row { display: flex; gap: 8px; }
.addr-row input { flex: 1; }
.appearance-tabs { display: flex; gap: 6px; margin-bottom: 11px; }
.appearance-tabs .tab {
padding: 6px 13px; border-radius: 9px; font-size: 12.5px; color: var(--dim);
background: var(--panel2); cursor: pointer; border: 1px solid transparent;
}
.appearance-tabs .tab.on { color: var(--fg); border-color: var(--accent);
background: rgba(106,178,242,.12); }
.pane { display: none; } .pane.on { display: block; }
.colors { display: flex; gap: 9px; }
.swatch { width: 30px; height: 30px; border-radius: 50%; cursor: pointer;
border: 2px solid transparent; }
.swatch.on { border-color: var(--fg); }
.pic-row { display: flex; align-items: center; gap: 13px; }
.pic-preview {
width: 54px; height: 54px; border-radius: 50%; flex: 0 0 54px; background: var(--bg);
border: 1px dashed var(--line); display: grid; place-items: center; color: var(--dim);
font-size: 20px; overflow: hidden;
}
.pic-preview.filled { border-style: solid; }
.pic-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pic-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.btn.sm { padding: 8px 13px; font-size: 12.5px; }
.btn.wide { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
border-style: dashed; color: var(--dim); }
.btn.wide:hover { color: var(--fg); border-color: var(--dim); }
.hint-line { font-size: 11.5px; color: var(--dim); margin: 2px 0 16px; padding: 9px 11px;
background: var(--bg); border-radius: 9px; border: 1px solid var(--line); line-height: 1.5; }
/* поповер проверки связи */
.layer { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
z-index: 250; }
.spin { width: 17px; height: 17px; border-radius: 50%; border: 2px solid var(--line);
border-top-color: var(--accent); animation: sp .7s linear infinite; flex: 0 0 17px; }
@keyframes sp { to { transform: rotate(360deg); } }
.probe-head { display: flex; align-items: center; gap: 13px; padding: 4px 0 14px; }
.probe-head .big { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center;
font-size: 19px; flex: 0 0 40px; }
.probe-head.ok .big { background: rgba(99,200,138,.15); color: var(--ok); }
.probe-head.err .big { background: rgba(236,57,66,.15); color: var(--err); }
.probe-head.busy .big { background: transparent; padding: 0; }
.probe-head .txt { flex: 1; min-width: 0; }
.probe-head .ttl { font-size: 14px; font-weight: 600; }
.probe-head .sub { font-size: 12px; color: var(--dim); margin-top: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.probe-list { font-size: 12px; }
.probe-list div { display: flex; justify-content: space-between; gap: 14px; padding: 7px 0;
border-bottom: 1px solid var(--line); }
.probe-list div:last-child { border-bottom: none; }
.probe-list span { color: var(--dim); }
.probe-list b { font-weight: 550; font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 11.5px; }
/* === окно клиента .window + сайдбар .sidebar (вариант sidebar-utility) === */
.window {
width: 1180px; height: 740px; background: var(--bg); border-radius: 12px;
display: flex; overflow: hidden; border: 1px solid var(--line); position: relative;
box-shadow: 0 24px 80px rgba(0,0,0,.6);
}
.sidebar { width: 320px; background: var(--panel); border-right: 1px solid var(--line);
display: flex; flex-direction: column; flex: 0 0 320px; }
.sb-head { padding: 14px 16px 10px; }
.sb-title { font-size: 17px; font-weight: 600; display: flex; align-items: center;
justify-content: space-between; }
.icon-btn { width: 30px; height: 30px; border-radius: 8px; border: none;
background: transparent; color: var(--dim); cursor: pointer; display: grid; place-items: center;
font-size: 17px; }
.icon-btn:hover { background: var(--panel2); color: var(--fg); }
.search { padding: 8px 12px; border-radius: 9px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); font-size: 13px; outline: none; font-family: inherit; }
.search:focus { border-color: var(--accent); }
.search::placeholder { color: var(--dim); }
/* лента папок (sidebar-utility) */
.folders { display: flex; gap: 6px; padding: 6px 16px 10px; overflow-x: auto; }
.folder { padding: 5px 11px; border-radius: 14px; font-size: 12px; color: var(--dim);
background: var(--panel2); cursor: pointer; white-space: nowrap; border: 1px solid transparent; }
.folder.active { color: var(--fg); border-color: var(--accent); background: rgba(106,178,242,.12); }
.folder .cnt { color: var(--badge); font-weight: 600; margin-left: 4px; }
.folder.act { margin-left: auto; background: transparent; color: var(--dim);
display: grid; place-items: center; padding: 5px 9px; font-size: 13px; }
.folder.act svg { width: 14px; height: 14px; stroke: currentColor; fill: none;
stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.folder.act:hover { color: var(--fg); background: var(--panel2); }
.chat-list { flex: 1; overflow-y: auto; padding: 4px 8px 12px; }
.chat-row { display: flex; gap: 11px; padding: 9px 10px; border-radius: 10px; cursor: pointer;
align-items: flex-start; }
.chat-row:hover { background: var(--panel2); }
.chat-row.active { background: #233040; }
.chat-mid { flex: 1; min-width: 0; }
.chat-name { font-size: 14px; font-weight: 500; display: flex; justify-content: space-between; gap: 8px; }
.chat-time { font-size: 11px; color: var(--dim); font-weight: 400; flex: 0 0 auto; }
.chat-prev { font-size: 12.5px; color: var(--dim); white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; margin-top: 2px; }
.badge { background: var(--badge); color: #0F1922; border-radius: 10px; font-size: 11px;
font-weight: 700; padding: 1px 6px; min-width: 19px; text-align: center; }
.row-tail { display: flex; flex-direction: column; align-items: flex-end; gap: 5px; flex: 0 0 auto; }
/* правая часть .main — заголовок чата */
.chat-head { height: 58px; flex: 0 0 58px; border-bottom: 1px solid var(--line);
display: flex; align-items: center; gap: 11px; padding: 0 16px; }
.chat-head .avatar { width: 36px; height: 36px; flex: 0 0 36px; font-size: 13px; }
.head-meta { flex: 1; }
.head-name { font-size: 14.5px; font-weight: 600; }
.head-sub { font-size: 11.5px; color: var(--dim); }
.head-sub .dot { color: var(--ok); }
.back-btn { display: none; width: 32px; height: 32px; flex: 0 0 32px; border-radius: 8px;
border: none; background: transparent; color: var(--fg); cursor: pointer; font-size: 17px;
place-items: center; }
.back-btn:hover { background: var(--panel); }
/* сообщения */
.messages { flex: 1; overflow-y: auto; padding: 18px 24px; display: flex; flex-direction: column;
gap: 12px; }
.msg { max-width: 74%; padding: 9px 13px; border-radius: 13px; font-size: 13.5px; }
.msg.user { align-self: flex-end; background: var(--user-bubble); border-bottom-right-radius: 4px; }
.msg.bot { align-self: flex-start; background: var(--bot-bubble); border-bottom-left-radius: 4px; }
.msg .meta { font-size: 10.5px; color: var(--dim); margin-top: 5px; }
.think { align-self: flex-start; max-width: 80%; border-left: 2px solid var(--accent);
background: rgba(106,178,242,.07); padding: 8px 12px; border-radius: 0 10px 10px 0;
font-size: 12.5px; color: var(--dim); }
.think b { color: var(--accent); font-weight: 600; }
.think .rest { display: none; }
.tool { align-self: flex-start; max-width: 80%; background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: 8px 12px; font-size: 12.5px;
display: flex; align-items: center; gap: 9px; }
.tool .gear { color: var(--accent); }
.tool code { color: var(--dim); font-size: 11.5px; }
/* композер */
.composer { border-top: 1px solid var(--line); padding: 12px 16px 14px; background: var(--panel); }
.attach-strip { display: flex; gap: 8px; margin-bottom: 9px; }
.attach-strip:empty { display: none; }
.composer .chip { background: var(--panel2); border: 1px solid var(--line); border-radius: 8px;
padding: 5px 9px; font-size: 11.5px; color: var(--dim); display: flex; align-items: center; gap: 7px; }
.composer .chip .x { cursor: pointer; color: var(--err); font-weight: 700; }
.input-row { display: flex; align-items: flex-end; gap: 9px; }
.input-wrap { flex: 1; display: flex; align-items: flex-end; background: var(--bg);
border: 1px solid var(--line); border-radius: 11px; padding: 9px 12px; gap: 9px; }
.input-wrap:focus-within { border-color: var(--accent); }
.input-wrap textarea { flex: 1; background: transparent; border: none; outline: none; resize: none;
color: var(--fg); font-family: inherit; font-size: 13.5px; line-height: 1.45; max-height: 120px;
height: 22px; }
.input-wrap textarea::placeholder { color: var(--dim); }
.rec-btn { width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--line);
background: var(--bg); color: var(--dim); cursor: pointer; display: grid; place-items: center;
font-size: 17px; flex: 0 0 40px; transition: all .18s; }
.rec-btn:hover { color: var(--fg); border-color: var(--dim); }
.rec-btn.recording { background: var(--err); border-color: var(--err); color: #fff;
animation: pulse 1.4s infinite; }
@keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(236,57,66,.55); }
50% { box-shadow: 0 0 0 9px rgba(236,57,66,0); } }
.send-btn { height: 40px; padding: 0 18px; border-radius: 11px; border: none;
background: var(--accent); color: #0F1922; font-size: 13px; font-weight: 600; cursor: pointer; }
.send-btn:disabled { background: var(--panel2); color: var(--dim); cursor: default; }
.hint { font-size: 11px; color: var(--dim); margin-top: 8px; display: flex;
justify-content: space-between; }
kbd { background: var(--panel2); border: 1px solid var(--line); border-radius: 4px;
padding: 1px 5px; font-size: 10.5px; }
/* запись — полоса над полем */
.rec-panel { display: flex; align-items: center; gap: 12px; margin-bottom: 10px;
background: var(--bg); border: 1px solid var(--err); border-radius: 11px; padding: 9px 13px; }
.wave { display: flex; align-items: center; gap: 3px; height: 22px; flex: 1; }
.wave i { width: 3px; background: var(--err); border-radius: 2px; height: 30%;
animation: wv .9s infinite ease-in-out; }
.wave i:nth-child(2n) { animation-delay: .12s; }
.wave i:nth-child(3n) { animation-delay: .24s; }
.wave i:nth-child(4n) { animation-delay: .36s; }
@keyframes wv { 0%,100% { height: 22%; } 50% { height: 100%; } }
.rec-time { font-variant-numeric: tabular-nums; font-size: 13px; color: var(--err); font-weight: 600; }
.rec-actions { display: flex; gap: 8px; }
.mini { border: 1px solid var(--line); background: var(--panel2); color: var(--fg);
border-radius: 8px; padding: 5px 11px; font-size: 12px; cursor: pointer; }
.mini.ok { background: var(--accent); border-color: var(--accent); color: #0F1922; font-weight: 600; }
.mini.cancel { color: var(--err); }
.asr-note { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--dim);
margin-bottom: 8px; padding-left: 2px; }
.asr-note .live { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
/* === 005 · выбор ассистента (agent-opt) === */
.d-lead { font-size: 12.5px; color: var(--dim); margin-bottom: 12px; }
.agent-opt { display: flex; align-items: center; gap: 12px; padding: 11px 12px;
border-radius: 11px; background: var(--panel2); margin-bottom: 9px; cursor: pointer;
border: 1.5px solid transparent; }
.agent-opt:hover { border-color: #33414F; }
.agent-opt.on { border-color: var(--accent); background: rgba(106,178,242,.10); }
.agent-opt .pick { width: 20px; height: 20px; flex: 0 0 20px; border-radius: 50%;
border: 2px solid #3D4A58; }
.agent-opt.on .pick { border-color: var(--accent); background: var(--accent); position: relative; }
.agent-opt.on .pick::after { content: ""; position: absolute; left: 5px; top: 1.5px; width: 5px;
height: 9px; border: solid #0F1922; border-width: 0 2px 2px 0; transform: rotate(42deg); }
.agent-opt .ag-state { font-size: 11px; display: flex; align-items: center; gap: 5px; margin-top: 4px; }
.agent-opt .ag-state .d { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
.agent-opt .ag-state.ok { color: var(--ok); }
.agent-opt .ag-state.off { color: var(--dim); } .agent-opt .ag-state.off .d { background: var(--dim); }
.agent-opt .ag-state.err { color: var(--err); } .agent-opt .ag-state.err .d { background: var(--err); }
/* === 006 · формы групп (approved-вариант) === */
.d-lead { font-size: 12.5px; color: var(--dim); margin-bottom: 12px; }
.warn { display: flex; gap: 9px; align-items: flex-start; margin-bottom: 15px; padding: 11px 13px;
border-radius: 10px; background: rgba(236,57,66,.09); border: 1px solid rgba(236,57,66,.32);
font-size: 12px; color: #F0A6AA; line-height: 1.5; }
.warn .i { flex: 0 0 auto; font-size: 13px; }
.info { display: flex; gap: 9px; align-items: flex-start; margin-top: 4px; padding: 11px 13px;
border-radius: 10px; background: var(--bg); border: 1px solid var(--line);
font-size: 11.5px; color: var(--dim); line-height: 1.5; }
.danger-zone { margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--line); }
.danger-zone .lbl { font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
color: var(--dim); font-weight: 700; margin-bottom: 9px; }
.btn.dang { color: var(--err); border-color: rgba(236,57,66,.4); }
.btn.dang:hover { border-color: var(--err); background: rgba(236,57,66,.09); }
.btn.dang.solid { background: var(--err); border-color: var(--err); color: #FFF; font-weight: 650; }
.btn.wide { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
border-style: dashed; color: var(--dim); }
.btn.wide:hover { color: var(--fg); border-color: var(--dim); }
.grp { display: flex; align-items: center; gap: 12px; padding: 11px 12px; border-radius: 11px;
background: var(--panel2); margin-bottom: 9px; border: 1px solid transparent; }
.grp:hover { border-color: var(--line); }
.grp .mid { flex: 1; min-width: 0; }
.grp .nm { font-size: 14px; font-weight: 550; }
.grp .sub { font-size: 11.5px; color: var(--dim); margin-top: 3px; }
.grp .acts { display: flex; gap: 4px; flex: 0 0 auto; }
.grp.sys { background: transparent; border: 1px dashed var(--line); }
.grp.sys .nm { color: var(--fg); }
.grp .lock { font-size: 11px; color: var(--dim); flex: 0 0 auto; }
.field { margin-bottom: 15px; }
.field label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 6px; }
.field input { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); font-size: 13.5px; outline: none; font-family: inherit; }
.field input:focus { border-color: var(--accent); }
.field .note { font-size: 11px; color: var(--dim); margin-top: 6px; }
@@ -0,0 +1,63 @@
package pw.binom.agentik.desktop
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import pw.binom.agentik.desktop.session.AgentRegistry
import pw.binom.agentik.desktop.settings.AppPaths
import pw.binom.agentik.desktop.settings.SettingsRepository
import pw.binom.agentik.desktop.ui.App
import pw.binom.agentik.desktop.ui.AppState
import pw.binom.agentik.desktop.ui.ClientImageSupport
import pw.binom.agentik.desktop.voice.VoiceNative
/**
* Точка входа desktop-клиента agentik.
*
* Поднимает окно Compose, реестр агентов и состояние приложения. Регистрирует
* декодер картинок для markdown-рендера (иначе картинки в ответах падают в
* текст) и создаёт каталоги данных.
*/
fun main() {
// Прогрев нативного ASR (Vosk + VAD) обязан произойти ДО инициализации
// skia/Compose, иначе создание модели распознавания после отрисовки окна
// валит JVM (см. [VoiceNative]). Поэтому он здесь, а не в `application {}`.
val voiceAvailable = VoiceNative.warmUp()
application {
// Картинки из markdown-сегментов декодируются через skia.
ClientImageSupport.decoder = { bytes ->
runCatching { org.jetbrains.skia.Image.makeFromEncoded(bytes).toComposeImageBitmap() }.getOrNull()
}
val windowState = rememberWindowState(size = DpSize(1240.dp, 780.dp))
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
val state = remember {
AppPaths.ensureAll()
val registry = AgentRegistry.create(
settingsRepo = SettingsRepository.default(),
scope = scope,
)
AppState(registry = registry, scope = scope, voiceAvailable = voiceAvailable)
}
Window(
onCloseRequest = {
state.shutdown()
scope.coroutineContext[kotlinx.coroutines.Job]?.cancel()
exitApplication()
},
state = windowState,
title = "Agentik Desktop",
) {
App(state)
}
}
}
@@ -0,0 +1,353 @@
/**
* Парсер Markdown в [MarkdownSegment].
* Чистый Kotlin — без Compose, без платформенных зависимостей.
*/
package pw.binom.agentik.desktop.markdown
import ru.otpbank.devex.markdown.DocumentVisitor
import ru.otpbank.devex.markdown.InlineVisitor
import ru.otpbank.devex.markdown.ListVisitor
import ru.otpbank.devex.markdown.MarkdownNode
import ru.otpbank.devex.markdown.MarkdownParser
import ru.otpbank.devex.markdown.RowVisitor
import ru.otpbank.devex.markdown.TableVisitor
private val parser = MarkdownParser()
fun parseMarkdown(markdown: String): List<MarkdownSegment> {
if (markdown.isBlank()) return listOf(MarkdownSegment.Text("", emptyList()))
val collector = SegmentCollector()
parser.parse(markdown, collector)
return collector.segments
}
private class SegmentCollector : DocumentVisitor {
val segments = mutableListOf<MarkdownSegment>()
private val boldStack = mutableListOf(false)
private val italicStack = mutableListOf(false)
private val codeStack = mutableListOf(false)
private val strikethroughStack = mutableListOf(false)
private var currentLink: String? = null
private val textBuilder = StringBuilder()
private val spans = mutableListOf<TextStyleSpan>()
private var hasContent = false
private var spanStart = 0
/** Маркер текущего пункта списка ("1. ", "• ", ...), вставляется перед первым текстом пункта. */
private var pendingItemMarker: String? = null
private val isBold: Boolean get() = boldStack.any { it }
private val isItalic: Boolean get() = italicStack.any { it }
private val isCode: Boolean get() = codeStack.any { it }
private val isStrikethrough: Boolean get() = strikethroughStack.any { it }
private fun recordSpan() {
if (!hasContent || spanStart >= textBuilder.length) return
val b = isBold
val i = isItalic
val c = isCode
val s = isStrikethrough
val l = currentLink
if (b || i || c || s || l != null) {
spans.add(
TextStyleSpan(
start = spanStart,
end = textBuilder.length,
bold = b,
italic = i,
code = c,
strikethrough = s,
link = l,
),
)
}
// Сдвигаем начало: уже записанный участок повторно не покрываем.
spanStart = textBuilder.length
}
private fun add(text: String) {
if (text.isEmpty()) return
recordSpan()
pendingItemMarker?.let {
textBuilder.append(it)
pendingItemMarker = null
}
textBuilder.append(text)
hasContent = true
}
/**
* Сбрасывает накопленный текст в [MarkdownSegment.Text] и очищает буферы.
* Вызывается в конце разбора и перед добавлением не-текстовых сегментов
* (например, картинки).
*/
private fun flushText() {
if (!hasContent) return
recordSpan()
segments.add(MarkdownSegment.Text(textBuilder.toString(), spans.toList()))
textBuilder.clear()
spans.clear()
spanStart = 0
hasContent = false
}
override fun visitDocumentEnd() {
flushText()
}
override fun visitParagraph(): InlineVisitor {
flushText()
return this
}
override fun closeParagraph() {
flushText()
}
override fun visitHeading(level: Int): InlineVisitor {
flushText()
boldStack.add(true)
return this
}
override fun closeHeading() {
boldStack.removeLastOrNull()
flushText()
}
override fun visitBlockquote(): DocumentVisitor {
flushText()
return this
}
override fun closeBlockquote() {
flushText()
}
override fun visitThematicBreak() {
flushText()
segments.add(MarkdownSegment.ThematicBreak)
}
override fun visitHtmlBlock(html: String) {
// Не выбрасываем содержимое — рендерим как обычный текст.
add(html)
}
override fun visitAutolink(url: String) {
// Не выбрасываем — показываем URL как текст.
add(url)
}
override fun visitOrderedList(): ListVisitor = object : ListVisitor {
private var counter = 0
override fun visitListItem(checked: Boolean?): DocumentVisitor {
// Начинаем пункт списка: маркер "N. " вставится перед первым текстом пункта.
flushText()
pendingItemMarker = "${++counter}. "
// Возвращаем коллектор, чтобы текст пункта попадал в наш textBuilder.
return this@SegmentCollector
}
override fun closeListItem() {
add("\n")
}
override fun closeList() {
flushText()
pendingItemMarker = null
}
}
override fun visitUnorderedList(): ListVisitor = object : ListVisitor {
override fun visitListItem(checked: Boolean?): DocumentVisitor {
flushText()
pendingItemMarker =
when (checked) {
true -> "- [x] "
false -> "- [ ] "
null -> "• "
}
return this@SegmentCollector
}
override fun closeListItem() {
add("\n")
}
override fun closeList() {
flushText()
pendingItemMarker = null
}
}
override fun visitText(text: String) {
add(text)
}
override fun visitBold(): InlineVisitor {
recordSpan()
boldStack.add(true)
spanStart = textBuilder.length
return this
}
override fun closeBold() {
recordSpan()
boldStack.removeLastOrNull()
spanStart = textBuilder.length
}
override fun visitItalic(): InlineVisitor {
recordSpan()
italicStack.add(true)
spanStart = textBuilder.length
return this
}
override fun closeItalic() {
recordSpan()
italicStack.removeLastOrNull()
spanStart = textBuilder.length
}
override fun visitStrikethrough(): InlineVisitor {
recordSpan()
strikethroughStack.add(true)
spanStart = textBuilder.length
return this
}
override fun closeStrikethrough() {
recordSpan()
strikethroughStack.removeLastOrNull()
spanStart = textBuilder.length
}
override fun visitInlineCode(code: String) {
recordSpan()
codeStack.add(true)
spanStart = textBuilder.length
textBuilder.append(code)
hasContent = true
recordSpan()
codeStack.removeLastOrNull()
spanStart = textBuilder.length
}
override fun visitLink(
url: String,
title: String?,
): InlineVisitor {
recordSpan()
currentLink = url
spanStart = textBuilder.length
return this
}
override fun closeLink() {
recordSpan()
currentLink = null
spanStart = textBuilder.length
}
override fun visitImage(
url: String,
alt: String,
title: String?,
) {
flushText()
segments.add(MarkdownSegment.Image(url, alt))
}
override fun visitSoftLineBreak() {
add(" ")
}
override fun visitHardLineBreak() {
add("\n")
}
override fun visitCodeBlock(
language: String?,
code: String,
) {
flushText()
segments.add(MarkdownSegment.CodeBlock(language, code))
}
override fun visitTable(columnCount: Int): TableVisitor {
flushText()
val headers = mutableListOf<String>()
val rows = mutableListOf<List<String>>()
val rowCells = mutableListOf<String>()
var isFirstRow = true
return object : TableVisitor {
override fun visitTableRow(): RowVisitor {
rowCells.clear()
return object : RowVisitor {
override fun visitTableCell(alignment: MarkdownNode.TableAlignment?): InlineVisitor {
val buf = StringBuilder()
return object : InlineVisitor {
override fun visitText(text: String) {
buf.append(text)
}
override fun visitInlineCode(code: String) {
buf.append(code)
}
override fun closeTableCell() {
rowCells.add(buf.toString().trim())
}
override fun visitBold(): InlineVisitor = this
override fun visitItalic(): InlineVisitor = this
override fun visitStrikethrough(): InlineVisitor = this
override fun visitLink(
url: String,
title: String?,
): InlineVisitor = this
override fun visitImage(
url: String,
alt: String,
title: String?,
) {
buf.append(alt)
}
override fun visitSoftLineBreak() {
buf.append(' ')
}
override fun visitHardLineBreak() {
buf.append(' ')
}
}
}
override fun closeTableRow() {
if (isFirstRow) {
headers.addAll(rowCells.toList())
isFirstRow = false
} else {
rows.add(rowCells.toList())
}
}
}
}
override fun closeTable() {
segments.add(MarkdownSegment.Table(headers.toList(), rows.toList()))
}
}
}
}
@@ -0,0 +1,59 @@
/**
* Общие модели данных для парсинга Markdown.
* Не содержат зависимостей от Compose или платформы.
*/
package pw.binom.agentik.desktop.markdown
/**
* Описание стилевого участка внутри текстового сегмента.
* Клиентские реализации (Compose Desktop, Android) конвертируют
* их в нативный styled text своей платформы.
*/
data class TextStyleSpan(
val start: Int,
val end: Int,
val bold: Boolean = false,
val italic: Boolean = false,
val code: Boolean = false,
val strikethrough: Boolean = false,
val link: String? = null,
)
/**
* Сегмент распарсенного Markdown-содержимого.
*/
sealed class MarkdownSegment {
/**
* Текстовый сегмент с inline-аннотациями стилей.
*/
data class Text(
val text: String,
val spans: List<TextStyleSpan>,
) : MarkdownSegment()
/**
* Блок кода (fenced code block).
*/
data class CodeBlock(
val language: String?,
val code: String,
) : MarkdownSegment()
/**
* Таблица с заголовками и строками данных.
*/
data class Table(
val headers: List<String>,
val rows: List<List<String>>,
) : MarkdownSegment()
/** Картинка: src — data URL (`data:image/...;base64,...`) или http(s) URL. */
data class Image(
val src: String,
val alt: String? = null,
) : MarkdownSegment()
/** Разделитель (горизонтальная линия `---`, `***`, ...). */
data object ThematicBreak : MarkdownSegment()
}
@@ -0,0 +1,64 @@
package pw.binom.agentik.desktop.media
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import pw.binom.agentik.desktop.settings.AppPaths
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import kotlin.io.path.exists
import kotlin.io.path.extension
/**
* Хранилище аватаров ассистентов. Сервер картинку не отдаёт
* (REQUIREMENTS R23.3.2) — пользователь выбирает файл локально, мы
* **копируем** его в `~/.agentik-desktop/avatars/<agentId>.<ext>` и
* запоминаем путь в [pw.binom.agentik.desktop.settings.AgentConfig.avatarPath].
*
* Копия, а не ссылка: оригинал пользователь может удалить/переместить,
* а аватар должен пережить это. Имя — по локальному agent id, поэтому
* замена аватара перезаписывает прежний (без мусора).
*/
class AvatarStorage(
private val dir: Path = AppPaths.avatars,
) {
/**
* Копирует [source] как аватар агента [agentId]. Возвращает абсолютный
* путь сохранённой копии (его и кладём в настройки).
*/
suspend fun save(agentId: String, source: Path): String = withContext(Dispatchers.IO) {
require(source.exists()) { "Файл аватара не найден: $source" }
AppPaths.ensure(dir)
val ext = source.extension.lowercase().takeIf { it.isNotBlank() } ?: "img"
val target = dir.resolve("${AppPaths.safeDirName(agentId)}.$ext")
// Убираем прежний аватар с другим расширением, чтобы не остался мусор.
delete(agentId)
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING)
target.toAbsolutePath().toString()
}
/** Удаляет текущий аватар агента (любое расширение). `true`, если что-то удалили. */
suspend fun delete(agentId: String): Boolean = withContext(Dispatchers.IO) {
if (!dir.exists()) return@withContext false
val prefix = AppPaths.safeDirName(agentId) + "."
var removed = false
Files.newDirectoryStream(dir) { path -> path.fileName.toString().startsWith(prefix) }
.use { stream ->
for (path in stream) {
runCatching {
Files.deleteIfExists(path)
removed = true
}
}
}
removed
}
/** Читает байты аватара по пути из настроек, или `null`, если файла нет. */
suspend fun read(path: String?): ByteArray? = withContext(Dispatchers.IO) {
if (path.isNullOrBlank()) return@withContext null
val p = Path.of(path)
if (!p.exists()) null else runCatching { Files.readAllBytes(p) }.getOrNull()
}
}
@@ -0,0 +1,161 @@
package pw.binom.agentik.desktop.model
import pw.binom.agentik.desktop.persistence.MetaRow
import pw.binom.agentik.desktop.session.LiveBlock
import pw.binom.agentik.desktop.session.LiveTurn
import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MessageRecord
import kotlin.time.Instant
/**
* Мапперы «слой хранения/стрима → UI-модель».
*
* Ручные, без рефлексии/библиотек: типов мало, а неявные конвертеры
* затрудняют отладку. Все мапперы — чистые функции.
*/
fun Content.toUiContent(): UiContent = when (this) {
is Content.Text -> UiContent.Text(body)
is Content.Image -> UiContent.Image(data, mime)
}
/**
* Разворачивает историю из журнала в плоский список для рендера.
*
* Отдельно склеивает `ToolCall` + `ToolResult` в один [UiMessage.Tool]:
* в журнале это две записи с общим `toolCallId` (денормализованный
* `toolName` в результате), в UI — одна карточка. Сиротливый
* `ToolResult` (без парного `ToolCall`) тоже становится карточкой.
*
* Порядок записей сохраняется.
*/
fun List<MessageRecord>.toUiMessages(): List<UiMessage> {
val out = ArrayList<UiMessage>(size)
// Индекс UiMessage.Tool по toolCallId — чтобы приклеить результат.
val toolsByCallId = HashMap<String, Int>()
for (record in this) {
when (record) {
is MessageRecord.UserMessage -> out += UiMessage.User(
id = record.id,
createdAt = record.createdAt,
content = record.content.map { it.toUiContent() },
)
is MessageRecord.AssistantMessage -> out += UiMessage.Assistant(
id = record.id,
createdAt = record.createdAt,
content = record.content.map { it.toUiContent() },
)
is MessageRecord.ToolCall -> {
val index = out.size
out += UiMessage.Tool(
id = record.id,
createdAt = record.createdAt,
toolName = record.toolName,
title = record.toolTitle,
argsJson = record.toolArgsJson,
)
toolsByCallId[record.id] = index
}
is MessageRecord.ToolResult -> {
val existing = toolsByCallId[record.toolCallId]
if (existing != null) {
val tool = out[existing] as UiMessage.Tool
out[existing] = tool.copy(result = record.result)
} else {
out += UiMessage.Tool(
id = record.toolCallId,
createdAt = record.createdAt,
toolName = record.toolName ?: "(unknown)",
title = null,
argsJson = "",
result = record.result,
)
}
}
is MessageRecord.Error -> out += UiMessage.Error(
id = record.id,
createdAt = record.createdAt,
message = record.message,
code = record.code,
)
}
}
return out
}
/**
* Превращает активный live-ход в список UI-сообщений. Ключи блоков
* переиспользуются как id — они стабильны в рамках хода, значит
* `LazyColumn` не перезапускает элементы на каждом токене.
*
* [lastBlockStreaming] = true только у последнего блока, если ход ещё
* активен: это маркер для курсора/мигающей каретки.
*/
fun LiveTurn.toUiMessages(now: Instant): List<UiMessage> {
val live = phase.isStreaming
val result = ArrayList<UiMessage>(blocks.size)
blocks.forEachIndexed { index, block ->
val last = live && index == blocks.lastIndex
result += when (block) {
is LiveBlock.Reasoning -> UiMessage.Reasoning(
id = block.key,
createdAt = now,
text = block.text.toString(),
streaming = last,
)
is LiveBlock.Answer -> UiMessage.Assistant(
id = block.key,
createdAt = now,
content = listOf(UiContent.Text(block.text.toString())),
streaming = last,
)
is LiveBlock.Image -> UiMessage.Assistant(
id = block.key,
createdAt = now,
content = listOf(UiContent.Image(block.data, block.mime)),
streaming = last,
)
is LiveBlock.Tool -> UiMessage.Tool(
id = block.key,
createdAt = now,
toolName = block.toolName,
title = block.title,
argsJson = block.argsJson,
result = block.result,
failed = block.failed,
)
is LiveBlock.Failure -> UiMessage.Error(
id = block.key,
createdAt = now,
message = block.message,
code = block.code,
)
}
}
return result
}
/**
* Строка списка диалогов. [meta] — локальная запись (может отсутствовать,
* если диалог ещё ни разу не открывали), [unread] — посчитанный счётчик.
*/
fun ConversationRecord.toUiConversation(meta: MetaRow?, unread: Int): UiConversation = UiConversation(
id = id,
title = title?.takeIf { it.isNotBlank() } ?: UNTITLED_CONVERSATION,
isTemporal = isTemporal,
createdAt = createdAt,
updatedAt = updatedAt,
groupId = meta?.groupId,
preview = meta?.preview,
unread = unread,
)
@@ -0,0 +1,95 @@
package pw.binom.agentik.desktop.model
import kotlin.time.Instant
/**
* Контент внутри сообщения для отрисовки. Живёт отдельно от
* journal-`Content` / proto-`Content`, чтобы UI не зависел от слоя
* хранения/транспорта.
*/
sealed interface UiContent {
data class Text(val body: String) : UiContent
data class Image(val data: ByteArray, val mime: String) : UiContent {
override fun equals(other: Any?): Boolean =
this === other || (other is Image && mime == other.mime && data.contentEquals(other.data))
override fun hashCode(): Int = 31 * mime.hashCode() + data.contentHashCode()
}
}
/**
* Сообщение в том виде, в каком его рисует чат. Один тип и для истории
* (из `KsqliteJournalStore`), и для «живого» хода (из `LiveTurn`) — так
* рендерер не раздваивается на «до End» и «после End».
*
* - [User] / [Assistant] — реплики.
* - [Reasoning] — рассуждения; в журнал не пишутся, бывают только live.
* - [Tool] — вызов инструмента, результат пристёгнут к тому же элементу.
* - [Error] — провал хода.
*
* [streaming] = true у последнего блока активного хода: UI показывает
* курсор/анимацию и, при необходимости, рендерит markdown не полностью.
*/
sealed interface UiMessage {
val id: String
val createdAt: Instant
data class User(
override val id: String,
override val createdAt: Instant,
val content: List<UiContent>,
) : UiMessage
data class Assistant(
override val id: String,
override val createdAt: Instant,
val content: List<UiContent>,
val streaming: Boolean = false,
) : UiMessage
data class Reasoning(
override val id: String,
override val createdAt: Instant,
val text: String,
val streaming: Boolean = false,
) : UiMessage
data class Tool(
override val id: String,
override val createdAt: Instant,
val toolName: String,
val title: String?,
val argsJson: String,
val result: String? = null,
val failed: String? = null,
) : UiMessage
data class Error(
override val id: String,
override val createdAt: Instant,
val message: String,
val code: String? = null,
) : UiMessage
}
/**
* Строка списка диалогов: серверный snapshot + локальные украшения
* (группа, превью, счётчик непрочитанного).
*
* [unread] вычислен из `journal.count(convId, after = meta.lastSeen)` —
* сам счётчик в БД не хранится (STORAGE.md §6.2). [preview] — локальная
* денормализация (STORAGE.md §6.1).
*/
data class UiConversation(
val id: String,
val title: String,
val isTemporal: Boolean,
val createdAt: Instant,
val updatedAt: Instant,
val groupId: Long?,
val preview: String?,
val unread: Int,
)
/** Заголовок диалога, если сервер не дал title. */
const val UNTITLED_CONVERSATION = "Новый диалог"
@@ -0,0 +1,85 @@
package pw.binom.agentik.desktop.net
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.desktop.settings.AgentConfig
/** Один шаг проверки связи. */
data class CheckStep(
val title: String,
val ok: Boolean,
val millis: Long,
val detail: String? = null,
)
/** Итог проверки связи с агентом (R23.4 — диалог со спиннером и таймингами). */
data class ConnectionReport(
val baseUrl: String,
val steps: List<CheckStep>,
val agentName: String? = null,
) {
val ok: Boolean get() = steps.all { it.ok }
val totalMillis: Long get() = steps.sumOf { it.millis }
}
/**
* Проверка связи с агентом без открытия полноценного [AgentConnection]:
* отдельное «одноразовое» подключение, которое закрывается сразу после
* прогона шагов.
*
* Шаги:
* 1. **Ответ сервера** — `GET {baseUrl}` за `AgentInfo` (имя/описание).
* 2. **Диалог** — создать временный диалог и удалить его (проверяет
* conversation-роуты, не тратит токены LLM).
*
* Сознательно **не** отправляем реальный ход: это дёргает модель (время,
* стоимость, побочные эффекты в истории). Проверки маршрутов достаточно,
* чтобы отличить «связи нет» от «связь есть».
*/
class ConnectionChecker(
private val engineFactory: HttpClientEngineFactory<*> = CIO,
private val timeoutMillis: Long = 15_000,
) {
suspend fun check(config: AgentConfig, clientId: String): ConnectionReport = withContext(Dispatchers.IO) {
val steps = mutableListOf<CheckStep>()
var agent: pw.binom.agentik.proto.Agent? = null
var name: String? = null
val t0 = System.nanoTime()
try {
agent = AgentikAgent(
id = clientId,
baseUrl = config.baseUrl,
engineFactory = engineFactory,
token = config.token,
)
name = agent.info.name
steps += CheckStep("Ответ сервера", true, elapsed(t0), runCatching { agent.info.description }.getOrNull())
} catch (t: Throwable) {
steps += CheckStep("Ответ сервера", false, elapsed(t0), t.message ?: t::class.simpleName)
return@withContext ConnectionReport(config.baseUrl, steps, name)
}
val a = agent
val t1 = System.nanoTime()
try {
val conv = a.createConversation(temp = true)
val id = conv.id
conv.close()
a.deleteConversation(id)
steps += CheckStep("Создание диалога", true, elapsed(t1))
} catch (t: Throwable) {
steps += CheckStep("Создание диалога", false, elapsed(t1), t.message ?: t::class.simpleName)
} finally {
runCatching { a.close() }
}
ConnectionReport(config.baseUrl, steps, name)
}
private fun elapsed(fromNanos: Long): Long = (System.nanoTime() - fromNanos) / 1_000_000
}
@@ -0,0 +1,458 @@
package pw.binom.agentik.desktop.persistence
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import kotlin.time.Instant
/**
* Локальное хранилище клиентских «украшений» поверх списка диалогов.
* Не часть протокола — сервер про эти данные не знает.
*
* **Что хранит:**
* - **Группы** — раскладка диалогов по категориям («Работа», «Дом», ...).
* Сервер про групп не знает (STORAGE.md §7), они целиком наши.
* - **`last_seen`** — отметка «докуда дочитано» на диалог, для бейджа
* «есть новое» (STORAGE.md §6.2). Число непрочитанных берётся как
* `journal.count(convId, after = lastSeen)` и здесь не лежит.
* - **`preview`** — последний фрагмент текста хода, денормализованный
* в строку. Сервер такого поля не отдаёт (STORAGE.md §6.1), клиент
* поддерживает сам на `Event.AppendText` / `Event.End`.
*
* **Почему НЕ наследуется от `KsqliteJournalStore`:** разные таблицы
* (`conversation_meta` + `chat_group` против `message`) и разные контракты
* (`MutableJournalStore` мы не реализуем — у нас другой набор операций).
* Общее с journal-store — только `SQLiteConnection`; они прекрасно живут
* на одном соединении, каждый создаёт свою часть схемы в `init`. Это
* та же модель, что между `KsqliteJournalStore` и
* `KsqliteMutableConversationStore` внутри `:journal-ksqlite` — они
* тоже не наследуются друг от друга, просто делят connection.
*
* **Не отдаёт JOIN с `conversation`-таблицей** — UI делает мерж сам:
* `agent.conversationStore.list(...)` + `metaRepo.listMetas()` +
* `metaRepo.listGroups()` → склейка в Kotlin. Связь ослаблена намеренно:
* мета-репо не знает названий колонок в чужой таблице.
*
* **Cascade удаления группы:** в [deleteGroup] сначала зануляем `group_id`
* в `conversation_meta` (R9.7 — диалоги переживают группу), потом удаляем
* саму группу. Одна транзакция.
*
* **Тред-безопасность:** один `Mutex` на все операции (как в
* `KsqliteJournalStore`). SQLite сам сериализует writer'ов, но mutex
* держит explicit ordering и предотвращает перемежающиеся read/write
* одних и тех же строк под гонкой (иначе `markSeen` мог бы прочитать
* старый `last_seen` параллельно с `setPreview`).
*
* **UPSERT** в `setGroup` / `markSeen` / `setPreview`:
* SQLite `INSERT ... ON CONFLICT(conv_id) DO UPDATE SET ...` —
* атомарно обновляет одно поле, не трогая остальные. Требует SQLite 3.24+
* (2018); текущие сборки ksqlite не ниже.
*
* **Схема встроена внутрь класса** — таблицы `chat_group` +
* `conversation_meta` существуют только для этого репо, выносить в
* отдельный `Schema`-object незачем. Имя `chat_group` потому что SQL
* `GROUP` — зарезервированное слово. DDL — `CREATE TABLE/INDEX IF NOT EXISTS`
* в одной транзакции, idempotent.
*/
class ConversationMetaRepository private constructor(
private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
) : AutoCloseable {
/**
* Внешнее соединение — репо НЕ закрывает его в [close]. Для
* shared-connection сценария, когда на одном `SQLiteConnection` сидят
* `KsqliteJournalStore`, `KsqliteMutableConversationStore` и этот репо.
*/
constructor(connection: SQLiteConnection) : this(connection, ownsConnection = false)
/**
* Открывает файловое соединение через `SQLiteConnection.open` и берёт
* на себя его закрытие в [close]. Для standalone использования.
*/
constructor(path: String) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
)
private companion object {
// ───── Schema (приватно — никому кроме этого класса не нужно) ─────
const val TABLE_GROUP = "chat_group"
const val TABLE_CONV_META = "conversation_meta"
const val COL_GROUP_ID = "id"
const val COL_GROUP_NAME = "name"
const val COL_CONV_ID = "conv_id"
const val COL_GROUP_FK = "group_id"
const val COL_LAST_SEEN = "last_seen"
const val COL_PREVIEW = "preview"
/** Докуда локальный кэш сообщений синхронизирован с сервером. */
const val COL_SYNCED_AT = "synced_at"
const val IDX_META_GROUP = "idx_conv_meta_group"
private val V1_GROUP_DDL = """
CREATE TABLE IF NOT EXISTS $TABLE_GROUP (
$COL_GROUP_ID INTEGER PRIMARY KEY AUTOINCREMENT,
$COL_GROUP_NAME TEXT NOT NULL UNIQUE
);
"""
private val V1_CONV_META_DDL = """
CREATE TABLE IF NOT EXISTS $TABLE_CONV_META (
$COL_CONV_ID TEXT NOT NULL PRIMARY KEY,
$COL_GROUP_FK INTEGER,
$COL_LAST_SEEN INTEGER,
$COL_PREVIEW TEXT
);
"""
private val V2_CONV_META_SYNCED_AT_DDL =
"ALTER TABLE $TABLE_CONV_META ADD COLUMN $COL_SYNCED_AT INTEGER"
private val V1_INDEXES_DDL = """
CREATE INDEX IF NOT EXISTS $IDX_META_GROUP
ON $TABLE_CONV_META($COL_GROUP_FK);
"""
/**
* Прогоняет миграцию. Идемпотентна и НЕ использует `PRAGMA user_version`:
* на одном connection с нами живут `KsqliteJournalStore` /
* `KsqliteMutableConversationStore` из `:journal-ksqlite`, а
* user_version — глобальный на файл, и несколько модулей, дёргающих
* его, затирали бы друг друга (см. комментарий в их `Schema.kt`).
* Вместо этого наличие колонки `synced_at` проверяется напрямую через
* `PRAGMA table_info`. Всё в одной транзакции.
*/
fun migrate(conn: SQLiteConnection) {
conn.exec("BEGIN")
try {
conn.exec(V1_GROUP_DDL)
conn.exec(V1_CONV_META_DDL)
conn.exec(V1_INDEXES_DDL)
if (!hasColumn(conn, TABLE_CONV_META, COL_SYNCED_AT)) {
conn.exec(V2_CONV_META_SYNCED_AT_DDL)
}
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
}
private fun hasColumn(conn: SQLiteConnection, table: String, column: String): Boolean {
conn.prepare("PRAGMA table_info($table)").use { stmt ->
stmt.executeQuery().use { rs ->
while (rs.next()) {
// table_info: cid, name, type, notnull, dflt_value, pk
if (rs.getText(1) == column) return true
}
}
}
return false
}
}
init {
migrate(connection)
}
private val mutex = Mutex()
// ───── Prepared statements ─────
// groups
private val listGroupsStmt = connection.prepare(
"SELECT $COL_GROUP_ID, $COL_GROUP_NAME " +
"FROM $TABLE_GROUP ORDER BY $COL_GROUP_NAME ASC"
)
private val insertGroupStmt = connection.prepare(
"INSERT INTO $TABLE_GROUP($COL_GROUP_NAME) VALUES (?)"
)
private val lastInsertRowIdStmt = connection.prepare("SELECT last_insert_rowid()")
private val renameGroupStmt = connection.prepare(
"UPDATE $TABLE_GROUP SET $COL_GROUP_NAME=? WHERE $COL_GROUP_ID=?"
)
private val groupExistsStmt = connection.prepare(
"SELECT 1 FROM $TABLE_GROUP WHERE $COL_GROUP_ID=?"
)
private val clearMetaGroupStmt = connection.prepare(
"UPDATE $TABLE_CONV_META SET $COL_GROUP_FK=NULL WHERE $COL_GROUP_FK=?"
)
private val deleteGroupStmt = connection.prepare(
"DELETE FROM $TABLE_GROUP WHERE $COL_GROUP_ID=?"
)
// per-conv meta
private val listMetasStmt = connection.prepare(
"SELECT $COL_CONV_ID, $COL_GROUP_FK, $COL_LAST_SEEN, $COL_PREVIEW, $COL_SYNCED_AT " +
"FROM $TABLE_CONV_META"
)
private val getMetaStmt = connection.prepare(
"SELECT $COL_CONV_ID, $COL_GROUP_FK, $COL_LAST_SEEN, $COL_PREVIEW, $COL_SYNCED_AT " +
"FROM $TABLE_CONV_META WHERE $COL_CONV_ID=?"
)
private val upsertGroupFkStmt = connection.prepare(
"INSERT INTO $TABLE_CONV_META($COL_CONV_ID, $COL_GROUP_FK) " +
"VALUES(?, ?) " +
"ON CONFLICT($COL_CONV_ID) DO UPDATE SET " +
"$COL_GROUP_FK=excluded.$COL_GROUP_FK"
)
private val upsertLastSeenStmt = connection.prepare(
"INSERT INTO $TABLE_CONV_META($COL_CONV_ID, $COL_LAST_SEEN) " +
"VALUES(?, ?) " +
"ON CONFLICT($COL_CONV_ID) DO UPDATE SET " +
"$COL_LAST_SEEN=excluded.$COL_LAST_SEEN"
)
private val upsertPreviewStmt = connection.prepare(
"INSERT INTO $TABLE_CONV_META($COL_CONV_ID, $COL_PREVIEW) " +
"VALUES(?, ?) " +
"ON CONFLICT($COL_CONV_ID) DO UPDATE SET " +
"$COL_PREVIEW=excluded.$COL_PREVIEW"
)
private val upsertSyncedAtStmt = connection.prepare(
"INSERT INTO $TABLE_CONV_META($COL_CONV_ID, $COL_SYNCED_AT) " +
"VALUES(?, ?) " +
"ON CONFLICT($COL_CONV_ID) DO UPDATE SET " +
"$COL_SYNCED_AT=excluded.$COL_SYNCED_AT"
)
private val deleteMetaStmt = connection.prepare(
"DELETE FROM $TABLE_CONV_META WHERE $COL_CONV_ID=?"
)
// ───── Group API ─────
/** Все группы, отсортированы по имени. */
suspend fun listGroups(): List<Group> = withContext(Dispatchers.Default) {
mutex.withLock {
listGroupsStmt.reset()
listGroupsStmt.clearBindings()
val out = mutableListOf<Group>()
listGroupsStmt.executeQuery().use { rs ->
while (rs.next()) {
out.add(Group(id = rs.getLong(0)!!, name = rs.getText(1)!!))
}
}
out
}
}
/**
* Создать группу. UNIQUE на имени — конфликт пробрасывается исключением
* SQLite. `name` валидируется UI заранее (не пустое, не дубликат).
*/
suspend fun createGroup(name: String): Group = withContext(Dispatchers.Default) {
mutex.withLock {
insertGroupStmt.reset()
insertGroupStmt.clearBindings()
insertGroupStmt.bindText(1, name)
insertGroupStmt.executeUpdate()
val id = readLastInsertRowId()
Group(id = id, name = name)
}
}
/** Переименовать группу. Возвращает `true` если группа с таким id существовала. */
suspend fun renameGroup(id: Long, name: String): Boolean = withContext(Dispatchers.Default) {
mutex.withLock {
renameGroupStmt.reset()
renameGroupStmt.clearBindings()
renameGroupStmt.bindText(1, name)
renameGroupStmt.bindLong(2, id)
renameGroupStmt.executeUpdate() > 0
}
}
/**
* Удалить группу. **Диалоги переживают удаление** (R9.7) — сначала
* зануляем `group_id` в `conversation_meta`, потом удаляем саму группу.
* Одна транзакция. Возвращает `false` если группы не было.
*/
suspend fun deleteGroup(id: Long): Boolean = withContext(Dispatchers.Default) {
mutex.withLock {
if (!groupExists(id)) return@withContext false
connection.exec("BEGIN")
try {
clearMetaGroupStmt.reset()
clearMetaGroupStmt.clearBindings()
clearMetaGroupStmt.bindLong(1, id)
clearMetaGroupStmt.executeUpdate()
deleteGroupStmt.reset()
deleteGroupStmt.clearBindings()
deleteGroupStmt.bindLong(1, id)
deleteGroupStmt.executeUpdate()
connection.exec("COMMIT")
true
} catch (t: Throwable) {
runCatching { connection.exec("ROLLBACK") }
throw t
}
}
}
// ───── Per-conv meta API ─────
/** Все мета-записи разом — для UI при старте (100–1000 строк, грузим в память). */
suspend fun listMetas(): List<MetaRow> = withContext(Dispatchers.Default) {
mutex.withLock {
listMetasStmt.reset()
listMetasStmt.clearBindings()
val out = mutableListOf<MetaRow>()
listMetasStmt.executeQuery().use { rs ->
while (rs.next()) {
out.add(rs.toMetaRow())
}
}
out
}
}
/** Мета для одного диалога, или `null` если записи нет. */
suspend fun meta(convId: String): MetaRow? = withContext(Dispatchers.Default) {
mutex.withLock {
getMetaStmt.reset()
getMetaStmt.clearBindings()
getMetaStmt.bindText(1, convId)
getMetaStmt.executeQuery().use { rs ->
if (!rs.next()) null else rs.toMetaRow()
}
}
}
/** Положить диалог в группу. `groupId = null` — без группы (= «Все чаты»). */
suspend fun setGroup(convId: String, groupId: Long?) = withContext(Dispatchers.Default) {
mutex.withLock {
upsertGroupFkStmt.reset()
upsertGroupFkStmt.clearBindings()
upsertGroupFkStmt.bindText(1, convId)
if (groupId != null) upsertGroupFkStmt.bindLong(2, groupId)
else upsertGroupFkStmt.bindNull(2)
upsertGroupFkStmt.executeUpdate()
}
}
/** Отметить диалог прочитанным до момента [at]. UPSERT — не трогает group/preview. */
suspend fun markSeen(convId: String, at: Instant) = withContext(Dispatchers.Default) {
mutex.withLock {
upsertLastSeenStmt.reset()
upsertLastSeenStmt.clearBindings()
upsertLastSeenStmt.bindText(1, convId)
upsertLastSeenStmt.bindLong(2, at.toEpochMilliseconds())
upsertLastSeenStmt.executeUpdate()
}
}
/** Обновить превью строки в списке. UPSERT — не трогает group/last_seen. */
suspend fun setPreview(convId: String, preview: String?) = withContext(Dispatchers.Default) {
mutex.withLock {
upsertPreviewStmt.reset()
upsertPreviewStmt.clearBindings()
upsertPreviewStmt.bindText(1, convId)
if (preview != null) upsertPreviewStmt.bindText(2, preview)
else upsertPreviewStmt.bindNull(2)
upsertPreviewStmt.executeUpdate()
}
}
/**
* Сдвинуть watermark синхронизации локального кэша сообщений: всё,
* что сервер отдал **до** [at] включительно, уже лежит в
* `KsqliteJournalStore`. Следующий бэкфилл идёт строго `after = at`
* — это защищает от повторного `INSERT` уже сохранённых записей
* (у message-таблицы `id` — PRIMARY KEY, дубль бросает исключение).
* UPSERT — не трогает group/last_seen/preview.
*/
suspend fun setSyncedAt(convId: String, at: Instant) = withContext(Dispatchers.Default) {
mutex.withLock {
upsertSyncedAtStmt.reset()
upsertSyncedAtStmt.clearBindings()
upsertSyncedAtStmt.bindText(1, convId)
upsertSyncedAtStmt.bindLong(2, at.toEpochMilliseconds())
upsertSyncedAtStmt.executeUpdate()
}
}
/**
* Удалить всю локальную мету диалога. Вызывается при удалении самого
* диалога (`agent.deleteConversation`), чтобы не копить сиротские строки.
* Возвращает `true`, если запись была.
*/
suspend fun deleteMeta(convId: String): Boolean = withContext(Dispatchers.Default) {
mutex.withLock {
deleteMetaStmt.reset()
deleteMetaStmt.clearBindings()
deleteMetaStmt.bindText(1, convId)
deleteMetaStmt.executeUpdate() > 0
}
}
override fun close() {
listGroupsStmt.close()
insertGroupStmt.close()
lastInsertRowIdStmt.close()
renameGroupStmt.close()
groupExistsStmt.close()
clearMetaGroupStmt.close()
deleteGroupStmt.close()
listMetasStmt.close()
getMetaStmt.close()
upsertGroupFkStmt.close()
upsertLastSeenStmt.close()
upsertPreviewStmt.close()
upsertSyncedAtStmt.close()
deleteMetaStmt.close()
if (ownsConnection) connection.close()
}
private fun readLastInsertRowId(): Long {
lastInsertRowIdStmt.reset()
lastInsertRowIdStmt.clearBindings()
lastInsertRowIdStmt.executeQuery().use { rs ->
check(rs.next()) { "last_insert_rowid() returned no rows" }
return rs.getLong(0) ?: error("last_insert_rowid() returned NULL")
}
}
private fun groupExists(id: Long): Boolean {
groupExistsStmt.reset()
groupExistsStmt.clearBindings()
groupExistsStmt.bindLong(1, id)
groupExistsStmt.executeQuery().use { rs -> return rs.next() }
}
private fun pw.binom.db.ksqlite.SQLiteResultSet.toMetaRow(): MetaRow = MetaRow(
convId = getText(0)!!,
groupId = getLong(1),
lastSeen = getLong(2)?.let { Instant.fromEpochMilliseconds(it) },
preview = getText(3),
syncedAt = getLong(4)?.let { Instant.fromEpochMilliseconds(it) },
)
}
/** Группа диалогов. Только имя и id — R9.5 (без цвета/иконки/порядка). */
data class Group(val id: Long, val name: String)
/**
* Per-conv локальное состояние.
* - `groupId == null` → диалог в «Все чаты» (без группы).
* - `lastSeen == null` → диалог ещё ни разу не открывали; бейдж «есть новое»
* по `localUpdatedAt > lastSeen` (lastSeen=null → бейдж всегда).
* - `preview == null` → ещё ни одного сообщения, в строке рисуем плейсхолдер.
* - `syncedAt == null` → локальный кэш сообщений ещё не догоняли с сервером;
* бэкфилл начинается с `Instant.DISTANT_PAST`.
*/
data class MetaRow(
val convId: String,
val groupId: Long?,
val lastSeen: Instant?,
val preview: String?,
val syncedAt: Instant? = null,
)
@@ -0,0 +1,268 @@
package pw.binom.agentik.desktop.session
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.desktop.model.UiConversation
import pw.binom.agentik.desktop.model.toUiConversation
import pw.binom.agentik.desktop.persistence.ConversationMetaRepository
import pw.binom.agentik.desktop.persistence.MetaRow
import pw.binom.agentik.desktop.settings.AgentConfig
import pw.binom.agentik.desktop.settings.AppPaths
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentInfo
import pw.binom.agentik.proto.Conversation
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
/**
* Подключение к одному агенту: HTTP-транспорт (`:client`) + локальное
* хранилище этого агента.
*
* **Что внутри:**
* - [agent] — `AgentikAgent(...)` из `:client`: HTTP/JSON/SSE/Bearer, кэш
* списка диалогов, авто-reconnect, всё «из коробки» (README `:client`).
* - `SQLiteConnection` — ОДНО соединение на агента, на нём сидят:
* - [journalCache] (`KsqliteJournalStore`) — все сообщения (offline);
* - [meta] (`ConversationMetaRepository`) — группы / lastSeen /
* preview / watermark.
* Отдельная БД на агента (а не общая): серверные id диалогов могут
* пересечься между агентами, а группы и так локальны для агента.
*
* **Почему не `KsqliteMutableConversationStore` для списка диалогов:**
* `AgentikAgent` уже отдаёт готовый кэш `conversationStore` (seed из
* сервера + live-refresh по outbox), но in-memory — `wrapWithLocalConversationCache`
* жёстко зашит на `InMemoryMutableConversationStore`. Персистить список —
* отдельная задача; сейчас offline-история сообщений есть, а список при
* старте подтягивается с сервера (при отсутствии сети — пуст).
*
* ## Создание
*
* [open] синхронно конструирует `AgentikAgent`, который внутри делает
* `GET {baseUrl}` за [AgentInfo]. Нет сети — [open] бросит; вызывающий
* (см. `AgentRegistry`) должен поймать и показать «нет связи».
*/
class AgentConnection private constructor(
val config: AgentConfig,
private val agent: Agent,
private val connection: SQLiteConnection,
private val scope: CoroutineScope,
) : AutoCloseable {
/** Локальный кэш сообщений (append-only, как audit log). */
val journalCache: MutableJournalStore = KsqliteJournalStore(connection)
/** Группы / lastSeen / preview / watermark синхронизации. */
val meta: ConversationMetaRepository = ConversationMetaRepository(connection)
/** Стабильный id агента (opaque). */
val id: String get() = config.id
/** Имя/описание от сервера. */
val info: AgentInfo get() = agent.info
/** Кэш списка диалогов (`:client`): seed + live-refresh. */
val conversationStore: ConversationStore get() = agent.conversationStore
private val conversationsState = MutableStateFlow<List<UiConversation>>(emptyList())
/**
* Собственный scope для внутренних подписок (список диалогов).
* Не используем [scope] вызывающего: подписка живёт до [close], а у
* вызывающего (в тестах — `runBlocking`) не должно оставаться вечных
* детей, иначе он никогда не завершится.
*/
private val watchScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/**
* Живой список диалогов для UI.
*
* Seed внутри `AgentikAgent` асинхронный (`listFlow` в фоновом job'е),
* поэтому одноразовое чтение сразу после [open] даёт пустой список.
* Здесь мы сами доводим список до сервера и поддерживаем живым:
* - ждём первый непустой снапшот (ограниченное число попыток);
* - дальше перечитываем на каждое outbox-событие списка
* (Created / Deleted / Renamed / Touched).
*/
val conversationsFlow: StateFlow<List<UiConversation>> = conversationsState.asStateFlow()
/** Live-события (SSE). */
val outbox: OutboxStore get() = agent.outbox
init {
startConversationWatch()
}
private fun startConversationWatch() {
watchScope.launch {
var attempts = 0
while (isActive && attempts < SEED_ATTEMPTS) {
if (refreshConversations()) break
attempts++
delay(SEED_RETRY_DELAY)
}
refreshConversations()
runCatching {
agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { refreshConversations() }
}
}
}
/**
* Перечитать список из кэша либы. При ошибке (нет сети) оставляем
* предыдущее значение, а не обнуляем список.
*
* @return true, если снапшот непустой.
*/
private suspend fun refreshConversations(): Boolean {
val snapshot = runCatching { uiConversations() }.getOrNull() ?: return false
conversationsState.value = snapshot
return snapshot.isNotEmpty()
}
/**
* Список диалогов в UI-виде: склейка серверного snapshot'а с локальными
* метами (группа, превью) и счётчиком непрочитанных.
*
* Счётчик берём у сервера (`journal.count(after=lastSeen)`) — точный и
* не требует полного бэкфилла сообщений; если сеть отвалилась, тихо
* падаем на локальный кэш (там может быть 0, если диалог не открывали).
*/
suspend fun uiConversations(limit: Int = 500): List<UiConversation> {
val records = agent.conversationStore.list(offset = 0, limit = limit)
val metas = meta.listMetas().associateBy { it.convId }
return records.map { rec -> rec.toUi(metas[rec.id]) }
}
private suspend fun ConversationRecord.toUi(metaRow: MetaRow?): UiConversation {
val seen = metaRow?.lastSeen
val convId = this.id
val unread = if (seen == null) {
0
} else {
runCatching { agent.journal.count(convId, after = seen) }
.getOrElse { runCatching { journalCache.count(convId, after = seen) }.getOrDefault(0L) }
.toInt()
}
return toUiConversation(metaRow, unread)
}
/** Диалог по id или `null`. */
suspend fun conversation(id: String): Conversation? = agent.getConversation(id)
/**
* Создать диалог. Сразу помечаем его прочитанным, чтобы только что
* созданный пустой чат не мигал бейджем.
*/
suspend fun createConversation(temp: Boolean = false): Conversation? {
val conv = runCatching { agent.createConversation(temp) }.getOrNull() ?: return null
meta.markSeen(conv.id, Clock.System.now())
watchScope.launch { refreshConversations() }
return conv
}
/**
* Удалить диалог на сервере и вычистить локальные следы (кэш сообщений +
* мета). Если сервер не подтвердил удаление — локальные данные не трогаем.
*/
suspend fun deleteConversation(id: String): Boolean {
val ok = runCatching { agent.deleteConversation(id) }.getOrDefault(false)
if (ok) {
runCatching { journalCache.clear(id) }
runCatching { meta.deleteMeta(id) }
watchScope.launch { refreshConversations() }
}
return ok
}
/** Переименовать диалог. `title = null` сбрасывает заголовок. */
suspend fun renameConversation(id: String, title: String?): Instant? =
runCatching { agent.renameConversation(id, title) }
.onSuccess { watchScope.launch { refreshConversations() } }
.getOrNull()
/**
* Открыть сессию диалога. Возвращает `null`, если диалог не найден.
* Сессию нужно закрыть вызывающему (`ChatSession.close`).
*/
suspend fun openSession(id: String): ChatSession? {
val conv = agent.getConversation(id) ?: return null
return ChatSession(
conversationId = id,
conversation = conv,
remoteJournal = agent.journal,
journalCache = journalCache,
meta = meta,
outbox = agent.outbox,
scope = scope,
)
}
override fun close() {
watchScope.cancel()
runCatching { agent.close() }
runCatching { journalCache.close() }
runCatching { meta.close() }
runCatching { connection.close() }
}
companion object {
/** Сколько раз ждать асинхронный seed списка в `AgentikAgent`. */
private const val SEED_ATTEMPTS = 20
/** Пауза между попытками дождаться seed (20 × 250 мс = 5 с). */
private val SEED_RETRY_DELAY = 250.milliseconds
/**
* Открывает подключение к агенту. [clientId] — идентификатор
* установки клиента (X-Client-Id на сервере), общий для всех агентов
* одного пользователя.
*
* @param dbPath путь к SQLite-файлу этого агента. По умолчанию —
* `AppPaths.ensureAgentDatabase(config.id)`; параметр существует,
* чтобы тест мог увести БД в свою temp-папку и не трогать
* пользовательские данные.
* @throws Throwable если сервер недоступен (см. KDoc класса).
*/
fun open(
config: AgentConfig,
clientId: String,
scope: CoroutineScope,
engineFactory: HttpClientEngineFactory<*> = CIO,
dbPath: String? = null,
): AgentConnection {
val resolvedPath = dbPath ?: AppPaths.ensureAgentDatabase(config.id).toString()
val connection = SQLiteConnection.open(path = resolvedPath)
try {
val agent = AgentikAgent(
id = clientId,
baseUrl = config.baseUrl,
engineFactory = engineFactory,
token = config.token,
)
return AgentConnection(config, agent, connection, scope)
} catch (t: Throwable) {
runCatching { connection.close() }
throw t
}
}
}
}
@@ -0,0 +1,190 @@
package pw.binom.agentik.desktop.session
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import pw.binom.agentik.desktop.settings.AgentConfig
import pw.binom.agentik.desktop.settings.AppSettings
import pw.binom.agentik.desktop.settings.SettingsRepository
/** Состояние подключения к агенту. */
sealed interface AgentStatus {
/** Ещё не пробовали подключиться (или идёт попытка). */
data object Connecting : AgentStatus
/** Подключение установлено, есть живая [connection]. */
data class Online(val connection: AgentConnection) : AgentStatus
/** Сервер недоступен; текст ошибки — для диалога «проверка связи». */
data class Offline(val error: String) : AgentStatus
}
/** Фабрика подключения — вынесена, чтобы UI-тесты не ходили в сеть. */
fun interface AgentConnector {
fun open(config: AgentConfig, clientId: String): AgentConnection
}
/**
* Реестр агентов: настройки + живые подключения.
*
* Владеет [AppSettings] (persist через [SettingsRepository]) и держит по
* одному [AgentConnection] на агента. Соединения открываются лениво и
* терпимо: если сервер недоступен, агент получает статус
* [AgentStatus.Offline] с текстом ошибки, но само приложение и остальные
* агенты живут (агент можно переподключить кнопкой).
*
* Все мутации сериализованы одним [Mutex]: UI может дёргать их из разных
* мест, а `settings.json` переписывается целиком.
*/
class AgentRegistry(
private val settingsRepo: SettingsRepository,
private val scope: CoroutineScope,
private val connector: AgentConnector,
) : AutoCloseable {
private val mutex = Mutex()
private val _settings = MutableStateFlow(AppSettings())
val settings: StateFlow<AppSettings> = _settings.asStateFlow()
private val _statuses = MutableStateFlow<Map<String, AgentStatus>>(emptyMap())
val statuses: StateFlow<Map<String, AgentStatus>> = _statuses.asStateFlow()
private var loaded = false
/** Загружает настройки (однократно) и размечает агентов как [AgentStatus.Connecting]. */
suspend fun load(): AppSettings = mutex.withLock {
if (loaded) return@withLock _settings.value
val s = settingsRepo.load()
_settings.value = s
_statuses.value = s.agents.associate { it.id to AgentStatus.Connecting }
loaded = true
s
}
/** Пробует подключиться ко всем агентам (параллельно). */
suspend fun connectAll() {
load()
val ids = _settings.value.agents.map { it.id }
coroutineScope {
ids.map { id -> async { launchConnect(id) } }.awaitAll()
}
}
/** Переподключить одного агента. */
suspend fun reconnect(agentId: String): AgentStatus {
load()
launchConnect(agentId)
return _statuses.value[agentId] ?: AgentStatus.Connecting
}
private suspend fun launchConnect(agentId: String) {
val (config, clientId) = mutex.withLock {
val config = _settings.value.agents.firstOrNull { it.id == agentId } ?: return
closeStatus(agentId)
_statuses.value = _statuses.value + (agentId to AgentStatus.Connecting)
config to _settings.value.clientId
}
val status = withContext(Dispatchers.IO) {
runCatching { connector.open(config, clientId) }
.fold(
onSuccess = { AgentStatus.Online(it) },
onFailure = { AgentStatus.Offline(it.message ?: it::class.simpleName ?: "ошибка подключения") },
)
}
mutex.withLock {
_statuses.value = _statuses.value + (agentId to status)
}
}
/** Добавить агента: persist + попытка подключения. */
suspend fun addAgent(config: AgentConfig): AgentStatus {
load()
mutex.withLock {
_settings.value = persist(_settings.value.copy(agents = _settings.value.agents + config))
_statuses.value = _statuses.value + (config.id to AgentStatus.Connecting)
}
launchConnect(config.id)
return _statuses.value[config.id] ?: AgentStatus.Connecting
}
/** Обновить конфиг агента (адрес/имя/цвет/аватар). Переподключает. */
suspend fun updateAgent(config: AgentConfig): AgentStatus {
load()
var reconnect = false
mutex.withLock {
val before = _settings.value.agents.firstOrNull { it.id == config.id }
if (before == null) return AgentStatus.Offline("агент не найден")
val agents = _settings.value.agents.map { if (it.id == config.id) config else it }
_settings.value = persist(_settings.value.copy(agents = agents))
reconnect = before.baseUrl != config.baseUrl || before.token != config.token
}
if (reconnect) {
launchConnect(config.id)
}
return _statuses.value[config.id] ?: AgentStatus.Connecting
}
/** Удалить агента: закрыть соединение, вычистить из настроек. */
suspend fun removeAgent(agentId: String) {
load()
mutex.withLock {
closeStatus(agentId)
_statuses.value = _statuses.value - agentId
_settings.value = persist(_settings.value.copy(agents = _settings.value.agents.filterNot { it.id == agentId }))
}
}
/** Текущее подключение агента, если он онлайн. */
fun connection(agentId: String): AgentConnection? =
(_statuses.value[agentId] as? AgentStatus.Online)?.connection
/** Обновить выбранную тему / группу / последний диалог. */
suspend fun updateSettings(transform: (AppSettings) -> AppSettings) {
load()
mutex.withLock {
_settings.value = persist(transform(_settings.value))
}
}
override fun close() {
_statuses.value.values.forEach { (it as? AgentStatus.Online)?.connection?.close() }
_statuses.value = emptyMap()
}
private suspend fun persist(next: AppSettings): AppSettings {
settingsRepo.save(next)
return next
}
private fun closeStatus(agentId: String) {
(_statuses.value[agentId] as? AgentStatus.Online)?.connection?.close()
}
companion object {
/**
* Стандартный реестр: подключения через `AgentikAgent` (CIO-движок).
*/
fun create(
settingsRepo: SettingsRepository,
scope: CoroutineScope,
engineFactory: HttpClientEngineFactory<*> = CIO,
): AgentRegistry = AgentRegistry(
settingsRepo = settingsRepo,
scope = scope,
connector = { config, clientId ->
AgentConnection.open(config = config, clientId = clientId, scope = scope, engineFactory = engineFactory)
},
)
}
}
@@ -0,0 +1,247 @@
package pw.binom.agentik.desktop.session
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.desktop.model.UiContent
import pw.binom.agentik.desktop.model.UiMessage
import pw.binom.agentik.desktop.model.toUiMessages
import pw.binom.agentik.desktop.persistence.ConversationMetaRepository
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
/**
* Один открытый диалог: склейка четырёх источников.
*
* - **История** — `KsqliteJournalStore` (локальный кэш `MessageRecord`),
* наружу отдаётся как `StateFlow<List<UiMessage>>`.
* - **Бэкфилл** — `JournalStore` (HTTP `GET /journal/...messages`): при
* старте догоняем то, что появилось с прошлого раза, и кладём в кэш.
* Watermark — `conversation_meta.synced_at`, чтобы не вставлять дубли
* (`message.id` — PRIMARY KEY, повторный INSERT бросает).
* - **Live-ход** — `OutboxStore.conversationEvents` (SSE): события
* `Working → StartReasoning → AppendText → … → End` накапливаются в
* [LiveTurn] и рисуются поверх истории, пока ход не закрыт.
* - **Наши поля** — [ConversationMetaRepository]: `lastSeen` (бейдж),
* `preview` (строка списка).
*
* ## Почему подписка на SSE берётся `after = synced_at`
*
* `synced_at` — `createdAt` последнего сообщения, уже лежащего в кэше.
* У завершённого хода все события (`Working`/`AppendText`/`End`) имеют
* `date`, не больший `createdAt` итогового сообщения, — значит, они
* отфильтруются и повторно не проиграются. А у **идущего** хода события
* свежее — они и попадут в поток. Так «догоняем» уже начатый ход, не
* переигрывая все прошлые (bounded tail outbox этого не запрещает, но
* переигрыш = лишние `sync()` на каждый чужой `End`).
*
* ## Поток событий vs `sync()` на `End`
*
* На `End`/`Interrupted` ход закрыт: подтягиваем авторитетную историю
* (`sync()` — инкрементальный бэкфилл + перечитка кэша) и гасим live-блоки,
* чтобы не было дублирования «live + history».
*/
class ChatSession(
val conversationId: String,
private val conversation: Conversation,
private val remoteJournal: JournalStore,
private val journalCache: MutableJournalStore,
private val meta: ConversationMetaRepository,
private val outbox: OutboxStore,
private val scope: CoroutineScope,
) : AutoCloseable {
private val _history = MutableStateFlow<List<UiMessage>>(emptyList())
/** История ходов из локального кэша, готовая к рендеру. */
val history: StateFlow<List<UiMessage>> = _history.asStateFlow()
/** Live-блоки активного хода (пусто, когда хода нет). */
private val _live = MutableStateFlow<List<UiMessage>>(emptyList())
val live: StateFlow<List<UiMessage>> = _live.asStateFlow()
/** Фаза текущего хода — для спиннера «агент думает» до первого токена. */
private val _phase = MutableStateFlow(TurnPhase.IDLE)
val phase: StateFlow<TurnPhase> = _phase.asStateFlow()
/** true, пока идёт активный ход (спиннер / стриминг). */
val isStreaming: Boolean get() = liveTurn.isStreaming
/** Поддерживает ли агент приём картинок от пользователя. */
val supportsImageInput: Boolean get() = conversation.isSupportImageInput
/** Поддерживает ли агент картинки в ответе. */
val supportsImageOutput: Boolean get() = conversation.isSupportImageOutput
private val liveTurn = LiveTurn()
private var eventsJob: Job? = null
private var started = false
/**
* Сериализует [sync]. Без него два параллельных триггера (оптимистичный
* `sync()` из [sendContent] и `sync()` из обработчика SSE `End`, который
* приходит почти сразу на быстром ответе) читают один и тот же watermark
* `null` → `after = DISTANT_PAST` и оба вставляют user-сообщение →
* `UNIQUE constraint failed: message.id`.
*/
private val syncMutex = Mutex()
/**
* Догоняет историю и подписывается на live-события. Идемпотентно —
* повторный вызов no-op. Вызывать из [scope].
*/
suspend fun start() {
if (started) return
started = true
liveTurn.onChange = {
_phase.value = liveTurn.phase
_live.value = liveTurn.toUiMessages(Clock.System.now())
}
sync()
subscribeEvents()
}
/** Отправить текстовый ход. Пустой/пробельный текст игнорируется. */
suspend fun send(text: String) {
val body = text.trim()
if (body.isEmpty()) return
sendContent(listOf(Content.Text(body)))
}
/**
* Отправить ход с произвольным контентом (текст/картинки). Оптимистично
* помечаем диалог прочитанным и сразу подтягиваем user-сообщение из
* журнала, чтобы оно появилось до ответа ассистента.
*/
suspend fun sendContent(content: List<Content>) {
if (content.isEmpty()) return
liveTurn.start()
conversation.send(content)
markSeen()
runCatching { sync() }
}
/** Прервать активный ход (best-effort на стороне сервера). */
suspend fun interrupt() {
conversation.interrupt()
}
/** Пометить диалог прочитанным до «сейчас». */
suspend fun markSeen() {
meta.markSeen(conversationId, Clock.System.now())
}
override fun close() {
eventsJob?.cancel()
eventsJob = null
runCatching { conversation.close() }
}
// ───── internals ─────
private suspend fun subscribeEvents() {
val after = currentSyncedAt() ?: Instant.DISTANT_PAST
eventsJob = scope.launch {
outbox.conversationEvents(after = after, conversationId = conversationId)
.map { it.event }
.collect { event ->
liveTurn.apply(event)
if (event is Event.End || event is Event.Interrupted) {
// Ход закрыт: авторитетная история важнее live-блоков.
runCatching { sync() }
liveTurn.reset()
}
}
}
}
private suspend fun currentSyncedAt(): Instant? = meta.meta(conversationId)?.syncedAt
/** Инкрементальный бэкфилл + перечитка кэша + превью. Сериализован. */
private suspend fun sync() = syncMutex.withLock {
backfill()
reloadHistory()
updatePreview()
}
/**
* Дотягивает сообщения, появившиеся с прошлого watermark'а.
*
* **Идемпотентность по id, а не по времени.** Watermark — миллисекундный:
* user- и assistant-сообщение быстрого ответа делят одну миллисекунду, и
* фильтр `createdAt > after` просто потерял бы второе. Поэтому watermark
* отступает на 1 мс назад, а повторно пришедшие записи отсекаются по
* множеству уже известных id (никакого `runCatching` — реальные ошибки
* записи должны быть видны).
*/
private suspend fun backfill() {
val row = meta.meta(conversationId)
val after = row?.syncedAt ?: Instant.DISTANT_PAST
val known = journalCache.list(
conversationId = conversationId,
after = Instant.DISTANT_PAST,
offset = 0,
limit = Int.MAX_VALUE,
).mapTo(mutableSetOf()) { it.id }
var newest: Instant? = null
remoteJournal.listFlow(conversationId = conversationId, after = after).collect { rec ->
if (known.add(rec.id)) {
journalCache.append(rec)
}
val n = newest
if (n == null || rec.createdAt > n) newest = rec.createdAt
}
newest?.let { meta.setSyncedAt(conversationId, it - 1.milliseconds) }
}
private suspend fun reloadHistory() {
val records = journalCache.list(
conversationId = conversationId,
after = Instant.DISTANT_PAST,
offset = 0,
limit = Int.MAX_VALUE,
)
_history.value = records.toUiMessages()
}
private suspend fun updatePreview() {
val last = _history.value.lastOrNull { it is UiMessage.Assistant } ?: _history.value.lastOrNull()
meta.setPreview(conversationId, last?.previewText())
}
}
/** Короткое однострочное превью сообщения для строки списка диалогов. */
internal fun UiMessage.previewText(maxLength: Int = 120): String? {
val raw = when (this) {
is UiMessage.User -> content.firstText()
is UiMessage.Assistant -> content.firstText() ?: content.imagePlaceholder()
is UiMessage.Reasoning -> text
is UiMessage.Tool -> title ?: toolName
is UiMessage.Error -> message
}?.replace(Regex("\\s+"), " ")?.trim()
if (raw.isNullOrEmpty()) return null
if (raw.length <= maxLength) return raw
val cut = raw.take(maxLength)
val lastSpace = cut.lastIndexOf(' ')
return (if (lastSpace > maxLength / 2) cut.take(lastSpace) else cut).trimEnd() + "…"
}
private fun List<UiContent>.firstText(): String? =
firstNotNullOfOrNull { (it as? UiContent.Text)?.body }
private fun List<UiContent>.imagePlaceholder(): String? =
if (any { it is UiContent.Image }) "[изображение]" else null
@@ -0,0 +1,301 @@
package pw.binom.agentik.desktop.session
import pw.binom.agentik.outbox.Event
/**
* Фаза текущего хода ассистента. Управляет спиннером и тем, что UI
* показывает вместо ещё не завершённого ответа.
*/
enum class TurnPhase {
/** Хода нет — стор чист. */
IDLE,
/** `send()` ушёл, ответа ещё нет (`Event.Working`). Спиннер «агент думает». */
WORKING,
/** Идут рассуждения (`StartReasoning` + `AppendText` в reasoning-блок). */
REASONING,
/** Стримится ответ (`StartResponse` + `AppendText`/`AppendImage`/tool'ы). */
RESPONDING,
/** Ход закрыт нормально (`End`). Live-блоки пора заменить историей из кэша. */
DONE,
/** Ход прерван пользователем (`Interrupted`). Частичный ответ НЕ сохранён. */
INTERRUPTED,
/** Ход упал (`Error`). */
FAILED;
/** true для DONE/INTERRUPTED/FAILED/IDLE — т.е. стриминг уже не идёт. */
val isTerminal: Boolean get() = this == IDLE || this == DONE || this == INTERRUPTED || this == FAILED
/** true, пока идёт активный ход (WORKING/REASONING/RESPONDING). */
val isStreaming: Boolean get() = !isTerminal
}
/**
* Блок «живого» (ещё не сохранённого в журнал) хода.
*
* Ключ [key] стабилен в пределах одного хода и нужен как `key` в
* `LazyColumn`: без него Compose перезапускает анимации/скролл при каждом
* новом токене.
*
* Текстовые блоки ([Reasoning], [Answer]) держат [StringBuilder] — append
* на токен O(1). Наружу отдаётся как есть; UI вызывает `.toString()` в
* момент отрисовки (парсер markdown всё равно строит строку).
*/
sealed interface LiveBlock {
val key: String
/** Рассуждения ассистента (`StartReasoning` → `AppendText`). Рендерятся приглушённо. */
class Reasoning(override val key: String) : LiveBlock {
val text: StringBuilder = StringBuilder()
}
/** Текст ответа (`StartResponse(TEXT)` → `AppendText`). Основной markdown-блок. */
class Answer(override val key: String) : LiveBlock {
val text: StringBuilder = StringBuilder()
}
/** Картинка, пришедшая целиком (`AppendImage`). Стриминга байтов нет. */
class Image(
override val key: String,
val mime: String,
val data: ByteArray,
) : LiveBlock
/**
* Вызов инструмента. [callId] = id из `Event.ToolCall` и, после `End`,
* id `MessageRecord.ToolCall`. Результат может придти позже
* (`ToolResult`) или упасть (`ToolFailed`) — тогда заполняется
* [result] / [failed].
*/
class Tool(
override val key: String,
val callId: String,
val title: String?,
val toolName: String,
val argsJson: String,
) : LiveBlock {
var result: String? = null
var failed: String? = null
}
/** `Event.Error` — ход провален. Терминальный блок. */
class Failure(
override val key: String,
val message: String,
val code: String?,
) : LiveBlock
}
/**
* Аккумулятор live-хода: применяет [Event] по мере SSE-стрима и держит
* текущий список [LiveBlock] + [phase].
*
* Это **модель представления**, не хранилище: после `End` ход
* «схлопывается» в обычную `MessageRecord`, и UI переключается на историю
* из `KsqliteJournalStore`. См. `ChatSession`.
*
* **Мутабельность намеренная.** Ход растёт токен за токеном; создавать
* новый иммутабельный список на каждый токен — это лишние аллокации и
* потеря стабильных ключей. UI подписывается на [onChange] и перерисовывает
* кадр (Compose coalesces изменения за кадр).
*
* **Чистота логики.** Метод [apply] — единственная точка входа; никаких
* сайд-эффектов кроме изменения собственного состояния. Юнит-тестируется
* синтетическими событиями без сети и БД.
*/
class LiveTurn {
var phase: TurnPhase = TurnPhase.IDLE
private set
private val _blocks = mutableListOf<LiveBlock>()
/** Блоки в порядке появления. Живой список — не копия. */
val blocks: List<LiveBlock> get() = _blocks
/**
* Хук «состояние изменилось» — UI вешает сюда `onChange = { revision++ }`.
* Вызывается после каждого [apply], который реально что-то поменял.
*/
var onChange: (() -> Unit)? = null
private var keyCounter: Int = 0
private fun nextKey(kind: String): String = "$kind-${keyCounter++}"
/** true, пока ход активен (аналог `phase.isStreaming`). */
val isStreaming: Boolean get() = phase.isStreaming
/**
* Начинает новый ход: чистит блоки и переводит в [TurnPhase.WORKING].
* Вызывать перед `send()` (или на `Event.Working`) — иначе блоки
* предыдущего хода смешаются с новыми.
*/
fun start() {
_blocks.clear()
keyCounter = 0
phase = TurnPhase.WORKING
fire()
}
/** Полный сброс в [TurnPhase.IDLE]. */
fun reset() {
_blocks.clear()
keyCounter = 0
phase = TurnPhase.IDLE
fire()
}
/**
* Применяет одно событие. Неизвестные/нерелевантные события
* (`ConversationClosing`, `CompactionTriggered`) игнорируются.
*/
fun apply(event: Event) {
when (event) {
is Event.Working -> {
// Синхронный маркер старта хода: новый ход — с чистого листа.
start()
}
is Event.StartReasoning -> {
phase = TurnPhase.REASONING
_blocks.add(LiveBlock.Reasoning(nextKey("reasoning")))
fire()
}
is Event.StartResponse -> {
phase = TurnPhase.RESPONDING
if (event.responseType == Event.ResponseType.TEXT) {
_blocks.add(LiveBlock.Answer(nextKey("answer")))
}
fire()
}
is Event.AppendText -> {
when (phase) {
TurnPhase.REASONING -> appendText(event.body, reasoning = true)
// AppendText без предшествующего StartResponse —
// трактуем как ответ (forward-compat).
else -> {
if (phase != TurnPhase.RESPONDING) phase = TurnPhase.RESPONDING
appendText(event.body, reasoning = false)
}
}
}
is Event.AppendImage -> {
phase = TurnPhase.RESPONDING
_blocks.add(LiveBlock.Image(nextKey("image"), event.mime, event.body))
fire()
}
is Event.ToolCall -> {
_blocks.add(
LiveBlock.Tool(
key = nextKey("tool"),
callId = event.id,
title = event.title,
toolName = event.toolName,
argsJson = event.toolArgs,
)
)
fire()
}
is Event.ToolResult -> {
val tool = findTool(event.toolCallId)
?: LiveBlock.Tool(
key = nextKey("tool"),
callId = event.toolCallId,
title = null,
toolName = event.toolName ?: "(unknown)",
argsJson = "",
).also { _blocks.add(it) }
tool.result = event.result
fire()
}
is Event.ToolFailed -> {
val tool = findTool(event.toolCallId)
?: LiveBlock.Tool(
key = nextKey("tool"),
callId = event.toolCallId,
title = null,
toolName = event.toolName ?: "(unknown)",
argsJson = "",
).also { _blocks.add(it) }
tool.failed = event.message
fire()
}
is Event.End -> {
phase = TurnPhase.DONE
fire()
}
is Event.Interrupted -> {
phase = TurnPhase.INTERRUPTED
fire()
}
is Event.Error -> {
_blocks.add(LiveBlock.Failure(nextKey("error"), event.message, event.code))
phase = TurnPhase.FAILED
fire()
}
// Не влияют на представление хода.
is Event.ConversationClosing, is Event.CompactionTriggered -> Unit
}
}
/**
* Дописывает текстовый чанк в последний блок нужного типа. Если
* последний блок — другого типа (например, между двумя кусками текста
* вклинился tool-call), заводит новый текстовый блок: это корректно —
* ответ продолжался после результата инструмента.
*/
private fun appendText(chunk: String, reasoning: Boolean) {
val last = _blocks.lastOrNull()
if (reasoning) {
val block = last as? LiveBlock.Reasoning
?: LiveBlock.Reasoning(nextKey("reasoning")).also { _blocks.add(it) }
block.text.append(chunk)
} else {
val block = last as? LiveBlock.Answer
?: LiveBlock.Answer(nextKey("answer")).also { _blocks.add(it) }
block.text.append(chunk)
}
fire()
}
private fun findTool(callId: String): LiveBlock.Tool? =
_blocks.filterIsInstance<LiveBlock.Tool>().lastOrNull { it.callId == callId }
private fun fire() {
onChange?.invoke()
}
/**
* Текст ответа целиком (все Answer-блоки). Используется для превью
* в списке диалогов и для «склеенного» рендера.
*/
fun answerText(): String =
_blocks.filterIsInstance<LiveBlock.Answer>().joinToString("") { it.text.toString() }
/**
* Короткое превью последнего ответа для строки списка диалогов.
* Обрезает по границе слова и добавляет многоточие.
*/
fun previewText(maxLength: Int = 120): String {
val raw = answerText().replace(Regex("\\s+"), " ").trim()
if (raw.length <= maxLength) return raw
val cut = raw.take(maxLength)
val lastSpace = cut.lastIndexOf(' ')
return (if (lastSpace > maxLength / 2) cut.take(lastSpace) else cut).trimEnd() + "…"
}
}
@@ -0,0 +1,82 @@
package pw.binom.agentik.desktop.settings
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.createDirectories
/**
* Каталоги данных десктоп-клиента.
*
* Всё локальное живёт в одном каталоге, чтобы пользователь мог снести
* состояние одной командой (`rm -rf ~/.agentik-desktop`) и чтобы при
* переносе на другую машину было понятно, что копировать.
*
* Корень можно переопределить переменной окружения `AGENTIK_DESKTOP_HOME`
* (нужно тестам, чтобы не трогать реальный `$HOME`).
*/
object AppPaths {
const val ENV_HOME = "AGENTIK_DESKTOP_HOME"
/** Корень данных: `$AGENTIK_DESKTOP_HOME` или `~/.agentik-desktop`. */
val root: Path by lazy {
val env = System.getenv(ENV_HOME)?.takeIf { it.isNotBlank() }
if (env != null) {
Path(env)
} else {
val home = System.getProperty("user.home") ?: "."
Path(home).resolve(".agentik-desktop")
}
}
/** Каталог данных всех агентов: `root/agents/`. */
val agentsDir: Path get() = root.resolve("agents")
/**
* Каталог данных одного агента: `root/agents/<safe id>/`.
*
* **Почему отдельный каталог/база на агента:** id диалогов выдаёт
* сервер, и два разных агента теоретически могут выдать одинаковый id —
* в общей таблице `message` их сообщения перемешались бы. Плюс группы
* (наша локальная сущность) логически принадлежат конкретному агенту.
* Отдельный файл на агента убирает и коллизии, и необходимость
* неймспейсить id.
*/
fun agentDir(agentId: String): Path = agentsDir.resolve(safeDirName(agentId))
/** SQLite-база одного агента: сообщения, диалоги, группы, меты. */
fun agentDatabase(agentId: String): Path = agentDir(agentId).resolve("agentik.db")
/** Скопированные пользователем аватары агентов (общий каталог). */
val avatars: Path get() = root.resolve("avatars")
/** Гарантирует существование каталога и возвращает его. */
fun ensure(dir: Path): Path {
dir.createDirectories()
return dir
}
/**
* Гарантирует существование каталога агента и возвращает путь к его БД.
* Вызывается перед открытием `SQLiteConnection`.
*/
fun ensureAgentDatabase(agentId: String): Path {
ensure(agentDir(agentId))
return agentDatabase(agentId)
}
/** Гарантирует существование корня и всех общих подкаталогов. */
fun ensureAll() {
ensure(root)
ensure(agentsDir)
ensure(avatars)
}
/**
* Приводит произвольный agent id к безопасному имени каталога:
* оставляет `[A-Za-z0-9._-]`, остальное — в `_`. Формат URL/hostname
* может содержать `:`/`/`, их в имени файла быть не должно.
*/
fun safeDirName(id: String): String =
id.replace(Regex("[^A-Za-z0-9._-]"), "_").take(80).ifBlank { "agent" }
}
@@ -0,0 +1,90 @@
package pw.binom.agentik.desktop.settings
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* Один подключённый агент в настройках клиента.
*
* **Что здесь есть и почему:**
* - [id] — локальный стабильный ключ (UUID), генерируется один раз. Нужен
* как ключ для аватара/групп/«последнего диалога», чтобы переименование
* или смена адреса не теряла привязку. Сервер про него не знает.
* - [baseUrl] — полный адрес фасада агента, обязательно с path-префиксом:
* `http://192.168.1.10:8080/agentik`. Не просто хост — `AgentikAgent`
* ходит по этому URL как есть.
* - [token] — Bearer. `null` = анонимно (сервер обычно так и разрешает,
* см. REQUIREMENTS R23.7 — аутентификации у сервера нет; поле оставлено
* на будущее и прокидывается в `AgentikAgent(token = ...)`).
* - [nameOverride] — локальное переименование ассистента. Сервер отдаёт
* своё имя через `Agent.info.name` (AgentInfo); это поле — на случай,
* если пользователь хочет звать его иначе только у себя. `null` →
* показываем серверное имя.
* - [avatarPath] — абсолютный путь к локально скопированному аватару
* (см. `AvatarStorage`). `null` → инициалы/цвет. Сервер аватары не
* отдаёт (REQUIREMENTS R23.3.2 — картинку выбирает пользователь).
* - [colorIndex] — индекс в палитре акцентов (0..4), для бейджа/полоски
* агента, когда аватара нет. Расширение палитры из Android (a1..a5).
*/
@Serializable
data class AgentConfig(
val id: String,
val baseUrl: String,
val token: String? = null,
val nameOverride: String? = null,
val avatarPath: String? = null,
val colorIndex: Int = 0,
) {
companion object {
/**
* Новый конфиг с сгенерированным локальным id. Вызывается UI'ом
* при добавлении агента; отдельного `create()` без id нет —
* id обязан существовать до первого сохранения.
*/
fun create(
baseUrl: String,
token: String? = null,
nameOverride: String? = null,
colorIndex: Int = 0,
): AgentConfig = AgentConfig(
id = UUID.randomUUID().toString(),
baseUrl = baseUrl.trim(),
token = token?.trim()?.takeIf { it.isNotBlank() },
nameOverride = nameOverride?.trim()?.takeIf { it.isNotBlank() },
colorIndex = colorIndex,
)
}
}
/**
* Корневой документ `settings.json`.
*
* Хранится целиком (файл небольшой) и переписывается атомарно. Поля:
* - [clientId] — идентичность клиента в логах сервера (X-Client-Id).
* Генерируется один раз при первом запуске и **никогда не меняется**
* (см. `client/README.md`: иначе ломается log multiplexing на сервере).
* - [agents] — список подключённых агентов; пустой список = первый запуск,
* UI показывает экран добавления агента.
* - [themeId] — id выбранной темы (`ThemeId`).
* - [selectedGroupId] — выбранная группа в сайдбаре (`null` = «Все чаты»).
* `-1` зарезервирован под «Все чаты» в UI, поэтому тут только реальные id
* групп из `ConversationMetaRepository` (Long, автоинкремент).
* - [lastConversationId] — последний открытый диалог (для восстановления
* при старте). Не гарантируется живым — UI проверяет существование.
*/
@Serializable
data class AppSettings(
val clientId: String = "",
val agents: List<AgentConfig> = emptyList(),
val themeId: String = ThemeId.DARK,
val selectedGroupId: Long? = null,
val lastConversationId: String? = null,
)
/** Идентификаторы тем. Значения — стабильные строки для JSON. */
object ThemeId {
const val DARK = "dark"
const val LIGHT = "light"
val all = listOf(DARK, LIGHT)
}
@@ -0,0 +1,135 @@
package pw.binom.agentik.desktop.settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.UUID
import kotlin.io.path.createDirectories
import kotlin.io.path.exists
import kotlin.io.path.readText
/**
* Персистентность [AppSettings] в JSON-файл.
*
* Намеренно **не** в `:client` (см. `client/README.md`: либа не навязывает
* формат настроек) — это зона десктоп-клиента.
*
* **Гарантии:**
* - [load] никогда не бросает на отсутствующем/битом файле: нет файла →
* дефолт с только что сгенерированным `clientId`; битый JSON → тоже
* дефолт (старый файл не трогаем, перезапишем при следующем [save]).
* - [load] генерирует `clientId`, если он пуст, и **сразу сохраняет** —
* чтобы один и тот же id жил между запусками (иначе сервер увидит
* каждый старт как нового клиента).
* - [save] атомарен: пишем во временный файл рядом и делаем
* `ATOMIC_MOVE`. Падение процесса посреди записи не оставит обрубок.
*
* **Тред-безопасность:** один `Mutex` на чтение/запись файла. Репозиторий
* не кэширует состояние в памяти — источник истины всегда файл (UI держит
* свой снимок в Compose-состоянии и зовёт [update] при изменениях).
*/
class SettingsRepository(private val file: Path) {
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
private val mutex = Mutex()
/**
* Читает настройки. Отсутствие файла = первый запуск: создаём дефолт,
* генерируем `clientId` и сохраняем. Битый файл тоже трактуется как
* первый запуск (не роняем приложение из-за настроек).
*/
suspend fun load(): AppSettings = mutex.withLock {
loadLocked()
}
/** Полная перезапись файла. Возвращает то, что записали. */
suspend fun save(settings: AppSettings): AppSettings = mutex.withLock {
val normalized = settings.normalized()
writeLocked(normalized)
normalized
}
/**
* Атомарный read-modify-write: читает актуальные настройки, применяет
* [transform], сохраняет результат. Возвращает новое значение. Все
* изменения UI'а должны идти через этот метод — иначе два параллельных
* «прочитал-изменил-записал» затирают друг друга.
*/
suspend fun update(transform: (AppSettings) -> AppSettings): AppSettings = mutex.withLock {
val updated = transform(loadLocked()).normalized()
writeLocked(updated)
updated
}
private fun loadLocked(): AppSettings {
val existing = readFileOrNull()
if (existing != null) {
val normalized = existing.normalized()
// Нормализация могла поправить пустой clientId или неизвестную
// тему — фиксируем результат на диске, чтобы не повторять
// починку при каждом старте.
if (normalized != existing) writeLocked(normalized)
return normalized
}
val fresh = AppSettings(clientId = newClientId())
writeLocked(fresh)
return fresh
}
private fun readFileOrNull(): AppSettings? {
if (!file.exists()) return null
return try {
json.decodeFromString(AppSettings.serializer(), file.readText())
} catch (_: Throwable) {
null
}
}
private fun writeLocked(settings: AppSettings) {
val parent = file.parent
if (parent != null) parent.createDirectories()
val text = json.encodeToString(AppSettings.serializer(), settings)
// Atomic move требует, чтобы tmp был на той же файловой системе —
// поэтому кладём рядом с целевым файлом, а не в /tmp.
val tmp = parent?.resolve(file.fileName.toString() + ".tmp")
?: Files.createTempFile("agentik-settings", ".json")
Files.writeString(tmp, text)
try {
Files.move(tmp, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
} catch (_: java.nio.file.AtomicMoveNotSupportedException) {
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING)
}
}
companion object {
/** Имя файла настроек внутри каталога приложения. */
const val FILE_NAME = "settings.json"
/** Префикс clientId. Сервер его не интерпретирует — только логи. */
private const val CLIENT_ID_PREFIX = "agentik-desktop-"
fun newClientId(): String = CLIENT_ID_PREFIX + UUID.randomUUID()
/** Репозиторий в каталоге [dir] (обычно `AppPaths.root()`). */
fun inDirectory(dir: Path): SettingsRepository = SettingsRepository(dir.resolve(FILE_NAME))
/** Репозиторий в стандартном каталоге данных приложения. */
fun default(): SettingsRepository = inDirectory(AppPaths.root)
}
}
private fun AppSettings.normalized(): AppSettings = copy(
clientId = clientId.ifBlank { SettingsRepository.newClientId() },
themeId = if (themeId in ThemeId.all) themeId else ThemeId.DARK,
)
@@ -0,0 +1,199 @@
package pw.binom.agentik.desktop.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.ui.components.LocalAvatarStorage
import pw.binom.agentik.desktop.ui.components.Overlay
import pw.binom.agentik.desktop.ui.screens.AgentRail
import pw.binom.agentik.desktop.ui.screens.AgentsDialog
import pw.binom.agentik.desktop.ui.screens.ChatPane
import pw.binom.agentik.desktop.ui.screens.ConversationList
import pw.binom.agentik.desktop.ui.screens.GroupsDialog
import pw.binom.agentik.desktop.ui.screens.NewChatDialog
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
private sealed interface AppDialog {
data object Agents : AppDialog
data object Groups : AppDialog
data object NewChat : AppDialog
}
/**
* Корень приложения: тема, рейка ассистентов, список диалогов, чат и
* модальные окна. Раскладка адаптивная: широкое окно — три колонки,
* узкое — список и чат по очереди (с кнопкой «назад»).
*/
@Composable
fun App(state: AppState) {
AgentikTheme(themeId = state.settingsState.themeId) {
CompositionLocalProvider(LocalAvatarStorage provides state.avatars) {
LaunchedEffect(Unit) { state.start() }
Root(state)
}
}
}
@Composable
private fun Root(state: AppState) {
val colors = AgentikTheme.colors
var dialog by remember { mutableStateOf<AppDialog?>(null) }
var selectedGroupId by remember { mutableStateOf<Long?>(null) }
BoxWithConstraints(Modifier.fillMaxSize().background(colors.background)) {
val wide = maxWidth >= 900.dp
val hasChat = state.activeConversationId != null
// При нескольких ассистентах спрашиваем, в каком создавать диалог.
val newChat: () -> Unit = {
if (state.agents.size > 1) {
dialog = AppDialog.NewChat
} else {
state.agents.firstOrNull()?.let { state.newConversation(it.id) }
}
}
Column(Modifier.fillMaxSize()) {
state.error?.let { message ->
ErrorBanner(message = message, onDismiss = { state.clearError() })
}
Row(Modifier.weight(1f)) {
AgentRail(
state = state,
onOpenAgents = { dialog = AppDialog.Agents },
)
Divider()
if (state.agents.isEmpty()) {
Welcome(onOpenAgents = { dialog = AppDialog.Agents }, modifier = Modifier.weight(1f))
} else if (wide) {
ConversationList(
state = state,
selectedGroupId = selectedGroupId,
onSelectGroup = { selectedGroupId = it },
onOpenConversation = { state.openConversation(it) },
onOpenGroups = { dialog = AppDialog.Groups },
onNewConversation = newChat,
modifier = Modifier.width(300.dp),
)
Divider()
ChatPane(state = state, onBack = null, modifier = Modifier.weight(1f))
} else {
if (hasChat) {
ChatPane(
state = state,
onBack = {
state.closeActiveConversation()
},
modifier = Modifier.weight(1f),
)
} else {
ConversationList(
state = state,
selectedGroupId = selectedGroupId,
onSelectGroup = { selectedGroupId = it },
onOpenConversation = { state.openConversation(it) },
onOpenGroups = { dialog = AppDialog.Groups },
onNewConversation = newChat,
modifier = Modifier.weight(1f),
)
}
}
}
}
when (dialog) {
AppDialog.Agents -> Overlay(onDismiss = { dialog = null }) {
AgentsDialog(state = state, onClose = { dialog = null })
}
AppDialog.Groups -> Overlay(onDismiss = { dialog = null }) {
GroupsDialog(state = state, onClose = { dialog = null })
}
AppDialog.NewChat -> Overlay(onDismiss = { dialog = null }) {
NewChatDialog(
state = state,
onPick = { agentId ->
dialog = null
state.newConversation(agentId)
},
onClose = { dialog = null },
)
}
null -> Unit
}
}
}
@Composable
private fun Divider() {
val colors = AgentikTheme.colors
Box(Modifier.width(1.dp).fillMaxHeight().background(colors.line))
}
@Composable
private fun ErrorBanner(message: String, onDismiss: () -> Unit) {
val colors = AgentikTheme.colors
Row(
modifier = Modifier
.fillMaxWidth()
.background(colors.error)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(message, color = colors.onAccent, fontSize = 12.5.sp, modifier = Modifier.weight(1f))
TextButton(onClick = onDismiss) { Text("Скрыть", color = colors.onAccent) }
}
}
@Composable
private fun Welcome(onOpenAgents: () -> Unit, modifier: Modifier = Modifier) {
val colors = AgentikTheme.colors
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(colors.panel)
.padding(28.dp),
) {
Text("💬", fontSize = 34.sp)
Text("Добро пожаловать", color = colors.text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
Text(
"Добавьте адрес сервера agentik, чтобы начать общение с ассистентом.",
color = colors.textDim,
fontSize = 13.sp,
)
Spacer(Modifier.height(6.dp))
TextButton(onClick = onOpenAgents) { Text("Добавить агента") }
}
}
}
@@ -0,0 +1,450 @@
package pw.binom.agentik.desktop.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import pw.binom.agentik.desktop.media.AvatarStorage
import pw.binom.agentik.desktop.model.UiConversation
import pw.binom.agentik.desktop.model.UiMessage
import pw.binom.agentik.desktop.net.CheckStep
import pw.binom.agentik.desktop.net.ConnectionChecker
import pw.binom.agentik.desktop.net.ConnectionReport
import pw.binom.agentik.desktop.persistence.Group
import pw.binom.agentik.desktop.session.AgentRegistry
import pw.binom.agentik.desktop.session.AgentStatus
import pw.binom.agentik.desktop.session.ChatSession
import pw.binom.agentik.desktop.settings.AgentConfig
import pw.binom.agentik.desktop.settings.AppSettings
import pw.binom.agentik.desktop.voice.VoiceController
import pw.binom.agentik.desktop.voice.VoiceState
/**
* Состояние приложения для UI: выбранный агент и диалог, список диалогов,
* группы, история открытого диалога, live-ход. Держит один [ChatSession] и
* один job-коллектор на него; при переключении диалога сессия закрывается.
*
* Класс намеренно «толстый»: это единственная точка, где UI-слой знает про
* [AgentRegistry] и [ChatSession]. Экраны читают [androidx.compose.runtime.State]
* и зовут методы — ничего не собирают сами.
*
* Все действия запускаются в переданном [scope] и не бросают наверх:
* ошибки складываются в [error] для баннера/тоста.
*/
class AppState(
private val registry: AgentRegistry,
private val scope: CoroutineScope,
val avatars: AvatarStorage = AvatarStorage(),
private val checker: ConnectionChecker = ConnectionChecker(),
/** Доступен ли голосовой ввод: результат прогрева нативного ASR до skia. */
val voiceAvailable: Boolean = true,
) {
/**
* Настройки, зеркалируемые в Compose-состояние (чтобы UI перерисовывался
* при добавлении/удалении агента, смене темы и т.п.).
*/
var settingsState by mutableStateOf(AppSettings())
private set
/** Статусы подключений, зеркалируемые в Compose-состояние. */
var statusesState by mutableStateOf<Map<String, AgentStatus>>(emptyMap())
private set
var activeAgentId by mutableStateOf<String?>(null)
private set
var activeConversationId by mutableStateOf<String?>(null)
private set
var conversations by mutableStateOf<List<UiConversation>>(emptyList())
private set
var groups by mutableStateOf<List<Group>>(emptyList())
private set
var history by mutableStateOf<List<UiMessage>>(emptyList())
private set
var live by mutableStateOf<List<UiMessage>>(emptyList())
private set
/** true, пока идёт активный ход (в т.ч. до первого токена — спиннер). */
var thinking by mutableStateOf(false)
private set
/** Последняя ошибка для баннера. Сбрасывается [clearError]. */
var error by mutableStateOf<String?>(null)
private set
/** Состояние голосового ввода (кнопка микрофона). */
var voiceState by mutableStateOf(VoiceState.IDLE)
private set
/** Промежуточный текст распознавания — показываем под композером. */
var voicePartial by mutableStateOf<String?>(null)
private set
/** Черновик композера: сюда же дописывается распознанная речь. */
var composerDraft by mutableStateOf("")
private set
private var session: ChatSession? = null
private var sessionJob: Job? = null
private var listJob: Job? = null
private var conversationsJob: Job? = null
/**
* Голосовой ввод. Итоговый текст дописывается в черновик композера
* (не отправляется сам — решает пользователь).
*/
private val voice = VoiceController(
scope = scope,
onState = { voiceState = it },
onPartial = { voicePartial = it },
onFinal = { text ->
voicePartial = null
composerDraft = appendToDraft(composerDraft, text)
},
onError = { error = it },
)
/** Конфиг активного агента. */
val activeAgent: AgentConfig?
get() = settingsState.agents.firstOrNull { it.id == activeAgentId }
/** Статус активного агента. */
val activeStatus: AgentStatus?
get() = activeAgentId?.let { statusesState[it] }
/** Каталог агентов. */
val agents: List<AgentConfig> get() = settingsState.agents
/**
* Отображаемое имя агента: локальное переименование → серверное
* `AgentInfo.name` → адрес (пока не подключились).
*/
fun agentDisplayName(config: AgentConfig): String =
config.nameOverride
?: registry.connection(config.id)?.info?.name
?: config.baseUrl
/** Цвет агента для полоски/аватара (пользовательский, не из темы). */
fun agentColor(config: AgentConfig): androidx.compose.ui.graphics.Color =
pw.binom.agentik.desktop.ui.theme.AgentColors.ofIndex(config.colorIndex)
// ───── lifecycle ─────
/** Загружает настройки, подключает агентов и выбирает первого. */
fun start() {
scope.launch { registry.settings.collect { settingsState = it } }
scope.launch { registry.statuses.collect { statusesState = it } }
scope.launch {
val s = registry.load()
registry.connectAll()
s.agents.firstOrNull()?.let { selectAgent(it.id) }
}
}
fun clearError() {
error = null
}
/**
* Показать ошибку в баннере. Отмену корутин (`CancellationException`)
* молча игнорируем: она приходит штатно, когда мы сами отменяем
* предыдущий job (например, [refresh] поверх [refresh]), и
* «StandaloneCoroutine was cancelled» в баннере — не ошибка пользователя.
*/
private fun reportError(t: Throwable, prefix: String? = null) {
if (t is CancellationException) return
val text = t.message ?: t::class.simpleName ?: "Ошибка"
error = if (prefix == null) text else "$prefix: $text"
}
// ───── agents ─────
fun selectAgent(agentId: String) {
if (activeAgentId == agentId) return
closeSession()
activeAgentId = agentId
activeConversationId = null
conversations = emptyList()
groups = emptyList()
history = emptyList()
live = emptyList()
watchConversations(agentId)
refresh()
}
/**
* Подписаться на живой список диалогов агента. Одноразового чтения мало:
* seed в `AgentikAgent` асинхронный, а новые/переименованные диалоги
* приходят outbox-событиями (см. [AgentConnection.conversationsFlow]).
*/
private fun watchConversations(agentId: String) {
conversationsJob?.cancel()
val conn = registry.connection(agentId)
if (conn == null) {
conversations = emptyList()
return
}
conversationsJob = scope.launch {
conn.conversationsFlow.collect { conversations = it }
}
}
fun addAgent(baseUrl: String, nameOverride: String? = null, colorIndex: Int = 0) {
if (baseUrl.isBlank()) {
error = "Укажите адрес агента"
return
}
scope.launch {
val cfg = AgentConfig.create(baseUrl = baseUrl, nameOverride = nameOverride, colorIndex = colorIndex)
runCatching { registry.addAgent(cfg) }.onFailure { reportError(it) }
selectAgent(cfg.id)
}
}
fun updateAgent(config: AgentConfig) {
scope.launch {
runCatching { registry.updateAgent(config) }.onFailure { reportError(it) }
refresh()
}
}
fun removeAgent(agentId: String) {
scope.launch {
if (activeAgentId == agentId) {
closeSession()
conversationsJob?.cancel()
activeAgentId = null
conversations = emptyList()
}
runCatching { registry.removeAgent(agentId) }.onFailure { reportError(it) }
settingsState.agents.firstOrNull()?.let { selectAgent(it.id) }
}
}
fun reconnect(agentId: String) {
scope.launch {
runCatching { registry.reconnect(agentId) }.onFailure { reportError(it) }
refresh()
}
}
/** Проверка связи для формы агента — вызывается UI из своей корутины. */
suspend fun checkConnection(config: AgentConfig): ConnectionReport =
runCatching { checker.check(config, settingsState.clientId) }
.getOrElse { ConnectionReport(config.baseUrl, listOf(CheckStep("Ответ сервера", false, 0, it.message))) }
// ───── conversations ─────
fun refresh() {
val conn = activeAgentId?.let { registry.connection(it) } ?: return
listJob?.cancel()
listJob = scope.launch {
runCatching {
conversations = conn.uiConversations()
groups = conn.meta.listGroups()
}.onFailure { reportError(it) }
}
}
fun openConversation(id: String) {
val conn = activeAgentId?.let { registry.connection(it) } ?: return
closeSession()
// Черновик и голос — принадлежат открытому диалогу, не переносим.
composerDraft = ""
cancelVoice()
activeConversationId = id
history = emptyList()
live = emptyList()
thinking = false
sessionJob = scope.launch {
val s = runCatching { conn.openSession(id) }.getOrNull()
if (s == null) {
error = "Диалог не найден на агенте"
return@launch
}
session = s
launch { s.history.collect { history = it } }
launch { s.live.collect { live = it } }
launch { s.phase.collect { thinking = it.isStreaming } }
runCatching { s.start() }
runCatching { s.markSeen() }
registry.updateSettings { it.copy(lastConversationId = id) }
refresh()
}
}
/** Закрыть открытый диалог (кнопка «назад» в узкой раскладке). */
fun closeActiveConversation() {
closeSession()
activeConversationId = null
history = emptyList()
live = emptyList()
thinking = false
composerDraft = ""
cancelVoice()
}
/**
* Создать диалог у конкретного агента (модалка «Новый диалог» при
* нескольких ассистентах). Переключает активного агента, если нужно.
*/
fun newConversation(agentId: String) {
if (agentId != activeAgentId) selectAgent(agentId)
val conn = registry.connection(agentId) ?: return
scope.launch {
val conv = runCatching { conn.createConversation() }.getOrNull()
if (conv == null) {
error = "Не удалось создать диалог"
return@launch
}
refresh()
openConversation(conv.id)
}
}
/** Создать диалог у активного агента (быстрый путь при одном ассистенте). */
fun newConversation() {
activeAgentId?.let { newConversation(it) }
}
fun deleteConversation(id: String) {
val conn = activeAgentId?.let { registry.connection(it) } ?: return
scope.launch {
val ok = runCatching { conn.deleteConversation(id) }.getOrDefault(false)
if (!ok) {
error = "Не удалось удалить диалог"
return@launch
}
if (activeConversationId == id) {
closeSession()
activeConversationId = null
history = emptyList()
live = emptyList()
}
refresh()
}
}
fun renameConversation(id: String, title: String?) {
val conn = activeAgentId?.let { registry.connection(it) } ?: return
scope.launch {
runCatching { conn.renameConversation(id, title) }.onFailure { reportError(it) }
refresh()
}
}
// ───── groups ─────
fun createGroup(name: String) {
val conn = activeAgentId?.let { registry.connection(it) } ?: return
if (name.isBlank()) return
scope.launch {
runCatching { conn.meta.createGroup(name.trim()) }.onFailure { reportError(it, "Группа не создана") }
refresh()
}
}
fun renameGroup(id: Long, name: String) {
val conn = activeAgentId?.let { registry.connection(it) } ?: return
if (name.isBlank()) return
scope.launch {
runCatching { conn.meta.renameGroup(id, name.trim()) }.onFailure { reportError(it) }
refresh()
}
}
fun deleteGroup(id: Long) {
val conn = activeAgentId?.let { registry.connection(it) } ?: return
scope.launch {
runCatching { conn.meta.deleteGroup(id) }.onFailure { reportError(it) }
refresh()
}
}
/** Положить диалог в группу (`null` — убрать из группы). */
fun setConversationGroup(convId: String, groupId: Long?) {
val conn = activeAgentId?.let { registry.connection(it) } ?: return
scope.launch {
runCatching { conn.meta.setGroup(convId, groupId) }.onFailure { reportError(it) }
refresh()
}
}
// ───── chat actions ─────
fun send(text: String) {
val s = session ?: return
if (text.isBlank()) return
scope.launch {
runCatching { s.send(text) }.onFailure { reportError(it, "Не отправлено") }
refresh()
}
}
// ───── composer / voice ─────
fun onDraftChange(text: String) {
composerDraft = text
}
/** Отправить то, что набрано в композере (кнопка/Enter). */
fun sendDraft() {
val body = composerDraft.trim()
if (body.isEmpty()) return
composerDraft = ""
voicePartial = null
voice.cancel()
send(body)
}
/** Кнопка микрофона: начать/остановить голосовой ввод. */
fun toggleVoice() {
if (!voiceAvailable) {
error = "Голосовой ввод недоступен: не удалось загрузить модель распознавания"
return
}
voice.toggle()
}
/** Прервать запись, не отдавая текст. */
fun cancelVoice() {
voicePartial = null
voice.cancel()
}
/** Дописать распознанное к черновику (без склейки слов). */
private fun appendToDraft(draft: String, add: String): String {
val left = draft.trimEnd()
return if (left.isEmpty()) add else "$left $add"
}
fun stop() {
val s = session ?: return
scope.launch { runCatching { s.interrupt() } }
}
private fun closeSession() {
sessionJob?.cancel()
sessionJob = null
session?.let { runCatching { it.close() } }
session = null
}
/** Закрыть сессию и подключения (при выходе из приложения). */
fun shutdown() {
conversationsJob?.cancel()
voice.close()
closeSession()
listJob?.cancel()
registry.close()
}
}
@@ -0,0 +1,123 @@
package pw.binom.agentik.desktop.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import pw.binom.agentik.desktop.ui.ClientImageSupport
import java.util.Base64
private enum class ImageLoadState { Loading, Ready, Error }
/**
* Картинка в сообщении: src — data URL (`data:image/...;base64,...`),
* относительный путь к нашему серверу (`/media/zulip/...` — Zulip-прокси)
* или http(s) URL. Загрузка/декодирование — через [ClientImageSupport];
* если decoder не зарегистрирован — показываем alt/ссылку текстом.
*/
@Composable
fun ChatImage(src: String, alt: String?) {
val decoder = ClientImageSupport.decoder
if (decoder == null) {
// Декодер не зарегистрирован — fallback: alt (или ссылка) текстом.
Text(
text = alt?.takeIf { it.isNotBlank() } ?: src,
modifier = Modifier.widthIn(max = 280.dp),
)
return
}
var bitmap by remember(src) { mutableStateOf<ImageBitmap?>(null) }
var state by remember(src) { mutableStateOf(ImageLoadState.Loading) }
LaunchedEffect(src) {
state = ImageLoadState.Loading
bitmap = null
val bytes = withContext(Dispatchers.IO) { loadImageBytes(src) }
val decoded = bytes?.let { decoder(it) }
if (decoded != null) {
bitmap = decoded
state = ImageLoadState.Ready
} else {
state = ImageLoadState.Error
}
}
when (state) {
ImageLoadState.Loading ->
Box(
modifier = Modifier.size(120.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
ImageLoadState.Error ->
Box(
modifier = Modifier.size(120.dp),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.BrokenImage,
contentDescription = "Ошибка загрузки картинки",
)
}
ImageLoadState.Ready ->
bitmap?.let { bmp ->
Image(
bitmap = bmp,
contentDescription = alt,
modifier =
Modifier
.widthIn(max = 280.dp)
.heightIn(max = 280.dp),
contentScale = ContentScale.Fit,
)
}
}
}
/**
* Возвращает байты картинки: для data URL — декодирует base64 после
* `base64,`; для относительного пути (`/media/zulip/...`) достраивает
* полный URL через [ClientImageSupport.serverBaseUrl]; для http(s) —
* качает через [ClientImageSupport.fetcher] (или дефолтный загрузчик).
*/
private fun loadImageBytes(src: String): ByteArray? {
if (src.startsWith("data:image/")) {
val base64Idx = src.indexOf("base64,")
if (base64Idx < 0) return null
val data = src.substring(base64Idx + "base64,".length)
return runCatching { Base64.getDecoder().decode(data) }.getOrNull()
}
val resolved =
if (src.startsWith("/")) {
val base = ClientImageSupport.serverBaseUrl
if (base == null) return null
base.trimEnd('/') + src
} else {
src
}
val fetcher = ClientImageSupport.fetcher ?: ClientImageSupport::defaultFetcher
return fetcher(resolved)
}
@@ -0,0 +1,50 @@
package pw.binom.agentik.desktop.ui
import androidx.compose.ui.graphics.ImageBitmap
import java.net.HttpURLConnection
import java.net.URL
/**
* Регистрируемые реализации декодирования и загрузки картинок для общего UI.
* Платформенные клиенты регистрируют декодер при старте:
* - android-app: `BitmapFactory.decodeByteArray(...)?.asImageBitmap()`;
* - client (десктоп): `org.jetbrains.skia.Image.makeFromEncoded(...)?.toComposeImageBitmap()`.
*
* Если decoder не зарегистрирован — картинки не показываются (fallback на текст).
*/
object ClientImageSupport {
/** Декодирует байты картинки в ImageBitmap. Регистрируется клиентом. */
var decoder: ((ByteArray) -> ImageBitmap?)? = null
/**
* Скачивает байты по URL. Регистрируется клиентом (или дефолт на
* java.net.HttpURLConnection — работает на JVM и Android).
*/
var fetcher: ((String) -> ByteArray?)? = null
/**
* Base URL чат-сервера, видимый клиентом (например `http://10.0.2.2:8643`
* на эмуляторе). Заполняется клиентом при старте. Нужен для относительных
* src картинок (`/media/zulip/...`) — клиент достраивает полный URL.
*/
var serverBaseUrl: String? = null
/** Дефолтный загрузчик: GET, таймаут 15 c, readBytes. */
fun defaultFetcher(url: String): ByteArray? = try {
val conn = (URL(url).openConnection() as? HttpURLConnection) ?: return null
conn.connectTimeout = 15_000
conn.readTimeout = 15_000
conn.requestMethod = "GET"
try {
if (conn.responseCode in 200..299) {
conn.inputStream.use { it.readBytes() }
} else {
null
}
} finally {
conn.disconnect()
}
} catch (_: Exception) {
null
}
}
@@ -0,0 +1,25 @@
package pw.binom.agentik.desktop.ui
import androidx.compose.ui.text.SpanStyle
import pw.binom.agentik.desktop.markdown.MarkdownSegment
import pw.binom.agentik.desktop.markdown.parseMarkdown
import java.awt.Desktop
import java.net.URI
/**
* Desktop-specific markdown utilities.
* Compose rendering lives in [pw.binom.agentik.desktop.ui.renderMarkdownSegments].
*/
object MarkdownRenderer {
fun parseMultiline(
markdown: String,
baseStyle: SpanStyle = SpanStyle(),
): List<MarkdownSegment> = parseMarkdown(markdown)
fun openUrl(url: String) {
try {
Desktop.getDesktop().browse(URI(url))
} catch (_: Exception) {
}
}
}
@@ -0,0 +1,382 @@
package pw.binom.agentik.desktop.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import pw.binom.agentik.desktop.ui.theme.AgentikColors
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.markdown.MarkdownSegment
import pw.binom.agentik.desktop.markdown.TextStyleSpan
import pw.binom.agentik.desktop.markdown.parseMarkdown
// ─────────────────────────────────────────────────────────────
// Public rendering entry point
// ─────────────────────────────────────────────────────────────
/**
* Parse markdown and render segments with the given base text style.
*/
@Composable
fun renderMarkdownSegments(
content: String,
textColor: Color,
fontSize: TextUnit,
isBubbleScrolled: Boolean = false,
onUrlClick: (String) -> Unit = {},
) {
val baseStyle = SpanStyle(color = textColor, fontSize = fontSize)
val colors = AgentikTheme.colors
val segments = parseMarkdown(content)
var previousWasText = false
for (segment in segments) {
when (segment) {
is MarkdownSegment.Text -> {
val annotated = segment.toAnnotatedString(baseStyle, colors)
if (annotated.text.isNotEmpty()) {
// Между соседними абзацами (последовательными текстовыми сегментами)
// оставляем вертикальный отступ, иначе абзацы склеиваются в сплошной блок.
if (previousWasText) {
Spacer(modifier = Modifier.height(12.dp))
}
clickableMarkdownText(
annotatedString = annotated,
modifier = Modifier
.then(if (isBubbleScrolled) Modifier.widthIn(max = 600.dp) else Modifier),
onUrlClick = onUrlClick,
)
previousWasText = true
}
}
is MarkdownSegment.CodeBlock -> {
previousWasText = false
val clipboardManager = LocalClipboardManager.current
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.then(
if (isBubbleScrolled) Modifier.widthIn(max = 600.dp)
else Modifier.fillMaxWidth()
)
.clip(RoundedCornerShape(8.dp))
.background(colors.codeBackground),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(AgentikTheme.colors.panelElevated)
.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = segment.language ?: "Code",
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = colors.textDim,
)
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Скопировать",
modifier = Modifier
.size(16.dp)
.clickable {
clipboardManager.setText(AnnotatedString(segment.code))
},
tint = colors.textDim,
)
}
Box(
modifier = Modifier
.padding(12.dp)
.horizontalScroll(rememberScrollState()),
) {
Text(
text = segment.code,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
color = colors.codeText,
)
}
}
Spacer(modifier = Modifier.height(4.dp))
}
is MarkdownSegment.Table -> {
previousWasText = false
renderTable(segment, textColor, fontSize, isBubbleScrolled)
}
is MarkdownSegment.Image -> {
previousWasText = false
Spacer(modifier = Modifier.height(4.dp))
ChatImage(src = segment.src, alt = segment.alt)
Spacer(modifier = Modifier.height(4.dp))
}
is MarkdownSegment.ThematicBreak -> {
previousWasText = false
Spacer(modifier = Modifier.height(4.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f)),
)
Spacer(modifier = Modifier.height(4.dp))
}
}
}
}
// ─────────────────────────────────────────────────────────────
// Table rendering
// ─────────────────────────────────────────────────────────────
@Composable
private fun renderTable(
segment: MarkdownSegment.Table,
textColor: Color,
fontSize: TextUnit,
isBubbleScrolled: Boolean,
) {
Spacer(modifier = Modifier.height(6.dp))
val columnWidths = remember(segment) {
val columnCount = segment.headers.size
val widths = MutableList(columnCount) { 0 }
segment.headers.forEachIndexed { i, header -> widths[i] = maxOf(widths[i], header.length) }
segment.rows.forEach { row ->
row.forEachIndexed { i, cell ->
if (i < columnCount) widths[i] = maxOf(widths[i], cell.length)
}
}
widths
}
// Широкие колонки (длинный текст в ячейках, напр. «Почему это оно» ~200 символов)
// не должны обрезаться. Решение зависит от того, есть ли внешний горизонтальный
// скролл (десктоп оборачивает пузырь с таблицей в Modifier.horizontalScroll):
// - isBubbleScrolled=true: таблица получает ширину по содержимому, а колонки,
// выходящие за край, прокручивает ВНЕШНИЙ скролл пузыря;
// - isBubbleScrolled=false (Android): добавляем горизонтальный скролл внутрь таблицы.
// ВАЖНО: нельзя класть Box(horizontalScroll) внутрь контейнера с бесконечным maxWidth
// (wrapContentWidth) — Compose падает с "measured with an infinity maximum width".
Column(
modifier = Modifier
.then(if (isBubbleScrolled) Modifier.wrapContentWidth() else Modifier.fillMaxWidth())
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.3f))
) {
if (isBubbleScrolled) {
TableRows(
segment = segment,
columnWidths = columnWidths,
textColor = textColor,
fontSize = fontSize,
)
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
) {
Column {
TableRows(
segment = segment,
columnWidths = columnWidths,
textColor = textColor,
fontSize = fontSize,
)
}
}
}
}
Spacer(modifier = Modifier.height(6.dp))
}
@Composable
private fun TableRows(
segment: MarkdownSegment.Table,
columnWidths: List<Int>,
textColor: Color,
fontSize: TextUnit,
) {
// Header row
TableRow(
cells = segment.headers,
widths = columnWidths,
isHeader = true,
textColor = textColor,
fontSize = fontSize,
)
// Data rows
segment.rows.forEachIndexed { idx, row ->
TableRow(
cells = row,
widths = columnWidths,
isHeader = false,
textColor = textColor,
fontSize = fontSize,
rowIndex = idx,
)
}
}
@Composable
private fun TableRow(
cells: List<String>,
widths: List<Int>,
isHeader: Boolean,
textColor: Color,
fontSize: TextUnit,
rowIndex: Int = 0,
) {
val bgColor = when {
isHeader -> MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
rowIndex % 2 == 0 -> Color.Transparent
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.03f)
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(bgColor)
) {
cells.forEachIndexed { i, cell ->
Box(
modifier = Modifier
.width((widths.getOrElse(i) { 10 } * 9).dp.coerceAtLeast(60.dp))
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
Text(
text = cell,
color = textColor,
fontSize = fontSize,
fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
softWrap = true,
)
}
}
}
}
// ─────────────────────────────────────────────────────────────
// Clickable markdown text (detects URL and inline-code taps)
// ─────────────────────────────────────────────────────────────
@Composable
private fun clickableMarkdownText(
annotatedString: AnnotatedString,
modifier: Modifier = Modifier,
onUrlClick: (String) -> Unit = {},
) {
val urlAnnotations = annotatedString.getStringAnnotations("URL", 0, annotatedString.length)
val codeAnnotations = annotatedString.getStringAnnotations("INLINE_CODE", 0, annotatedString.length)
val hasClickables = urlAnnotations.isNotEmpty() || codeAnnotations.isNotEmpty()
if (hasClickables) {
val clipboardManager = LocalClipboardManager.current
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = annotatedString,
modifier = modifier.pointerInput(annotatedString) {
detectTapGestures { offset ->
val layout = textLayoutResult ?: return@detectTapGestures
val position = layout.getOffsetForPosition(offset)
annotatedString
.getStringAnnotations("INLINE_CODE", position, position)
.firstOrNull()
?.let { clipboardManager.setText(AnnotatedString(it.item)) }
?: annotatedString
.getStringAnnotations("URL", position, position)
.firstOrNull()
?.let { onUrlClick(it.item) }
}
},
onTextLayout = { textLayoutResult = it },
)
} else {
Text(text = annotatedString, modifier = modifier)
}
}
// ─────────────────────────────────────────────────────────────
// Convert MarkdownSegment.Text to Compose AnnotatedString
// ─────────────────────────────────────────────────────────────
fun MarkdownSegment.Text.toAnnotatedString(
baseStyle: SpanStyle = SpanStyle(),
colors: AgentikColors = darkAgentikColors,
): AnnotatedString {
val builder = AnnotatedString.Builder(text)
for (span in spans) {
val style = buildSpanStyle(span, colors)
val merged = baseStyle.merge(style)
builder.addStyle(merged, span.start, span.end)
val link = span.link
if (link != null) {
builder.addStringAnnotation("URL", link, span.start, span.end)
}
}
return builder.toAnnotatedString()
}
private fun buildSpanStyle(span: TextStyleSpan, colors: AgentikColors): SpanStyle {
var style = SpanStyle()
if (span.bold) style = style.copy(fontWeight = FontWeight.Bold)
if (span.italic) style = style.copy(fontStyle = androidx.compose.ui.text.font.FontStyle.Italic)
if (span.code) {
style = style.copy(
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
background = colors.panelElevated,
)
}
if (span.strikethrough) style = style.copy(textDecoration = androidx.compose.ui.text.style.TextDecoration.LineThrough)
if (span.link != null) {
style = style.copy(
color = colors.accent,
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
)
}
return style
}
@@ -0,0 +1,31 @@
package pw.binom.agentik.desktop.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
/**
* Модальный слой поверх окна (не отдельное окно ОС): затемнение + карточка
* по центру. Клик по затемнению закрывает; клики внутри карточки — нет.
*/
@Composable
fun Overlay(onDismiss: () -> Unit, content: @Composable () -> Unit) {
val colors = AgentikTheme.colors
Box(
modifier = Modifier
.fillMaxSize()
.background(colors.scrim)
.pointerInput(Unit) { detectTapGestures { onDismiss() } },
contentAlignment = Alignment.Center,
) {
Box(modifier = Modifier.pointerInput(Unit) { detectTapGestures { } }) {
content()
}
}
}
@@ -0,0 +1,127 @@
package pw.binom.agentik.desktop.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.media.AvatarStorage
import pw.binom.agentik.desktop.ui.theme.AgentColors
import java.time.Instant as JavaInstant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.time.Instant
/** Доступ к [AvatarStorage] из глубины дерева — чтобы не тащить его параметром. */
val LocalAvatarStorage = staticCompositionLocalOf<AvatarStorage> { error("AvatarStorage не предоставлен") }
/**
* Кружок ассистента/диалога: картинка из настроек, иначе инициалы на
* пользовательском [color]. Цвет — не из темы (THEMES.md), поэтому текст
* на нём подбирается по яркости ([AgentColors.contrastText]).
*/
@Composable
fun AgentAvatar(
displayName: String,
color: Color,
avatarPath: String?,
modifier: Modifier = Modifier,
size: Dp = 36.dp,
) {
val storage = LocalAvatarStorage.current
val bytes by produceState<ByteArray?>(initialValue = null, avatarPath) {
value = if (avatarPath.isNullOrBlank()) null else storage.read(avatarPath)
}
val bitmap: ImageBitmap? = remember(bytes) {
bytes?.let { runCatching { org.jetbrains.skia.Image.makeFromEncoded(it).toComposeImageBitmap() }.getOrNull() }
}
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = displayName,
contentScale = ContentScale.Crop,
modifier = modifier.size(size).clip(CircleShape),
)
} else {
Box(
modifier = modifier.size(size).clip(CircleShape).background(color),
contentAlignment = Alignment.Center,
) {
Text(
text = initials(displayName),
color = AgentColors.contrastText(color),
fontWeight = FontWeight.Bold,
fontSize = (size.value * 0.34f).sp,
)
}
}
}
/** Кружок непрочитанных с числом. */
@Composable
fun UnreadBadge(count: Int, background: Color, contentColor: Color) {
if (count <= 0) return
Box(
modifier = Modifier
.clip(RoundedCornerShape(11.dp))
.background(background)
.padding(horizontal = 6.dp, vertical = 1.dp),
) {
Text(text = count.toString(), color = contentColor, fontSize = 10.5.sp, fontWeight = FontWeight.Bold)
}
}
/** Первые буквы слов имени, максимум две. */
fun initials(name: String): String {
val parts = name.trim().split(Regex("[\\s·._-]+")).filter { it.isNotBlank() }
return when {
parts.isEmpty() -> "?"
parts.size == 1 -> parts[0].take(2).uppercase()
else -> (parts[0].take(1) + parts[1].take(1)).uppercase()
}
}
private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
private val weekdays = listOf("пн", "вт", "ср", "чт", "пт", "сб", "вс")
/**
* Подпись времени в строке списка диалогов: сегодня — `HH:mm`,
* вчера — «вчера», на этой неделе — короткий день недели, иначе — `dd.MM`.
*/
fun formatListTime(instant: Instant, now: Instant = kotlin.time.Clock.System.now()): String {
val zone = ZoneId.systemDefault()
val dt = JavaInstant.ofEpochMilli(instant.toEpochMilliseconds()).atZone(zone)
val today = JavaInstant.ofEpochMilli(now.toEpochMilliseconds()).atZone(zone).toLocalDate()
val date = dt.toLocalDate()
return when {
date == today -> timeFormatter.format(dt)
date == today.minusDays(1) -> "вчера"
date.isAfter(today.minusDays(7)) -> weekdays[date.dayOfWeek.value - 1]
else -> dateFormatter.format(dt)
}
}
/** Короткое время сообщения (`HH:mm`). */
fun formatMessageTime(instant: Instant): String =
timeFormatter.format(JavaInstant.ofEpochMilli(instant.toEpochMilliseconds()).atZone(ZoneId.systemDefault()))
@@ -0,0 +1,445 @@
package pw.binom.agentik.desktop.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import pw.binom.agentik.desktop.net.ConnectionReport
import pw.binom.agentik.desktop.session.AgentStatus
import pw.binom.agentik.desktop.settings.AgentConfig
import pw.binom.agentik.desktop.ui.AppState
import pw.binom.agentik.desktop.ui.components.AgentAvatar
import pw.binom.agentik.desktop.ui.theme.AgentColors
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
private sealed interface AgentMode {
data object List : AgentMode
data object Add : AgentMode
data class Edit(val config: AgentConfig) : AgentMode
data class Check(val config: AgentConfig, val report: ConnectionReport? = null) : AgentMode
}
/**
* Окно «Агенты»: список карточек (создать/изменить/удалить), формы
* добавления/правки и поповер проверки связи. Имя и описание берутся с
* сервера (`AgentInfo`), на клиенте настраивается только адрес и метка —
* см. `sketches/index.html` секции 10–13.
*/
@Composable
fun AgentsDialog(state: AppState, onClose: () -> Unit) {
val colors = AgentikTheme.colors
var mode by remember { mutableStateOf<AgentMode>(AgentMode.List) }
DialogCard(width = 520.dp) {
when (val m = mode) {
AgentMode.List -> ListMode(
state = state,
onClose = onClose,
onAdd = { mode = AgentMode.Add },
onEdit = { mode = AgentMode.Edit(it) },
onCheck = { mode = AgentMode.Check(it) },
)
AgentMode.Add -> AgentForm(
title = "Новый агент",
hint = "Имя и описание берутся с сервера агента автоматически. " +
"Здесь настраивается только адрес и визуальная метка.",
confirmLabel = "Добавить агента",
initial = null,
onCancel = { mode = AgentMode.List },
onCheck = { mode = AgentMode.Check(it) },
onConfirm = { config ->
state.addAgent(
baseUrl = config.baseUrl,
nameOverride = config.nameOverride,
colorIndex = config.colorIndex,
)
onClose()
},
)
is AgentMode.Edit -> AgentForm(
title = "Изменить агента",
hint = "Имя и описание задаются сервером и здесь недоступны. " +
"Можно изменить адрес и визуальную метку.",
confirmLabel = "Сохранить",
initial = m.config,
onCancel = { mode = AgentMode.List },
onCheck = { mode = AgentMode.Check(it) },
onConfirm = { config ->
state.updateAgent(config)
mode = AgentMode.List
},
)
is AgentMode.Check -> CheckMode(
state = state,
config = m.config,
initialReport = m.report,
onBack = { mode = AgentMode.List },
)
}
}
}
// ───────── list ─────────
@Composable
private fun ListMode(
state: AppState,
onClose: () -> Unit,
onAdd: () -> Unit,
onEdit: (AgentConfig) -> Unit,
onCheck: (AgentConfig) -> Unit,
) {
val colors = AgentikTheme.colors
DialogHeader("Агенты", onClose)
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
state.agents.forEach { config ->
AgentCard(
state = state,
config = config,
onCheck = { onCheck(config) },
onEdit = { onEdit(config) },
onDelete = { state.removeAgent(config.id) },
)
}
if (state.agents.isEmpty()) {
Text(
"Пока ни одного агента. Добавьте адрес сервера agentik.",
color = colors.textDim,
fontSize = 13.sp,
modifier = Modifier.padding(vertical = 12.dp),
)
}
TextButton(onClick = onAdd, modifier = Modifier.fillMaxWidth()) { Text("+ Добавить агента") }
Spacer(Modifier.height(4.dp))
}
DialogFooter {
Button(onClick = onClose, modifier = Modifier.weight(1f)) { Text("Готово") }
}
}
@Composable
private fun AgentCard(
state: AppState,
config: AgentConfig,
onCheck: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
val colors = AgentikTheme.colors
val status = state.statusesState[config.id]
val desc = state.registryInfoDescription(config)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(colors.panelElevated)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
AgentAvatar(
displayName = state.agentDisplayName(config),
color = state.agentColor(config),
avatarPath = config.avatarPath,
size = 42.dp,
)
Column(Modifier.weight(1f)) {
Text(state.agentDisplayName(config), color = colors.text, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold)
if (desc != null) {
Text(desc, color = colors.textDim, fontSize = 11.5.sp, maxLines = 2)
}
Text(config.baseUrl, color = colors.textDim, fontSize = 11.sp, fontFamily = FontFamily.Monospace)
Spacer(Modifier.height(2.dp))
StatusLine(status)
}
IconButton(onClick = onCheck) {
Icon(Icons.Default.Refresh, contentDescription = "Проверить связь", tint = colors.textDim, modifier = Modifier.size(18.dp))
}
IconButton(onClick = onEdit) {
Icon(Icons.Default.Edit, contentDescription = "Изменить", tint = colors.textDim, modifier = Modifier.size(18.dp))
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "Удалить", tint = colors.error, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun StatusLine(status: AgentStatus?) {
val colors = AgentikTheme.colors
val (text, color) = when (status) {
is AgentStatus.Online -> "на связи" to colors.ok
is AgentStatus.Offline -> "не отвечает" to colors.error
AgentStatus.Connecting -> "подключение…" to colors.textDim
null -> "не проверялся" to colors.textDim
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Box(Modifier.size(6.dp).clip(CircleShape).background(color))
Text(text, color = color, fontSize = 11.sp)
}
}
// ───────── form ─────────
@Composable
private fun AgentForm(
title: String,
hint: String,
confirmLabel: String,
initial: AgentConfig?,
onCancel: () -> Unit,
onCheck: (AgentConfig) -> Unit,
onConfirm: (AgentConfig) -> Unit,
) {
val colors = AgentikTheme.colors
var baseUrl by remember { mutableStateOf(initial?.baseUrl ?: "") }
var nameOverride by remember { mutableStateOf(initial?.nameOverride ?: "") }
var colorIndex by remember { mutableStateOf(initial?.colorIndex ?: 0) }
DialogHeader(title, onCancel)
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 18.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Hint(hint)
Text("Адрес сервера агента", color = colors.textDim, fontSize = 11.5.sp)
OutlinedTextField(
value = baseUrl,
onValueChange = { baseUrl = it },
placeholder = { Text("http://хост:порт/agentik") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = {
onCheck(
AgentConfig(
id = initial?.id ?: "probe",
baseUrl = baseUrl.trim(),
nameOverride = nameOverride.ifBlank { null },
colorIndex = colorIndex,
)
)
},
enabled = baseUrl.isNotBlank(),
) { Text("Проверить связь") }
}
Text("Как отличать в списке", color = colors.textDim, fontSize = 11.5.sp)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
AgentColors.defaults.forEachIndexed { index, color ->
Box(
modifier = Modifier
.size(26.dp)
.clip(CircleShape)
.background(color)
.border(
width = if (index == colorIndex) 2.dp else 0.dp,
color = if (index == colorIndex) colors.text else Color.Transparent,
shape = CircleShape,
)
.clickable { colorIndex = index },
)
}
}
OutlinedTextField(
value = nameOverride,
onValueChange = { nameOverride = it },
label = { Text("Своё имя (необязательно)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(2.dp))
}
DialogFooter {
TextButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("Отмена") }
Button(
onClick = {
onConfirm(
AgentConfig(
id = initial?.id ?: "",
baseUrl = baseUrl.trim(),
nameOverride = nameOverride.trim().ifBlank { null },
colorIndex = colorIndex,
)
)
},
enabled = baseUrl.isNotBlank(),
modifier = Modifier.weight(1f),
) { Text(confirmLabel) }
}
}
// ───────── check ─────────
@Composable
private fun CheckMode(
state: AppState,
config: AgentConfig,
initialReport: ConnectionReport?,
onBack: () -> Unit,
) {
val colors = AgentikTheme.colors
val scope = rememberCoroutineScope()
var report by remember(config) { mutableStateOf(initialReport) }
fun run() {
report = null
scope.launch { report = state.checkConnection(config) }
}
androidx.compose.runtime.LaunchedEffect(config) { if (report == null) run() }
DialogHeader("Проверка связи", onBack)
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
val current = report
if (current == null) {
Row(
modifier = Modifier.padding(vertical = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = colors.accent)
Text("Проверяю…", color = colors.textDim, fontSize = 13.sp)
}
} else {
Text(
text = config.baseUrl,
color = colors.text,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
)
current.steps.forEach { step ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(if (step.ok) "✓" else "✕", color = if (step.ok) colors.ok else colors.error, fontSize = 13.sp)
Spacer(Modifier.width(8.dp))
Text(step.title, color = colors.text, fontSize = 12.5.sp, modifier = Modifier.weight(1f))
Text("${step.millis} мс", color = colors.textDim, fontSize = 11.sp)
}
step.detail?.let { Text(it, color = colors.textDim, fontSize = 11.sp, modifier = Modifier.padding(start = 22.dp)) }
}
Hint(
if (current.ok) "Адрес рабочий. Можно добавлять этого агента."
else "Агент не ответил. Проверьте адрес и доступность сервера."
)
}
Spacer(Modifier.height(2.dp))
}
DialogFooter {
TextButton(onClick = { run() }, modifier = Modifier.weight(1f)) { Text("Проверить снова") }
Button(onClick = onBack, modifier = Modifier.weight(1f)) { Text("Готово") }
}
}
// ───────── shared bits ─────────
@Composable
internal fun DialogCard(width: androidx.compose.ui.unit.Dp, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {
val colors = AgentikTheme.colors
Surface(
color = colors.panel,
shape = RoundedCornerShape(14.dp),
modifier = Modifier.widthIn(min = width, max = width).heightIn(max = 640.dp),
) {
Column(modifier = Modifier.padding(vertical = 14.dp)) { this.content() }
}
}
@Composable
internal fun DialogHeader(title: String, onClose: () -> Unit) {
val colors = AgentikTheme.colors
Row(
modifier = Modifier.fillMaxWidth().padding(start = 18.dp, end = 10.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, color = colors.text, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = "Закрыть", tint = colors.textDim, modifier = Modifier.size(18.dp))
}
}
}
@Composable
internal fun DialogFooter(content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp, start = 18.dp, end = 18.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) { this.content() }
}
@Composable
internal fun Hint(text: String) {
val colors = AgentikTheme.colors
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(colors.panelElevated)
.padding(10.dp),
) {
Text(text, color = colors.textDim, fontSize = 11.5.sp)
}
}
/** Описание агента с сервера, если он онлайн. */
@Composable
private fun AppState.registryInfoDescription(config: AgentConfig): String? =
(statusesState[config.id] as? AgentStatus.Online)?.connection?.info?.description
@@ -0,0 +1,290 @@
package pw.binom.agentik.desktop.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.Icon
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.session.AgentStatus
import pw.binom.agentik.desktop.voice.VoiceState
import pw.binom.agentik.desktop.ui.AppState
import pw.binom.agentik.desktop.ui.components.AgentAvatar
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
/**
* Правая зона: шапка диалога, лента сообщений, композер.
*
* Голос: кнопка микрофона запускает запись (`mic-api`), распознавание идёт
* потоком (`asr-vosk` + `vad-kmp`), промежуточный текст показывается над полем,
* финал дописывается в черновик.
*/
@Composable
fun ChatPane(
state: AppState,
onBack: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val colors = AgentikTheme.colors
val config = state.activeAgent
val agentColor = config?.let { state.agentColor(it) } ?: colors.accent
val agentName = config?.let { state.agentDisplayName(it) } ?: ""
Column(
modifier = modifier
.fillMaxSize()
.background(colors.background)
// Ctrl+M — голосовой ввод, то же, что кнопка микрофона.
.onPreviewKeyEvent { e ->
if (e.type == KeyEventType.KeyDown && e.isCtrlPressed && e.key == Key.M) {
state.toggleVoice()
true
} else {
false
}
},
) {
// ── шапка ──
Row(
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.background(colors.panel)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (onBack != null) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад", tint = colors.textDim)
}
}
AgentAvatar(
displayName = agentName,
color = agentColor,
avatarPath = config?.avatarPath,
size = 36.dp,
)
Column(Modifier.weight(1f)) {
Text(agentName, color = colors.text, fontSize = 14.sp)
ConnectionLabel(state.activeStatus)
}
if (state.thinking) {
IconButton(onClick = { state.stop() }) {
Text("Стоп", color = colors.error, fontSize = 12.5.sp)
}
}
}
HorizontalDivider(color = colors.line)
// ── лента ──
if (config == null) {
CenteredHint("Выберите ассистента", colors.textDim)
} else {
MessageStream(
history = state.history,
live = state.live,
thinking = state.thinking,
agentColor = agentColor,
modifier = Modifier.weight(1f),
)
Composer(
enabled = true,
draft = state.composerDraft,
onDraftChange = { state.onDraftChange(it) },
onSend = { state.sendDraft() },
voiceState = state.voiceState,
voicePartial = state.voicePartial,
voiceAvailable = state.voiceAvailable,
onMic = { state.toggleVoice() },
)
}
}
}
@Composable
private fun ConnectionLabel(status: AgentStatus?) {
val colors = AgentikTheme.colors
val (text, color) = when (status) {
is AgentStatus.Online -> "на связи" to colors.ok
is AgentStatus.Offline -> "не отвечает" to colors.error
AgentStatus.Connecting -> "подключение…" to colors.textDim
null -> "не проверялся" to colors.textDim
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Box(Modifier.size(6.dp).clip(RoundedCornerShape(3.dp)).background(color))
Text(text, color = color, fontSize = 11.5.sp)
}
}
@Composable
private fun Composer(
enabled: Boolean,
draft: String,
onDraftChange: (String) -> Unit,
onSend: () -> Unit,
voiceState: VoiceState,
voicePartial: String?,
voiceAvailable: Boolean,
onMic: () -> Unit,
) {
val colors = AgentikTheme.colors
val text = draft
// Клик в любом месте бокса фокусирует поле: сам BasicTextField по высоте
// равен строке, и клик по его padding'у иначе уходит в Box.
val focusRequester = remember { FocusRequester() }
fun submit() {
if (text.isNotBlank()) onSend()
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(colors.panel)
.padding(horizontal = 18.dp, vertical = 12.dp),
) {
// Распознаваемая речь — над полем ввода, серым, пока не станет финалом.
if (voicePartial != null) {
Text(
voicePartial,
color = colors.textDim,
fontSize = 12.sp,
modifier = Modifier.padding(bottom = 6.dp),
)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(11.dp))
.background(colors.background)
.border(1.dp, colors.line, RoundedCornerShape(11.dp))
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
) { focusRequester.requestFocus() }
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
if (text.isEmpty()) {
Text("Написать агенту…", color = colors.textDim, fontSize = 13.sp)
}
BasicTextField(
value = text,
onValueChange = onDraftChange,
enabled = enabled,
textStyle = TextStyle(color = colors.text, fontSize = 13.sp),
cursorBrush = SolidColor(colors.accent),
maxLines = 6,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { submit() }),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
// Enter отправляет (подсказка под полем это обещает),
// Shift+Enter — перевод строки. В многострочном поле
// Enter по умолчанию вставляет \n и никуда не отправляет.
.onPreviewKeyEvent { e ->
if (e.type == KeyEventType.KeyDown && e.key == Key.Enter && !e.isShiftPressed) {
submit()
true
} else {
false
}
},
)
}
MicButton(voiceState, voiceAvailable, onMic)
IconButton(
onClick = { submit() },
enabled = enabled && text.isNotBlank(),
) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "Отправить",
tint = if (text.isNotBlank()) colors.accent else colors.textDim,
)
}
}
Spacer(Modifier.height(6.dp))
Text(composerHint(voiceState, voiceAvailable), color = colors.textDim, fontSize = 10.5.sp)
}
}
/**
* Кнопка микрофона: цвет и подсказка зависят от состояния ввода. Если нативный
* ASR не прогрелся (нет библиотек/модели) — кнопка заблокирована.
*/
@Composable
private fun MicButton(voiceState: VoiceState, voiceAvailable: Boolean, onMic: () -> Unit) {
val colors = AgentikTheme.colors
val (tint, description) = when {
!voiceAvailable -> colors.textDim to "Голосовой ввод недоступен"
voiceState == VoiceState.IDLE -> colors.textDim to "Голосовой ввод"
voiceState == VoiceState.PREPARING -> colors.textDim to "Готовлю распознавание…"
voiceState == VoiceState.RECORDING -> colors.error to "Остановить запись"
else -> colors.accent to "Распознаю…"
}
IconButton(
onClick = onMic,
// Во время дочитывания финала повторное нажатие бессмысленно.
enabled = voiceAvailable && voiceState != VoiceState.RECOGNIZING,
) {
Icon(Icons.Default.Mic, contentDescription = description, tint = tint)
}
}
private fun composerHint(voiceState: VoiceState, voiceAvailable: Boolean): String = when {
!voiceAvailable -> "голос недоступен · отправка Enter"
voiceState == VoiceState.IDLE -> "голос — микрофон (Ctrl+M) · отправка Enter"
voiceState == VoiceState.PREPARING -> "голос: загружаю модель распознавания…"
voiceState == VoiceState.RECORDING -> "голос: идёт запись — нажмите микрофон, чтобы закончить"
else -> "голос: распознаю…"
}
@Composable
private fun CenteredHint(text: String, color: androidx.compose.ui.graphics.Color) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text, color = color, fontSize = 14.sp)
}
}
@@ -0,0 +1,303 @@
package pw.binom.agentik.desktop.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.model.UiConversation
import pw.binom.agentik.desktop.session.AgentStatus
import pw.binom.agentik.desktop.ui.AppState
import pw.binom.agentik.desktop.ui.components.AgentAvatar
import pw.binom.agentik.desktop.ui.components.UnreadBadge
import pw.binom.agentik.desktop.ui.components.formatListTime
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
/**
* Узкая вертикальная рейка ассистентов: переключение агента и вход в
* настройки агентов (`+`). Цвет каждого кружка — пользовательский цвет
* агента, не из темы.
*/
@Composable
fun AgentRail(
state: AppState,
onOpenAgents: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = AgentikTheme.colors
Column(
modifier = modifier
.fillMaxHeight()
.width(60.dp)
.background(colors.background)
.padding(vertical = 10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
state.agents.forEach { config ->
val online = state.statusesState[config.id] is AgentStatus.Online
Box {
AgentAvatar(
displayName = state.agentDisplayName(config),
color = state.agentColor(config),
avatarPath = config.avatarPath,
size = 40.dp,
modifier = Modifier.clickable { state.selectAgent(config.id) },
)
if (config.id == state.activeAgentId) {
Box(
Modifier
.align(Alignment.BottomCenter)
.size(width = 22.dp, height = 3.dp)
.clip(RoundedCornerShape(2.dp))
.background(state.agentColor(config)),
)
}
if (!online) {
Box(
Modifier
.align(Alignment.TopEnd)
.size(9.dp)
.clip(RoundedCornerShape(5.dp))
.background(colors.error),
)
}
}
}
Spacer(Modifier.weight(1f))
IconButton(onClick = onOpenAgents) {
Icon(Icons.Default.Settings, contentDescription = "Агенты", tint = colors.textDim)
}
}
}
/**
* Панель диалогов: шапка с `+`, поиск, папки-группы и список диалогов.
* Показываются диалоги активного агента (groups — локальные для агента).
*/
@Composable
fun ConversationList(
state: AppState,
selectedGroupId: Long?,
onSelectGroup: (Long?) -> Unit,
onOpenConversation: (String) -> Unit,
onOpenGroups: () -> Unit,
onNewConversation: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = AgentikTheme.colors
var query by remember { mutableStateOf("") }
val filtered = state.conversations
.filter { selectedGroupId == null || it.groupId == selectedGroupId }
.filter {
query.isBlank() ||
it.title.contains(query, ignoreCase = true) ||
(it.preview?.contains(query, ignoreCase = true) == true)
}
Column(modifier = modifier.fillMaxHeight().background(colors.panel)) {
Row(
modifier = Modifier.fillMaxWidth().height(52.dp).padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Диалоги", color = colors.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.weight(1f))
IconButton(onClick = onNewConversation) {
Icon(Icons.Default.Add, contentDescription = "Новый диалог", tint = colors.textDim)
}
}
// поиск
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.height(34.dp)
.clip(RoundedCornerShape(10.dp))
.background(colors.background),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Spacer(Modifier.width(9.dp))
Icon(Icons.Default.Search, contentDescription = null, tint = colors.textDim, modifier = Modifier.size(15.dp))
Box(Modifier.weight(1f)) {
if (query.isEmpty()) {
Text("Поиск…", color = colors.textDim, fontSize = 12.sp)
}
androidx.compose.foundation.text.BasicTextField(
value = query,
onValueChange = { query = it },
textStyle = androidx.compose.ui.text.TextStyle(color = colors.text, fontSize = 12.sp),
cursorBrush = androidx.compose.ui.graphics.SolidColor(colors.accent),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
Spacer(Modifier.height(8.dp))
// папки
FolderRow(
title = "Все чаты",
count = state.conversations.count { it.unread > 0 }.takeIf { it > 0 },
active = selectedGroupId == null,
onClick = { onSelectGroup(null) },
trailing = {
IconButton(onClick = onOpenGroups, modifier = Modifier.size(26.dp)) {
Icon(Icons.Default.Settings, contentDescription = "Группы", tint = colors.textDim, modifier = Modifier.size(15.dp))
}
},
)
state.groups.forEach { group ->
FolderRow(
title = group.name,
count = state.conversations.count { it.groupId == group.id && it.unread > 0 }.takeIf { it > 0 },
active = selectedGroupId == group.id,
onClick = { onSelectGroup(group.id) },
)
}
Spacer(Modifier.height(6.dp))
HorizontalDivider(color = colors.line)
LazyColumn(Modifier.weight(1f)) {
items(items = filtered, key = { it.id }) { conv ->
ConversationRow(
conversation = conv,
agentColor = state.activeAgent?.let { state.agentColor(it) } ?: colors.accent,
avatarPath = state.activeAgent?.avatarPath,
active = conv.id == state.activeConversationId,
onClick = { onOpenConversation(conv.id) },
)
}
}
}
}
@Composable
private fun FolderRow(
title: String,
count: Int?,
active: Boolean,
onClick: () -> Unit,
trailing: (@Composable () -> Unit)? = null,
) {
val colors = AgentikTheme.colors
Row(
modifier = Modifier
.fillMaxWidth()
.height(32.dp)
.background(if (active) colors.panelElevated else Color.Transparent)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
title,
color = if (active) colors.text else colors.textDim,
fontSize = 13.sp,
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal,
)
if (count != null) {
Spacer(Modifier.width(6.dp))
Text(count.toString(), color = colors.badge, fontSize = 11.sp, fontWeight = FontWeight.Bold)
}
if (trailing != null) {
Spacer(Modifier.weight(1f))
trailing()
}
}
}
@Composable
private fun ConversationRow(
conversation: UiConversation,
agentColor: Color,
avatarPath: String?,
active: Boolean,
onClick: () -> Unit,
) {
val colors = AgentikTheme.colors
Row(
modifier = Modifier
.fillMaxWidth()
.background(if (active) colors.panelElevated else Color.Transparent)
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.width(3.dp).height(40.dp).clip(RoundedCornerShape(2.dp)).background(agentColor))
Spacer(Modifier.width(9.dp))
AgentAvatar(
displayName = conversation.title,
color = agentColor,
avatarPath = avatarPath,
size = 34.dp,
)
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
conversation.title,
color = colors.text,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(6.dp))
Text(formatListTime(conversation.updatedAt), color = colors.textDim, fontSize = 10.5.sp)
}
Spacer(Modifier.height(2.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
conversation.preview ?: "…",
color = colors.textDim,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (conversation.unread > 0) {
Spacer(Modifier.width(6.dp))
UnreadBadge(conversation.unread, background = colors.badge, contentColor = colors.onAccent)
}
}
}
Spacer(Modifier.width(12.dp))
}
}
@@ -0,0 +1,200 @@
package pw.binom.agentik.desktop.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.persistence.Group
import pw.binom.agentik.desktop.ui.AppState
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
private sealed interface GroupMode {
data object List : GroupMode
data object Add : GroupMode
data class Edit(val group: Group) : GroupMode
}
/**
* Окно «Группы»: список, создание, переименование и удаление. Группы —
* локальные для устройства (сервер про них не знает, `THEMES`/`STORAGE` §7);
* удаление группы не удаляет диалоги (R9.7). В режиме правки можно
* разложить диалоги по группе.
*/
@Composable
fun GroupsDialog(state: AppState, onClose: () -> Unit) {
val colors = AgentikTheme.colors
var mode by remember { mutableStateOf<GroupMode>(GroupMode.List) }
DialogCard(width = 480.dp) {
when (val m = mode) {
GroupMode.List -> {
DialogHeader("Группы", onClose)
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 18.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
state.groups.forEach { group ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(colors.panelElevated)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(group.name, color = colors.text, fontSize = 13.sp, modifier = Modifier.weight(1f))
Text(
"${state.conversations.count { it.groupId == group.id }}",
color = colors.textDim,
fontSize = 11.sp,
)
IconButton(onClick = { mode = GroupMode.Edit(group) }) {
Icon(Icons.Default.Edit, contentDescription = "Изменить", tint = colors.textDim, modifier = Modifier.size(17.dp))
}
IconButton(onClick = { state.deleteGroup(group.id) }) {
Icon(Icons.Default.Delete, contentDescription = "Удалить", tint = colors.error, modifier = Modifier.size(17.dp))
}
}
}
if (state.groups.isEmpty()) {
Text(
"Групп пока нет. Создайте, чтобы разложить диалоги по папкам.",
color = colors.textDim,
fontSize = 13.sp,
modifier = Modifier.padding(vertical = 10.dp),
)
}
TextButton(onClick = { mode = GroupMode.Add }, modifier = Modifier.fillMaxWidth()) {
Text("+ Новая группа")
}
Spacer(Modifier.height(4.dp))
}
DialogFooter {
Button(onClick = onClose, modifier = Modifier.weight(1f)) { Text("Готово") }
}
}
GroupMode.Add -> {
var name by remember { mutableStateOf("") }
DialogHeader("Новая группа", { mode = GroupMode.List })
Column(Modifier.padding(horizontal = 18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Название группы") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(2.dp))
}
DialogFooter {
TextButton(onClick = { mode = GroupMode.List }, modifier = Modifier.weight(1f)) { Text("Отмена") }
Button(
onClick = {
state.createGroup(name)
mode = GroupMode.List
},
enabled = name.isNotBlank(),
modifier = Modifier.weight(1f),
) { Text("Создать") }
}
}
is GroupMode.Edit -> {
var name by remember(m.group.id) { mutableStateOf(m.group.name) }
DialogHeader("Изменить группу", { mode = GroupMode.List })
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 18.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Название группы") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Text("Диалоги группы", color = colors.textDim, fontSize = 11.5.sp)
state.conversations.forEach { conv ->
val checked = conv.groupId == m.group.id
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable {
state.setConversationGroup(conv.id, if (checked) null else m.group.id)
}
.padding(horizontal = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = checked, onCheckedChange = {
state.setConversationGroup(conv.id, if (checked) null else m.group.id)
})
Text(
conv.title,
color = colors.text,
fontSize = 12.5.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(Modifier.height(4.dp))
}
DialogFooter {
IconButton(
onClick = {
state.deleteGroup(m.group.id)
mode = GroupMode.List
},
) {
Icon(Icons.Default.Delete, contentDescription = "Удалить группу", tint = colors.error)
}
Spacer(Modifier.width(4.dp))
Button(
onClick = {
state.renameGroup(m.group.id, name)
mode = GroupMode.List
},
enabled = name.isNotBlank(),
modifier = Modifier.weight(1f),
) { Text("Сохранить") }
}
}
}
}
}
@@ -0,0 +1,256 @@
package pw.binom.agentik.desktop.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.model.UiContent
import pw.binom.agentik.desktop.model.UiMessage
import pw.binom.agentik.desktop.ui.ChatImage
import pw.binom.agentik.desktop.ui.components.formatMessageTime
import pw.binom.agentik.desktop.ui.renderMarkdownSegments
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import java.util.Base64
/**
* Лента сообщений: история из кэша + live-блоки активного хода. Оба
* источника идут через одни и те же пузыри — переход «live → history» на
* `End` не раздваивает вид.
*/
@Composable
fun MessageStream(
history: List<UiMessage>,
live: List<UiMessage>,
thinking: Boolean,
agentColor: Color,
modifier: Modifier = Modifier,
) {
val colors = AgentikTheme.colors
val listState = rememberLazyListState()
val all = history + live
LaunchedEffect(all.size, thinking) {
if (all.isNotEmpty()) listState.animateScrollToItem(all.size - 1)
}
LazyColumn(
state = listState,
modifier = modifier.fillMaxSize().padding(horizontal = 22.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item { Spacer(Modifier.height(8.dp)) }
items(items = all, key = { it.id }) { message ->
MessageRow(message = message, agentColor = agentColor)
}
if (thinking && live.isEmpty()) {
item(key = "thinking") {
Row(
modifier = Modifier.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = colors.accent,
)
Text("Агент думает…", color = colors.textDim, fontSize = 13.sp)
}
}
}
item { Spacer(Modifier.height(8.dp)) }
}
}
@Composable
private fun MessageRow(message: UiMessage, agentColor: Color) {
val colors = AgentikTheme.colors
when (message) {
is UiMessage.User -> Bubble(
alignEnd = true,
background = colors.userBubble,
) {
TextContent(message.content, textColor = colors.onUserBubble, fontSize = 13.5.sp)
Timestamp(message.createdAt, colors.onUserBubble.copy(alpha = 0.6f))
}
is UiMessage.Assistant -> Bubble(
alignEnd = false,
background = colors.botBubble,
) {
MarkdownContent(message.content, agentColor)
Timestamp(message.createdAt, colors.textDim)
}
is UiMessage.Reasoning -> Bubble(
alignEnd = false,
background = colors.panelElevated,
) {
Text(
text = "Размышления",
color = colors.textDim,
fontSize = 10.5.sp,
fontWeight = FontWeight.SemiBold,
fontStyle = FontStyle.Italic,
)
Spacer(Modifier.height(3.dp))
Text(
text = message.text,
color = colors.textDim,
fontSize = 12.5.sp,
fontStyle = FontStyle.Italic,
)
}
is UiMessage.Tool -> Bubble(alignEnd = false, background = colors.panelElevated) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text("⚙", color = colors.accent, fontSize = 12.sp)
Text(
text = message.title ?: message.toolName,
color = colors.text,
fontSize = 12.5.sp,
fontWeight = FontWeight.SemiBold,
)
}
if (message.argsJson.isNotBlank()) {
Spacer(Modifier.height(4.dp))
CodeBlock(prefix = "$ ", body = message.argsJson, tint = colors.textDim)
}
message.result?.let {
Spacer(Modifier.height(4.dp))
CodeBlock(prefix = "", body = it, tint = colors.textDim)
}
message.failed?.let {
Spacer(Modifier.height(4.dp))
Text(it, color = colors.error, fontSize = 12.sp)
}
}
is UiMessage.Error -> Bubble(alignEnd = false, background = colors.botBubble) {
Text(
text = message.message,
color = colors.error,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
)
message.code?.let { Text(it, color = colors.textDim, fontSize = 11.sp) }
}
}
}
@Composable
private fun Bubble(alignEnd: Boolean, background: Color, content: @Composable () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (alignEnd) Arrangement.End else Arrangement.Start,
) {
Column(
modifier = Modifier
.widthIn(max = 620.dp)
.clip(RoundedCornerShape(12.dp))
.background(background)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
content()
}
}
}
@Composable
private fun Timestamp(instant: kotlin.time.Instant, color: Color) {
Spacer(Modifier.height(3.dp))
Text(formatMessageTime(instant), color = color, fontSize = 10.5.sp)
}
@Composable
private fun TextContent(content: List<UiContent>, textColor: Color, fontSize: androidx.compose.ui.unit.TextUnit) {
Column {
content.forEach { part ->
when (part) {
is UiContent.Text -> Text(part.body, color = textColor, fontSize = fontSize)
is UiContent.Image -> {
ChatImage(src = dataUrl(part), alt = null)
Spacer(Modifier.height(4.dp))
}
}
}
}
}
@Composable
private fun MarkdownContent(content: List<UiContent>, agentColor: Color) {
val colors = AgentikTheme.colors
Column {
content.forEach { part ->
when (part) {
is UiContent.Text -> renderMarkdownSegments(
content = part.body,
textColor = colors.text,
fontSize = 13.5.sp,
)
is UiContent.Image -> {
ChatImage(src = dataUrl(part), alt = null)
Spacer(Modifier.height(4.dp))
}
}
}
}
// Полоска цвета агента — визуальная привязка ответа к ассистенту.
Spacer(Modifier.height(2.dp))
Box(
Modifier
.size(width = 28.dp, height = 2.dp)
.clip(RoundedCornerShape(1.dp))
.background(agentColor),
)
}
@Composable
private fun CodeBlock(prefix: String, body: String, tint: Color) {
val colors = AgentikTheme.colors
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(colors.panel)
.border(1.dp, colors.line, RoundedCornerShape(8.dp))
.padding(8.dp),
) {
Text(
text = prefix + body,
color = tint,
fontSize = 11.5.sp,
fontFamily = FontFamily.Monospace,
)
}
}
private fun dataUrl(image: UiContent.Image): String =
"data:${image.mime};base64,${Base64.getEncoder().encodeToString(image.data)}"
@@ -0,0 +1,84 @@
package pw.binom.agentik.desktop.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.session.AgentStatus
import pw.binom.agentik.desktop.ui.AppState
import pw.binom.agentik.desktop.ui.components.AgentAvatar
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
/**
* Модалка «Новый диалог»: при нескольких ассистентах спрашиваем, в каком
* создавать (диалог живёт внутри ассистента). Одобренный макет —
* `sketches/index.html` секция 15.
*/
@Composable
fun NewChatDialog(
state: AppState,
onPick: (String) -> Unit,
onClose: () -> Unit,
) {
val colors = AgentikTheme.colors
DialogCard(width = 440.dp) {
DialogHeader("Новый диалог", onClose)
Column(
modifier = Modifier.padding(horizontal = 18.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
state.agents.forEach { config ->
val online = state.statusesState[config.id] is AgentStatus.Online
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(colors.panelElevated)
.clickable(enabled = online) { onPick(config.id) }
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
AgentAvatar(
displayName = state.agentDisplayName(config),
color = state.agentColor(config),
avatarPath = config.avatarPath,
size = 38.dp,
)
Column(Modifier.weight(1f)) {
Text(
state.agentDisplayName(config),
color = if (online) colors.text else colors.textDim,
fontSize = 13.5.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
if (online) "на связи" else "не отвечает",
color = if (online) colors.ok else colors.error,
fontSize = 11.sp,
)
}
}
}
Spacer(Modifier.height(4.dp))
}
DialogFooter {
Button(onClick = onClose, modifier = Modifier.weight(1f)) { Text("Отмена") }
}
}
}
@@ -0,0 +1,40 @@
package pw.binom.agentik.desktop.ui.theme
import androidx.compose.ui.graphics.Color
/**
* Цвета ассистентов — **пользовательские**, не часть темы (THEMES.md,
* «Особый случай»). При смене темы они остаются собой, иначе ассистенты
* перестанут отличаться друг от друга.
*
* Значения — расширение палитры `--a1..--a5` из Android-клиента,
* подобраны под тёмную тему. В светлой теме читаемость ещё не проверена
* (открытый вопрос в THEMES.md) — [contrastText] подбирает текст под фон
* по яркости, чтобы хотя бы текст оставался читаемым.
*/
object AgentColors {
val defaults: List<Color> = listOf(
Color(0xFF6AB2F2),
Color(0xFF86C99A),
Color(0xFFC9A886),
Color(0xFFB79AE0),
Color(0xFFE28C8C),
)
/** Цвет агента по индексу из [pw.binom.agentik.desktop.settings.AgentConfig.colorIndex]. */
fun ofIndex(index: Int): Color {
val n = defaults.size
return defaults[((index % n) + n) % n]
}
/**
* Цвет текста/иконки, читаемый на [background]: тёмный на светлых
* плашках, светлый на тёмных. Считается по относительной яркости,
* а не берётся из темы — подложка-то пользовательская.
*/
fun contrastText(background: Color): Color {
val luminance = 0.2126f * background.red + 0.7152f * background.green + 0.0722f * background.blue
return if (luminance > 0.6f) Color(0xFF10161C) else Color(0xFFF2F5F8)
}
}
@@ -0,0 +1,176 @@
package pw.binom.agentik.desktop.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import pw.binom.agentik.desktop.settings.ThemeId
/**
* Семантические цвета интерфейса. **Ни один цвет не задаётся числом вне
* этого файла** (см. `THEMES.md`): экраны берут цвета отсюда по смыслу,
* поэтому смена темы не оставит «тёмно-синюю панель на светлом фоне».
*
* Это расширение над `MaterialTheme.colorScheme`: у нас есть слоты, которых
* в Material нет (пузыри, полоска агента, счётчик). Стандартные компоненты
* Material по-прежнему берут цвета из схемы (см. [toMaterialScheme]).
*/
@Immutable
data class AgentikColors(
/** Фон окна. */
val background: Color,
/** Панели: сайдбар, шапки, композер. */
val panel: Color,
/** «Приподнятые» элементы на панели: ховер строки, поля ввода, блоки кода. */
val panelElevated: Color,
/** Разделители и рамки. */
val line: Color,
/** Основной текст. */
val text: Color,
/** Приглушённый текст: время, подписи, плейсхолдеры. */
val textDim: Color,
/** Главный акцент: активная вкладка, курсор, ссылки. */
val accent: Color,
/** Текст/иконка на акцентной плашке (тёмный — на обоих наших акцентах). */
val onAccent: Color,
/** Фон кружка непрочитанных. */
val badge: Color,
/** Ошибки, опасные зоны, кнопка записи. */
val error: Color,
/** Успех, «на связи». */
val ok: Color,
/** Пузырь сообщения пользователя. */
val userBubble: Color,
/** Текст внутри пузыря пользователя. */
val onUserBubble: Color,
/** Пузырь сообщения ассистента. */
val botBubble: Color,
/** Текст внутри пузыря ассистента. */
val onBotBubble: Color,
/** Подложка выбранного элемента (акцент с прозрачностью). */
val selection: Color,
/** Затемнение под модалкой. */
val scrim: Color,
/** Фон блока кода в сообщении (шапка блока берёт [panelElevated]). */
val codeBackground: Color,
/** Текст внутри блока кода. */
val codeText: Color,
) {
/** Стандартная Material-схема из наших слотов — для готовых компонентов. */
fun toMaterialScheme(dark: Boolean): androidx.compose.material3.ColorScheme {
val base = if (dark) darkColorScheme() else lightColorScheme()
return base.copy(
primary = accent,
onPrimary = onAccent,
primaryContainer = accent,
onPrimaryContainer = onAccent,
secondary = accent,
onSecondary = onAccent,
background = background,
onBackground = text,
surface = panel,
onSurface = text,
surfaceVariant = panelElevated,
onSurfaceVariant = textDim,
surfaceTint = accent,
outline = line,
outlineVariant = line,
error = error,
onError = onAccent,
errorContainer = error,
onErrorContainer = onAccent,
scrim = scrim,
inverseSurface = text,
inverseOnSurface = background,
inversePrimary = accent,
)
}
}
/**
* Тёмная тема — эталонная, значения согласованы с Android-клиентом
* (`agentik-android/design/BORROW.md`). Числа живут только здесь.
*/
val darkAgentikColors = AgentikColors(
background = Color(0xFF121218),
panel = Color(0xFF17212B),
panelElevated = Color(0xFF1C2733),
line = Color(0xFF232B36),
text = Color(0xFFEBEBEB),
textDim = Color(0xFF768C9E),
accent = Color(0xFF6AB2F2),
onAccent = Color(0xFF0F1922),
badge = Color(0xFF5EB5F7),
error = Color(0xFFEC3942),
ok = Color(0xFF63C88A),
userBubble = Color(0xFF2B5278),
onUserBubble = Color(0xFFEBEBEB),
botBubble = Color(0xFF1C2733),
onBotBubble = Color(0xFFEBEBEB),
selection = Color(0x226AB2F2),
scrim = Color(0x99000000),
codeBackground = Color(0xFF1E1E1E),
codeText = Color(0xFFD4D4D4),
)
/**
* Светлая тема — предполагается (THEMES.md: «пока известно только: тёмная
* есть, светлая предполагается»). Значения подобраны так, чтобы сохранить
* смысл слотов; визуально её ещё предстоит проверить на всех экранах.
*/
val lightAgentikColors = AgentikColors(
background = Color(0xFFF5F6F8),
panel = Color(0xFFFFFFFF),
panelElevated = Color(0xFFEDEFF3),
line = Color(0xFFD7DBE2),
text = Color(0xFF1A1D23),
textDim = Color(0xFF6B7686),
accent = Color(0xFF2A6FB5),
onAccent = Color(0xFFFFFFFF),
badge = Color(0xFF2A6FB5),
error = Color(0xFFC7262E),
ok = Color(0xFF2E8B57),
userBubble = Color(0xFFD3E3F7),
onUserBubble = Color(0xFF1A1D23),
botBubble = Color(0xFFEDEFF3),
onBotBubble = Color(0xFF1A1D23),
selection = Color(0x222A6FB5),
scrim = Color(0x66000000),
codeBackground = Color(0xFFEAEDF2),
codeText = Color(0xFF1A1D23),
)
private val LocalAgentikColors = staticCompositionLocalOf { darkAgentikColors }
/** Доступ к семантическим цветам из экранов: `AgentikTheme.colors.panel`. */
object AgentikTheme {
val colors: AgentikColors
@Composable
@ReadOnlyComposable
get() = LocalAgentikColors.current
}
/**
* Корневой провайдер темы. Оборачивает всё приложение; [themeId] — значение
* из настроек (`ThemeId.DARK` / `ThemeId.LIGHT`).
*/
@Composable
fun AgentikTheme(
themeId: String,
content: @Composable () -> Unit,
) {
val dark = themeId != ThemeId.LIGHT
val colors = if (dark) darkAgentikColors else lightAgentikColors
CompositionLocalProvider(LocalAgentikColors provides colors) {
MaterialTheme(
colorScheme = colors.toMaterialScheme(dark = dark),
content = content,
)
}
}
@@ -0,0 +1,57 @@
package pw.binom.agentik.desktop.voice
/** Частота дискретизации всего голосового тракта (контракт `mic-api`/`asr-api`). */
const val VOICE_SAMPLE_RATE: Int = 16_000
/** Длина чанка, которую просим у микрофона (100 мс = 1600 сэмплов = 3200 байт). */
const val VOICE_CHUNK_MILLIS: Int = 100
/**
* s16le PCM (как отдаёт `mic-api`) → float сэмплы в [-1, 1] (как ждёт `asr-api`
* и VAD). Нечётный хвостовой байт отбрасывается: кадр — 2 байта.
*/
fun pcm16ToFloats(pcm: ByteArray, offset: Int = 0, length: Int = pcm.size - offset): FloatArray {
val count = length / 2
val out = FloatArray(count)
for (i in 0 until count) {
val lo = pcm[offset + i * 2].toInt() and 0xFF
val hi = pcm[offset + i * 2 + 1].toInt()
out[i] = ((hi shl 8) or lo).toShort() / 32768f
}
return out
}
/**
* Режет произвольные порции сэмплов на окна фиксированного размера.
*
* Нужно для VAD: Silero принимает строго `windowSizeSamples` (512 на 16 кГц),
* а от микрофона приходят чанки по 1600 сэмплов — не кратные 512.
* Неполное окно держится до следующей порции.
*
* Колбэк получает копию окна, а не внутренний буфер: вызывающий может её
* сохранить.
*/
class PcmWindower(
val windowSize: Int,
private val onWindow: (FloatArray) -> Unit,
) {
private val buf = FloatArray(windowSize)
private var fill = 0
/** Сколько сэмплов накоплено в неполном окне (для тестов/диагностики). */
val pending: Int get() = fill
fun push(samples: FloatArray) {
var i = 0
while (i < samples.size) {
val n = minOf(windowSize - fill, samples.size - i)
samples.copyInto(buf, fill, i, i + n)
fill += n
i += n
if (fill == windowSize) {
onWindow(buf.copyOf())
fill = 0
}
}
}
}
@@ -0,0 +1,190 @@
package pw.binom.agentik.desktop.voice
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import pw.binom.mic.MicAudio
/** Состояние голосового ввода (для кнопки микрофона). */
enum class VoiceState {
/** Ничего не происходит. */
IDLE,
/** Грузятся модели распознавания (первый раз — секунды). */
PREPARING,
/** Микрофон пишет; [VoiceController.onPartial] отдаёт растущий текст. */
RECORDING,
/** Ввод закрыт, дочитывается финальный текст. */
RECOGNIZING,
}
/** Шов для тестов: источник аудио-чанков (s16le, 16 кГц, моно). */
fun interface MicSource {
fun record(sampleRate: Int, chunkMillis: Int): Flow<ByteArray>
}
/**
* Голосовой ввод: микрофон → детектор речи → распознавание → текст.
*
* Одно нажатие:
* - [start] → `PREPARING` (грузятся модели) → `RECORDING` (микрофон пишет,
* [onPartial] отдаёт растущий текст);
* - [stop] → `RECOGNIZING` (дочитываем хвост) → `IDLE` + [onFinal] с итогом;
* - [cancel] — выбросить всё, ничего не отдавать;
* - нет микрофона / упала модель → [onError] и `IDLE`.
*
* Тишина не превращается в мусор: если VAD ни разу не увидел речь, финал не
* отдаётся ([onError] «Речь не распознана»).
*
* ## Почему финал читается в отдельной корутине
*
* [stop] отменяет корутину записи, а `VoskStt.finish()` (пролив распознавателя
* + хвостовой результат) — блокирующий нативный вызов, который после отмены
* уже не выполнится. Поэтому завершение запускается из `finally` в **другой**
* корутине ([NonCancellable]), которую отмена записи не задевает; собственно
* нативная работа по-прежнему идёт на одном выделенном потоке.
*
* Всё нативное (Vosk/ONNX) считается на **одном** потоке: эти библиотеки не
* потокобезопасны, а [VoiceRecognizerFactory.create] грузит модели.
*/
class VoiceController(
private val scope: CoroutineScope,
private val recognizers: VoiceRecognizerFactory = VoiceRecognizerFactory { VoskVoiceRecognizer() },
private val mic: MicSource = MicSource { rate, chunkMillis ->
MicAudio.record(sampleRate = rate, channels = 1, chunkMillis = chunkMillis)
},
private val onState: (VoiceState) -> Unit = {},
private val onPartial: (String) -> Unit = {},
private val onFinal: (String) -> Unit = {},
private val onError: (String) -> Unit = {},
) : AutoCloseable {
private val audioDispatcher = Dispatchers.Default.limitedParallelism(1)
private var recordJob: Job? = null
private var finishJob: Job? = null
private var discard = false
private var stopping = false
private var lastPartial: String? = null
/** Текущее состояние; каждое изменение уходит в [onState]. */
var state: VoiceState = VoiceState.IDLE
private set(value) {
if (field != value) {
field = value
onState(value)
}
}
/** true, пока пишем/готовимся/дочитываем финал. */
val isActive: Boolean get() = recordJob?.isActive == true || finishJob?.isActive == true
/** Кнопка микрофона: нажатие в простое — начать, в работе — остановить. */
fun toggle() {
if (isActive) stop() else start()
}
fun start() {
if (isActive) return
discard = false
stopping = false
lastPartial = null
state = VoiceState.PREPARING
recordJob = scope.launch {
var recognizer: VoiceRecognizer? = null
var micError: String? = null
try {
val created: VoiceRecognizer = try {
withContext(audioDispatcher) { recognizers.create() }
} catch (e: CancellationException) {
state = VoiceState.IDLE
return@launch
} catch (e: Throwable) {
state = VoiceState.IDLE
onError("Не удалось запустить распознавание: ${e.message ?: e::class.simpleName}")
return@launch
}
recognizer = created
// stop() во время подготовки уже выставил RECOGNIZING — не затираем.
if (!stopping) state = VoiceState.RECORDING
try {
mic.record(VOICE_SAMPLE_RATE, VOICE_CHUNK_MILLIS).collect { chunk ->
if (chunk.isEmpty()) return@collect
withContext(audioDispatcher) {
created.feed(chunk)
val p = created.partial()
if (p != null && p != lastPartial) {
lastPartial = p
onPartial(p)
}
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
micError = "Микрофон недоступен: ${e.message ?: e::class.simpleName}"
}
} finally {
// Сюда попадаем и при stop()/cancel() (отмена) — а финал должен
// быть прочитан, поэтому отдельная некоррелируемая корутина.
recognizer?.let { finishAsync(it, discardFinal = discard, micError = micError) }
}
}
}
/** Закрыть ввод и отдать итоговый текст через [onFinal]. */
fun stop() {
if (recordJob?.isActive != true) return
stopping = true
state = VoiceState.RECOGNIZING
recordJob?.cancel()
}
/** Прервать без текста. */
fun cancel() {
if (recordJob?.isActive != true) return
discard = true
stopping = true
recordJob?.cancel()
state = VoiceState.IDLE
}
private fun finishAsync(recognizer: VoiceRecognizer, discardFinal: Boolean, micError: String?) {
finishJob = scope.launch(NonCancellable) {
val (text, speech) = withContext(audioDispatcher) {
val t = if (discardFinal) "" else runCatching { recognizer.finish() }.getOrDefault("")
t.trim() to recognizer.speechDetected
}
// close() не suspend — выполняется даже при отмене.
runCatching { recognizer.close() }
if (discardFinal) {
state = VoiceState.IDLE
return@launch
}
val err = micError
when {
err != null -> onError(err)
text.isNotEmpty() -> onFinal(text)
!speech -> onError("Речь не распознана")
}
state = VoiceState.IDLE
}
}
override fun close() {
discard = true
stopping = true
recordJob?.cancel()
finishJob?.cancel()
}
}
@@ -0,0 +1,49 @@
package pw.binom.agentik.desktop.voice
import pw.binom.asr.Stt
import pw.binom.asr.vosk.VoskConfig
import pw.binom.asr.vosk.vosk
import pw.binom.voice.vad.VadInstance
/**
* Прогрев нативного ASR-стека (Vosk/Kaldi + ONNX) **до** инициализации skia.
*
* ## Зачем это обязательно
*
* skiko (нативный рендер Compose) и C++-библиотеки распознавания в одном
* процессе конфликтуют по порядку загрузки: если C++-стек инициализируется
* ПОСЛЕ skiko, первое же создание модели валит JVM с SIGSEGV — падение идёт из
* C++-логов (`kaldi::ParseOptions::RegisterSpecific` → `libstdc++
* std::ostream::_M_insert` → null-стрим в libc), см. probe-заметки в
* MIC-ASR-SEARCH.md. Если же стек прогрет ДО skiko, то и повторное создание
* модели, и свежий VAD после отрисовки окна работают нормально
* (проверено `RealSpeechIT`).
*
* Поэтому [warmUp] зовётся в `main()` до `application { }`. Побочный плюс:
* модель Vosk (~46 МБ, распаковка ~2 с) грузится на старте, а не при первом
* нажатии микрофона.
*
* Если прогрев не удался (нет нативных библиотек), микрофон отключается: лучше
* показать «голос недоступен», чем уронить приложение на первом клике.
*/
object VoiceNative {
private var warmed: Boolean? = null
/** Доступен ли голосовой ввод — результат [warmUp]. */
val available: Boolean get() = warmed == true
/**
* Инициализирует нативный ASR (Vosk + VAD) до skiko. Идемпотентно;
* возвращает `true`, если голосовой ввод доступен.
*/
fun warmUp(): Boolean {
warmed?.let { return it }
val ok = runCatching {
Stt.vosk(VoskConfig(VoskVoiceRecognizer.MODEL_ASSET_NAME, VOICE_SAMPLE_RATE)).close()
VadInstance(VOICE_SAMPLE_RATE).close()
}.isSuccess
warmed = ok
return ok
}
}
@@ -0,0 +1,90 @@
package pw.binom.agentik.desktop.voice
import pw.binom.asr.Stt
import pw.binom.asr.vosk.VoskConfig
import pw.binom.asr.vosk.vosk
import pw.binom.voice.vad.VadInstance
/**
* Потоковое распознавание речи: принимает s16le PCM 16 кГц моно, отдаёт текст.
*
* Интерфейс-шов для тестов: реальная реализация тянет нативные модели
* (Vosk + ONNX), в тестах подставляется фейк, который просто считает байты.
*
* Не потокобезопасен: все вызовы должны идти последовательно из одной корутины
* (это обеспечивает [VoiceController] через однопоточный диспетчер).
*/
interface VoiceRecognizer : AutoCloseable {
/** Подать порцию аудио (s16le, 16 кГц, моно). */
fun feed(pcm: ByteArray)
/** Промежуточный текст (растёт по ходу речи), или `null` если его нет. */
fun partial(): String?
/** Конец ввода → итоговый текст. */
fun finish(): String
/** Была ли в поданном аудио речь (по VAD). */
val speechDetected: Boolean
}
/**
* Фабрика распознавателя. Создание **дорогое** (загрузка модели Vosk ~секунды
* на первый раз, распаковка zip в tmp) — звать только с фонового диспетчера.
*/
fun interface VoiceRecognizerFactory {
fun create(): VoiceRecognizer
}
/**
* Распознаватель по умолчанию: [Stt.vosk] (модель `vosk-model-small-ru-0.22.zip`
* лежит внутри артефакта `asr-vosk-jvm`, качать нечего) + Silero VAD из
* `vad-jvm` (модель `silero_vad.onnx` тоже внутри артефакта).
*
* VAD используется как детектор «говорили ли вообще»: аудио в Vosk идёт целиком
* (он сам умеет endpointer по паузам, обрезать тишину вредно), а вот пустой
* финал при полной тишине даёт [speechDetected] = false — тогда
* [VoiceController] не выдаёт мусорный текст.
*/
class VoskVoiceRecognizer(
private val vad: VadInstance = VadInstance(VOICE_SAMPLE_RATE),
private val stt: Stt = Stt.vosk(VoskConfig(MODEL_ASSET_NAME, VOICE_SAMPLE_RATE)),
) : VoiceRecognizer {
private val windower = PcmWindower(vad.windowSizeSamples) { window ->
if (vad.processingWindow(window) >= VAD_SPEECH_THRESHOLD) speechSeen = true
}
private var speechSeen = false
override val speechDetected: Boolean get() = speechSeen
override fun feed(pcm: ByteArray) {
if (pcm.isEmpty()) return
val samples = pcm16ToFloats(pcm)
windower.push(samples)
stt.feed(samples)
}
override fun partial(): String? =
stt.partialText()?.trim()?.takeIf { it.isNotEmpty() }
override fun finish(): String {
stt.endOfInput()
return stt.finalText().trim()
}
override fun close() {
runCatching { stt.close() }
runCatching { vad.close() }
}
companion object {
/** Имя zip-ресурса в classpath — совпадает с вложенным в `asr-vosk-jvm`. */
const val MODEL_ASSET_NAME: String = "vosk-model-small-ru-0.22.zip"
/** Порог Silero VAD (вероятность речи 0..1), классические 0.5. */
const val VAD_SPEECH_THRESHOLD: Float = 0.5f
}
}
@@ -0,0 +1,196 @@
package pw.binom.agentik.desktop.markdown
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class MarkdownParserTest {
private fun renderedText(md: String): String =
parseMarkdown(md)
.joinToString("\n") { segment ->
when (segment) {
is MarkdownSegment.Text -> segment.text
is MarkdownSegment.CodeBlock -> segment.code
is MarkdownSegment.Table ->
(segment.headers + segment.rows.flatten()).joinToString("|")
is MarkdownSegment.Image -> "[img:${segment.src}]"
is MarkdownSegment.ThematicBreak -> "[---]"
}
}
/**
* Регрессия: одиночный «!» не должен теряться.
* Было сломано в ru.otpbank.ai:markdown 0.10.0 (ветка IMAGE без else
* проглатывала одиночный «!»), исправлено в 0.51.0.
*/
@Test
fun `exclamation marks are preserved`() {
assertEquals("Привет!", renderedText("Привет!"))
assertEquals("!!", renderedText("!!"))
assertEquals("Привет! Как дела?", renderedText("Привет! Как дела?"))
assertEquals("Внимание!", renderedText("Внимание!"))
}
@Test
fun `inline styles italic bold strike code are recorded`() {
val md = "*italic* **bold** ~~strike~~ `code` and plain"
val text = parseMarkdown(md).filterIsInstance<MarkdownSegment.Text>().single()
assertEquals("italic bold strike code and plain", text.text)
fun spanFor(prefix: String): TextStyleSpan = text.spans.single { text.text.startsWith(prefix, it.start) }
val italic = spanFor("italic")
assertTrue(italic.italic, "italic span must have italic=true")
val bold = spanFor("bold")
assertTrue(bold.bold, "bold span must have bold=true")
val strike = spanFor("strike")
assertTrue(strike.strikethrough, "strike span must have strikethrough=true")
val code = spanFor("code")
assertTrue(code.code, "code span must have code=true")
}
@Test
fun `ordered list items are not dropped`() {
val md =
"""
|Конкретно два места:
|
|1. Моя шпаргалка по кластеру (файл k3s-инвентарь).
|2. Ваша заметка задач /root/notes/2026-08-05.md.
|
|А больше ничего.
""".trimMargin()
val text = renderedText(md)
assertTrue(text.contains("Конкретно два места"), "Нет вводного абзаца: $text")
assertTrue(text.contains("1. Моя шпаргалка по кластеру"), "Пункт 1 потерян: $text")
assertTrue(text.contains("2. Ваша заметка задач"), "Пункт 2 потерян: $text")
assertTrue(text.contains("А больше ничего"), "Нет завершающего абзаца: $text")
}
@Test
fun `unordered list items are not dropped`() {
val md =
"""
|- первый пункт
|- второй пункт
""".trimMargin()
val text = renderedText(md)
assertTrue(text.contains("• первый пункт"), "Маркер списка не добавлен или пункт потерян: $text")
assertTrue(text.contains("• второй пункт"), "Пункт потерян: $text")
}
@Test
fun `plain paragraph stays intact`() {
val md = "Просто текст без форматирования."
assertEquals("Просто текст без форматирования.", renderedText(md).trim())
}
@Test
fun `thematic break, autolink and html block are not dropped`() {
val md =
"""
|Текст
|
|-----
|
|<https://example.com>
|
|<div>hello</div>
""".trimMargin()
val segments = parseMarkdown(md)
val text = renderedText(md)
assertTrue(
segments.any { it is MarkdownSegment.ThematicBreak },
"Разделитель `---` не распознан: ${segments.joinToString { it.toString() }}",
)
assertTrue(text.contains("https://example.com"), "Автоссылка потеряна: $text")
assertTrue(text.contains("hello"), "HTML-блок потерян: $text")
}
@Test
fun `table with bold and comma inside cells is parsed`() {
// Реальный кейс из Hermes: жирный текст и запятая внутри ячеек не должны
// ломать распознавание таблицы и разбиение на ячейки.
val md =
"""
|| # | Книга | Автор / изд. | Чтец | Почему это оно |
||---|-------|-------------|------|----------------|
|| 1 | **«Призрак поместья Торнхилл»** (#1 серии) | Альва Морис, Эксмо Freedom | Илья Дементьев | причина |
|
|После таблицы — обычный абзац.
""".trimMargin()
val segments = parseMarkdown(md)
val table = segments.filterIsInstance<MarkdownSegment.Table>().firstOrNull()
assertTrue(table != null, "Таблица не распознана: ${segments.joinToString { it.toString() }}")
assertEquals(listOf("#", "Книга", "Автор / изд.", "Чтец", "Почему это оно"), table!!.headers)
val row = table.rows[0]
assertEquals(5, row.size, "Строка должна иметь 5 ячеек, а имеет ${row.size}: $row")
assertEquals("«Призрак поместья Торнхилл» (#1 серии)", row[1])
assertEquals("Альва Морис, Эксмо Freedom", row[2])
}
@Test
fun `image эмитится как отдельный сегмент с src`() {
// data URL (Hermes).
val segments = parseMarkdown("""![альт](data:image/png;base64,QUJD)""")
val image = segments.filterIsInstance<MarkdownSegment.Image>().single()
assertEquals("data:image/png;base64,QUJD", image.src)
assertEquals("альт", image.alt)
}
@Test
fun `image с http-адресом эмитится как отдельный сегмент`() {
val segments = parseMarkdown("""![x](http://example.com/img.png)""")
val image = segments.filterIsInstance<MarkdownSegment.Image>().single()
assertEquals("http://example.com/img.png", image.src)
}
@Test
fun `текст вокруг картинки не теряется`() {
val segments = parseMarkdown("""текст до ![альт](data:image/png;base64,QUJD) текст после""")
assertEquals(
listOf("текст до ", " текст после"),
segments.filterIsInstance<MarkdownSegment.Text>().map { it.text },
)
assertEquals(1, segments.filterIsInstance<MarkdownSegment.Image>().size)
// Порядок: текст, картинка, текст.
assertEquals(
listOf("Text", "Image", "Text"),
segments.map { it.javaClass.simpleName },
)
}
@Test
fun `original answer from hermes keeps paragraph breaks`() {
val md =
"""
|Сударь, извольте одной строкой: скорость одна и та же.
|
|Свет и радио — это одно и то же явление.
|
|Разница не в скорости, а в свойствах.
""".trimMargin()
val segments = parseMarkdown(md)
val text = renderedText(md)
// Что реально отдаёт парсер:
println("=== segment count: ${segments.size}")
segments.forEachIndexed { i, s ->
println("--- [$i] ${s.javaClass.simpleName}: ${(s as? MarkdownSegment.Text)?.text ?: s}")
}
println("=== joined text: [" + text.replace("\n", "\\n") + "]")
assertTrue(segments.size >= 3, "Ожидалось несколько абзацев, получено ${segments.size}: ${segments.joinToString { it.toString() }}")
assertTrue(text.contains("\n"), "Между абзацами нет перевода строки: ${text.replace("\n", "\\n")}")
}
}
@@ -0,0 +1,84 @@
package pw.binom.agentik.desktop.media
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.io.path.exists
import kotlin.io.path.writeBytes
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Тесты [AvatarStorage] на реальном временном каталоге: копирование,
* замена (старое расширение вычищается), удаление, чтение.
*/
class AvatarStorageTest {
@TempDir
lateinit var dir: Path
private fun storage() = AvatarStorage(dir)
@Test
fun `save copies file and returns absolute path`() = runBlocking {
val src = dir.resolve("src.png")
src.writeBytes(byteArrayOf(1, 2, 3))
val saved = storage().save("agent-1", src)
assertTrue(Path.of(saved).exists())
assertTrue(Path.of(saved).isAbsolute)
assertEquals(listOf<Byte>(1, 2, 3), Path.of(saved).toFile().readBytes().toList())
}
@Test
fun `save with new extension removes old avatar`() = runBlocking {
val png = dir.resolve("a.png").apply { writeBytes(byteArrayOf(9)) }
val jpg = dir.resolve("b.jpg").apply { writeBytes(byteArrayOf(8)) }
val storage = storage()
val first = storage.save("agent-1", png)
val second = storage.save("agent-1", jpg)
assertFalse(Path.of(first).exists(), "старый аватар должен быть удалён")
assertTrue(Path.of(second).exists())
// В каталоге ровно один файл агента (+ исходники теста).
val owned = dir.toFile().listFiles()!!.filter { it.name.startsWith("agent-1.") }
assertEquals(1, owned.size)
}
@Test
fun `read returns bytes or null`() = runBlocking {
val src = dir.resolve("x.png").apply { writeBytes(byteArrayOf(4, 5)) }
val storage = storage()
val saved = storage.save("agent-2", src)
assertEquals(listOf<Byte>(4, 5), storage.read(saved)!!.toList())
assertNull(storage.read(null))
assertNull(storage.read(dir.resolve("missing.png").toString()))
}
@Test
fun `delete removes avatar`() = runBlocking {
val src = dir.resolve("x.png").apply { writeBytes(byteArrayOf(1)) }
val storage = storage()
val saved = storage.save("agent-3", src)
assertTrue(storage.delete("agent-3"))
assertFalse(Path.of(saved).exists())
assertFalse(storage.delete("agent-3"))
}
@Test
fun `agent ids with unsafe characters are sanitized`() = runBlocking {
val src = dir.resolve("x.png").apply { writeBytes(byteArrayOf(1)) }
val saved = storage().save("http://host:8080/x", src)
val fileName = Path.of(saved).fileName.toString()
assertFalse(fileName.contains(":"), "двоеточие недопустимо в имени файла: $fileName")
assertFalse(fileName.contains("/"))
}
}
@@ -0,0 +1,138 @@
package pw.binom.agentik.desktop.model
import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.MessageRecord
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Тесты мапперов истории журнала в UI-модель. Ключевое — склейка
* `ToolCall` + `ToolResult` в одну карточку и сохранение порядка.
*/
class MappersTest {
private fun at(ms: Long) = Instant.fromEpochMilliseconds(ms)
@Test
fun `user and assistant map to their ui types`() {
val ui = listOf(
MessageRecord.UserMessage(
id = "u1",
conversationId = "c",
content = listOf<Content>(Content.Text("привет")),
createdAt = at(1),
),
MessageRecord.AssistantMessage(
id = "a1",
conversationId = "c",
content = listOf<Content>(Content.Text("ответ")),
createdAt = at(2),
),
).toUiMessages()
assertEquals(2, ui.size)
val user = ui[0] as UiMessage.User
assertEquals("привет", (user.content.single() as UiContent.Text).body)
val assistant = ui[1] as UiMessage.Assistant
assertEquals("ответ", (assistant.content.single() as UiContent.Text).body)
}
@Test
fun `tool result is stitched into its tool call`() {
val ui = listOf(
MessageRecord.ToolCall(
id = "call-1",
conversationId = "c",
toolName = "shell",
toolTitle = "Выполнить",
toolArgsJson = "{\"cmd\":\"ls\"}",
createdAt = at(1),
),
MessageRecord.ToolResult(
id = "res-1",
conversationId = "c",
toolCallId = "call-1",
toolName = "shell",
result = "ok",
createdAt = at(2),
),
).toUiMessages()
assertEquals(1, ui.size, "результат не должен создавать отдельную карточку")
val tool = ui.single() as UiMessage.Tool
assertEquals("call-1", tool.id)
assertEquals("shell", tool.toolName)
assertEquals("Выполнить", tool.title)
assertEquals("ok", tool.result)
}
@Test
fun `orphan tool result becomes standalone card`() {
val ui = listOf(
MessageRecord.ToolResult(
id = "res-1",
conversationId = "c",
toolCallId = "missing",
toolName = null,
result = "data",
createdAt = at(1),
),
).toUiMessages()
val tool = ui.single() as UiMessage.Tool
assertEquals("missing", tool.id)
assertEquals("(unknown)", tool.toolName)
assertEquals("data", tool.result)
}
@Test
fun `error maps with code`() {
val ui = listOf(
MessageRecord.Error(
id = "e1",
conversationId = "c",
message = "упало",
code = "E42",
createdAt = at(1),
),
).toUiMessages()
val error = ui.single() as UiMessage.Error
assertEquals("упало", error.message)
assertEquals("E42", error.code)
}
@Test
fun `order is preserved across mixed records`() {
val ui = listOf(
MessageRecord.UserMessage("u", "c", listOf<Content>(Content.Text("1")), at(1)),
MessageRecord.ToolCall("t", "c", "shell", null, "{}", at(2)),
MessageRecord.ToolResult("r", "c", "t", "shell", "out", at(3)),
MessageRecord.AssistantMessage("a", "c", listOf<Content>(Content.Text("2")), at(4)),
).toUiMessages()
assertEquals(3, ui.size)
assertTrue(ui[0] is UiMessage.User)
assertTrue(ui[1] is UiMessage.Tool)
assertTrue(ui[2] is UiMessage.Assistant)
}
@Test
fun `image content is preserved`() {
val bytes = byteArrayOf(1, 2, 3)
val ui = listOf<MessageRecord>(
MessageRecord.AssistantMessage(
id = "a",
conversationId = "c",
content = listOf<Content>(Content.Image(bytes, "image/png")),
createdAt = at(1),
),
).toUiMessages()
val image = (ui.single() as UiMessage.Assistant).content.single() as UiContent.Image
assertEquals("image/png", image.mime)
assertTrue(bytes.contentEquals(image.data))
}
}
@@ -0,0 +1,243 @@
package pw.binom.agentik.desktop.persistence
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Интеграционные тесты [ConversationMetaRepository]. Каждый тест получает
* свежий `:memory:` репозиторий в [setUp], закрывается в [tearDown] —
* состояние не пересекается.
*
* Имя с суффиксом `IT` — по конвенции тестов этого проекта (см.
* `REQUIREMENTS.md`/настройки тестов): настоящие интеграционные тесты
* с реальной SQLite под капотом, не моки.
*
* **Что покрыто:**
* - Group CRUD: create с автоинкрементом id, list сортируется по имени,
* rename, delete с каскадом (`group_id → NULL` у диалогов).
* - Meta UPSERT-семантика: `setGroup` / `markSeen` / `setPreview` —
* каждое поле пишется независимо, остальные не затираются (тесты
* `markSeen does not overwrite group or preview` и т.п.).
* - `null`-семантика: `meta(unknown) == null`, `setGroup(c, null)` /
* `setPreview(c, null)` корректно сбрасывают.
* - Schema идемпотентна: повторное открытие файлового репозитория
* сохраняет данные.
*/
class ConversationMetaRepositoryIT {
private lateinit var repo: ConversationMetaRepository
@BeforeEach
fun setUp() {
repo = ConversationMetaRepository(":memory:")
}
@AfterEach
fun tearDown() {
repo.close()
}
// ───── Group CRUD ─────
@Test
fun `createGroup assigns auto-incremented ids and list is sorted by name`() = runBlocking {
val z = repo.createGroup("Зима")
val a = repo.createGroup("Альфа")
val m = repo.createGroup("Лето")
assertTrue(z.id > 0)
assertTrue(a.id > 0)
assertTrue(m.id > 0)
// Сортировка по имени, не по id
val names = repo.listGroups().map { it.name }
assertEquals(listOf("Альфа", "Зима", "Лето"), names)
}
@Test
fun `renameGroup updates name and returns true when group exists`() = runBlocking {
val g = repo.createGroup("Работа")
assertTrue(repo.renameGroup(g.id, "Офис"))
assertEquals("Офис", repo.listGroups().single { it.id == g.id }.name)
}
@Test
fun `renameGroup returns false for nonexistent id`() = runBlocking {
assertFalse(repo.renameGroup(999_999L, "Что угодно"))
}
@Test
fun `deleteGroup cascades group_id to NULL on conversations`() = runBlocking {
val work = repo.createGroup("Работа")
repo.setGroup("conv-1", work.id)
repo.setGroup("conv-2", work.id)
repo.setPreview("conv-1", "что-то")
assertTrue(repo.deleteGroup(work.id))
// Группа исчезла
assertEquals(emptyList(), repo.listGroups())
// Диалоги пережили удаление (R9.7) — но без группы
val c1 = repo.meta("conv-1")
assertNotNull(c1)
assertNull(c1.groupId)
assertEquals("что-то", c1.preview) // preview не затерся каскадом
val c2 = repo.meta("conv-2")
assertNotNull(c2)
assertNull(c2.groupId)
}
@Test
fun `deleteGroup returns false and does not throw for nonexistent id`() = runBlocking {
assertFalse(repo.deleteGroup(999_999L))
}
// ───── Per-conv meta ─────
@Test
fun `meta returns null for unknown convId`() = runBlocking {
assertNull(repo.meta("nope"))
}
@Test
fun `setGroup upserts — first call inserts, second updates`() = runBlocking {
val g1 = repo.createGroup("G1")
val g2 = repo.createGroup("G2")
repo.setGroup("c", g1.id)
assertEquals(g1.id, repo.meta("c")?.groupId)
repo.setGroup("c", g2.id)
assertEquals(g2.id, repo.meta("c")?.groupId)
}
@Test
fun `setGroup with null clears group_id but keeps the row`() = runBlocking {
val g = repo.createGroup("G")
repo.setGroup("c", g.id)
repo.setPreview("c", "preview")
repo.setGroup("c", null)
val meta = repo.meta("c")
assertNotNull(meta)
assertNull(meta.groupId)
assertEquals("preview", meta.preview)
}
@Test
fun `markSeen does not overwrite group or preview`() = runBlocking {
val g = repo.createGroup("G")
val ts = Instant.fromEpochMilliseconds(12345L)
repo.setGroup("c", g.id)
repo.setPreview("c", "preview-text")
repo.markSeen("c", ts)
val meta = repo.meta("c")
assertEquals(g.id, meta?.groupId)
assertEquals("preview-text", meta?.preview)
assertEquals(ts, meta?.lastSeen)
}
@Test
fun `setPreview does not overwrite group or lastSeen`() = runBlocking {
val g = repo.createGroup("G")
val ts = Instant.fromEpochMilliseconds(99_999L)
repo.setGroup("c", g.id)
repo.markSeen("c", ts)
repo.setPreview("c", "first preview")
repo.setPreview("c", "second preview") // overwrite only preview
val meta = repo.meta("c")
assertEquals(g.id, meta?.groupId)
assertEquals(ts, meta?.lastSeen)
assertEquals("second preview", meta?.preview)
}
@Test
fun `setPreview with null clears preview but keeps group and lastSeen`() = runBlocking {
val g = repo.createGroup("G")
val ts = Instant.fromEpochMilliseconds(7L)
repo.setGroup("c", g.id)
repo.markSeen("c", ts)
repo.setPreview("c", "old")
repo.setPreview("c", null)
val meta = repo.meta("c")
assertEquals(g.id, meta?.groupId)
assertEquals(ts, meta?.lastSeen)
assertNull(meta?.preview)
}
@Test
fun `listMetas returns all rows including ones with all-null fields`() = runBlocking {
repo.setGroup("c1", null) // строка c groupId=null, lastSeen=null, preview=null
repo.setGroup("c2", null)
repo.setPreview("c2", "x") // апдейтим только preview
val ts = Instant.fromEpochMilliseconds(1L)
repo.markSeen("c3", ts) // строка только с lastSeen
val metas = repo.listMetas().associateBy { it.convId }
assertEquals(3, metas.size)
assertNotNull(metas["c1"])
assertNull(metas["c1"]?.groupId)
assertNull(metas["c1"]?.lastSeen)
assertNull(metas["c1"]?.preview)
assertNull(metas["c2"]?.groupId)
assertEquals("x", metas["c2"]?.preview)
assertEquals(ts, metas["c3"]?.lastSeen)
assertNull(metas["c3"]?.preview)
}
// ───── Persistence across instances (только для файлового режима) ─────
@Test
fun `schema migration is idempotent — reopen preserves data`() = runBlocking {
val path = File.createTempFile("agentik-meta-", ".db").also { it.deleteOnExit() }.absolutePath
ConversationMetaRepository(path).use { r ->
r.createGroup("Persistent")
r.createGroup("Group2")
}
ConversationMetaRepository(path).use { r ->
val names = r.listGroups().map { it.name }.sorted()
assertEquals(listOf("Group2", "Persistent"), names)
}
}
@Test
fun `independent instances on different paths do not see each other`() = runBlocking {
val pathA = File.createTempFile("agentik-meta-a-", ".db").also { it.deleteOnExit() }.absolutePath
val pathB = File.createTempFile("agentik-meta-b-", ".db").also { it.deleteOnExit() }.absolutePath
ConversationMetaRepository(pathA).use { rA ->
rA.createGroup("OnlyInA")
ConversationMetaRepository(pathB).use { rB ->
rB.createGroup("OnlyInB")
assertEquals(listOf("OnlyInB"), rB.listGroups().map { it.name })
}
assertEquals(listOf("OnlyInA"), rA.listGroups().map { it.name })
}
}
}
@@ -0,0 +1,325 @@
package pw.binom.agentik.desktop.session
import io.ktor.server.cio.CIO as ServerCIO
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.desktop.model.UiContent
import pw.binom.agentik.desktop.model.UiMessage
import pw.binom.agentik.desktop.settings.AgentConfig
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore
import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.MutableOutboxStore
import pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentInfo
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext
import pw.binom.agentik.server.agentikAgent
import kotlin.test.AfterTest
import kotlin.test.Test
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
/**
* Сквозной тест: настоящий `:server` (HTTP/JSON/SSE) поверх in-memory-агента,
* а с другой стороны — наш `AgentConnection` + `ChatSession`. Проверяем, что
* десктопный стек работает по живому протоколу: создать диалог → отправить →
* увидеть стриминг → после `End` история в локальном SQLite → повторное
* открытие не плодит дублей.
*
* Никаких моков протокола: `AgentikAgent` (HTTP) ↔ `embeddedServer` ↔
* `agentikAgent(fake)`. Именно это ловит расхождения JSON/SSE-контракта,
* которые юнит-тесты с ручными фейками не видят.
*/
class AgentConnectionE2ETest {
private lateinit var server: io.ktor.server.engine.EmbeddedServer<*, *>
private var port: Int = 0
@AfterTest
fun tearDown() {
if (::server.isInitialized) server.stop(100, 300)
}
private fun startServer(agent: Agent) {
server = embeddedServer(ServerCIO, port = 0, host = "127.0.0.1") {
routing { agentikAgent(agent, path = "/agentik", token = null) }
}.start(wait = false)
port = runBlocking { server.engine.resolvedConnectors().first().port }
}
@Test
fun `send streams through real server and persists into local sqlite`() = runBlocking {
val fake = EchoAgent()
startServer(fake)
val conn = AgentConnection.open(
config = AgentConfig.create(baseUrl = "http://127.0.0.1:$port/agentik"),
clientId = "e2e-client",
scope = this,
)
try {
val conv = assertNotNull(conn.createConversation(temp = false), "createConversation")
val session = assertNotNull(conn.openSession(conv.id), "openSession")
try {
session.start()
val beforeTurn = Clock.System.now() - 50.milliseconds
session.send("привет")
await(timeoutMs = 15_000) {
session.history.value.size >= 2 && !session.isStreaming
}
val history = session.history.value
assertEquals(2, history.size, "user + assistant: $history")
val user = history[0] as UiMessage.User
assertEquals("привет", (user.content.single() as UiContent.Text).body)
val bot = history[1] as UiMessage.Assistant
assertEquals("эхо: привет", (bot.content.single() as UiContent.Text).body)
// Локальный кэш — на диске (agentik.db), а не в HTTP.
val cached = conn.journalCache.list(conv.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
assertEquals(2, cached.size, "cache records")
// Превью денормализовано из ответа ассистента.
assertEquals("эхо: привет", conn.meta.meta(conv.id)?.preview)
assertNotNull(conn.meta.meta(conv.id)?.syncedAt)
// Счётчик непрочитанных считается сервером от lastSeen.
// Сначала дожидаемся, пока `:client`-кэш списка диалогов
// подтянет созданный диалог (seed/live-refresh асинхронны).
await(timeoutMs = 10_000) {
conn.conversationStore.list(offset = 0, limit = 500).any { it.id == conv.id }
}
conn.meta.markSeen(conv.id, beforeTurn)
val ui = conn.uiConversations()
val row = ui.first { it.id == conv.id }
assertEquals(2, row.unread, "unread since beforeTurn")
// Повторное открытие читает из кэша и НЕ дублирует бэкфиллом.
val again = assertNotNull(conn.openSession(conv.id))
try {
again.start()
assertEquals(2, again.history.value.size, "reopen must not duplicate")
} finally {
again.close()
}
} finally {
session.close()
}
} finally {
conn.close()
}
}
@Test
fun `interrupt emits interrupted and commits nothing`() = runBlocking {
val fake = EchoAgent(interruptible = true)
startServer(fake)
val conn = AgentConnection.open(
config = AgentConfig.create(baseUrl = "http://127.0.0.1:$port/agentik"),
clientId = "e2e-client",
scope = this,
)
try {
val conv = assertNotNull(conn.createConversation(temp = false))
val session = assertNotNull(conn.openSession(conv.id))
try {
session.start()
val sendJob = launch { session.send("прервись") }
// Ждём, пока сервер реально вошёл в send (user-сообщение в журнале).
await(timeoutMs = 10_000) { fake.journal.count(conv.id) >= 1 }
session.interrupt()
await(timeoutMs = 10_000) { !session.isStreaming }
sendJob.join()
assertTrue(
session.history.value.none { it is UiMessage.Assistant },
"interrupted turn must not commit an assistant message",
)
} finally {
session.close()
}
} finally {
conn.close()
}
}
private suspend fun await(timeoutMs: Long = 5_000, condition: suspend () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (condition()) return
delay(25)
}
assertTrue(condition(), "condition not met within ${timeoutMs}ms")
}
}
/**
* Эхо-агент: на любой `send` отвечает `эхо: <текст>`, эмитит полный набор
* live-событий и пишет пару user/assistant в журнал — ровно то, что делает
* настоящий ChatAgent, но без LLM.
*
* @param interruptible если true — между `Working` и ответом ждёт
* `Interrupted`; ход закрывается прерыванием, ответ НЕ пишется.
*/
private class EchoAgent(private val interruptible: Boolean = false) : Agent {
override val id: String = "e2e-echo"
override val info: AgentInfo = AgentInfo(
name = "Эхо",
description = "Тестовый эхо-агент для сквозного теста",
)
override val journal: MutableJournalStore = InMemoryJournalStore()
override val outbox = InMemoryOutboxStore(maxMessages = null, ttl = null)
private val store: MutableConversationStore = InMemoryMutableConversationStore()
override val conversationStore: ConversationStore get() = store
private val conversations = mutableMapOf<String, EchoConversation>()
private var seq = 0
override fun createConversation(temp: Boolean): Conversation {
val id = "conv-${++seq}"
val now = Clock.System.now()
val conv = EchoConversation(id, journal, outbox, store, now, interruptible)
conversations[id] = conv
runBlocking {
store.upsert(ConversationRecord(id, null, temp, now, now))
outbox.append(CommonEvent.Agent(now, AgentEvent.Created(now, id)))
}
return conv
}
override suspend fun getConversation(id: String): Conversation? = conversations[id]
override suspend fun deleteConversation(id: String): Boolean {
val conv = conversations.remove(id) ?: return false
conv.close()
store.delete(id)
journal.clear(id)
val now = Clock.System.now()
outbox.append(CommonEvent.Agent(now, AgentEvent.Deleted(now, id)))
return true
}
override suspend fun renameConversation(id: String, title: String?): Instant? =
store.rename(id, title)
override fun close() {}
}
private class EchoConversation(
override val id: String,
private val journal: MutableJournalStore,
private val outbox: MutableOutboxStore,
private val store: MutableConversationStore,
createdAt: Instant,
private val interruptible: Boolean,
) : Conversation {
override val isSupportImageInput: Boolean = false
override val isSupportImageOutput: Boolean = false
override val isTemporal: Boolean = false
override var title: String? = null
private var updated: Instant = createdAt
override val updatedAt: Instant get() = updated
private var msgSeq = 0
/**
* id сообщения с zero-padded счётчиком В НАЧАЛЕ: журнал сортирует по
* `createdAt ASC, id ASC`, а фейк штампует user- и assistant-сообщение
* в одну и ту же миллисекунду — без padding'а лексикографически
* `...-a2` < `...-u1` и ассистент «уезжает» в начало истории.
*/
private fun nextMsgId(convId: String, role: String): String =
"$convId-${(++msgSeq).toString().padStart(6, '0')}-$role"
private val interrupted = java.util.concurrent.atomic.AtomicBoolean(false)
override suspend fun rename(title: String) {
this.title = title
}
override suspend fun interrupt() {
interrupted.set(true)
val now = Clock.System.now()
emit(now, Event.Interrupted(now))
}
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> = emptyList()
override suspend fun send(content: List<Content>, context: MessageContext?) {
val now = Clock.System.now()
journal.append(
MessageRecord.UserMessage(nextMsgId(id, "user"), id, content.toJournal(), now, context = null),
)
emit(now, Event.Working(now))
if (interruptible) {
// Имитация долгого LLM-цикла: крутимся, пока не придёт interrupt().
while (!interrupted.get()) delay(20)
return
}
val text = content.filterIsInstance<Content.Text>().joinToString("") { it.body }
val reply = "эхо: $text"
emit(now, Event.StartResponse(now, Event.ResponseType.TEXT))
emit(now, Event.AppendText(now, reply))
val endAt = Clock.System.now()
journal.append(
MessageRecord.AssistantMessage(
nextMsgId(id, "assistant"),
id,
listOf(pw.binom.agentik.journal.Content.Text(reply)),
endAt,
),
)
emit(endAt, Event.End(endAt))
updated = endAt
store.touch(id, endAt)
outbox.append(CommonEvent.Agent(endAt, AgentEvent.Touched(endAt, id, endAt)))
}
private suspend fun emit(at: Instant, event: Event) {
outbox.append(CommonEvent.Conversation(at, id, event))
}
override fun close() {}
}
/** `:proto.Content` → `:journal.Content` (в проде это делает storage-impl). */
private fun List<Content>.toJournal(): List<pw.binom.agentik.journal.Content> = map { c ->
when (c) {
is Content.Text -> pw.binom.agentik.journal.Content.Text(c.body)
is Content.Image -> pw.binom.agentik.journal.Content.Image(c.data, c.mime)
}
}
@@ -0,0 +1,128 @@
package pw.binom.agentik.desktop.session
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.io.TempDir
import pw.binom.agentik.desktop.settings.AgentConfig
import pw.binom.agentik.desktop.settings.SettingsRepository
import pw.binom.agentik.desktop.settings.ThemeId
import java.nio.file.Path
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Тесты [AgentRegistry]: persist настроек и переходы статусов. Подключение
* подменено [AgentConnector], который всегда падает — так проверяем
* деградацию в [AgentStatus.Offline] без сети. Успешное [AgentStatus.Online]
* потребовало бы настоящего [AgentConnection] (его конструктор приватный),
* поэтому проверяется косвенно — см. подключение к реальному серверу вручную.
*/
class AgentRegistryTest {
@TempDir
lateinit var dir: Path
private fun registry(scope: CoroutineScope) = AgentRegistry(
settingsRepo = SettingsRepository.inDirectory(dir),
scope = scope,
connector = { _, _ -> throw RuntimeException("нет сети") },
)
@Test
fun `add agent persists config and goes offline on failure`() = runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val reg = registry(scope)
try {
reg.load()
val cfg = AgentConfig.create("http://192.168.1.5:8080/agentik", nameOverride = "Тестовый")
val status = reg.addAgent(cfg)
assertTrue(status is AgentStatus.Offline)
assertEquals(1, reg.settings.value.agents.size)
assertNull(reg.connection(cfg.id))
// persist переживает переоткрытие
val reopened = SettingsRepository.inDirectory(dir).load()
assertEquals(listOf(cfg), reopened.agents)
} finally {
reg.close()
scope.cancel()
}
}
@Test
fun `update agent persists new baseUrl`() = runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val reg = registry(scope)
try {
reg.load()
val cfg = AgentConfig.create("http://old:8080/agentik")
reg.addAgent(cfg)
reg.updateAgent(cfg.copy(baseUrl = "http://new:9090/agentik"))
assertEquals("http://new:9090/agentik", reg.settings.value.agents.single().baseUrl)
assertEquals("http://new:9090/agentik", SettingsRepository.inDirectory(dir).load().agents.single().baseUrl)
} finally {
reg.close()
scope.cancel()
}
}
@Test
fun `remove agent drops it from settings and statuses`() = runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val reg = registry(scope)
try {
reg.load()
val cfg = AgentConfig.create("http://host:8080/agentik")
reg.addAgent(cfg)
reg.removeAgent(cfg.id)
assertTrue(reg.settings.value.agents.isEmpty())
assertNull(reg.statuses.value[cfg.id])
assertTrue(SettingsRepository.inDirectory(dir).load().agents.isEmpty())
} finally {
reg.close()
scope.cancel()
}
}
@Test
fun `updateSettings persists theme`() = runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val reg = registry(scope)
try {
reg.load()
reg.updateSettings { it.copy(themeId = ThemeId.LIGHT) }
assertEquals(ThemeId.LIGHT, reg.settings.value.themeId)
assertEquals(ThemeId.LIGHT, SettingsRepository.inDirectory(dir).load().themeId)
} finally {
reg.close()
scope.cancel()
}
}
@Test
fun `update nonexistent agent is offline and does not add it`() = runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val reg = registry(scope)
try {
reg.load()
val status = reg.updateAgent(AgentConfig.create("http://host:8080/agentik"))
assertTrue(status is AgentStatus.Offline)
assertTrue(reg.settings.value.agents.isEmpty())
} finally {
reg.close()
scope.cancel()
}
}
}
@@ -0,0 +1,325 @@
package pw.binom.agentik.desktop.session
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.jupiter.api.Test
import pw.binom.agentik.desktop.model.UiMessage
import pw.binom.agentik.desktop.persistence.ConversationMetaRepository
import pw.binom.agentik.journal.Content as JournalContent
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Тесты [ChatSession] на фейковом транспорте: реальные `KsqliteJournalStore`
* и `ConversationMetaRepository` (in-memory SQLite), но `JournalStore` /
* `OutboxStore` / `Conversation` — подделки. Проверяют склейку
* «бэкфилл → кэш → live-ход → End → история» без сети.
*
* Используют `runBlocking` (как `ConversationMetaRepositoryIT`), а не
* `runTest`: репозитории под капотом уходят в `Dispatchers.Default`, и
* виртуальное время `runTest` их не дожидается. Вместо этого — [await]
* с реальным `delay`.
*/
class ChatSessionTest {
private val convId = "conv-1"
private fun at(ms: Long) = Instant.fromEpochMilliseconds(ms)
private fun assistant(id: String, ms: Long, body: String) = MessageRecord.AssistantMessage(
id = id,
conversationId = convId,
content = listOf<JournalContent>(JournalContent.Text(body)),
createdAt = at(ms),
)
private fun user(id: String, ms: Long, body: String) = MessageRecord.UserMessage(
id = id,
conversationId = convId,
content = listOf<JournalContent>(JournalContent.Text(body)),
createdAt = at(ms),
)
/** Ждёт выполнения условия или падает по таймауту. */
private suspend fun await(timeoutMs: Long = 5_000, condition: suspend () -> Boolean) {
val ok = withTimeoutOrNull(timeoutMs) {
while (!condition()) delay(5)
true
}
check(ok == true) { "await: условие не выполнено за ${timeoutMs}мс" }
}
private class Fixture {
val connection: SQLiteConnection = SQLiteConnection.memory()
val cache = KsqliteJournalStore(connection)
val meta = ConversationMetaRepository(connection)
val outbox = FakeOutboxStore()
var remote = FakeJournalStore()
val conversation = FakeConversation("conv-1")
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun session() = ChatSession(
conversationId = "conv-1",
conversation = conversation,
remoteJournal = remote,
journalCache = cache,
meta = meta,
outbox = outbox,
scope = scope,
)
fun close() {
scope.cancel()
cache.close()
meta.close()
connection.close()
}
}
@Test
fun `start backfills remote journal into local cache`() = runBlocking {
val f = Fixture()
f.remote.records = listOf(user("u1", 1, "привет"), assistant("a1", 2, "здравствуй"))
val session = f.session()
try {
session.start()
await { session.history.value.size == 2 }
assertEquals(2, session.history.value.size)
// watermark = createdAt последней записи минус 1 мс (см. backfill:
// на миллисекундной границе assistant-сообщение делит timestamp с
// user-сообщением, и строгий `createdAt > after` его бы потерял).
assertEquals(at(1), f.meta.meta(convId)?.syncedAt)
} finally {
f.close()
}
}
@Test
fun `second start does not duplicate history`() = runBlocking {
val f = Fixture()
f.remote.records = listOf(user("u1", 1, "привет"), assistant("a1", 2, "здравствуй"))
val session = f.session()
try {
session.start()
await { session.history.value.size == 2 }
session.start()
delay(50)
assertEquals(2, session.history.value.size)
} finally {
f.close()
}
}
@Test
fun `legacy cache without watermark tolerates duplicate backfill`() = runBlocking {
val f = Fixture()
// Кэш уже содержит сообщение, но meta.syncedAt пуст (БД до миграции).
f.cache.append(user("u1", 1, "старое"))
f.remote.records = listOf(user("u1", 1, "старое"), assistant("a1", 2, "новое"))
val session = f.session()
try {
session.start()
await { session.history.value.size == 2 }
// Дубль u1 не уронил вставку, обе записи на месте.
assertEquals(2, session.history.value.size)
} finally {
f.close()
}
}
@Test
fun `live turn streams then collapses into history on End`() = runBlocking {
val f = Fixture()
val session = f.session()
try {
session.start()
f.outbox.awaitSubscriber()
f.outbox.emit(Event.Working(at(10)))
f.outbox.emit(Event.StartResponse(at(11), Event.ResponseType.TEXT))
f.outbox.emit(Event.AppendText(at(12), "Hel"))
f.outbox.emit(Event.AppendText(at(13), "lo"))
await { session.live.value.size == 1 }
assertTrue(session.live.value.single() is UiMessage.Assistant)
assertTrue(session.isStreaming)
assertEquals("Hello", (session.live.value.single() as UiMessage.Assistant)
.let { (it.content.single() as pw.binom.agentik.desktop.model.UiContent.Text).body })
// Сервер зафиксировал ход в журнале и прислал End.
f.remote.records = listOf(user("u1", 9, "вопрос"), assistant("a1", 14, "Hello"))
f.outbox.emit(Event.End(at(14)))
await { session.live.value.isEmpty() && session.history.value.size == 2 }
assertTrue(session.live.value.isEmpty())
assertTrue(!session.isStreaming)
} finally {
f.close()
}
}
@Test
fun `backfill keeps a reply that shares its millisecond with the watermark`() = runBlocking {
val f = Fixture()
// Кэшируем user-сообщение; watermark встанет на его же миллисекунду.
f.remote.records = listOf(user("u1", 5, "вопрос"))
val session = f.session()
try {
session.start()
await { session.history.value.size == 1 }
// Ответ пришёл в ТУ ЖЕ миллисекунду (быстрый локальный агент).
// Фильтр `createdAt > after` потерял бы его, если бы watermark
// стоял ровно на 5 мс — отсюда отступ назад на 1 мс + отсечение
// известных id вместо опоры на время.
f.remote.records = listOf(user("u1", 5, "вопрос"), assistant("a1", 5, "ответ"))
f.outbox.emit(Event.End(at(5)))
await { session.history.value.size == 2 }
// Порядок при равных timestamp'ах задаёт id (контракт SQL
// `createdAt ASC, id ASC`) — здесь он не важен, важно что оба
// сообщения доехали: ответ не потерян на границе watermark'а.
assertEquals(1, session.history.value.count { it is UiMessage.User })
assertEquals(1, session.history.value.count { it is UiMessage.Assistant })
} finally {
f.close()
}
}
@Test
fun `interrupt clears live turn without committing`() = runBlocking {
val f = Fixture()
val session = f.session()
try {
session.start()
f.outbox.awaitSubscriber()
f.outbox.emit(Event.StartResponse(at(10), Event.ResponseType.TEXT))
f.outbox.emit(Event.AppendText(at(11), "частичный"))
await { session.live.value.size == 1 }
f.outbox.emit(Event.Interrupted(at(12)))
await { session.live.value.isEmpty() }
assertTrue(session.live.value.isEmpty())
assertTrue(session.history.value.isEmpty())
} finally {
f.close()
}
}
@Test
fun `preview is denormalized from last assistant message`() = runBlocking {
val f = Fixture()
f.remote.records = listOf(
user("u1", 1, "привет"),
assistant("a1", 2, "Большой ответ\nв несколько строк"),
)
val session = f.session()
try {
session.start()
await { f.meta.meta(convId)?.preview != null }
assertEquals("Большой ответ в несколько строк", f.meta.meta(convId)?.preview)
} finally {
f.close()
}
}
}
// ───────────────────────── fakes ─────────────────────────
private class FakeJournalStore(
var records: List<MessageRecord> = emptyList(),
) : JournalStore {
override suspend fun list(
conversationId: String,
after: Instant,
offset: Int,
limit: Int,
): List<MessageRecord> = records
.filter { it.conversationId == conversationId && it.createdAt > after }
.sortedWith(compareBy({ it.createdAt }, { it.id }))
.drop(offset)
.take(limit)
override suspend fun count(conversationId: String): Long =
records.count { it.conversationId == conversationId }.toLong()
override suspend fun count(conversationId: String, after: Instant): Long =
records.count { it.conversationId == conversationId && it.createdAt > after }.toLong()
override fun close() {}
}
private class FakeOutboxStore : OutboxStore {
private val shared = MutableSharedFlow<CommonEvent>(replay = 256, extraBufferCapacity = 256)
override fun events(after: Instant?): Flow<CommonEvent> = shared.asSharedFlow()
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
override fun close() {}
/** Ждёт, пока у потока появится подписчик (ChatSession подписался). */
suspend fun awaitSubscriber() {
withTimeoutOrNull(5_000) {
while (shared.subscriptionCount.value == 0) delay(5)
}
}
suspend fun emit(event: Event, conversationId: String = "conv-1") {
shared.emit(CommonEvent.Conversation(date = event.date, conversationId = conversationId, event = event))
}
}
private class FakeConversation(
override val id: String,
override val isSupportImageInput: Boolean = true,
override val isSupportImageOutput: Boolean = true,
override val isTemporal: Boolean = false,
override var title: String? = null,
override var updatedAt: Instant = Instant.fromEpochMilliseconds(0),
) : Conversation {
val sent = mutableListOf<List<Content>>()
var interruptCount = 0
override suspend fun rename(title: String) {
this.title = title
}
override suspend fun send(content: List<Content>, context: MessageContext?) {
sent += content
}
override suspend fun interrupt() {
interruptCount++
}
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> = emptyList()
override fun close() {}
}
@@ -0,0 +1,209 @@
package pw.binom.agentik.desktop.session
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
import pw.binom.agentik.outbox.Event
import kotlin.time.Clock
import kotlin.time.Instant
class LiveTurnTest {
private val now = Clock.System.now()
private fun at() = Instant.fromEpochMilliseconds(now.toEpochMilliseconds())
private fun LiveTurn.feed(vararg events: Event) {
events.forEach { apply(it) }
}
@Test
fun `full happy path accumulates reasoning and answer`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartReasoning(at()),
Event.AppendText(at(), "сначала "),
Event.AppendText(at(), "подумаем"),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.AppendText(at(), "Привет, "),
Event.AppendText(at(), "мир!"),
Event.End(at()),
)
assertEquals(TurnPhase.DONE, turn.phase)
val blocks = turn.blocks
assertEquals(2, blocks.size)
val reasoning = assertIs<LiveBlock.Reasoning>(blocks[0])
assertEquals("сначала подумаем", reasoning.text.toString())
val answer = assertIs<LiveBlock.Answer>(blocks[1])
assertEquals("Привет, мир!", answer.text.toString())
assertEquals("Привет, мир!", turn.answerText())
}
@Test
fun `tool call and result are stitched into one block`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.ToolCall(at(), id = "t1", title = "grep", toolName = "grep", toolArgs = "{\"q\":\"x\"}"),
Event.ToolResult(at(), toolCallId = "t1", toolName = "grep", result = "found 3"),
Event.End(at()),
)
val tools = turn.blocks.filterIsInstance<LiveBlock.Tool>()
assertEquals(1, tools.size)
assertEquals("t1", tools[0].callId)
assertEquals("grep", tools[0].toolName)
assertEquals("{\"q\":\"x\"}", tools[0].argsJson)
assertEquals("found 3", tools[0].result)
assertEquals(null, tools[0].failed)
}
@Test
fun `orphan tool result creates a block`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.ToolResult(at(), toolCallId = "missing", toolName = null, result = "ok"),
)
val tool = turn.blocks.filterIsInstance<LiveBlock.Tool>().single()
assertEquals("missing", tool.callId)
assertEquals("(unknown)", tool.toolName)
assertEquals("ok", tool.result)
}
@Test
fun `tool failed marks the block`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.ToolCall(at(), id = "t9", title = null, toolName = "bash", toolArgs = "{}"),
Event.ToolFailed(at(), toolCallId = "t9", toolName = "bash", message = "boom", durationMs = 5),
)
val tool = turn.blocks.filterIsInstance<LiveBlock.Tool>().single()
assertEquals("boom", tool.failed)
}
@Test
fun `error appends failure block and fails the turn`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.AppendText(at(), "partial"),
Event.Error(at(), message = "llm down", code = "E500"),
)
assertEquals(TurnPhase.FAILED, turn.phase)
val failure = turn.blocks.filterIsInstance<LiveBlock.Failure>().single()
assertEquals("llm down", failure.message)
assertEquals("E500", failure.code)
// Частичный ответ остаётся в блоках — UI решает, показывать ли его.
assertEquals("partial", turn.answerText())
}
@Test
fun `interrupted turn is terminal and keeps partial text`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.AppendText(at(), "не до конца"),
Event.Interrupted(at()),
)
assertEquals(TurnPhase.INTERRUPTED, turn.phase)
assertTrue(turn.phase.isTerminal)
assertEquals("не до конца", turn.answerText())
}
@Test
fun `text after tool call starts a new answer block`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.AppendText(at(), "до"),
Event.ToolCall(at(), id = "t1", title = null, toolName = "grep", toolArgs = "{}"),
Event.AppendText(at(), "после"),
)
val answers = turn.blocks.filterIsInstance<LiveBlock.Answer>()
assertEquals(2, answers.size)
assertEquals("до", answers[0].text.toString())
assertEquals("после", answers[1].text.toString())
assertEquals("допосле", turn.answerText())
}
@Test
fun `image response produces image block`() {
val bytes = byteArrayOf(1, 2, 3)
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.IMAGE),
Event.AppendImage(at(), body = bytes, mime = "image/png"),
Event.End(at()),
)
assertTrue(turn.blocks.filterIsInstance<LiveBlock.Answer>().isEmpty())
val image = turn.blocks.filterIsInstance<LiveBlock.Image>().single()
assertEquals("image/png", image.mime)
assertEquals(3, image.data.size)
}
@Test
fun `working resets the previous turn`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.AppendText(at(), "старое"),
Event.End(at()),
)
turn.feed(Event.Working(at()), Event.AppendText(at(), "новое"))
assertEquals(1, turn.blocks.size)
assertEquals("новое", turn.answerText())
}
@Test
fun `append without explicit start response still streams as answer`() {
val turn = LiveTurn()
turn.feed(Event.Working(at()), Event.AppendText(at(), "без старта"))
assertEquals(TurnPhase.RESPONDING, turn.phase)
assertEquals("без старта", turn.answerText())
}
@Test
fun `onChange fires on mutation`() {
val turn = LiveTurn()
var count = 0
turn.onChange = { count++ }
turn.feed(Event.Working(at()), Event.AppendText(at(), "x"))
assertEquals(2, count)
}
@Test
fun `preview text collapses whitespace and truncates`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.AppendText(at(), "Первая строка\n\nвторая строка "),
)
assertEquals("Первая строка вторая строка", turn.previewText())
val long = LiveTurn()
long.feed(Event.Working(at()), Event.AppendText(at(), "слово ".repeat(60)))
val preview = long.previewText(maxLength = 30)
assertTrue(preview.length <= 31)
assertTrue(preview.endsWith("…"))
}
}
@@ -0,0 +1,195 @@
package pw.binom.agentik.desktop.session
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assumptions.assumeTrue
import pw.binom.agentik.desktop.model.UiContent
import pw.binom.agentik.desktop.model.UiMessage
import pw.binom.agentik.desktop.settings.AgentConfig
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import java.nio.file.Files
/**
* Реальная интеграция с ЖИВЫМ `:standalone`-сервером (Qwen через vLLM),
* а не с in-memory-эхо-агентом из [AgentConnectionE2ETest].
*
* Адрес берётся из env `AGENTIK_E2E_URL`, по умолчанию
* `http://127.0.0.1:8080/agentik`. Если сервер не поднят — тест
* **пропускается** (`assumeTrue`), поэтому `./gradlew jvmTest` остаётся
* зелёным без запущенного агента, а с ним — проверяет настоящий контракт:
* создать диалог → отправить → дождаться живого ответа LLM → история в
* локальном SQLite → повторное открытие не плодит дублей.
*
* БД уводится в temp-папку, пользовательский `~/.agentik-desktop` не
* трогается.
*/
class RealServerIT {
private val connection = mutableListOf<AgentConnection>()
@AfterTest
fun tearDown() {
connection.forEach { runCatching { it.close() } }
connection.clear()
}
private fun url(): String =
System.getenv("AGENTIK_E2E_URL")?.takeIf { it.isNotBlank() }
?: "http://127.0.0.1:8080/agentik"
private fun connect(scope: kotlinx.coroutines.CoroutineScope, db: String): AgentConnection? =
runCatching {
AgentConnection.open(
config = AgentConfig.create(baseUrl = url()),
clientId = "agentik-desktop-real-e2e",
scope = scope,
dbPath = db,
)
}.getOrNull()
@Test
fun `live server answers and the turn lands in the local cache`() = runBlocking {
val dir = Files.createTempDirectory("agentik-real-e2e")
val db = dir.resolve("agentik.db").toString()
val conn = connect(this, db)
assumeTrue(conn != null, "standalone-сервер недоступен на ${url()} — тест пропущен")
conn!!
connection += conn
val conv = conn.createConversation(temp = false)
assertNotNull(conv, "createConversation")
val session = assertNotNull(conn.openSession(conv.id), "openSession")
try {
session.start()
session.send("Ответь ровно одним словом: привет")
await(timeoutMs = 120_000) {
session.history.value.size >= 2 && !session.isStreaming
}
val history = session.history.value
assertEquals(2, history.size, "user + assistant: $history")
val user = history[0] as UiMessage.User
assertEquals("Ответь ровно одним словом: привет", (user.content.single() as UiContent.Text).body)
val bot = history[1] as UiMessage.Assistant
val answer = (bot.content.single() as UiContent.Text).body
assertTrue(answer.isNotBlank(), "LLM вернул пустой ответ")
// Локальный кэш (offline-история) — на диске.
val cached = conn.journalCache.list(conv.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
assertEquals(2, cached.size, "cache records")
// Превью + watermark денормализованы из ответа.
assertNotNull(conn.meta.meta(conv.id)?.preview)
assertNotNull(conn.meta.meta(conv.id)?.syncedAt)
// Список диалогов от сервера содержит созданный.
await(timeoutMs = 20_000) {
conn.uiConversations().any { it.id == conv.id }
}
// Переоткрытие читает из кэша и НЕ дублирует бэкфиллом.
val again = assertNotNull(conn.openSession(conv.id))
try {
again.start()
assertEquals(2, again.history.value.size, "reopen must not duplicate")
} finally {
again.close()
}
} finally {
session.close()
}
}
/**
* Сценарий «перезапуск клиента»: закрываем соединение и открываем
* заново на той же БД — история должна читаться из локального кэша,
* а не теряться.
*/
@Test
fun `history survives a reconnect on the same database`() = runBlocking {
val dir = Files.createTempDirectory("agentik-real-e2e-reconnect")
val db = dir.resolve("agentik.db").toString()
val convId: String
run {
val conn = connect(this, db)
assumeTrue(conn != null, "standalone-сервер недоступен на ${url()} — тест пропущен")
conn!!
connection += conn
val conv = assertNotNull(conn.createConversation(temp = false))
val session = assertNotNull(conn.openSession(conv.id))
session.start()
session.send("Скажи 'ок'")
await(timeoutMs = 120_000) { session.history.value.size >= 2 && !session.isStreaming }
convId = conv.id
session.close()
conn.close()
connection -= conn
}
val conn2 = assertNotNull(connect(this, db), "повторное подключение")
connection += conn2
val session2 = assertNotNull(conn2.openSession(convId), "openSession после перезапуска")
try {
session2.start()
await(timeoutMs = 30_000) { session2.history.value.size >= 2 }
assertEquals(2, session2.history.value.size, "история из локального кэша после реконнекта")
} finally {
session2.close()
}
}
/**
* Регрессия на реальный баг: `POST /conversations/{id}/messages` на
* сервере отвечает не сразу, а по завершении всего хода агента. Длинный
* ответ LLM (десятки секунд) обрывался дефолтным request-timeout'ом
* клиента (15000 мс) — ход умирал на клиенте, продолжая жить на сервере.
* Клиент обязан ставить на этот запрос infinite-timeout
* (`ConversationClient.send` → `noReadTimeout()`).
*/
@Test
fun `long llm answer outlives the default client request timeout`() = runBlocking {
val dir = Files.createTempDirectory("agentik-real-e2e-long")
val conn = connect(this, dir.resolve("agentik.db").toString())
assumeTrue(conn != null, "standalone-сервер недоступен на ${url()} — тест пропущен")
conn!!
connection += conn
val conv = assertNotNull(conn.createConversation(temp = false))
val session = assertNotNull(conn.openSession(conv.id))
try {
session.start()
session.send("Напиши связный текст примерно на 300 слов про историю SQLite. Без списков и заголовков.")
await(timeoutMs = 300_000) {
session.history.value.size >= 2 && !session.isStreaming
}
val answer = (session.history.value[1] as UiMessage.Assistant)
.content.filterIsInstance<UiContent.Text>().joinToString("\n") { it.body }
assertTrue(
answer.length > 500,
"ожидался длинный ответ (иначе тест не проверяет таймаут), а пришло ${answer.length} символов: $answer",
)
} finally {
session.close()
}
}
private suspend fun await(timeoutMs: Long = 5_000, condition: suspend () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (condition()) return
delay(50)
}
assertTrue(condition(), "condition not met within ${timeoutMs}ms")
}
}
@@ -0,0 +1,124 @@
package pw.binom.agentik.desktop.settings
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.readText
import kotlin.io.path.writeText
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Тесты [SettingsRepository]. Работают на реальном файле во временном
* каталоге JUnit ([TempDir]) — не моки, поэтому проверяют и атомарную
* запись, и устойчивость к битому файлу.
*/
class SettingsRepositoryTest {
@TempDir
lateinit var dir: Path
private fun repo() = SettingsRepository.inDirectory(dir)
@Test
fun `first load creates file and generates clientId`() = runBlocking {
val settings = repo().load()
assertTrue(settings.clientId.startsWith("agentik-desktop-"))
assertEquals(ThemeId.DARK, settings.themeId)
assertTrue(settings.agents.isEmpty())
assertTrue(dir.resolve(SettingsRepository.FILE_NAME).toFile().exists())
}
@Test
fun `clientId is stable across reloads`() = runBlocking {
val first = repo().load().clientId
val second = repo().load().clientId
assertEquals(first, second)
}
@Test
fun `agents round-trip`() = runBlocking {
val repository = repo()
val agent = AgentConfig.create(
baseUrl = "http://192.168.1.10:8080/agentik",
token = " secret ",
nameOverride = "Порфирий",
colorIndex = 2,
)
repository.update { it.copy(agents = listOf(agent)) }
val loaded = repository.load()
assertEquals(1, loaded.agents.size)
val a = loaded.agents.single()
assertEquals(agent.id, a.id)
assertEquals("http://192.168.1.10:8080/agentik", a.baseUrl)
assertEquals("secret", a.token)
assertEquals("Порфирий", a.nameOverride)
assertEquals(2, a.colorIndex)
}
@Test
fun `create trims blanks to null`() {
val agent = AgentConfig.create(baseUrl = " http://x ", token = " ", nameOverride = "")
assertEquals("http://x", agent.baseUrl)
assertNull(agent.token)
assertNull(agent.nameOverride)
}
@Test
fun `update applies transform over current value`() = runBlocking {
val repository = repo()
repository.update { it.copy(themeId = ThemeId.LIGHT, selectedGroupId = 7) }
val loaded = repository.load()
assertEquals(ThemeId.LIGHT, loaded.themeId)
assertEquals(7L, loaded.selectedGroupId)
}
@Test
fun `corrupt file falls back to defaults with fresh clientId`() = runBlocking {
dir.resolve(SettingsRepository.FILE_NAME).writeText("{ this is not json")
val loaded = repo().load()
assertTrue(loaded.clientId.startsWith("agentik-desktop-"))
assertEquals(ThemeId.DARK, loaded.themeId)
}
@Test
fun `unknown theme id is normalized to dark and persisted`() = runBlocking {
dir.resolve(SettingsRepository.FILE_NAME).writeText(
"""{"clientId":"agentik-desktop-1","themeId":"neon"}"""
)
val loaded = repo().load()
assertEquals(ThemeId.DARK, loaded.themeId)
// Нормализованное значение должно уехать на диск.
assertTrue(dir.resolve(SettingsRepository.FILE_NAME).readText().contains("\"dark\""))
}
@Test
fun `blank clientId in file is filled and persisted`() = runBlocking {
dir.resolve(SettingsRepository.FILE_NAME).writeText("""{"clientId":""}""")
val loaded = repo().load()
assertTrue(loaded.clientId.isNotBlank())
assertTrue(dir.resolve(SettingsRepository.FILE_NAME).readText().contains(loaded.clientId))
}
@Test
fun `two repositories in different dirs keep separate clientIds`() = runBlocking {
val other = Files.createTempDirectory("agentik-settings")
try {
val a = repo().load().clientId
val b = SettingsRepository.inDirectory(other).load().clientId
assertNotEquals(a, b)
} finally {
other.toFile().deleteRecursively()
}
}
}
@@ -0,0 +1,162 @@
package pw.binom.agentik.desktop.ui
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.jupiter.api.Assumptions.assumeTrue
import pw.binom.agentik.desktop.model.UiMessage
import pw.binom.agentik.desktop.session.AgentConnection
import pw.binom.agentik.desktop.session.AgentRegistry
import pw.binom.agentik.desktop.session.AgentStatus
import pw.binom.agentik.desktop.settings.AgentConfig
import pw.binom.agentik.desktop.settings.SettingsRepository
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import java.net.HttpURLConnection
import java.net.URI
import java.nio.file.Files
/**
* Приложение целиком (без пикселей) против ЖИВОГО `:standalone`-агента:
* настройки → реестр → [AppState] → диалог с настоящей LLM.
*
* Это замена GUI-прогону, когда vision-сервис недоступен: UI у нас — чистая
* функция от [AppState], а сам рендер уже проверен `ThemeRenderTest`.
* Проверяем то, что ломается только в жизни: создание диалога на сервере,
* отправку, живой ответ модели, попадание реплик в локальный кэш (историю),
* снятие «непрочитанного» после открытия.
*
* Адрес — `AGENTIK_E2E_URL` или `http://127.0.0.1:8080/agentik`; если сервер
* не поднят, тест **пропускается** (`assumeTrue`), как [RealServerIT].
*/
class RealAppStateIT {
private var registry: AgentRegistry? = null
private var state: AppState? = null
// Свой scope, а не coroutineContext из runBlocking: у реестра/сессии живут
// вечные коллекторы (SSE, listFlow), и runBlocking ждал бы их вечно.
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@AfterTest
fun tearDown() {
runCatching { state?.shutdown() }
runCatching { registry?.close() }
scope.cancel()
}
private fun url(): String =
System.getenv("AGENTIK_E2E_URL")?.takeIf { it.isNotBlank() }
?: "http://127.0.0.1:8080/agentik"
private fun reachable(url: String): Boolean = runCatching {
(URI("$url").toURL().openConnection() as HttpURLConnection).run {
connectTimeout = 1_500
readTimeout = 1_500
requestMethod = "GET"
responseCode in 200..499
}
}.getOrDefault(false)
private suspend fun await(
what: String,
timeoutMs: Long = 10_000,
condition: suspend () -> Boolean,
) {
val ok = withTimeoutOrNull(timeoutMs) {
while (!condition()) delay(20)
true
}
check(ok == true) { "не дождались: $what (${timeoutMs}мс)" }
}
@Test
fun `app state creates a dialog and gets a live answer from the real agent`() = runBlocking {
val base = url()
assumeTrue(reachable(base), "standalone-агент недоступен на $base — тест пропущен")
val tmp = Files.createTempDirectory("agentik-appstate")
val reg = AgentRegistry.create(
settingsRepo = SettingsRepository(tmp.resolve("settings.json")),
scope = scope,
)
registry = reg
val st = AppState(registry = reg, scope = scope)
state = st
st.start()
st.addAgent(baseUrl = base, nameOverride = "Живой агент")
await("агент появился в настройках") { st.agents.isNotEmpty() }
val agentId = st.agents.first().id
await("агент подключился", timeoutMs = 20_000) {
st.statusesState[agentId] is AgentStatus.Online
}
st.selectAgent(agentId)
await("список диалогов подтянулся с сервера") { st.conversations.isNotEmpty() }
st.newConversation(agentId)
await("диалог открылся") { st.activeConversationId != null }
val convId = st.activeConversationId!!
st.send("Ответь ровно одним словом: два плюс два?")
await("пришёл ответ ассистента", timeoutMs = 240_000) {
st.history.any { it is UiMessage.Assistant }
}
val assistant = st.history.filterIsInstance<UiMessage.Assistant>().last()
val assistantText = assistant.content.filterIsInstance<pw.binom.agentik.desktop.model.UiContent.Text>()
.joinToString(" ") { it.body }
assertTrue(assistantText.isNotBlank(), "ответ ассистента пустой")
assertTrue(
st.history.any { it is UiMessage.User },
"реплика пользователя должна быть в истории (из локального кэша)",
)
// Превью денормализовано в локальную мету (для строки списка).
val preview = reg.connection(agentId)!!.meta.meta(convId)?.preview
assertNotNull(preview, "preview диалога должен записаться локально")
// Открытие диалога помечает его просмотренным: непрочитанных нет.
val lastSeen = reg.connection(agentId)!!.meta.meta(convId)?.lastSeen
assertNotNull(lastSeen, "lastSeen должен выставиться при открытии")
// История диалога переживает повторное открытие (тот же локальный кэш).
st.openConversation(convId)
await("история перезагрузилась из кэша") { st.history.isNotEmpty() }
assertTrue(
st.history.filterIsInstance<UiMessage.Assistant>().isNotEmpty(),
"после повторного открытия ответ должен остаться в истории",
)
}
@Test
fun `live dialog list arrives by itself right after connect`() = runBlocking {
val base = url()
assumeTrue(reachable(base), "standalone-агент недоступен на $base — тест пропущен")
val tmp = Files.createTempDirectory("agentik-convflow")
val cfg = AgentConfig.create(baseUrl = base, nameOverride = "Живой агент")
val conn = AgentConnection.open(
config = cfg,
clientId = "agentik-desktop-it",
scope = scope,
dbPath = tmp.resolve("agentik.db").toString(),
)
// Seed внутри `AgentikAgent` асинхронный: список обязан приехать сам,
// без единого `refresh()` со стороны UI. Именно этого не хватало —
// приложение показывало пустой список диалогов.
await("живой список диалогов доехал сам", timeoutMs = 15_000) {
conn.conversationsFlow.value.isNotEmpty()
}
conn.close()
}
}
@@ -0,0 +1,154 @@
package pw.binom.agentik.desktop.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.ui.theme.AgentikColors
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
import pw.binom.agentik.desktop.ui.theme.lightAgentikColors
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Палитра и реальный рендер Compose без открытия окна.
*
* Зачем: светлая тема до сих пор проверялась только глазами по скриншоту, а
* markdown-код-блок изначально хардкодил тёмный фон (`#1E1E1E`), из-за чего в
* светлой теме выглядел чужеродно. Здесь тема прогоняется через настоящий
* оффскрин-рендер, а цвета сверяются с проектными токенами.
*/
class ThemeRenderTest {
// ── токены (THEMES.md / sketches/style.css) ────────────────────────────
@Test
fun darkPaletteMatchesDesignTokens() {
val c = darkAgentikColors
assertEquals(Color(0xFF121218), c.background)
assertEquals(Color(0xFF17212B), c.panel)
assertEquals(Color(0xFF1C2733), c.panelElevated)
assertEquals(Color(0xFF6AB2F2), c.accent)
assertEquals(Color(0xFFEBEBEB), c.text)
assertEquals(Color(0xFF768C9E), c.textDim)
assertEquals(Color(0xFFEC3942), c.error)
assertEquals(Color(0xFF2B5278), c.userBubble)
}
@Test
fun lightPaletteKeepsSemanticSlots() {
val c = lightAgentikColors
assertEquals(Color(0xFFF5F6F8), c.background)
assertEquals(Color(0xFFFFFFFF), c.panel)
assertEquals(Color(0xFF2A6FB5), c.accent)
assertEquals(Color(0xFF1A1D23), c.text)
// светлый фон обязан быть светлым: контраст к тексту
assertTrue(luminance(c.background) > luminance(c.text), "фон светлее текста")
assertTrue(luminance(c.panel) > luminance(c.panelElevated), "panel светлее panelElevated")
assertTrue(luminance(c.botBubble) > luminance(c.text), "бот-бабл светлее текста")
}
@Test
fun opaqueSlotsStayOpaqueAndScrimTransparent() {
for (c in listOf(darkAgentikColors, lightAgentikColors)) {
assertEquals(1f, c.background.alpha)
assertEquals(1f, c.panel.alpha)
assertEquals(1f, c.text.alpha)
assertEquals(1f, c.accent.alpha)
// scrim/selection — прозрачные по замыслу
assertTrue(c.scrim.alpha < 1f)
assertTrue(c.selection.alpha < 1f)
}
}
// ── рендер ─────────────────────────────────────────────────────────────
@OptIn(ExperimentalTestApi::class)
@Test
fun codeBlockPaintsThemeCodeBackgroundInLightTheme() = runComposeUiTest {
setContent {
AgentikTheme("light") {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) {
Column {
renderMarkdownSegments(
content = "```kotlin\nval x = 1\n```",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
)
}
}
}
}
val pixels = onRoot().captureToImage().toPixelMap()
val histogram = HashMap<Color, Int>()
for (y in 0 until pixels.height) {
for (x in 0 until pixels.width) {
val color = pixels[x, y]
histogram[color] = (histogram[color] ?: 0) + 1
}
}
val top = histogram.entries.sortedByDescending { it.value }.take(4)
.joinToString { "#${it.key.toHexArgb()}×${it.value}" }
assertTrue(
histogram.containsKey(lightAgentikColors.codeBackground),
"код-блок должен краситься в codeBackground светлой темы, а покрасился в: $top",
)
assertFalse(
histogram.containsKey(Color(0xFF1E1E1E)),
"тёмный хардкод код-фона протёк в светлую тему: $top",
)
}
@OptIn(ExperimentalTestApi::class)
@Test
fun darkThemeStillRendersCodeBlockDark() = runComposeUiTest {
setContent {
AgentikTheme("dark") {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) {
Column {
renderMarkdownSegments(
content = "```kotlin\nval x = 1\n```",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
)
}
}
}
}
val pixels = onRoot().captureToImage().toPixelMap()
var found = false
for (y in 0 until pixels.height) {
for (x in 0 until pixels.width) {
if (pixels[x, y] == darkAgentikColors.codeBackground) {
found = true
break
}
}
}
assertTrue(found, "в тёмной теме код-блок должен краситься в dark codeBackground")
}
private fun luminance(color: Color): Float =
0.2126f * color.red + 0.7152f * color.green + 0.0722f * color.blue
private fun Color.toHexArgb(): String {
val a = (alpha * 255).toInt()
val r = (red * 255).toInt()
val g = (green * 255).toInt()
val b = (blue * 255).toInt()
return "%02X%02X%02X%02X".format(a, r, g, b)
}
}
@@ -0,0 +1,144 @@
package pw.binom.agentik.desktop.voice
import pw.binom.voice.vad.VadInstance
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Чистые преобразования звука: s16le → float и нарезка на окна VAD.
* Без нативных библиотек — только арифметика.
*/
class AudioTest {
@Test
fun `s16le bytes become floats in minus one to one`() {
val pcm = byteArrayOf(
0x00, 0x00, // 0
0xFF.toByte(), 0x7F, // +32767
0x00, 0x80.toByte(), // -32768
0x00, 0x40, // +16384
)
val floats = pcm16ToFloats(pcm)
assertEquals(4, floats.size)
assertEquals(0f, floats[0])
assertEquals(32767f / 32768f, floats[1])
assertEquals(-1f, floats[2])
assertEquals(0.5f, floats[3])
}
@Test
fun `negative values keep their sign`() {
// -1 в s16le = 0xFFFF
val floats = pcm16ToFloats(byteArrayOf(0xFF.toByte(), 0xFF.toByte()))
assertEquals(1, floats.size)
assertEquals(-1f / 32768f, floats[0])
}
@Test
fun `offset and length select a slice`() {
val pcm = byteArrayOf(
0x11, 0x11, // пропустить
0x00, 0x00, // 0
0x00, 0x40, // 0.5
0x22, 0x22, // пропустить
)
val floats = pcm16ToFloats(pcm, offset = 2, length = 4)
assertEquals(2, floats.size)
assertEquals(0f, floats[0])
assertEquals(0.5f, floats[1])
}
@Test
fun `odd trailing byte is dropped`() {
val floats = pcm16ToFloats(byteArrayOf(0x00, 0x40, 0x7F))
assertEquals(1, floats.size)
assertEquals(0.5f, floats[0])
}
@Test
fun `windower emits exact windows and keeps the remainder`() {
val windows = mutableListOf<List<Float>>()
val windower = PcmWindower(windowSize = 4) { windows.add(it.toList()) }
// 4 → окно
windower.push(floatArrayOf(1f, 2f, 3f, 4f))
assertEquals(1, windows.size)
assertEquals(listOf(1f, 2f, 3f, 4f), windows[0])
assertEquals(0, windower.pending)
// 2 → ждём
windower.push(floatArrayOf(5f, 6f))
assertEquals(1, windows.size)
assertEquals(2, windower.pending)
// 5 → добираем окно (5,6,7,8), остаётся (9,10,11)
windower.push(floatArrayOf(7f, 8f, 9f, 10f, 11f))
assertEquals(2, windows.size)
assertEquals(listOf(5f, 6f, 7f, 8f), windows[1])
assertEquals(3, windower.pending)
// 12 → добираем (9,10,11,12) и снова пусто
windower.push(floatArrayOf(12f))
assertEquals(3, windows.size)
assertEquals(listOf(9f, 10f, 11f, 12f), windows[2])
assertEquals(0, windower.pending)
}
@Test
fun `windower gives independent copies not aliased buffer`() {
val windows = mutableListOf<FloatArray>()
val windower = PcmWindower(windowSize = 2) { windows.add(it) }
windower.push(floatArrayOf(1f, 2f))
windower.push(floatArrayOf(3f, 4f))
assertEquals(2, windows.size)
// Если бы отдавали внутренний буфер, первое окно уже было бы (3,4).
assertEquals(listOf(1f, 2f), windows[0].toList())
assertEquals(listOf(3f, 4f), windows[1].toList())
}
@Test
fun `windower accepts a large single push`() {
val windows = mutableListOf<FloatArray>()
val windower = PcmWindower(windowSize = 512) { windows.add(it) }
windower.push(FloatArray(1600) { it.toFloat() })
assertEquals(3, windows.size)
assertEquals(1600 - 3 * 512, windower.pending)
assertEquals(512, windows[0].size)
assertEquals(0f, windows[0][0])
assertEquals(511f, windows[0][511])
// Второе окно начинается там, где кончилось первое.
assertEquals(512f, windows[1][0])
}
@Test
fun `empty push does nothing`() {
val windows = mutableListOf<FloatArray>()
val windower = PcmWindower(windowSize = 4) { windows.add(it) }
windower.push(FloatArray(0))
assertTrue(windows.isEmpty())
assertEquals(0, windower.pending)
}
@Test
fun `vad model reports low probability for silence`() {
// Реальная загрузка silero_vad.onnx из артефакта `vad-jvm` (нативная
// onnxruntime). Тишина не должна выглядеть речью.
VadInstance(VOICE_SAMPLE_RATE).use { vad ->
assertEquals(512, vad.windowSizeSamples)
val silenceProbability = vad.processingWindow(FloatArray(vad.windowSizeSamples))
assertNotNull(silenceProbability)
assertTrue(
silenceProbability < VoskVoiceRecognizer.VAD_SPEECH_THRESHOLD,
"тишина дала вероятность речи $silenceProbability",
)
}
}
}
@@ -0,0 +1,95 @@
package pw.binom.agentik.desktop.voice
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.ui.renderMarkdownSegments
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Настоящая речь через весь продуктовый путь, в том порядке, в каком это
* происходит в приложении:
*
* 1. [VoiceNative.warmUp] — нативный ASR (Vosk + ONNX VAD) прогревается ДО skia;
* 2. инициализируется skiko (оффскрин-рендер Compose с текстом);
* 3. создаётся **новый** [VoskVoiceRecognizer] (свежая модель Vosk + свежий VAD)
* уже после skia и распознаёт реальный русский сэмпл.
*
* Шаг 3 — это ровно то, что делает кнопка микрофона в приложении (каждая запись
* создаёт свой распознаватель). Без шага 1 он валит JVM с SIGSEGV: см.
* [VoiceNative]. Реплика в `raz-dva-tri.wav` — «раз два три четыре пять»
* (16 кГц, моно, s16le).
*/
class RealSpeechIT {
@OptIn(ExperimentalTestApi::class)
@Test
fun `speech is recognized after the compose window has been initialized`() {
// 1. как в main(): прогреваем нативный ASR до skiko.
assertTrue(VoiceNative.warmUp(), "нативный ASR должен прогреться до skiko")
assertTrue(VoiceNative.available)
// 2. skiko/Compose инициализируется и реально рисует (как окно приложения).
runComposeUiTest {
setContent {
AgentikTheme("dark") {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble)) {
renderMarkdownSegments(
content = "проверка: **окно** уже отрисовано",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
)
}
}
}
}
// 3. обычный путь распознавания — уже после skiko.
val recognizer = VoskVoiceRecognizer()
try {
val pcm = loadPcm("/voice/raz-dva-tri.wav")
assertTrue(pcm.size > VOICE_SAMPLE_RATE, "сэмпл должен быть длиннее секунды")
var offset = 0
val chunk = VOICE_SAMPLE_RATE / 10 * 2
var maxPartial = ""
while (offset < pcm.size) {
val n = minOf(chunk, pcm.size - offset)
recognizer.feed(pcm.copyOfRange(offset, offset + n))
offset += n
recognizer.partial()?.let { if (it.length > maxPartial.length) maxPartial = it }
}
val final = recognizer.finish()
assertTrue(recognizer.speechDetected, "VAD должен увидеть речь (partials: '$maxPartial')")
assertTrue(
final.contains("раз") && final.contains("пять"),
"ожидали услышать «раз два три четыре пять», получили: '$final' (partials: '$maxPartial')",
)
} finally {
recognizer.close()
}
}
/** PCM-данные из WAV: пропускаем RIFF-заголовок до чанка `data`. */
private fun loadPcm(resource: String): ByteArray {
val bytes = javaClass.getResourceAsStream(resource)!!.readBytes()
var i = 12
while (i + 8 <= bytes.size) {
val id = String(bytes, i, 4, Charsets.US_ASCII)
val size = (bytes[i + 4].toInt() and 0xFF) or
((bytes[i + 5].toInt() and 0xFF) shl 8) or
((bytes[i + 6].toInt() and 0xFF) shl 16) or
((bytes[i + 7].toInt() and 0xFF) shl 24)
if (id == "data") return bytes.copyOfRange(i + 8, minOf(i + 8 + size, bytes.size))
i += 8 + size + (size and 1)
}
error("в $resource нет data-чанка")
}
}
@@ -0,0 +1,277 @@
package pw.binom.agentik.desktop.voice
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.fail
import kotlin.test.assertTrue
/**
* Состояния и колбэки [VoiceController] на фейковых источнике аудио и
* распознавателе — без микрофона и без нативных библиотек.
*
* Время настоящее (`runBlocking` + ожидание состояния), а не `runTest` с
* виртуальным: контроллер считает на выделенном потоке `Dispatchers.Default`.
*/
class VoiceControllerTest {
private val chunk = ByteArray(4) { it.toByte() }
private fun finiteMic(vararg chunks: ByteArray): MicSource =
MicSource { _, _ -> flow { chunks.forEach { emit(it) } } }
private fun endlessMic(chunk: ByteArray = ByteArray(4) { 1 }): MicSource = MicSource { _, _ ->
flow {
while (true) {
emit(chunk)
delay(5)
}
}
}
private class FakeRecognizer(
private val partials: List<String> = emptyList(),
private val finalText: String = "",
override val speechDetected: Boolean = true,
) : VoiceRecognizer {
val fed = mutableListOf<ByteArray>()
var finished = false
var closed = false
private var partialCalls = 0
override fun feed(pcm: ByteArray) {
fed += pcm
}
override fun partial(): String? {
if (partials.isEmpty()) return null
return partials[minOf(partialCalls++, partials.size - 1)]
}
override fun finish(): String {
finished = true
return finalText
}
override fun close() {
closed = true
}
}
private class Recorder {
val states = mutableListOf<VoiceState>()
val partials = mutableListOf<String>()
val finals = mutableListOf<String>()
val errors = mutableListOf<String>()
}
private fun controller(
scope: CoroutineScope,
mic: MicSource,
recognizer: VoiceRecognizer,
rec: Recorder,
) = VoiceController(
scope = scope,
recognizers = VoiceRecognizerFactory { recognizer },
mic = mic,
onState = { rec.states += it },
onPartial = { rec.partials += it },
onFinal = { rec.finals += it },
onError = { rec.errors += it },
)
@Test
fun `stop delivers final text and closes recognizer`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer(partials = listOf("при"), finalText = "привет")
val voice = controller(this, endlessMic(), recognizer, rec)
voice.start()
await("микрофон начал писать") { voice.state == VoiceState.RECORDING }
await("пришёл промежуточный текст") { rec.partials.isNotEmpty() }
assertEquals(listOf("при"), rec.partials)
voice.stop()
awaitWithDump("финал отдан", rec, voice) { rec.finals.isNotEmpty() }
awaitWithDump("вернулись в простой", rec, voice) { voice.state == VoiceState.IDLE }
assertEquals(listOf("привет"), rec.finals)
assertTrue(recognizer.finished, "finish() должен быть вызван")
assertTrue(recognizer.closed, "recognizer должен быть закрыт")
assertEquals(listOf(VoiceState.PREPARING, VoiceState.RECORDING, VoiceState.RECOGNIZING, VoiceState.IDLE), rec.states)
}
@Test
fun `cancelled recording emits nothing`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer(partials = listOf("при"), finalText = "привет")
val voice = controller(this, endlessMic(), recognizer, rec)
voice.start()
await("микрофон начал писать") { voice.state == VoiceState.RECORDING }
await("пришёл промежуточный текст") { rec.partials.isNotEmpty() }
voice.cancel()
awaitWithDump("вернулись в простой", rec, voice) { voice.state == VoiceState.IDLE }
delay(50)
assertTrue(rec.finals.isEmpty(), "после cancel финал не отдаётся: ${rec.finals}")
assertEquals(VoiceState.IDLE, voice.state)
}
@Test
fun `microphone ending on its own finishes the turn`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer(finalText = "конец")
val voice = controller(this, finiteMic(chunk, chunk), recognizer, rec)
voice.start()
awaitWithDump("финал отдан без stop()", rec, voice) { rec.finals.isNotEmpty() }
assertEquals(listOf("конец"), rec.finals)
assertEquals(VoiceState.IDLE, voice.state)
assertTrue(recognizer.closed)
}
@Test
fun `microphone chunks reach the recognizer`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer(finalText = "ок")
val voice = controller(this, finiteMic(chunk, chunk, chunk), recognizer, rec)
voice.start()
awaitWithDump("финал отдан", rec, voice) { rec.finals.isNotEmpty() }
assertEquals(3, recognizer.fed.size)
assertEquals(chunk.toList(), recognizer.fed[0].toList())
}
@Test
fun `empty chunks are skipped`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer(finalText = "ок")
val voice = controller(this, finiteMic(ByteArray(0), chunk, ByteArray(0)), recognizer, rec)
voice.start()
awaitWithDump("финал отдан", rec, voice) { rec.finals.isNotEmpty() }
assertEquals(1, recognizer.fed.size)
}
@Test
fun `silence is not turned into text`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer(finalText = "", speechDetected = false)
val voice = controller(this, finiteMic(chunk, chunk), recognizer, rec)
voice.start()
awaitWithDump("ошибка про тишину", rec, voice) { rec.errors.isNotEmpty() }
assertEquals(listOf("Речь не распознана"), rec.errors)
assertTrue(rec.finals.isEmpty())
assertEquals(VoiceState.IDLE, voice.state)
}
@Test
fun `empty final with speech detected is silently dropped`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer(finalText = " ", speechDetected = true)
val voice = controller(this, finiteMic(chunk), recognizer, rec)
voice.start()
awaitWithDump("вернулись в простой", rec, voice) { voice.state == VoiceState.IDLE && recognizer.finished }
delay(50)
assertTrue(rec.finals.isEmpty())
assertTrue(rec.errors.isEmpty(), "речь была, но текста нет — это не ошибка: ${rec.errors}")
}
@Test
fun `microphone failure surfaces as error`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer()
val mic = MicSource { _, _ ->
flow<ByteArray> { throw IllegalStateException("no line") }
}
val voice = controller(this, mic, recognizer, rec)
voice.start()
awaitWithDump("ошибка микрофона", rec, voice) { rec.errors.isNotEmpty() }
assertEquals(listOf("Микрофон недоступен: no line"), rec.errors)
assertEquals(VoiceState.IDLE, voice.state)
assertTrue(recognizer.closed, "распознаватель закрывается и при ошибке микрофона")
}
@Test
fun `recognizer creation failure surfaces as error`() = runBlocking {
val rec = Recorder()
val voice = VoiceController(
scope = this,
recognizers = VoiceRecognizerFactory { throw IllegalStateException("model not found") },
mic = finiteMic(chunk),
onState = { rec.states += it },
onError = { rec.errors += it },
)
voice.start()
await("ошибка создания") { rec.errors.isNotEmpty() }
assertEquals(listOf("Не удалось запустить распознавание: model not found"), rec.errors)
assertEquals(VoiceState.IDLE, voice.state)
}
@Test
fun `toggle starts then stops`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer(finalText = "готово")
val voice = controller(this, endlessMic(), recognizer, rec)
voice.toggle()
await("пишем") { voice.state == VoiceState.RECORDING }
assertTrue(voice.isActive)
voice.toggle()
awaitWithDump("финал", rec, voice) { rec.finals.isNotEmpty() }
assertTrue(rec.finals.contains("готово"))
}
@Test
fun `stop before start does nothing`() = runBlocking {
val rec = Recorder()
val voice = controller(this, endlessMic(), FakeRecognizer(), rec)
voice.stop()
voice.cancel()
delay(30)
assertEquals(VoiceState.IDLE, voice.state)
assertTrue(rec.states.isEmpty(), "никаких переходов не ожидается: ${rec.states}")
}
/** Ожидание условия с реальным временем (см. заметку про ksqlite/Dispatchers). */
private suspend fun await(what: String, timeoutMs: Long = 5_000, condition: () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (condition()) return
delay(5)
}
fail("не дождались: $what")
}
/** То же, но с дампом состояния — чтобы падение было диагностируемым. */
private suspend fun awaitWithDump(what: String, rec: Recorder, voice: VoiceController, condition: () -> Boolean) {
val deadline = System.currentTimeMillis() + 5_000
while (System.currentTimeMillis() < deadline) {
if (condition()) return
delay(5)
}
fail(
"не дождались: $what; state=${voice.state} active=${voice.isActive} " +
"states=${rec.states} partials=${rec.partials} finals=${rec.finals} errors=${rec.errors}"
)
}
}
@@ -0,0 +1,36 @@
package pw.binom.agentik.desktop.voice
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
/**
* Настоящий тракт: модель Vosk из артефакта `asr-vosk-jvm` + Silero VAD из
* `vad-jvm`. Проверяем, что модели находятся, распаковываются и работают, а
* тишина не даёт текста.
*
* Один тест на класс: создание распознавателя грузит нативные модели
* (первый запуск распаковывает zip 46 МБ в tmp), а ONNX-сессия VAD не терпит
* повторного `close()` — поэтому никаких `use {}` вокруг общих объектов.
*/
class VoskVoiceRecognizerIT {
@Test
fun `silence produces no text and no speech detection`() {
val recognizer = VoskVoiceRecognizer()
try {
// 1 секунда тишины чанками по 100 мс — ровно как от микрофона.
repeat(10) {
recognizer.feed(ByteArray(VOICE_SAMPLE_RATE / 10 * 2))
}
// Промежуточного текста на тишине нет…
assertEquals(null, recognizer.partial())
assertFalse(recognizer.speechDetected, "тишина не должна детектиться как речь")
// …и финал пустой.
assertEquals("", recognizer.finish())
} finally {
recognizer.close()
}
}
}
Binary file not shown.