Десктоп-клиент: 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:
2026-09-28 10:08:10 +03:00
parent 27f349550c
commit 2c71542b13
53 changed files with 9147 additions and 0 deletions
@@ -0,0 +1,95 @@
package pw.binom.agentik.desktop.voice
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.ui.renderMarkdownSegments
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Настоящая речь через весь продуктовый путь, в том порядке, в каком это
* происходит в приложении:
*
* 1. [VoiceNative.warmUp] — нативный ASR (Vosk + ONNX VAD) прогревается ДО skia;
* 2. инициализируется skiko (оффскрин-рендер Compose с текстом);
* 3. создаётся **новый** [VoskVoiceRecognizer] (свежая модель Vosk + свежий VAD)
* уже после skia и распознаёт реальный русский сэмпл.
*
* Шаг 3 — это ровно то, что делает кнопка микрофона в приложении (каждая запись
* создаёт свой распознаватель). Без шага 1 он валит JVM с SIGSEGV: см.
* [VoiceNative]. Реплика в `raz-dva-tri.wav` — «раз два три четыре пять»
* (16 кГц, моно, s16le).
*/
class RealSpeechIT {
@OptIn(ExperimentalTestApi::class)
@Test
fun `speech is recognized after the compose window has been initialized`() {
// 1. как в main(): прогреваем нативный ASR до skiko.
assertTrue(VoiceNative.warmUp(), "нативный ASR должен прогреться до skiko")
assertTrue(VoiceNative.available)
// 2. skiko/Compose инициализируется и реально рисует (как окно приложения).
runComposeUiTest {
setContent {
AgentikTheme("dark") {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble)) {
renderMarkdownSegments(
content = "проверка: **окно** уже отрисовано",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
)
}
}
}
}
// 3. обычный путь распознавания — уже после skiko.
val recognizer = VoskVoiceRecognizer()
try {
val pcm = loadPcm("/voice/raz-dva-tri.wav")
assertTrue(pcm.size > VOICE_SAMPLE_RATE, "сэмпл должен быть длиннее секунды")
var offset = 0
val chunk = VOICE_SAMPLE_RATE / 10 * 2
var maxPartial = ""
while (offset < pcm.size) {
val n = minOf(chunk, pcm.size - offset)
recognizer.feed(pcm.copyOfRange(offset, offset + n))
offset += n
recognizer.partial()?.let { if (it.length > maxPartial.length) maxPartial = it }
}
val final = recognizer.finish()
assertTrue(recognizer.speechDetected, "VAD должен увидеть речь (partials: '$maxPartial')")
assertTrue(
final.contains("раз") && final.contains("пять"),
"ожидали услышать «раз два три четыре пять», получили: '$final' (partials: '$maxPartial')",
)
} finally {
recognizer.close()
}
}
/** PCM-данные из WAV: пропускаем RIFF-заголовок до чанка `data`. */
private fun loadPcm(resource: String): ByteArray {
val bytes = javaClass.getResourceAsStream(resource)!!.readBytes()
var i = 12
while (i + 8 <= bytes.size) {
val id = String(bytes, i, 4, Charsets.US_ASCII)
val size = (bytes[i + 4].toInt() and 0xFF) or
((bytes[i + 5].toInt() and 0xFF) shl 8) or
((bytes[i + 6].toInt() and 0xFF) shl 16) or
((bytes[i + 7].toInt() and 0xFF) shl 24)
if (id == "data") return bytes.copyOfRange(i + 8, minOf(i + 8 + size, bytes.size))
i += 8 + size + (size and 1)
}
error("в $resource нет data-чанка")
}
}