feat: branch navigation for edit/regenerate instead of destructive delete
Edit creates a sibling branch (same parentId) instead of deleting messages. Regenerate creates a new assistant branch. BranchNavigator UI shows chevron arrows with "1/3" counter on messages with siblings. Tree state (activeRootId, activeChild) synced to server via PATCH. New ChatTree data structure mirrors the web frontend's tree.ts. StoredMessage.parentId parsed from API response.
This commit is contained in:
parent
7ba42338c5
commit
287cdb4c6b
7 changed files with 309 additions and 70 deletions
|
|
@ -280,7 +280,7 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
|
||||||
| `fb6bc98` | **Web search provider picker** — for Vertex models: Google vs Enterprise Web Search dropdown pill, `webSearchProvider` sent in chat request |
|
| `fb6bc98` | **Web search provider picker** — for Vertex models: Google vs Enterprise Web Search dropdown pill, `webSearchProvider` sent in chat request |
|
||||||
| `1899f2c` | **Voice message display** — audio-only user messages render as compact "🎤 Sprachnachricht" pill instead of ugly filename chip |
|
| `1899f2c` | **Voice message display** — audio-only user messages render as compact "🎤 Sprachnachricht" pill instead of ugly filename chip |
|
||||||
| `44ff82a` | **Voice message ordering fix** — caches user + assistant messages in Room with correct `sortOrder` after server save (was missing, caused wrong order on reload) |
|
| `44ff82a` | **Voice message ordering fix** — caches user + assistant messages in Room with correct `sortOrder` after server save (was missing, caused wrong order on reload) |
|
||||||
| `8f72afd` | **Edit and resend user messages** — SquarePen icon on user messages, removes message + all after it, puts text back in input field |
|
| `8f72afd` | **Edit and resend user messages** — SquarePen icon on user messages, creates branch (old messages preserved), text back in input field |
|
||||||
| `b2df7e9` | **Modern message action icons** — ClipboardCopy (copy), SquarePen (edit), RotateCcw (regenerate), 36dp touch target, 20dp icons |
|
| `b2df7e9` | **Modern message action icons** — ClipboardCopy (copy), SquarePen (edit), RotateCcw (regenerate), 36dp touch target, 20dp icons |
|
||||||
| `421b29e` | **ReasoningBlock + SourceCards redesign** — accent-tinted background + gradient borders, KaizenShapes.md (16dp radius), domain instead of full URL in source cards |
|
| `421b29e` | **ReasoningBlock + SourceCards redesign** — accent-tinted background + gradient borders, KaizenShapes.md (16dp radius), domain instead of full URL in source cards |
|
||||||
| `75ca8c0` | **Visual refresh** — body text W400→W300 (airier), headings scaled up (H1 26sp, H2 22sp, H3 18sp), `animateItem()` on messages (350ms fade-in, spring placement), blobs 20% larger + better distribution, higher alpha on OLED, light-mode factor 0.65→0.78, distinct `primaryDeep` per theme |
|
| `75ca8c0` | **Visual refresh** — body text W400→W300 (airier), headings scaled up (H1 26sp, H2 22sp, H3 18sp), `animateItem()` on messages (350ms fade-in, spring placement), blobs 20% larger + better distribution, higher alpha on OLED, light-mode factor 0.65→0.78, distinct `primaryDeep` per theme |
|
||||||
|
|
@ -292,6 +292,7 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
|
||||||
| `1c9491e` | **Text selection** — `SelectionContainer` on user + assistant messages. Long-press to select, copy, share |
|
| `1c9491e` | **Text selection** — `SelectionContainer` on user + assistant messages. Long-press to select, copy, share |
|
||||||
| `1c9491e` | **Markdown spacing** — heading spacing 18→24/20dp, list items 8dp gap + muted bullets, blockquote 12dp inset |
|
| `1c9491e` | **Markdown spacing** — heading spacing 18→24/20dp, list items 8dp gap + muted bullets, blockquote 12dp inset |
|
||||||
| `430d5b6` | **Flat sidebar user row** — removed heavy card (background, border, shadow), replaced with flat row + thin separator |
|
| `430d5b6` | **Flat sidebar user row** — removed heavy card (background, border, shadow), replaced with flat row + thin separator |
|
||||||
|
| — | **Branch navigation (tree support)** — `ChatTree` data structure mirrors web frontend's tree. Edit creates a branch (sibling node with same parentId) instead of deleting messages. Regenerate creates a new assistant branch. `BranchNavigator` UI (chevron arrows + "1/3" counter) on messages with siblings. `activeRootId`/`activeChild` synced to server via PATCH. `StoredMessage.parentId` parsed from API. `patchConversation` handles JSON object values for `activeChild` |
|
||||||
|
|
||||||
**Earlier UI/feel work (Phase 0 prototype → feel-first):**
|
**Earlier UI/feel work (Phase 0 prototype → feel-first):**
|
||||||
- Liquid glass styling, floating panels, glassmorphic sidebar
|
- Liquid glass styling, floating panels, glassmorphic sidebar
|
||||||
|
|
|
||||||
|
|
@ -442,6 +442,49 @@ fun TokenBadge(used: Int, contextLength: Int, modifier: Modifier = Modifier) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BranchNavigator(
|
||||||
|
branchInfo: BranchInfo,
|
||||||
|
onNavigate: (Int) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val cs = MaterialTheme.colorScheme
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(28.dp).clip(KaizenShapes.circle)
|
||||||
|
.clickable(enabled = branchInfo.index > 0) { onNavigate(-1) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
KaizenIcons.ChevronLeft, null,
|
||||||
|
tint = cs.onSurfaceVariant.copy(alpha = if (branchInfo.index > 0) 0.65f else 0.20f),
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"${branchInfo.index + 1}/${branchInfo.total}",
|
||||||
|
color = cs.onSurfaceVariant.copy(alpha = 0.55f),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier.size(28.dp).clip(KaizenShapes.circle)
|
||||||
|
.clickable(enabled = branchInfo.index < branchInfo.total - 1) { onNavigate(1) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
KaizenIcons.ChevronRight, null,
|
||||||
|
tint = cs.onSurfaceVariant.copy(alpha = if (branchInfo.index < branchInfo.total - 1) 0.65f else 0.20f),
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageRow(
|
fun MessageRow(
|
||||||
message: Message,
|
message: Message,
|
||||||
|
|
@ -451,6 +494,8 @@ fun MessageRow(
|
||||||
onEdit: ((String, String) -> Unit)? = null,
|
onEdit: ((String, String) -> Unit)? = null,
|
||||||
onReadAloud: ((String) -> Unit)? = null,
|
onReadAloud: ((String) -> Unit)? = null,
|
||||||
isPlayingTts: Boolean = false,
|
isPlayingTts: Boolean = false,
|
||||||
|
branchInfo: BranchInfo? = null,
|
||||||
|
onNavigateBranch: ((String, Int) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
|
|
@ -535,7 +580,12 @@ fun MessageRow(
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(top = 6.dp, end = 4.dp),
|
Modifier.padding(top = 6.dp, end = 4.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
if (branchInfo != null && onNavigateBranch != null) {
|
||||||
|
BranchNavigator(branchInfo, onNavigate = { dir -> onNavigateBranch(message.wireId, dir) })
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
}
|
||||||
MessageActionButton(
|
MessageActionButton(
|
||||||
icon = if (copied) KaizenIcons.Check else KaizenIcons.ClipboardCopy,
|
icon = if (copied) KaizenIcons.Check else KaizenIcons.ClipboardCopy,
|
||||||
tint = if (copied) KaizenSemanticColors.success else cs.onSurfaceVariant.copy(alpha = 0.55f),
|
tint = if (copied) KaizenSemanticColors.success else cs.onSurfaceVariant.copy(alpha = 0.55f),
|
||||||
|
|
@ -597,7 +647,12 @@ fun MessageRow(
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(start = 38.dp, top = 6.dp),
|
Modifier.padding(start = 38.dp, top = 6.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
if (branchInfo != null && onNavigateBranch != null) {
|
||||||
|
BranchNavigator(branchInfo, onNavigate = { dir -> onNavigateBranch(message.wireId, dir) })
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
}
|
||||||
MessageActionButton(
|
MessageActionButton(
|
||||||
icon = if (copied) KaizenIcons.Check else KaizenIcons.ClipboardCopy,
|
icon = if (copied) KaizenIcons.Check else KaizenIcons.ClipboardCopy,
|
||||||
tint = if (copied) KaizenSemanticColors.success else cs.onSurfaceVariant.copy(alpha = 0.55f),
|
tint = if (copied) KaizenSemanticColors.success else cs.onSurfaceVariant.copy(alpha = 0.55f),
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ data class Message(
|
||||||
val tools: List<ToolEvent> = emptyList(),
|
val tools: List<ToolEvent> = emptyList(),
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
val sources: List<SearchSource> = emptyList(),
|
val sources: List<SearchSource> = emptyList(),
|
||||||
|
val branchInfo: BranchInfo? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Suggestion(val labelRes: Int, val promptRes: Int, val icon: ImageVector)
|
data class Suggestion(val labelRes: Int, val promptRes: Int, val icon: ImageVector)
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,31 @@ fun ChatScreen(
|
||||||
var chatModel by remember { mutableStateOf<String?>(null) }
|
var chatModel by remember { mutableStateOf<String?>(null) }
|
||||||
var streamJob by remember { mutableStateOf<Job?>(null) }
|
var streamJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
||||||
|
// Tree state for branch navigation
|
||||||
|
var chatTree by remember { mutableStateOf(ChatTree.EMPTY) }
|
||||||
|
val storedMessagesMap = remember { mutableMapOf<String, dev.kaizen.app.net.StoredMessage>() }
|
||||||
|
var editParentId by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
fun rebuildVisibleMessages() {
|
||||||
|
val path = getVisiblePath(chatTree)
|
||||||
|
messages.clear()
|
||||||
|
for (entry in path) {
|
||||||
|
val sm = storedMessagesMap[entry.messageId] ?: continue
|
||||||
|
messages.add(
|
||||||
|
Message(
|
||||||
|
id = nextId++,
|
||||||
|
role = if (sm.role == "assistant") Role.Assistant else Role.User,
|
||||||
|
content = sm.content,
|
||||||
|
wireId = sm.id,
|
||||||
|
attachments = sm.attachments,
|
||||||
|
reasoning = sm.reasoning ?: "",
|
||||||
|
sources = sm.sources ?: emptyList(),
|
||||||
|
branchInfo = entry.branchInfo,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Audio recording state
|
// Audio recording state
|
||||||
var isRecording by remember { mutableStateOf(false) }
|
var isRecording by remember { mutableStateOf(false) }
|
||||||
val recorderRef = remember { mutableStateOf<MediaRecorder?>(null) }
|
val recorderRef = remember { mutableStateOf<MediaRecorder?>(null) }
|
||||||
|
|
@ -309,6 +334,17 @@ fun ChatScreen(
|
||||||
sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) } ?: emptyList(),
|
sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) } ?: emptyList(),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
|
storedMessagesMap[userMsg.wireId] = dev.kaizen.app.net.StoredMessage(
|
||||||
|
id = userMsg.wireId, role = "user", content = "",
|
||||||
|
parentId = userParentId, attachments = listOf(att),
|
||||||
|
)
|
||||||
|
storedMessagesMap[assistantWireId] = dev.kaizen.app.net.StoredMessage(
|
||||||
|
id = assistantWireId, role = "assistant", content = finalContent,
|
||||||
|
parentId = userMsg.wireId, model = cfg.model,
|
||||||
|
reasoning = finalReasoning,
|
||||||
|
sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) },
|
||||||
|
)
|
||||||
|
chatTree = buildChatTree(storedMessagesMap.values.toList(), chatTree.activeRootId, chatTree.activeChild)
|
||||||
}
|
}
|
||||||
if (convIdDeferred != null) {
|
if (convIdDeferred != null) {
|
||||||
chat.conversationRepo.insertOptimistic(cid, "(Voice)")
|
chat.conversationRepo.insertOptimistic(cid, "(Voice)")
|
||||||
|
|
@ -443,6 +479,8 @@ fun ChatScreen(
|
||||||
viewingLockedChat = false
|
viewingLockedChat = false
|
||||||
usedTokens = 0
|
usedTokens = 0
|
||||||
chatModel = null
|
chatModel = null
|
||||||
|
chatTree = ChatTree.EMPTY
|
||||||
|
storedMessagesMap.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unlockAndOpen(summary: ConversationSummary, password: String? = null) {
|
fun unlockAndOpen(summary: ConversationSummary, password: String? = null) {
|
||||||
|
|
@ -462,22 +500,12 @@ fun ChatScreen(
|
||||||
passwordUnlockTarget = null
|
passwordUnlockTarget = null
|
||||||
passwordUnlockError = null
|
passwordUnlockError = null
|
||||||
passwordUnlockForToggle = false
|
passwordUnlockForToggle = false
|
||||||
val stored = KaizenApi.fetchLockedMessages(cfg.baseUrl, cfg.token, summary.id, unlockToken)
|
val data = KaizenApi.fetchLockedConversationData(cfg.baseUrl, cfg.token, summary.id, unlockToken)
|
||||||
if (stored.isNotEmpty()) {
|
if (data.messages.isNotEmpty()) {
|
||||||
messages.clear()
|
storedMessagesMap.clear()
|
||||||
stored.forEach { m ->
|
data.messages.forEach { storedMessagesMap[it.id] = it }
|
||||||
messages.add(
|
chatTree = buildChatTree(data.messages, data.activeRootId, data.activeChild)
|
||||||
Message(
|
rebuildVisibleMessages()
|
||||||
id = nextId++,
|
|
||||||
role = if (m.role == "assistant") Role.Assistant else Role.User,
|
|
||||||
content = m.content,
|
|
||||||
wireId = m.id,
|
|
||||||
attachments = m.attachments,
|
|
||||||
reasoning = m.reasoning ?: "",
|
|
||||||
sources = m.sources ?: emptyList(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
conversationId = summary.id
|
conversationId = summary.id
|
||||||
viewingLockedChat = true
|
viewingLockedChat = true
|
||||||
}
|
}
|
||||||
|
|
@ -546,46 +574,36 @@ fun ChatScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
conversationId = summary.id
|
conversationId = summary.id
|
||||||
val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id)
|
val data = KaizenApi.fetchConversationData(cfg.baseUrl, cfg.token, summary.id)
|
||||||
if (stored.isNotEmpty()) {
|
if (data.messages.isNotEmpty()) {
|
||||||
val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) }
|
val entities = data.messages.mapIndexed { i, m -> m.toEntity(summary.id, i) }
|
||||||
chat.messageRepo.cacheMessages(summary.id, entities)
|
chat.messageRepo.cacheMessages(summary.id, entities)
|
||||||
val lastAssistant = stored.lastOrNull { it.role == "assistant" }
|
val lastAssistant = data.messages.lastOrNull { it.role == "assistant" }
|
||||||
lastAssistant?.model?.let { chatModel = it }
|
lastAssistant?.model?.let { chatModel = it }
|
||||||
usedTokens = stored.sumOf { (it.inputTokens ?: 0) + (it.outputTokens ?: 0) }
|
usedTokens = data.messages.sumOf { (it.inputTokens ?: 0) + (it.outputTokens ?: 0) }
|
||||||
if (stored.size != cachedList.size || stored.zip(cachedList).any { (s, c) -> s.id != c.id || s.content != c.content }) {
|
|
||||||
messages.clear()
|
storedMessagesMap.clear()
|
||||||
stored.forEach { m ->
|
data.messages.forEach { storedMessagesMap[it.id] = it }
|
||||||
messages.add(
|
chatTree = buildChatTree(data.messages, data.activeRootId, data.activeChild)
|
||||||
Message(
|
rebuildVisibleMessages()
|
||||||
id = nextId++,
|
|
||||||
role = if (m.role == "assistant") Role.Assistant else Role.User,
|
|
||||||
content = m.content,
|
|
||||||
wireId = m.id,
|
|
||||||
attachments = m.attachments,
|
|
||||||
reasoning = m.reasoning ?: "",
|
|
||||||
sources = m.sources ?: emptyList(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun send(text: String) {
|
fun send(text: String, branchParentId: String? = null, branchAttachments: List<Attachment> = emptyList()) {
|
||||||
val trimmed = text.trim()
|
val trimmed = text.trim()
|
||||||
val uploadedAtts = pendingFiles.mapNotNull { it.uploaded }
|
val uploadedAtts = if (branchAttachments.isNotEmpty()) branchAttachments else pendingFiles.mapNotNull { it.uploaded }
|
||||||
if (trimmed.isEmpty() && uploadedAtts.isEmpty()) return
|
if (trimmed.isEmpty() && uploadedAtts.isEmpty()) return
|
||||||
if (isStreaming) return
|
if (isStreaming) return
|
||||||
val cfg = session.config ?: return
|
val cfg = session.config ?: return
|
||||||
haptics.click()
|
haptics.click()
|
||||||
|
|
||||||
val userParentId = messages.lastOrNull()?.wireId
|
val userParentId = editParentId ?: branchParentId ?: messages.lastOrNull()?.wireId
|
||||||
|
editParentId = null
|
||||||
val userMsg = Message(nextId++, Role.User, trimmed, attachments = uploadedAtts)
|
val userMsg = Message(nextId++, Role.User, trimmed, attachments = uploadedAtts)
|
||||||
messages.add(userMsg)
|
messages.add(userMsg)
|
||||||
input = ""
|
input = ""
|
||||||
pendingFiles.clear()
|
if (branchAttachments.isEmpty()) pendingFiles.clear()
|
||||||
val assistantId = nextId++
|
val assistantId = nextId++
|
||||||
val assistantWireId = java.util.UUID.randomUUID().toString()
|
val assistantWireId = java.util.UUID.randomUUID().toString()
|
||||||
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
|
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
|
||||||
|
|
@ -681,6 +699,24 @@ fun ChatScreen(
|
||||||
sources = finalSources ?: emptyList(),
|
sources = finalSources ?: emptyList(),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
// Update tree with the new nodes
|
||||||
|
storedMessagesMap[userMsg.wireId] = dev.kaizen.app.net.StoredMessage(
|
||||||
|
id = userMsg.wireId, role = "user", content = trimmed,
|
||||||
|
parentId = userParentId, attachments = uploadedAtts,
|
||||||
|
)
|
||||||
|
storedMessagesMap[assistantWireId] = dev.kaizen.app.net.StoredMessage(
|
||||||
|
id = assistantWireId, role = "assistant", content = finalContent,
|
||||||
|
parentId = userMsg.wireId, model = cfg.model,
|
||||||
|
reasoning = finalReasoning,
|
||||||
|
sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) },
|
||||||
|
)
|
||||||
|
chatTree = buildChatTree(
|
||||||
|
storedMessagesMap.values.toList(),
|
||||||
|
chatTree.activeRootId,
|
||||||
|
chatTree.activeChild,
|
||||||
|
)
|
||||||
|
|
||||||
if (wasNewConversation) {
|
if (wasNewConversation) {
|
||||||
val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
|
val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
|
||||||
if (title != null) {
|
if (title != null) {
|
||||||
|
|
@ -888,24 +924,22 @@ fun ChatScreen(
|
||||||
onRegenerate = if (message.role == Role.Assistant && !isStreaming) { _ ->
|
onRegenerate = if (message.role == Role.Assistant && !isStreaming) { _ ->
|
||||||
val lastUserIdx = messages.indexOfLast { it.role == Role.User && messages.indexOf(it) < messages.indexOf(message) }
|
val lastUserIdx = messages.indexOfLast { it.role == Role.User && messages.indexOf(it) < messages.indexOf(message) }
|
||||||
if (lastUserIdx >= 0) {
|
if (lastUserIdx >= 0) {
|
||||||
val userContent = messages[lastUserIdx].content
|
val userMsg = messages[lastUserIdx]
|
||||||
messages.removeAt(messages.indexOf(message))
|
val parentId = chatTree.nodes[userMsg.wireId]?.parentId
|
||||||
send(userContent)
|
val msgIdx = messages.indexOf(message)
|
||||||
|
if (msgIdx >= 0) {
|
||||||
|
messages.subList(msgIdx, messages.size).clear()
|
||||||
|
}
|
||||||
|
send(userMsg.content, branchParentId = parentId, branchAttachments = userMsg.attachments)
|
||||||
}
|
}
|
||||||
} else null,
|
} else null,
|
||||||
onEdit = if (message.role == Role.User && !isStreaming) { wireId, content ->
|
onEdit = if (message.role == Role.User && !isStreaming) { wireId, content ->
|
||||||
|
val node = chatTree.nodes[wireId]
|
||||||
|
val parentId = node?.parentId
|
||||||
val idx = messages.indexOfFirst { it.wireId == wireId }
|
val idx = messages.indexOfFirst { it.wireId == wireId }
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
val cfg = session.config ?: return@MessageRow
|
messages.subList(idx, messages.size).clear()
|
||||||
val toRemove = messages.subList(idx, messages.size).map { it.wireId }
|
editParentId = parentId
|
||||||
messages.removeAll { it.wireId in toRemove }
|
|
||||||
if (conversationId != null) {
|
|
||||||
scope.launch {
|
|
||||||
toRemove.forEach { id ->
|
|
||||||
KaizenApi.deleteMessage(cfg.baseUrl, cfg.token, conversationId!!, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
input = content
|
input = content
|
||||||
}
|
}
|
||||||
} else null,
|
} else null,
|
||||||
|
|
@ -913,6 +947,24 @@ fun ChatScreen(
|
||||||
readAloud(content, message.id, message.wireId)
|
readAloud(content, message.id, message.wireId)
|
||||||
} else null,
|
} else null,
|
||||||
isPlayingTts = playingTtsForId == message.id,
|
isPlayingTts = playingTtsForId == message.id,
|
||||||
|
branchInfo = message.branchInfo,
|
||||||
|
onNavigateBranch = if (!isStreaming) { wireId, direction ->
|
||||||
|
val newTree = treeNavigate(chatTree, wireId, direction)
|
||||||
|
chatTree = newTree
|
||||||
|
rebuildVisibleMessages()
|
||||||
|
if (conversationId != null) {
|
||||||
|
val cfg = session.config ?: return@MessageRow
|
||||||
|
scope.launch {
|
||||||
|
KaizenApi.patchConversation(
|
||||||
|
cfg.baseUrl, cfg.token, conversationId!!,
|
||||||
|
mapOf(
|
||||||
|
"activeRootId" to newTree.activeRootId,
|
||||||
|
"activeChild" to newTree.activeChild,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
106
app/src/main/java/dev/kaizen/app/chat/ChatTree.kt
Normal file
106
app/src/main/java/dev/kaizen/app/chat/ChatTree.kt
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package dev.kaizen.app.chat
|
||||||
|
|
||||||
|
import dev.kaizen.app.net.StoredMessage
|
||||||
|
|
||||||
|
data class ChatNode(
|
||||||
|
val id: String,
|
||||||
|
val role: String,
|
||||||
|
val parentId: String?,
|
||||||
|
val childIds: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BranchInfo(val index: Int, val total: Int)
|
||||||
|
|
||||||
|
data class VisibleEntry(
|
||||||
|
val messageId: String,
|
||||||
|
val branchInfo: BranchInfo?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatTree(
|
||||||
|
val nodes: Map<String, ChatNode>,
|
||||||
|
val rootIds: List<String>,
|
||||||
|
val activeRootId: String?,
|
||||||
|
val activeChild: Map<String, String>,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val EMPTY = ChatTree(emptyMap(), emptyList(), null, emptyMap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildChatTree(
|
||||||
|
messages: List<StoredMessage>,
|
||||||
|
savedActiveRootId: String?,
|
||||||
|
savedActiveChild: Map<String, String>,
|
||||||
|
): ChatTree {
|
||||||
|
val nodes = mutableMapOf<String, ChatNode>()
|
||||||
|
val rootIds = mutableListOf<String>()
|
||||||
|
|
||||||
|
for (msg in messages) {
|
||||||
|
nodes[msg.id] = ChatNode(
|
||||||
|
id = msg.id,
|
||||||
|
role = msg.role,
|
||||||
|
parentId = msg.parentId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (msg in messages) {
|
||||||
|
val pid = msg.parentId
|
||||||
|
if (pid != null && nodes.containsKey(pid)) {
|
||||||
|
val parent = nodes[pid]!!
|
||||||
|
nodes[pid] = parent.copy(childIds = parent.childIds + msg.id)
|
||||||
|
} else if (pid == null) {
|
||||||
|
rootIds.add(msg.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val activeRootId = if (savedActiveRootId != null && nodes.containsKey(savedActiveRootId))
|
||||||
|
savedActiveRootId
|
||||||
|
else rootIds.lastOrNull()
|
||||||
|
|
||||||
|
return ChatTree(nodes, rootIds, activeRootId, savedActiveChild)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getVisiblePath(tree: ChatTree): List<VisibleEntry> {
|
||||||
|
if (tree.rootIds.isEmpty()) return emptyList()
|
||||||
|
val activeRootId = tree.activeRootId ?: tree.rootIds.last()
|
||||||
|
val result = mutableListOf<VisibleEntry>()
|
||||||
|
var currentId: String? = activeRootId
|
||||||
|
|
||||||
|
while (currentId != null) {
|
||||||
|
val node = tree.nodes[currentId] ?: break
|
||||||
|
val siblings = if (node.parentId == null) {
|
||||||
|
tree.rootIds
|
||||||
|
} else {
|
||||||
|
tree.nodes[node.parentId]?.childIds ?: listOf(node.id)
|
||||||
|
}
|
||||||
|
val branchInfo = if (siblings.size > 1) {
|
||||||
|
BranchInfo(index = siblings.indexOf(node.id), total = siblings.size)
|
||||||
|
} else null
|
||||||
|
|
||||||
|
result.add(VisibleEntry(node.id, branchInfo))
|
||||||
|
|
||||||
|
if (node.childIds.isEmpty()) break
|
||||||
|
currentId = tree.activeChild[node.id] ?: node.childIds.last()
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun treeNavigate(tree: ChatTree, nodeId: String, direction: Int): ChatTree {
|
||||||
|
val node = tree.nodes[nodeId] ?: return tree
|
||||||
|
val siblings = if (node.parentId == null) {
|
||||||
|
tree.rootIds
|
||||||
|
} else {
|
||||||
|
tree.nodes[node.parentId]?.childIds ?: return tree
|
||||||
|
}
|
||||||
|
val idx = siblings.indexOf(nodeId)
|
||||||
|
val newIdx = idx + direction
|
||||||
|
if (newIdx < 0 || newIdx >= siblings.size) return tree
|
||||||
|
val newActiveId = siblings[newIdx]
|
||||||
|
|
||||||
|
return if (node.parentId == null) {
|
||||||
|
tree.copy(activeRootId = newActiveId)
|
||||||
|
} else {
|
||||||
|
tree.copy(activeChild = tree.activeChild + (node.parentId to newActiveId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -99,6 +99,7 @@ data class StoredMessage(
|
||||||
val id: String,
|
val id: String,
|
||||||
val role: String,
|
val role: String,
|
||||||
val content: String = "",
|
val content: String = "",
|
||||||
|
val parentId: String? = null,
|
||||||
val attachments: List<Attachment> = emptyList(),
|
val attachments: List<Attachment> = emptyList(),
|
||||||
val reasoning: String? = null,
|
val reasoning: String? = null,
|
||||||
val sources: List<SearchSource>? = null,
|
val sources: List<SearchSource>? = null,
|
||||||
|
|
@ -107,7 +108,17 @@ data class StoredMessage(
|
||||||
val outputTokens: Int? = null,
|
val outputTokens: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList())
|
@Serializable private data class ConversationDetail(
|
||||||
|
val messages: List<StoredMessage> = emptyList(),
|
||||||
|
val activeRootId: String? = null,
|
||||||
|
val activeChild: Map<String, String> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ConversationData(
|
||||||
|
val messages: List<StoredMessage> = emptyList(),
|
||||||
|
val activeRootId: String? = null,
|
||||||
|
val activeChild: Map<String, String> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
/** A message to persist. `parentId` chains the linear history; `kind` is always "chat" for the app. */
|
/** A message to persist. `parentId` chains the linear history; `kind` is always "chat" for the app. */
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -309,6 +320,9 @@ object KaizenApi {
|
||||||
|
|
||||||
/** GET /api/v1/conversations/[id] — the stored messages, in order (empty on failure/locked). */
|
/** 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> =
|
suspend fun fetchMessages(baseUrl: String, token: String, id: String): List<StoredMessage> =
|
||||||
|
fetchConversationData(baseUrl, token, id).messages
|
||||||
|
|
||||||
|
suspend fun fetchConversationData(baseUrl: String, token: String, id: String): ConversationData =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val req = Request.Builder()
|
val req = Request.Builder()
|
||||||
.url("$baseUrl/api/v1/conversations/$id")
|
.url("$baseUrl/api/v1/conversations/$id")
|
||||||
|
|
@ -318,13 +332,14 @@ object KaizenApi {
|
||||||
client.newCall(req).execute().use { resp ->
|
client.newCall(req).execute().use { resp ->
|
||||||
if (!resp.isSuccessful) {
|
if (!resp.isSuccessful) {
|
||||||
Log.w(TAG, "fetchMessages($id): HTTP ${resp.code}")
|
Log.w(TAG, "fetchMessages($id): HTTP ${resp.code}")
|
||||||
return@use emptyList()
|
return@use ConversationData()
|
||||||
}
|
}
|
||||||
json.decodeFromString<ConversationDetail>(resp.body!!.string()).messages
|
val detail = json.decodeFromString<ConversationDetail>(resp.body!!.string())
|
||||||
|
ConversationData(detail.messages, detail.activeRootId, detail.activeChild)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "fetchMessages($id): ${e.javaClass.simpleName}: ${e.message}")
|
Log.w(TAG, "fetchMessages($id): ${e.javaClass.simpleName}: ${e.message}")
|
||||||
emptyList()
|
ConversationData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,17 +363,20 @@ object KaizenApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PATCH /api/v1/conversations/[id] — update title, locked, pinned. */
|
/** PATCH /api/v1/conversations/[id] — update title, locked, pinned, activeRootId, activeChild. */
|
||||||
suspend fun patchConversation(baseUrl: String, token: String, id: String, body: Map<String, Any?>): Boolean =
|
suspend fun patchConversation(baseUrl: String, token: String, id: String, body: Map<String, Any?>): Boolean =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val payload = json.encodeToString(kotlinx.serialization.json.JsonObject(
|
fun toJsonElement(v: Any?): kotlinx.serialization.json.JsonElement = when (v) {
|
||||||
body.mapValues { (_, v) ->
|
|
||||||
when (v) {
|
|
||||||
is String -> kotlinx.serialization.json.JsonPrimitive(v)
|
is String -> kotlinx.serialization.json.JsonPrimitive(v)
|
||||||
is Boolean -> kotlinx.serialization.json.JsonPrimitive(v)
|
is Boolean -> kotlinx.serialization.json.JsonPrimitive(v)
|
||||||
|
is Map<*, *> -> kotlinx.serialization.json.JsonObject(
|
||||||
|
v.entries.associate { (k, v2) -> k.toString() to toJsonElement(v2) }
|
||||||
|
)
|
||||||
|
null -> kotlinx.serialization.json.JsonNull
|
||||||
else -> kotlinx.serialization.json.JsonNull
|
else -> kotlinx.serialization.json.JsonNull
|
||||||
}
|
}
|
||||||
}
|
val payload = json.encodeToString(kotlinx.serialization.json.JsonObject(
|
||||||
|
body.mapValues { (_, v) -> toJsonElement(v) }
|
||||||
)).toRequestBody(jsonMedia)
|
)).toRequestBody(jsonMedia)
|
||||||
val req = Request.Builder()
|
val req = Request.Builder()
|
||||||
.url("$baseUrl/api/v1/conversations/$id")
|
.url("$baseUrl/api/v1/conversations/$id")
|
||||||
|
|
@ -414,6 +432,9 @@ object KaizenApi {
|
||||||
|
|
||||||
/** GET /api/v1/conversations/[id] with unlock token — fetch messages of a locked conversation. */
|
/** GET /api/v1/conversations/[id] with unlock token — fetch messages of a locked conversation. */
|
||||||
suspend fun fetchLockedMessages(baseUrl: String, token: String, id: String, unlockToken: String): List<StoredMessage> =
|
suspend fun fetchLockedMessages(baseUrl: String, token: String, id: String, unlockToken: String): List<StoredMessage> =
|
||||||
|
fetchLockedConversationData(baseUrl, token, id, unlockToken).messages
|
||||||
|
|
||||||
|
suspend fun fetchLockedConversationData(baseUrl: String, token: String, id: String, unlockToken: String): ConversationData =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val req = Request.Builder()
|
val req = Request.Builder()
|
||||||
.url("$baseUrl/api/v1/conversations/$id")
|
.url("$baseUrl/api/v1/conversations/$id")
|
||||||
|
|
@ -424,13 +445,14 @@ object KaizenApi {
|
||||||
client.newCall(req).execute().use { resp ->
|
client.newCall(req).execute().use { resp ->
|
||||||
if (!resp.isSuccessful) {
|
if (!resp.isSuccessful) {
|
||||||
Log.w(TAG, "fetchLockedMessages($id): HTTP ${resp.code}")
|
Log.w(TAG, "fetchLockedMessages($id): HTTP ${resp.code}")
|
||||||
return@use emptyList()
|
return@use ConversationData()
|
||||||
}
|
}
|
||||||
json.decodeFromString<ConversationDetail>(resp.body!!.string()).messages
|
val detail = json.decodeFromString<ConversationDetail>(resp.body!!.string())
|
||||||
|
ConversationData(detail.messages, detail.activeRootId, detail.activeChild)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "fetchLockedMessages($id): ${e.javaClass.simpleName}: ${e.message}")
|
Log.w(TAG, "fetchLockedMessages($id): ${e.javaClass.simpleName}: ${e.message}")
|
||||||
emptyList()
|
ConversationData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ object KaizenIcons {
|
||||||
// ── Navigation ──────────────────────────────────────────────────────────
|
// ── Navigation ──────────────────────────────────────────────────────────
|
||||||
val ArrowLeft: ImageVector get() = Lucide.ArrowLeft
|
val ArrowLeft: ImageVector get() = Lucide.ArrowLeft
|
||||||
val ChevronDown: ImageVector get() = Lucide.ChevronDown
|
val ChevronDown: ImageVector get() = Lucide.ChevronDown
|
||||||
|
val ChevronLeft: ImageVector get() = Lucide.ChevronLeft
|
||||||
|
val ChevronRight: ImageVector get() = Lucide.ChevronRight
|
||||||
val ChevronUp: ImageVector get() = Lucide.ChevronUp
|
val ChevronUp: ImageVector get() = Lucide.ChevronUp
|
||||||
val Menu: ImageVector get() = KaizenCustomIcons.Menu
|
val Menu: ImageVector get() = KaizenCustomIcons.Menu
|
||||||
val MoreVertical: ImageVector get() = Lucide.EllipsisVertical
|
val MoreVertical: ImageVector get() = Lucide.EllipsisVertical
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue