Compare commits

..

No commits in common. "827a2f307cbb49c645b74517ce7397949b24db67" and "7399c73e6ab02fa0d08b7da87949d4c0a9e7bace" have entirely different histories.

7 changed files with 48 additions and 156 deletions

View file

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

View file

@ -34,14 +34,12 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -49,10 +47,7 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration 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.dp
import androidx.compose.ui.unit.sp
import dev.kaizen.app.haptics.rememberHaptics import dev.kaizen.app.haptics.rememberHaptics
import dev.kaizen.app.net.ChatHttpException import dev.kaizen.app.net.ChatHttpException
import dev.kaizen.app.net.ChatSpeed import dev.kaizen.app.net.ChatSpeed
@ -64,8 +59,6 @@ import dev.kaizen.app.net.SessionViewModel
import dev.kaizen.app.net.WireMessage import dev.kaizen.app.net.WireMessage
import dev.kaizen.app.settings.SettingsScreen import dev.kaizen.app.settings.SettingsScreen
import dev.kaizen.app.settings.SettingsViewModel import dev.kaizen.app.settings.SettingsViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// Screen enumeration for modular state-driven navigation (Zero Technical Debt) // Screen enumeration for modular state-driven navigation (Zero Technical Debt)
@ -107,40 +100,20 @@ fun ChatScreen(
var conversationId by remember { mutableStateOf<String?>(null) } var conversationId by remember { mutableStateOf<String?>(null) }
var conversations by remember { mutableStateOf<List<ConversationSummary>>(emptyList()) } var conversations by remember { mutableStateOf<List<ConversationSummary>>(emptyList()) }
var loadError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(session.config?.baseUrl, session.config?.token) { LaunchedEffect(session.config?.baseUrl, session.config?.token) {
val cfg = session.config ?: return@LaunchedEffect val cfg = session.config ?: return@LaunchedEffect
loadError = null // Fetch fresh models from server and cache them locally if successful
val fetchedModels = KaizenApi.fetchModels(cfg.baseUrl, cfg.token) val fetchedModels = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)
if (fetchedModels != null) { if (fetchedModels.isNotEmpty()) {
models = fetchedModels models = fetchedModels
if (fetchedModels.isNotEmpty()) session.store.saveModelsCache(fetchedModels) session.store.saveModelsCache(fetchedModels)
} }
val fetchedConvos = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token) conversations = 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() { fun refreshConversations() {
val cfg = session.config ?: return val cfg = session.config ?: return
scope.launch { scope.launch { conversations = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token) }
KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)?.let { conversations = it }
}
} }
/** Start a fresh chat — drops the active conversation, the next send creates a new one. */ /** Start a fresh chat — drops the active conversation, the next send creates a new one. */
@ -195,19 +168,17 @@ fun ChatScreen(
haptics.thinkingStart() haptics.thinkingStart()
var sawContent = false var sawContent = false
try { try {
// Launch conversation creation in parallel — the chat stream does not // Ensure a server conversation exists before the exchange is persisted.
// need a conversation id, only the post-stream persistence does. This val convId = conversationId ?: KaizenApi.createConversation(cfg.baseUrl, cfg.token)?.also {
// eliminates a full network RTT from the TTFT on new chats. conversationId = it
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 -> KaizenApi.chat(cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed).collect { visible ->
val idx = messages.indexOfLast { it.id == assistantId } val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect if (idx < 0) return@collect
if (!sawContent && visible.isNotEmpty()) { if (!sawContent && visible.isNotEmpty()) {
sawContent = true sawContent = true
haptics.responseStart() haptics.responseStart() // the answer begins (reasoning may have streamed first)
} }
messages[idx] = messages[idx].copy( messages[idx] = messages[idx].copy(
thinking = if (sawContent) false else messages[idx].thinking, thinking = if (sawContent) false else messages[idx].thinking,
@ -218,9 +189,6 @@ fun ChatScreen(
val finalContent = if (idx >= 0) messages[idx].content else "" val finalContent = if (idx >= 0) messages[idx].content else ""
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false) 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. // Persist the exchange (best-effort); auto-title + sidebar refresh on a new conversation.
if (convId != null && finalContent.isNotEmpty()) { if (convId != null && finalContent.isNotEmpty()) {
val saved = KaizenApi.saveMessages( val saved = KaizenApi.saveMessages(
@ -235,15 +203,11 @@ fun ChatScreen(
} }
} catch (e: Exception) { } catch (e: Exception) {
val idx = messages.indexOfLast { it.id == assistantId } val idx = messages.indexOfLast { it.id == assistantId }
if (idx >= 0) { if (idx >= 0) messages[idx] = messages[idx].copy(
val existing = messages[idx].content streaming = false,
val errorSuffix = "\n\n${chatErrorText(e)}" thinking = false,
messages[idx] = messages[idx].copy( content = chatErrorText(e),
streaming = false, )
thinking = false,
content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e),
)
}
} finally { } finally {
isStreaming = false isStreaming = false
haptics.responseEnd() haptics.responseEnd()
@ -251,17 +215,10 @@ fun ChatScreen(
} }
} }
// Auto-scroll: isolated from the main composition via derivedStateOf + snapshotFlow. // Keep pinned to the bottom as the stream grows
// Only the snapshot read triggers work — the root composable does NOT recompose on val lastLength = messages.lastOrNull()?.content?.length ?: 0
// every streamed character, eliminating the recomposition storm that previously LaunchedEffect(messages.size, lastLength) {
// re-drew the entire screen (top bar, input, background) at 60+ fps. if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
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 // Single source of truth Screen routing
@ -384,27 +341,6 @@ 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) // 3. Floating Bottom Control Dock (responsive centering and clear bottom margins)
Column( Column(
modifier = Modifier 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> { private fun buildGroups(models: List<KaizenModel>, favorites: Set<String>, query: String): List<ModelGroup> {
val q = query.trim().lowercase() val q = query.trim().lowercase()
val filtered = if (q.isEmpty()) models val filtered = if (q.isEmpty()) models
else models.filter { it.name?.lowercase()?.contains(q) == true || it.id.lowercase().contains(q) } else models.filter { it.name.lowercase().contains(q) || it.id.lowercase().contains(q) }
val groups = mutableListOf<ModelGroup>() val groups = mutableListOf<ModelGroup>()
val favs = filtered.filter { it.id in favorites } val favs = filtered.filter { it.id in favorites }
@ -267,7 +267,7 @@ private fun ModelRow(
// Model Name & Clean Technical ID // Model Name & Clean Technical ID
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
model.name ?: model.id, model.name,
color = cs.onBackground, color = cs.onBackground,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,

View file

@ -13,9 +13,8 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import android.util.Log import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
import java.io.InputStreamReader
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
// --- Wire DTOs (additive v1 contract — ignoreUnknownKeys tolerates new fields) --- // --- Wire DTOs (additive v1 contract — ignoreUnknownKeys tolerates new fields) ---
@ -35,7 +34,7 @@ import java.util.concurrent.TimeUnit
@Serializable @Serializable
data class KaizenModel( data class KaizenModel(
val id: String, val id: String,
val name: String? = null, val name: String,
val provider: String? = null, val provider: String? = null,
) )
@ -96,8 +95,6 @@ class ChatHttpException(val code: Int) : IOException("chat failed: HTTP $code")
*/ */
object KaizenApi { object KaizenApi {
private const val TAG = "KaizenApi"
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // streaming chat — never time out a healthy stream .readTimeout(0, TimeUnit.SECONDS) // streaming chat — never time out a healthy stream
@ -140,13 +137,13 @@ object KaizenApi {
if (!resp.isSuccessful) return@use null if (!resp.isSuccessful) return@use null
json.decodeFromString<MeResponse>(resp.body!!.string()).defaultModel json.decodeFromString<MeResponse>(resp.body!!.string()).defaultModel
} }
} catch (e: Exception) { } catch (e: IOException) {
null null
} }
} }
/** GET /api/v1/models — the full model catalog for the picker. Returns null on failure (with logged reason). */ /** 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>? = suspend fun fetchModels(baseUrl: String, token: String): List<KaizenModel> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val req = Request.Builder() val req = Request.Builder()
.url("$baseUrl/api/v1/models") .url("$baseUrl/api/v1/models")
@ -154,20 +151,16 @@ object KaizenApi {
.build() .build()
try { try {
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) { if (!resp.isSuccessful) return@use emptyList()
Log.w(TAG, "fetchModels: HTTP ${resp.code} from $baseUrl")
return@use null
}
json.decodeFromString<ModelsResponse>(resp.body!!.string()).models json.decodeFromString<ModelsResponse>(resp.body!!.string()).models
} }
} catch (e: Exception) { } catch (e: IOException) {
Log.w(TAG, "fetchModels: ${e.javaClass.simpleName}: ${e.message}") emptyList()
null
} }
} }
/** GET /api/v1/conversations — the sidebar list. Returns null on failure (with logged reason). */ /** GET /api/v1/conversations — the sidebar list (best-effort, empty on any failure). */
suspend fun fetchConversations(baseUrl: String, token: String): List<ConversationSummary>? = suspend fun fetchConversations(baseUrl: String, token: String): List<ConversationSummary> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val req = Request.Builder() val req = Request.Builder()
.url("$baseUrl/api/v1/conversations") .url("$baseUrl/api/v1/conversations")
@ -175,15 +168,11 @@ object KaizenApi {
.build() .build()
try { try {
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) { if (!resp.isSuccessful) return@use emptyList()
Log.w(TAG, "fetchConversations: HTTP ${resp.code} from $baseUrl")
return@use null
}
json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations
} }
} catch (e: Exception) { } catch (e: IOException) {
Log.w(TAG, "fetchConversations: ${e.javaClass.simpleName}: ${e.message}") emptyList()
null
} }
} }
@ -198,14 +187,10 @@ object KaizenApi {
.build() .build()
try { try {
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) { if (!resp.isSuccessful) return@use null
Log.w(TAG, "createConversation: HTTP ${resp.code}")
return@use null
}
json.decodeFromString<CreatedConversation>(resp.body!!.string()).id json.decodeFromString<CreatedConversation>(resp.body!!.string()).id
} }
} catch (e: Exception) { } catch (e: IOException) {
Log.w(TAG, "createConversation: ${e.javaClass.simpleName}: ${e.message}")
null null
} }
} }
@ -219,14 +204,10 @@ object KaizenApi {
.build() .build()
try { try {
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) { if (!resp.isSuccessful) return@use emptyList()
Log.w(TAG, "fetchMessages($id): HTTP ${resp.code}")
return@use emptyList()
}
json.decodeFromString<ConversationDetail>(resp.body!!.string()).messages json.decodeFromString<ConversationDetail>(resp.body!!.string()).messages
} }
} catch (e: Exception) { } catch (e: IOException) {
Log.w(TAG, "fetchMessages($id): ${e.javaClass.simpleName}: ${e.message}")
emptyList() emptyList()
} }
} }
@ -241,12 +222,8 @@ object KaizenApi {
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.build() .build()
try { try {
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp -> resp.isSuccessful }
if (!resp.isSuccessful) Log.w(TAG, "saveMessages($id): HTTP ${resp.code}") } catch (e: IOException) {
resp.isSuccessful
}
} catch (e: Exception) {
Log.w(TAG, "saveMessages($id): ${e.javaClass.simpleName}: ${e.message}")
false false
} }
} }
@ -261,7 +238,7 @@ object KaizenApi {
.build() .build()
try { try {
client.newCall(req).execute().use { } client.newCall(req).execute().use { }
} catch (e: Exception) { } catch (e: IOException) {
// best-effort: a missing title is cosmetic // best-effort: a missing title is cosmetic
} }
} }
@ -269,12 +246,8 @@ object KaizenApi {
/** /**
* POST /api/v1/chat streams the assistant reply. Emits the cumulative *visible* * POST /api/v1/chat streams the assistant reply. Emits the cumulative *visible*
* text after each network chunk (sentinels stripped), so the caller just assigns * text after each network chunk (sentinels stripped), so the caller just assigns
* it to the message content. * 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.
* 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. * [speed] maps to the additive `reasoningEffort`/`preferThroughput` v1 fields.
*/ */
@ -307,29 +280,14 @@ object KaizenApi {
} }
resp.body!!.byteStream().use { input -> resp.body!!.byteStream().use { input ->
val reader = InputStreamReader(input, Charsets.UTF_8) val raw = ByteArrayOutputStream()
val consumer = StreamConsumer.Incremental() val buf = ByteArray(4096)
val charBuf = CharArray(4096)
while (true) { while (true) {
val n = reader.read(charBuf) val n = input.read(buf)
if (n == -1) break if (n == -1) break
emit(consumer.append(charBuf, 0, n)) raw.write(buf, 0, n)
emit(StreamConsumer.visibleText(raw.toByteArray().decodeToString()))
} }
} }
}.flowOn(Dispatchers.IO) }.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,7 +21,6 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 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-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-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-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-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }