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
This commit is contained in:
parent
d81c6ffda7
commit
827a2f307c
7 changed files with 148 additions and 40 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) ---
|
||||
|
|
@ -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
|
||||
|
|
@ -142,8 +145,8 @@ object KaizenApi {
|
|||
}
|
||||
}
|
||||
|
||||
/** 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: Exception) {
|
||||
emptyList()
|
||||
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: Exception) {
|
||||
emptyList()
|
||||
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: 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: 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 }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(N²) 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue