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 } 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 hasReasoning = $derived(!!message.reasoningContent);
78 const processingState = useProcessingState();
80 let currentConfig = $derived(config());
81 let isRouter = $derived(isRouterMode());
82 let showRawOutput = $state(false);
84 let rawOutputContent = $derived.by(() => {
85 const sections = deriveAgenticSections(message, toolMessages, [], false);
86 const parts: string[] = [];
88 for (const section of sections) {
89 switch (section.type) {
90 case AgenticSectionType.REASONING:
91 case AgenticSectionType.REASONING_PENDING:
92 parts.push(`${REASONING_TAGS.START}\n${section.content}\n${REASONING_TAGS.END}`);
95 case AgenticSectionType.TEXT:
96 parts.push(section.content);
99 case AgenticSectionType.TOOL_CALL:
100 case AgenticSectionType.TOOL_CALL_PENDING:
101 case AgenticSectionType.TOOL_CALL_STREAMING: {
102 const callObj: Record<string, unknown> = { name: section.toolName };
104 if (section.toolArgs) {
106 callObj.arguments = JSON.parse(section.toolArgs);
108 callObj.arguments = section.toolArgs;
112 parts.push(JSON.stringify(callObj, null, 2));
114 if (section.toolResult) {
115 parts.push(`[Tool Result]\n${section.toolResult}`);
123 return parts.join('\n\n\n');
126 let activeStatsView = $state<ChatMessageStatsView>(ChatMessageStatsView.GENERATION);
127 let statsContainerEl: HTMLDivElement | undefined = $state();
129 function getScrollParent(el: HTMLElement): HTMLElement | null {
130 let parent = el.parentElement;
132 const style = getComputedStyle(parent);
133 if (/(auto|scroll)/.test(style.overflowY)) {
136 parent = parent.parentElement;
141 async function handleStatsViewChange(view: ChatMessageStatsView) {
142 const el = statsContainerEl;
144 activeStatsView = view;
149 const scrollParent = getScrollParent(el);
151 activeStatsView = view;
156 const yBefore = el.getBoundingClientRect().top;
158 activeStatsView = view;
162 const delta = el.getBoundingClientRect().top - yBefore;
164 scrollParent.scrollTop += delta;
167 // Correct any drift after browser paint
168 requestAnimationFrame(() => {
169 const drift = el.getBoundingClientRect().top - yBefore;
171 if (Math.abs(drift) > 1) {
172 scrollParent.scrollTop += drift;
177 let highlightAgenticTurns = $derived(
179 (currentConfig.alwaysShowAgenticTurns || activeStatsView === ChatMessageStatsView.SUMMARY)
182 let displayedModel = $derived(message.model ?? null);
184 let isCurrentlyLoading = $derived(isLoading());
185 let isStreaming = $derived(isChatStreaming());
186 let hasNoContent = $derived(!message?.content?.trim());
187 let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
189 let showProcessingInfoTop = $derived(
190 message?.role === MessageRole.ASSISTANT &&
191 isActivelyProcessing &&
194 isLastAssistantMessage
197 let showProcessingInfoBottom = $derived(
198 message?.role === MessageRole.ASSISTANT &&
199 isActivelyProcessing &&
200 (!hasNoContent || isAgentic) &&
201 isLastAssistantMessage
204 function handleCopyModel() {
205 void copyToClipboard(displayedModel ?? '');
209 if (showProcessingInfoTop || showProcessingInfoBottom) {
210 processingState.startMonitoring();
216 class="text-md group w-full leading-7.5 {className}"
218 aria-label="Assistant message with actions"
220 {#if showProcessingInfoTop}
221 <div class="mt-6 w-full max-w-[48rem]" in:fade>
222 <div class="processing-container">
223 <span class="processing-text">
224 {processingState.getPromptProgressText() ??
225 processingState.getProcessingMessage() ??
232 {#if editCtx.isEditing}
233 <ChatMessageEditForm />
234 {:else if message.role === MessageRole.ASSISTANT}
236 <pre class="raw-output">{rawOutputContent || ''}</pre>
238 <ChatMessageAgenticContent
241 isStreaming={isChatStreaming()}
242 {isLastAssistantMessage}
243 highlightTurns={highlightAgenticTurns}
247 <div class="text-sm whitespace-pre-wrap">
252 {#if showProcessingInfoBottom}
253 <div class="mt-4 w-full max-w-[48rem]" in:fade>
254 <div class="processing-container">
255 <span class="processing-text">
256 {processingState.getPromptProgressText() ??
257 processingState.getProcessingMessage() ??
264 <div class="info my-6 grid gap-4 tabular-nums">
267 bind:this={statsContainerEl}
268 class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground"
271 <ModelsSelectorDropdown
272 currentModel={displayedModel}
273 disabled={isLoading()}
274 onModelChange={async (modelId: string, modelName: string) => {
275 const status = modelsStore.getModelStatus(modelId);
277 if (status !== ServerModelStatus.LOADED) {
278 await modelsStore.loadModel(modelId);
281 onRegenerate(modelName);
286 <ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
289 {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
290 {@const agentic = message.timings.agentic}
291 <ChatMessageStatistics
292 promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
293 promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
294 predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
295 predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
296 agenticTimings={agentic}
297 onActiveViewChange={handleStatsViewChange}
299 {:else if isLoading() && currentConfig.showMessageStats}
300 {@const liveStats = processingState.getLiveProcessingStats()}
301 {@const genStats = processingState.getLiveGenerationStats()}
302 {@const promptProgress = processingState.processingState?.promptProgress}
303 {@const isStillProcessingPrompt =
304 promptProgress && promptProgress.processed < promptProgress.total}
306 {#if liveStats || genStats}
307 <ChatMessageStatistics
309 isProcessingPrompt={!!isStillProcessingPrompt}
310 promptTokens={liveStats?.tokensProcessed}
311 promptMs={liveStats?.timeMs}
312 predictedTokens={genStats?.tokensGenerated}
313 predictedMs={genStats?.timeMs}
321 {#if message.timestamp && !editCtx.isEditing}
322 <ChatMessageActionIcons
323 role={MessageRole.ASSISTANT}
325 actionsPosition="left"
332 onContinue={currentConfig.enableContinueGeneration && !hasReasoning ? onContinue : undefined}
336 {onNavigateToSibling}
337 {onShowDeleteDialogChange}
338 showRawOutputSwitch={currentConfig.showRawOutputSwitch}
339 rawOutputEnabled={showRawOutput}
340 onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
346 .processing-container {
348 flex-direction: column;
349 align-items: flex-start;
354 background: linear-gradient(
356 var(--muted-foreground),
358 var(--muted-foreground)
360 background-size: 200% 100%;
361 background-clip: text;
362 -webkit-background-clip: text;
363 -webkit-text-fill-color: transparent;
364 animation: shine 1s linear infinite;
371 background-position: -200% 0;
379 padding: 1rem 1.25rem;
381 background: hsl(var(--muted) / 0.3);
382 color: var(--foreground);
384 ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
385 'Liberation Mono', Menlo, monospace;
388 white-space: pre-wrap;
389 word-break: break-word;