summaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages
diff options
context:
space:
mode:
authorMitja Felicijan <mitja.felicijan@gmail.com>2026-02-12 20:57:17 +0100
committerMitja Felicijan <mitja.felicijan@gmail.com>2026-02-12 20:57:17 +0100
commitb333b06772c89d96aacb5490d6a219fba7c09cc6 (patch)
tree211df60083a5946baa2ed61d33d8121b7e251b06 /llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages
downloadllmnpc-b333b06772c89d96aacb5490d6a219fba7c09cc6.tar.gz
Engage!
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages')
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte286
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte100
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte418
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageBranchingControls.svelte84
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte391
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte175
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte216
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte68
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte163
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte143
10 files changed, 2044 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
new file mode 100644
index 0000000..220276f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
@@ -0,0 +1,286 @@
+<script lang="ts">
+ import { chatStore } from '$lib/stores/chat.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+ import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils';
+ import ChatMessageAssistant from './ChatMessageAssistant.svelte';
+ import ChatMessageUser from './ChatMessageUser.svelte';
+ import ChatMessageSystem from './ChatMessageSystem.svelte';
+
+ interface Props {
+ class?: string;
+ message: DatabaseMessage;
+ onCopy?: (message: DatabaseMessage) => void;
+ onContinueAssistantMessage?: (message: DatabaseMessage) => void;
+ onDelete?: (message: DatabaseMessage) => void;
+ onEditWithBranching?: (
+ message: DatabaseMessage,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ) => void;
+ onEditWithReplacement?: (
+ message: DatabaseMessage,
+ newContent: string,
+ shouldBranch: boolean
+ ) => void;
+ onEditUserMessagePreserveResponses?: (
+ message: DatabaseMessage,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ) => void;
+ onNavigateToSibling?: (siblingId: string) => void;
+ onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
+ siblingInfo?: ChatMessageSiblingInfo | null;
+ }
+
+ let {
+ class: className = '',
+ message,
+ onCopy,
+ onContinueAssistantMessage,
+ onDelete,
+ onEditWithBranching,
+ onEditWithReplacement,
+ onEditUserMessagePreserveResponses,
+ onNavigateToSibling,
+ onRegenerateWithBranching,
+ siblingInfo = null
+ }: Props = $props();
+
+ let deletionInfo = $state<{
+ totalCount: number;
+ userMessages: number;
+ assistantMessages: number;
+ messageTypes: string[];
+ } | null>(null);
+ let editedContent = $state(message.content);
+ let editedExtras = $state<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
+ let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
+ let isEditing = $state(false);
+ let showDeleteDialog = $state(false);
+ let shouldBranchAfterEdit = $state(false);
+ let textareaElement: HTMLTextAreaElement | undefined = $state();
+
+ let thinkingContent = $derived.by(() => {
+ if (message.role === 'assistant') {
+ const trimmedThinking = message.thinking?.trim();
+
+ return trimmedThinking ? trimmedThinking : null;
+ }
+ return null;
+ });
+
+ let toolCallContent = $derived.by((): ApiChatCompletionToolCall[] | string | null => {
+ if (message.role === 'assistant') {
+ const trimmedToolCalls = message.toolCalls?.trim();
+
+ if (!trimmedToolCalls) {
+ return null;
+ }
+
+ try {
+ const parsed = JSON.parse(trimmedToolCalls);
+
+ if (Array.isArray(parsed)) {
+ return parsed as ApiChatCompletionToolCall[];
+ }
+ } catch {
+ // Harmony-only path: fall back to the raw string so issues surface visibly.
+ }
+
+ return trimmedToolCalls;
+ }
+ return null;
+ });
+
+ function handleCancelEdit() {
+ isEditing = false;
+ editedContent = message.content;
+ editedExtras = message.extra ? [...message.extra] : [];
+ editedUploadedFiles = [];
+ }
+
+ function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) {
+ editedExtras = extras;
+ }
+
+ function handleEditedUploadedFilesChange(files: ChatUploadedFile[]) {
+ editedUploadedFiles = files;
+ }
+
+ async function handleCopy() {
+ const asPlainText = Boolean(config().copyTextAttachmentsAsPlainText);
+ const clipboardContent = formatMessageForClipboard(message.content, message.extra, asPlainText);
+ await copyToClipboard(clipboardContent, 'Message copied to clipboard');
+ onCopy?.(message);
+ }
+
+ function handleConfirmDelete() {
+ onDelete?.(message);
+ showDeleteDialog = false;
+ }
+
+ async function handleDelete() {
+ deletionInfo = await chatStore.getDeletionInfo(message.id);
+ showDeleteDialog = true;
+ }
+
+ function handleEdit() {
+ isEditing = true;
+ editedContent = message.content;
+ editedExtras = message.extra ? [...message.extra] : [];
+ editedUploadedFiles = [];
+
+ setTimeout(() => {
+ if (textareaElement) {
+ textareaElement.focus();
+ textareaElement.setSelectionRange(
+ textareaElement.value.length,
+ textareaElement.value.length
+ );
+ }
+ }, 0);
+ }
+
+ function handleEditedContentChange(content: string) {
+ editedContent = content;
+ }
+
+ function handleEditKeydown(event: KeyboardEvent) {
+ // Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari)
+ // This prevents saving edit when confirming IME word selection (e.g., Japanese/Chinese input)
+ if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
+ event.preventDefault();
+ handleSaveEdit();
+ } else if (event.key === 'Escape') {
+ event.preventDefault();
+ handleCancelEdit();
+ }
+ }
+
+ function handleRegenerate(modelOverride?: string) {
+ onRegenerateWithBranching?.(message, modelOverride);
+ }
+
+ function handleContinue() {
+ onContinueAssistantMessage?.(message);
+ }
+
+ async function handleSaveEdit() {
+ if (message.role === 'user' || message.role === 'system') {
+ const finalExtras = await getMergedExtras();
+ onEditWithBranching?.(message, editedContent.trim(), finalExtras);
+ } else {
+ // For assistant messages, preserve exact content including trailing whitespace
+ // This is important for the Continue feature to work properly
+ onEditWithReplacement?.(message, editedContent, shouldBranchAfterEdit);
+ }
+
+ isEditing = false;
+ shouldBranchAfterEdit = false;
+ editedUploadedFiles = [];
+ }
+
+ async function handleSaveEditOnly() {
+ if (message.role === 'user') {
+ // For user messages, trim to avoid accidental whitespace
+ const finalExtras = await getMergedExtras();
+ onEditUserMessagePreserveResponses?.(message, editedContent.trim(), finalExtras);
+ }
+
+ isEditing = false;
+ editedUploadedFiles = [];
+ }
+
+ async function getMergedExtras(): Promise<DatabaseMessageExtra[]> {
+ if (editedUploadedFiles.length === 0) {
+ return editedExtras;
+ }
+
+ const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only');
+ const result = await parseFilesToMessageExtras(editedUploadedFiles);
+ const newExtras = result?.extras || [];
+
+ return [...editedExtras, ...newExtras];
+ }
+
+ function handleShowDeleteDialogChange(show: boolean) {
+ showDeleteDialog = show;
+ }
+</script>
+
+{#if message.role === 'system'}
+ <ChatMessageSystem
+ bind:textareaElement
+ class={className}
+ {deletionInfo}
+ {editedContent}
+ {isEditing}
+ {message}
+ onCancelEdit={handleCancelEdit}
+ onConfirmDelete={handleConfirmDelete}
+ onCopy={handleCopy}
+ onDelete={handleDelete}
+ onEdit={handleEdit}
+ onEditKeydown={handleEditKeydown}
+ onEditedContentChange={handleEditedContentChange}
+ {onNavigateToSibling}
+ onSaveEdit={handleSaveEdit}
+ onShowDeleteDialogChange={handleShowDeleteDialogChange}
+ {showDeleteDialog}
+ {siblingInfo}
+ />
+{:else if message.role === 'user'}
+ <ChatMessageUser
+ bind:textareaElement
+ class={className}
+ {deletionInfo}
+ {editedContent}
+ {editedExtras}
+ {editedUploadedFiles}
+ {isEditing}
+ {message}
+ onCancelEdit={handleCancelEdit}
+ onConfirmDelete={handleConfirmDelete}
+ onCopy={handleCopy}
+ onDelete={handleDelete}
+ onEdit={handleEdit}
+ onEditKeydown={handleEditKeydown}
+ onEditedContentChange={handleEditedContentChange}
+ onEditedExtrasChange={handleEditedExtrasChange}
+ onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
+ {onNavigateToSibling}
+ onSaveEdit={handleSaveEdit}
+ onSaveEditOnly={handleSaveEditOnly}
+ onShowDeleteDialogChange={handleShowDeleteDialogChange}
+ {showDeleteDialog}
+ {siblingInfo}
+ />
+{:else}
+ <ChatMessageAssistant
+ bind:textareaElement
+ class={className}
+ {deletionInfo}
+ {editedContent}
+ {isEditing}
+ {message}
+ messageContent={message.content}
+ onCancelEdit={handleCancelEdit}
+ onConfirmDelete={handleConfirmDelete}
+ onContinue={handleContinue}
+ onCopy={handleCopy}
+ onDelete={handleDelete}
+ onEdit={handleEdit}
+ onEditKeydown={handleEditKeydown}
+ onEditedContentChange={handleEditedContentChange}
+ {onNavigateToSibling}
+ onRegenerate={handleRegenerate}
+ onSaveEdit={handleSaveEdit}
+ onShowDeleteDialogChange={handleShowDeleteDialogChange}
+ {shouldBranchAfterEdit}
+ onShouldBranchAfterEditChange={(value) => (shouldBranchAfterEdit = value)}
+ {showDeleteDialog}
+ {siblingInfo}
+ {thinkingContent}
+ {toolCallContent}
+ />
+{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte
new file mode 100644
index 0000000..3cb4815
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte
@@ -0,0 +1,100 @@
+<script lang="ts">
+ import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
+ import {
+ ActionButton,
+ ChatMessageBranchingControls,
+ DialogConfirmation
+ } from '$lib/components/app';
+
+ interface Props {
+ role: 'user' | 'assistant';
+ justify: 'start' | 'end';
+ actionsPosition: 'left' | 'right';
+ siblingInfo?: ChatMessageSiblingInfo | null;
+ showDeleteDialog: boolean;
+ deletionInfo: {
+ totalCount: number;
+ userMessages: number;
+ assistantMessages: number;
+ messageTypes: string[];
+ } | null;
+ onCopy: () => void;
+ onEdit?: () => void;
+ onRegenerate?: () => void;
+ onContinue?: () => void;
+ onDelete: () => void;
+ onConfirmDelete: () => void;
+ onNavigateToSibling?: (siblingId: string) => void;
+ onShowDeleteDialogChange: (show: boolean) => void;
+ }
+
+ let {
+ actionsPosition,
+ deletionInfo,
+ justify,
+ onCopy,
+ onEdit,
+ onConfirmDelete,
+ onContinue,
+ onDelete,
+ onNavigateToSibling,
+ onShowDeleteDialogChange,
+ onRegenerate,
+ role,
+ siblingInfo = null,
+ showDeleteDialog
+ }: Props = $props();
+
+ function handleConfirmDelete() {
+ onConfirmDelete();
+ onShowDeleteDialogChange(false);
+ }
+</script>
+
+<div class="relative {justify === 'start' ? 'mt-2' : ''} flex h-6 items-center justify-{justify}">
+ <div
+ class="absolute top-0 {actionsPosition === 'left'
+ ? 'left-0'
+ : 'right-0'} flex items-center gap-2 opacity-100 transition-opacity"
+ >
+ {#if siblingInfo && siblingInfo.totalSiblings > 1}
+ <ChatMessageBranchingControls {siblingInfo} {onNavigateToSibling} />
+ {/if}
+
+ <div
+ class="pointer-events-auto inset-0 flex items-center gap-1 opacity-100 transition-all duration-150"
+ >
+ <ActionButton icon={Copy} tooltip="Copy" onclick={onCopy} />
+
+ {#if onEdit}
+ <ActionButton icon={Edit} tooltip="Edit" onclick={onEdit} />
+ {/if}
+
+ {#if role === 'assistant' && onRegenerate}
+ <ActionButton icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
+ {/if}
+
+ {#if role === 'assistant' && onContinue}
+ <ActionButton icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
+ {/if}
+
+ <ActionButton icon={Trash2} tooltip="Delete" onclick={onDelete} />
+ </div>
+ </div>
+</div>
+
+<DialogConfirmation
+ bind:open={showDeleteDialog}
+ title="Delete Message"
+ description={deletionInfo && deletionInfo.totalCount > 1
+ ? `This will delete ${deletionInfo.totalCount} messages including: ${deletionInfo.userMessages} user message${deletionInfo.userMessages > 1 ? 's' : ''} and ${deletionInfo.assistantMessages} assistant response${deletionInfo.assistantMessages > 1 ? 's' : ''}. All messages in this branch and their responses will be permanently removed. This action cannot be undone.`
+ : 'Are you sure you want to delete this message? This action cannot be undone.'}
+ confirmText={deletionInfo && deletionInfo.totalCount > 1
+ ? `Delete ${deletionInfo.totalCount} Messages`
+ : 'Delete'}
+ cancelText="Cancel"
+ variant="destructive"
+ icon={Trash2}
+ onConfirm={handleConfirmDelete}
+ onCancel={() => onShowDeleteDialogChange(false)}
+/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
new file mode 100644
index 0000000..2b34b1c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
@@ -0,0 +1,418 @@
+<script lang="ts">
+ import {
+ ModelBadge,
+ ChatMessageActions,
+ ChatMessageStatistics,
+ ChatMessageThinkingBlock,
+ CopyToClipboardIcon,
+ MarkdownContent,
+ ModelsSelector
+ } from '$lib/components/app';
+ import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
+ import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
+ import { isLoading } from '$lib/stores/chat.svelte';
+ import { autoResizeTextarea, copyToClipboard } from '$lib/utils';
+ import { fade } from 'svelte/transition';
+ import { Check, X, Wrench } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import { Checkbox } from '$lib/components/ui/checkbox';
+ import { INPUT_CLASSES } from '$lib/constants/input-classes';
+ import Label from '$lib/components/ui/label/label.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+ import { conversationsStore } from '$lib/stores/conversations.svelte';
+ import { isRouterMode } from '$lib/stores/server.svelte';
+
+ interface Props {
+ class?: string;
+ deletionInfo: {
+ totalCount: number;
+ userMessages: number;
+ assistantMessages: number;
+ messageTypes: string[];
+ } | null;
+ editedContent?: string;
+ isEditing?: boolean;
+ message: DatabaseMessage;
+ messageContent: string | undefined;
+ onCancelEdit?: () => void;
+ onCopy: () => void;
+ onConfirmDelete: () => void;
+ onContinue?: () => void;
+ onDelete: () => void;
+ onEdit?: () => void;
+ onEditKeydown?: (event: KeyboardEvent) => void;
+ onEditedContentChange?: (content: string) => void;
+ onNavigateToSibling?: (siblingId: string) => void;
+ onRegenerate: (modelOverride?: string) => void;
+ onSaveEdit?: () => void;
+ onShowDeleteDialogChange: (show: boolean) => void;
+ onShouldBranchAfterEditChange?: (value: boolean) => void;
+ showDeleteDialog: boolean;
+ shouldBranchAfterEdit?: boolean;
+ siblingInfo?: ChatMessageSiblingInfo | null;
+ textareaElement?: HTMLTextAreaElement;
+ thinkingContent: string | null;
+ toolCallContent: ApiChatCompletionToolCall[] | string | null;
+ }
+
+ let {
+ class: className = '',
+ deletionInfo,
+ editedContent = '',
+ isEditing = false,
+ message,
+ messageContent,
+ onCancelEdit,
+ onConfirmDelete,
+ onContinue,
+ onCopy,
+ onDelete,
+ onEdit,
+ onEditKeydown,
+ onEditedContentChange,
+ onNavigateToSibling,
+ onRegenerate,
+ onSaveEdit,
+ onShowDeleteDialogChange,
+ onShouldBranchAfterEditChange,
+ showDeleteDialog,
+ shouldBranchAfterEdit = false,
+ siblingInfo = null,
+ textareaElement = $bindable(),
+ thinkingContent,
+ toolCallContent = null
+ }: Props = $props();
+
+ const toolCalls = $derived(
+ Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null
+ );
+ const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null);
+
+ const processingState = useProcessingState();
+
+ let currentConfig = $derived(config());
+ let isRouter = $derived(isRouterMode());
+ let displayedModel = $derived((): string | null => {
+ if (message.model) {
+ return message.model;
+ }
+
+ return null;
+ });
+
+ const { handleModelChange } = useModelChangeValidation({
+ getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
+ onSuccess: (modelName) => onRegenerate(modelName)
+ });
+
+ function handleCopyModel() {
+ const model = displayedModel();
+
+ void copyToClipboard(model ?? '');
+ }
+
+ $effect(() => {
+ if (isEditing && textareaElement) {
+ autoResizeTextarea(textareaElement);
+ }
+ });
+
+ $effect(() => {
+ if (isLoading() && !message?.content?.trim()) {
+ processingState.startMonitoring();
+ }
+ });
+
+ function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) {
+ const callNumber = index + 1;
+ const functionName = toolCall.function?.name?.trim();
+ const label = functionName || `Call #${callNumber}`;
+
+ const payload: Record<string, unknown> = {};
+
+ const id = toolCall.id?.trim();
+ if (id) {
+ payload.id = id;
+ }
+
+ const type = toolCall.type?.trim();
+ if (type) {
+ payload.type = type;
+ }
+
+ if (toolCall.function) {
+ const fnPayload: Record<string, unknown> = {};
+
+ const name = toolCall.function.name?.trim();
+ if (name) {
+ fnPayload.name = name;
+ }
+
+ const rawArguments = toolCall.function.arguments?.trim();
+ if (rawArguments) {
+ try {
+ fnPayload.arguments = JSON.parse(rawArguments);
+ } catch {
+ fnPayload.arguments = rawArguments;
+ }
+ }
+
+ if (Object.keys(fnPayload).length > 0) {
+ payload.function = fnPayload;
+ }
+ }
+
+ const formattedPayload = JSON.stringify(payload, null, 2);
+
+ return {
+ label,
+ tooltip: formattedPayload,
+ copyValue: formattedPayload
+ };
+ }
+
+ function handleCopyToolCall(payload: string) {
+ void copyToClipboard(payload, 'Tool call copied to clipboard');
+ }
+</script>
+
+<div
+ class="text-md group w-full leading-7.5 {className}"
+ role="group"
+ aria-label="Assistant message with actions"
+>
+ {#if thinkingContent}
+ <ChatMessageThinkingBlock
+ reasoningContent={thinkingContent}
+ isStreaming={!message.timestamp}
+ hasRegularContent={!!messageContent?.trim()}
+ />
+ {/if}
+
+ {#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()}
+ <div class="mt-6 w-full max-w-[48rem]" in:fade>
+ <div class="processing-container">
+ <span class="processing-text">
+ {processingState.getPromptProgressText() ?? processingState.getProcessingMessage()}
+ </span>
+ </div>
+ </div>
+ {/if}
+
+ {#if isEditing}
+ <div class="w-full">
+ <textarea
+ bind:this={textareaElement}
+ bind:value={editedContent}
+ class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
+ onkeydown={onEditKeydown}
+ oninput={(e) => {
+ autoResizeTextarea(e.currentTarget);
+ onEditedContentChange?.(e.currentTarget.value);
+ }}
+ placeholder="Edit assistant message..."
+ ></textarea>
+
+ <div class="mt-2 flex items-center justify-between">
+ <div class="flex items-center space-x-2">
+ <Checkbox
+ id="branch-after-edit"
+ bind:checked={shouldBranchAfterEdit}
+ onCheckedChange={(checked) => onShouldBranchAfterEditChange?.(checked === true)}
+ />
+ <Label for="branch-after-edit" class="cursor-pointer text-sm text-muted-foreground">
+ Branch conversation after edit
+ </Label>
+ </div>
+ <div class="flex gap-2">
+ <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
+ <X class="mr-1 h-3 w-3" />
+ Cancel
+ </Button>
+
+ <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent?.trim()} size="sm">
+ <Check class="mr-1 h-3 w-3" />
+ Save
+ </Button>
+ </div>
+ </div>
+ </div>
+ {:else if message.role === 'assistant'}
+ {#if config().disableReasoningFormat}
+ <pre class="raw-output">{messageContent || ''}</pre>
+ {:else}
+ <MarkdownContent content={messageContent || ''} />
+ {/if}
+ {:else}
+ <div class="text-sm whitespace-pre-wrap">
+ {messageContent}
+ </div>
+ {/if}
+
+ <div class="info my-6 grid gap-4 tabular-nums">
+ {#if displayedModel()}
+ <div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
+ {#if isRouter}
+ <ModelsSelector
+ currentModel={displayedModel()}
+ onModelChange={handleModelChange}
+ disabled={isLoading()}
+ upToMessageId={message.id}
+ />
+ {:else}
+ <ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} />
+ {/if}
+
+ {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
+ <ChatMessageStatistics
+ promptTokens={message.timings.prompt_n}
+ promptMs={message.timings.prompt_ms}
+ predictedTokens={message.timings.predicted_n}
+ predictedMs={message.timings.predicted_ms}
+ />
+ {:else if isLoading() && currentConfig.showMessageStats}
+ {@const liveStats = processingState.getLiveProcessingStats()}
+ {@const genStats = processingState.getLiveGenerationStats()}
+ {@const promptProgress = processingState.processingState?.promptProgress}
+ {@const isStillProcessingPrompt =
+ promptProgress && promptProgress.processed < promptProgress.total}
+
+ {#if liveStats || genStats}
+ <ChatMessageStatistics
+ isLive={true}
+ isProcessingPrompt={!!isStillProcessingPrompt}
+ promptTokens={liveStats?.tokensProcessed}
+ promptMs={liveStats?.timeMs}
+ predictedTokens={genStats?.tokensGenerated}
+ predictedMs={genStats?.timeMs}
+ />
+ {/if}
+ {/if}
+ </div>
+ {/if}
+
+ {#if config().showToolCalls}
+ {#if (toolCalls && toolCalls.length > 0) || fallbackToolCalls}
+ <span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
+ <span class="inline-flex items-center gap-1">
+ <Wrench class="h-3.5 w-3.5" />
+
+ <span>Tool calls:</span>
+ </span>
+
+ {#if toolCalls && toolCalls.length > 0}
+ {#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)}
+ {@const badge = formatToolCallBadge(toolCall, index)}
+ <button
+ type="button"
+ class="tool-call-badge inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
+ title={badge.tooltip}
+ aria-label={`Copy tool call ${badge.label}`}
+ onclick={() => handleCopyToolCall(badge.copyValue)}
+ >
+ {badge.label}
+ <CopyToClipboardIcon
+ text={badge.copyValue}
+ ariaLabel={`Copy tool call ${badge.label}`}
+ />
+ </button>
+ {/each}
+ {:else if fallbackToolCalls}
+ <button
+ type="button"
+ class="tool-call-badge tool-call-badge--fallback inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
+ title={fallbackToolCalls}
+ aria-label="Copy tool call payload"
+ onclick={() => handleCopyToolCall(fallbackToolCalls)}
+ >
+ {fallbackToolCalls}
+ <CopyToClipboardIcon text={fallbackToolCalls} ariaLabel="Copy tool call payload" />
+ </button>
+ {/if}
+ </span>
+ {/if}
+ {/if}
+ </div>
+
+ {#if message.timestamp && !isEditing}
+ <ChatMessageActions
+ role="assistant"
+ justify="start"
+ actionsPosition="left"
+ {siblingInfo}
+ {showDeleteDialog}
+ {deletionInfo}
+ {onCopy}
+ {onEdit}
+ {onRegenerate}
+ onContinue={currentConfig.enableContinueGeneration && !thinkingContent
+ ? onContinue
+ : undefined}
+ {onDelete}
+ {onConfirmDelete}
+ {onNavigateToSibling}
+ {onShowDeleteDialogChange}
+ />
+ {/if}
+</div>
+
+<style>
+ .processing-container {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+
+ .processing-text {
+ background: linear-gradient(
+ 90deg,
+ var(--muted-foreground),
+ var(--foreground),
+ var(--muted-foreground)
+ );
+ background-size: 200% 100%;
+ background-clip: text;
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ animation: shine 1s linear infinite;
+ font-weight: 500;
+ font-size: 0.875rem;
+ }
+
+ @keyframes shine {
+ to {
+ background-position: -200% 0;
+ }
+ }
+
+ .raw-output {
+ width: 100%;
+ max-width: 48rem;
+ margin-top: 1.5rem;
+ padding: 1rem 1.25rem;
+ border-radius: 1rem;
+ background: hsl(var(--muted) / 0.3);
+ color: var(--foreground);
+ font-family:
+ ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
+ 'Liberation Mono', Menlo, monospace;
+ font-size: 0.875rem;
+ line-height: 1.6;
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+
+ .tool-call-badge {
+ max-width: 12rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .tool-call-badge--fallback {
+ max-width: 20rem;
+ white-space: normal;
+ word-break: break-word;
+ }
+</style>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageBranchingControls.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageBranchingControls.svelte
new file mode 100644
index 0000000..7420bb1
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageBranchingControls.svelte
@@ -0,0 +1,84 @@
+<script lang="ts">
+ import { ChevronLeft, ChevronRight } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+
+ interface Props {
+ class?: string;
+ siblingInfo: ChatMessageSiblingInfo | null;
+ onNavigateToSibling?: (siblingId: string) => void;
+ }
+
+ let { class: className = '', siblingInfo, onNavigateToSibling }: Props = $props();
+
+ let hasPrevious = $derived(siblingInfo && siblingInfo.currentIndex > 0);
+ let hasNext = $derived(siblingInfo && siblingInfo.currentIndex < siblingInfo.totalSiblings - 1);
+ let nextSiblingId = $derived(
+ hasNext ? siblingInfo!.siblingIds[siblingInfo!.currentIndex + 1] : null
+ );
+ let previousSiblingId = $derived(
+ hasPrevious ? siblingInfo!.siblingIds[siblingInfo!.currentIndex - 1] : null
+ );
+
+ function handleNext() {
+ if (nextSiblingId) {
+ onNavigateToSibling?.(nextSiblingId);
+ }
+ }
+
+ function handlePrevious() {
+ if (previousSiblingId) {
+ onNavigateToSibling?.(previousSiblingId);
+ }
+ }
+</script>
+
+{#if siblingInfo && siblingInfo.totalSiblings > 1}
+ <div
+ aria-label="Message version {siblingInfo.currentIndex + 1} of {siblingInfo.totalSiblings}"
+ class="flex items-center gap-1 text-xs text-muted-foreground {className}"
+ role="navigation"
+ >
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <Button
+ aria-label="Previous message version"
+ class="h-5 w-5 p-0 {!hasPrevious ? 'cursor-not-allowed opacity-30' : ''}"
+ disabled={!hasPrevious}
+ onclick={handlePrevious}
+ size="sm"
+ variant="ghost"
+ >
+ <ChevronLeft class="h-3 w-3" />
+ </Button>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>Previous version</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+
+ <span class="px-1 font-mono text-xs">
+ {siblingInfo.currentIndex + 1}/{siblingInfo.totalSiblings}
+ </span>
+
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <Button
+ aria-label="Next message version"
+ class="h-5 w-5 p-0 {!hasNext ? 'cursor-not-allowed opacity-30' : ''}"
+ disabled={!hasNext}
+ onclick={handleNext}
+ size="sm"
+ variant="ghost"
+ >
+ <ChevronRight class="h-3 w-3" />
+ </Button>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>Next version</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ </div>
+{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte
new file mode 100644
index 0000000..f812ea2
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte
@@ -0,0 +1,391 @@
+<script lang="ts">
+ import { X, ArrowUp, Paperclip, AlertTriangle } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import { Switch } from '$lib/components/ui/switch';
+ import { ChatAttachmentsList, DialogConfirmation, ModelsSelector } from '$lib/components/app';
+ import { INPUT_CLASSES } from '$lib/constants/input-classes';
+ import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
+ import { AttachmentType, FileTypeCategory, MimeTypeText } from '$lib/enums';
+ import { config } from '$lib/stores/settings.svelte';
+ import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
+ import { setEditModeActive, clearEditMode } from '$lib/stores/chat.svelte';
+ import { conversationsStore } from '$lib/stores/conversations.svelte';
+ import { modelsStore } from '$lib/stores/models.svelte';
+ import { isRouterMode } from '$lib/stores/server.svelte';
+ import {
+ autoResizeTextarea,
+ getFileTypeCategory,
+ getFileTypeCategoryByExtension,
+ parseClipboardContent
+ } from '$lib/utils';
+
+ interface Props {
+ messageId: string;
+ editedContent: string;
+ editedExtras?: DatabaseMessageExtra[];
+ editedUploadedFiles?: ChatUploadedFile[];
+ originalContent: string;
+ originalExtras?: DatabaseMessageExtra[];
+ showSaveOnlyOption?: boolean;
+ onCancelEdit: () => void;
+ onSaveEdit: () => void;
+ onSaveEditOnly?: () => void;
+ onEditKeydown: (event: KeyboardEvent) => void;
+ onEditedContentChange: (content: string) => void;
+ onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
+ onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
+ textareaElement?: HTMLTextAreaElement;
+ }
+
+ let {
+ messageId,
+ editedContent,
+ editedExtras = [],
+ editedUploadedFiles = [],
+ originalContent,
+ originalExtras = [],
+ showSaveOnlyOption = false,
+ onCancelEdit,
+ onSaveEdit,
+ onSaveEditOnly,
+ onEditKeydown,
+ onEditedContentChange,
+ onEditedExtrasChange,
+ onEditedUploadedFilesChange,
+ textareaElement = $bindable()
+ }: Props = $props();
+
+ let fileInputElement: HTMLInputElement | undefined = $state();
+ let saveWithoutRegenerate = $state(false);
+ let showDiscardDialog = $state(false);
+ let isRouter = $derived(isRouterMode());
+ let currentConfig = $derived(config());
+
+ let pasteLongTextToFileLength = $derived.by(() => {
+ const n = Number(currentConfig.pasteLongTextToFileLen);
+
+ return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
+ });
+
+ let hasUnsavedChanges = $derived.by(() => {
+ if (editedContent !== originalContent) return true;
+ if (editedUploadedFiles.length > 0) return true;
+
+ const extrasChanged =
+ editedExtras.length !== originalExtras.length ||
+ editedExtras.some((extra, i) => extra !== originalExtras[i]);
+
+ if (extrasChanged) return true;
+
+ return false;
+ });
+
+ let hasAttachments = $derived(
+ (editedExtras && editedExtras.length > 0) ||
+ (editedUploadedFiles && editedUploadedFiles.length > 0)
+ );
+
+ let canSubmit = $derived(editedContent.trim().length > 0 || hasAttachments);
+
+ function getEditedAttachmentsModalities(): ModelModalities {
+ const modalities: ModelModalities = { vision: false, audio: false };
+
+ for (const extra of editedExtras) {
+ if (extra.type === AttachmentType.IMAGE) {
+ modalities.vision = true;
+ }
+
+ if (
+ extra.type === AttachmentType.PDF &&
+ 'processedAsImages' in extra &&
+ extra.processedAsImages
+ ) {
+ modalities.vision = true;
+ }
+
+ if (extra.type === AttachmentType.AUDIO) {
+ modalities.audio = true;
+ }
+ }
+
+ for (const file of editedUploadedFiles) {
+ const category = getFileTypeCategory(file.type) || getFileTypeCategoryByExtension(file.name);
+ if (category === FileTypeCategory.IMAGE) {
+ modalities.vision = true;
+ }
+ if (category === FileTypeCategory.AUDIO) {
+ modalities.audio = true;
+ }
+ }
+
+ return modalities;
+ }
+
+ function getRequiredModalities(): ModelModalities {
+ const beforeModalities = conversationsStore.getModalitiesUpToMessage(messageId);
+ const editedModalities = getEditedAttachmentsModalities();
+
+ return {
+ vision: beforeModalities.vision || editedModalities.vision,
+ audio: beforeModalities.audio || editedModalities.audio
+ };
+ }
+
+ const { handleModelChange } = useModelChangeValidation({
+ getRequiredModalities,
+ onValidationFailure: async (previousModelId) => {
+ if (previousModelId) {
+ await modelsStore.selectModelById(previousModelId);
+ }
+ }
+ });
+
+ function handleFileInputChange(event: Event) {
+ const input = event.target as HTMLInputElement;
+ if (!input.files || input.files.length === 0) return;
+
+ const files = Array.from(input.files);
+
+ processNewFiles(files);
+ input.value = '';
+ }
+
+ function handleGlobalKeydown(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ attemptCancel();
+ }
+ }
+
+ function attemptCancel() {
+ if (hasUnsavedChanges) {
+ showDiscardDialog = true;
+ } else {
+ onCancelEdit();
+ }
+ }
+
+ function handleRemoveExistingAttachment(index: number) {
+ if (!onEditedExtrasChange) return;
+
+ const newExtras = [...editedExtras];
+
+ newExtras.splice(index, 1);
+ onEditedExtrasChange(newExtras);
+ }
+
+ function handleRemoveUploadedFile(fileId: string) {
+ if (!onEditedUploadedFilesChange) return;
+
+ const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId);
+
+ onEditedUploadedFilesChange(newFiles);
+ }
+
+ function handleSubmit() {
+ if (!canSubmit) return;
+
+ if (saveWithoutRegenerate && onSaveEditOnly) {
+ onSaveEditOnly();
+ } else {
+ onSaveEdit();
+ }
+
+ saveWithoutRegenerate = false;
+ }
+
+ async function processNewFiles(files: File[]) {
+ if (!onEditedUploadedFilesChange) return;
+
+ const { processFilesToChatUploaded } = await import('$lib/utils/browser-only');
+ const processed = await processFilesToChatUploaded(files);
+
+ onEditedUploadedFilesChange([...editedUploadedFiles, ...processed]);
+ }
+
+ function handlePaste(event: ClipboardEvent) {
+ if (!event.clipboardData) return;
+
+ const files = Array.from(event.clipboardData.items)
+ .filter((item) => item.kind === 'file')
+ .map((item) => item.getAsFile())
+ .filter((file): file is File => file !== null);
+
+ if (files.length > 0) {
+ event.preventDefault();
+ processNewFiles(files);
+
+ return;
+ }
+
+ const text = event.clipboardData.getData(MimeTypeText.PLAIN);
+
+ if (text.startsWith('"')) {
+ const parsed = parseClipboardContent(text);
+
+ if (parsed.textAttachments.length > 0) {
+ event.preventDefault();
+ onEditedContentChange(parsed.message);
+
+ const attachmentFiles = parsed.textAttachments.map(
+ (att) =>
+ new File([att.content], att.name, {
+ type: MimeTypeText.PLAIN
+ })
+ );
+
+ processNewFiles(attachmentFiles);
+
+ setTimeout(() => {
+ textareaElement?.focus();
+ }, 10);
+
+ return;
+ }
+ }
+
+ if (
+ text.length > 0 &&
+ pasteLongTextToFileLength > 0 &&
+ text.length > pasteLongTextToFileLength
+ ) {
+ event.preventDefault();
+
+ const textFile = new File([text], 'Pasted', {
+ type: MimeTypeText.PLAIN
+ });
+
+ processNewFiles([textFile]);
+ }
+ }
+
+ $effect(() => {
+ if (textareaElement) {
+ autoResizeTextarea(textareaElement);
+ }
+ });
+
+ $effect(() => {
+ setEditModeActive(processNewFiles);
+
+ return () => {
+ clearEditMode();
+ };
+ });
+</script>
+
+<svelte:window onkeydown={handleGlobalKeydown} />
+
+<input
+ bind:this={fileInputElement}
+ type="file"
+ multiple
+ class="hidden"
+ onchange={handleFileInputChange}
+/>
+
+<div
+ class="{INPUT_CLASSES} w-full max-w-[80%] overflow-hidden rounded-3xl backdrop-blur-md"
+ data-slot="edit-form"
+>
+ <ChatAttachmentsList
+ attachments={editedExtras}
+ uploadedFiles={editedUploadedFiles}
+ readonly={false}
+ onFileRemove={(fileId) => {
+ if (fileId.startsWith('attachment-')) {
+ const index = parseInt(fileId.replace('attachment-', ''), 10);
+ if (!isNaN(index) && index >= 0 && index < editedExtras.length) {
+ handleRemoveExistingAttachment(index);
+ }
+ } else {
+ handleRemoveUploadedFile(fileId);
+ }
+ }}
+ limitToSingleRow
+ class="py-5"
+ style="scroll-padding: 1rem;"
+ />
+
+ <div class="relative min-h-[48px] px-5 py-3">
+ <textarea
+ bind:this={textareaElement}
+ bind:value={editedContent}
+ class="field-sizing-content max-h-80 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
+ onkeydown={onEditKeydown}
+ oninput={(e) => {
+ autoResizeTextarea(e.currentTarget);
+ onEditedContentChange(e.currentTarget.value);
+ }}
+ onpaste={handlePaste}
+ placeholder="Edit your message..."
+ ></textarea>
+
+ <div class="flex w-full items-center gap-3" style="container-type: inline-size">
+ <Button
+ class="h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
+ onclick={() => fileInputElement?.click()}
+ type="button"
+ title="Add attachment"
+ >
+ <span class="sr-only">Attach files</span>
+
+ <Paperclip class="h-4 w-4" />
+ </Button>
+
+ <div class="flex-1"></div>
+
+ {#if isRouter}
+ <ModelsSelector
+ forceForegroundText={true}
+ useGlobalSelection={true}
+ onModelChange={handleModelChange}
+ />
+ {/if}
+
+ <Button
+ class="h-8 w-8 shrink-0 rounded-full p-0"
+ onclick={handleSubmit}
+ disabled={!canSubmit}
+ type="button"
+ title={saveWithoutRegenerate ? 'Save changes' : 'Send and regenerate'}
+ >
+ <span class="sr-only">{saveWithoutRegenerate ? 'Save' : 'Send'}</span>
+
+ <ArrowUp class="h-5 w-5" />
+ </Button>
+ </div>
+ </div>
+</div>
+
+<div class="mt-2 flex w-full max-w-[80%] items-center justify-between">
+ {#if showSaveOnlyOption && onSaveEditOnly}
+ <div class="flex items-center gap-2">
+ <Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" />
+
+ <label for="save-only-switch" class="cursor-pointer text-xs text-muted-foreground">
+ Update without re-sending
+ </label>
+ </div>
+ {:else}
+ <div></div>
+ {/if}
+
+ <Button class="h-7 px-3 text-xs" onclick={attemptCancel} size="sm" variant="ghost">
+ <X class="mr-1 h-3 w-3" />
+
+ Cancel
+ </Button>
+</div>
+
+<DialogConfirmation
+ bind:open={showDiscardDialog}
+ title="Discard changes?"
+ description="You have unsaved changes. Are you sure you want to discard them?"
+ confirmText="Discard"
+ cancelText="Keep editing"
+ variant="destructive"
+ icon={AlertTriangle}
+ onConfirm={onCancelEdit}
+ onCancel={() => (showDiscardDialog = false)}
+/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte
new file mode 100644
index 0000000..24fe592
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte
@@ -0,0 +1,175 @@
+<script lang="ts">
+ import { Clock, Gauge, WholeWord, BookOpenText, Sparkles } from '@lucide/svelte';
+ import { BadgeChatStatistic } from '$lib/components/app';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { ChatMessageStatsView } from '$lib/enums';
+
+ interface Props {
+ predictedTokens?: number;
+ predictedMs?: number;
+ promptTokens?: number;
+ promptMs?: number;
+ // Live mode: when true, shows stats during streaming
+ isLive?: boolean;
+ // Whether prompt processing is still in progress
+ isProcessingPrompt?: boolean;
+ // Initial view to show (defaults to READING in live mode)
+ initialView?: ChatMessageStatsView;
+ }
+
+ let {
+ predictedTokens,
+ predictedMs,
+ promptTokens,
+ promptMs,
+ isLive = false,
+ isProcessingPrompt = false,
+ initialView = ChatMessageStatsView.GENERATION
+ }: Props = $props();
+
+ let activeView: ChatMessageStatsView = $state(initialView);
+ let hasAutoSwitchedToGeneration = $state(false);
+
+ // In live mode: auto-switch to GENERATION tab when prompt processing completes
+ $effect(() => {
+ if (isLive) {
+ // Auto-switch to generation tab only when prompt processing is done (once)
+ if (
+ !hasAutoSwitchedToGeneration &&
+ !isProcessingPrompt &&
+ predictedTokens &&
+ predictedTokens > 0
+ ) {
+ activeView = ChatMessageStatsView.GENERATION;
+ hasAutoSwitchedToGeneration = true;
+ } else if (!hasAutoSwitchedToGeneration) {
+ // Stay on READING while prompt is still being processed
+ activeView = ChatMessageStatsView.READING;
+ }
+ }
+ });
+
+ let hasGenerationStats = $derived(
+ predictedTokens !== undefined &&
+ predictedTokens > 0 &&
+ predictedMs !== undefined &&
+ predictedMs > 0
+ );
+
+ let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0);
+ let timeInSeconds = $derived(
+ predictedMs !== undefined ? (predictedMs / 1000).toFixed(2) : '0.00'
+ );
+
+ let promptTokensPerSecond = $derived(
+ promptTokens !== undefined && promptMs !== undefined && promptMs > 0
+ ? (promptTokens / promptMs) * 1000
+ : undefined
+ );
+
+ let promptTimeInSeconds = $derived(
+ promptMs !== undefined ? (promptMs / 1000).toFixed(2) : undefined
+ );
+
+ let hasPromptStats = $derived(
+ promptTokens !== undefined &&
+ promptMs !== undefined &&
+ promptTokensPerSecond !== undefined &&
+ promptTimeInSeconds !== undefined
+ );
+
+ // In live mode, generation tab is disabled until we have generation stats
+ let isGenerationDisabled = $derived(isLive && !hasGenerationStats);
+</script>
+
+<div class="inline-flex items-center text-xs text-muted-foreground">
+ <div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5">
+ {#if hasPromptStats || isLive}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <button
+ type="button"
+ class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
+ ChatMessageStatsView.READING
+ ? 'bg-background text-foreground shadow-sm'
+ : 'hover:text-foreground'}"
+ onclick={() => (activeView = ChatMessageStatsView.READING)}
+ >
+ <BookOpenText class="h-3 w-3" />
+ <span class="sr-only">Reading</span>
+ </button>
+ </Tooltip.Trigger>
+ <Tooltip.Content>
+ <p>Reading (prompt processing)</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/if}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <button
+ type="button"
+ class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
+ ChatMessageStatsView.GENERATION
+ ? 'bg-background text-foreground shadow-sm'
+ : isGenerationDisabled
+ ? 'cursor-not-allowed opacity-40'
+ : 'hover:text-foreground'}"
+ onclick={() => !isGenerationDisabled && (activeView = ChatMessageStatsView.GENERATION)}
+ disabled={isGenerationDisabled}
+ >
+ <Sparkles class="h-3 w-3" />
+ <span class="sr-only">Generation</span>
+ </button>
+ </Tooltip.Trigger>
+ <Tooltip.Content>
+ <p>
+ {isGenerationDisabled
+ ? 'Generation (waiting for tokens...)'
+ : 'Generation (token output)'}
+ </p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ </div>
+
+ <div class="flex items-center gap-1 px-2">
+ {#if activeView === ChatMessageStatsView.GENERATION && hasGenerationStats}
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={WholeWord}
+ value="{predictedTokens?.toLocaleString()} tokens"
+ tooltipLabel="Generated tokens"
+ />
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={Clock}
+ value="{timeInSeconds}s"
+ tooltipLabel="Generation time"
+ />
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={Gauge}
+ value="{tokensPerSecond.toFixed(2)} tokens/s"
+ tooltipLabel="Generation speed"
+ />
+ {:else if hasPromptStats}
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={WholeWord}
+ value="{promptTokens} tokens"
+ tooltipLabel="Prompt tokens"
+ />
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={Clock}
+ value="{promptTimeInSeconds}s"
+ tooltipLabel="Prompt processing time"
+ />
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={Gauge}
+ value="{promptTokensPerSecond!.toFixed(2)} tokens/s"
+ tooltipLabel="Prompt processing speed"
+ />
+ {/if}
+ </div>
+</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte
new file mode 100644
index 0000000..c203822
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte
@@ -0,0 +1,216 @@
+<script lang="ts">
+ import { Check, X } from '@lucide/svelte';
+ import { Card } from '$lib/components/ui/card';
+ import { Button } from '$lib/components/ui/button';
+ import { MarkdownContent } from '$lib/components/app';
+ import { INPUT_CLASSES } from '$lib/constants/input-classes';
+ import { config } from '$lib/stores/settings.svelte';
+ import ChatMessageActions from './ChatMessageActions.svelte';
+
+ interface Props {
+ class?: string;
+ message: DatabaseMessage;
+ isEditing: boolean;
+ editedContent: string;
+ siblingInfo?: ChatMessageSiblingInfo | null;
+ showDeleteDialog: boolean;
+ deletionInfo: {
+ totalCount: number;
+ userMessages: number;
+ assistantMessages: number;
+ messageTypes: string[];
+ } | null;
+ onCancelEdit: () => void;
+ onSaveEdit: () => void;
+ onEditKeydown: (event: KeyboardEvent) => void;
+ onEditedContentChange: (content: string) => void;
+ onCopy: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+ onConfirmDelete: () => void;
+ onNavigateToSibling?: (siblingId: string) => void;
+ onShowDeleteDialogChange: (show: boolean) => void;
+ textareaElement?: HTMLTextAreaElement;
+ }
+
+ let {
+ class: className = '',
+ message,
+ isEditing,
+ editedContent,
+ siblingInfo = null,
+ showDeleteDialog,
+ deletionInfo,
+ onCancelEdit,
+ onSaveEdit,
+ onEditKeydown,
+ onEditedContentChange,
+ onCopy,
+ onEdit,
+ onDelete,
+ onConfirmDelete,
+ onNavigateToSibling,
+ onShowDeleteDialogChange,
+ textareaElement = $bindable()
+ }: Props = $props();
+
+ let isMultiline = $state(false);
+ let messageElement: HTMLElement | undefined = $state();
+ let isExpanded = $state(false);
+ let contentHeight = $state(0);
+ const MAX_HEIGHT = 200; // pixels
+ const currentConfig = config();
+
+ let showExpandButton = $derived(contentHeight > MAX_HEIGHT);
+
+ $effect(() => {
+ if (!messageElement || !message.content.trim()) return;
+
+ if (message.content.includes('\n')) {
+ isMultiline = true;
+ }
+
+ const resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const element = entry.target as HTMLElement;
+ const estimatedSingleLineHeight = 24;
+
+ isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5;
+ contentHeight = element.scrollHeight;
+ }
+ });
+
+ resizeObserver.observe(messageElement);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ });
+
+ function toggleExpand() {
+ isExpanded = !isExpanded;
+ }
+</script>
+
+<div
+ aria-label="System message with actions"
+ class="group flex flex-col items-end gap-3 md:gap-2 {className}"
+ role="group"
+>
+ {#if isEditing}
+ <div class="w-full max-w-[80%]">
+ <textarea
+ bind:this={textareaElement}
+ bind:value={editedContent}
+ class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
+ onkeydown={onEditKeydown}
+ oninput={(e) => onEditedContentChange(e.currentTarget.value)}
+ placeholder="Edit system message..."
+ ></textarea>
+
+ <div class="mt-2 flex justify-end gap-2">
+ <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
+ <X class="mr-1 h-3 w-3" />
+ Cancel
+ </Button>
+
+ <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent.trim()} size="sm">
+ <Check class="mr-1 h-3 w-3" />
+ Send
+ </Button>
+ </div>
+ </div>
+ {:else}
+ {#if message.content.trim()}
+ <div class="relative max-w-[80%]">
+ <button
+ class="group/expand w-full text-left {!isExpanded && showExpandButton
+ ? 'cursor-pointer'
+ : 'cursor-auto'}"
+ onclick={showExpandButton && !isExpanded ? toggleExpand : undefined}
+ type="button"
+ >
+ <Card
+ class="rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5"
+ data-multiline={isMultiline ? '' : undefined}
+ style="border: 2px dashed hsl(var(--border));"
+ >
+ <div
+ class="relative overflow-hidden transition-all duration-300 {isExpanded
+ ? 'cursor-text select-text'
+ : 'select-none'}"
+ style={!isExpanded && showExpandButton
+ ? `max-height: ${MAX_HEIGHT}px;`
+ : 'max-height: none;'}
+ >
+ {#if currentConfig.renderUserContentAsMarkdown}
+ <div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}">
+ <MarkdownContent class="markdown-system-content" content={message.content} />
+ </div>
+ {:else}
+ <span
+ bind:this={messageElement}
+ class="text-md whitespace-pre-wrap {isExpanded ? 'cursor-text' : ''}"
+ >
+ {message.content}
+ </span>
+ {/if}
+
+ {#if !isExpanded && showExpandButton}
+ <div
+ class="pointer-events-none absolute right-0 bottom-0 left-0 h-48 bg-gradient-to-t from-muted to-transparent"
+ ></div>
+ <div
+ class="pointer-events-none absolute right-0 bottom-4 left-0 flex justify-center opacity-0 transition-opacity group-hover/expand:opacity-100"
+ >
+ <Button
+ class="rounded-full px-4 py-1.5 text-xs shadow-md"
+ size="sm"
+ variant="outline"
+ >
+ Show full system message
+ </Button>
+ </div>
+ {/if}
+ </div>
+
+ {#if isExpanded && showExpandButton}
+ <div class="mb-2 flex justify-center">
+ <Button
+ class="rounded-full px-4 py-1.5 text-xs"
+ onclick={(e) => {
+ e.stopPropagation();
+ toggleExpand();
+ }}
+ size="sm"
+ variant="outline"
+ >
+ Collapse System Message
+ </Button>
+ </div>
+ {/if}
+ </Card>
+ </button>
+ </div>
+ {/if}
+
+ {#if message.timestamp}
+ <div class="max-w-[80%]">
+ <ChatMessageActions
+ actionsPosition="right"
+ {deletionInfo}
+ justify="end"
+ {onConfirmDelete}
+ {onCopy}
+ {onDelete}
+ {onEdit}
+ {onNavigateToSibling}
+ {onShowDeleteDialogChange}
+ {siblingInfo}
+ {showDeleteDialog}
+ role="user"
+ />
+ </div>
+ {/if}
+ {/if}
+</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte
new file mode 100644
index 0000000..9245ad5
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte
@@ -0,0 +1,68 @@
+<script lang="ts">
+ import { Brain } from '@lucide/svelte';
+ import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
+ import * as Collapsible from '$lib/components/ui/collapsible/index.js';
+ import { buttonVariants } from '$lib/components/ui/button/index.js';
+ import { Card } from '$lib/components/ui/card';
+ import { config } from '$lib/stores/settings.svelte';
+
+ interface Props {
+ class?: string;
+ hasRegularContent?: boolean;
+ isStreaming?: boolean;
+ reasoningContent: string | null;
+ }
+
+ let {
+ class: className = '',
+ hasRegularContent = false,
+ isStreaming = false,
+ reasoningContent
+ }: Props = $props();
+
+ const currentConfig = config();
+
+ let isExpanded = $state(currentConfig.showThoughtInProgress);
+
+ $effect(() => {
+ if (hasRegularContent && reasoningContent && currentConfig.showThoughtInProgress) {
+ isExpanded = false;
+ }
+ });
+</script>
+
+<Collapsible.Root bind:open={isExpanded} class="mb-6 {className}">
+ <Card class="gap-0 border-muted bg-muted/30 py-0">
+ <Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3">
+ <div class="flex items-center gap-2 text-muted-foreground">
+ <Brain class="h-4 w-4" />
+
+ <span class="text-sm font-medium">
+ {isStreaming ? 'Reasoning...' : 'Reasoning'}
+ </span>
+ </div>
+
+ <div
+ class={buttonVariants({
+ variant: 'ghost',
+ size: 'sm',
+ class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
+ })}
+ >
+ <ChevronsUpDownIcon class="h-4 w-4" />
+
+ <span class="sr-only">Toggle reasoning content</span>
+ </div>
+ </Collapsible.Trigger>
+
+ <Collapsible.Content>
+ <div class="border-t border-muted px-3 pb-3">
+ <div class="pt-3">
+ <div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
+ {reasoningContent ?? ''}
+ </div>
+ </div>
+ </div>
+ </Collapsible.Content>
+ </Card>
+</Collapsible.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte
new file mode 100644
index 0000000..041c6bd
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte
@@ -0,0 +1,163 @@
+<script lang="ts">
+ import { Card } from '$lib/components/ui/card';
+ import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
+ import { config } from '$lib/stores/settings.svelte';
+ import ChatMessageActions from './ChatMessageActions.svelte';
+ import ChatMessageEditForm from './ChatMessageEditForm.svelte';
+
+ interface Props {
+ class?: string;
+ message: DatabaseMessage;
+ isEditing: boolean;
+ editedContent: string;
+ editedExtras?: DatabaseMessageExtra[];
+ editedUploadedFiles?: ChatUploadedFile[];
+ siblingInfo?: ChatMessageSiblingInfo | null;
+ showDeleteDialog: boolean;
+ deletionInfo: {
+ totalCount: number;
+ userMessages: number;
+ assistantMessages: number;
+ messageTypes: string[];
+ } | null;
+ onCancelEdit: () => void;
+ onSaveEdit: () => void;
+ onSaveEditOnly?: () => void;
+ onEditKeydown: (event: KeyboardEvent) => void;
+ onEditedContentChange: (content: string) => void;
+ onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
+ onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
+ onCopy: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+ onConfirmDelete: () => void;
+ onNavigateToSibling?: (siblingId: string) => void;
+ onShowDeleteDialogChange: (show: boolean) => void;
+ textareaElement?: HTMLTextAreaElement;
+ }
+
+ let {
+ class: className = '',
+ message,
+ isEditing,
+ editedContent,
+ editedExtras = [],
+ editedUploadedFiles = [],
+ siblingInfo = null,
+ showDeleteDialog,
+ deletionInfo,
+ onCancelEdit,
+ onSaveEdit,
+ onSaveEditOnly,
+ onEditKeydown,
+ onEditedContentChange,
+ onEditedExtrasChange,
+ onEditedUploadedFilesChange,
+ onCopy,
+ onEdit,
+ onDelete,
+ onConfirmDelete,
+ onNavigateToSibling,
+ onShowDeleteDialogChange,
+ textareaElement = $bindable()
+ }: Props = $props();
+
+ let isMultiline = $state(false);
+ let messageElement: HTMLElement | undefined = $state();
+ const currentConfig = config();
+
+ $effect(() => {
+ if (!messageElement || !message.content.trim()) return;
+
+ if (message.content.includes('\n')) {
+ isMultiline = true;
+ return;
+ }
+
+ const resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const element = entry.target as HTMLElement;
+ const estimatedSingleLineHeight = 24; // Typical line height for text-md
+
+ isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5;
+ }
+ });
+
+ resizeObserver.observe(messageElement);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ });
+</script>
+
+<div
+ aria-label="User message with actions"
+ class="group flex flex-col items-end gap-3 md:gap-2 {className}"
+ role="group"
+>
+ {#if isEditing}
+ <ChatMessageEditForm
+ bind:textareaElement
+ messageId={message.id}
+ {editedContent}
+ {editedExtras}
+ {editedUploadedFiles}
+ originalContent={message.content}
+ originalExtras={message.extra}
+ showSaveOnlyOption={!!onSaveEditOnly}
+ {onCancelEdit}
+ {onSaveEdit}
+ {onSaveEditOnly}
+ {onEditKeydown}
+ {onEditedContentChange}
+ {onEditedExtrasChange}
+ {onEditedUploadedFilesChange}
+ />
+ {:else}
+ {#if message.extra && message.extra.length > 0}
+ <div class="mb-2 max-w-[80%]">
+ <ChatAttachmentsList attachments={message.extra} readonly={true} imageHeight="h-80" />
+ </div>
+ {/if}
+
+ {#if message.content.trim()}
+ <Card
+ class="max-w-[80%] rounded-[1.125rem] border-none bg-primary px-3.75 py-1.5 text-primary-foreground data-[multiline]:py-2.5"
+ data-multiline={isMultiline ? '' : undefined}
+ >
+ {#if currentConfig.renderUserContentAsMarkdown}
+ <div bind:this={messageElement} class="text-md">
+ <MarkdownContent
+ class="markdown-user-content text-primary-foreground"
+ content={message.content}
+ />
+ </div>
+ {:else}
+ <span bind:this={messageElement} class="text-md whitespace-pre-wrap">
+ {message.content}
+ </span>
+ {/if}
+ </Card>
+ {/if}
+
+ {#if message.timestamp}
+ <div class="max-w-[80%]">
+ <ChatMessageActions
+ actionsPosition="right"
+ {deletionInfo}
+ justify="end"
+ {onConfirmDelete}
+ {onCopy}
+ {onDelete}
+ {onEdit}
+ {onNavigateToSibling}
+ {onShowDeleteDialogChange}
+ {siblingInfo}
+ {showDeleteDialog}
+ role="user"
+ />
+ </div>
+ {/if}
+ {/if}
+</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
new file mode 100644
index 0000000..c203f10
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
@@ -0,0 +1,143 @@
+<script lang="ts">
+ import { ChatMessage } from '$lib/components/app';
+ import { chatStore } from '$lib/stores/chat.svelte';
+ import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+ import { getMessageSiblings } from '$lib/utils';
+
+ interface Props {
+ class?: string;
+ messages?: DatabaseMessage[];
+ onUserAction?: () => void;
+ }
+
+ let { class: className, messages = [], onUserAction }: Props = $props();
+
+ let allConversationMessages = $state<DatabaseMessage[]>([]);
+ const currentConfig = config();
+
+ function refreshAllMessages() {
+ const conversation = activeConversation();
+
+ if (conversation) {
+ conversationsStore.getConversationMessages(conversation.id).then((messages) => {
+ allConversationMessages = messages;
+ });
+ } else {
+ allConversationMessages = [];
+ }
+ }
+
+ // Single effect that tracks both conversation and message changes
+ $effect(() => {
+ const conversation = activeConversation();
+
+ if (conversation) {
+ refreshAllMessages();
+ }
+ });
+
+ let displayMessages = $derived.by(() => {
+ if (!messages.length) {
+ return [];
+ }
+
+ // Filter out system messages if showSystemMessage is false
+ const filteredMessages = currentConfig.showSystemMessage
+ ? messages
+ : messages.filter((msg) => msg.type !== 'system');
+
+ return filteredMessages.map((message) => {
+ const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
+
+ return {
+ message,
+ siblingInfo: siblingInfo || {
+ message,
+ siblingIds: [message.id],
+ currentIndex: 0,
+ totalSiblings: 1
+ }
+ };
+ });
+ });
+
+ async function handleNavigateToSibling(siblingId: string) {
+ await conversationsStore.navigateToSibling(siblingId);
+ }
+
+ async function handleEditWithBranching(
+ message: DatabaseMessage,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ) {
+ onUserAction?.();
+
+ await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
+
+ refreshAllMessages();
+ }
+
+ async function handleEditWithReplacement(
+ message: DatabaseMessage,
+ newContent: string,
+ shouldBranch: boolean
+ ) {
+ onUserAction?.();
+
+ await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
+
+ refreshAllMessages();
+ }
+
+ async function handleRegenerateWithBranching(message: DatabaseMessage, modelOverride?: string) {
+ onUserAction?.();
+
+ await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
+
+ refreshAllMessages();
+ }
+
+ async function handleContinueAssistantMessage(message: DatabaseMessage) {
+ onUserAction?.();
+
+ await chatStore.continueAssistantMessage(message.id);
+
+ refreshAllMessages();
+ }
+
+ async function handleEditUserMessagePreserveResponses(
+ message: DatabaseMessage,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ) {
+ onUserAction?.();
+
+ await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
+
+ refreshAllMessages();
+ }
+
+ async function handleDeleteMessage(message: DatabaseMessage) {
+ await chatStore.deleteMessage(message.id);
+
+ refreshAllMessages();
+ }
+</script>
+
+<div class="flex h-full flex-col space-y-10 pt-16 md:pt-24 {className}" style="height: auto; ">
+ {#each displayMessages as { message, siblingInfo } (message.id)}
+ <ChatMessage
+ class="mx-auto w-full max-w-[48rem]"
+ {message}
+ {siblingInfo}
+ onDelete={handleDeleteMessage}
+ onNavigateToSibling={handleNavigateToSibling}
+ onEditWithBranching={handleEditWithBranching}
+ onEditWithReplacement={handleEditWithReplacement}
+ onEditUserMessagePreserveResponses={handleEditUserMessagePreserveResponses}
+ onRegenerateWithBranching={handleRegenerateWithBranching}
+ onContinueAssistantMessage={handleContinueAssistantMessage}
+ />
+ {/each}
+</div>