Skip to content

Commit 9f0682a

Browse files
committed
feat: Implements conversation branching
Adds branching functionality to conversations, allowing users to create new message paths by responding to previous messages. Introduces utilities for navigating the conversation tree, including filtering messages by branch, finding leaf nodes, and managing sibling messages. Also adds a new service API for creating message branches and updating the current conversation node.
1 parent 9911485 commit 9f0682a

File tree

9 files changed

+1083
-128
lines changed

9 files changed

+1083
-128
lines changed

tools/server/public/index.html.gz

3.84 KB
Binary file not shown.

tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte

Lines changed: 92 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import { Button } from '$lib/components/ui/button';
55
import { ChatAttachmentsList, ChatMessageThinkingBlock, MarkdownContent } from '$lib/components/app';
66
import { Tooltip, TooltipContent, TooltipTrigger } from '$lib/components/ui/tooltip';
7+
import MessageBranchingControls from './MessageBranchingControls.svelte';
8+
import type { MessageSiblingInfo } from '$lib/utils/branching';
79
import {
810
AlertDialog,
911
AlertDialogAction,
@@ -12,11 +14,11 @@
1214
AlertDialogDescription,
1315
AlertDialogFooter,
1416
AlertDialogHeader,
15-
AlertDialogTitle,
16-
AlertDialogTrigger
17+
AlertDialogTitle
1718
} from '$lib/components/ui/alert-dialog';
1819
import { copyToClipboard } from '$lib/utils/copy';
1920
import { parseThinkingContent } from '$lib/utils/thinking';
21+
import { getDeletionInfo } from '$lib/stores/chat.svelte';
2022
import { isLoading } from '$lib/stores/chat.svelte';
2123
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
2224
import { fade } from 'svelte/transition';
@@ -25,27 +27,30 @@
2527
interface Props {
2628
class?: string;
2729
message: DatabaseMessage;
28-
onEdit?: (message: DatabaseMessage) => void;
30+
siblingInfo?: MessageSiblingInfo | null;
2931
onCopy?: (message: DatabaseMessage) => void;
30-
onRegenerate?: (message: DatabaseMessage) => void;
31-
onUpdateMessage?: (message: DatabaseMessage, newContent: string) => void;
3232
onDelete?: (message: DatabaseMessage) => void;
33+
onNavigateToSibling?: (siblingId: string) => void;
34+
onEditWithBranching?: (message: DatabaseMessage, newContent: string) => void;
35+
onRegenerateWithBranching?: (message: DatabaseMessage) => void;
3336
}
3437
3538
let {
3639
class: className = '',
3740
message,
38-
onEdit,
41+
siblingInfo = null,
3942
onCopy,
40-
onRegenerate,
41-
onUpdateMessage,
42-
onDelete
43+
onDelete,
44+
onNavigateToSibling,
45+
onEditWithBranching,
46+
onRegenerateWithBranching
4347
}: Props = $props();
4448
45-
let isEditing = $state(false);
49+
let showDeleteDialog = $state(false);
4650
let editedContent = $state(message.content);
51+
let isEditing = $state(false);
52+
let deletionInfo = $state<{ totalCount: number; userMessages: number; assistantMessages: number; messageTypes: string[] } | null>(null);
4753
let textareaElement: HTMLTextAreaElement | undefined = $state();
48-
let showDeleteDialog = $state(false);
4954
5055
const processingState = useProcessingState();
5156
@@ -93,7 +98,6 @@
9398
);
9499
}
95100
}, 0);
96-
onEdit?.(message);
97101
}
98102
99103
function handleEditKeydown(event: KeyboardEvent) {
@@ -107,17 +111,18 @@
107111
}
108112
109113
function handleRegenerate() {
110-
onRegenerate?.(message);
114+
onRegenerateWithBranching?.(message);
111115
}
112116
113117
function handleSaveEdit() {
114118
if (editedContent.trim() !== message.content) {
115-
onUpdateMessage?.(message, editedContent.trim());
119+
onEditWithBranching?.(message, editedContent.trim());
116120
}
117121
isEditing = false;
118122
}
119123
120-
function handleDelete() {
124+
async function handleDelete() {
125+
deletionInfo = await getDeletionInfo(message.id);
121126
showDeleteDialog = true;
122127
}
123128
@@ -187,9 +192,9 @@
187192
</Card>
188193
{/if}
189194

190-
<div class="relative flex h-6 items-center">
191-
{@render messageActions({ role: 'user' })}
192-
</div>
195+
{#if message.timestamp}
196+
{@render timestampAndActions({ role: 'user', justify: 'end', actionsPosition: 'right' })}
197+
{/if}
193198
{/if}
194199
</div>
195200
{:else}
@@ -233,9 +238,7 @@
233238
{/if}
234239

235240
{#if message.timestamp}
236-
<div class="relative mt-2 flex h-6 items-center">
237-
{@render messageActions({ role: 'assistant' })}
238-
</div>
241+
{@render timestampAndActions({ role: 'assistant', justify: 'start', actionsPosition: 'left' })}
239242
{/if}
240243
</div>
241244
{/if}
@@ -244,89 +247,92 @@
244247
<div
245248
class="pointer-events-none inset-0 flex items-center gap-1 opacity-0 transition-all duration-150 group-hover:pointer-events-auto group-hover:opacity-100"
246249
>
247-
<Tooltip>
248-
<TooltipTrigger>
249-
<Button variant="ghost" size="sm" class="h-6 w-6 p-0" onclick={handleCopy}>
250-
<Copy class="h-3 w-3" />
251-
</Button>
252-
</TooltipTrigger>
253-
254-
<TooltipContent>
255-
<p>Copy</p>
256-
</TooltipContent>
257-
</Tooltip>
250+
{@render actionButton({ icon: Copy, tooltip: 'Copy', onclick: handleCopy })}
251+
258252
{#if config?.role === 'user'}
259-
<Tooltip>
260-
<TooltipTrigger>
261-
<Button variant="ghost" size="sm" class="h-6 w-6 p-0" onclick={handleEdit}>
262-
<Edit class="h-3 w-3" />
263-
</Button>
264-
</TooltipTrigger>
265-
266-
<TooltipContent>
267-
<p>Edit</p>
268-
</TooltipContent>
269-
</Tooltip>
253+
{@render actionButton({ icon: Edit, tooltip: 'Edit', onclick: handleEdit })}
270254
{:else if config?.role === 'assistant'}
271-
<Tooltip>
272-
<TooltipTrigger>
273-
<Button
274-
variant="ghost"
275-
size="sm"
276-
class="h-6 w-6 p-0"
277-
onclick={handleRegenerate}
278-
>
279-
<RefreshCw class="h-3 w-3" />
280-
</Button>
281-
</TooltipTrigger>
282-
283-
<TooltipContent>
284-
<p>Regenerate</p>
285-
</TooltipContent>
286-
</Tooltip>
255+
{@render actionButton({ icon: RefreshCw, tooltip: 'Regenerate', onclick: handleRegenerate })}
287256
{/if}
288-
289-
<Tooltip>
290-
<TooltipTrigger>
291-
<Button variant="ghost" size="sm" class="h-6 w-6 p-0 hover:bg-destructive/10 hover:text-destructive" onclick={handleDelete}>
292-
<Trash2 class="h-3 w-3 text-destructive" />
293-
</Button>
294-
</TooltipTrigger>
295-
296-
<TooltipContent>
297-
<p>Delete</p>
298-
</TooltipContent>
299-
</Tooltip>
257+
258+
{@render actionButton({ icon: Trash2, tooltip: 'Delete', onclick: handleDelete })}
300259
</div>
260+
{/snippet}
261+
262+
{#snippet actionButton(config: { icon: any; tooltip: string; onclick: () => void })}
263+
<Tooltip>
264+
<TooltipTrigger>
265+
<Button variant="ghost" size="sm" class="h-6 w-6 p-0" onclick={config.onclick}>
266+
{@const IconComponent = config.icon}
267+
<IconComponent class="h-3 w-3" />
268+
</Button>
269+
</TooltipTrigger>
270+
271+
<TooltipContent>
272+
<p>{config.tooltip}</p>
273+
</TooltipContent>
274+
</Tooltip>
275+
{/snippet}
276+
277+
{#snippet timestampAndActions(config: { role: ChatRole; justify: 'start' | 'end'; actionsPosition: 'left' | 'right' })}
278+
<div class="relative {config.justify === 'start' ? 'mt-2' : ''} flex h-6 items-center justify-{config.justify}">
279+
<div class="flex items-center text-xs text-muted-foreground group-hover:opacity-0 transition-opacity">
280+
{new Date(message.timestamp).toLocaleTimeString(undefined, {
281+
hour: '2-digit',
282+
minute: '2-digit'
283+
})}
284+
</div>
301285

302-
{#if messageContent.trim().length > 0}
303-
<div
304-
class="{config?.role === 'user'
305-
? 'right-0'
306-
: 'left-0'} text-muted-foreground absolute text-xs transition-all duration-150 group-hover:pointer-events-none group-hover:opacity-0"
307-
>
308-
{message.timestamp
309-
? new Date(message.timestamp).toLocaleTimeString(undefined, {
310-
hour: '2-digit',
311-
minute: '2-digit'
312-
})
313-
: ''}
286+
<div class="absolute {config.actionsPosition}-0 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
287+
{#if siblingInfo && siblingInfo.totalSiblings > 1}
288+
<MessageBranchingControls
289+
{siblingInfo}
290+
{onNavigateToSibling}
291+
/>
292+
{/if}
293+
{@render messageActions({ role: config.role })}
314294
</div>
315-
{/if}
295+
</div>
316296
{/snippet}
317297

318298
<AlertDialog bind:open={showDeleteDialog}>
319299
<AlertDialogContent>
320300
<AlertDialogHeader>
321301
<AlertDialogTitle>Delete Message</AlertDialogTitle>
322302
<AlertDialogDescription>
323-
Are you sure you want to delete this message? This action cannot be undone.
303+
{#if deletionInfo && deletionInfo.totalCount > 1}
304+
<div class="space-y-2">
305+
<p>This will delete <strong>{deletionInfo.totalCount} messages</strong> including:</p>
306+
307+
<ul class="list-disc list-inside text-sm space-y-1 ml-4">
308+
{#if deletionInfo.userMessages > 0}
309+
<li>{deletionInfo.userMessages} user message{deletionInfo.userMessages > 1 ? 's' : ''}</li>
310+
{/if}
311+
312+
{#if deletionInfo.assistantMessages > 0}
313+
<li>{deletionInfo.assistantMessages} assistant response{deletionInfo.assistantMessages > 1 ? 's' : ''}</li>
314+
{/if}
315+
</ul>
316+
317+
<p class="text-sm text-muted-foreground mt-2">
318+
All messages in this branch and their responses will be permanently removed. This action cannot be undone.
319+
</p>
320+
</div>
321+
{:else}
322+
Are you sure you want to delete this message? This action cannot be undone.
323+
{/if}
324324
</AlertDialogDescription>
325325
</AlertDialogHeader>
326+
326327
<AlertDialogFooter>
327328
<AlertDialogCancel>Cancel</AlertDialogCancel>
329+
328330
<AlertDialogAction onclick={handleConfirmDelete} class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
329-
Delete
331+
{#if deletionInfo && deletionInfo.totalCount > 1}
332+
Delete {deletionInfo.totalCount} Messages
333+
{:else}
334+
Delete
335+
{/if}
330336
</AlertDialogAction>
331337
</AlertDialogFooter>
332338
</AlertDialogContent>
Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,104 @@
11
<script lang="ts">
22
import {
3-
updateMessage,
4-
regenerateMessage,
5-
deleteMessage
3+
deleteMessage,
4+
navigateToSibling,
5+
editMessageWithBranching,
6+
regenerateMessageWithBranching
67
} from '$lib/stores/chat.svelte';
8+
import { activeConversation, activeMessages } from '$lib/stores/chat.svelte';
79
import { ChatMessage } from '$lib/components/app';
10+
import { getMessageSiblings } from '$lib/utils/branching';
11+
import { DatabaseService } from '$lib/services/database';
812
913
interface Props {
1014
class?: string;
1115
messages?: DatabaseMessage[];
1216
}
1317
1418
let { class: className, messages = [] }: Props = $props();
19+
20+
let allConversationMessages = $state<DatabaseMessage[]>([]);
21+
let lastUpdateTime = $state(0);
22+
23+
function refreshAllMessages() {
24+
const conversation = activeConversation();
25+
if (conversation) {
26+
DatabaseService.getConversationMessages(conversation.id).then(messages => {
27+
allConversationMessages = messages;
28+
lastUpdateTime = Date.now();
29+
});
30+
} else {
31+
allConversationMessages = [];
32+
}
33+
}
34+
35+
// Single effect that tracks both conversation and message changes
36+
$effect(() => {
37+
const conversation = activeConversation();
38+
const currentActiveMessages = activeMessages();
39+
40+
// Track message count and timestamps to detect changes
41+
const messageCount = currentActiveMessages.length;
42+
const lastMessageTimestamp = currentActiveMessages.length > 0 ?
43+
Math.max(...currentActiveMessages.map(m => m.timestamp || 0)) : 0;
44+
45+
if (conversation) {
46+
refreshAllMessages();
47+
}
48+
});
49+
50+
let displayMessages = $derived.by(() => {
51+
if (!messages.length) {
52+
return [];
53+
}
54+
55+
// Force dependency on lastUpdateTime to ensure reactivity
56+
const _ = lastUpdateTime;
57+
58+
return messages.map(message => {
59+
const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
60+
return {
61+
message,
62+
siblingInfo: siblingInfo || {
63+
message,
64+
siblingIds: [message.id],
65+
currentIndex: 0,
66+
totalSiblings: 1
67+
}
68+
};
69+
});
70+
});
71+
72+
async function handleNavigateToSibling(siblingId: string) {
73+
await navigateToSibling(siblingId);
74+
}
75+
async function handleEditWithBranching(message: DatabaseMessage, newContent: string) {
76+
await editMessageWithBranching(message.id, newContent);
77+
// Refresh after editing to update sibling counts
78+
refreshAllMessages();
79+
}
80+
async function handleRegenerateWithBranching(message: DatabaseMessage) {
81+
await regenerateMessageWithBranching(message.id);
82+
// Refresh after regenerating to update sibling counts
83+
refreshAllMessages();
84+
}
85+
async function handleDeleteMessage(message: DatabaseMessage) {
86+
await deleteMessage(message.id);
87+
// Refresh after deleting to update sibling counts
88+
refreshAllMessages();
89+
}
1590
</script>
1691

1792
<div class="flex h-full flex-col space-y-10 pt-16 md:pt-24 {className}" style="height: auto; ">
18-
{#each messages as message}
93+
{#each displayMessages as { message, siblingInfo }}
1994
<ChatMessage
2095
class="mx-auto w-full max-w-[48rem]"
2196
{message}
22-
onUpdateMessage={async (msg, newContent) => {
23-
await updateMessage(msg.id, newContent);
24-
}}
25-
onRegenerate={async (msg) => {
26-
await regenerateMessage(msg.id);
27-
}}
28-
onDelete={async (msg) => {
29-
await deleteMessage(msg.id);
30-
}}
97+
{siblingInfo}
98+
onDelete={handleDeleteMessage}
99+
onNavigateToSibling={handleNavigateToSibling}
100+
onEditWithBranching={handleEditWithBranching}
101+
onRegenerateWithBranching={handleRegenerateWithBranching}
31102
/>
32103
{/each}
33104
</div>

0 commit comments

Comments
 (0)