aboutsummaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages
diff options
context:
space:
mode:
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 @@
1<script lang="ts">
2 import { chatStore } from '$lib/stores/chat.svelte';
3 import { config } from '$lib/stores/settings.svelte';
4 import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils';
5 import ChatMessageAssistant from './ChatMessageAssistant.svelte';
6 import ChatMessageUser from './ChatMessageUser.svelte';
7 import ChatMessageSystem from './ChatMessageSystem.svelte';
8
9 interface Props {
10 class?: string;
11 message: DatabaseMessage;
12 onCopy?: (message: DatabaseMessage) => void;
13 onContinueAssistantMessage?: (message: DatabaseMessage) => void;
14 onDelete?: (message: DatabaseMessage) => void;
15 onEditWithBranching?: (
16 message: DatabaseMessage,
17 newContent: string,
18 newExtras?: DatabaseMessageExtra[]
19 ) => void;
20 onEditWithReplacement?: (
21 message: DatabaseMessage,
22 newContent: string,
23 shouldBranch: boolean
24 ) => void;
25 onEditUserMessagePreserveResponses?: (
26 message: DatabaseMessage,
27 newContent: string,
28 newExtras?: DatabaseMessageExtra[]
29 ) => void;
30 onNavigateToSibling?: (siblingId: string) => void;
31 onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
32 siblingInfo?: ChatMessageSiblingInfo | null;
33 }
34
35 let {
36 class: className = '',
37 message,
38 onCopy,
39 onContinueAssistantMessage,
40 onDelete,
41 onEditWithBranching,
42 onEditWithReplacement,
43 onEditUserMessagePreserveResponses,
44 onNavigateToSibling,
45 onRegenerateWithBranching,
46 siblingInfo = null
47 }: Props = $props();
48
49 let deletionInfo = $state<{
50 totalCount: number;
51 userMessages: number;
52 assistantMessages: number;
53 messageTypes: string[];
54 } | null>(null);
55 let editedContent = $state(message.content);
56 let editedExtras = $state<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
57 let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
58 let isEditing = $state(false);
59 let showDeleteDialog = $state(false);
60 let shouldBranchAfterEdit = $state(false);
61 let textareaElement: HTMLTextAreaElement | undefined = $state();
62
63 let thinkingContent = $derived.by(() => {
64 if (message.role === 'assistant') {
65 const trimmedThinking = message.thinking?.trim();
66
67 return trimmedThinking ? trimmedThinking : null;
68 }
69 return null;
70 });
71
72 let toolCallContent = $derived.by((): ApiChatCompletionToolCall[] | string | null => {
73 if (message.role === 'assistant') {
74 const trimmedToolCalls = message.toolCalls?.trim();
75
76 if (!trimmedToolCalls) {
77 return null;
78 }
79
80 try {
81 const parsed = JSON.parse(trimmedToolCalls);
82
83 if (Array.isArray(parsed)) {
84 return parsed as ApiChatCompletionToolCall[];
85 }
86 } catch {
87 // Harmony-only path: fall back to the raw string so issues surface visibly.
88 }
89
90 return trimmedToolCalls;
91 }
92 return null;
93 });
94
95 function handleCancelEdit() {
96 isEditing = false;
97 editedContent = message.content;
98 editedExtras = message.extra ? [...message.extra] : [];
99 editedUploadedFiles = [];
100 }
101
102 function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) {
103 editedExtras = extras;
104 }
105
106 function handleEditedUploadedFilesChange(files: ChatUploadedFile[]) {
107 editedUploadedFiles = files;
108 }
109
110 async function handleCopy() {
111 const asPlainText = Boolean(config().copyTextAttachmentsAsPlainText);
112 const clipboardContent = formatMessageForClipboard(message.content, message.extra, asPlainText);
113 await copyToClipboard(clipboardContent, 'Message copied to clipboard');
114 onCopy?.(message);
115 }
116
117 function handleConfirmDelete() {
118 onDelete?.(message);
119 showDeleteDialog = false;
120 }
121
122 async function handleDelete() {
123 deletionInfo = await chatStore.getDeletionInfo(message.id);
124 showDeleteDialog = true;
125 }
126
127 function handleEdit() {
128 isEditing = true;
129 editedContent = message.content;
130 editedExtras = message.extra ? [...message.extra] : [];
131 editedUploadedFiles = [];
132
133 setTimeout(() => {
134 if (textareaElement) {
135 textareaElement.focus();
136 textareaElement.setSelectionRange(
137 textareaElement.value.length,
138 textareaElement.value.length
139 );
140 }
141 }, 0);
142 }
143
144 function handleEditedContentChange(content: string) {
145 editedContent = content;
146 }
147
148 function handleEditKeydown(event: KeyboardEvent) {
149 // Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari)
150 // This prevents saving edit when confirming IME word selection (e.g., Japanese/Chinese input)
151 if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
152 event.preventDefault();
153 handleSaveEdit();
154 } else if (event.key === 'Escape') {
155 event.preventDefault();
156 handleCancelEdit();
157 }
158 }
159
160 function handleRegenerate(modelOverride?: string) {
161 onRegenerateWithBranching?.(message, modelOverride);
162 }
163
164 function handleContinue() {
165 onContinueAssistantMessage?.(message);
166 }
167
168 async function handleSaveEdit() {
169 if (message.role === 'user' || message.role === 'system') {
170 const finalExtras = await getMergedExtras();
171 onEditWithBranching?.(message, editedContent.trim(), finalExtras);
172 } else {
173 // For assistant messages, preserve exact content including trailing whitespace
174 // This is important for the Continue feature to work properly
175 onEditWithReplacement?.(message, editedContent, shouldBranchAfterEdit);
176 }
177
178 isEditing = false;
179 shouldBranchAfterEdit = false;
180 editedUploadedFiles = [];
181 }
182
183 async function handleSaveEditOnly() {
184 if (message.role === 'user') {
185 // For user messages, trim to avoid accidental whitespace
186 const finalExtras = await getMergedExtras();
187 onEditUserMessagePreserveResponses?.(message, editedContent.trim(), finalExtras);
188 }
189
190 isEditing = false;
191 editedUploadedFiles = [];
192 }
193
194 async function getMergedExtras(): Promise<DatabaseMessageExtra[]> {
195 if (editedUploadedFiles.length === 0) {
196 return editedExtras;
197 }
198
199 const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only');
200 const result = await parseFilesToMessageExtras(editedUploadedFiles);
201 const newExtras = result?.extras || [];
202
203 return [...editedExtras, ...newExtras];
204 }
205
206 function handleShowDeleteDialogChange(show: boolean) {
207 showDeleteDialog = show;
208 }
209</script>
210
211{#if message.role === 'system'}
212 <ChatMessageSystem
213 bind:textareaElement
214 class={className}
215 {deletionInfo}
216 {editedContent}
217 {isEditing}
218 {message}
219 onCancelEdit={handleCancelEdit}
220 onConfirmDelete={handleConfirmDelete}
221 onCopy={handleCopy}
222 onDelete={handleDelete}
223 onEdit={handleEdit}
224 onEditKeydown={handleEditKeydown}
225 onEditedContentChange={handleEditedContentChange}
226 {onNavigateToSibling}
227 onSaveEdit={handleSaveEdit}
228 onShowDeleteDialogChange={handleShowDeleteDialogChange}
229 {showDeleteDialog}
230 {siblingInfo}
231 />
232{:else if message.role === 'user'}
233 <ChatMessageUser
234 bind:textareaElement
235 class={className}
236 {deletionInfo}
237 {editedContent}
238 {editedExtras}
239 {editedUploadedFiles}
240 {isEditing}
241 {message}
242 onCancelEdit={handleCancelEdit}
243 onConfirmDelete={handleConfirmDelete}
244 onCopy={handleCopy}
245 onDelete={handleDelete}
246 onEdit={handleEdit}
247 onEditKeydown={handleEditKeydown}
248 onEditedContentChange={handleEditedContentChange}
249 onEditedExtrasChange={handleEditedExtrasChange}
250 onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
251 {onNavigateToSibling}
252 onSaveEdit={handleSaveEdit}
253 onSaveEditOnly={handleSaveEditOnly}
254 onShowDeleteDialogChange={handleShowDeleteDialogChange}
255 {showDeleteDialog}
256 {siblingInfo}
257 />
258{:else}
259 <ChatMessageAssistant
260 bind:textareaElement
261 class={className}
262 {deletionInfo}
263 {editedContent}
264 {isEditing}
265 {message}
266 messageContent={message.content}
267 onCancelEdit={handleCancelEdit}
268 onConfirmDelete={handleConfirmDelete}
269 onContinue={handleContinue}
270 onCopy={handleCopy}
271 onDelete={handleDelete}
272 onEdit={handleEdit}
273 onEditKeydown={handleEditKeydown}
274 onEditedContentChange={handleEditedContentChange}
275 {onNavigateToSibling}
276 onRegenerate={handleRegenerate}
277 onSaveEdit={handleSaveEdit}
278 onShowDeleteDialogChange={handleShowDeleteDialogChange}
279 {shouldBranchAfterEdit}
280 onShouldBranchAfterEditChange={(value) => (shouldBranchAfterEdit = value)}
281 {showDeleteDialog}
282 {siblingInfo}
283 {thinkingContent}
284 {toolCallContent}
285 />
286{/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 @@
1<script lang="ts">
2 import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
3 import {
4 ActionButton,
5 ChatMessageBranchingControls,
6 DialogConfirmation
7 } from '$lib/components/app';
8
9 interface Props {
10 role: 'user' | 'assistant';
11 justify: 'start' | 'end';
12 actionsPosition: 'left' | 'right';
13 siblingInfo?: ChatMessageSiblingInfo | null;
14 showDeleteDialog: boolean;
15 deletionInfo: {
16 totalCount: number;
17 userMessages: number;
18 assistantMessages: number;
19 messageTypes: string[];
20 } | null;
21 onCopy: () => void;
22 onEdit?: () => void;
23 onRegenerate?: () => void;
24 onContinue?: () => void;
25 onDelete: () => void;
26 onConfirmDelete: () => void;
27 onNavigateToSibling?: (siblingId: string) => void;
28 onShowDeleteDialogChange: (show: boolean) => void;
29 }
30
31 let {
32 actionsPosition,
33 deletionInfo,
34 justify,
35 onCopy,
36 onEdit,
37 onConfirmDelete,
38 onContinue,
39 onDelete,
40 onNavigateToSibling,
41 onShowDeleteDialogChange,
42 onRegenerate,
43 role,
44 siblingInfo = null,
45 showDeleteDialog
46 }: Props = $props();
47
48 function handleConfirmDelete() {
49 onConfirmDelete();
50 onShowDeleteDialogChange(false);
51 }
52</script>
53
54<div class="relative {justify === 'start' ? 'mt-2' : ''} flex h-6 items-center justify-{justify}">
55 <div
56 class="absolute top-0 {actionsPosition === 'left'
57 ? 'left-0'
58 : 'right-0'} flex items-center gap-2 opacity-100 transition-opacity"
59 >
60 {#if siblingInfo && siblingInfo.totalSiblings > 1}
61 <ChatMessageBranchingControls {siblingInfo} {onNavigateToSibling} />
62 {/if}
63
64 <div
65 class="pointer-events-auto inset-0 flex items-center gap-1 opacity-100 transition-all duration-150"
66 >
67 <ActionButton icon={Copy} tooltip="Copy" onclick={onCopy} />
68
69 {#if onEdit}
70 <ActionButton icon={Edit} tooltip="Edit" onclick={onEdit} />
71 {/if}
72
73 {#if role === 'assistant' && onRegenerate}
74 <ActionButton icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
75 {/if}
76
77 {#if role === 'assistant' && onContinue}
78 <ActionButton icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
79 {/if}
80
81 <ActionButton icon={Trash2} tooltip="Delete" onclick={onDelete} />
82 </div>
83 </div>
84</div>
85
86<DialogConfirmation
87 bind:open={showDeleteDialog}
88 title="Delete Message"
89 description={deletionInfo && deletionInfo.totalCount > 1
90 ? `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.`
91 : 'Are you sure you want to delete this message? This action cannot be undone.'}
92 confirmText={deletionInfo && deletionInfo.totalCount > 1
93 ? `Delete ${deletionInfo.totalCount} Messages`
94 : 'Delete'}
95 cancelText="Cancel"
96 variant="destructive"
97 icon={Trash2}
98 onConfirm={handleConfirmDelete}
99 onCancel={() => onShowDeleteDialogChange(false)}
100/>
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 @@
1<script lang="ts">
2 import {
3 ModelBadge,
4 ChatMessageActions,
5 ChatMessageStatistics,
6 ChatMessageThinkingBlock,
7 CopyToClipboardIcon,
8 MarkdownContent,
9 ModelsSelector
10 } from '$lib/components/app';
11 import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
12 import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
13 import { isLoading } from '$lib/stores/chat.svelte';
14 import { autoResizeTextarea, copyToClipboard } from '$lib/utils';
15 import { fade } from 'svelte/transition';
16 import { Check, X, Wrench } from '@lucide/svelte';
17 import { Button } from '$lib/components/ui/button';
18 import { Checkbox } from '$lib/components/ui/checkbox';
19 import { INPUT_CLASSES } from '$lib/constants/input-classes';
20 import Label from '$lib/components/ui/label/label.svelte';
21 import { config } from '$lib/stores/settings.svelte';
22 import { conversationsStore } from '$lib/stores/conversations.svelte';
23 import { isRouterMode } from '$lib/stores/server.svelte';
24
25 interface Props {
26 class?: string;
27 deletionInfo: {
28 totalCount: number;
29 userMessages: number;
30 assistantMessages: number;
31 messageTypes: string[];
32 } | null;
33 editedContent?: string;
34 isEditing?: boolean;
35 message: DatabaseMessage;
36 messageContent: string | undefined;
37 onCancelEdit?: () => void;
38 onCopy: () => void;
39 onConfirmDelete: () => void;
40 onContinue?: () => void;
41 onDelete: () => void;
42 onEdit?: () => void;
43 onEditKeydown?: (event: KeyboardEvent) => void;
44 onEditedContentChange?: (content: string) => void;
45 onNavigateToSibling?: (siblingId: string) => void;
46 onRegenerate: (modelOverride?: string) => void;
47 onSaveEdit?: () => void;
48 onShowDeleteDialogChange: (show: boolean) => void;
49 onShouldBranchAfterEditChange?: (value: boolean) => void;
50 showDeleteDialog: boolean;
51 shouldBranchAfterEdit?: boolean;
52 siblingInfo?: ChatMessageSiblingInfo | null;
53 textareaElement?: HTMLTextAreaElement;
54 thinkingContent: string | null;
55 toolCallContent: ApiChatCompletionToolCall[] | string | null;
56 }
57
58 let {
59 class: className = '',
60 deletionInfo,
61 editedContent = '',
62 isEditing = false,
63 message,
64 messageContent,
65 onCancelEdit,
66 onConfirmDelete,
67 onContinue,
68 onCopy,
69 onDelete,
70 onEdit,
71 onEditKeydown,
72 onEditedContentChange,
73 onNavigateToSibling,
74 onRegenerate,
75 onSaveEdit,
76 onShowDeleteDialogChange,
77 onShouldBranchAfterEditChange,
78 showDeleteDialog,
79 shouldBranchAfterEdit = false,
80 siblingInfo = null,
81 textareaElement = $bindable(),
82 thinkingContent,
83 toolCallContent = null
84 }: Props = $props();
85
86 const toolCalls = $derived(
87 Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null
88 );
89 const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null);
90
91 const processingState = useProcessingState();
92
93 let currentConfig = $derived(config());
94 let isRouter = $derived(isRouterMode());
95 let displayedModel = $derived((): string | null => {
96 if (message.model) {
97 return message.model;
98 }
99
100 return null;
101 });
102
103 const { handleModelChange } = useModelChangeValidation({
104 getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
105 onSuccess: (modelName) => onRegenerate(modelName)
106 });
107
108 function handleCopyModel() {
109 const model = displayedModel();
110
111 void copyToClipboard(model ?? '');
112 }
113
114 $effect(() => {
115 if (isEditing && textareaElement) {
116 autoResizeTextarea(textareaElement);
117 }
118 });
119
120 $effect(() => {
121 if (isLoading() && !message?.content?.trim()) {
122 processingState.startMonitoring();
123 }
124 });
125
126 function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) {
127 const callNumber = index + 1;
128 const functionName = toolCall.function?.name?.trim();
129 const label = functionName || `Call #${callNumber}`;
130
131 const payload: Record<string, unknown> = {};
132
133 const id = toolCall.id?.trim();
134 if (id) {
135 payload.id = id;
136 }
137
138 const type = toolCall.type?.trim();
139 if (type) {
140 payload.type = type;
141 }
142
143 if (toolCall.function) {
144 const fnPayload: Record<string, unknown> = {};
145
146 const name = toolCall.function.name?.trim();
147 if (name) {
148 fnPayload.name = name;
149 }
150
151 const rawArguments = toolCall.function.arguments?.trim();
152 if (rawArguments) {
153 try {
154 fnPayload.arguments = JSON.parse(rawArguments);
155 } catch {
156 fnPayload.arguments = rawArguments;
157 }
158 }
159
160 if (Object.keys(fnPayload).length > 0) {
161 payload.function = fnPayload;
162 }
163 }
164
165 const formattedPayload = JSON.stringify(payload, null, 2);
166
167 return {
168 label,
169 tooltip: formattedPayload,
170 copyValue: formattedPayload
171 };
172 }
173
174 function handleCopyToolCall(payload: string) {
175 void copyToClipboard(payload, 'Tool call copied to clipboard');
176 }
177</script>
178
179<div
180 class="text-md group w-full leading-7.5 {className}"
181 role="group"
182 aria-label="Assistant message with actions"
183>
184 {#if thinkingContent}
185 <ChatMessageThinkingBlock
186 reasoningContent={thinkingContent}
187 isStreaming={!message.timestamp}
188 hasRegularContent={!!messageContent?.trim()}
189 />
190 {/if}
191
192 {#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()}
193 <div class="mt-6 w-full max-w-[48rem]" in:fade>
194 <div class="processing-container">
195 <span class="processing-text">
196 {processingState.getPromptProgressText() ?? processingState.getProcessingMessage()}
197 </span>
198 </div>
199 </div>
200 {/if}
201
202 {#if isEditing}
203 <div class="w-full">
204 <textarea
205 bind:this={textareaElement}
206 bind:value={editedContent}
207 class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
208 onkeydown={onEditKeydown}
209 oninput={(e) => {
210 autoResizeTextarea(e.currentTarget);
211 onEditedContentChange?.(e.currentTarget.value);
212 }}
213 placeholder="Edit assistant message..."
214 ></textarea>
215
216 <div class="mt-2 flex items-center justify-between">
217 <div class="flex items-center space-x-2">
218 <Checkbox
219 id="branch-after-edit"
220 bind:checked={shouldBranchAfterEdit}
221 onCheckedChange={(checked) => onShouldBranchAfterEditChange?.(checked === true)}
222 />
223 <Label for="branch-after-edit" class="cursor-pointer text-sm text-muted-foreground">
224 Branch conversation after edit
225 </Label>
226 </div>
227 <div class="flex gap-2">
228 <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
229 <X class="mr-1 h-3 w-3" />
230 Cancel
231 </Button>
232
233 <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent?.trim()} size="sm">
234 <Check class="mr-1 h-3 w-3" />
235 Save
236 </Button>
237 </div>
238 </div>
239 </div>
240 {:else if message.role === 'assistant'}
241 {#if config().disableReasoningFormat}
242 <pre class="raw-output">{messageContent || ''}</pre>
243 {:else}
244 <MarkdownContent content={messageContent || ''} />
245 {/if}
246 {:else}
247 <div class="text-sm whitespace-pre-wrap">
248 {messageContent}
249 </div>
250 {/if}
251
252 <div class="info my-6 grid gap-4 tabular-nums">
253 {#if displayedModel()}
254 <div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
255 {#if isRouter}
256 <ModelsSelector
257 currentModel={displayedModel()}
258 onModelChange={handleModelChange}
259 disabled={isLoading()}
260 upToMessageId={message.id}
261 />
262 {:else}
263 <ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} />
264 {/if}
265
266 {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
267 <ChatMessageStatistics
268 promptTokens={message.timings.prompt_n}
269 promptMs={message.timings.prompt_ms}
270 predictedTokens={message.timings.predicted_n}
271 predictedMs={message.timings.predicted_ms}
272 />
273 {:else if isLoading() && currentConfig.showMessageStats}
274 {@const liveStats = processingState.getLiveProcessingStats()}
275 {@const genStats = processingState.getLiveGenerationStats()}
276 {@const promptProgress = processingState.processingState?.promptProgress}
277 {@const isStillProcessingPrompt =
278 promptProgress && promptProgress.processed < promptProgress.total}
279
280 {#if liveStats || genStats}
281 <ChatMessageStatistics
282 isLive={true}
283 isProcessingPrompt={!!isStillProcessingPrompt}
284 promptTokens={liveStats?.tokensProcessed}
285 promptMs={liveStats?.timeMs}
286 predictedTokens={genStats?.tokensGenerated}
287 predictedMs={genStats?.timeMs}
288 />
289 {/if}
290 {/if}
291 </div>
292 {/if}
293
294 {#if config().showToolCalls}
295 {#if (toolCalls && toolCalls.length > 0) || fallbackToolCalls}
296 <span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
297 <span class="inline-flex items-center gap-1">
298 <Wrench class="h-3.5 w-3.5" />
299
300 <span>Tool calls:</span>
301 </span>
302
303 {#if toolCalls && toolCalls.length > 0}
304 {#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)}
305 {@const badge = formatToolCallBadge(toolCall, index)}
306 <button
307 type="button"
308 class="tool-call-badge inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
309 title={badge.tooltip}
310 aria-label={`Copy tool call ${badge.label}`}
311 onclick={() => handleCopyToolCall(badge.copyValue)}
312 >
313 {badge.label}
314 <CopyToClipboardIcon
315 text={badge.copyValue}
316 ariaLabel={`Copy tool call ${badge.label}`}
317 />
318 </button>
319 {/each}
320 {:else if fallbackToolCalls}
321 <button
322 type="button"
323 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"
324 title={fallbackToolCalls}
325 aria-label="Copy tool call payload"
326 onclick={() => handleCopyToolCall(fallbackToolCalls)}
327 >
328 {fallbackToolCalls}
329 <CopyToClipboardIcon text={fallbackToolCalls} ariaLabel="Copy tool call payload" />
330 </button>
331 {/if}
332 </span>
333 {/if}
334 {/if}
335 </div>
336
337 {#if message.timestamp && !isEditing}
338 <ChatMessageActions
339 role="assistant"
340 justify="start"
341 actionsPosition="left"
342 {siblingInfo}
343 {showDeleteDialog}
344 {deletionInfo}
345 {onCopy}
346 {onEdit}
347 {onRegenerate}
348 onContinue={currentConfig.enableContinueGeneration && !thinkingContent
349 ? onContinue
350 : undefined}
351 {onDelete}
352 {onConfirmDelete}
353 {onNavigateToSibling}
354 {onShowDeleteDialogChange}
355 />
356 {/if}
357</div>
358
359<style>
360 .processing-container {
361 display: flex;
362 flex-direction: column;
363 align-items: flex-start;
364 gap: 0.5rem;
365 }
366
367 .processing-text {
368 background: linear-gradient(
369 90deg,
370 var(--muted-foreground),
371 var(--foreground),
372 var(--muted-foreground)
373 );
374 background-size: 200% 100%;
375 background-clip: text;
376 -webkit-background-clip: text;
377 -webkit-text-fill-color: transparent;
378 animation: shine 1s linear infinite;
379 font-weight: 500;
380 font-size: 0.875rem;
381 }
382
383 @keyframes shine {
384 to {
385 background-position: -200% 0;
386 }
387 }
388
389 .raw-output {
390 width: 100%;
391 max-width: 48rem;
392 margin-top: 1.5rem;
393 padding: 1rem 1.25rem;
394 border-radius: 1rem;
395 background: hsl(var(--muted) / 0.3);
396 color: var(--foreground);
397 font-family:
398 ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
399 'Liberation Mono', Menlo, monospace;
400 font-size: 0.875rem;
401 line-height: 1.6;
402 white-space: pre-wrap;
403 word-break: break-word;
404 }
405
406 .tool-call-badge {
407 max-width: 12rem;
408 white-space: nowrap;
409 overflow: hidden;
410 text-overflow: ellipsis;
411 }
412
413 .tool-call-badge--fallback {
414 max-width: 20rem;
415 white-space: normal;
416 word-break: break-word;
417 }
418</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 @@
1<script lang="ts">
2 import { ChevronLeft, ChevronRight } from '@lucide/svelte';
3 import { Button } from '$lib/components/ui/button';
4 import * as Tooltip from '$lib/components/ui/tooltip';
5
6 interface Props {
7 class?: string;
8 siblingInfo: ChatMessageSiblingInfo | null;
9 onNavigateToSibling?: (siblingId: string) => void;
10 }
11
12 let { class: className = '', siblingInfo, onNavigateToSibling }: Props = $props();
13
14 let hasPrevious = $derived(siblingInfo && siblingInfo.currentIndex > 0);
15 let hasNext = $derived(siblingInfo && siblingInfo.currentIndex < siblingInfo.totalSiblings - 1);
16 let nextSiblingId = $derived(
17 hasNext ? siblingInfo!.siblingIds[siblingInfo!.currentIndex + 1] : null
18 );
19 let previousSiblingId = $derived(
20 hasPrevious ? siblingInfo!.siblingIds[siblingInfo!.currentIndex - 1] : null
21 );
22
23 function handleNext() {
24 if (nextSiblingId) {
25 onNavigateToSibling?.(nextSiblingId);
26 }
27 }
28
29 function handlePrevious() {
30 if (previousSiblingId) {
31 onNavigateToSibling?.(previousSiblingId);
32 }
33 }
34</script>
35
36{#if siblingInfo && siblingInfo.totalSiblings > 1}
37 <div
38 aria-label="Message version {siblingInfo.currentIndex + 1} of {siblingInfo.totalSiblings}"
39 class="flex items-center gap-1 text-xs text-muted-foreground {className}"
40 role="navigation"
41 >
42 <Tooltip.Root>
43 <Tooltip.Trigger>
44 <Button
45 aria-label="Previous message version"
46 class="h-5 w-5 p-0 {!hasPrevious ? 'cursor-not-allowed opacity-30' : ''}"
47 disabled={!hasPrevious}
48 onclick={handlePrevious}
49 size="sm"
50 variant="ghost"
51 >
52 <ChevronLeft class="h-3 w-3" />
53 </Button>
54 </Tooltip.Trigger>
55
56 <Tooltip.Content>
57 <p>Previous version</p>
58 </Tooltip.Content>
59 </Tooltip.Root>
60
61 <span class="px-1 font-mono text-xs">
62 {siblingInfo.currentIndex + 1}/{siblingInfo.totalSiblings}
63 </span>
64
65 <Tooltip.Root>
66 <Tooltip.Trigger>
67 <Button
68 aria-label="Next message version"
69 class="h-5 w-5 p-0 {!hasNext ? 'cursor-not-allowed opacity-30' : ''}"
70 disabled={!hasNext}
71 onclick={handleNext}
72 size="sm"
73 variant="ghost"
74 >
75 <ChevronRight class="h-3 w-3" />
76 </Button>
77 </Tooltip.Trigger>
78
79 <Tooltip.Content>
80 <p>Next version</p>
81 </Tooltip.Content>
82 </Tooltip.Root>
83 </div>
84{/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 @@
1<script lang="ts">
2 import { X, ArrowUp, Paperclip, AlertTriangle } from '@lucide/svelte';
3 import { Button } from '$lib/components/ui/button';
4 import { Switch } from '$lib/components/ui/switch';
5 import { ChatAttachmentsList, DialogConfirmation, ModelsSelector } from '$lib/components/app';
6 import { INPUT_CLASSES } from '$lib/constants/input-classes';
7 import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
8 import { AttachmentType, FileTypeCategory, MimeTypeText } from '$lib/enums';
9 import { config } from '$lib/stores/settings.svelte';
10 import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
11 import { setEditModeActive, clearEditMode } from '$lib/stores/chat.svelte';
12 import { conversationsStore } from '$lib/stores/conversations.svelte';
13 import { modelsStore } from '$lib/stores/models.svelte';
14 import { isRouterMode } from '$lib/stores/server.svelte';
15 import {
16 autoResizeTextarea,
17 getFileTypeCategory,
18 getFileTypeCategoryByExtension,
19 parseClipboardContent
20 } from '$lib/utils';
21
22 interface Props {
23 messageId: string;
24 editedContent: string;
25 editedExtras?: DatabaseMessageExtra[];
26 editedUploadedFiles?: ChatUploadedFile[];
27 originalContent: string;
28 originalExtras?: DatabaseMessageExtra[];
29 showSaveOnlyOption?: boolean;
30 onCancelEdit: () => void;
31 onSaveEdit: () => void;
32 onSaveEditOnly?: () => void;
33 onEditKeydown: (event: KeyboardEvent) => void;
34 onEditedContentChange: (content: string) => void;
35 onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
36 onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
37 textareaElement?: HTMLTextAreaElement;
38 }
39
40 let {
41 messageId,
42 editedContent,
43 editedExtras = [],
44 editedUploadedFiles = [],
45 originalContent,
46 originalExtras = [],
47 showSaveOnlyOption = false,
48 onCancelEdit,
49 onSaveEdit,
50 onSaveEditOnly,
51 onEditKeydown,
52 onEditedContentChange,
53 onEditedExtrasChange,
54 onEditedUploadedFilesChange,
55 textareaElement = $bindable()
56 }: Props = $props();
57
58 let fileInputElement: HTMLInputElement | undefined = $state();
59 let saveWithoutRegenerate = $state(false);
60 let showDiscardDialog = $state(false);
61 let isRouter = $derived(isRouterMode());
62 let currentConfig = $derived(config());
63
64 let pasteLongTextToFileLength = $derived.by(() => {
65 const n = Number(currentConfig.pasteLongTextToFileLen);
66
67 return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
68 });
69
70 let hasUnsavedChanges = $derived.by(() => {
71 if (editedContent !== originalContent) return true;
72 if (editedUploadedFiles.length > 0) return true;
73
74 const extrasChanged =
75 editedExtras.length !== originalExtras.length ||
76 editedExtras.some((extra, i) => extra !== originalExtras[i]);
77
78 if (extrasChanged) return true;
79
80 return false;
81 });
82
83 let hasAttachments = $derived(
84 (editedExtras && editedExtras.length > 0) ||
85 (editedUploadedFiles && editedUploadedFiles.length > 0)
86 );
87
88 let canSubmit = $derived(editedContent.trim().length > 0 || hasAttachments);
89
90 function getEditedAttachmentsModalities(): ModelModalities {
91 const modalities: ModelModalities = { vision: false, audio: false };
92
93 for (const extra of editedExtras) {
94 if (extra.type === AttachmentType.IMAGE) {
95 modalities.vision = true;
96 }
97
98 if (
99 extra.type === AttachmentType.PDF &&
100 'processedAsImages' in extra &&
101 extra.processedAsImages
102 ) {
103 modalities.vision = true;
104 }
105
106 if (extra.type === AttachmentType.AUDIO) {
107 modalities.audio = true;
108 }
109 }
110
111 for (const file of editedUploadedFiles) {
112 const category = getFileTypeCategory(file.type) || getFileTypeCategoryByExtension(file.name);
113 if (category === FileTypeCategory.IMAGE) {
114 modalities.vision = true;
115 }
116 if (category === FileTypeCategory.AUDIO) {
117 modalities.audio = true;
118 }
119 }
120
121 return modalities;
122 }
123
124 function getRequiredModalities(): ModelModalities {
125 const beforeModalities = conversationsStore.getModalitiesUpToMessage(messageId);
126 const editedModalities = getEditedAttachmentsModalities();
127
128 return {
129 vision: beforeModalities.vision || editedModalities.vision,
130 audio: beforeModalities.audio || editedModalities.audio
131 };
132 }
133
134 const { handleModelChange } = useModelChangeValidation({
135 getRequiredModalities,
136 onValidationFailure: async (previousModelId) => {
137 if (previousModelId) {
138 await modelsStore.selectModelById(previousModelId);
139 }
140 }
141 });
142
143 function handleFileInputChange(event: Event) {
144 const input = event.target as HTMLInputElement;
145 if (!input.files || input.files.length === 0) return;
146
147 const files = Array.from(input.files);
148
149 processNewFiles(files);
150 input.value = '';
151 }
152
153 function handleGlobalKeydown(event: KeyboardEvent) {
154 if (event.key === 'Escape') {
155 event.preventDefault();
156 attemptCancel();
157 }
158 }
159
160 function attemptCancel() {
161 if (hasUnsavedChanges) {
162 showDiscardDialog = true;
163 } else {
164 onCancelEdit();
165 }
166 }
167
168 function handleRemoveExistingAttachment(index: number) {
169 if (!onEditedExtrasChange) return;
170
171 const newExtras = [...editedExtras];
172
173 newExtras.splice(index, 1);
174 onEditedExtrasChange(newExtras);
175 }
176
177 function handleRemoveUploadedFile(fileId: string) {
178 if (!onEditedUploadedFilesChange) return;
179
180 const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId);
181
182 onEditedUploadedFilesChange(newFiles);
183 }
184
185 function handleSubmit() {
186 if (!canSubmit) return;
187
188 if (saveWithoutRegenerate && onSaveEditOnly) {
189 onSaveEditOnly();
190 } else {
191 onSaveEdit();
192 }
193
194 saveWithoutRegenerate = false;
195 }
196
197 async function processNewFiles(files: File[]) {
198 if (!onEditedUploadedFilesChange) return;
199
200 const { processFilesToChatUploaded } = await import('$lib/utils/browser-only');
201 const processed = await processFilesToChatUploaded(files);
202
203 onEditedUploadedFilesChange([...editedUploadedFiles, ...processed]);
204 }
205
206 function handlePaste(event: ClipboardEvent) {
207 if (!event.clipboardData) return;
208
209 const files = Array.from(event.clipboardData.items)
210 .filter((item) => item.kind === 'file')
211 .map((item) => item.getAsFile())
212 .filter((file): file is File => file !== null);
213
214 if (files.length > 0) {
215 event.preventDefault();
216 processNewFiles(files);
217
218 return;
219 }
220
221 const text = event.clipboardData.getData(MimeTypeText.PLAIN);
222
223 if (text.startsWith('"')) {
224 const parsed = parseClipboardContent(text);
225
226 if (parsed.textAttachments.length > 0) {
227 event.preventDefault();
228 onEditedContentChange(parsed.message);
229
230 const attachmentFiles = parsed.textAttachments.map(
231 (att) =>
232 new File([att.content], att.name, {
233 type: MimeTypeText.PLAIN
234 })
235 );
236
237 processNewFiles(attachmentFiles);
238
239 setTimeout(() => {
240 textareaElement?.focus();
241 }, 10);
242
243 return;
244 }
245 }
246
247 if (
248 text.length > 0 &&
249 pasteLongTextToFileLength > 0 &&
250 text.length > pasteLongTextToFileLength
251 ) {
252 event.preventDefault();
253
254 const textFile = new File([text], 'Pasted', {
255 type: MimeTypeText.PLAIN
256 });
257
258 processNewFiles([textFile]);
259 }
260 }
261
262 $effect(() => {
263 if (textareaElement) {
264 autoResizeTextarea(textareaElement);
265 }
266 });
267
268 $effect(() => {
269 setEditModeActive(processNewFiles);
270
271 return () => {
272 clearEditMode();
273 };
274 });
275</script>
276
277<svelte:window onkeydown={handleGlobalKeydown} />
278
279<input
280 bind:this={fileInputElement}
281 type="file"
282 multiple
283 class="hidden"
284 onchange={handleFileInputChange}
285/>
286
287<div
288 class="{INPUT_CLASSES} w-full max-w-[80%] overflow-hidden rounded-3xl backdrop-blur-md"
289 data-slot="edit-form"
290>
291 <ChatAttachmentsList
292 attachments={editedExtras}
293 uploadedFiles={editedUploadedFiles}
294 readonly={false}
295 onFileRemove={(fileId) => {
296 if (fileId.startsWith('attachment-')) {
297 const index = parseInt(fileId.replace('attachment-', ''), 10);
298 if (!isNaN(index) && index >= 0 && index < editedExtras.length) {
299 handleRemoveExistingAttachment(index);
300 }
301 } else {
302 handleRemoveUploadedFile(fileId);
303 }
304 }}
305 limitToSingleRow
306 class="py-5"
307 style="scroll-padding: 1rem;"
308 />
309
310 <div class="relative min-h-[48px] px-5 py-3">
311 <textarea
312 bind:this={textareaElement}
313 bind:value={editedContent}
314 class="field-sizing-content max-h-80 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
315 onkeydown={onEditKeydown}
316 oninput={(e) => {
317 autoResizeTextarea(e.currentTarget);
318 onEditedContentChange(e.currentTarget.value);
319 }}
320 onpaste={handlePaste}
321 placeholder="Edit your message..."
322 ></textarea>
323
324 <div class="flex w-full items-center gap-3" style="container-type: inline-size">
325 <Button
326 class="h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
327 onclick={() => fileInputElement?.click()}
328 type="button"
329 title="Add attachment"
330 >
331 <span class="sr-only">Attach files</span>
332
333 <Paperclip class="h-4 w-4" />
334 </Button>
335
336 <div class="flex-1"></div>
337
338 {#if isRouter}
339 <ModelsSelector
340 forceForegroundText={true}
341 useGlobalSelection={true}
342 onModelChange={handleModelChange}
343 />
344 {/if}
345
346 <Button
347 class="h-8 w-8 shrink-0 rounded-full p-0"
348 onclick={handleSubmit}
349 disabled={!canSubmit}
350 type="button"
351 title={saveWithoutRegenerate ? 'Save changes' : 'Send and regenerate'}
352 >
353 <span class="sr-only">{saveWithoutRegenerate ? 'Save' : 'Send'}</span>
354
355 <ArrowUp class="h-5 w-5" />
356 </Button>
357 </div>
358 </div>
359</div>
360
361<div class="mt-2 flex w-full max-w-[80%] items-center justify-between">
362 {#if showSaveOnlyOption && onSaveEditOnly}
363 <div class="flex items-center gap-2">
364 <Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" />
365
366 <label for="save-only-switch" class="cursor-pointer text-xs text-muted-foreground">
367 Update without re-sending
368 </label>
369 </div>
370 {:else}
371 <div></div>
372 {/if}
373
374 <Button class="h-7 px-3 text-xs" onclick={attemptCancel} size="sm" variant="ghost">
375 <X class="mr-1 h-3 w-3" />
376
377 Cancel
378 </Button>
379</div>
380
381<DialogConfirmation
382 bind:open={showDiscardDialog}
383 title="Discard changes?"
384 description="You have unsaved changes. Are you sure you want to discard them?"
385 confirmText="Discard"
386 cancelText="Keep editing"
387 variant="destructive"
388 icon={AlertTriangle}
389 onConfirm={onCancelEdit}
390 onCancel={() => (showDiscardDialog = false)}
391/>
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 @@
1<script lang="ts">
2 import { Clock, Gauge, WholeWord, BookOpenText, Sparkles } from '@lucide/svelte';
3 import { BadgeChatStatistic } from '$lib/components/app';
4 import * as Tooltip from '$lib/components/ui/tooltip';
5 import { ChatMessageStatsView } from '$lib/enums';
6
7 interface Props {
8 predictedTokens?: number;
9 predictedMs?: number;
10 promptTokens?: number;
11 promptMs?: number;
12 // Live mode: when true, shows stats during streaming
13 isLive?: boolean;
14 // Whether prompt processing is still in progress
15 isProcessingPrompt?: boolean;
16 // Initial view to show (defaults to READING in live mode)
17 initialView?: ChatMessageStatsView;
18 }
19
20 let {
21 predictedTokens,
22 predictedMs,
23 promptTokens,
24 promptMs,
25 isLive = false,
26 isProcessingPrompt = false,
27 initialView = ChatMessageStatsView.GENERATION
28 }: Props = $props();
29
30 let activeView: ChatMessageStatsView = $state(initialView);
31 let hasAutoSwitchedToGeneration = $state(false);
32
33 // In live mode: auto-switch to GENERATION tab when prompt processing completes
34 $effect(() => {
35 if (isLive) {
36 // Auto-switch to generation tab only when prompt processing is done (once)
37 if (
38 !hasAutoSwitchedToGeneration &&
39 !isProcessingPrompt &&
40 predictedTokens &&
41 predictedTokens > 0
42 ) {
43 activeView = ChatMessageStatsView.GENERATION;
44 hasAutoSwitchedToGeneration = true;
45 } else if (!hasAutoSwitchedToGeneration) {
46 // Stay on READING while prompt is still being processed
47 activeView = ChatMessageStatsView.READING;
48 }
49 }
50 });
51
52 let hasGenerationStats = $derived(
53 predictedTokens !== undefined &&
54 predictedTokens > 0 &&
55 predictedMs !== undefined &&
56 predictedMs > 0
57 );
58
59 let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0);
60 let timeInSeconds = $derived(
61 predictedMs !== undefined ? (predictedMs / 1000).toFixed(2) : '0.00'
62 );
63
64 let promptTokensPerSecond = $derived(
65 promptTokens !== undefined && promptMs !== undefined && promptMs > 0
66 ? (promptTokens / promptMs) * 1000
67 : undefined
68 );
69
70 let promptTimeInSeconds = $derived(
71 promptMs !== undefined ? (promptMs / 1000).toFixed(2) : undefined
72 );
73
74 let hasPromptStats = $derived(
75 promptTokens !== undefined &&
76 promptMs !== undefined &&
77 promptTokensPerSecond !== undefined &&
78 promptTimeInSeconds !== undefined
79 );
80
81 // In live mode, generation tab is disabled until we have generation stats
82 let isGenerationDisabled = $derived(isLive && !hasGenerationStats);
83</script>
84
85<div class="inline-flex items-center text-xs text-muted-foreground">
86 <div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5">
87 {#if hasPromptStats || isLive}
88 <Tooltip.Root>
89 <Tooltip.Trigger>
90 <button
91 type="button"
92 class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
93 ChatMessageStatsView.READING
94 ? 'bg-background text-foreground shadow-sm'
95 : 'hover:text-foreground'}"
96 onclick={() => (activeView = ChatMessageStatsView.READING)}
97 >
98 <BookOpenText class="h-3 w-3" />
99 <span class="sr-only">Reading</span>
100 </button>
101 </Tooltip.Trigger>
102 <Tooltip.Content>
103 <p>Reading (prompt processing)</p>
104 </Tooltip.Content>
105 </Tooltip.Root>
106 {/if}
107 <Tooltip.Root>
108 <Tooltip.Trigger>
109 <button
110 type="button"
111 class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
112 ChatMessageStatsView.GENERATION
113 ? 'bg-background text-foreground shadow-sm'
114 : isGenerationDisabled
115 ? 'cursor-not-allowed opacity-40'
116 : 'hover:text-foreground'}"
117 onclick={() => !isGenerationDisabled && (activeView = ChatMessageStatsView.GENERATION)}
118 disabled={isGenerationDisabled}
119 >
120 <Sparkles class="h-3 w-3" />
121 <span class="sr-only">Generation</span>
122 </button>
123 </Tooltip.Trigger>
124 <Tooltip.Content>
125 <p>
126 {isGenerationDisabled
127 ? 'Generation (waiting for tokens...)'
128 : 'Generation (token output)'}
129 </p>
130 </Tooltip.Content>
131 </Tooltip.Root>
132 </div>
133
134 <div class="flex items-center gap-1 px-2">
135 {#if activeView === ChatMessageStatsView.GENERATION && hasGenerationStats}
136 <BadgeChatStatistic
137 class="bg-transparent"
138 icon={WholeWord}
139 value="{predictedTokens?.toLocaleString()} tokens"
140 tooltipLabel="Generated tokens"
141 />
142 <BadgeChatStatistic
143 class="bg-transparent"
144 icon={Clock}
145 value="{timeInSeconds}s"
146 tooltipLabel="Generation time"
147 />
148 <BadgeChatStatistic
149 class="bg-transparent"
150 icon={Gauge}
151 value="{tokensPerSecond.toFixed(2)} tokens/s"
152 tooltipLabel="Generation speed"
153 />
154 {:else if hasPromptStats}
155 <BadgeChatStatistic
156 class="bg-transparent"
157 icon={WholeWord}
158 value="{promptTokens} tokens"
159 tooltipLabel="Prompt tokens"
160 />
161 <BadgeChatStatistic
162 class="bg-transparent"
163 icon={Clock}
164 value="{promptTimeInSeconds}s"
165 tooltipLabel="Prompt processing time"
166 />
167 <BadgeChatStatistic
168 class="bg-transparent"
169 icon={Gauge}
170 value="{promptTokensPerSecond!.toFixed(2)} tokens/s"
171 tooltipLabel="Prompt processing speed"
172 />
173 {/if}
174 </div>
175</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 @@
1<script lang="ts">
2 import { Check, X } from '@lucide/svelte';
3 import { Card } from '$lib/components/ui/card';
4 import { Button } from '$lib/components/ui/button';
5 import { MarkdownContent } from '$lib/components/app';
6 import { INPUT_CLASSES } from '$lib/constants/input-classes';
7 import { config } from '$lib/stores/settings.svelte';
8 import ChatMessageActions from './ChatMessageActions.svelte';
9
10 interface Props {
11 class?: string;
12 message: DatabaseMessage;
13 isEditing: boolean;
14 editedContent: string;
15 siblingInfo?: ChatMessageSiblingInfo | null;
16 showDeleteDialog: boolean;
17 deletionInfo: {
18 totalCount: number;
19 userMessages: number;
20 assistantMessages: number;
21 messageTypes: string[];
22 } | null;
23 onCancelEdit: () => void;
24 onSaveEdit: () => void;
25 onEditKeydown: (event: KeyboardEvent) => void;
26 onEditedContentChange: (content: string) => void;
27 onCopy: () => void;
28 onEdit: () => void;
29 onDelete: () => void;
30 onConfirmDelete: () => void;
31 onNavigateToSibling?: (siblingId: string) => void;
32 onShowDeleteDialogChange: (show: boolean) => void;
33 textareaElement?: HTMLTextAreaElement;
34 }
35
36 let {
37 class: className = '',
38 message,
39 isEditing,
40 editedContent,
41 siblingInfo = null,
42 showDeleteDialog,
43 deletionInfo,
44 onCancelEdit,
45 onSaveEdit,
46 onEditKeydown,
47 onEditedContentChange,
48 onCopy,
49 onEdit,
50 onDelete,
51 onConfirmDelete,
52 onNavigateToSibling,
53 onShowDeleteDialogChange,
54 textareaElement = $bindable()
55 }: Props = $props();
56
57 let isMultiline = $state(false);
58 let messageElement: HTMLElement | undefined = $state();
59 let isExpanded = $state(false);
60 let contentHeight = $state(0);
61 const MAX_HEIGHT = 200; // pixels
62 const currentConfig = config();
63
64 let showExpandButton = $derived(contentHeight > MAX_HEIGHT);
65
66 $effect(() => {
67 if (!messageElement || !message.content.trim()) return;
68
69 if (message.content.includes('\n')) {
70 isMultiline = true;
71 }
72
73 const resizeObserver = new ResizeObserver((entries) => {
74 for (const entry of entries) {
75 const element = entry.target as HTMLElement;
76 const estimatedSingleLineHeight = 24;
77
78 isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5;
79 contentHeight = element.scrollHeight;
80 }
81 });
82
83 resizeObserver.observe(messageElement);
84
85 return () => {
86 resizeObserver.disconnect();
87 };
88 });
89
90 function toggleExpand() {
91 isExpanded = !isExpanded;
92 }
93</script>
94
95<div
96 aria-label="System message with actions"
97 class="group flex flex-col items-end gap-3 md:gap-2 {className}"
98 role="group"
99>
100 {#if isEditing}
101 <div class="w-full max-w-[80%]">
102 <textarea
103 bind:this={textareaElement}
104 bind:value={editedContent}
105 class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
106 onkeydown={onEditKeydown}
107 oninput={(e) => onEditedContentChange(e.currentTarget.value)}
108 placeholder="Edit system message..."
109 ></textarea>
110
111 <div class="mt-2 flex justify-end gap-2">
112 <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
113 <X class="mr-1 h-3 w-3" />
114 Cancel
115 </Button>
116
117 <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent.trim()} size="sm">
118 <Check class="mr-1 h-3 w-3" />
119 Send
120 </Button>
121 </div>
122 </div>
123 {:else}
124 {#if message.content.trim()}
125 <div class="relative max-w-[80%]">
126 <button
127 class="group/expand w-full text-left {!isExpanded && showExpandButton
128 ? 'cursor-pointer'
129 : 'cursor-auto'}"
130 onclick={showExpandButton && !isExpanded ? toggleExpand : undefined}
131 type="button"
132 >
133 <Card
134 class="rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5"
135 data-multiline={isMultiline ? '' : undefined}
136 style="border: 2px dashed hsl(var(--border));"
137 >
138 <div
139 class="relative overflow-hidden transition-all duration-300 {isExpanded
140 ? 'cursor-text select-text'
141 : 'select-none'}"
142 style={!isExpanded && showExpandButton
143 ? `max-height: ${MAX_HEIGHT}px;`
144 : 'max-height: none;'}
145 >
146 {#if currentConfig.renderUserContentAsMarkdown}
147 <div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}">
148 <MarkdownContent class="markdown-system-content" content={message.content} />
149 </div>
150 {:else}
151 <span
152 bind:this={messageElement}
153 class="text-md whitespace-pre-wrap {isExpanded ? 'cursor-text' : ''}"
154 >
155 {message.content}
156 </span>
157 {/if}
158
159 {#if !isExpanded && showExpandButton}
160 <div
161 class="pointer-events-none absolute right-0 bottom-0 left-0 h-48 bg-gradient-to-t from-muted to-transparent"
162 ></div>
163 <div
164 class="pointer-events-none absolute right-0 bottom-4 left-0 flex justify-center opacity-0 transition-opacity group-hover/expand:opacity-100"
165 >
166 <Button
167 class="rounded-full px-4 py-1.5 text-xs shadow-md"
168 size="sm"
169 variant="outline"
170 >
171 Show full system message
172 </Button>
173 </div>
174 {/if}
175 </div>
176
177 {#if isExpanded && showExpandButton}
178 <div class="mb-2 flex justify-center">
179 <Button
180 class="rounded-full px-4 py-1.5 text-xs"
181 onclick={(e) => {
182 e.stopPropagation();
183 toggleExpand();
184 }}
185 size="sm"
186 variant="outline"
187 >
188 Collapse System Message
189 </Button>
190 </div>
191 {/if}
192 </Card>
193 </button>
194 </div>
195 {/if}
196
197 {#if message.timestamp}
198 <div class="max-w-[80%]">
199 <ChatMessageActions
200 actionsPosition="right"
201 {deletionInfo}
202 justify="end"
203 {onConfirmDelete}
204 {onCopy}
205 {onDelete}
206 {onEdit}
207 {onNavigateToSibling}
208 {onShowDeleteDialogChange}
209 {siblingInfo}
210 {showDeleteDialog}
211 role="user"
212 />
213 </div>
214 {/if}
215 {/if}
216</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 @@
1<script lang="ts">
2 import { Brain } from '@lucide/svelte';
3 import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
4 import * as Collapsible from '$lib/components/ui/collapsible/index.js';
5 import { buttonVariants } from '$lib/components/ui/button/index.js';
6 import { Card } from '$lib/components/ui/card';
7 import { config } from '$lib/stores/settings.svelte';
8
9 interface Props {
10 class?: string;
11 hasRegularContent?: boolean;
12 isStreaming?: boolean;
13 reasoningContent: string | null;
14 }
15
16 let {
17 class: className = '',
18 hasRegularContent = false,
19 isStreaming = false,
20 reasoningContent
21 }: Props = $props();
22
23 const currentConfig = config();
24
25 let isExpanded = $state(currentConfig.showThoughtInProgress);
26
27 $effect(() => {
28 if (hasRegularContent && reasoningContent && currentConfig.showThoughtInProgress) {
29 isExpanded = false;
30 }
31 });
32</script>
33
34<Collapsible.Root bind:open={isExpanded} class="mb-6 {className}">
35 <Card class="gap-0 border-muted bg-muted/30 py-0">
36 <Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3">
37 <div class="flex items-center gap-2 text-muted-foreground">
38 <Brain class="h-4 w-4" />
39
40 <span class="text-sm font-medium">
41 {isStreaming ? 'Reasoning...' : 'Reasoning'}
42 </span>
43 </div>
44
45 <div
46 class={buttonVariants({
47 variant: 'ghost',
48 size: 'sm',
49 class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
50 })}
51 >
52 <ChevronsUpDownIcon class="h-4 w-4" />
53
54 <span class="sr-only">Toggle reasoning content</span>
55 </div>
56 </Collapsible.Trigger>
57
58 <Collapsible.Content>
59 <div class="border-t border-muted px-3 pb-3">
60 <div class="pt-3">
61 <div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
62 {reasoningContent ?? ''}
63 </div>
64 </div>
65 </div>
66 </Collapsible.Content>
67 </Card>
68</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 @@
1<script lang="ts">
2 import { Card } from '$lib/components/ui/card';
3 import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
4 import { config } from '$lib/stores/settings.svelte';
5 import ChatMessageActions from './ChatMessageActions.svelte';
6 import ChatMessageEditForm from './ChatMessageEditForm.svelte';
7
8 interface Props {
9 class?: string;
10 message: DatabaseMessage;
11 isEditing: boolean;
12 editedContent: string;
13 editedExtras?: DatabaseMessageExtra[];
14 editedUploadedFiles?: ChatUploadedFile[];
15 siblingInfo?: ChatMessageSiblingInfo | null;
16 showDeleteDialog: boolean;
17 deletionInfo: {
18 totalCount: number;
19 userMessages: number;
20 assistantMessages: number;
21 messageTypes: string[];
22 } | null;
23 onCancelEdit: () => void;
24 onSaveEdit: () => void;
25 onSaveEditOnly?: () => void;
26 onEditKeydown: (event: KeyboardEvent) => void;
27 onEditedContentChange: (content: string) => void;
28 onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
29 onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
30 onCopy: () => void;
31 onEdit: () => void;
32 onDelete: () => void;
33 onConfirmDelete: () => void;
34 onNavigateToSibling?: (siblingId: string) => void;
35 onShowDeleteDialogChange: (show: boolean) => void;
36 textareaElement?: HTMLTextAreaElement;
37 }
38
39 let {
40 class: className = '',
41 message,
42 isEditing,
43 editedContent,
44 editedExtras = [],
45 editedUploadedFiles = [],
46 siblingInfo = null,
47 showDeleteDialog,
48 deletionInfo,
49 onCancelEdit,
50 onSaveEdit,
51 onSaveEditOnly,
52 onEditKeydown,
53 onEditedContentChange,
54 onEditedExtrasChange,
55 onEditedUploadedFilesChange,
56 onCopy,
57 onEdit,
58 onDelete,
59 onConfirmDelete,
60 onNavigateToSibling,
61 onShowDeleteDialogChange,
62 textareaElement = $bindable()
63 }: Props = $props();
64
65 let isMultiline = $state(false);
66 let messageElement: HTMLElement | undefined = $state();
67 const currentConfig = config();
68
69 $effect(() => {
70 if (!messageElement || !message.content.trim()) return;
71
72 if (message.content.includes('\n')) {
73 isMultiline = true;
74 return;
75 }
76
77 const resizeObserver = new ResizeObserver((entries) => {
78 for (const entry of entries) {
79 const element = entry.target as HTMLElement;
80 const estimatedSingleLineHeight = 24; // Typical line height for text-md
81
82 isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5;
83 }
84 });
85
86 resizeObserver.observe(messageElement);
87
88 return () => {
89 resizeObserver.disconnect();
90 };
91 });
92</script>
93
94<div
95 aria-label="User message with actions"
96 class="group flex flex-col items-end gap-3 md:gap-2 {className}"
97 role="group"
98>
99 {#if isEditing}
100 <ChatMessageEditForm
101 bind:textareaElement
102 messageId={message.id}
103 {editedContent}
104 {editedExtras}
105 {editedUploadedFiles}
106 originalContent={message.content}
107 originalExtras={message.extra}
108 showSaveOnlyOption={!!onSaveEditOnly}
109 {onCancelEdit}
110 {onSaveEdit}
111 {onSaveEditOnly}
112 {onEditKeydown}
113 {onEditedContentChange}
114 {onEditedExtrasChange}
115 {onEditedUploadedFilesChange}
116 />
117 {:else}
118 {#if message.extra && message.extra.length > 0}
119 <div class="mb-2 max-w-[80%]">
120 <ChatAttachmentsList attachments={message.extra} readonly={true} imageHeight="h-80" />
121 </div>
122 {/if}
123
124 {#if message.content.trim()}
125 <Card
126 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"
127 data-multiline={isMultiline ? '' : undefined}
128 >
129 {#if currentConfig.renderUserContentAsMarkdown}
130 <div bind:this={messageElement} class="text-md">
131 <MarkdownContent
132 class="markdown-user-content text-primary-foreground"
133 content={message.content}
134 />
135 </div>
136 {:else}
137 <span bind:this={messageElement} class="text-md whitespace-pre-wrap">
138 {message.content}
139 </span>
140 {/if}
141 </Card>
142 {/if}
143
144 {#if message.timestamp}
145 <div class="max-w-[80%]">
146 <ChatMessageActions
147 actionsPosition="right"
148 {deletionInfo}
149 justify="end"
150 {onConfirmDelete}
151 {onCopy}
152 {onDelete}
153 {onEdit}
154 {onNavigateToSibling}
155 {onShowDeleteDialogChange}
156 {siblingInfo}
157 {showDeleteDialog}
158 role="user"
159 />
160 </div>
161 {/if}
162 {/if}
163</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 @@
1<script lang="ts">
2 import { ChatMessage } from '$lib/components/app';
3 import { chatStore } from '$lib/stores/chat.svelte';
4 import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
5 import { config } from '$lib/stores/settings.svelte';
6 import { getMessageSiblings } from '$lib/utils';
7
8 interface Props {
9 class?: string;
10 messages?: DatabaseMessage[];
11 onUserAction?: () => void;
12 }
13
14 let { class: className, messages = [], onUserAction }: Props = $props();
15
16 let allConversationMessages = $state<DatabaseMessage[]>([]);
17 const currentConfig = config();
18
19 function refreshAllMessages() {
20 const conversation = activeConversation();
21
22 if (conversation) {
23 conversationsStore.getConversationMessages(conversation.id).then((messages) => {
24 allConversationMessages = messages;
25 });
26 } else {
27 allConversationMessages = [];
28 }
29 }
30
31 // Single effect that tracks both conversation and message changes
32 $effect(() => {
33 const conversation = activeConversation();
34
35 if (conversation) {
36 refreshAllMessages();
37 }
38 });
39
40 let displayMessages = $derived.by(() => {
41 if (!messages.length) {
42 return [];
43 }
44
45 // Filter out system messages if showSystemMessage is false
46 const filteredMessages = currentConfig.showSystemMessage
47 ? messages
48 : messages.filter((msg) => msg.type !== 'system');
49
50 return filteredMessages.map((message) => {
51 const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
52
53 return {
54 message,
55 siblingInfo: siblingInfo || {
56 message,
57 siblingIds: [message.id],
58 currentIndex: 0,
59 totalSiblings: 1
60 }
61 };
62 });
63 });
64
65 async function handleNavigateToSibling(siblingId: string) {
66 await conversationsStore.navigateToSibling(siblingId);
67 }
68
69 async function handleEditWithBranching(
70 message: DatabaseMessage,
71 newContent: string,
72 newExtras?: DatabaseMessageExtra[]
73 ) {
74 onUserAction?.();
75
76 await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
77
78 refreshAllMessages();
79 }
80
81 async function handleEditWithReplacement(
82 message: DatabaseMessage,
83 newContent: string,
84 shouldBranch: boolean
85 ) {
86 onUserAction?.();
87
88 await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
89
90 refreshAllMessages();
91 }
92
93 async function handleRegenerateWithBranching(message: DatabaseMessage, modelOverride?: string) {
94 onUserAction?.();
95
96 await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
97
98 refreshAllMessages();
99 }
100
101 async function handleContinueAssistantMessage(message: DatabaseMessage) {
102 onUserAction?.();
103
104 await chatStore.continueAssistantMessage(message.id);
105
106 refreshAllMessages();
107 }
108
109 async function handleEditUserMessagePreserveResponses(
110 message: DatabaseMessage,
111 newContent: string,
112 newExtras?: DatabaseMessageExtra[]
113 ) {
114 onUserAction?.();
115
116 await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
117
118 refreshAllMessages();
119 }
120
121 async function handleDeleteMessage(message: DatabaseMessage) {
122 await chatStore.deleteMessage(message.id);
123
124 refreshAllMessages();
125 }
126</script>
127
128<div class="flex h-full flex-col space-y-10 pt-16 md:pt-24 {className}" style="height: auto; ">
129 {#each displayMessages as { message, siblingInfo } (message.id)}
130 <ChatMessage
131 class="mx-auto w-full max-w-[48rem]"
132 {message}
133 {siblingInfo}
134 onDelete={handleDeleteMessage}
135 onNavigateToSibling={handleNavigateToSibling}
136 onEditWithBranching={handleEditWithBranching}
137 onEditWithReplacement={handleEditWithReplacement}
138 onEditUserMessagePreserveResponses={handleEditUserMessagePreserveResponses}
139 onRegenerateWithBranching={handleRegenerateWithBranching}
140 onContinueAssistantMessage={handleContinueAssistantMessage}
141 />
142 {/each}
143</div>