diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5687449..9897a4b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index 6033749..3f1c6f4 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -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(null) } var conversations by remember { mutableStateOf>(emptyList()) } + var loadError by remember { mutableStateOf(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 diff --git a/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt b/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt index 7b44a74..4e7127e 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt @@ -69,7 +69,7 @@ private fun groupFor(m: KaizenModel): String = when { private fun buildGroups(models: List, favorites: Set, query: String): List { 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() 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, diff --git a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt index 1aa466f..e6ae8e9 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -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 = + /** 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? = 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(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 = + /** GET /api/v1/conversations — the sidebar list. Returns null on failure (with logged reason). */ + suspend fun fetchConversations(baseUrl: String, token: String): List? = 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(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(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(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 + } + } } diff --git a/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt b/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt index d740c7d..91324b4 100644 Binary files a/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt and b/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt differ diff --git a/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt b/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt index c9bd324..0de9144 100644 Binary files a/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt and b/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 160cffe..ecae944 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }