aboutsummaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen
diff options
context:
space:
mode:
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen')
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte617
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenDragOverlay.svelte17
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte28
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte120
4 files changed, 782 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
new file mode 100644
index 0000000..2743955
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
@@ -0,0 +1,617 @@
1<script lang="ts">
2 import { afterNavigate } from '$app/navigation';
3 import {
4 ChatForm,
5 ChatScreenHeader,
6 ChatMessages,
7 ChatScreenProcessingInfo,
8 DialogEmptyFileAlert,
9 DialogChatError,
10 ServerLoadingSplash,
11 DialogConfirmation
12 } from '$lib/components/app';
13 import * as Alert from '$lib/components/ui/alert';
14 import * as AlertDialog from '$lib/components/ui/alert-dialog';
15 import {
16 AUTO_SCROLL_AT_BOTTOM_THRESHOLD,
17 AUTO_SCROLL_INTERVAL,
18 INITIAL_SCROLL_DELAY
19 } from '$lib/constants/auto-scroll';
20 import {
21 chatStore,
22 errorDialog,
23 isLoading,
24 isEditing,
25 getAddFilesHandler
26 } from '$lib/stores/chat.svelte';
27 import {
28 conversationsStore,
29 activeMessages,
30 activeConversation
31 } from '$lib/stores/conversations.svelte';
32 import { config } from '$lib/stores/settings.svelte';
33 import { serverLoading, serverError, serverStore, isRouterMode } from '$lib/stores/server.svelte';
34 import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
35 import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
36 import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
37 import { onMount } from 'svelte';
38 import { fade, fly, slide } from 'svelte/transition';
39 import { Trash2, AlertTriangle, RefreshCw } from '@lucide/svelte';
40 import ChatScreenDragOverlay from './ChatScreenDragOverlay.svelte';
41
42 let { showCenteredEmpty = false } = $props();
43
44 let disableAutoScroll = $derived(Boolean(config().disableAutoScroll));
45 let autoScrollEnabled = $state(true);
46 let chatScrollContainer: HTMLDivElement | undefined = $state();
47 let dragCounter = $state(0);
48 let isDragOver = $state(false);
49 let lastScrollTop = $state(0);
50 let scrollInterval: ReturnType<typeof setInterval> | undefined;
51 let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
52 let showFileErrorDialog = $state(false);
53 let uploadedFiles = $state<ChatUploadedFile[]>([]);
54 let userScrolledUp = $state(false);
55
56 let fileErrorData = $state<{
57 generallyUnsupported: File[];
58 modalityUnsupported: File[];
59 modalityReasons: Record<string, string>;
60 supportedTypes: string[];
61 }>({
62 generallyUnsupported: [],
63 modalityUnsupported: [],
64 modalityReasons: {},
65 supportedTypes: []
66 });
67
68 let showDeleteDialog = $state(false);
69
70 let showEmptyFileDialog = $state(false);
71
72 let emptyFileNames = $state<string[]>([]);
73
74 let isEmpty = $derived(
75 showCenteredEmpty && !activeConversation() && activeMessages().length === 0 && !isLoading()
76 );
77
78 let activeErrorDialog = $derived(errorDialog());
79 let isServerLoading = $derived(serverLoading());
80 let hasPropsError = $derived(!!serverError());
81
82 let isCurrentConversationLoading = $derived(isLoading());
83
84 let isRouter = $derived(isRouterMode());
85
86 let conversationModel = $derived(
87 chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
88 );
89
90 let activeModelId = $derived.by(() => {
91 const options = modelOptions();
92
93 if (!isRouter) {
94 return options.length > 0 ? options[0].model : null;
95 }
96
97 const selectedId = selectedModelId();
98 if (selectedId) {
99 const model = options.find((m) => m.id === selectedId);
100 if (model) return model.model;
101 }
102
103 if (conversationModel) {
104 const model = options.find((m) => m.model === conversationModel);
105 if (model) return model.model;
106 }
107
108 return null;
109 });
110
111 let modelPropsVersion = $state(0);
112
113 $effect(() => {
114 if (activeModelId) {
115 const cached = modelsStore.getModelProps(activeModelId);
116 if (!cached) {
117 modelsStore.fetchModelProps(activeModelId).then(() => {
118 modelPropsVersion++;
119 });
120 }
121 }
122 });
123
124 let hasAudioModality = $derived.by(() => {
125 if (activeModelId) {
126 void modelPropsVersion;
127 return modelsStore.modelSupportsAudio(activeModelId);
128 }
129
130 return false;
131 });
132
133 let hasVisionModality = $derived.by(() => {
134 if (activeModelId) {
135 void modelPropsVersion;
136
137 return modelsStore.modelSupportsVision(activeModelId);
138 }
139
140 return false;
141 });
142
143 async function handleDeleteConfirm() {
144 const conversation = activeConversation();
145
146 if (conversation) {
147 await conversationsStore.deleteConversation(conversation.id);
148 }
149
150 showDeleteDialog = false;
151 }
152
153 function handleDragEnter(event: DragEvent) {
154 event.preventDefault();
155
156 dragCounter++;
157
158 if (event.dataTransfer?.types.includes('Files')) {
159 isDragOver = true;
160 }
161 }
162
163 function handleDragLeave(event: DragEvent) {
164 event.preventDefault();
165
166 dragCounter--;
167
168 if (dragCounter === 0) {
169 isDragOver = false;
170 }
171 }
172
173 function handleErrorDialogOpenChange(open: boolean) {
174 if (!open) {
175 chatStore.dismissErrorDialog();
176 }
177 }
178
179 function handleDragOver(event: DragEvent) {
180 event.preventDefault();
181 }
182
183 function handleDrop(event: DragEvent) {
184 event.preventDefault();
185
186 isDragOver = false;
187 dragCounter = 0;
188
189 if (event.dataTransfer?.files) {
190 const files = Array.from(event.dataTransfer.files);
191
192 if (isEditing()) {
193 const handler = getAddFilesHandler();
194
195 if (handler) {
196 handler(files);
197 return;
198 }
199 }
200
201 processFiles(files);
202 }
203 }
204
205 function handleFileRemove(fileId: string) {
206 uploadedFiles = uploadedFiles.filter((f) => f.id !== fileId);
207 }
208
209 function handleFileUpload(files: File[]) {
210 processFiles(files);
211 }
212
213 function handleKeydown(event: KeyboardEvent) {
214 const isCtrlOrCmd = event.ctrlKey || event.metaKey;
215
216 if (isCtrlOrCmd && event.shiftKey && (event.key === 'd' || event.key === 'D')) {
217 event.preventDefault();
218 if (activeConversation()) {
219 showDeleteDialog = true;
220 }
221 }
222 }
223
224 function handleScroll() {
225 if (disableAutoScroll || !chatScrollContainer) return;
226
227 const { scrollTop, scrollHeight, clientHeight } = chatScrollContainer;
228 const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
229 const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
230
231 if (scrollTop < lastScrollTop && !isAtBottom) {
232 userScrolledUp = true;
233 autoScrollEnabled = false;
234 } else if (isAtBottom && userScrolledUp) {
235 userScrolledUp = false;
236 autoScrollEnabled = true;
237 }
238
239 if (scrollTimeout) {
240 clearTimeout(scrollTimeout);
241 }
242
243 scrollTimeout = setTimeout(() => {
244 if (isAtBottom) {
245 userScrolledUp = false;
246 autoScrollEnabled = true;
247 }
248 }, AUTO_SCROLL_INTERVAL);
249
250 lastScrollTop = scrollTop;
251 }
252
253 async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
254 const result = files
255 ? await parseFilesToMessageExtras(files, activeModelId ?? undefined)
256 : undefined;
257
258 if (result?.emptyFiles && result.emptyFiles.length > 0) {
259 emptyFileNames = result.emptyFiles;
260 showEmptyFileDialog = true;
261
262 if (files) {
263 const emptyFileNamesSet = new Set(result.emptyFiles);
264 uploadedFiles = uploadedFiles.filter((file) => !emptyFileNamesSet.has(file.name));
265 }
266 return false;
267 }
268
269 const extras = result?.extras;
270
271 // Enable autoscroll for user-initiated message sending
272 if (!disableAutoScroll) {
273 userScrolledUp = false;
274 autoScrollEnabled = true;
275 }
276 await chatStore.sendMessage(message, extras);
277 scrollChatToBottom();
278
279 return true;
280 }
281
282 async function processFiles(files: File[]) {
283 const generallySupported: File[] = [];
284 const generallyUnsupported: File[] = [];
285
286 for (const file of files) {
287 if (isFileTypeSupported(file.name, file.type)) {
288 generallySupported.push(file);
289 } else {
290 generallyUnsupported.push(file);
291 }
292 }
293
294 // Use model-specific capabilities for file validation
295 const capabilities = { hasVision: hasVisionModality, hasAudio: hasAudioModality };
296 const { supportedFiles, unsupportedFiles, modalityReasons } = filterFilesByModalities(
297 generallySupported,
298 capabilities
299 );
300
301 const allUnsupportedFiles = [...generallyUnsupported, ...unsupportedFiles];
302
303 if (allUnsupportedFiles.length > 0) {
304 const supportedTypes: string[] = ['text files', 'PDFs'];
305
306 if (hasVisionModality) supportedTypes.push('images');
307 if (hasAudioModality) supportedTypes.push('audio files');
308
309 fileErrorData = {
310 generallyUnsupported,
311 modalityUnsupported: unsupportedFiles,
312 modalityReasons,
313 supportedTypes
314 };
315 showFileErrorDialog = true;
316 }
317
318 if (supportedFiles.length > 0) {
319 const processed = await processFilesToChatUploaded(
320 supportedFiles,
321 activeModelId ?? undefined
322 );
323 uploadedFiles = [...uploadedFiles, ...processed];
324 }
325 }
326
327 function scrollChatToBottom(behavior: ScrollBehavior = 'smooth') {
328 if (disableAutoScroll) return;
329
330 chatScrollContainer?.scrollTo({
331 top: chatScrollContainer?.scrollHeight,
332 behavior
333 });
334 }
335
336 afterNavigate(() => {
337 if (!disableAutoScroll) {
338 setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
339 }
340 });
341
342 onMount(() => {
343 if (!disableAutoScroll) {
344 setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
345 }
346 });
347
348 $effect(() => {
349 if (disableAutoScroll) {
350 autoScrollEnabled = false;
351 if (scrollInterval) {
352 clearInterval(scrollInterval);
353 scrollInterval = undefined;
354 }
355 return;
356 }
357
358 if (isCurrentConversationLoading && autoScrollEnabled) {
359 scrollInterval = setInterval(scrollChatToBottom, AUTO_SCROLL_INTERVAL);
360 } else if (scrollInterval) {
361 clearInterval(scrollInterval);
362 scrollInterval = undefined;
363 }
364 });
365</script>
366
367{#if isDragOver}
368 <ChatScreenDragOverlay />
369{/if}
370
371<svelte:window onkeydown={handleKeydown} />
372
373<ChatScreenHeader />
374
375{#if !isEmpty}
376 <div
377 bind:this={chatScrollContainer}
378 aria-label="Chat interface with file drop zone"
379 class="flex h-full flex-col overflow-y-auto px-4 md:px-6"
380 ondragenter={handleDragEnter}
381 ondragleave={handleDragLeave}
382 ondragover={handleDragOver}
383 ondrop={handleDrop}
384 onscroll={handleScroll}
385 role="main"
386 >
387 <ChatMessages
388 class="mb-16 md:mb-24"
389 messages={activeMessages()}
390 onUserAction={() => {
391 if (!disableAutoScroll) {
392 userScrolledUp = false;
393 autoScrollEnabled = true;
394 scrollChatToBottom();
395 }
396 }}
397 />
398
399 <div
400 class="pointer-events-none sticky right-0 bottom-0 left-0 mt-auto"
401 in:slide={{ duration: 150, axis: 'y' }}
402 >
403 <ChatScreenProcessingInfo />
404
405 {#if hasPropsError}
406 <div
407 class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
408 in:fly={{ y: 10, duration: 250 }}
409 >
410 <Alert.Root variant="destructive">
411 <AlertTriangle class="h-4 w-4" />
412 <Alert.Title class="flex items-center justify-between">
413 <span>Server unavailable</span>
414 <button
415 onclick={() => serverStore.fetch()}
416 disabled={isServerLoading}
417 class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
418 >
419 <RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
420 {isServerLoading ? 'Retrying...' : 'Retry'}
421 </button>
422 </Alert.Title>
423 <Alert.Description>{serverError()}</Alert.Description>
424 </Alert.Root>
425 </div>
426 {/if}
427
428 <div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
429 <ChatForm
430 disabled={hasPropsError || isEditing()}
431 isLoading={isCurrentConversationLoading}
432 onFileRemove={handleFileRemove}
433 onFileUpload={handleFileUpload}
434 onSend={handleSendMessage}
435 onStop={() => chatStore.stopGeneration()}
436 showHelperText={false}
437 bind:uploadedFiles
438 />
439 </div>
440 </div>
441 </div>
442{:else if isServerLoading}
443 <!-- Server Loading State -->
444 <ServerLoadingSplash />
445{:else}
446 <div
447 aria-label="Welcome screen with file drop zone"
448 class="flex h-full items-center justify-center"
449 ondragenter={handleDragEnter}
450 ondragleave={handleDragLeave}
451 ondragover={handleDragOver}
452 ondrop={handleDrop}
453 role="main"
454 >
455 <div class="w-full max-w-[48rem] px-4">
456 <div class="mb-10 text-center" in:fade={{ duration: 300 }}>
457 <h1 class="mb-4 text-3xl font-semibold tracking-tight">llama.cpp</h1>
458
459 <p class="text-lg text-muted-foreground">
460 {serverStore.props?.modalities?.audio
461 ? 'Record audio, type a message '
462 : 'Type a message'} or upload files to get started
463 </p>
464 </div>
465
466 {#if hasPropsError}
467 <div class="mb-4" in:fly={{ y: 10, duration: 250 }}>
468 <Alert.Root variant="destructive">
469 <AlertTriangle class="h-4 w-4" />
470 <Alert.Title class="flex items-center justify-between">
471 <span>Server unavailable</span>
472 <button
473 onclick={() => serverStore.fetch()}
474 disabled={isServerLoading}
475 class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
476 >
477 <RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
478 {isServerLoading ? 'Retrying...' : 'Retry'}
479 </button>
480 </Alert.Title>
481 <Alert.Description>{serverError()}</Alert.Description>
482 </Alert.Root>
483 </div>
484 {/if}
485
486 <div in:fly={{ y: 10, duration: 250, delay: hasPropsError ? 0 : 300 }}>
487 <ChatForm
488 disabled={hasPropsError}
489 isLoading={isCurrentConversationLoading}
490 onFileRemove={handleFileRemove}
491 onFileUpload={handleFileUpload}
492 onSend={handleSendMessage}
493 onStop={() => chatStore.stopGeneration()}
494 showHelperText={true}
495 bind:uploadedFiles
496 />
497 </div>
498 </div>
499 </div>
500{/if}
501
502<!-- File Upload Error Alert Dialog -->
503<AlertDialog.Root bind:open={showFileErrorDialog}>
504 <AlertDialog.Portal>
505 <AlertDialog.Overlay />
506
507 <AlertDialog.Content class="flex max-w-md flex-col">
508 <AlertDialog.Header>
509 <AlertDialog.Title>File Upload Error</AlertDialog.Title>
510
511 <AlertDialog.Description class="text-sm text-muted-foreground">
512 Some files cannot be uploaded with the current model.
513 </AlertDialog.Description>
514 </AlertDialog.Header>
515
516 <div class="!max-h-[50vh] min-h-0 flex-1 space-y-4 overflow-y-auto">
517 {#if fileErrorData.generallyUnsupported.length > 0}
518 <div class="space-y-2">
519 <h4 class="text-sm font-medium text-destructive">Unsupported File Types</h4>
520
521 <div class="space-y-1">
522 {#each fileErrorData.generallyUnsupported as file (file.name)}
523 <div class="rounded-md bg-destructive/10 px-3 py-2">
524 <p class="font-mono text-sm break-all text-destructive">
525 {file.name}
526 </p>
527
528 <p class="mt-1 text-xs text-muted-foreground">File type not supported</p>
529 </div>
530 {/each}
531 </div>
532 </div>
533 {/if}
534
535 {#if fileErrorData.modalityUnsupported.length > 0}
536 <div class="space-y-2">
537 <div class="space-y-1">
538 {#each fileErrorData.modalityUnsupported as file (file.name)}
539 <div class="rounded-md bg-destructive/10 px-3 py-2">
540 <p class="font-mono text-sm break-all text-destructive">
541 {file.name}
542 </p>
543
544 <p class="mt-1 text-xs text-muted-foreground">
545 {fileErrorData.modalityReasons[file.name] || 'Not supported by current model'}
546 </p>
547 </div>
548 {/each}
549 </div>
550 </div>
551 {/if}
552 </div>
553
554 <div class="rounded-md bg-muted/50 p-3">
555 <h4 class="mb-2 text-sm font-medium">This model supports:</h4>
556
557 <p class="text-sm text-muted-foreground">
558 {fileErrorData.supportedTypes.join(', ')}
559 </p>
560 </div>
561
562 <AlertDialog.Footer>
563 <AlertDialog.Action onclick={() => (showFileErrorDialog = false)}>
564 Got it
565 </AlertDialog.Action>
566 </AlertDialog.Footer>
567 </AlertDialog.Content>
568 </AlertDialog.Portal>
569</AlertDialog.Root>
570
571<DialogConfirmation
572 bind:open={showDeleteDialog}
573 title="Delete Conversation"
574 description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
575 confirmText="Delete"
576 cancelText="Cancel"
577 variant="destructive"
578 icon={Trash2}
579 onConfirm={handleDeleteConfirm}
580 onCancel={() => (showDeleteDialog = false)}
581/>
582
583<DialogEmptyFileAlert
584 bind:open={showEmptyFileDialog}
585 emptyFiles={emptyFileNames}
586 onOpenChange={(open) => {
587 if (!open) {
588 emptyFileNames = [];
589 }
590 }}
591/>
592
593<DialogChatError
594 message={activeErrorDialog?.message ?? ''}
595 contextInfo={activeErrorDialog?.contextInfo}
596 onOpenChange={handleErrorDialogOpenChange}
597 open={Boolean(activeErrorDialog)}
598 type={activeErrorDialog?.type ?? 'server'}
599/>
600
601<style>
602 .conversation-chat-form {
603 position: relative;
604
605 &::after {
606 content: '';
607 position: absolute;
608 bottom: 0;
609 z-index: -1;
610 left: 0;
611 right: 0;
612 width: 100%;
613 height: 2.375rem;
614 background-color: var(--background);
615 }
616 }
617</style>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenDragOverlay.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenDragOverlay.svelte
new file mode 100644
index 0000000..ab4adb2
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenDragOverlay.svelte
@@ -0,0 +1,17 @@
1<script>
2 import { Upload } from '@lucide/svelte';
3</script>
4
5<div
6 class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
7>
8 <div
9 class="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-border bg-background p-12 shadow-lg"
10 >
11 <Upload class="mb-4 h-12 w-12 text-muted-foreground" />
12
13 <p class="text-lg font-medium text-foreground">Attach a file</p>
14
15 <p class="text-sm text-muted-foreground">Drop your files here to upload</p>
16 </div>
17</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte
new file mode 100644
index 0000000..874140f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte
@@ -0,0 +1,28 @@
1<script lang="ts">
2 import { Settings } from '@lucide/svelte';
3 import { DialogChatSettings } from '$lib/components/app';
4 import { Button } from '$lib/components/ui/button';
5 import { useSidebar } from '$lib/components/ui/sidebar';
6
7 const sidebar = useSidebar();
8
9 let settingsOpen = $state(false);
10
11 function toggleSettings() {
12 settingsOpen = true;
13 }
14</script>
15
16<header
17 class="md:background-transparent pointer-events-none fixed top-0 right-0 left-0 z-50 flex items-center justify-end bg-background/40 p-4 backdrop-blur-xl duration-200 ease-linear {sidebar.open
18 ? 'md:left-[var(--sidebar-width)]'
19 : ''}"
20>
21 <div class="pointer-events-auto flex items-center space-x-2">
22 <Button variant="ghost" size="sm" onclick={toggleSettings}>
23 <Settings class="h-4 w-4" />
24 </Button>
25 </div>
26</header>
27
28<DialogChatSettings open={settingsOpen} onOpenChange={(open) => (settingsOpen = open)} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte
new file mode 100644
index 0000000..a60ae9e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte
@@ -0,0 +1,120 @@
1<script lang="ts">
2 import { untrack } from 'svelte';
3 import { PROCESSING_INFO_TIMEOUT } from '$lib/constants/processing-info';
4 import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
5 import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
6 import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte';
7 import { config } from '$lib/stores/settings.svelte';
8
9 const processingState = useProcessingState();
10
11 let isCurrentConversationLoading = $derived(isLoading());
12 let isStreaming = $derived(isChatStreaming());
13 let hasProcessingData = $derived(processingState.processingState !== null);
14 let processingDetails = $derived(processingState.getProcessingDetails());
15
16 let showProcessingInfo = $derived(
17 isCurrentConversationLoading || isStreaming || config().keepStatsVisible || hasProcessingData
18 );
19
20 $effect(() => {
21 const conversation = activeConversation();
22
23 untrack(() => chatStore.setActiveProcessingConversation(conversation?.id ?? null));
24 });
25
26 $effect(() => {
27 const keepStatsVisible = config().keepStatsVisible;
28 const shouldMonitor = keepStatsVisible || isCurrentConversationLoading || isStreaming;
29
30 if (shouldMonitor) {
31 processingState.startMonitoring();
32 }
33
34 if (!isCurrentConversationLoading && !isStreaming && !keepStatsVisible) {
35 const timeout = setTimeout(() => {
36 if (!config().keepStatsVisible && !isChatStreaming()) {
37 processingState.stopMonitoring();
38 }
39 }, PROCESSING_INFO_TIMEOUT);
40
41 return () => clearTimeout(timeout);
42 }
43 });
44
45 $effect(() => {
46 const conversation = activeConversation();
47 const messages = activeMessages() as DatabaseMessage[];
48 const keepStatsVisible = config().keepStatsVisible;
49
50 if (keepStatsVisible && conversation) {
51 if (messages.length === 0) {
52 untrack(() => chatStore.clearProcessingState(conversation.id));
53 return;
54 }
55
56 if (!isCurrentConversationLoading && !isStreaming) {
57 untrack(() => chatStore.restoreProcessingStateFromMessages(messages, conversation.id));
58 }
59 }
60 });
61</script>
62
63<div class="chat-processing-info-container pointer-events-none" class:visible={showProcessingInfo}>
64 <div class="chat-processing-info-content">
65 {#each processingDetails as detail (detail)}
66 <span class="chat-processing-info-detail pointer-events-auto">{detail}</span>
67 {/each}
68 </div>
69</div>
70
71<style>
72 .chat-processing-info-container {
73 position: sticky;
74 top: 0;
75 z-index: 10;
76 padding: 1.5rem 1rem;
77 opacity: 0;
78 transform: translateY(50%);
79 transition:
80 opacity 300ms ease-out,
81 transform 300ms ease-out;
82 }
83
84 .chat-processing-info-container.visible {
85 opacity: 1;
86 transform: translateY(0);
87 }
88
89 .chat-processing-info-content {
90 display: flex;
91 flex-wrap: wrap;
92 align-items: center;
93 gap: 1rem;
94 justify-content: center;
95 max-width: 48rem;
96 margin: 0 auto;
97 }
98
99 .chat-processing-info-detail {
100 color: var(--muted-foreground);
101 font-size: 0.75rem;
102 padding: 0.25rem 0.75rem;
103 background: var(--muted);
104 border-radius: 0.375rem;
105 font-family:
106 ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
107 white-space: nowrap;
108 }
109
110 @media (max-width: 768px) {
111 .chat-processing-info-content {
112 gap: 0.5rem;
113 }
114
115 .chat-processing-info-detail {
116 font-size: 0.7rem;
117 padding: 0.2rem 0.5rem;
118 }
119 }
120</style>