]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/blob
6670d9302dc9553e3206027cd6e7920402078d45
[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, 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';
22
23 import { hasAgenticContent } from '$lib/utils';
24
25 interface Props {
26 class?: string;
27 deletionInfo: {
28 totalCount: number;
29 userMessages: number;
30 assistantMessages: number;
31 messageTypes: string[];
32 } | null;
33 isLastAssistantMessage?: boolean;
34 message: DatabaseMessage;
35 toolMessages?: DatabaseMessage[];
36 messageContent: string | undefined;
37 onCopy: () => void;
38 onConfirmDelete: () => void;
39 onContinue?: () => void;
40 onDelete: () => void;
41 onEdit?: () => 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;
49 }
50
51 let {
52 class: className = '',
53 deletionInfo,
54 isLastAssistantMessage = false,
55 message,
56 toolMessages = [],
57 messageContent,
58 onConfirmDelete,
59 onContinue,
60 onCopy,
61 onDelete,
62 onEdit,
63 onForkConversation,
64 onNavigateToSibling,
65 onRegenerate,
66 onShowDeleteDialogChange,
67 showDeleteDialog,
68 siblingInfo = null,
69 textareaElement = $bindable()
70 }: Props = $props();
71
72 // Get edit context
73 const editCtx = getMessageEditContext();
74
75 const isAgentic = $derived(hasAgenticContent(message, toolMessages));
76 const processingState = useProcessingState();
77
78 let currentConfig = $derived(config());
79 let isRouter = $derived(isRouterMode());
80 let showRawOutput = $state(false);
81
82 let rawOutputContent = $derived.by(() => {
83 const sections = deriveAgenticSections(message, toolMessages, [], false);
84 const parts: string[] = [];
85
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}`);
91 break;
92
93 case AgenticSectionType.TEXT:
94 parts.push(section.content);
95 break;
96
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 };
101
102 if (section.toolArgs) {
103 try {
104 callObj.arguments = JSON.parse(section.toolArgs);
105 } catch {
106 callObj.arguments = section.toolArgs;
107 }
108 }
109
110 parts.push(JSON.stringify(callObj, null, 2));
111
112 if (section.toolResult) {
113 parts.push(`[Tool Result]\n${section.toolResult}`);
114 }
115
116 break;
117 }
118 }
119 }
120
121 return parts.join('\n\n\n');
122 });
123
124 let displayedModel = $derived(message.model ?? null);
125
126 // model being switched to while it loads, so the selector bar tracks it
127 let pendingModel = $state<string | null>(null);
128
129 let isCurrentlyLoading = $derived(isLoading());
130 let isStreaming = $derived(isChatStreaming());
131 let hasNoContent = $derived(!message?.content?.trim());
132 let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
133
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
138 );
139 let modelLoadingText = $derived(modelLoadProgressText(modelLoadProgress));
140
141 let showProcessingInfoTop = $derived(
142 message?.role === MessageRole.ASSISTANT &&
143 isActivelyProcessing &&
144 hasNoContent &&
145 !isAgentic &&
146 isLastAssistantMessage
147 );
148
149 let showProcessingInfoBottom = $derived(
150 message?.role === MessageRole.ASSISTANT &&
151 isActivelyProcessing &&
152 (!hasNoContent || isAgentic) &&
153 isLastAssistantMessage
154 );
155
156 let assistantEl: HTMLDivElement | undefined = $state();
157 let lastUserMessageHeight = $state(0);
158 let assistantMarginTop = $state(0);
159
160 $effect(() => {
161 if (!assistantEl) return;
162
163 assistantMarginTop = Math.round(parseFloat(getComputedStyle(assistantEl).marginTop));
164
165 const chatMessageEl = assistantEl.closest('.chat-message');
166 const previousChatMessage = chatMessageEl?.previousElementSibling;
167 const userMessageEl = previousChatMessage?.querySelector(
168 '.chat-message-user'
169 ) as HTMLElement | null;
170
171 if (!userMessageEl) {
172 lastUserMessageHeight = 0;
173 return;
174 }
175
176 const updateHeight = () => {
177 const rect = userMessageEl.getBoundingClientRect();
178 const marginTop = Math.round(parseFloat(getComputedStyle(userMessageEl).marginTop));
179 lastUserMessageHeight = Math.round(rect.height + marginTop);
180 };
181
182 updateHeight();
183
184 const resizeObserver = new ResizeObserver(updateHeight);
185 resizeObserver.observe(userMessageEl);
186
187 return () => {
188 resizeObserver.disconnect();
189 };
190 });
191
192 function handleCopyModel() {
193 void copyToClipboard(displayedModel ?? '');
194 }
195
196 $effect(() => {
197 if (showProcessingInfoTop || showProcessingInfoBottom) {
198 processingState.startMonitoring();
199 }
200 });
201 </script>
202
203 <div
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`
208 : undefined}
209 style:--assistant-margin-top={assistantMarginTop > 0 ? `${assistantMarginTop}px` : undefined}
210 role="group"
211 aria-label="Assistant message with actions"
212 >
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">
217 {modelLoadingText ??
218 processingState.getPromptProgressText() ??
219 processingState.getProcessingMessage() ??
220 'Processing...'}
221 </span>
222 </div>
223 </div>
224 {/if}
225
226 {#if editCtx.isEditing}
227 <ChatMessageEditForm />
228 {:else if message.role === MessageRole.ASSISTANT}
229 {#if showRawOutput}
230 <pre class="raw-output">{rawOutputContent || ''}</pre>
231 {:else}
232 <ChatMessageAgenticContent
233 {message}
234 {toolMessages}
235 isStreaming={isChatStreaming()}
236 {isLastAssistantMessage}
237 />
238 {/if}
239 {:else}
240 <div class="text-sm whitespace-pre-wrap">
241 {messageContent}
242 </div>
243 {/if}
244
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">
249 {modelLoadingText ??
250 processingState.getPromptProgressText() ??
251 processingState.getProcessingMessage() ??
252 'Processing...'}
253 </span>
254 </div>
255 </div>
256 {/if}
257
258 <div class="info my-6 grid gap-4 tabular-nums">
259 {#if displayedModel}
260 <div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
261 {#if isRouter}
262 <ModelsSelectorDropdown
263 currentModel={pendingModel ?? displayedModel}
264 disabled={isLoading()}
265 onModelChange={async (modelId: string, modelName: string) => {
266 const status = modelsStore.getModelStatus(modelId);
267
268 if (status !== ServerModelStatus.LOADED) {
269 pendingModel = modelId;
270
271 try {
272 await modelsStore.loadModel(modelId);
273 } finally {
274 pendingModel = null;
275 }
276 }
277
278 onRegenerate(modelName);
279 return true;
280 }}
281 />
282 {:else}
283 <ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
284 {/if}
285
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}
295 />
296 {:else if isLoading() && currentConfig.showMessageStats}
297 {@const liveStats = processingState.getLiveProcessingStats()}
298 {@const genStats = processingState.getLiveGenerationStats()}
299
300 {#if genStats}
301 <ChatMessageStatistics
302 mode={ChatMessageStatisticsMode.GENERATION}
303 isLive
304 promptTokens={liveStats?.tokensProcessed}
305 promptMs={liveStats?.timeMs}
306 predictedTokens={genStats.tokensGenerated}
307 predictedMs={genStats.timeMs}
308 />
309 {/if}
310 {/if}
311 </div>
312 {/if}
313 </div>
314
315 {#if message.timestamp && !editCtx.isEditing}
316 <ChatMessageActionIcons
317 role={MessageRole.ASSISTANT}
318 justify="start"
319 actionsPosition="left"
320 {siblingInfo}
321 {showDeleteDialog}
322 {deletionInfo}
323 {onCopy}
324 {onEdit}
325 {onRegenerate}
326 onContinue={currentConfig.enableContinueGeneration ? onContinue : undefined}
327 {onForkConversation}
328 {onDelete}
329 {onConfirmDelete}
330 {onNavigateToSibling}
331 {onShowDeleteDialogChange}
332 showRawOutputSwitch={currentConfig.showRawOutputSwitch}
333 rawOutputEnabled={showRawOutput}
334 onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
335 />
336 {/if}
337 </div>
338
339 <style>
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)
345 );
346 min-height: calc(100dvh - var(--assistant-min-height-offset));
347
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)
353 );
354 }
355 }
356
357 .processing-container {
358 display: flex;
359 flex-direction: column;
360 align-items: flex-start;
361 gap: 0.5rem;
362 }
363
364 .processing-text {
365 background: linear-gradient(
366 90deg,
367 var(--muted-foreground),
368 var(--foreground),
369 var(--muted-foreground)
370 );
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;
376 font-weight: 500;
377 font-size: 0.875rem;
378 }
379
380 @keyframes shine {
381 to {
382 background-position: -200% 0;
383 }
384 }
385
386 .raw-output {
387 width: 100%;
388 max-width: 48rem;
389 margin-top: 1.5rem;
390 padding: 1rem 1.25rem;
391 border-radius: 1rem;
392 background: hsl(var(--muted) / 0.3);
393 color: var(--foreground);
394 font-size: 0.875rem;
395 line-height: 1.6;
396 white-space: pre-wrap;
397 word-break: break-word;
398 }
399 </style>