Compare commits
No commits in common. "ea5e625f0fb04f352e6318e7b348c89bd284c614" and "44c17b6f9f5c2bdd46ec3f8415320469e8109aeb" have entirely different histories.
ea5e625f0f
...
44c17b6f9f
10 changed files with 75 additions and 766 deletions
|
|
@ -36,7 +36,6 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||||
import androidx.compose.ui.draw.blur
|
import androidx.compose.ui.draw.blur
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.BlendMode
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
|
|
@ -52,8 +51,6 @@ import dev.kaizen.app.ui.theme.AmberDeep
|
||||||
import dev.kaizen.app.ui.theme.BlobAmber
|
import dev.kaizen.app.ui.theme.BlobAmber
|
||||||
import dev.kaizen.app.ui.theme.BlobBlue
|
import dev.kaizen.app.ui.theme.BlobBlue
|
||||||
import dev.kaizen.app.ui.theme.BlobTeal
|
import dev.kaizen.app.ui.theme.BlobTeal
|
||||||
import dev.kaizen.app.ui.theme.DITHER_ALPHA
|
|
||||||
import dev.kaizen.app.ui.theme.rememberDitherBrush
|
|
||||||
|
|
||||||
/** Mesh/blob background: large, heavily blurred color circles that drift slowly. */
|
/** Mesh/blob background: large, heavily blurred color circles that drift slowly. */
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -69,17 +66,7 @@ fun MeshBackground(content: @Composable BoxScope.() -> Unit) {
|
||||||
animationSpec = infiniteRepeatable(tween(22000, easing = LinearEasing), RepeatMode.Reverse),
|
animationSpec = infiniteRepeatable(tween(22000, easing = LinearEasing), RepeatMode.Reverse),
|
||||||
label = "drift",
|
label = "drift",
|
||||||
)
|
)
|
||||||
// Dither overlay (drawn last, over everything) — kills 8-bit gradient banding.
|
Box(Modifier.fillMaxSize().background(base)) {
|
||||||
val ditherBrush = rememberDitherBrush()
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(base)
|
|
||||||
.drawWithContent {
|
|
||||||
drawContent()
|
|
||||||
drawRect(brush = ditherBrush, alpha = DITHER_ALPHA)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Blob(BlobAmber, 360.dp, (-60f + drift * 30f).dp, (-40f + drift * 24f).dp, 0.30f * factor)
|
Blob(BlobAmber, 360.dp, (-60f + drift * 30f).dp, (-40f + drift * 24f).dp, 0.30f * factor)
|
||||||
Blob(BlobBlue, 380.dp, (170f - drift * 44f).dp, (150f + drift * 40f).dp, 0.24f * factor)
|
Blob(BlobBlue, 380.dp, (170f - drift * 44f).dp, (150f + drift * 40f).dp, 0.24f * factor)
|
||||||
Blob(BlobTeal, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.22f * factor)
|
Blob(BlobTeal, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.22f * factor)
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,6 @@ data class Message(
|
||||||
val content: String,
|
val content: String,
|
||||||
val streaming: Boolean = false,
|
val streaming: Boolean = false,
|
||||||
val thinking: Boolean = false,
|
val thinking: Boolean = false,
|
||||||
// Stable server-side id (UUID) used for persistence + parentId chaining. Generated
|
|
||||||
// on creation; overwritten with the DB id when loading an existing conversation.
|
|
||||||
val wireId: String = java.util.UUID.randomUUID().toString(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Empty-state suggestion pills (mirrors the web hero)
|
// Empty-state suggestion pills (mirrors the web hero)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
|
@ -50,11 +49,7 @@ import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
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.ConversationSummary
|
|
||||||
import dev.kaizen.app.net.KaizenApi
|
import dev.kaizen.app.net.KaizenApi
|
||||||
import dev.kaizen.app.net.KaizenModel
|
|
||||||
import dev.kaizen.app.net.SaveMessage
|
|
||||||
import dev.kaizen.app.net.SessionViewModel
|
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
|
||||||
|
|
@ -92,67 +87,16 @@ fun ChatScreen(
|
||||||
// Native sidebar drawer state
|
// Native sidebar drawer state
|
||||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||||
|
|
||||||
// Model picker: catalog (best-effort fetch) + sheet visibility
|
|
||||||
var models by remember { mutableStateOf<List<KaizenModel>>(emptyList()) }
|
|
||||||
var showModelSheet by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Conversation persistence: the active server conversation + the sidebar list
|
|
||||||
var conversationId by remember { mutableStateOf<String?>(null) }
|
|
||||||
var conversations by remember { mutableStateOf<List<ConversationSummary>>(emptyList()) }
|
|
||||||
|
|
||||||
LaunchedEffect(session.config?.baseUrl, session.config?.token) {
|
|
||||||
val cfg = session.config ?: return@LaunchedEffect
|
|
||||||
models = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)
|
|
||||||
conversations = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshConversations() {
|
|
||||||
val cfg = session.config ?: return
|
|
||||||
scope.launch { conversations = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Start a fresh chat — drops the active conversation, the next send creates a new one. */
|
|
||||||
fun newChat() {
|
|
||||||
messages.clear()
|
|
||||||
conversationId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load a stored conversation into the linear message list. Locked ones are skipped (no in-app unlock yet). */
|
|
||||||
fun openConversation(summary: ConversationSummary) {
|
|
||||||
if (summary.locked) return
|
|
||||||
val cfg = session.config ?: return
|
|
||||||
scope.launch {
|
|
||||||
val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id)
|
|
||||||
messages.clear()
|
|
||||||
stored.forEach { m ->
|
|
||||||
messages.add(
|
|
||||||
Message(
|
|
||||||
id = nextId++,
|
|
||||||
role = if (m.role == "assistant") Role.Assistant else Role.User,
|
|
||||||
content = m.content,
|
|
||||||
wireId = m.id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
conversationId = summary.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun send(text: String) {
|
fun send(text: String) {
|
||||||
val trimmed = text.trim()
|
val trimmed = text.trim()
|
||||||
if (trimmed.isEmpty() || isStreaming) return
|
if (trimmed.isEmpty() || isStreaming) return
|
||||||
val cfg = session.config ?: return
|
val cfg = session.config ?: return
|
||||||
haptics.click()
|
haptics.click()
|
||||||
// parentId chains the linear history: the user msg hangs off the last message.
|
messages.add(Message(nextId++, Role.User, trimmed))
|
||||||
val userParentId = messages.lastOrNull()?.wireId
|
|
||||||
val userMsg = Message(nextId++, Role.User, trimmed)
|
|
||||||
messages.add(userMsg)
|
|
||||||
input = ""
|
input = ""
|
||||||
val assistantId = nextId++
|
val assistantId = nextId++
|
||||||
val assistantWireId = java.util.UUID.randomUUID().toString()
|
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true))
|
||||||
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
|
|
||||||
isStreaming = true
|
isStreaming = true
|
||||||
val wasNewConversation = conversationId == null
|
|
||||||
|
|
||||||
// Wire history = the visible conversation minus the streaming placeholder.
|
// Wire history = the visible conversation minus the streaming placeholder.
|
||||||
val history = messages
|
val history = messages
|
||||||
|
|
@ -163,12 +107,7 @@ fun ChatScreen(
|
||||||
haptics.thinkingStart()
|
haptics.thinkingStart()
|
||||||
var sawContent = false
|
var sawContent = false
|
||||||
try {
|
try {
|
||||||
// Ensure a server conversation exists before the exchange is persisted.
|
KaizenApi.chat(cfg.baseUrl, cfg.token, cfg.model, history).collect { visible ->
|
||||||
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 }
|
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()) {
|
||||||
|
|
@ -181,21 +120,7 @@ fun ChatScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val idx = messages.indexOfLast { it.id == assistantId }
|
val idx = messages.indexOfLast { it.id == assistantId }
|
||||||
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)
|
||||||
|
|
||||||
// Persist the exchange (best-effort); auto-title + sidebar refresh on a new conversation.
|
|
||||||
if (convId != null && finalContent.isNotEmpty()) {
|
|
||||||
val saved = KaizenApi.saveMessages(
|
|
||||||
cfg.baseUrl, cfg.token, convId,
|
|
||||||
listOf(
|
|
||||||
SaveMessage(id = userMsg.wireId, parentId = userParentId, role = "user", content = trimmed),
|
|
||||||
SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (saved && wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
|
|
||||||
if (saved) refreshConversations()
|
|
||||||
}
|
|
||||||
} 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) messages[idx] = messages[idx].copy(
|
||||||
|
|
@ -232,17 +157,10 @@ fun ChatScreen(
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
KaizenSidebar(
|
KaizenSidebar(
|
||||||
userName = userName,
|
userName = userName,
|
||||||
conversations = conversations,
|
|
||||||
currentId = conversationId,
|
|
||||||
onClose = { scope.launch { drawerState.close() } },
|
onClose = { scope.launch { drawerState.close() } },
|
||||||
onNewChat = {
|
onNewChat = {
|
||||||
haptics.tick()
|
haptics.tick()
|
||||||
newChat()
|
messages.clear()
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
},
|
|
||||||
onSelectConversation = { summary ->
|
|
||||||
haptics.tick()
|
|
||||||
openConversation(summary)
|
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
},
|
},
|
||||||
onOpenSettings = {
|
onOpenSettings = {
|
||||||
|
|
@ -252,7 +170,7 @@ fun ChatScreen(
|
||||||
},
|
},
|
||||||
onLogout = {
|
onLogout = {
|
||||||
haptics.tick()
|
haptics.tick()
|
||||||
newChat()
|
messages.clear()
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
session.logout()
|
session.logout()
|
||||||
}
|
}
|
||||||
|
|
@ -304,35 +222,24 @@ fun ChatScreen(
|
||||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.16f), Color.White.copy(alpha = 0.03f)))
|
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.16f), Color.White.copy(alpha = 0.03f)))
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.padding(start = 16.dp, top = 10.dp, end = 16.dp),
|
.padding(start = 16.dp, top = 10.dp)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
.shadow(elevation = 6.dp, shape = CircleShape, clip = false) // Floats above backgrounds
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(menuGlassBg)
|
||||||
|
.border(1.2.dp, menuGlassBorder, CircleShape)
|
||||||
|
.clickable { haptics.tick(); scope.launch { drawerState.open() } }
|
||||||
|
.size(44.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Box(
|
Icon(
|
||||||
modifier = Modifier
|
imageVector = Icons.Rounded.Menu,
|
||||||
.shadow(elevation = 6.dp, shape = CircleShape, clip = false) // Floats above backgrounds
|
contentDescription = "Menü öffnen",
|
||||||
.clip(CircleShape)
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
.background(menuGlassBg)
|
modifier = Modifier.size(20.dp)
|
||||||
.border(1.2.dp, menuGlassBorder, CircleShape)
|
|
||||||
.clickable { haptics.tick(); scope.launch { drawerState.open() } }
|
|
||||||
.size(44.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Rounded.Menu,
|
|
||||||
contentDescription = "Menü öffnen",
|
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
ModelPill(
|
|
||||||
label = modelDisplayName(session.config?.model ?: "", models),
|
|
||||||
onClick = { haptics.tick(); showModelSheet = true },
|
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -353,20 +260,6 @@ fun ChatScreen(
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp)) // Completely clears keyboard edges on all screens
|
Spacer(Modifier.height(16.dp)) // Completely clears keyboard edges on all screens
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model + speed picker (bottom sheet), opened by the top pill
|
|
||||||
if (showModelSheet) {
|
|
||||||
ModelSheet(
|
|
||||||
models = models,
|
|
||||||
selectedModelId = session.config?.model ?: "",
|
|
||||||
favorites = session.favorites,
|
|
||||||
speed = session.config?.speed ?: ChatSpeed.Normal,
|
|
||||||
onSelectModel = { session.setModel(it); haptics.tick() },
|
|
||||||
onToggleFavorite = { session.toggleFavorite(it) },
|
|
||||||
onSelectSpeed = { session.setSpeed(it); haptics.tick() },
|
|
||||||
onDismiss = { showModelSheet = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,269 +0,0 @@
|
||||||
package dev.kaizen.app.chat
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.Check
|
|
||||||
import androidx.compose.material.icons.rounded.ExpandMore
|
|
||||||
import androidx.compose.material.icons.rounded.Search
|
|
||||||
import androidx.compose.material.icons.rounded.Star
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.SegmentedButton
|
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextField
|
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import dev.kaizen.app.net.ChatSpeed
|
|
||||||
import dev.kaizen.app.net.KaizenModel
|
|
||||||
import dev.kaizen.app.ui.theme.Amber
|
|
||||||
|
|
||||||
/** Human label + composite-id resolver for the top pill — falls back to a de-prefixed id. */
|
|
||||||
fun modelDisplayName(modelId: String, models: List<KaizenModel>): String =
|
|
||||||
models.firstOrNull { it.id == modelId }?.name ?: modelId.substringAfter(":")
|
|
||||||
|
|
||||||
/** The glass top pill showing the active model — tap opens [ModelSheet]. */
|
|
||||||
@Composable
|
|
||||||
fun ModelPill(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
|
||||||
val cs = MaterialTheme.colorScheme
|
|
||||||
Row(
|
|
||||||
modifier = modifier
|
|
||||||
.clip(RoundedCornerShape(22.dp))
|
|
||||||
.background(cs.surface.copy(alpha = 0.7f))
|
|
||||||
.border(1.dp, cs.onSurface.copy(alpha = 0.08f), RoundedCornerShape(22.dp))
|
|
||||||
.clickable { onClick() }
|
|
||||||
.padding(start = 14.dp, end = 8.dp, top = 9.dp, bottom = 9.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
color = cs.onBackground,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
Icons.Rounded.ExpandMore,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = cs.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A logical group of models in the picker (Favorites, Vertex AI, then by publisher). */
|
|
||||||
private data class ModelGroup(val label: String, val models: List<KaizenModel>)
|
|
||||||
|
|
||||||
private fun groupFor(m: KaizenModel): String = when {
|
|
||||||
m.provider == "vertex" -> "Vertex AI"
|
|
||||||
else -> m.id.substringBefore("/", "").ifEmpty { "Andere" }
|
|
||||||
.replaceFirstChar { it.uppercase() }
|
|
||||||
}
|
|
||||||
|
|
||||||
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) || it.id.lowercase().contains(q) }
|
|
||||||
|
|
||||||
val groups = mutableListOf<ModelGroup>()
|
|
||||||
val favs = filtered.filter { it.id in favorites }
|
|
||||||
if (favs.isNotEmpty()) groups += ModelGroup("★ Favoriten", favs)
|
|
||||||
|
|
||||||
// Vertex first, then publishers alphabetically — favorites still appear in their home group too.
|
|
||||||
filtered.groupBy { groupFor(it) }
|
|
||||||
.toSortedMap(compareBy({ if (it == "Vertex AI") "" else it.lowercase() }))
|
|
||||||
.forEach { (label, list) -> groups += ModelGroup(label, list.sortedBy { it.name }) }
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun ModelSheet(
|
|
||||||
models: List<KaizenModel>,
|
|
||||||
selectedModelId: String,
|
|
||||||
favorites: Set<String>,
|
|
||||||
speed: ChatSpeed,
|
|
||||||
onSelectModel: (String) -> Unit,
|
|
||||||
onToggleFavorite: (String) -> Unit,
|
|
||||||
onSelectSpeed: (ChatSpeed) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
val cs = MaterialTheme.colorScheme
|
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
||||||
var query by remember { mutableStateOf("") }
|
|
||||||
val groups = remember(models, favorites, query) { buildGroups(models, favorites, query) }
|
|
||||||
|
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
|
|
||||||
Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
|
|
||||||
// --- Response speed segment ---
|
|
||||||
Text(
|
|
||||||
"Geschwindigkeit",
|
|
||||||
color = cs.onSurfaceVariant,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp),
|
|
||||||
)
|
|
||||||
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
|
|
||||||
val options = ChatSpeed.entries
|
|
||||||
options.forEachIndexed { i, opt ->
|
|
||||||
SegmentedButton(
|
|
||||||
selected = speed == opt,
|
|
||||||
onClick = { onSelectSpeed(opt) },
|
|
||||||
shape = SegmentedButtonDefaults.itemShape(i, options.size),
|
|
||||||
) { Text(speedLabel(opt)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.size(16.dp))
|
|
||||||
|
|
||||||
// --- Search ---
|
|
||||||
TextField(
|
|
||||||
value = query,
|
|
||||||
onValueChange = { query = it },
|
|
||||||
placeholder = { Text("Modell suchen…") },
|
|
||||||
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
|
||||||
disabledIndicatorColor = Color.Transparent,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.size(12.dp))
|
|
||||||
|
|
||||||
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 460.dp)) {
|
|
||||||
if (groups.isEmpty()) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
if (models.isEmpty()) "Modelle werden geladen…" else "Keine Treffer",
|
|
||||||
color = cs.onSurfaceVariant,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
modifier = Modifier.padding(vertical = 24.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
groups.forEach { group ->
|
|
||||||
item(key = "h_${group.label}") {
|
|
||||||
Text(
|
|
||||||
group.label,
|
|
||||||
color = cs.onSurfaceVariant.copy(alpha = 0.6f),
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.padding(top = 14.dp, bottom = 6.dp, start = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
items(group.models, key = { "${group.label}_${it.id}" }) { model ->
|
|
||||||
ModelRow(
|
|
||||||
model = model,
|
|
||||||
selected = model.id == selectedModelId,
|
|
||||||
favorite = model.id in favorites,
|
|
||||||
onClick = { onSelectModel(model.id); onDismiss() },
|
|
||||||
onToggleFavorite = { onToggleFavorite(model.id) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item { Spacer(Modifier.size(24.dp)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ModelRow(
|
|
||||||
model: KaizenModel,
|
|
||||||
selected: Boolean,
|
|
||||||
favorite: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onToggleFavorite: () -> Unit,
|
|
||||||
) {
|
|
||||||
val cs = MaterialTheme.colorScheme
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(if (selected) Amber.copy(alpha = 0.12f) else Color.Transparent)
|
|
||||||
.clickable { onClick() }
|
|
||||||
.padding(horizontal = 8.dp, vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.clip(RoundedCornerShape(10.dp))
|
|
||||||
.clickable { onToggleFavorite() },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Rounded.Star,
|
|
||||||
contentDescription = "Favorit",
|
|
||||||
tint = if (favorite) Amber else cs.onSurfaceVariant.copy(alpha = 0.35f),
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.size(4.dp))
|
|
||||||
Text(
|
|
||||||
model.name,
|
|
||||||
color = cs.onBackground,
|
|
||||||
fontSize = 15.sp,
|
|
||||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
if (model.provider == "vertex") {
|
|
||||||
VertexBadge()
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
}
|
|
||||||
if (selected) {
|
|
||||||
Icon(Icons.Rounded.Check, contentDescription = "Ausgewählt", tint = Amber, modifier = Modifier.size(20.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun VertexBadge() {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.clip(RoundedCornerShape(6.dp))
|
|
||||||
.background(Amber.copy(alpha = 0.16f))
|
|
||||||
.padding(horizontal = 7.dp, vertical = 2.dp),
|
|
||||||
) {
|
|
||||||
Text("Vertex", color = Amber, fontSize = 10.sp, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun speedLabel(s: ChatSpeed): String = when (s) {
|
|
||||||
ChatSpeed.Instant -> "Instant"
|
|
||||||
ChatSpeed.Normal -> "Normal"
|
|
||||||
ChatSpeed.Max -> "Max"
|
|
||||||
}
|
|
||||||
|
|
@ -46,35 +46,35 @@ import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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 androidx.compose.ui.unit.sp
|
||||||
import dev.kaizen.app.net.ConversationSummary
|
|
||||||
import dev.kaizen.app.ui.theme.Amber
|
import dev.kaizen.app.ui.theme.Amber
|
||||||
import java.time.Instant
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/** Date-bucket header for a conversation, from its ISO `updatedAt` (Heute/Gestern/dd. MMM yyyy). */
|
// Mock sidebar history item representing a past conversation
|
||||||
private fun dateLabel(updatedAt: String?): String {
|
data class ConvoHistoryItem(
|
||||||
val instant = runCatching { Instant.parse(updatedAt) }.getOrNull() ?: return "FRÜHER"
|
val id: String,
|
||||||
val date = instant.atZone(ZoneId.systemDefault()).toLocalDate()
|
val title: String,
|
||||||
val today = LocalDate.now()
|
val dateGroup: String,
|
||||||
return when (date) {
|
val locked: Boolean = false,
|
||||||
today -> "HEUTE"
|
val pinned: Boolean = false
|
||||||
today.minusDays(1) -> "GESTERN"
|
)
|
||||||
else -> date.format(DateTimeFormatter.ofPattern("dd. MMM yyyy", Locale.GERMAN)).uppercase()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pinned first, then chronological date buckets — preserves the API's desc-updatedAt order. */
|
// Authentically mirrored mock history from sidebar.png
|
||||||
private fun groupConversations(conversations: List<ConversationSummary>): Map<String, List<ConversationSummary>> {
|
val mockConvoHistory = listOf(
|
||||||
val groups = LinkedHashMap<String, MutableList<ConversationSummary>>()
|
ConvoHistoryItem("1", "Planetenübersicht P...", "16. JUNI 2026"),
|
||||||
conversations.filter { it.pinned }.takeIf { it.isNotEmpty() }?.let { groups["★ ANGEHEFTET"] = it.toMutableList() }
|
ConvoHistoryItem("2", "Orbitaltheorie in der ...", "16. JUNI 2026", locked = true),
|
||||||
conversations.filterNot { it.pinned }.forEach {
|
ConvoHistoryItem("3", "Wetter in Magdeburg", "16. JUNI 2026"),
|
||||||
groups.getOrPut(dateLabel(it.updatedAt)) { mutableListOf() }.add(it)
|
ConvoHistoryItem("4", "Aktuelle Uhrzeit in To...", "16. JUNI 2026"),
|
||||||
}
|
|
||||||
return groups
|
ConvoHistoryItem("5", "📷 Aussehen und Styl...", "15. JUNI 2026"),
|
||||||
}
|
ConvoHistoryItem("6", "👤 Objektive Bewertu...", "15. JUNI 2026"),
|
||||||
|
|
||||||
|
ConvoHistoryItem("7", "Beziehungen und ihre...", "14. JUNI 2026"),
|
||||||
|
|
||||||
|
ConvoHistoryItem("8", "Online-Test erfolgreic...", "12. JUNI 2026"),
|
||||||
|
|
||||||
|
ConvoHistoryItem("9", "Hallo Gemini", "11. JUNI 2026"),
|
||||||
|
|
||||||
|
ConvoHistoryItem("10", "Gesperrter Chat", "10. JUNI 2026", locked = true)
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Floating glassmorphic sidebar drawer content.
|
* Floating glassmorphic sidebar drawer content.
|
||||||
|
|
@ -85,11 +85,8 @@ private fun groupConversations(conversations: List<ConversationSummary>): Map<St
|
||||||
@Composable
|
@Composable
|
||||||
fun KaizenSidebar(
|
fun KaizenSidebar(
|
||||||
userName: String,
|
userName: String,
|
||||||
conversations: List<ConversationSummary>,
|
|
||||||
currentId: String?,
|
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onNewChat: () -> Unit,
|
onNewChat: () -> Unit,
|
||||||
onSelectConversation: (ConversationSummary) -> Unit,
|
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
|
|
@ -215,39 +212,27 @@ fun KaizenSidebar(
|
||||||
|
|
||||||
// --- BODY Sektion: Scrollable Chats list grouped by Date ---
|
// --- BODY Sektion: Scrollable Chats list grouped by Date ---
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
if (conversations.isEmpty()) {
|
val groupedHistory = remember { mockConvoHistory.groupBy { it.dateGroup } }
|
||||||
Text(
|
|
||||||
text = "Noch keine Chats",
|
LazyColumn(
|
||||||
color = cs.onSurfaceVariant.copy(alpha = 0.5f),
|
modifier = Modifier.fillMaxSize(),
|
||||||
fontSize = 14.sp,
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
modifier = Modifier.padding(start = 12.dp, top = 12.dp)
|
) {
|
||||||
)
|
groupedHistory.forEach { (dateGroup, items) ->
|
||||||
} else {
|
// Date header (e.g., 16. JUNI 2026)
|
||||||
val grouped = remember(conversations) { groupConversations(conversations) }
|
item(key = "header_$dateGroup") {
|
||||||
|
Text(
|
||||||
LazyColumn(
|
text = dateGroup,
|
||||||
modifier = Modifier.fillMaxSize(),
|
color = cs.onSurfaceVariant.copy(alpha = 0.45f),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
fontSize = 11.sp,
|
||||||
) {
|
fontWeight = FontWeight.Bold,
|
||||||
grouped.forEach { (dateGroup, items) ->
|
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp)
|
||||||
// Date header (e.g., HEUTE, 16. JUN 2026)
|
)
|
||||||
item(key = "header_$dateGroup") {
|
}
|
||||||
Text(
|
|
||||||
text = dateGroup,
|
// History items
|
||||||
color = cs.onSurfaceVariant.copy(alpha = 0.45f),
|
items(items, key = { it.id }) { item ->
|
||||||
fontSize = 11.sp,
|
HistoryItemRow(item, onClick = { onClose() })
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
items(items, key = { it.id }) { item ->
|
|
||||||
HistoryItemRow(
|
|
||||||
item = item,
|
|
||||||
selected = item.id == currentId,
|
|
||||||
onClick = { onSelectConversation(item) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -336,20 +321,12 @@ fun KaizenSidebar(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HistoryItemRow(item: ConversationSummary, selected: Boolean, onClick: () -> Unit) {
|
private fun HistoryItemRow(item: ConvoHistoryItem, onClick: () -> Unit) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
|
|
||||||
val itemBg = when {
|
val itemBg = if (isDark) Color(0x0AFFFFFF) else Color(0x05000000)
|
||||||
selected -> Amber.copy(alpha = 0.14f)
|
val itemBorder = if (isDark) Color.White.copy(alpha = 0.04f) else Color.Black.copy(alpha = 0.03f)
|
||||||
isDark -> Color(0x0AFFFFFF)
|
|
||||||
else -> Color(0x05000000)
|
|
||||||
}
|
|
||||||
val itemBorder = when {
|
|
||||||
selected -> Amber.copy(alpha = 0.35f)
|
|
||||||
isDark -> Color.White.copy(alpha = 0.04f)
|
|
||||||
else -> Color.Black.copy(alpha = 0.03f)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
||||||
|
|
@ -23,59 +23,7 @@ import java.util.concurrent.TimeUnit
|
||||||
@Serializable private data class TokenResponse(val token: String)
|
@Serializable private data class TokenResponse(val token: String)
|
||||||
@Serializable private data class MeResponse(val defaultModel: String? = null)
|
@Serializable private data class MeResponse(val defaultModel: String? = null)
|
||||||
@Serializable data class WireMessage(val role: String, val content: String)
|
@Serializable data class WireMessage(val role: String, val content: String)
|
||||||
@Serializable private data class ChatRequest(
|
@Serializable private data class ChatRequest(val model: String, val messages: List<WireMessage>)
|
||||||
val model: String,
|
|
||||||
val messages: List<WireMessage>,
|
|
||||||
val reasoningEffort: String? = null,
|
|
||||||
val preferThroughput: Boolean? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** One entry from GET /api/v1/models — the picker's row. `provider == "vertex"` drives the hoster badge. */
|
|
||||||
@Serializable
|
|
||||||
data class KaizenModel(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val provider: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable private data class ModelsResponse(val models: List<KaizenModel> = emptyList())
|
|
||||||
|
|
||||||
/** A conversation as shown in the sidebar list. Extra fields from the API are ignored. */
|
|
||||||
@Serializable
|
|
||||||
data class ConversationSummary(
|
|
||||||
val id: String,
|
|
||||||
val title: String = "",
|
|
||||||
val locked: Boolean = false,
|
|
||||||
val pinned: Boolean = false,
|
|
||||||
val updatedAt: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable private data class ConversationsResponse(val conversations: List<ConversationSummary> = emptyList())
|
|
||||||
@Serializable private data class CreateConvoRequest(val title: String? = null)
|
|
||||||
@Serializable private data class CreatedConversation(val id: String)
|
|
||||||
|
|
||||||
/** One persisted message from GET /api/v1/conversations/[id]. */
|
|
||||||
@Serializable
|
|
||||||
data class StoredMessage(
|
|
||||||
val id: String,
|
|
||||||
val role: String,
|
|
||||||
val content: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList())
|
|
||||||
|
|
||||||
/** A message to persist. `parentId` chains the linear history; `kind` is always "chat" for the app. */
|
|
||||||
@Serializable
|
|
||||||
data class SaveMessage(
|
|
||||||
val id: String,
|
|
||||||
val parentId: String?,
|
|
||||||
val role: String,
|
|
||||||
val content: String,
|
|
||||||
val kind: String = "chat",
|
|
||||||
val model: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable private data class SaveMessagesRequest(val messages: List<SaveMessage>)
|
|
||||||
|
|
||||||
/** Outcome of an in-app login attempt — surfaced as distinct UI states on the login screen. */
|
/** Outcome of an in-app login attempt — surfaced as distinct UI states on the login screen. */
|
||||||
sealed interface LoginResult {
|
sealed interface LoginResult {
|
||||||
|
|
@ -142,130 +90,14 @@ 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<KaizenModel> =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val req = Request.Builder()
|
|
||||||
.url("$baseUrl/api/v1/models")
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
client.newCall(req).execute().use { resp ->
|
|
||||||
if (!resp.isSuccessful) return@use emptyList()
|
|
||||||
json.decodeFromString<ModelsResponse>(resp.body!!.string()).models
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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")
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
client.newCall(req).execute().use { resp ->
|
|
||||||
if (!resp.isSuccessful) return@use emptyList()
|
|
||||||
json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/conversations — create one, returns its id (null on failure). */
|
|
||||||
suspend fun createConversation(baseUrl: String, token: String, title: String? = null): String? =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val body = json.encodeToString(CreateConvoRequest(title)).toRequestBody(jsonMedia)
|
|
||||||
val req = Request.Builder()
|
|
||||||
.url("$baseUrl/api/v1/conversations")
|
|
||||||
.post(body)
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
client.newCall(req).execute().use { resp ->
|
|
||||||
if (!resp.isSuccessful) return@use null
|
|
||||||
json.decodeFromString<CreatedConversation>(resp.body!!.string()).id
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /api/v1/conversations/[id] — the stored messages, in order (empty on failure/locked). */
|
|
||||||
suspend fun fetchMessages(baseUrl: String, token: String, id: String): List<StoredMessage> =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val req = Request.Builder()
|
|
||||||
.url("$baseUrl/api/v1/conversations/$id")
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
client.newCall(req).execute().use { resp ->
|
|
||||||
if (!resp.isSuccessful) return@use emptyList()
|
|
||||||
json.decodeFromString<ConversationDetail>(resp.body!!.string()).messages
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/conversations/[id]/messages — persist an exchange. Returns success. */
|
|
||||||
suspend fun saveMessages(baseUrl: String, token: String, id: String, messages: List<SaveMessage>): Boolean =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val body = json.encodeToString(SaveMessagesRequest(messages)).toRequestBody(jsonMedia)
|
|
||||||
val req = Request.Builder()
|
|
||||||
.url("$baseUrl/api/v1/conversations/$id/messages")
|
|
||||||
.post(body)
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
client.newCall(req).execute().use { resp -> resp.isSuccessful }
|
|
||||||
} catch (e: IOException) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/conversations/[id]/title — fire-and-forget auto-title after the first exchange. */
|
|
||||||
suspend fun generateTitle(baseUrl: String, token: String, id: String) =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val req = Request.Builder()
|
|
||||||
.url("$baseUrl/api/v1/conversations/$id/title")
|
|
||||||
.post("{}".toRequestBody(jsonMedia))
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
client.newCall(req).execute().use { }
|
|
||||||
} catch (e: IOException) {
|
|
||||||
// best-effort: a missing title is cosmetic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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. The full byte buffer is re-decoded each chunk to keep
|
||||||
* UTF-8 multi-byte sequences that span chunk boundaries intact.
|
* UTF-8 multi-byte sequences that span chunk boundaries intact.
|
||||||
*
|
|
||||||
* [speed] maps to the additive `reasoningEffort`/`preferThroughput` v1 fields.
|
|
||||||
*/
|
*/
|
||||||
fun chat(
|
fun chat(baseUrl: String, token: String, model: String, messages: List<WireMessage>): Flow<String> = flow {
|
||||||
baseUrl: String,
|
val payload = json.encodeToString(ChatRequest(model, messages)).toRequestBody(jsonMedia)
|
||||||
token: String,
|
|
||||||
model: String,
|
|
||||||
messages: List<WireMessage>,
|
|
||||||
speed: ChatSpeed = ChatSpeed.Normal,
|
|
||||||
): Flow<String> = flow {
|
|
||||||
val payload = json.encodeToString(
|
|
||||||
ChatRequest(
|
|
||||||
model = model,
|
|
||||||
messages = messages,
|
|
||||||
reasoningEffort = speed.reasoningEffort,
|
|
||||||
preferThroughput = if (speed.preferThroughput) true else null,
|
|
||||||
),
|
|
||||||
).toRequestBody(jsonMedia)
|
|
||||||
val req = Request.Builder()
|
val req = Request.Builder()
|
||||||
.url("$baseUrl/api/v1/chat")
|
.url("$baseUrl/api/v1/chat")
|
||||||
.post(payload)
|
.post(payload)
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,7 @@ class SecureStore(context: Context) {
|
||||||
val token = prefs.getString(KEY_TOKEN, null) ?: return null
|
val token = prefs.getString(KEY_TOKEN, null) ?: return null
|
||||||
val model = prefs.getString(KEY_MODEL, null) ?: DEFAULT_MODEL
|
val model = prefs.getString(KEY_MODEL, null) ?: DEFAULT_MODEL
|
||||||
val email = prefs.getString(KEY_EMAIL, null) ?: ""
|
val email = prefs.getString(KEY_EMAIL, null) ?: ""
|
||||||
val speed = prefs.getString(KEY_SPEED, null)
|
return ServerConfig(baseUrl, token, model, email)
|
||||||
?.let { runCatching { ChatSpeed.valueOf(it) }.getOrNull() } ?: ChatSpeed.Normal
|
|
||||||
return ServerConfig(baseUrl, token, model, email, speed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun save(config: ServerConfig) {
|
fun save(config: ServerConfig) {
|
||||||
|
|
@ -44,17 +42,9 @@ class SecureStore(context: Context) {
|
||||||
.putString(KEY_TOKEN, config.token)
|
.putString(KEY_TOKEN, config.token)
|
||||||
.putString(KEY_MODEL, config.model)
|
.putString(KEY_MODEL, config.model)
|
||||||
.putString(KEY_EMAIL, config.email)
|
.putString(KEY_EMAIL, config.email)
|
||||||
.putString(KEY_SPEED, config.speed.name)
|
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Favorite model ids — pinned to the top of the picker. Not sensitive, but lives here to share one store. */
|
|
||||||
fun loadFavorites(): Set<String> = prefs.getStringSet(KEY_FAVORITES, emptySet())!!.toSet()
|
|
||||||
|
|
||||||
fun saveFavorites(ids: Set<String>) {
|
|
||||||
prefs.edit().putStringSet(KEY_FAVORITES, ids).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
prefs.edit().clear().apply()
|
prefs.edit().clear().apply()
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +54,5 @@ class SecureStore(context: Context) {
|
||||||
const val KEY_TOKEN = "token"
|
const val KEY_TOKEN = "token"
|
||||||
const val KEY_MODEL = "model"
|
const val KEY_MODEL = "model"
|
||||||
const val KEY_EMAIL = "email"
|
const val KEY_EMAIL = "email"
|
||||||
const val KEY_SPEED = "speed"
|
|
||||||
const val KEY_FAVORITES = "favorite_models"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,5 @@
|
||||||
package dev.kaizen.app.net
|
package dev.kaizen.app.net
|
||||||
|
|
||||||
/**
|
|
||||||
* Response speed — the user-facing framing of the backend's reasoning/throughput
|
|
||||||
* knobs (`ChatOptions.reasoningEffort` + `preferThroughput`). [Normal] sends no
|
|
||||||
* override (provider default); [Instant] is the web "instant" preset (no reasoning
|
|
||||||
* + `:nitro` throughput routing); [Max] asks for high reasoning effort.
|
|
||||||
*/
|
|
||||||
enum class ChatSpeed(val reasoningEffort: String?, val preferThroughput: Boolean) {
|
|
||||||
Instant(reasoningEffort = "none", preferThroughput = true),
|
|
||||||
Normal(reasoningEffort = null, preferThroughput = false),
|
|
||||||
Max(reasoningEffort = "high", preferThroughput = false),
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app is deployment-agnostic: one binary that points at EITHER the official
|
* The app is deployment-agnostic: one binary that points at EITHER the official
|
||||||
* server OR any self-hosted instance, via one uniform `Authorization: Bearer`
|
* server OR any self-hosted instance, via one uniform `Authorization: Bearer`
|
||||||
|
|
@ -21,14 +9,12 @@ enum class ChatSpeed(val reasoningEffort: String?, val preferThroughput: Boolean
|
||||||
* @param token the `kzn_...` personal access token (stored encrypted, never logged)
|
* @param token the `kzn_...` personal access token (stored encrypted, never logged)
|
||||||
* @param model composite model id sent to /api/v1/chat, e.g. "vertex:gemini-2.5-flash"
|
* @param model composite model id sent to /api/v1/chat, e.g. "vertex:gemini-2.5-flash"
|
||||||
* @param email the signed-in email (for display in the sidebar/settings)
|
* @param email the signed-in email (for display in the sidebar/settings)
|
||||||
* @param speed response-speed preset sent with each chat request
|
|
||||||
*/
|
*/
|
||||||
data class ServerConfig(
|
data class ServerConfig(
|
||||||
val baseUrl: String,
|
val baseUrl: String,
|
||||||
val token: String,
|
val token: String,
|
||||||
val model: String,
|
val model: String,
|
||||||
val email: String,
|
val email: String,
|
||||||
val speed: ChatSpeed = ChatSpeed.Normal,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Default origin pre-filled on the login screen (editable — self-host points it elsewhere). */
|
/** Default origin pre-filled on the login screen (editable — self-host points it elsewhere). */
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,6 @@ class SessionViewModel(private val store: SecureStore) {
|
||||||
var config by mutableStateOf(store.load())
|
var config by mutableStateOf(store.load())
|
||||||
private set
|
private set
|
||||||
|
|
||||||
/** Favorite model ids (pinned to the top of the picker), as Compose snapshot state. */
|
|
||||||
var favorites by mutableStateOf(store.loadFavorites())
|
|
||||||
private set
|
|
||||||
|
|
||||||
val isLoggedIn: Boolean get() = config != null
|
val isLoggedIn: Boolean get() = config != null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -42,34 +38,8 @@ class SessionViewModel(private val store: SecureStore) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Switch the chat model (persisted). No-op if not logged in. */
|
|
||||||
fun setModel(modelId: String) {
|
|
||||||
val cfg = config ?: return
|
|
||||||
if (cfg.model == modelId) return
|
|
||||||
val updated = cfg.copy(model = modelId)
|
|
||||||
store.save(updated)
|
|
||||||
config = updated
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Switch the response-speed preset (persisted). */
|
|
||||||
fun setSpeed(speed: ChatSpeed) {
|
|
||||||
val cfg = config ?: return
|
|
||||||
if (cfg.speed == speed) return
|
|
||||||
val updated = cfg.copy(speed = speed)
|
|
||||||
store.save(updated)
|
|
||||||
config = updated
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Toggle a model id in/out of favorites (persisted). */
|
|
||||||
fun toggleFavorite(modelId: String) {
|
|
||||||
val next = favorites.toMutableSet().apply { if (!add(modelId)) remove(modelId) }
|
|
||||||
store.saveFavorites(next)
|
|
||||||
favorites = next
|
|
||||||
}
|
|
||||||
|
|
||||||
fun logout() {
|
fun logout() {
|
||||||
store.clear()
|
store.clear()
|
||||||
config = null
|
config = null
|
||||||
favorites = emptySet()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
package dev.kaizen.app.ui.theme
|
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
|
||||||
import androidx.compose.ui.graphics.ImageShader
|
|
||||||
import androidx.compose.ui.graphics.ShaderBrush
|
|
||||||
import androidx.compose.ui.graphics.TileMode
|
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cross-version gradient dither.
|
|
||||||
*
|
|
||||||
* 8-bit sRGB radial gradients quantize into visible concentric rings (the
|
|
||||||
* "verpickelt"/banding look on the orb and mesh blobs). Overlaying a tiled,
|
|
||||||
* ~1-LSB black/white/transparent noise pattern trades those hard bands for
|
|
||||||
* imperceptible fine grain — the standard fix that works on every API level
|
|
||||||
* (no AGSL / API-33 gating needed, so it covers minSdk 26).
|
|
||||||
*
|
|
||||||
* The pattern is deterministic (fixed seed) so it never shimmers between
|
|
||||||
* recompositions; ~⅓ black, ⅓ white, ⅓ transparent makes the perturbation
|
|
||||||
* symmetric (darkens and brightens equally) at a low overall [DITHER_ALPHA].
|
|
||||||
*
|
|
||||||
* [DITHER_ALPHA] is the single tuning knob: higher = bands vanish faster but
|
|
||||||
* fine grain creeps in (worst on the near-black Obsidian background); lower =
|
|
||||||
* cleaner but bands may survive. ~0.012–0.018 is the sweet spot — dial on device.
|
|
||||||
*/
|
|
||||||
const val DITHER_ALPHA = 0.015f
|
|
||||||
|
|
||||||
private fun buildNoiseBitmap(size: Int = 160): ImageBitmap {
|
|
||||||
val pixels = IntArray(size * size)
|
|
||||||
val rnd = Random(0x4A1B)
|
|
||||||
for (i in pixels.indices) {
|
|
||||||
pixels[i] = when (rnd.nextInt(3)) {
|
|
||||||
0 -> 0xFF000000.toInt() // darken
|
|
||||||
1 -> 0xFFFFFFFF.toInt() // brighten
|
|
||||||
else -> 0x00000000 // leave untouched
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
|
||||||
bmp.setPixels(pixels, 0, size, 0, 0, size, size)
|
|
||||||
return bmp.asImageBitmap()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A tiled noise [ShaderBrush] for dithering. Build once, fill the whole surface at [DITHER_ALPHA]. */
|
|
||||||
@Composable
|
|
||||||
fun rememberDitherBrush(): ShaderBrush {
|
|
||||||
val image = remember { buildNoiseBitmap() }
|
|
||||||
return remember(image) { ShaderBrush(ImageShader(image, TileMode.Repeated, TileMode.Repeated)) }
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue