- sketches/index.html: все экраны одним документом в порядке 1·… (14–19 — бывшие approved) - approved/ (4 файла групп + 3 макета + README) удалён как дубль - стили вынесены в sketches/style.css - README, THEMES, BORROW-FROM-ASSISTENT, MARKDOWN-SOURCE, MIC-ASR-SEARCH приведены к фактическому состоянию
17 KiB
Что берём из 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):
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 для десктопа:
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation(libs.kotlinx.coroutines.swing) // ВАЖНО: Dispatchers.Main на десктопе = Swing EDT
Репозитории (без них не соберётся):
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")
Точка входа приложения:
compose.desktop {
application {
mainClass = "<наш пакет>.MainKt"
}
}
⚠️ Не путать: в самом agentik версия Kotlin
2.4.20, а в assistent2.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 } писать просто
val isNotAtBottom = listState.canScrollForward
Прямое чтение убирает DerivedSnapshotState и гонку вместе с ним.
3.3. Ленивый список: getOrNull + стабильный ключ
Где: client/.../ui/ChatScreen.kt, строки ~1030–1043.
Список сообщений меняется фоном (подгрузка истории, новые сообщения), поэтому прямой доступ по индексу может выйти за границы и уронить композицию.
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.
val listState = rememberLazyListState()
LaunchedEffect(messages.size, isThinking, isTranscribing.value) {
if (messages.isNotEmpty()) listState.scrollToItem(messages.size - 1)
}
Приём: ключи LaunchedEffect — то, при изменении чего прокручивать. Плюс
«печатает» и «запись голоса» в ключах: прокрутка случается и при появлении
индикатора, а не только при новом сообщении. Для нас то же: ответ течёт потоком.
3.5. Кнопка «прокрутить вниз» — плавно и без гонки
Где: там же, строки ~1085–1110.
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.
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) превращает
координаты в позицию в тексте; по позиции ищем аннотацию (ссылка или код) и
открываем её.
Modifier
.pointerInput(annotatedString) {
detectTapGestures { offset ->
val layout = textLayoutResult ?: return@detectTapGestures
val position = layout.getOffsetForPosition(offset)
... // найти аннотацию по position
}
}
Зачем нам: ссылки в ответах агента должны открываться в браузере, а не быть просто текстом.
3.8. Копирование блока кода по кнопке
Где: там же, строки ~90–125.
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.
Там есть готовый образец, очень близкий к тому, что нужно нам:
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.