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>