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, ChatMessageStatisticsMode } from '$lib/enums';
15 import { REASONING_TAGS } from '$lib/constants/agentic';
16 import { fade } from 'svelte/transition';
17 import { MessageRole } from '$lib/enums';
18 import { config } from '$lib/stores/settings.svelte';
19 import { isRouterMode } from '$lib/stores/server.svelte';
20 import { modelsStore } from '$lib/stores/models.svelte';
21 import { ServerModelStatus } from '$lib/enums';
23 import { hasAgenticContent } from '$lib/utils';
30 assistantMessages: number;
31 messageTypes: string[];
33 isLastAssistantMessage?: boolean;
34 message: DatabaseMessage;
35 toolMessages?: DatabaseMessage[];
36 messageContent: string | undefined;
38 onConfirmDelete: () => void;
39 onContinue?: () => void;
42 onForkConversation?: (options: { name: string; includeAttachments: boolean }) => void;
43 onNavigateToSibling?: (siblingId: string) => void;
44 onRegenerate: (modelOverride?: string) => void;
45 onShowDeleteDialogChange: (show: boolean) => void;
46 showDeleteDialog: boolean;
47 siblingInfo?: ChatMessageSiblingInfo | null;
48 textareaElement?: HTMLTextAreaElement;
52 class: className = '',
54 isLastAssistantMessage = false,
66 onShowDeleteDialogChange,
69 textareaElement = $bindable()
73 const editCtx = getMessageEditContext();
75 const isAgentic = $derived(hasAgenticContent(message, toolMessages));
76 const processingState = useProcessingState();
78 let currentConfig = $derived(config());
79 let isRouter = $derived(isRouterMode());
80 let showRawOutput = $state(false);
82 let rawOutputContent = $derived.by(() => {
83 const sections = deriveAgenticSections(message, toolMessages, [], false);
84 const parts: string[] = [];
86 for (const section of sections) {
87 switch (section.type) {
88 case AgenticSectionType.REASONING:
89 case AgenticSectionType.REASONING_PENDING:
90 parts.push(`${REASONING_TAGS.START}\n${section.content}\n${REASONING_TAGS.END}`);
93 case AgenticSectionType.TEXT:
94 parts.push(section.content);
97 case AgenticSectionType.TOOL_CALL:
98 case AgenticSectionType.TOOL_CALL_PENDING:
99 case AgenticSectionType.TOOL_CALL_STREAMING: {
100 const callObj: Record<string, unknown> = { name: section.toolName };
102 if (section.toolArgs) {
104 callObj.arguments = JSON.parse(section.toolArgs);
106 callObj.arguments = section.toolArgs;
110 parts.push(JSON.stringify(callObj, null, 2));
112 if (section.toolResult) {
113 parts.push(`[Tool Result]\n${section.toolResult}`);
121 return parts.join('\n\n\n');
124 let displayedModel = $derived(message.model ?? null);
126 // model being switched to while it loads, so the selector bar tracks it
127 let pendingModel = $state<string | null>(null);
129 let isCurrentlyLoading = $derived(isLoading());
130 let isStreaming = $derived(isChatStreaming());
131 let hasNoContent = $derived(!message?.content?.trim());
132 let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
134 // during a router auto-load the message has no model yet, so target the selected one
135 let loadTargetModel = $derived(message.model ?? modelsStore.selectedModelName);
136 let modelLoadProgress = $derived(
137 isRouter && loadTargetModel ? modelsStore.getLoadProgress(loadTargetModel) : null
139 let modelLoadingText = $derived(modelLoadProgressText(modelLoadProgress));
141 let showProcessingInfoTop = $derived(
142 message?.role === MessageRole.ASSISTANT &&
143 isActivelyProcessing &&
146 isLastAssistantMessage
149 let showProcessingInfoBottom = $derived(
150 message?.role === MessageRole.ASSISTANT &&
151 isActivelyProcessing &&
152 (!hasNoContent || isAgentic) &&
153 isLastAssistantMessage
156 let assistantEl: HTMLDivElement | undefined = $state();
157 let lastUserMessageHeight = $state(0);
158 let assistantMarginTop = $state(0);
161 if (!assistantEl) return;
163 assistantMarginTop = Math.round(parseFloat(getComputedStyle(assistantEl).marginTop));
165 const chatMessageEl = assistantEl.closest('.chat-message');
166 const previousChatMessage = chatMessageEl?.previousElementSibling;
167 const userMessageEl = previousChatMessage?.querySelector(
169 ) as HTMLElement | null;
171 if (!userMessageEl) {
172 lastUserMessageHeight = 0;
176 const updateHeight = () => {
177 const rect = userMessageEl.getBoundingClientRect();
178 const marginTop = Math.round(parseFloat(getComputedStyle(userMessageEl).marginTop));
179 lastUserMessageHeight = Math.round(rect.height + marginTop);
184 const resizeObserver = new ResizeObserver(updateHeight);
185 resizeObserver.observe(userMessageEl);
188 resizeObserver.disconnect();
192 function handleCopyModel() {
193 void copyToClipboard(displayedModel ?? '');
197 if (showProcessingInfoTop || showProcessingInfoBottom) {
198 processingState.startMonitoring();
204 bind:this={assistantEl}
205 class="chat-message-assistant text-md group w-full leading-7.5 {className}"
206 style:--last-user-message-height={lastUserMessageHeight > 0
207 ? `${lastUserMessageHeight}px`
209 style:--assistant-margin-top={assistantMarginTop > 0 ? `${assistantMarginTop}px` : undefined}
211 aria-label="Assistant message with actions"
213 {#if showProcessingInfoTop}
214 <div class="mt-6 w-full max-w-3xl" in:fade>
215 <div class="processing-container">
216 <span class="processing-text">
218 processingState.getPromptProgressText() ??
219 processingState.getProcessingMessage() ??
226 {#if editCtx.isEditing}
227 <ChatMessageEditForm />
228 {:else if message.role === MessageRole.ASSISTANT}
230 <pre class="raw-output">{rawOutputContent || ''}</pre>
232 <ChatMessageAgenticContent
235 isStreaming={isChatStreaming()}
236 {isLastAssistantMessage}
240 <div class="text-sm whitespace-pre-wrap">
245 {#if showProcessingInfoBottom}
246 <div class="mt-4 w-full max-w-3xl" in:fade>
247 <div class="processing-container">
248 <span class="processing-text">
250 processingState.getPromptProgressText() ??
251 processingState.getProcessingMessage() ??
258 <div class="info my-6 grid gap-4 tabular-nums">
260 <div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
262 <ModelsSelectorDropdown
263 currentModel={pendingModel ?? displayedModel}
264 disabled={isLoading()}
265 onModelChange={async (modelId: string, modelName: string) => {
266 const status = modelsStore.getModelStatus(modelId);
268 if (status !== ServerModelStatus.LOADED) {
269 pendingModel = modelId;
272 await modelsStore.loadModel(modelId);
278 onRegenerate(modelName);
283 <ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
286 {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
287 {@const agentic = message.timings.agentic}
288 <ChatMessageStatistics
289 mode={ChatMessageStatisticsMode.GENERATION}
290 promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
291 promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
292 predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
293 predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
294 agenticTimings={agentic}
296 {:else if isLoading() && currentConfig.showMessageStats}
297 {@const liveStats = processingState.getLiveProcessingStats()}
298 {@const genStats = processingState.getLiveGenerationStats()}
301 <ChatMessageStatistics
302 mode={ChatMessageStatisticsMode.GENERATION}
304 promptTokens={liveStats?.tokensProcessed}
305 promptMs={liveStats?.timeMs}
306 predictedTokens={genStats.tokensGenerated}
307 predictedMs={genStats.timeMs}
315 {#if message.timestamp && !editCtx.isEditing}
316 <ChatMessageActionIcons
317 role={MessageRole.ASSISTANT}
319 actionsPosition="left"
326 onContinue={currentConfig.enableContinueGeneration ? onContinue : undefined}
330 {onNavigateToSibling}
331 {onShowDeleteDialogChange}
332 showRawOutputSwitch={currentConfig.showRawOutputSwitch}
333 rawOutputEnabled={showRawOutput}
334 onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
340 :global(.chat-message):last-child .chat-message-assistant {
341 --assistant-min-height-offset: calc(
342 var(--last-user-message-height, 19rem) + var(--chat-form-height, 6rem) +
343 var(--chat-form-bottom-position, 0.5rem) + var(--chat-form-padding-top, 6rem) +
344 var(--assistant-margin-top, 3rem)
346 min-height: calc(100dvh - var(--assistant-min-height-offset));
348 @media (width > 768px) {
349 --assistant-min-height-offset: calc(
350 var(--last-user-message-height, 18rem) + var(--chat-form-height, 6rem) +
351 var(--chat-form-bottom-position, 1rem) + var(--chat-form-padding-top, 6rem) +
352 var(--assistant-margin-top, 3rem)
357 .processing-container {
359 flex-direction: column;
360 align-items: flex-start;
365 background: linear-gradient(
367 var(--muted-foreground),
369 var(--muted-foreground)
371 background-size: 200% 100%;
372 background-clip: text;
373 -webkit-background-clip: text;
374 -webkit-text-fill-color: transparent;
375 animation: shine 1s linear infinite;
382 background-position: -200% 0;
390 padding: 1rem 1.25rem;
392 background: hsl(var(--muted) / 0.3);
393 color: var(--foreground);
396 white-space: pre-wrap;
397 word-break: break-word;