3 ChatMessageAgenticContent,
4 ChatMessageActionIcons,
9 } from '$lib/components/app';
10 import { getMessageEditContext } from '$lib/contexts';
11 import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
12 import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
13 import { copyToClipboard, deriveAgenticSections, modelLoadProgressText } from '$lib/utils';
14 import { AgenticSectionType } from '$lib/enums';
15 import { REASONING_TAGS } from '$lib/constants/agentic';
16 import { tick } from 'svelte';
17 import { fade } from 'svelte/transition';
18 import { MessageRole, ChatMessageStatsView } from '$lib/enums';
19 import { config } from '$lib/stores/settings.svelte';
20 import { isRouterMode } from '$lib/stores/server.svelte';
21 import { modelsStore } from '$lib/stores/models.svelte';
22 import { ServerModelStatus } from '$lib/enums';
24 import { hasAgenticContent } from '$lib/utils';
31 assistantMessages: number;
32 messageTypes: string[];
34 isLastAssistantMessage?: boolean;
35 message: DatabaseMessage;
36 toolMessages?: DatabaseMessage[];
37 messageContent: string | undefined;
39 onConfirmDelete: () => void;
40 onContinue?: () => void;
43 onForkConversation?: (options: { name: string; includeAttachments: boolean }) => void;
44 onNavigateToSibling?: (siblingId: string) => void;
45 onRegenerate: (modelOverride?: string) => void;
46 onShowDeleteDialogChange: (show: boolean) => void;
47 showDeleteDialog: boolean;
48 siblingInfo?: ChatMessageSiblingInfo | null;
49 textareaElement?: HTMLTextAreaElement;
53 class: className = '',
55 isLastAssistantMessage = false,
67 onShowDeleteDialogChange,
70 textareaElement = $bindable()
74 const editCtx = getMessageEditContext();
76 const isAgentic = $derived(hasAgenticContent(message, toolMessages));
77 const processingState = useProcessingState();
79 let currentConfig = $derived(config());
80 let isRouter = $derived(isRouterMode());
81 let showRawOutput = $state(false);
83 let rawOutputContent = $derived.by(() => {
84 const sections = deriveAgenticSections(message, toolMessages, [], false);
85 const parts: string[] = [];
87 for (const section of sections) {
88 switch (section.type) {
89 case AgenticSectionType.REASONING:
90 case AgenticSectionType.REASONING_PENDING:
91 parts.push(`${REASONING_TAGS.START}\n${section.content}\n${REASONING_TAGS.END}`);
94 case AgenticSectionType.TEXT:
95 parts.push(section.content);
98 case AgenticSectionType.TOOL_CALL:
99 case AgenticSectionType.TOOL_CALL_PENDING:
100 case AgenticSectionType.TOOL_CALL_STREAMING: {
101 const callObj: Record<string, unknown> = { name: section.toolName };
103 if (section.toolArgs) {
105 callObj.arguments = JSON.parse(section.toolArgs);
107 callObj.arguments = section.toolArgs;
111 parts.push(JSON.stringify(callObj, null, 2));
113 if (section.toolResult) {
114 parts.push(`[Tool Result]\n${section.toolResult}`);
122 return parts.join('\n\n\n');
125 let activeStatsView = $state<ChatMessageStatsView>(ChatMessageStatsView.GENERATION);
126 let statsContainerEl: HTMLDivElement | undefined = $state();
128 function getScrollParent(el: HTMLElement): HTMLElement | null {
129 let parent = el.parentElement;
131 const style = getComputedStyle(parent);
132 if (/(auto|scroll)/.test(style.overflowY)) {
135 parent = parent.parentElement;
140 async function handleStatsViewChange(view: ChatMessageStatsView) {
141 const el = statsContainerEl;
143 activeStatsView = view;
148 const scrollParent = getScrollParent(el);
150 activeStatsView = view;
155 const yBefore = el.getBoundingClientRect().top;
157 activeStatsView = view;
161 const delta = el.getBoundingClientRect().top - yBefore;
163 scrollParent.scrollTop += delta;
166 // Correct any drift after browser paint
167 requestAnimationFrame(() => {
168 const drift = el.getBoundingClientRect().top - yBefore;
170 if (Math.abs(drift) > 1) {
171 scrollParent.scrollTop += drift;
176 let highlightAgenticTurns = $derived(
178 (currentConfig.alwaysShowAgenticTurns || activeStatsView === ChatMessageStatsView.SUMMARY)
181 let displayedModel = $derived(message.model ?? null);
183 // model being switched to while it loads, so the selector bar tracks it
184 let pendingModel = $state<string | null>(null);
186 let isCurrentlyLoading = $derived(isLoading());
187 let isStreaming = $derived(isChatStreaming());
188 let hasNoContent = $derived(!message?.content?.trim());
189 let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
191 // during a router auto-load the message has no model yet, so target the selected one
192 let loadTargetModel = $derived(message.model ?? modelsStore.selectedModelName);
193 let modelLoadProgress = $derived(
194 isRouter && loadTargetModel ? modelsStore.getLoadProgress(loadTargetModel) : null
196 let modelLoadingText = $derived(modelLoadProgressText(modelLoadProgress));
198 let showProcessingInfoTop = $derived(
199 message?.role === MessageRole.ASSISTANT &&
200 isActivelyProcessing &&
203 isLastAssistantMessage
206 let showProcessingInfoBottom = $derived(
207 message?.role === MessageRole.ASSISTANT &&
208 isActivelyProcessing &&
209 (!hasNoContent || isAgentic) &&
210 isLastAssistantMessage
213 let assistantEl: HTMLDivElement | undefined = $state();
214 let lastUserMessageHeight = $state(0);
215 let assistantMarginTop = $state(0);
218 if (!assistantEl) return;
220 assistantMarginTop = Math.round(parseFloat(getComputedStyle(assistantEl).marginTop));
222 const chatMessageEl = assistantEl.closest('.chat-message');
223 const previousChatMessage = chatMessageEl?.previousElementSibling;
224 const userMessageEl = previousChatMessage?.querySelector(
226 ) as HTMLElement | null;
228 if (!userMessageEl) {
229 lastUserMessageHeight = 0;
233 const updateHeight = () => {
234 const rect = userMessageEl.getBoundingClientRect();
235 const marginTop = Math.round(parseFloat(getComputedStyle(userMessageEl).marginTop));
236 lastUserMessageHeight = Math.round(rect.height + marginTop);
241 const resizeObserver = new ResizeObserver(updateHeight);
242 resizeObserver.observe(userMessageEl);
245 resizeObserver.disconnect();
249 function handleCopyModel() {
250 void copyToClipboard(displayedModel ?? '');
254 if (showProcessingInfoTop || showProcessingInfoBottom) {
255 processingState.startMonitoring();
261 bind:this={assistantEl}
262 class="chat-message-assistant text-md group w-full leading-7.5 {className}"
263 style:--last-user-message-height={lastUserMessageHeight > 0
264 ? `${lastUserMessageHeight}px`
266 style:--assistant-margin-top={assistantMarginTop > 0 ? `${assistantMarginTop}px` : undefined}
268 aria-label="Assistant message with actions"
270 {#if showProcessingInfoTop}
271 <div class="mt-6 w-full max-w-3xl" in:fade>
272 <div class="processing-container">
273 <span class="processing-text">
275 processingState.getPromptProgressText() ??
276 processingState.getProcessingMessage() ??
283 {#if editCtx.isEditing}
284 <ChatMessageEditForm />
285 {:else if message.role === MessageRole.ASSISTANT}
287 <pre class="raw-output">{rawOutputContent || ''}</pre>
289 <ChatMessageAgenticContent
292 isStreaming={isChatStreaming()}
293 {isLastAssistantMessage}
294 highlightTurns={highlightAgenticTurns}
298 <div class="text-sm whitespace-pre-wrap">
303 {#if showProcessingInfoBottom}
304 <div class="mt-4 w-full max-w-3xl" in:fade>
305 <div class="processing-container">
306 <span class="processing-text">
308 processingState.getPromptProgressText() ??
309 processingState.getProcessingMessage() ??
316 <div class="info my-6 grid gap-4 tabular-nums">
319 bind:this={statsContainerEl}
320 class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground"
323 <ModelsSelectorDropdown
324 currentModel={pendingModel ?? displayedModel}
325 disabled={isLoading()}
326 onModelChange={async (modelId: string, modelName: string) => {
327 const status = modelsStore.getModelStatus(modelId);
329 if (status !== ServerModelStatus.LOADED) {
330 pendingModel = modelId;
333 await modelsStore.loadModel(modelId);
339 onRegenerate(modelName);
344 <ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
347 {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
348 {@const agentic = message.timings.agentic}
349 <ChatMessageStatistics
350 promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
351 promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
352 predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
353 predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
354 agenticTimings={agentic}
355 onActiveViewChange={handleStatsViewChange}
357 {:else if isLoading() && currentConfig.showMessageStats}
358 {@const liveStats = processingState.getLiveProcessingStats()}
359 {@const genStats = processingState.getLiveGenerationStats()}
360 {@const promptProgress = processingState.processingState?.promptProgress}
361 {@const isStillProcessingPrompt =
362 promptProgress && promptProgress.processed < promptProgress.total}
364 {#if liveStats || genStats}
365 <ChatMessageStatistics
367 isProcessingPrompt={!!isStillProcessingPrompt}
368 promptTokens={liveStats?.tokensProcessed}
369 promptMs={liveStats?.timeMs}
370 predictedTokens={genStats?.tokensGenerated}
371 predictedMs={genStats?.timeMs}
379 {#if message.timestamp && !editCtx.isEditing}
380 <ChatMessageActionIcons
381 role={MessageRole.ASSISTANT}
383 actionsPosition="left"
390 onContinue={currentConfig.enableContinueGeneration ? onContinue : undefined}
394 {onNavigateToSibling}
395 {onShowDeleteDialogChange}
396 showRawOutputSwitch={currentConfig.showRawOutputSwitch}
397 rawOutputEnabled={showRawOutput}
398 onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
404 :global(.chat-message):last-child .chat-message-assistant {
405 --assistant-min-height-offset: calc(
406 var(--last-user-message-height, 19rem) + var(--chat-form-height, 6rem) +
407 var(--chat-form-bottom-position, 0.5rem) + var(--chat-form-padding-top, 6rem) +
408 var(--assistant-margin-top, 3rem)
410 min-height: calc(100dvh - var(--assistant-min-height-offset));
412 @media (width > 768px) {
413 --assistant-min-height-offset: calc(
414 var(--last-user-message-height, 18rem) + var(--chat-form-height, 6rem) +
415 var(--chat-form-bottom-position, 1rem) + var(--chat-form-padding-top, 6rem) +
416 var(--assistant-margin-top, 3rem)
421 .processing-container {
423 flex-direction: column;
424 align-items: flex-start;
429 background: linear-gradient(
431 var(--muted-foreground),
433 var(--muted-foreground)
435 background-size: 200% 100%;
436 background-clip: text;
437 -webkit-background-clip: text;
438 -webkit-text-fill-color: transparent;
439 animation: shine 1s linear infinite;
446 background-position: -200% 0;
454 padding: 1rem 1.25rem;
456 background: hsl(var(--muted) / 0.3);
457 color: var(--foreground);
460 white-space: pre-wrap;
461 word-break: break-word;