Compare commits

..

No commits in common. "1362daee531ed2637f9b57f3bc3e35819f587497" and "e62c5253868b028964194f3f0b66628c96286c16" have entirely different histories.

4 changed files with 27 additions and 100 deletions

View file

@ -13,7 +13,6 @@ 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
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.ime
@ -429,13 +428,7 @@ fun ChatScreen(
val finalSources = finalMsg?.sources?.takeIf { it.isNotEmpty() } val finalSources = finalMsg?.sources?.takeIf { it.isNotEmpty() }
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false) if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
val convId = convIdDeferred?.await()?.also { cid -> val convId = convIdDeferred?.await()?.also { conversationId = it } ?: conversationId
conversationId = cid
scope.launch {
val preview = trimmed.take(60).lines().first()
chat.conversationRepo.insertOptimistic(cid, preview)
}
} ?: conversationId
if (convId != null && finalContent.isNotEmpty()) { if (convId != null && finalContent.isNotEmpty()) {
val saved = KaizenApi.saveMessages( val saved = KaizenApi.saveMessages(
@ -470,12 +463,7 @@ fun ChatScreen(
sources = finalSources ?: emptyList(), sources = finalSources ?: emptyList(),
), ),
)) ))
if (wasNewConversation) { if (wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
if (title != null) {
chat.conversationRepo.updateTitle(convId, title)
}
}
refreshConversations() refreshConversations()
} }
} }
@ -706,61 +694,33 @@ fun ChatScreen(
.statusBarsPadding() .statusBarsPadding()
) { ) {
Row( Row(
modifier = Modifier.padding(start = 14.dp, top = 10.dp, end = 14.dp, bottom = 20.dp), modifier = Modifier.padding(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 20.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
val displayModel = chatModel ?: session.config?.model ?: ""
// Unified top bar: sidebar trigger + model name in one glass pill
GlassSurface( GlassSurface(
shape = KaizenShapes.pill, shape = KaizenShapes.circle,
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()), tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
shadowStack = KaizenShadows.level1, shadowStack = KaizenShadows.level2,
cornerRadius = 20.dp, cornerRadius = 22.dp,
modifier = Modifier
.size(44.dp)
.clickable { haptics.tick(); scope.launch { drawerState.open() } },
) { ) {
Row( Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Modifier.heightIn(min = 38.dp), Icon(
verticalAlignment = Alignment.CenterVertically, imageVector = Icons.Rounded.Menu,
) { contentDescription = context.getString(R.string.chat_open_menu),
// Sidebar trigger area tint = MaterialTheme.colorScheme.onBackground,
Box( modifier = Modifier.size(20.dp)
Modifier
.size(38.dp)
.clip(KaizenShapes.circle)
.clickable { haptics.tick(); scope.launch { drawerState.open() } },
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Rounded.Menu,
contentDescription = context.getString(R.string.chat_open_menu),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(17.dp),
)
}
// Divider dot
Box(
Modifier
.size(3.dp)
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.20f), KaizenShapes.circle)
) )
// Model name — tap opens picker
Box(
Modifier
.clip(KaizenShapes.pill)
.clickable { haptics.tick(); showModelSheet = true }
.padding(start = 10.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
) {
Text(
modelDisplayName(displayModel, models),
color = MaterialTheme.colorScheme.onBackground,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
modifier = Modifier.widthIn(max = 180.dp),
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
}
} }
} }
Spacer(Modifier.width(8.dp))
val displayModel = chatModel ?: session.config?.model ?: ""
ModelPill(
label = modelDisplayName(displayModel, models),
onClick = { haptics.tick(); showModelSheet = true },
)
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
val ctxLen = models.find { it.id == displayModel }?.context_length ?: 1_000_000 val ctxLen = models.find { it.id == displayModel }?.context_length ?: 1_000_000
TokenBadge(used = usedTokens, contextLength = ctxLen) TokenBadge(used = usedTokens, contextLength = ctxLen)

View file

@ -14,26 +14,13 @@ interface ConversationDao {
@Upsert @Upsert
suspend fun upsertAll(conversations: List<ConversationEntity>) suspend fun upsertAll(conversations: List<ConversationEntity>)
@Upsert
suspend fun upsert(conversation: ConversationEntity)
@Query("DELETE FROM conversations") @Query("DELETE FROM conversations")
suspend fun deleteAll() suspend fun deleteAll()
@Query("DELETE FROM conversations WHERE id NOT IN (:keepIds)")
suspend fun deleteExcept(keepIds: List<String>)
@Query("UPDATE conversations SET title = :title WHERE id = :id")
suspend fun updateTitle(id: String, title: String)
@Transaction @Transaction
suspend fun replaceAll(conversations: List<ConversationEntity>) { suspend fun replaceAll(conversations: List<ConversationEntity>) {
if (conversations.isEmpty()) { deleteAll()
deleteAll() upsertAll(conversations)
} else {
upsertAll(conversations)
deleteExcept(conversations.map { it.id })
}
} }
@Query("SELECT cachedAt FROM conversations LIMIT 1") @Query("SELECT cachedAt FROM conversations LIMIT 1")

View file

@ -21,21 +21,6 @@ class ConversationRepository(private val dao: ConversationDao) {
} }
} }
suspend fun insertOptimistic(id: String, title: String) {
dao.upsert(ConversationEntity(
id = id,
title = title,
locked = false,
pinned = false,
updatedAt = java.time.Instant.now().toString(),
cachedAt = System.currentTimeMillis(),
))
}
suspend fun updateTitle(id: String, title: String) {
dao.updateTitle(id, title)
}
suspend fun lastCachedAt(): Long? = dao.lastCachedAt() suspend fun lastCachedAt(): Long? = dao.lastCachedAt()
suspend fun allUnlockedIds(): List<String> = dao.allUnlockedIds() suspend fun allUnlockedIds(): List<String> = dao.allUnlockedIds()

View file

@ -67,7 +67,6 @@ data class ConversationSummary(
@Serializable private data class UnlockResponse(val unlockToken: String = "") @Serializable private data class UnlockResponse(val unlockToken: String = "")
@Serializable private data class CreateConvoRequest(val title: String? = null) @Serializable private data class CreateConvoRequest(val title: String? = null)
@Serializable private data class CreatedConversation(val id: String) @Serializable private data class CreatedConversation(val id: String)
@Serializable private data class TitleResponse(val title: String? = null)
@Serializable data class AppDevice( @Serializable data class AppDevice(
val id: String, val id: String,
@ -444,8 +443,8 @@ object KaizenApi {
} }
} }
/** POST /api/v1/conversations/[id]/title — returns the generated title or null. */ /** POST /api/v1/conversations/[id]/title — fire-and-forget auto-title after the first exchange. */
suspend fun generateTitle(baseUrl: String, token: String, id: String): String? = suspend fun generateTitle(baseUrl: String, token: String, id: String) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val req = Request.Builder() val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$id/title") .url("$baseUrl/api/v1/conversations/$id/title")
@ -453,13 +452,9 @@ 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 { }
if (!resp.isSuccessful) return@use null
val body = resp.body?.string() ?: return@use null
json.decodeFromString<TitleResponse>(body).title
}
} catch (e: Exception) { } catch (e: Exception) {
null // best-effort: a missing title is cosmetic
} }
} }