Compare commits

...

2 commits

Author SHA1 Message Date
Bruno Deanoz
827a2f307c fix: streaming performance, error handling, and API diagnostics
- Eliminate recomposition hell: isolate auto-scroll via derivedStateOf + snapshotFlow
- Preserve partial response on connection drop instead of replacing with error
- O(1) incremental stream parser (StreamConsumer.Incremental) replacing O(N²) re-scan
- Parallel conversation creation via async/await to reduce TTFT
- TCP/TLS prewarm on app resume via LifecycleResumeEffect
- Surface API failures with Log.w and UI error banner instead of silent emptyList()
- Fix nullable KaizenModel.name crash in ModelSheet
2026-06-19 15:34:59 +02:00
Bruno Deanoz
d81c6ffda7 fix: make KaizenApi exceptionally robust against malformed JSON or parsing failures 2026-06-19 09:06:55 +02:00
7 changed files with 156 additions and 48 deletions

View file

@ -50,6 +50,7 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
// Networking + JSON for the real backend (POST /api/v1/auth/token, /chat, /me)
implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json)

View file

@ -34,12 +34,14 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -47,7 +49,10 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.material3.Text
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.kaizen.app.haptics.rememberHaptics
import dev.kaizen.app.net.ChatHttpException
import dev.kaizen.app.net.ChatSpeed
@ -59,6 +64,8 @@ import dev.kaizen.app.net.SessionViewModel
import dev.kaizen.app.net.WireMessage
import dev.kaizen.app.settings.SettingsScreen
import dev.kaizen.app.settings.SettingsViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
// Screen enumeration for modular state-driven navigation (Zero Technical Debt)
@ -100,20 +107,40 @@ fun ChatScreen(
var conversationId by remember { mutableStateOf<String?>(null) }
var conversations by remember { mutableStateOf<List<ConversationSummary>>(emptyList()) }
var loadError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(session.config?.baseUrl, session.config?.token) {
val cfg = session.config ?: return@LaunchedEffect
// Fetch fresh models from server and cache them locally if successful
loadError = null
val fetchedModels = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)
if (fetchedModels.isNotEmpty()) {
if (fetchedModels != null) {
models = fetchedModels
session.store.saveModelsCache(fetchedModels)
if (fetchedModels.isNotEmpty()) session.store.saveModelsCache(fetchedModels)
}
conversations = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)
val fetchedConvos = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)
if (fetchedConvos != null) {
conversations = fetchedConvos
}
if (fetchedModels == null || fetchedConvos == null) {
loadError = "Verbindung zum Server fehlgeschlagen"
}
}
// Warm the HTTP/2 connection pool on every app resume (covers background → foreground
// after OkHttp's 5-minute keep-alive has expired). On first composition, the
// LaunchedEffect above already warms the pool via fetchModels/fetchConversations.
LifecycleResumeEffect(session.config?.baseUrl) {
session.config?.let { cfg ->
scope.launch { KaizenApi.prewarm(cfg.baseUrl) }
}
onPauseOrDispose { }
}
fun refreshConversations() {
val cfg = session.config ?: return
scope.launch { conversations = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token) }
scope.launch {
KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)?.let { conversations = it }
}
}
/** Start a fresh chat — drops the active conversation, the next send creates a new one. */
@ -168,17 +195,19 @@ fun ChatScreen(
haptics.thinkingStart()
var sawContent = false
try {
// Ensure a server conversation exists before the exchange is persisted.
val convId = conversationId ?: KaizenApi.createConversation(cfg.baseUrl, cfg.token)?.also {
conversationId = it
}
// Launch conversation creation in parallel — the chat stream does not
// need a conversation id, only the post-stream persistence does. This
// eliminates a full network RTT from the TTFT on new chats.
val convIdDeferred = if (conversationId == null) {
async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) }
} else null
KaizenApi.chat(cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed).collect { visible ->
val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect
if (!sawContent && visible.isNotEmpty()) {
sawContent = true
haptics.responseStart() // the answer begins (reasoning may have streamed first)
haptics.responseStart()
}
messages[idx] = messages[idx].copy(
thinking = if (sawContent) false else messages[idx].thinking,
@ -189,6 +218,9 @@ fun ChatScreen(
val finalContent = if (idx >= 0) messages[idx].content else ""
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
// Await the conversation id — the create ran in parallel with the stream
val convId = convIdDeferred?.await()?.also { conversationId = it } ?: conversationId
// Persist the exchange (best-effort); auto-title + sidebar refresh on a new conversation.
if (convId != null && finalContent.isNotEmpty()) {
val saved = KaizenApi.saveMessages(
@ -203,11 +235,15 @@ fun ChatScreen(
}
} catch (e: Exception) {
val idx = messages.indexOfLast { it.id == assistantId }
if (idx >= 0) messages[idx] = messages[idx].copy(
streaming = false,
thinking = false,
content = chatErrorText(e),
)
if (idx >= 0) {
val existing = messages[idx].content
val errorSuffix = "\n\n${chatErrorText(e)}"
messages[idx] = messages[idx].copy(
streaming = false,
thinking = false,
content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e),
)
}
} finally {
isStreaming = false
haptics.responseEnd()
@ -215,10 +251,17 @@ fun ChatScreen(
}
}
// Keep pinned to the bottom as the stream grows
val lastLength = messages.lastOrNull()?.content?.length ?: 0
LaunchedEffect(messages.size, lastLength) {
if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
// Auto-scroll: isolated from the main composition via derivedStateOf + snapshotFlow.
// Only the snapshot read triggers work — the root composable does NOT recompose on
// every streamed character, eliminating the recomposition storm that previously
// re-drew the entire screen (top bar, input, background) at 60+ fps.
val autoScrollKey by remember {
derivedStateOf { messages.size to (messages.lastOrNull()?.content?.length ?: 0) }
}
LaunchedEffect(Unit) {
snapshotFlow { autoScrollKey }.collect {
if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
}
}
// Single source of truth Screen routing
@ -341,6 +384,27 @@ fun ChatScreen(
)
}
// Error banner when initial data load fails
if (loadError != null) {
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = 62.dp, start = 24.dp, end = 24.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFFDC2626).copy(alpha = 0.15f))
.border(1.dp, Color(0xFFDC2626).copy(alpha = 0.3f), RoundedCornerShape(12.dp))
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(
loadError!!,
color = Color(0xFFDC2626),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
}
}
// 3. Floating Bottom Control Dock (responsive centering and clear bottom margins)
Column(
modifier = Modifier

View file

@ -69,7 +69,7 @@ private fun groupFor(m: KaizenModel): String = when {
private fun buildGroups(models: List<KaizenModel>, favorites: Set<String>, query: String): List<ModelGroup> {
val q = query.trim().lowercase()
val filtered = if (q.isEmpty()) models
else models.filter { it.name.lowercase().contains(q) || it.id.lowercase().contains(q) }
else models.filter { it.name?.lowercase()?.contains(q) == true || it.id.lowercase().contains(q) }
val groups = mutableListOf<ModelGroup>()
val favs = filtered.filter { it.id in favorites }
@ -267,7 +267,7 @@ private fun ModelRow(
// Model Name & Clean Technical ID
Column(modifier = Modifier.weight(1f)) {
Text(
model.name,
model.name ?: model.id,
color = cs.onBackground,
fontSize = 15.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,

View file

@ -13,8 +13,9 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.ByteArrayOutputStream
import android.util.Log
import java.io.IOException
import java.io.InputStreamReader
import java.util.concurrent.TimeUnit
// --- Wire DTOs (additive v1 contract — ignoreUnknownKeys tolerates new fields) ---
@ -34,7 +35,7 @@ import java.util.concurrent.TimeUnit
@Serializable
data class KaizenModel(
val id: String,
val name: String,
val name: String? = null,
val provider: String? = null,
)
@ -95,6 +96,8 @@ class ChatHttpException(val code: Int) : IOException("chat failed: HTTP $code")
*/
object KaizenApi {
private const val TAG = "KaizenApi"
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // streaming chat — never time out a healthy stream
@ -137,13 +140,13 @@ object KaizenApi {
if (!resp.isSuccessful) return@use null
json.decodeFromString<MeResponse>(resp.body!!.string()).defaultModel
}
} catch (e: IOException) {
} catch (e: Exception) {
null
}
}
/** GET /api/v1/models — the full model catalog for the picker (best-effort, empty on any failure). */
suspend fun fetchModels(baseUrl: String, token: String): List<KaizenModel> =
/** GET /api/v1/models — the full model catalog for the picker. Returns null on failure (with logged reason). */
suspend fun fetchModels(baseUrl: String, token: String): List<KaizenModel>? =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/models")
@ -151,16 +154,20 @@ object KaizenApi {
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@use emptyList()
if (!resp.isSuccessful) {
Log.w(TAG, "fetchModels: HTTP ${resp.code} from $baseUrl")
return@use null
}
json.decodeFromString<ModelsResponse>(resp.body!!.string()).models
}
} catch (e: IOException) {
emptyList()
} catch (e: Exception) {
Log.w(TAG, "fetchModels: ${e.javaClass.simpleName}: ${e.message}")
null
}
}
/** GET /api/v1/conversations — the sidebar list (best-effort, empty on any failure). */
suspend fun fetchConversations(baseUrl: String, token: String): List<ConversationSummary> =
/** GET /api/v1/conversations — the sidebar list. Returns null on failure (with logged reason). */
suspend fun fetchConversations(baseUrl: String, token: String): List<ConversationSummary>? =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations")
@ -168,11 +175,15 @@ object KaizenApi {
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@use emptyList()
if (!resp.isSuccessful) {
Log.w(TAG, "fetchConversations: HTTP ${resp.code} from $baseUrl")
return@use null
}
json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations
}
} catch (e: IOException) {
emptyList()
} catch (e: Exception) {
Log.w(TAG, "fetchConversations: ${e.javaClass.simpleName}: ${e.message}")
null
}
}
@ -187,10 +198,14 @@ object KaizenApi {
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@use null
if (!resp.isSuccessful) {
Log.w(TAG, "createConversation: HTTP ${resp.code}")
return@use null
}
json.decodeFromString<CreatedConversation>(resp.body!!.string()).id
}
} catch (e: IOException) {
} catch (e: Exception) {
Log.w(TAG, "createConversation: ${e.javaClass.simpleName}: ${e.message}")
null
}
}
@ -204,10 +219,14 @@ object KaizenApi {
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@use emptyList()
if (!resp.isSuccessful) {
Log.w(TAG, "fetchMessages($id): HTTP ${resp.code}")
return@use emptyList()
}
json.decodeFromString<ConversationDetail>(resp.body!!.string()).messages
}
} catch (e: IOException) {
} catch (e: Exception) {
Log.w(TAG, "fetchMessages($id): ${e.javaClass.simpleName}: ${e.message}")
emptyList()
}
}
@ -222,8 +241,12 @@ object KaizenApi {
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { resp -> resp.isSuccessful }
} catch (e: IOException) {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) Log.w(TAG, "saveMessages($id): HTTP ${resp.code}")
resp.isSuccessful
}
} catch (e: Exception) {
Log.w(TAG, "saveMessages($id): ${e.javaClass.simpleName}: ${e.message}")
false
}
}
@ -238,7 +261,7 @@ object KaizenApi {
.build()
try {
client.newCall(req).execute().use { }
} catch (e: IOException) {
} catch (e: Exception) {
// best-effort: a missing title is cosmetic
}
}
@ -246,8 +269,12 @@ object KaizenApi {
/**
* POST /api/v1/chat streams the assistant reply. Emits the cumulative *visible*
* text after each network chunk (sentinels stripped), so the caller just assigns
* it to the message content. The full byte buffer is re-decoded each chunk to keep
* UTF-8 multi-byte sequences that span chunk boundaries intact.
* it to the message content.
*
* Uses [InputStreamReader] for correct UTF-8 multi-byte boundary handling and
* [StreamConsumer.Incremental] for O(chunk-size) parsing each character is
* visited exactly once across the entire stream, avoiding the O() cost of
* re-decoding and re-scanning the full buffer on every chunk.
*
* [speed] maps to the additive `reasoningEffort`/`preferThroughput` v1 fields.
*/
@ -280,14 +307,29 @@ object KaizenApi {
}
resp.body!!.byteStream().use { input ->
val raw = ByteArrayOutputStream()
val buf = ByteArray(4096)
val reader = InputStreamReader(input, Charsets.UTF_8)
val consumer = StreamConsumer.Incremental()
val charBuf = CharArray(4096)
while (true) {
val n = input.read(buf)
val n = reader.read(charBuf)
if (n == -1) break
raw.write(buf, 0, n)
emit(StreamConsumer.visibleText(raw.toByteArray().decodeToString()))
emit(consumer.append(charBuf, 0, n))
}
}
}.flowOn(Dispatchers.IO)
/**
* Warm the OkHttp HTTP/2 connection pool against [baseUrl]. Fire-and-forget
* GET to the unauth `/api/v1/meta` endpoint eliminates the cold TCP/TLS
* handshake (~150-300ms on mobile) from the first real request after app
* start or background resume.
*/
suspend fun prewarm(baseUrl: String) = withContext(Dispatchers.IO) {
try {
val req = Request.Builder().url("$baseUrl/api/v1/meta").build()
client.newCall(req).execute().close()
} catch (_: Exception) {
// best-effort — a failed prewarm just means the first real request pays the cold cost
}
}
}

View file

@ -21,6 +21,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }