]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/blob
a1d02d6ece7d6d037eaccd29fb1e1c6d85fbe213
[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 let isCurrentlyLoading = $derived(isLoading());
184 let isStreaming = $derived(isChatStreaming());
185 let hasNoContent = $derived(!message?.content?.trim());
186 let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
187
188 // during a router auto-load the message has no model yet, so target the selected one
189 let loadTargetModel = $derived(message.model ?? modelsStore.selectedModelName);
190 let modelLoadProgress = $derived(
191 isRouter && loadTargetModel ? modelsStore.getLoadProgress(loadTargetModel) : null
192 );
193 let modelLoadingText = $derived(modelLoadProgressText(modelLoadProgress));
194
195 let showProcessingInfoTop = $derived(
196 message?.role === MessageRole.ASSISTANT &&
197 isActivelyProcessing &&
198 hasNoContent &&
199 !isAgentic &&
200 isLastAssistantMessage
201 );
202
203 let showProcessingInfoBottom = $derived(
204 message?.role === MessageRole.ASSISTANT &&
205 isActivelyProcessing &&
206 (!hasNoContent || isAgentic) &&
207 isLastAssistantMessage
208 );
209
210 let assistantEl: HTMLDivElement | undefined = $state();
211 let lastUserMessageHeight = $state(0);
212 let assistantMarginTop = $state(0);
213
214 $effect(() => {
215 if (!assistantEl) return;
216
217 assistantMarginTop = Math.round(parseFloat(getComputedStyle(assistantEl).marginTop));
218
219 const chatMessageEl = assistantEl.closest('.chat-message');
220 const previousChatMessage = chatMessageEl?.previousElementSibling;
221 const userMessageEl = previousChatMessage?.querySelector(
222 '.chat-message-user'
223 ) as HTMLElement | null;
224
225 if (!userMessageEl) {
226 lastUserMessageHeight = 0;
227 return;
228 }
229
230 const updateHeight = () => {
231 const rect = userMessageEl.getBoundingClientRect();
232 const marginTop = Math.round(parseFloat(getComputedStyle(userMessageEl).marginTop));
233 lastUserMessageHeight = Math.round(rect.height + marginTop);
234 };
235
236 updateHeight();
237
238 const resizeObserver = new ResizeObserver(updateHeight);
239 resizeObserver.observe(userMessageEl);
240
241 return () => {
242 resizeObserver.disconnect();
243 };
244 });
245
246 function handleCopyModel() {
247 void copyToClipboard(displayedModel ?? '');
248 }
249
250 $effect(() => {
251 if (showProcessingInfoTop || showProcessingInfoBottom) {
252 processingState.startMonitoring();
253 }
254 });
255 </script>
256
257 <div
258 bind:this={assistantEl}
259 class="chat-message-assistant text-md group w-full leading-7.5 {className}"
260 style:--last-user-message-height={lastUserMessageHeight > 0
261 ? `${lastUserMessageHeight}px`
262 : undefined}
263 style:--assistant-margin-top={assistantMarginTop > 0 ? `${assistantMarginTop}px` : undefined}
264 role="group"
265 aria-label="Assistant message with actions"
266 >
267 {#if showProcessingInfoTop}
268 <div class="mt-6 w-full max-w-3xl" in:fade>
269 <div class="processing-container">
270 <span class="processing-text">
271 {modelLoadingText ??
272 processingState.getPromptProgressText() ??
273 processingState.getProcessingMessage() ??
274 'Processing...'}
275 </span>
276 </div>
277 </div>
278 {/if}
279
280 {#if editCtx.isEditing}
281 <ChatMessageEditForm />
282 {:else if message.role === MessageRole.ASSISTANT}
283 {#if showRawOutput}
284 <pre class="raw-output">{rawOutputContent || ''}</pre>
285 {:else}
286 <ChatMessageAgenticContent
287 {message}
288 {toolMessages}
289 isStreaming={isChatStreaming()}
290 {isLastAssistantMessage}
291 highlightTurns={highlightAgenticTurns}
292 />
293 {/if}
294 {:else}
295 <div class="text-sm whitespace-pre-wrap">
296 {messageContent}
297 </div>
298 {/if}
299
300 {#if showProcessingInfoBottom}
301 <div class="mt-4 w-full max-w-3xl" in:fade>
302 <div class="processing-container">
303 <span class="processing-text">
304 {modelLoadingText ??
305 processingState.getPromptProgressText() ??
306 processingState.getProcessingMessage() ??
307 'Processing...'}
308 </span>
309 </div>
310 </div>
311 {/if}
312
313 <div class="info my-6 grid gap-4 tabular-nums">
314 {#if displayedModel}
315 <div
316 bind:this={statsContainerEl}
317 class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground"
318 >
319 {#if isRouter}
320 <ModelsSelectorDropdown
321 currentModel={displayedModel}
322 disabled={isLoading()}
323 onModelChange={async (modelId: string, modelName: string) => {
324 const status = modelsStore.getModelStatus(modelId);
325
326 if (status !== ServerModelStatus.LOADED) {
327 await modelsStore.loadModel(modelId);
328 }
329
330 onRegenerate(modelName);
331 return true;
332 }}
333 />
334 {:else}
335 <ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
336 {/if}
337
338 {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
339 {@const agentic = message.timings.agentic}
340 <ChatMessageStatistics
341 promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
342 promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
343 predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
344 predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
345 agenticTimings={agentic}
346 onActiveViewChange={handleStatsViewChange}
347 />
348 {:else if isLoading() && currentConfig.showMessageStats}
349 {@const liveStats = processingState.getLiveProcessingStats()}
350 {@const genStats = processingState.getLiveGenerationStats()}
351 {@const promptProgress = processingState.processingState?.promptProgress}
352 {@const isStillProcessingPrompt =
353 promptProgress && promptProgress.processed < promptProgress.total}
354
355 {#if liveStats || genStats}
356 <ChatMessageStatistics
357 isLive
358 isProcessingPrompt={!!isStillProcessingPrompt}
359 promptTokens={liveStats?.tokensProcessed}
360 promptMs={liveStats?.timeMs}
361 predictedTokens={genStats?.tokensGenerated}
362 predictedMs={genStats?.timeMs}
363 />
364 {/if}
365 {/if}
366 </div>
367 {/if}
368 </div>
369
370 {#if message.timestamp && !editCtx.isEditing}
371 <ChatMessageActionIcons
372 role={MessageRole.ASSISTANT}
373 justify="start"
374 actionsPosition="left"
375 {siblingInfo}
376 {showDeleteDialog}
377 {deletionInfo}
378 {onCopy}
379 {onEdit}
380 {onRegenerate}
381 onContinue={currentConfig.enableContinueGeneration ? onContinue : undefined}
382 {onForkConversation}
383 {onDelete}
384 {onConfirmDelete}
385 {onNavigateToSibling}
386 {onShowDeleteDialogChange}
387 showRawOutputSwitch={currentConfig.showRawOutputSwitch}
388 rawOutputEnabled={showRawOutput}
389 onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
390 />
391 {/if}
392 </div>
393
394 <style>
395 :global(.chat-message):last-child .chat-message-assistant {
396 --assistant-min-height-offset: calc(
397 var(--last-user-message-height, 19rem) + var(--chat-form-height, 6rem) +
398 var(--chat-form-bottom-position, 0.5rem) + var(--chat-form-padding-top, 6rem) +
399 var(--assistant-margin-top, 3rem)
400 );
401 min-height: calc(100dvh - var(--assistant-min-height-offset));
402
403 @media (width > 768px) {
404 --assistant-min-height-offset: calc(
405 var(--last-user-message-height, 18rem) + var(--chat-form-height, 6rem) +
406 var(--chat-form-bottom-position, 1rem) + var(--chat-form-padding-top, 6rem) +
407 var(--assistant-margin-top, 3rem)
408 );
409 }
410 }
411
412 .processing-container {
413 display: flex;
414 flex-direction: column;
415 align-items: flex-start;
416 gap: 0.5rem;
417 }
418
419 .processing-text {
420 background: linear-gradient(
421 90deg,
422 var(--muted-foreground),
423 var(--foreground),
424 var(--muted-foreground)
425 );
426 background-size: 200% 100%;
427 background-clip: text;
428 -webkit-background-clip: text;
429 -webkit-text-fill-color: transparent;
430 animation: shine 1s linear infinite;
431 font-weight: 500;
432 font-size: 0.875rem;
433 }
434
435 @keyframes shine {
436 to {
437 background-position: -200% 0;
438 }
439 }
440
441 .raw-output {
442 width: 100%;
443 max-width: 48rem;
444 margin-top: 1.5rem;
445 padding: 1rem 1.25rem;
446 border-radius: 1rem;
447 background: hsl(var(--muted) / 0.3);
448 color: var(--foreground);
449 font-size: 0.875rem;
450 line-height: 1.6;
451 white-space: pre-wrap;
452 word-break: break-word;
453 }
454 </style>