Files
agentik-desktop/BORROW-FROM-ASSISTENT.md
subochev 27f349550c Макеты — один sketches/index.html; approved/ удалён
- sketches/index.html: все экраны одним документом в порядке 1·… (14–19 — бывшие approved)
- approved/ (4 файла групп + 3 макета + README) удалён как дубль
- стили вынесены в sketches/style.css
- README, THEMES, BORROW-FROM-ASSISTENT, MARKDOWN-SOURCE, MIC-ASR-SEARCH
  приведены к фактическому состоянию
2026-09-28 10:07:57 +03:00

17 KiB
Raw Permalink Blame History

Что берём из 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, а в 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 } писать просто

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.