]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/blob
2fb6066d9e88adfcbc2de5c1ff6bb26e6a42b0d7
[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 } 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 hasReasoning = $derived(!!message.reasoningContent);
78 const processingState = useProcessingState();
79
80 let currentConfig = $derived(config());
81 let isRouter = $derived(isRouterMode());
82 let showRawOutput = $state(false);
83
84 let rawOutputContent = $derived.by(() => {
85 const sections = deriveAgenticSections(message, toolMessages, [], false);
86 const parts: string[] = [];
87
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}`);
93 break;
94
95 case AgenticSectionType.TEXT:
96 parts.push(section.content);
97 break;
98
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 };
103
104 if (section.toolArgs) {
105 try {
106 callObj.arguments = JSON.parse(section.toolArgs);
107 } catch {
108 callObj.arguments = section.toolArgs;
109 }
110 }
111
112 parts.push(JSON.stringify(callObj, null, 2));
113
114 if (section.toolResult) {
115 parts.push(`[Tool Result]\n${section.toolResult}`);
116 }
117
118 break;
119 }
120 }
121 }
122
123 return parts.join('\n\n\n');
124 });
125
126 let activeStatsView = $state<ChatMessageStatsView>(ChatMessageStatsView.GENERATION);
127 let statsContainerEl: HTMLDivElement | undefined = $state();
128
129 function getScrollParent(el: HTMLElement): HTMLElement | null {
130 let parent = el.parentElement;
131 while (parent) {
132 const style = getComputedStyle(parent);
133 if (/(auto|scroll)/.test(style.overflowY)) {
134 return parent;
135 }
136 parent = parent.parentElement;
137 }
138 return null;
139 }
140
141 async function handleStatsViewChange(view: ChatMessageStatsView) {
142 const el = statsContainerEl;
143 if (!el) {
144 activeStatsView = view;
145
146 return;
147 }
148
149 const scrollParent = getScrollParent(el);
150 if (!scrollParent) {
151 activeStatsView = view;
152
153 return;
154 }
155
156 const yBefore = el.getBoundingClientRect().top;
157
158 activeStatsView = view;
159
160 await tick();
161
162 const delta = el.getBoundingClientRect().top - yBefore;
163 if (delta !== 0) {
164 scrollParent.scrollTop += delta;
165 }
166
167 // Correct any drift after browser paint
168 requestAnimationFrame(() => {
169 const drift = el.getBoundingClientRect().top - yBefore;
170
171 if (Math.abs(drift) > 1) {
172 scrollParent.scrollTop += drift;
173 }
174 });
175 }
176
177 let highlightAgenticTurns = $derived(
178 isAgentic &&
179 (currentConfig.alwaysShowAgenticTurns || activeStatsView === ChatMessageStatsView.SUMMARY)
180 );
181
182 let displayedModel = $derived(message.model ?? null);
183
184 let isCurrentlyLoading = $derived(isLoading());
185 let isStreaming = $derived(isChatStreaming());
186 let hasNoContent = $derived(!message?.content?.trim());
187 let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
188
189 let showProcessingInfoTop = $derived(
190 message?.role === MessageRole.ASSISTANT &&
191 isActivelyProcessing &&
192 hasNoContent &&
193 !isAgentic &&
194 isLastAssistantMessage
195 );
196
197 let showProcessingInfoBottom = $derived(
198 message?.role === MessageRole.ASSISTANT &&
199 isActivelyProcessing &&
200 (!hasNoContent || isAgentic) &&
201 isLastAssistantMessage
202 );
203
204 function handleCopyModel() {
205 void copyToClipboard(displayedModel ?? '');
206 }
207
208 $effect(() => {
209 if (showProcessingInfoTop || showProcessingInfoBottom) {
210 processingState.startMonitoring();
211 }
212 });
213 </script>
214
215 <div
216 class="text-md group w-full leading-7.5 {className}"
217 role="group"
218 aria-label="Assistant message with actions"
219 >
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() ??
226 'Processing...'}
227 </span>
228 </div>
229 </div>
230 {/if}
231
232 {#if editCtx.isEditing}
233 <ChatMessageEditForm />
234 {:else if message.role === MessageRole.ASSISTANT}
235 {#if showRawOutput}
236 <pre class="raw-output">{rawOutputContent || ''}</pre>
237 {:else}
238 <ChatMessageAgenticContent
239 {message}
240 {toolMessages}
241 isStreaming={isChatStreaming()}
242 {isLastAssistantMessage}
243 highlightTurns={highlightAgenticTurns}
244 />
245 {/if}
246 {:else}
247 <div class="text-sm whitespace-pre-wrap">
248 {messageContent}
249 </div>
250 {/if}
251
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() ??
258 'Processing...'}
259 </span>
260 </div>
261 </div>
262 {/if}
263
264 <div class="info my-6 grid gap-4 tabular-nums">
265 {#if displayedModel}
266 <div
267 bind:this={statsContainerEl}
268 class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground"
269 >
270 {#if isRouter}
271 <ModelsSelectorDropdown
272 currentModel={displayedModel}
273 disabled={isLoading()}
274 onModelChange={async (modelId: string, modelName: string) => {
275 const status = modelsStore.getModelStatus(modelId);
276
277 if (status !== ServerModelStatus.LOADED) {
278 await modelsStore.loadModel(modelId);
279 }
280
281 onRegenerate(modelName);
282 return true;
283 }}
284 />
285 {:else}
286 <ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
287 {/if}
288
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}
298 />
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}
305
306 {#if liveStats || genStats}
307 <ChatMessageStatistics
308 isLive
309 isProcessingPrompt={!!isStillProcessingPrompt}
310 promptTokens={liveStats?.tokensProcessed}
311 promptMs={liveStats?.timeMs}
312 predictedTokens={genStats?.tokensGenerated}
313 predictedMs={genStats?.timeMs}
314 />
315 {/if}
316 {/if}
317 </div>
318 {/if}
319 </div>
320
321 {#if message.timestamp && !editCtx.isEditing}
322 <ChatMessageActionIcons
323 role={MessageRole.ASSISTANT}
324 justify="start"
325 actionsPosition="left"
326 {siblingInfo}
327 {showDeleteDialog}
328 {deletionInfo}
329 {onCopy}
330 {onEdit}
331 {onRegenerate}
332 onContinue={currentConfig.enableContinueGeneration && !hasReasoning ? onContinue : undefined}
333 {onForkConversation}
334 {onDelete}
335 {onConfirmDelete}
336 {onNavigateToSibling}
337 {onShowDeleteDialogChange}
338 showRawOutputSwitch={currentConfig.showRawOutputSwitch}
339 rawOutputEnabled={showRawOutput}
340 onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
341 />
342 {/if}
343 </div>
344
345 <style>
346 .processing-container {
347 display: flex;
348 flex-direction: column;
349 align-items: flex-start;
350 gap: 0.5rem;
351 }
352
353 .processing-text {
354 background: linear-gradient(
355 90deg,
356 var(--muted-foreground),
357 var(--foreground),
358 var(--muted-foreground)
359 );
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;
365 font-weight: 500;
366 font-size: 0.875rem;
367 }
368
369 @keyframes shine {
370 to {
371 background-position: -200% 0;
372 }
373 }
374
375 .raw-output {
376 width: 100%;
377 max-width: 48rem;
378 margin-top: 1.5rem;
379 padding: 1rem 1.25rem;
380 border-radius: 1rem;
381 background: hsl(var(--muted) / 0.3);
382 color: var(--foreground);
383 font-family:
384 ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
385 'Liberation Mono', Menlo, monospace;
386 font-size: 0.875rem;
387 line-height: 1.6;
388 white-space: pre-wrap;
389 word-break: break-word;
390 }
391 </style>