27f349550c
- sketches/index.html: все экраны одним документом в порядке 1·… (14–19 — бывшие approved) - approved/ (4 файла групп + 3 макета + README) удалён как дубль - стили вынесены в sketches/style.css - README, THEMES, BORROW-FROM-ASSISTENT, MARKDOWN-SOURCE, MIC-ASR-SEARCH приведены к фактическому состоянию
356 lines
17 KiB
Markdown
356 lines
17 KiB
Markdown
# Что берём из `ai/assistent`, а что не берём
|
||
|
||
**Правило:** из `ai/assistent` берём **только приёмы отрисовки, плагины и версии**.
|
||
Архитектуру, слои, окна и дизайн — **не берём**, пишем своё. Дизайн у нас свой,
|
||
нарисованный (см. `sketches/`), и он не должен наследовать чужую структуру.
|
||
|
||
**Репозиторий:** https://git.binom.pw/ai/assistent, ветка `main`, коммит `2c5b3f9`
|
||
**RAW-адреса** (нужен логин, репозиторий приватный): `https://git.binom.pw/ai/assistent/raw/branch/main/<путь>`
|
||
|
||
---
|
||
|
||
# ЧАСТЬ 1. ЧТО БЕРЁМ
|
||
|
||
## 1. Плагины и версии
|
||
|
||
Из `gradle/libs.versions.toml` и `gradle/wrapper/` репозитория assistent:
|
||
|
||
| Что | Версия |
|
||
|---|---|
|
||
| Kotlin | `2.4.0` |
|
||
| Compose Multiplatform | `1.7.3` |
|
||
| kotlinx-serialization | `1.11.0` |
|
||
| kotlinx-coroutines | `1.11.0` |
|
||
| Ktor | `3.1.2` |
|
||
| kotlin-logging | `7.0.3` |
|
||
| ktlint | `14.2.0` |
|
||
| JUnit | `5.10.2` |
|
||
| Mockito | `5.12.0` |
|
||
| log4j | `2.24.3` |
|
||
| Gradle | `9.4.1` |
|
||
|
||
**Плагины** (модуль десктоп-клиента, `client/build.gradle.kts`):
|
||
|
||
```kotlin
|
||
plugins {
|
||
alias(libs.plugins.kotlin.jvm)
|
||
alias(libs.plugins.kotlin.compose)
|
||
alias(libs.plugins.kotlin.serialization)
|
||
alias(libs.plugins.compose)
|
||
alias(libs.plugins.ktlint)
|
||
}
|
||
```
|
||
|
||
**Зависимости Compose для десктопа:**
|
||
|
||
```kotlin
|
||
implementation(compose.desktop.currentOs)
|
||
implementation(compose.material3)
|
||
implementation(compose.materialIconsExtended)
|
||
implementation(libs.kotlinx.coroutines.swing) // ВАЖНО: Dispatchers.Main на десктопе = Swing EDT
|
||
```
|
||
|
||
**Репозитории** (без них не соберётся):
|
||
|
||
```kotlin
|
||
mavenCentral()
|
||
mavenLocal()
|
||
maven {
|
||
url = uri("http://192.168.76.117/repository/developerspace-prod-mvn-hosted/")
|
||
isAllowInsecureProtocol = true
|
||
}
|
||
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||
maven("https://maven.google.com")
|
||
```
|
||
|
||
**Точка входа приложения:**
|
||
|
||
```kotlin
|
||
compose.desktop {
|
||
application {
|
||
mainClass = "<наш пакет>.MainKt"
|
||
}
|
||
}
|
||
```
|
||
|
||
> ⚠️ **Не путать:** в самом agentik версия Kotlin `2.4.20`, а в assistent `2.4.0`.
|
||
> Ориентироваться на версию нашего проекта agentik, а не копировать слепо.
|
||
|
||
## 2. Отрисовка Markdown
|
||
|
||
**Уже скопировано в проект** (28.09.2026): 6 файлов + 11 тестов парсера, пакеты
|
||
переименованы под `pw.binom.agentik.desktop.*`. Подробности, карта «источник →
|
||
назначение», зависимости и как пересинхронизировать — в **`MARKDOWN-SOURCE.md`**.
|
||
|
||
## 3. Приёмы отрисовки (самое ценное)
|
||
|
||
Здесь чужие грабли, уже набитые шишки. Каждый пункт — из кода assistent, с указанием места.
|
||
|
||
### 3.1. Таблица и бесконечная ширина — Compose падает
|
||
|
||
**Где:** `client-shared/.../ui/MarkdownRendering.kt`, функция `renderTable`, строки ~170–258.
|
||
|
||
Нельзя класть `Box(horizontalScroll)` внутрь контейнера с бесконечной максимальной
|
||
шириной (`wrapContentWidth`) — Compose падает с ошибкой
|
||
`measured with an infinity maximum width`. Из комментария в коде:
|
||
|
||
- если внешний скролл уже есть — таблица растёт по содержимому (`wrapContentWidth`),
|
||
а широкие колонки прокручивает **внешний** скролл;
|
||
- если внешнего нет — скролл добавляется **внутрь** таблицы (`fillMaxWidth` + `horizontalScroll`).
|
||
|
||
Приём: **параметр `isBubbleScrolled`**, которым таблица переключается между двумя
|
||
режимами, вместо одной универсальной раскладки.
|
||
|
||
### 3.2. `derivedStateOf` создаёт гонку — читать напрямую
|
||
|
||
**Где:** `client/.../ui/ChatScreen.kt`, строки ~1085–1092.
|
||
|
||
Из комментария: производное состояние, созданное при рекомпозиции, читается
|
||
observer'ом во время фоновых изменений списков — известная гонка Compose
|
||
`state created after snapshot was taken`.
|
||
|
||
**Приём:** вместо `derivedStateOf { listState.canScrollForward }` писать просто
|
||
|
||
```kotlin
|
||
val isNotAtBottom = listState.canScrollForward
|
||
```
|
||
|
||
Прямое чтение убирает `DerivedSnapshotState` и гонку вместе с ним.
|
||
|
||
### 3.3. Ленивый список: `getOrNull` + стабильный ключ
|
||
|
||
**Где:** `client/.../ui/ChatScreen.kt`, строки ~1030–1043.
|
||
|
||
Список сообщений меняется фоном (подгрузка истории, новые сообщения), поэтому
|
||
прямой доступ по индексу может выйти за границы и уронить композицию.
|
||
|
||
```kotlin
|
||
items(
|
||
count = messages.size,
|
||
key = { index -> messages.getOrNull(index)?.id ?: "pos:$index" },
|
||
) { index ->
|
||
val msg = messages.getOrNull(index) ?: return@items
|
||
...
|
||
}
|
||
```
|
||
|
||
**Приём:** `count` + `key` + `getOrNull` — вместо `items(list)`. Ключ стабилен при
|
||
изменении списка, лишних перерисовок нет.
|
||
|
||
### 3.4. Автоскролл вниз при новых сообщениях
|
||
|
||
**Где:** `client/.../ui/ChatScreen.kt`, строки ~861–868.
|
||
|
||
```kotlin
|
||
val listState = rememberLazyListState()
|
||
LaunchedEffect(messages.size, isThinking, isTranscribing.value) {
|
||
if (messages.isNotEmpty()) listState.scrollToItem(messages.size - 1)
|
||
}
|
||
```
|
||
|
||
**Приём:** ключи `LaunchedEffect` — то, при изменении чего прокручивать. Плюс
|
||
«печатает» и «запись голоса» в ключах: прокрутка случается и при появлении
|
||
индикатора, а не только при новом сообщении. Для нас то же: ответ течёт потоком.
|
||
|
||
### 3.5. Кнопка «прокрутить вниз» — плавно и без гонки
|
||
|
||
**Где:** там же, строки ~1085–1110.
|
||
|
||
```kotlin
|
||
val isNotAtBottom = listState.canScrollForward
|
||
val buttonAlpha by animateFloatAsState(
|
||
targetValue = if (isNotAtBottom) 1f else 0f,
|
||
animationSpec = tween(200),
|
||
label = "scrollBtnAlpha",
|
||
)
|
||
```
|
||
|
||
Прокрутка — в корутине: `scope.launch { listState.animateScrollToItem(size - 1) }`.
|
||
Кнопка проявляется/исчезает плавно, а не мигает.
|
||
|
||
### 3.6. Полоса прокрутки, видимая по наведению
|
||
|
||
**Где:** там же, строки ~1062–1086.
|
||
|
||
```kotlin
|
||
VerticalScrollbar(
|
||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight()
|
||
.alpha(msgScrollbarAlpha).width(8.dp),
|
||
adapter = rememberScrollbarAdapter(scrollState = listState),
|
||
style = ScrollbarStyle(minimalHeight = 30.dp, thickness = 8.dp,
|
||
shape = RoundedCornerShape(4.dp), hoverDurationMillis = 150),
|
||
)
|
||
```
|
||
|
||
**Приём:** `rememberScrollbarAdapter` привязывает полосу к ленивому списку; ширина
|
||
8 dp и появление по наведению — не мозолит глаза.
|
||
|
||
### 3.7. Кликабельные ссылки внутри текста
|
||
|
||
**Где:** `client-shared/.../ui/MarkdownRendering.kt`, строки ~301–336.
|
||
|
||
Приём: `pointerInput` + `detectTapGestures` даёт координаты нажатия;
|
||
`onTextLayout` сохраняет `textLayoutResult`; `getOffsetForPosition(offset)` превращает
|
||
координаты в позицию в тексте; по позиции ищем аннотацию (ссылка или код) и
|
||
открываем её.
|
||
|
||
```kotlin
|
||
Modifier
|
||
.pointerInput(annotatedString) {
|
||
detectTapGestures { offset ->
|
||
val layout = textLayoutResult ?: return@detectTapGestures
|
||
val position = layout.getOffsetForPosition(offset)
|
||
... // найти аннотацию по position
|
||
}
|
||
}
|
||
```
|
||
|
||
**Зачем нам:** ссылки в ответах агента должны открываться в браузере, а не быть
|
||
просто текстом.
|
||
|
||
### 3.8. Копирование блока кода по кнопке
|
||
|
||
**Где:** там же, строки ~90–125.
|
||
|
||
```kotlin
|
||
val clipboardManager = LocalClipboardManager.current
|
||
...
|
||
clipboardManager.setText(AnnotatedString(segment.code))
|
||
```
|
||
|
||
**Приём:** `LocalClipboardManager` работает и на десктопе, отдельная библиотека не нужна.
|
||
|
||
### 3.9. Выделение текста мышью
|
||
|
||
`SelectionContainer` вокруг сообщения — иначе текст не выделить, а для агента это
|
||
нужно постоянно.
|
||
|
||
### 3.10. Образец темы: ценный, но повторять НЕ всё
|
||
|
||
**Где:** `client-shared/src/main/kotlin/pw/binom/client/shared/ui/FolderTheme.kt`.
|
||
|
||
Там есть готовый образец, **очень близкий к тому, что нужно нам**:
|
||
|
||
```kotlin
|
||
data class ClientTheme(
|
||
val name: String,
|
||
val colorScheme: ColorScheme,
|
||
val folderIconColor: Color,
|
||
val unreadBadgeColor: Color,
|
||
// ...
|
||
)
|
||
|
||
val DarkClientTheme = ClientTheme(
|
||
name = "dark",
|
||
colorScheme = darkColorScheme(
|
||
primary = Color(0xFF6AB2F2), onPrimary = Color.White,
|
||
surface = Color(0xFF1A1A2E), onSurface = Color(0xFFEBEBEB),
|
||
background = Color(0xFF121218), onBackground = Color(0xFFE0E0E0),
|
||
error = Color(0xFFCF6679),
|
||
// ...
|
||
),
|
||
// ...
|
||
)
|
||
```
|
||
|
||
**Что тут ценно — и почти совпадает с нашим решением:**
|
||
|
||
- тема **уже описана отдельным объектом**, а не размазана по экранам;
|
||
- в комментарии к коду прямо сказано: «Сейчас — одна тёмная тема; **в будущем —
|
||
переключатель тем (светлая/тёмная)**». То есть к тому же и шли;
|
||
- значения цветов объявлены **единым источником правды для всех платформ**
|
||
(десктоп и Android) — ровно наш случай, десктоп как образец для Android;
|
||
- кроме цветов схемы в теме есть **именованные значения для смыслов**
|
||
(`unreadBadgeColor`, `folderIconColor`) — то, к чему мы и хотим прийти.
|
||
|
||
**Что повторять НЕ надо:** в остальном коде assistent цвета всё равно вписаны
|
||
числами — **36 штук** (`Color(0xFF4CAF50)`, `Color(0xFFF131327)` и подобные) прямо
|
||
в экранах. То есть theme есть, а половина цветов мимо неё. Плюс `folderIconColor`
|
||
и подобные значения заданы как `Color(0xFF768C9E)` **внутри** объекта темы — это
|
||
правильное место, но при второй теме их придётся задавать заново руками.
|
||
|
||
**Наш вывод:** берём **идею** (тема = объект, имена по смыслу, единый источник для
|
||
платформ), но **делаем строже**: ни одного цвета числом вне самой схемы, все
|
||
именованные значения — в теме, и всё проверяется на второй теме. Подробно — `THEMES.md`.
|
||
|
||
### 3.11. Сборка стилей текста
|
||
|
||
**Где:** там же, `MarkdownSegment.Text.toAnnotatedString`, строки ~341–377.
|
||
|
||
`AnnotatedString.Builder` + построение `SpanStyle` из простого описания
|
||
(`bold`/`italic`/`code`/`strikethrough`/`link`). Логику стилей держат в данных, а не
|
||
в Композе — удобно и переносимо.
|
||
|
||
## 4. Запись микрофона — код из assistent брать НЕ надо
|
||
|
||
**Где:** `client/src/main/kotlin/pw/binom/client/audio/MicrophoneRecorder.kt` (83 строки).
|
||
Всё на стандартной Java (`javax.sound.sampled`: `AudioFormat`, `AudioSystem`,
|
||
`DataLine`, `TargetDataLine`), формат PCM 16 бит / 16 кГц / моно, буфер 4096 байт.
|
||
|
||
**Но копировать это не нужно.** У нас есть своя готовая библиотека **`mic-kmp`**
|
||
(опубликована, версия `1.0.0`), которая делает то же самое, только лучше:
|
||
отмена структурная — микрофон освобождается сам через `finally`, без ручного `close()`.
|
||
|
||
Здесь файл полезен **только как источник приёмов**:
|
||
|
||
- подтверждает формат, который ждёт распознавание: **16 кГц, моно, PCM 16 бит, little-endian**
|
||
(`AudioFormat.Encoding.PCM_SIGNED, sampleRate, 16, 1, 2, sampleRate, false`);
|
||
- показывает, что сторонние библиотеки для звука не нужны — хватает стандартной Java;
|
||
- отдаёт данные кусками (`ReceiveChannel<ByteArray>`), а не файлом — можно стримить.
|
||
|
||
Подробности и координаты библиотек — `MIC-ASR-SEARCH.md`.
|
||
|
||
---
|
||
|
||
# ЧАСТЬ 2. ЧТО НЕ БЕРЁМ
|
||
|
||
## Архитектура и слои — пишем своё
|
||
|
||
Не копировать:
|
||
|
||
- **`client/src/main/kotlin/pw/binom/client/`** целиком — это чужое приложение:
|
||
`Main.kt`, `viewmodel/ChatViewModel.kt`, `config/AppSettings.kt`, `config/ClientConfig.kt`,
|
||
`cache/*`.
|
||
- **`client/.../ui/ChatScreen.kt`** (1903 строки) — экран чата целиком. Оттуда годятся
|
||
только перечисленные выше приёмы, а не структура.
|
||
- **`ui/SettingsDialog.kt`** (301) — чужое окно настроек. У нас свой дизайн настроек
|
||
(`approved/004-settings-agents.html`).
|
||
- **`ui/NewChatDialog.kt`** (616), **`ui/ParticipantsDialog.kt`** (223) — чужое.
|
||
- **`ui/MessageBubble.kt`** (789) — пузырь сообщения; тянет `chat-common` и чужие модели.
|
||
- **Цвета числами в экранах** — в assistent их 36 штук `Color(0xFF…)` прямо в
|
||
`ChatScreen` и соседних файлах. Тема там есть, но обходится мимо. Нам нужны
|
||
цвета строго из схемы, см. `THEMES.md`.
|
||
- **Модули `chat-*`** (`chat-common`, `chat-client`, `chat-cache`, `chat-adapter`,
|
||
`chat-server-client`) — чужая система чатов. У нас связь с агентом идёт через
|
||
**`pw.binom.agentik:client`**, совсем другой протокол.
|
||
|
||
**Почему не берём:** у нас другая модель — один клиент к нескольким агентам agentik,
|
||
а не чат-сервер с комнатами, участниками и адаптерами. Чужая структура тут только
|
||
запутает.
|
||
|
||
## Что у нас своё (не из assistent)
|
||
|
||
- Раскладка и дизайн — `approved/001-sidebar-utility.html` (основа) и
|
||
`sketches/002`…`005` (остальное).
|
||
- Различение агентов цветом или картинкой.
|
||
- Проверка связи с агентом отдельным окном.
|
||
- Поведение узкого окна (список ↔ чат с кнопкой «назад»).
|
||
- Показ хода работы агента (свёрнутые шаги).
|
||
- Хранение списка агентов.
|
||
|
||
---
|
||
|
||
# Итог одной таблицей
|
||
|
||
| Берём | Не берём |
|
||
|---|---|
|
||
| Версии и плагины, репозитории | Архитектуру и слои |
|
||
| Отрисовку Markdown (6 файлов, уже скопированы) | Экраны целиком (`ChatScreen`, `SettingsDialog`, …) |
|
||
| Приёмы: таблицы, ленивый список, автоскролл, полоса прокрутки | Модули `chat-*` и модели сообщений |
|
||
| Кликабельные ссылки, копирование кода, выделение текста | `MessageBubble` |
|
||
| Приём записи звука (формат 16к/моно, `javax.sound`) | `ChatViewModel`, `config/*`, `cache/*` |
|
||
| Зависимость `ru.otpbank.ai:markdown:0.51.0` | Чужой дизайн и окна |
|
||
|
||
**Микрофон и распознавание — не из assistent.** У нас для этого есть свои
|
||
опубликованные библиотеки: `mic-kmp`, `asr-kmp`, `vad-kmp`. Разбор — `MIC-ASR-SEARCH.md`.
|