diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/MarkdownStyle.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/MarkdownStyle.kt new file mode 100644 index 0000000..0a0681d --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/MarkdownStyle.kt @@ -0,0 +1,152 @@ +package pw.binom.agentik.desktop.ui + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import pw.binom.agentik.desktop.ui.theme.AgentikColors +import pw.binom.agentik.desktop.ui.theme.darkAgentikColors + +/** + * Стиль отрисовки markdown: цвета, шрифты и отступы, из которых рендер + * ([renderMarkdownSegments]) берёт решительно всё. + * + * Зачем отдельный контракт: рендер не должен знать ни про тему приложения, ни + * про конкретные числа. Раньше он получал только `textColor` + `fontSize`, а + * остальное (фон код-блока, цвет ссылки, отступы, фон таблицы) тянул из + * `AgentikTheme`/`MaterialTheme` напрямую — из-за чего часть слотов жила вне + * темы, а тело сообщения могло уехать в цвет по умолчанию (чёрный). + * + * Реализация под наш клиент — [AgentikMarkdownStyle]; текущий стиль лежит в + * [LocalMarkdownStyle] (его подкладывает `AgentikTheme`), поэтому вызову + * достаточно `renderMarkdownSegments(content)`. + */ +@Immutable +interface MarkdownStyle { + /** Цвет основного текста сообщения (в тёмной теме — светлый). */ + val textColor: Color + + /** Кегль тела сообщения. */ + val bodyFontSize: TextUnit + val bodyFontFamily: FontFamily + + /** Интервал строк тела; [TextUnit.Unspecified] — на усмотрение движка. */ + val bodyLineHeight: TextUnit + + /** Ссылки: цвет + подчёркивание. */ + val linkColor: Color + + /** Inline-код: `code`. */ + val monospaceFamily: FontFamily + val inlineCodeFontSize: TextUnit + val inlineCodeBackground: Color + + /** Блок кода (``` … ```). */ + val codeBackground: Color + val codeTextColor: Color + val codeHeaderBackground: Color + val codeFontSize: TextUnit + + /** Таблица. */ + val tableSurface: Color + val tableHeaderBackground: Color + val tableStripeBackground: Color + val tableFontSize: TextUnit + + /** Мелкие подписи (язык код-блока, иконка копирования) и разделитель `---`. */ + val labelColor: Color + val labelFontSize: TextUnit + val divider: Color + + /** Отступы. */ + val paragraphSpacing: Dp + val blockSpacing: Dp + val tableSpacing: Dp + val codePadding: Dp + val codeHeaderPaddingHorizontal: Dp + val codeHeaderPaddingVertical: Dp + val tableCellPaddingHorizontal: Dp + val tableCellPaddingVertical: Dp + val copyIconSize: Dp + + /** Ширина контента, когда сообщение лежит в горизонтально скроллящемся пузыре. */ + val maxContentWidth: Dp +} + +/** + * База, накладываемая на ВЕСЬ текстовый сегмент. + * + * Парсер заводит стилевые спаны только для размеченных участков (жирный/код/ + * ссылка), поэтому без этой базы «простой» текст остаётся без цвета и рисуется + * цветом по умолчанию (чёрным) — это и был баг «текст чёрный вместо белого». + * + * Именно функция, а не свойство интерфейса: с делегированием + * (`object : MarkdownStyle by otherStyle`) свойство с реализацией по умолчанию + * уезжает в делегат и перекрытый `textColor` в базе не учитывается. + * [bodyLineHeight] сюда не входит — интервал строк задаётся у абзаца + * (`Text(lineHeight = …)`), в `SpanStyle` такого слота нет. + */ +fun MarkdownStyle.bodySpan(): SpanStyle = SpanStyle( + color = textColor, + fontSize = bodyFontSize, + fontFamily = bodyFontFamily, +) + +/** + * Наш стиль: цвета — из [AgentikColors] активной темы, типографика и отступы — + * из макетов клиента (`sketches/index.html`). + */ +@Immutable +class AgentikMarkdownStyle(private val colors: AgentikColors) : MarkdownStyle { + override val textColor: Color get() = colors.text + + override val bodyFontSize: TextUnit = 13.5.sp + override val bodyFontFamily: FontFamily = FontFamily.Default + override val bodyLineHeight: TextUnit = 20.sp + + override val linkColor: Color get() = colors.accent + + override val monospaceFamily: FontFamily = FontFamily.Monospace + override val inlineCodeFontSize: TextUnit = 13.sp + override val inlineCodeBackground: Color get() = colors.panelElevated + + override val codeBackground: Color get() = colors.codeBackground + override val codeTextColor: Color get() = colors.codeText + override val codeHeaderBackground: Color get() = colors.panelElevated + override val codeFontSize: TextUnit = 13.sp + + override val tableSurface: Color get() = colors.panel.copy(alpha = 0.3f) + override val tableHeaderBackground: Color get() = colors.accent.copy(alpha = 0.1f) + override val tableStripeBackground: Color get() = colors.text.copy(alpha = 0.03f) + override val tableFontSize: TextUnit = 13.5.sp + + override val labelColor: Color get() = colors.textDim + override val labelFontSize: TextUnit = 12.sp + override val divider: Color get() = colors.text.copy(alpha = 0.25f) + + override val paragraphSpacing: Dp = 12.dp + override val blockSpacing: Dp = 4.dp + override val tableSpacing: Dp = 6.dp + override val codePadding: Dp = 12.dp + override val codeHeaderPaddingHorizontal: Dp = 12.dp + override val codeHeaderPaddingVertical: Dp = 6.dp + override val tableCellPaddingHorizontal: Dp = 8.dp + override val tableCellPaddingVertical: Dp = 6.dp + override val copyIconSize: Dp = 16.dp + + override val maxContentWidth: Dp = 600.dp +} + +/** Стиль по умолчанию — тёмная тема (эталонная); совпадает с дефолтом [AgentikColors]. */ +private val DefaultMarkdownStyle: MarkdownStyle = AgentikMarkdownStyle(darkAgentikColors) + +/** + * Текущий стиль markdown. Подкладывается `AgentikTheme`, поэтому экраны читают + * его неявно: `renderMarkdownSegments(content)`. + */ +val LocalMarkdownStyle = staticCompositionLocalOf { DefaultMarkdownStyle } diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/net/ConnectionCheckerTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/net/ConnectionCheckerTest.kt new file mode 100644 index 0000000..2de7953 --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/net/ConnectionCheckerTest.kt @@ -0,0 +1,136 @@ +package pw.binom.agentik.desktop.net + +import io.ktor.server.cio.CIO as ServerCIO +import io.ktor.server.engine.embeddedServer +import io.ktor.server.routing.routing +import kotlinx.coroutines.runBlocking +import pw.binom.agentik.desktop.settings.AgentConfig +import pw.binom.agentik.journal.ConversationStore +import pw.binom.agentik.journal.inmemory.InMemoryJournalStore +import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore +import pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore +import pw.binom.agentik.proto.Agent +import pw.binom.agentik.proto.AgentInfo +import pw.binom.agentik.proto.Content +import pw.binom.agentik.proto.Conversation +import pw.binom.agentik.proto.Message +import pw.binom.agentik.proto.MessageContext +import pw.binom.agentik.server.agentikAgent +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * Проверка связи не должна иметь побочных эффектов: единственный запрос — + * информация об агенте (`GET {baseUrl}`), никаких пробных диалогов. + * + * Регрессия: раньше после ответа сервера чекер создавал временный диалог и + * удалял его — сервер получал мусорную запись `ConversationRecord`, а + * пользователь, проверяя адрес, оставлял след в истории агента. + */ +class ConnectionCheckerTest { + + private lateinit var server: io.ktor.server.engine.EmbeddedServer<*, *> + private var port: Int = 0 + + @AfterTest + fun tearDown() { + if (::server.isInitialized) server.stop(100, 300) + } + + private fun startServer(agent: Agent) { + server = embeddedServer(ServerCIO, port = 0, host = "127.0.0.1") { + routing { agentikAgent(agent, path = "/agentik", token = null) } + }.start(wait = false) + port = runBlocking { server.engine.resolvedConnectors().first().port } + } + + @Test + fun `check asks for agent info and creates no conversation`() = runBlocking { + val stub = StubAgent() + startServer(stub) + + val report = ConnectionChecker().check( + config = AgentConfig.create(baseUrl = "http://127.0.0.1:$port/agentik"), + clientId = "checker-test", + ) + + assertTrue(report.ok, "живой сервер обязан пройти проверку: ${report.steps}") + assertEquals(1, report.steps.size, "ровно один шаг — запрос информации: ${report.steps}") + assertEquals(ConnectionChecker.STEP_AGENT_INFO, report.steps.single().title) + assertEquals("Стенд", report.agentName, "имя агента берётся из AgentInfo") + assertEquals(0, stub.created, "проверка связи НЕ должна создавать диалог") + } + + @Test + fun `check fails on an unreachable agent`() = runBlocking { + val report = ConnectionChecker().check( + config = AgentConfig.create(baseUrl = "http://127.0.0.1:1/agentik"), + clientId = "checker-test", + ) + + assertTrue(!report.ok, "мёртвый адрес не должен проходить проверку: $report") + assertEquals(1, report.steps.size) + assertTrue(!report.steps.single().ok, "шаг помечен неуспешным") + assertNull(report.agentName, "имя неизвестно, если агент не ответил") + } +} + +/** + * Минимальный агент для стенда: [ConnectionChecker] ходит только за + * [Agent.info], поэтому всё остальное — заглушки, а [createConversation] + * считает вызовы, чтобы тест поймал попытку создать пробный диалог. + */ +private class StubAgent : Agent { + + override val id: String = "checker-stub" + + override val info: AgentInfo = AgentInfo( + name = "Стенд", + description = "Агент для теста проверки связи", + ) + + override val journal = InMemoryJournalStore() + + override val outbox = InMemoryOutboxStore(maxMessages = null, ttl = null) + + private val store = InMemoryMutableConversationStore() + + override val conversationStore: ConversationStore get() = store + + /** Сколько раз кто-то пытался создать диалог (должно остаться нулём). */ + var created: Int = 0 + private set + + override fun createConversation(temp: Boolean): Conversation { + created++ + val now = Clock.System.now() + return StubConversation("stub-$created", now) + } + + override suspend fun getConversation(id: String): Conversation? = null + + override suspend fun deleteConversation(id: String): Boolean = false + + override suspend fun renameConversation(id: String, title: String?): Instant? = null + + override fun close() {} +} + +private class StubConversation(override val id: String, createdAt: Instant) : Conversation { + override val isSupportImageInput: Boolean = false + override val isSupportImageOutput: Boolean = false + override val isTemporal: Boolean = false + override val title: String? = null + override val updatedAt: Instant = createdAt + + override suspend fun rename(title: String) {} + override suspend fun send(content: List, context: MessageContext?) {} + override suspend fun interrupt() {} + override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List = emptyList() + override fun close() {} +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/ui/MarkdownStyleTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/ui/MarkdownStyleTest.kt new file mode 100644 index 0000000..dd5cd22 --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/ui/MarkdownStyleTest.kt @@ -0,0 +1,173 @@ +package pw.binom.agentik.desktop.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.sp +import pw.binom.agentik.desktop.markdown.MarkdownSegment +import pw.binom.agentik.desktop.markdown.parseMarkdown +import pw.binom.agentik.desktop.ui.theme.AgentikTheme +import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme +import pw.binom.agentik.desktop.ui.theme.darkAgentikColors +import pw.binom.agentik.desktop.ui.theme.lightAgentikColors +import pw.binom.agentik.desktop.ui.theme.toAgentikTheme +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Стиль отрисовки markdown: [MarkdownStyle] + [AgentikMarkdownStyle]. + * + * Регрессия, ради которой всё это: парсер заводит спаны только для размеченных + * участков (жирный/код/ссылка), а база накладывалась ВНУТРИ спанов — поэтому + * «простой» текст оставался без цвета и рисовался чёрным (дефолт + * `LocalContentColor`) вместо цвета темы. + */ +class MarkdownStyleTest { + + private val darkStyle = AgentikMarkdownStyle(darkAgentikColors) + + // ── цвет текста ──────────────────────────────────────────────────────── + + @Test + fun `plain text is painted with the style text color`() { + val segment = parseMarkdown("Просто текст без разметки").single() as MarkdownSegment.Text + assertTrue(segment.spans.isEmpty(), "в простом тексте нет стилевых спанов") + + val annotated = segment.toAnnotatedString(darkStyle) + val base = annotated.spanStyles.singleOrNull() + assertNotNull(base, "база накладывается на весь текст, даже без спанов") + assertEquals(0, base.start) + assertEquals(segment.text.length, base.end) + assertEquals(darkAgentikColors.text, base.item.color) + } + + @Test + fun `colors come from the theme`() { + val light = AgentikMarkdownStyle(lightAgentikColors) + + assertEquals(darkAgentikColors.text, darkStyle.textColor) + assertEquals(darkAgentikColors.accent, darkStyle.linkColor) + assertEquals(darkAgentikColors.codeBackground, darkStyle.codeBackground) + assertEquals(darkAgentikColors.codeText, darkStyle.codeTextColor) + assertEquals(darkAgentikColors.panelElevated, darkStyle.inlineCodeBackground) + + assertEquals(lightAgentikColors.text, light.textColor) + assertEquals(lightAgentikColors.codeBackground, light.codeBackground) + + // стиль реально зависит от темы, а не хардкодит тёмные значения + assertNotEquals(darkStyle.textColor, light.textColor) + assertNotEquals(darkStyle.codeBackground, light.codeBackground) + } + + @Test + fun `inline code and links take their slots from the style`() { + val segment = parseMarkdown("код `val x` и [ссылка](http://example.com)") + .single() as MarkdownSegment.Text + val annotated = segment.toAnnotatedString(darkStyle) + + val code = annotated.spanStyles.firstOrNull { + it.item.fontFamily == FontFamily.Monospace && it.item.background == darkAgentikColors.panelElevated + } + assertNotNull(code, "inline-код берёт моноширинный шрифт и фон из стиля") + + val link = annotated.spanStyles.firstOrNull { + it.item.color == darkAgentikColors.accent && it.item.textDecoration == TextDecoration.Underline + } + assertNotNull(link, "ссылка берёт цвет из стиля") + + val url = annotated.getStringAnnotations("URL", 0, annotated.length).singleOrNull() + assertNotNull(url, "ссылка помечается аннотацией URL") + assertEquals("http://example.com", url.item) + } + + // ── рендер ───────────────────────────────────────────────────────────── + + @OptIn(ExperimentalTestApi::class) + @Test + fun `plain text renders light, never black`() = runComposeUiTest { + // Крупный кегль — чтобы в гистограмме гарантированно были пиксели, + // полностью залитые цветом текста (не только сглаженные края). + val big = object : MarkdownStyle by AgentikMarkdownStyle(darkAgentikColors) { + override val bodyFontSize = 30.sp + } + setContent { + ProvideAgentikTheme("dark".toAgentikTheme()) { + Box(Modifier.fillMaxSize().background(AgentikTheme.colors.background)) { + renderMarkdownSegments("Текст сообщения", style = big) + } + } + } + + val histogram = onRoot().captureToImage().toPixelMap().histogram() + assertTrue( + histogram.keys.any { it.red > 0.8f && it.green > 0.8f && it.blue > 0.8f }, + "текст должен быть светлым (цвет темы ${darkAgentikColors.text}), а не чёрным: ${histogram.top()}", + ) + assertFalse( + histogram.containsKey(Color.Black), + "в тёмной теме чёрного текста быть не должно: ${histogram.top()}", + ) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `renderer follows the style from the composition local`() = runComposeUiTest { + val magenta = object : MarkdownStyle by AgentikMarkdownStyle(darkAgentikColors) { + override val textColor = Color(0xFFFF00FF) + } + setContent { + ProvideAgentikTheme("dark".toAgentikTheme()) { + CompositionLocalProvider(LocalMarkdownStyle provides magenta) { + Box(Modifier.fillMaxSize().background(AgentikTheme.colors.background)) { + renderMarkdownSegments("Текст сообщения") + } + } + } + } + + val histogram = onRoot().captureToImage().toPixelMap().histogram() + assertTrue( + histogram.keys.any { it.red > 0.5f && it.blue > 0.5f && it.green < 0.3f }, + "рендер обязан брать стиль из LocalMarkdownStyle: ${histogram.top()}", + ) + } + + // ── helpers ──────────────────────────────────────────────────────────── + + private fun androidx.compose.ui.graphics.PixelMap.histogram(): Map { + val out = HashMap() + for (y in 0 until height) { + for (x in 0 until width) { + val color = this[x, y] + out[color] = (out[color] ?: 0) + 1 + } + } + return out + } + + private fun Map.top(): String = + entries.sortedByDescending { it.value }.take(5) + .joinToString { "#${it.key.toHexArgb()}×${it.value}" } + + private fun Color.toHexArgb(): String { + val a = (alpha * 255).toInt() + val r = (red * 255).toInt() + val g = (green * 255).toInt() + val b = (blue * 255).toInt() + return "%02X%02X%02X%02X".format(a, r, g, b) + } +}