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>