BORROW-FROM-ASSISTENT.md: что берём (приёмы, плагины, версии), что не берём (архитектура)

This commit is contained in:
Porfiry
2026-09-19 01:36:50 +03:00
parent 10cf44d2e2
commit 2b65baa139
4 changed files with 317 additions and 0 deletions
+299
View File
@@ -0,0 +1,299 @@
# Что берём из `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
Отдельный документ: **`MARKDOWN-SOURCE.md`** — 4 файла, разобрано подробно.
## 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. Сборка стилей текста
**Где:** там же, `MarkdownSegment.Text.toAnnotatedString`, строки ~341–377.
`AnnotatedString.Builder` + построение `SpanStyle` из простого описания
(`bold`/`italic`/`code`/`strikethrough`/`link`). Логику стилей держат в данных, а не
в Композе — удобно и переносимо.
## 4. Запись микрофона на десктопе — без единой библиотеки
**Где:** `client/src/main/kotlin/pw/binom/client/audio/MicrophoneRecorder.kt` (83 строки).
Ценнейшая находка для нашей кнопки записи. Всё на стандартной Java:
```kotlin
import javax.sound.sampled.AudioFormat
import javax.sound.sampled.AudioSystem
import javax.sound.sampled.DataLine
import javax.sound.sampled.TargetDataLine
```
Формат: **PCM 16 бит, 16 кГц, моно, little-endian**, буфер 4096 байт.
Наружу отдаёт `ReceiveChannel<ByteArray>` — чанки сразу уходят на распознавание,
без накопления файла. Микрофон — `AudioSystem.getLine(...) as TargetDataLine`.
**Почему важно:** сторонние библиотеки для звука не нужны вообще. Мы это
собирались решать позже, но приём уже есть и он рабочий.
---
# ЧАСТЬ 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) — чужое окно настроек. У нас свой дизайн настроек
(`sketches/004-settings-agents`).
- **`ui/NewChatDialog.kt`** (616), **`ui/ParticipantsDialog.kt`** (223) — чужое.
- **`ui/MessageBubble.kt`** (789) — пузырь сообщения; тянет `chat-common` и чужие модели.
- **Модули `chat-*`** (`chat-common`, `chat-client`, `chat-cache`, `chat-adapter`,
`chat-server-client`) — чужая система чатов. У нас связь с агентом идёт через
**`pw.binom.agentik:client`**, совсем другой протокол.
**Почему не берём:** у нас другая модель — один клиент к нескольким агентам agentik,
а не чат-сервер с комнатами, участниками и адаптерами. Чужая структура тут только
запутает.
## Что у нас своё (не из assistent)
- Раскладка и дизайн — `sketches/001`…`004`.
- Различение агентов цветом или картинкой.
- Проверка связи с агентом отдельным окном.
- Поведение узкого окна (список ↔ чат с кнопкой «назад»).
- Показ хода работы агента (свёрнутые шаги).
- Хранение списка агентов.
---
# Итог одной таблицей
| Берём | Не берём |
|---|---|
| Версии и плагины, репозитории | Архитектуру и слои |
| Отрисовку Markdown (4 файла) | Экраны целиком (`ChatScreen`, `SettingsDialog`, …) |
| Приёмы: таблицы, ленивый список, автоскролл, полоса прокрутки | Модули `chat-*` и модели сообщений |
| Кликабельные ссылки, копирование кода, выделение текста | `MessageBubble` |
| Запись микрофона через `javax.sound` | `ChatViewModel`, `config/*`, `cache/*` |
| Зависимость `ru.otpbank.ai:markdown:0.51.0` | Чужой дизайн и окна |
+4
View File
@@ -65,6 +65,10 @@ Compose Desktop, так что пример использования ровн
из плюшек — выделение текста и контекстное меню. Для отрисовки разметки не нужен,
но полезен, если понадобится пример.
**Границы заимствования — подробнее в `BORROW-FROM-ASSISTENT.md`.** Коротко:
из assistent берём приёмы отрисовки, плагины и версии; архитектуру, слои и готовые
экраны (`ChatScreen`, `SettingsDialog`, модули `chat-*`) — не берём, у нас своё.
---
## Зависимости
+9
View File
@@ -15,6 +15,8 @@ sketches/003-three-pane-command/index.html # три панели + состо
sketches/004-settings-agents/index.html # настройки агентов + проверка связи
```
`BORROW-FROM-ASSISTENT.md` — что берём из `ai/assistent` (приёмы, плагины, версии),
а что не берём (архитектура, экраны, дизайн — своё).
`MARKDOWN-SOURCE.md` — откуда брать готовую отрисовку Markdown (файлы и адреса).
`REQUIREMENTS.md` — требования. Статус: накидываем, ни один пункт не обязателен
к исполнению в том виде, как записан.
@@ -44,6 +46,13 @@ sketches/004-settings-agents/index.html # настройки агентов
→ не отвечает, с подробностями и подсказкой. Добавить агента можно только
после успешной проверки.
## Заимствования: только приёмы, не архитектура
Из репозитория `ai/assistent` берём **приёмы отрисовки, плагины и версии**.
Архитектуру, слои, готовые экраны и окна — **не берём**: дизайн у нас свой,
нарисованный (см. `sketches/`). Разбор по пунктам — в `BORROW-FROM-ASSISTENT.md`,
там же таблица «берём / не берём».
## Как смотреть
Открыть в браузере любой из файлов — каждый самодостаточный, без сборки.
+5
View File
@@ -62,6 +62,11 @@
(по нажатию или на удержание) — решим позже.
- **R19.** Распознавание речи — **отдельная тема, здесь не решается.** Важно только:
где кнопка, что видно во время записи, что происходит после.
- **R19.1.** **Как читать микрофон — уже известно, берём приём из `ai/assistent`.**
Никаких сторонних библиотек: стандартные средства Java, класс
`MicrophoneRecorder` (83 строки). Формат — 16 кГц, 16 бит, моно.
Наружу отдаёт поток кусочков, а не файл: можно отправлять на распознавание
сразу, не дожидаясь конца записи. Подробности — `BORROW-FROM-ASSISTENT.md`, п. 4.
## 6. Несколько агентов