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.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,14 +34,12 @@ 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
@ -49,10 +47,7 @@ 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
@ -64,8 +59,6 @@ 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)
@ -107,40 +100,20 @@ 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
loadError = null
// Fetch fresh models from server and cache them locally if successful
val fetchedModels = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)
if (fetchedModels != null) {
if (fetchedModels.isNotEmpty()) {
models = fetchedModels
if (fetchedModels.isNotEmpty()) session.store.saveModelsCache(fetchedModels)
session.store.saveModelsCache(fetchedModels)
}
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 { }
conversations = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)
}
fun refreshConversations() {
val cfg = session.config ?: return
scope.launch {
KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)?.let { conversations = it }
}
scope.launch { conversations = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token) }
}
/** Start a fresh chat — drops the active conversation, the next send creates a new one. */
@ -195,19 +168,17 @@ fun ChatScreen(
haptics.thinkingStart()
var sawContent = false
try {
// 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
// Ensure a server conversation exists before the exchange is persisted.
val convId = conversationId ?: KaizenApi.createConversation(cfg.baseUrl, cfg.token)?.also {
conversationId = it
}
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()
haptics.responseStart() // the answer begins (reasoning may have streamed first)
}
messages[idx] = messages[idx].copy(
thinking = if (sawContent) false else messages[idx].thinking,
@ -218,9 +189,6 @@ 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(
@ -235,15 +203,11 @@ fun ChatScreen(
}
} catch (e: Exception) {
val idx = messages.indexOfLast { it.id == assistantId }
if (idx >= 0) {
val existing = messages[idx].content
val errorSuffix = "\n\n${chatErrorText(e)}"
messages[idx] = messages[idx].copy(
if (idx >= 0) messages[idx] = messages[idx].copy(
streaming = false,
thinking = false,
content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e),
content = chatErrorText(e),
)
}
} finally {
isStreaming = false
haptics.responseEnd()
@ -251,18 +215,11 @@ fun ChatScreen(
}
}
// 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 {
// 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)
}
}
// Single source of truth Screen routing
when (currentScreen) {
@ -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)
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) == true || it.id.lowercase().contains(q) }
else models.filter { it.name.lowercase().contains(q) || 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.id,
model.name,
color = cs.onBackground,
fontSize = 15.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,

View file

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