@@ -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` | Чужой дизайн и окна |