Десктоп-клиент: Compose-UI, сессии, SQLite-кэш, голос, живые тесты
- settings/: settings.json (агенты, тема, clientId), атомарная запись - persistence/: ConversationMetaRepository — группы, lastSeen, превью, watermark синхронизации (synced_at через PRAGMA table_info) - session/: AgentRegistry (параллельное подключение, деградация в офлайн), AgentConnection (журнал + мета + живой список диалогов conversationsFlow, refresh по SSE-потоку), ChatSession (history + backfill + live + preview, дедуп по id, 1 мс шаг watermark, syncMutex), LiveTurn - UI: рейка агентов, список диалогов (живое обновление), чат со стримингом, композер (Enter отправляет, Shift+Enter переносит, Ctrl+M — голос), окна «Агенты»/«Группы»/«Новый диалог», тёмная и светлая темы - voice/: mic-api → Silero VAD → Vosk на одном выделенном потоке; VoiceNative.warmUp() до skia (иначе SIGSEGV), модель в артефакте - markdown/: вендоренный рендер из ai/assistent - ошибки в баннер через AppState.reportError (CancellationException игнорируется) - тесты 103 (реальная SQLite + фейковый транспорт); против живого агента — RealServerIT, RealAppStateIT, RealSpeechIT - LIVE-RUN-NOTES.md: живой прогон в GUI и найденные/починенные баги
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
package pw.binom.agentik.desktop.ui
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import pw.binom.agentik.desktop.model.UiMessage
|
||||
import pw.binom.agentik.desktop.session.AgentConnection
|
||||
import pw.binom.agentik.desktop.session.AgentRegistry
|
||||
import pw.binom.agentik.desktop.session.AgentStatus
|
||||
import pw.binom.agentik.desktop.settings.AgentConfig
|
||||
import pw.binom.agentik.desktop.settings.SettingsRepository
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
import java.nio.file.Files
|
||||
|
||||
/**
|
||||
* Приложение целиком (без пикселей) против ЖИВОГО `:standalone`-агента:
|
||||
* настройки → реестр → [AppState] → диалог с настоящей LLM.
|
||||
*
|
||||
* Это замена GUI-прогону, когда vision-сервис недоступен: UI у нас — чистая
|
||||
* функция от [AppState], а сам рендер уже проверен `ThemeRenderTest`.
|
||||
* Проверяем то, что ломается только в жизни: создание диалога на сервере,
|
||||
* отправку, живой ответ модели, попадание реплик в локальный кэш (историю),
|
||||
* снятие «непрочитанного» после открытия.
|
||||
*
|
||||
* Адрес — `AGENTIK_E2E_URL` или `http://127.0.0.1:8080/agentik`; если сервер
|
||||
* не поднят, тест **пропускается** (`assumeTrue`), как [RealServerIT].
|
||||
*/
|
||||
class RealAppStateIT {
|
||||
|
||||
private var registry: AgentRegistry? = null
|
||||
private var state: AppState? = null
|
||||
|
||||
// Свой scope, а не coroutineContext из runBlocking: у реестра/сессии живут
|
||||
// вечные коллекторы (SSE, listFlow), и runBlocking ждал бы их вечно.
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
runCatching { state?.shutdown() }
|
||||
runCatching { registry?.close() }
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun url(): String =
|
||||
System.getenv("AGENTIK_E2E_URL")?.takeIf { it.isNotBlank() }
|
||||
?: "http://127.0.0.1:8080/agentik"
|
||||
|
||||
private fun reachable(url: String): Boolean = runCatching {
|
||||
(URI("$url").toURL().openConnection() as HttpURLConnection).run {
|
||||
connectTimeout = 1_500
|
||||
readTimeout = 1_500
|
||||
requestMethod = "GET"
|
||||
responseCode in 200..499
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
private suspend fun await(
|
||||
what: String,
|
||||
timeoutMs: Long = 10_000,
|
||||
condition: suspend () -> Boolean,
|
||||
) {
|
||||
val ok = withTimeoutOrNull(timeoutMs) {
|
||||
while (!condition()) delay(20)
|
||||
true
|
||||
}
|
||||
check(ok == true) { "не дождались: $what (${timeoutMs}мс)" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `app state creates a dialog and gets a live answer from the real agent`() = runBlocking {
|
||||
val base = url()
|
||||
assumeTrue(reachable(base), "standalone-агент недоступен на $base — тест пропущен")
|
||||
|
||||
val tmp = Files.createTempDirectory("agentik-appstate")
|
||||
val reg = AgentRegistry.create(
|
||||
settingsRepo = SettingsRepository(tmp.resolve("settings.json")),
|
||||
scope = scope,
|
||||
)
|
||||
registry = reg
|
||||
val st = AppState(registry = reg, scope = scope)
|
||||
state = st
|
||||
|
||||
st.start()
|
||||
st.addAgent(baseUrl = base, nameOverride = "Живой агент")
|
||||
await("агент появился в настройках") { st.agents.isNotEmpty() }
|
||||
val agentId = st.agents.first().id
|
||||
|
||||
await("агент подключился", timeoutMs = 20_000) {
|
||||
st.statusesState[agentId] is AgentStatus.Online
|
||||
}
|
||||
st.selectAgent(agentId)
|
||||
await("список диалогов подтянулся с сервера") { st.conversations.isNotEmpty() }
|
||||
|
||||
st.newConversation(agentId)
|
||||
await("диалог открылся") { st.activeConversationId != null }
|
||||
val convId = st.activeConversationId!!
|
||||
|
||||
st.send("Ответь ровно одним словом: два плюс два?")
|
||||
await("пришёл ответ ассистента", timeoutMs = 240_000) {
|
||||
st.history.any { it is UiMessage.Assistant }
|
||||
}
|
||||
|
||||
val assistant = st.history.filterIsInstance<UiMessage.Assistant>().last()
|
||||
val assistantText = assistant.content.filterIsInstance<pw.binom.agentik.desktop.model.UiContent.Text>()
|
||||
.joinToString(" ") { it.body }
|
||||
assertTrue(assistantText.isNotBlank(), "ответ ассистента пустой")
|
||||
assertTrue(
|
||||
st.history.any { it is UiMessage.User },
|
||||
"реплика пользователя должна быть в истории (из локального кэша)",
|
||||
)
|
||||
|
||||
// Превью денормализовано в локальную мету (для строки списка).
|
||||
val preview = reg.connection(agentId)!!.meta.meta(convId)?.preview
|
||||
assertNotNull(preview, "preview диалога должен записаться локально")
|
||||
|
||||
// Открытие диалога помечает его просмотренным: непрочитанных нет.
|
||||
val lastSeen = reg.connection(agentId)!!.meta.meta(convId)?.lastSeen
|
||||
assertNotNull(lastSeen, "lastSeen должен выставиться при открытии")
|
||||
|
||||
// История диалога переживает повторное открытие (тот же локальный кэш).
|
||||
st.openConversation(convId)
|
||||
await("история перезагрузилась из кэша") { st.history.isNotEmpty() }
|
||||
assertTrue(
|
||||
st.history.filterIsInstance<UiMessage.Assistant>().isNotEmpty(),
|
||||
"после повторного открытия ответ должен остаться в истории",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live dialog list arrives by itself right after connect`() = runBlocking {
|
||||
val base = url()
|
||||
assumeTrue(reachable(base), "standalone-агент недоступен на $base — тест пропущен")
|
||||
|
||||
val tmp = Files.createTempDirectory("agentik-convflow")
|
||||
val cfg = AgentConfig.create(baseUrl = base, nameOverride = "Живой агент")
|
||||
val conn = AgentConnection.open(
|
||||
config = cfg,
|
||||
clientId = "agentik-desktop-it",
|
||||
scope = scope,
|
||||
dbPath = tmp.resolve("agentik.db").toString(),
|
||||
)
|
||||
|
||||
// Seed внутри `AgentikAgent` асинхронный: список обязан приехать сам,
|
||||
// без единого `refresh()` со стороны UI. Именно этого не хватало —
|
||||
// приложение показывало пустой список диалогов.
|
||||
await("живой список диалогов доехал сам", timeoutMs = 15_000) {
|
||||
conn.conversationsFlow.value.isNotEmpty()
|
||||
}
|
||||
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package pw.binom.agentik.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import pw.binom.agentik.desktop.ui.theme.AgentikColors
|
||||
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
|
||||
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
|
||||
import pw.binom.agentik.desktop.ui.theme.lightAgentikColors
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Палитра и реальный рендер Compose без открытия окна.
|
||||
*
|
||||
* Зачем: светлая тема до сих пор проверялась только глазами по скриншоту, а
|
||||
* markdown-код-блок изначально хардкодил тёмный фон (`#1E1E1E`), из-за чего в
|
||||
* светлой теме выглядел чужеродно. Здесь тема прогоняется через настоящий
|
||||
* оффскрин-рендер, а цвета сверяются с проектными токенами.
|
||||
*/
|
||||
class ThemeRenderTest {
|
||||
|
||||
// ── токены (THEMES.md / sketches/style.css) ────────────────────────────
|
||||
|
||||
@Test
|
||||
fun darkPaletteMatchesDesignTokens() {
|
||||
val c = darkAgentikColors
|
||||
assertEquals(Color(0xFF121218), c.background)
|
||||
assertEquals(Color(0xFF17212B), c.panel)
|
||||
assertEquals(Color(0xFF1C2733), c.panelElevated)
|
||||
assertEquals(Color(0xFF6AB2F2), c.accent)
|
||||
assertEquals(Color(0xFFEBEBEB), c.text)
|
||||
assertEquals(Color(0xFF768C9E), c.textDim)
|
||||
assertEquals(Color(0xFFEC3942), c.error)
|
||||
assertEquals(Color(0xFF2B5278), c.userBubble)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lightPaletteKeepsSemanticSlots() {
|
||||
val c = lightAgentikColors
|
||||
assertEquals(Color(0xFFF5F6F8), c.background)
|
||||
assertEquals(Color(0xFFFFFFFF), c.panel)
|
||||
assertEquals(Color(0xFF2A6FB5), c.accent)
|
||||
assertEquals(Color(0xFF1A1D23), c.text)
|
||||
// светлый фон обязан быть светлым: контраст к тексту
|
||||
assertTrue(luminance(c.background) > luminance(c.text), "фон светлее текста")
|
||||
assertTrue(luminance(c.panel) > luminance(c.panelElevated), "panel светлее panelElevated")
|
||||
assertTrue(luminance(c.botBubble) > luminance(c.text), "бот-бабл светлее текста")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun opaqueSlotsStayOpaqueAndScrimTransparent() {
|
||||
for (c in listOf(darkAgentikColors, lightAgentikColors)) {
|
||||
assertEquals(1f, c.background.alpha)
|
||||
assertEquals(1f, c.panel.alpha)
|
||||
assertEquals(1f, c.text.alpha)
|
||||
assertEquals(1f, c.accent.alpha)
|
||||
// scrim/selection — прозрачные по замыслу
|
||||
assertTrue(c.scrim.alpha < 1f)
|
||||
assertTrue(c.selection.alpha < 1f)
|
||||
}
|
||||
}
|
||||
|
||||
// ── рендер ─────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@Test
|
||||
fun codeBlockPaintsThemeCodeBackgroundInLightTheme() = runComposeUiTest {
|
||||
setContent {
|
||||
AgentikTheme("light") {
|
||||
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) {
|
||||
Column {
|
||||
renderMarkdownSegments(
|
||||
content = "```kotlin\nval x = 1\n```",
|
||||
textColor = AgentikTheme.colors.text,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val pixels = onRoot().captureToImage().toPixelMap()
|
||||
val histogram = HashMap<Color, Int>()
|
||||
for (y in 0 until pixels.height) {
|
||||
for (x in 0 until pixels.width) {
|
||||
val color = pixels[x, y]
|
||||
histogram[color] = (histogram[color] ?: 0) + 1
|
||||
}
|
||||
}
|
||||
val top = histogram.entries.sortedByDescending { it.value }.take(4)
|
||||
.joinToString { "#${it.key.toHexArgb()}×${it.value}" }
|
||||
assertTrue(
|
||||
histogram.containsKey(lightAgentikColors.codeBackground),
|
||||
"код-блок должен краситься в codeBackground светлой темы, а покрасился в: $top",
|
||||
)
|
||||
assertFalse(
|
||||
histogram.containsKey(Color(0xFF1E1E1E)),
|
||||
"тёмный хардкод код-фона протёк в светлую тему: $top",
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@Test
|
||||
fun darkThemeStillRendersCodeBlockDark() = runComposeUiTest {
|
||||
setContent {
|
||||
AgentikTheme("dark") {
|
||||
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) {
|
||||
Column {
|
||||
renderMarkdownSegments(
|
||||
content = "```kotlin\nval x = 1\n```",
|
||||
textColor = AgentikTheme.colors.text,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val pixels = onRoot().captureToImage().toPixelMap()
|
||||
var found = false
|
||||
for (y in 0 until pixels.height) {
|
||||
for (x in 0 until pixels.width) {
|
||||
if (pixels[x, y] == darkAgentikColors.codeBackground) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(found, "в тёмной теме код-блок должен краситься в dark codeBackground")
|
||||
}
|
||||
|
||||
private fun luminance(color: Color): Float =
|
||||
0.2126f * color.red + 0.7152f * color.green + 0.0722f * color.blue
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user