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>