]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/blob
14f6c5ca027b24f79d217bcb1f76cfd11199ffb6
[pkg/ggml/sources/llama.cpp] /
1 <script lang="ts">
2 import {
3 ChatMessageAgenticContent,
4 ChatMessageActionIcons,
5 ChatMessageEditForm,
6 ChatMessageStatistics,
7 ModelBadge,
8 ModelsSelectorDropdown
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';
23
24 import { hasAgenticContent } from '$lib/utils';
25
26 interface Props {
27 class?: string;
28 deletionInfo: {
29 totalCount: number;
30 userMessages: number;
31 assistantMessages: number;
32 messageTypes: string[];
33 } | null;
34 isLastAssistantMessage?: boolean;
35 message: DatabaseMessage;
36 toolMessages?: DatabaseMessage[];
37 messageContent: string | undefined;
38 onCopy: () => void;
39 onConfirmDelete: () => void;
40 onContinue?: () => void;
41 onDelete: () => void;
42 onEdit?: () => 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;
50 }
51
52 let {
53 class: className = '',
54 deletionInfo,
55 isLastAssistantMessage = false,
56 message,
57 toolMessages = [],
58 messageContent,
59 onConfirmDelete,
60 onContinue,
61 onCopy,
62 onDelete,
63 onEdit,
64 onForkConversation,
65 onNavigateToSibling,
66 onRegenerate,
67 onShowDeleteDialogChange,
68 showDeleteDialog,
69 siblingInfo = null,
70 textareaElement = $bindable()
71 }: Props = $props();
72
73 // Get edit context
74 const editCtx = getMessageEditContext();
75
76 const isAgentic = $derived(hasAgenticContent(message, toolMessages));
77 const processingState = useProcessingState();
78
79 let currentConfig = $derived(config());
80 let isRouter = $derived(isRouterMode());
81 let showRawOutput = $state(false);
82
83 let rawOutputContent = $derived.by(() => {
84 const sections = deriveAgenticSections(message, toolMessages, [], false);
85 const parts: string[] = [];
86
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}`);
92 break;
93
94 case AgenticSectionType.TEXT:
95 parts.push(section.content);
96 break;
97
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 };
102
103 if (section.toolArgs) {
104 try {
105 callObj.arguments = JSON.parse(section.toolArgs);
106 } catch {
107 callObj.arguments = section.toolArgs;
108 }
109 }
110
111 parts.push(JSON.stringify(callObj, null, 2));
112
113 if (section.toolResult) {
114 parts.push(`[Tool Result]\n${section.toolResult}`);
115 }
116
117 break;
118 }
119 }
120 }
121
122 return parts.join('\n\n\n');
123 });
124
125 let activeStatsView = $state<ChatMessageStatsView>(ChatMessageStatsView.GENERATION);
126 let statsContainerEl: HTMLDivElement | undefined = $state();
127
128 function getScrollParent(el: HTMLElement): HTMLElement | null {
129 let parent = el.parentElement;
130 while (parent) {
131 const style = getComputedStyle(parent);
132 if (/(auto|scroll)/.test(style.overflowY)) {
133 return parent;
134 }
135 parent = parent.parentElement;
136 }
137 return null;
138 }
139
140 async function handleStatsViewChange(view: ChatMessageStatsView) {
141 const el = statsContainerEl;
142 if (!el) {
143 activeStatsView = view;
144
145 return;
146 }
147
148 const scrollParent = getScrollParent(el);
149 if (!scrollParent) {
150 activeStatsView = view;
151
152 return;
153 }
154
155 const yBefore = el.getBoundingClientRect().top;
156
157 activeStatsView = view;
158
159 await tick();
160
161 const delta = el.getBoundingClientRect().top - yBefore;
162 if (delta !== 0) {
163 scrollParent.scrollTop += delta;
164 }
165
166 // Correct any drift after browser paint
167 requestAnimationFrame(() => {
168 const drift = el.getBoundingClientRect().top - yBefore;
169
170 if (Math.abs(drift) > 1) {
171 scrollParent.scrollTop += drift;
172 }
173 });
174 }
175
176 let highlightAgenticTurns = $derived(
177 isAgentic &&
178 (currentConfig.alwaysShowAgenticTurns || activeStatsView === ChatMessageStatsView.SUMMARY)
179 );
180
181 let displayedModel = $derived(message.model ?? null);
182
183 // model being switched to while it loads, so the selector bar tracks it
184 let pendingModel = $state<string | null>(null);
185
186 let isCurrentlyLoading = $derived(isLoading());
187 let isStreaming = $derived(isChatStreaming());
188 let hasNoContent = $derived(!message?.content?.trim());
189 let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
190
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
195 );
196 let modelLoadingText = $derived(modelLoadProgressText(modelLoadProgress));
197
198 let showProcessingInfoTop = $derived(
199 message?.role === MessageRole.ASSISTANT &&
200 isActivelyProcessing &&
201 hasNoContent &&
202 !isAgentic &&
203 isLastAssistantMessage
204 );
205
206 let showProcessingInfoBottom = $derived(
207 message?.role === MessageRole.ASSISTANT &&
208 isActivelyProcessing &&
209 (!hasNoContent || isAgentic) &&
210 isLastAssistantMessage
211 );
212
213 let assistantEl: HTMLDivElement | undefined = $state();
214 let lastUserMessageHeight = $state(0);
215 let assistantMarginTop = $state(0);
216
217 $effect(() => {
218 if (!assistantEl) return;
219
220 assistantMarginTop = Math.round(parseFloat(getComputedStyle(assistantEl).marginTop));
221
222 const chatMessageEl = assistantEl.closest('.chat-message');
223 const previousChatMessage = chatMessageEl?.previousElementSibling;
224 const userMessageEl = previousChatMessage?.querySelector(
225 '.chat-message-user'
226 ) as HTMLElement | null;
227
228 if (!userMessageEl) {
229 lastUserMessageHeight = 0;
230 return;
231 }
232
233 const updateHeight = () => {
234 const rect = userMessageEl.getBoundingClientRect();
235 const marginTop = Math.round(parseFloat(getComputedStyle(userMessageEl).marginTop));
236 lastUserMessageHeight = Math.round(rect.height + marginTop);
237 };
238
239 updateHeight();
240
241 const resizeObserver = new ResizeObserver(updateHeight);
242 resizeObserver.observe(userMessageEl);
243
244 return () => {
245 resizeObserver.disconnect();
246 };
247 });
248
249 function handleCopyModel() {
250 void copyToClipboard(displayedModel ?? '');
251 }
252
253 $effect(() => {
254 if (showProcessingInfoTop || showProcessingInfoBottom) {
255 processingState.startMonitoring();
256 }
257 });
258 </script>
259
260 <div
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`
265 : undefined}
266 style:--assistant-margin-top={assistantMarginTop > 0 ? `${assistantMarginTop}px` : undefined}
267 role="group"
268 aria-label="Assistant message with actions"
269 >
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">
274 {modelLoadingText ??
275 processingState.getPromptProgressText() ??
276 processingState.getProcessingMessage() ??
277 'Processing...'}
278 </span>
279 </div>
280 </div>
281 {/if}
282
283 {#if editCtx.isEditing}
284 <ChatMessageEditForm />
285 {:else if message.role === MessageRole.ASSISTANT}
286 {#if showRawOutput}
287 <pre class="raw-output">{rawOutputContent || ''}</pre>
288 {:else}
289 <ChatMessageAgenticContent
290 {message}
291 {toolMessages}
292 isStreaming={isChatStreaming()}
293 {isLastAssistantMessage}
294 highlightTurns={highlightAgenticTurns}
295 />
296 {/if}
297 {:else}
298 <div class="text-sm whitespace-pre-wrap">
299 {messageContent}
300 </div>
301 {/if}
302
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">
307 {modelLoadingText ??
308 processingState.getPromptProgressText() ??
309 processingState.getProcessingMessage() ??
310 'Processing...'}
311 </span>
312 </div>
313 </div>
314 {/if}
315
316 <div class="info my-6 grid gap-4 tabular-nums">
317 {#if displayedModel}
318 <div
319 bind:this={statsContainerEl}
320 class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground"
321 >
322 {#if isRouter}
323 <ModelsSelectorDropdown
324 currentModel={pendingModel ?? displayedModel}
325 disabled={isLoading()}
326 onModelChange={async (modelId: string, modelName: string) => {
327 const status = modelsStore.getModelStatus(modelId);
328
329 if (status !== ServerModelStatus.LOADED) {
330 pendingModel = modelId;
331
332 try {
333 await modelsStore.loadModel(modelId);
334 } finally {
335 pendingModel = null;
336 }
337 }
338
339 onRegenerate(modelName);
340 return true;
341 }}
342 />
343 {:else}
344 <ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
345 {/if}
346
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}
356 />
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}
363
364 {#if liveStats || genStats}
365 <ChatMessageStatistics
366 isLive
367 isProcessingPrompt={!!isStillProcessingPrompt}
368 promptTokens={liveStats?.tokensProcessed}
369 promptMs={liveStats?.timeMs}
370 predictedTokens={genStats?.tokensGenerated}
371 predictedMs={genStats?.timeMs}
372 />
373 {/if}
374 {/if}
375 </div>
376 {/if}
377 </div>
378
379 {#if message.timestamp && !editCtx.isEditing}
380 <ChatMessageActionIcons
381 role={MessageRole.ASSISTANT}
382 justify="start"
383 actionsPosition="left"
384 {siblingInfo}
385 {showDeleteDialog}
386 {deletionInfo}
387 {onCopy}
388 {onEdit}
389 {onRegenerate}
390 onContinue={currentConfig.enableContinueGeneration ? onContinue : undefined}
391 {onForkConversation}
392 {onDelete}
393 {onConfirmDelete}
394 {onNavigateToSibling}
395 {onShowDeleteDialogChange}
396 showRawOutputSwitch={currentConfig.showRawOutputSwitch}
397 rawOutputEnabled={showRawOutput}
398 onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
399 />
400 {/if}
401 </div>
402
403 <style>
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)
409 );
410 min-height: calc(100dvh - var(--assistant-min-height-offset));
411
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)
417 );
418 }
419 }
420
421 .processing-container {
422 display: flex;
423 flex-direction: column;
424 align-items: flex-start;
425 gap: 0.5rem;
426 }
427
428 .processing-text {
429 background: linear-gradient(
430 90deg,
431 var(--muted-foreground),
432 var(--foreground),
433 var(--muted-foreground)
434 );
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;
440 font-weight: 500;
441 font-size: 0.875rem;
442 }
443
444 @keyframes shine {
445 to {
446 background-position: -200% 0;
447 }
448 }
449
450 .raw-output {
451 width: 100%;
452 max-width: 48rem;
453 margin-top: 1.5rem;
454 padding: 1rem 1.25rem;
455 border-radius: 1rem;
456 background: hsl(var(--muted) / 0.3);
457 color: var(--foreground);
458 font-size: 0.875rem;
459 line-height: 1.6;
460 white-space: pre-wrap;
461 word-break: break-word;
462 }
463 </style>