Compare commits
3 Commits
093115c415
...
2c71542b13
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c71542b13 | |||
| 27f349550c | |||
| 960cd60120 |
+30
@@ -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/
|
||||
@@ -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/*` |
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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`) — закрывать ровно один раз.
|
||||
|
||||
|
||||
@@ -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. Где показывать несколько агентов в одном списке диалогов (сейчас список —
|
||||
у активного агента).
|
||||
|
||||
@@ -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`), иначе не читается.
|
||||
|
||||
## Что осталось решить
|
||||
|
||||
- **Какие темы будут кроме тёмной.** Пока известно только: тёмная есть, светлая
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
**Главное, что говорит окно: диалоги не удалятся.** Группа исчезнет из ленты,
|
||||
диалоги останутся и будут видны в «Все чаты». Это первое, что написано
|
||||
в предупреждении, — потому что именно этого боятся, нажимая «удалить».
|
||||
|
||||
Отдельный вопрос — **что сделать с диалогами**: оставить без группы (вернутся
|
||||
в «Все чаты»). Другого пути нет: диалог обязан быть где-то виден, «корзины»
|
||||
у диалогов не существует. Так и написано в окне — чтобы не выглядело, будто
|
||||
выбор есть, а его нет.
|
||||
|
||||
Кнопка подтверждения — **красная залитая**, в тексте прямо «Удалить группу».
|
||||
|
||||
---
|
||||
|
||||
## Что решено и почему
|
||||
|
||||
- **Карандаш вместо «+».** Иначе две кнопки подряд означают «создать» — и в шапке
|
||||
списка, и в ленте групп.
|
||||
- **«Все чаты» не удаляется.** Нужна точка опоры, где собран весь список.
|
||||
- **Диалоги не удаляются вместе с группой.** Иначе одно нажатие стирает работу.
|
||||
- **Группа без диалогов допустима.** Создать заранее — нормальный сценарий.
|
||||
- **Никаких настроек у группы, кроме названия.** Цвет, значок, порядок — не
|
||||
просили, не делаем.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- **Куда диалог кладётся при создании** — в текущую открытую группу или без
|
||||
группы? Сейчас в макете не показано.
|
||||
- **Можно ли диалог быть в нескольких группах** (как ярлык в почте) или только
|
||||
в одной? От этого зависит, что писать в удалении.
|
||||
- **Порядок групп** — по алфавиту, по времени создания или перетаскиванием?
|
||||
- **Что если удалить группу, в которой открыт диалог.** Сейчас не показано.
|
||||
@@ -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` — четыре файла, открывать по одному, по состоянию.
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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")
|
||||
@@ -1,25 +0,0 @@
|
||||
# Вариант 2 — Rail / Voice-first
|
||||
|
||||
## Суть
|
||||
Голос — главное действие, а не дополнение к клавиатуре. Огромная кнопка микрофона
|
||||
слева в панели ввода, живая расшифровка на экране, пока говоришь.
|
||||
|
||||
## Ключевые решения
|
||||
- **Раскладка:** рейка иконок (76) + диалоги (296) + чат. Рейка — вход в память агента
|
||||
и инструменты, это не просто мессенджер.
|
||||
- **Голос:** кнопка 58 px, при записи краснеет и пульсирует. Над полем раскрывается
|
||||
панель: волна, таймер и **текст, который появляется по мере речи**.
|
||||
- **Три выхода из записи:** «Отправить» / «В поле ввода» (поправить руками) / «Отменить».
|
||||
Голосовое сообщение в ленте рисуется с волной и кнопкой проигрывания.
|
||||
- **Горячие клавиши:** Ctrl+R — запись, удержание пробела — говорить, Esc — отмена.
|
||||
|
||||
## Что хорошо
|
||||
- Голосом пользоваться реально удобно: видно, что тебя услышали, до отправки.
|
||||
- Ошибку в тексте видно до отправки — можно поправить.
|
||||
|
||||
## Что слабо
|
||||
- Больше элементов на экране, надо разбираться.
|
||||
- «Пробел на удержание» конфликтует с обычным набором текста — нужна аккуратность.
|
||||
|
||||
## Кому
|
||||
Тому, кто в основном диктует, а не печатает.
|
||||
@@ -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>
|
||||
@@ -1,25 +0,0 @@
|
||||
# Вариант 3 — Three-pane / Command center
|
||||
|
||||
## Суть
|
||||
Три панели: диалоги, чат и **состояние агента**. Работа агента видна не как текст
|
||||
в переписке, а как отдельные раскрывающиеся шаги.
|
||||
|
||||
## Ключевые решения
|
||||
- **Раскладка:** 268 + центр + 306. Третья панель — модель, контекст, инструменты,
|
||||
память агента, файлы сессии.
|
||||
- **Шаг агента** — сворачиваемый блок: что делал, сколько заняло, детали по клику.
|
||||
Это то, чем агент отличается от собеседника, и здесь оно вынесено в структуру.
|
||||
- **Кнопка записи:** компактная рядом с «Отправить»; при записи снизу появляется
|
||||
строка: квадрат «стоп», индикатор уровня, текущая расшифровка, таймер.
|
||||
- **Короткая диктовка** (от 2 с) уходит сразу, текст остаётся в поле.
|
||||
|
||||
## Что хорошо
|
||||
- Видно, что агент делает прямо сейчас и сколько это стоит.
|
||||
- Удобно на широком экране, ничего не надо прятать.
|
||||
|
||||
## Что слабо
|
||||
- На узком экране (ноутбук 1366) третью панель придётся убирать.
|
||||
- Настройки сессии — не то, на что хочется смотреть постоянно.
|
||||
|
||||
## Кому
|
||||
Тому, кто держит агента включённым весь день и следит за расходом.
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
## Плюс: список диалогов с двумя агентами
|
||||
Диалоги сгруппированы по агентам, у группы цветная точка, у каждой строки цветная
|
||||
полоска, цвет аватара совпадает с цветом агента из настроек.
|
||||
|
||||
## Что слабо / открытые вопросы
|
||||
- Одного цвета для различения может оказаться мало.
|
||||
- Где хранится список агентов — не решено.
|
||||
- Пароля/ключа доступа у библиотеки нет — поля для него нет.
|
||||
@@ -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>
|
||||
@@ -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`), окно и затемнение устроены так же. Ничего нового не изобреталось.
|
||||
@@ -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>
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
+458
@@ -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("""""")
|
||||
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("""""")
|
||||
val image = segments.filterIsInstance<MarkdownSegment.Image>().single()
|
||||
assertEquals("http://example.com/img.png", image.src)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `текст вокруг картинки не теряется`() {
|
||||
val segments = parseMarkdown("""текст до  текст после""")
|
||||
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))
|
||||
}
|
||||
}
|
||||
+243
@@ -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.
Reference in New Issue
Block a user