Compare commits
2 commits
7399c73e6a
...
827a2f307c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
827a2f307c | ||
|
|
d81c6ffda7 |
7 changed files with 156 additions and 48 deletions
|
|
@ -50,6 +50,7 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -34,12 +34,14 @@ 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
|
||||||
|
|
@ -47,7 +49,10 @@ 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
|
||||||
|
|
@ -59,6 +64,8 @@ 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)
|
||||||
|
|
@ -100,20 +107,40 @@ 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
|
||||||
// Fetch fresh models from server and cache them locally if successful
|
loadError = null
|
||||||
val fetchedModels = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)
|
val fetchedModels = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)
|
||||||
if (fetchedModels.isNotEmpty()) {
|
if (fetchedModels != null) {
|
||||||
models = fetchedModels
|
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() {
|
fun refreshConversations() {
|
||||||
val cfg = session.config ?: return
|
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. */
|
/** Start a fresh chat — drops the active conversation, the next send creates a new one. */
|
||||||
|
|
@ -168,17 +195,19 @@ fun ChatScreen(
|
||||||
haptics.thinkingStart()
|
haptics.thinkingStart()
|
||||||
var sawContent = false
|
var sawContent = false
|
||||||
try {
|
try {
|
||||||
// Ensure a server conversation exists before the exchange is persisted.
|
// Launch conversation creation in parallel — the chat stream does not
|
||||||
val convId = conversationId ?: KaizenApi.createConversation(cfg.baseUrl, cfg.token)?.also {
|
// need a conversation id, only the post-stream persistence does. This
|
||||||
conversationId = it
|
// 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 ->
|
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() // the answer begins (reasoning may have streamed first)
|
haptics.responseStart()
|
||||||
}
|
}
|
||||||
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,
|
||||||
|
|
@ -189,6 +218,9 @@ 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(
|
||||||
|
|
@ -203,11 +235,15 @@ 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) messages[idx] = messages[idx].copy(
|
if (idx >= 0) {
|
||||||
streaming = false,
|
val existing = messages[idx].content
|
||||||
thinking = false,
|
val errorSuffix = "\n\n⚠ ${chatErrorText(e)}"
|
||||||
content = chatErrorText(e),
|
messages[idx] = messages[idx].copy(
|
||||||
)
|
streaming = false,
|
||||||
|
thinking = false,
|
||||||
|
content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e),
|
||||||
|
)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isStreaming = false
|
isStreaming = false
|
||||||
haptics.responseEnd()
|
haptics.responseEnd()
|
||||||
|
|
@ -215,10 +251,17 @@ fun ChatScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep pinned to the bottom as the stream grows
|
// Auto-scroll: isolated from the main composition via derivedStateOf + snapshotFlow.
|
||||||
val lastLength = messages.lastOrNull()?.content?.length ?: 0
|
// Only the snapshot read triggers work — the root composable does NOT recompose on
|
||||||
LaunchedEffect(messages.size, lastLength) {
|
// every streamed character, eliminating the recomposition storm that previously
|
||||||
if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
|
// 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
|
// 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)
|
// 3. Floating Bottom Control Dock (responsive centering and clear bottom margins)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
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> {
|
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) || it.id.lowercase().contains(q) }
|
else models.filter { it.name?.lowercase()?.contains(q) == true || 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.name ?: model.id,
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,9 @@ 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 java.io.ByteArrayOutputStream
|
import android.util.Log
|
||||||
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) ---
|
||||||
|
|
@ -34,7 +35,7 @@ import java.util.concurrent.TimeUnit
|
||||||
@Serializable
|
@Serializable
|
||||||
data class KaizenModel(
|
data class KaizenModel(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String? = null,
|
||||||
val provider: String? = null,
|
val provider: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -95,6 +96,8 @@ 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
|
||||||
|
|
@ -137,13 +140,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: IOException) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/v1/models — the full model catalog for the picker (best-effort, empty on any failure). */
|
/** 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> =
|
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")
|
||||||
|
|
@ -151,16 +154,20 @@ object KaizenApi {
|
||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
client.newCall(req).execute().use { resp ->
|
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
|
json.decodeFromString<ModelsResponse>(resp.body!!.string()).models
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} 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). */
|
/** GET /api/v1/conversations — the sidebar list. Returns null on failure (with logged reason). */
|
||||||
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")
|
||||||
|
|
@ -168,11 +175,15 @@ object KaizenApi {
|
||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
client.newCall(req).execute().use { resp ->
|
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
|
json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: Exception) {
|
||||||
emptyList()
|
Log.w(TAG, "fetchConversations: ${e.javaClass.simpleName}: ${e.message}")
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,10 +198,14 @@ object KaizenApi {
|
||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
client.newCall(req).execute().use { resp ->
|
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
|
json.decodeFromString<CreatedConversation>(resp.body!!.string()).id
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "createConversation: ${e.javaClass.simpleName}: ${e.message}")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -204,10 +219,14 @@ object KaizenApi {
|
||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
client.newCall(req).execute().use { resp ->
|
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
|
json.decodeFromString<ConversationDetail>(resp.body!!.string()).messages
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "fetchMessages($id): ${e.javaClass.simpleName}: ${e.message}")
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -222,8 +241,12 @@ object KaizenApi {
|
||||||
.header("Authorization", "Bearer $token")
|
.header("Authorization", "Bearer $token")
|
||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
client.newCall(req).execute().use { resp -> resp.isSuccessful }
|
client.newCall(req).execute().use { resp ->
|
||||||
} catch (e: IOException) {
|
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
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -238,7 +261,7 @@ object KaizenApi {
|
||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
client.newCall(req).execute().use { }
|
client.newCall(req).execute().use { }
|
||||||
} catch (e: IOException) {
|
} catch (e: Exception) {
|
||||||
// best-effort: a missing title is cosmetic
|
// 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*
|
* 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. The full byte buffer is re-decoded each chunk to keep
|
* it to the message content.
|
||||||
* 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(N²) 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.
|
||||||
*/
|
*/
|
||||||
|
|
@ -280,14 +307,29 @@ object KaizenApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.body!!.byteStream().use { input ->
|
resp.body!!.byteStream().use { input ->
|
||||||
val raw = ByteArrayOutputStream()
|
val reader = InputStreamReader(input, Charsets.UTF_8)
|
||||||
val buf = ByteArray(4096)
|
val consumer = StreamConsumer.Incremental()
|
||||||
|
val charBuf = CharArray(4096)
|
||||||
while (true) {
|
while (true) {
|
||||||
val n = input.read(buf)
|
val n = reader.read(charBuf)
|
||||||
if (n == -1) break
|
if (n == -1) break
|
||||||
raw.write(buf, 0, n)
|
emit(consumer.append(charBuf, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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-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" }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue