Compare commits
No commits in common. "827a2f307cbb49c645b74517ce7397949b24db67" and "7399c73e6ab02fa0d08b7da87949d4c0a9e7bace" have entirely different histories.
827a2f307c
...
7399c73e6a
7 changed files with 48 additions and 156 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
val errorSuffix = "\n\n⚠ ${chatErrorText(e)}"
|
|
||||||
messages[idx] = messages[idx].copy(
|
|
||||||
streaming = false,
|
streaming = false,
|
||||||
thinking = false,
|
thinking = false,
|
||||||
content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e),
|
content = chatErrorText(e),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
isStreaming = false
|
isStreaming = false
|
||||||
haptics.responseEnd()
|
haptics.responseEnd()
|
||||||
|
|
@ -251,18 +215,11 @@ 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.
|
|
||||||
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)
|
if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Single source of truth Screen routing
|
// Single source of truth Screen routing
|
||||||
when (currentScreen) {
|
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)
|
// 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) == 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,
|
||||||
|
|
|
||||||
|
|
@ -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(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.
|
||||||
*/
|
*/
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -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" }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue