Compare commits
2 commits
e62c525386
...
1362daee53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1362daee53 | ||
|
|
1929073ad5 |
4 changed files with 100 additions and 27 deletions
|
|
@ -13,6 +13,7 @@ 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
|
||||||
|
|
@ -428,7 +429,13 @@ 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 { conversationId = it } ?: conversationId
|
val convId = convIdDeferred?.await()?.also { cid ->
|
||||||
|
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(
|
||||||
|
|
@ -463,7 +470,12 @@ fun ChatScreen(
|
||||||
sources = finalSources ?: emptyList(),
|
sources = finalSources ?: emptyList(),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
if (wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
|
if (wasNewConversation) {
|
||||||
|
val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
|
||||||
|
if (title != null) {
|
||||||
|
chat.conversationRepo.updateTitle(convId, title)
|
||||||
|
}
|
||||||
|
}
|
||||||
refreshConversations()
|
refreshConversations()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -694,33 +706,61 @@ fun ChatScreen(
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 20.dp),
|
modifier = Modifier.padding(start = 14.dp, top = 10.dp, end = 14.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.circle,
|
shape = KaizenShapes.pill,
|
||||||
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
|
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
|
||||||
shadowStack = KaizenShadows.level2,
|
shadowStack = KaizenShadows.level1,
|
||||||
cornerRadius = 22.dp,
|
cornerRadius = 20.dp,
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clickable { haptics.tick(); scope.launch { drawerState.open() } },
|
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Row(
|
||||||
Icon(
|
Modifier.heightIn(min = 38.dp),
|
||||||
imageVector = Icons.Rounded.Menu,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
contentDescription = context.getString(R.string.chat_open_menu),
|
) {
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
// Sidebar trigger area
|
||||||
modifier = Modifier.size(20.dp)
|
Box(
|
||||||
|
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)
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,26 @@ 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>) {
|
||||||
deleteAll()
|
if (conversations.isEmpty()) {
|
||||||
upsertAll(conversations)
|
deleteAll()
|
||||||
|
} else {
|
||||||
|
upsertAll(conversations)
|
||||||
|
deleteExcept(conversations.map { it.id })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query("SELECT cachedAt FROM conversations LIMIT 1")
|
@Query("SELECT cachedAt FROM conversations LIMIT 1")
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,21 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ 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,
|
||||||
|
|
@ -443,8 +444,8 @@ object KaizenApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/v1/conversations/[id]/title — fire-and-forget auto-title after the first exchange. */
|
/** POST /api/v1/conversations/[id]/title — returns the generated title or null. */
|
||||||
suspend fun generateTitle(baseUrl: String, token: String, id: String) =
|
suspend fun generateTitle(baseUrl: String, token: String, id: String): 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")
|
||||||
|
|
@ -452,9 +453,13 @@ object KaizenApi {
|
||||||
.header("Authorization", "Bearer $token")
|
.header("Authorization", "Bearer $token")
|
||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
client.newCall(req).execute().use { }
|
client.newCall(req).execute().use { resp ->
|
||||||
|
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) {
|
||||||
// best-effort: a missing title is cosmetic
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue