summaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/lib/components/app/chat
diff options
context:
space:
mode:
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components/app/chat')
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte283
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte165
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte64
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte243
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte117
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte315
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte123
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte52
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte55
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte204
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte30
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormHelperText.svelte17
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte59
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte286
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte100
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte418
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageBranchingControls.svelte84
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte391
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte175
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte216
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte68
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte163
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte143
-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
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte508
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte255
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFooter.svelte59
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte317
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte18
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte211
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte81
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte200
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte19
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/handle-mobile-sidebar-item-click.ts9
37 files changed, 6230 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte
new file mode 100644
index 0000000..0b0bf52
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte
@@ -0,0 +1,283 @@
+<script lang="ts">
+ import { Button } from '$lib/components/ui/button';
+ import * as Alert from '$lib/components/ui/alert';
+ import { SyntaxHighlightedCode } from '$lib/components/app';
+ import { FileText, Image, Music, FileIcon, Eye, Info } from '@lucide/svelte';
+ import {
+ isTextFile,
+ isImageFile,
+ isPdfFile,
+ isAudioFile,
+ getLanguageFromFilename
+ } from '$lib/utils';
+ import { convertPDFToImage } from '$lib/utils/browser-only';
+ import { modelsStore } from '$lib/stores/models.svelte';
+
+ interface Props {
+ // Either an uploaded file or a stored attachment
+ uploadedFile?: ChatUploadedFile;
+ attachment?: DatabaseMessageExtra;
+ // For uploaded files
+ preview?: string;
+ name?: string;
+ textContent?: string;
+ // For checking vision modality
+ activeModelId?: string;
+ }
+
+ let { uploadedFile, attachment, preview, name, textContent, activeModelId }: Props = $props();
+
+ let hasVisionModality = $derived(
+ activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false
+ );
+
+ let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
+
+ // Determine file type from uploaded file or attachment
+ let isAudio = $derived(isAudioFile(attachment, uploadedFile));
+ let isImage = $derived(isImageFile(attachment, uploadedFile));
+ let isPdf = $derived(isPdfFile(attachment, uploadedFile));
+ let isText = $derived(isTextFile(attachment, uploadedFile));
+
+ let displayPreview = $derived(
+ uploadedFile?.preview ||
+ (isImage && attachment && 'base64Url' in attachment ? attachment.base64Url : preview)
+ );
+
+ let displayTextContent = $derived(
+ uploadedFile?.textContent ||
+ (attachment && 'content' in attachment ? attachment.content : textContent)
+ );
+
+ let language = $derived(getLanguageFromFilename(displayName));
+
+ let IconComponent = $derived(() => {
+ if (isImage) return Image;
+ if (isText || isPdf) return FileText;
+ if (isAudio) return Music;
+
+ return FileIcon;
+ });
+
+ let pdfViewMode = $state<'text' | 'pages'>('pages');
+
+ let pdfImages = $state<string[]>([]);
+
+ let pdfImagesLoading = $state(false);
+
+ let pdfImagesError = $state<string | null>(null);
+
+ async function loadPdfImages() {
+ if (!isPdf || pdfImages.length > 0 || pdfImagesLoading) return;
+
+ pdfImagesLoading = true;
+ pdfImagesError = null;
+
+ try {
+ let file: File | null = null;
+
+ if (uploadedFile?.file) {
+ file = uploadedFile.file;
+ } else if (isPdf && attachment) {
+ // Check if we have pre-processed images
+ if (
+ 'images' in attachment &&
+ attachment.images &&
+ Array.isArray(attachment.images) &&
+ attachment.images.length > 0
+ ) {
+ pdfImages = attachment.images;
+ return;
+ }
+
+ // Convert base64 back to File for processing
+ if ('base64Data' in attachment && attachment.base64Data) {
+ const base64Data = attachment.base64Data;
+ const byteCharacters = atob(base64Data);
+ const byteNumbers = new Array(byteCharacters.length);
+ for (let i = 0; i < byteCharacters.length; i++) {
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
+ }
+ const byteArray = new Uint8Array(byteNumbers);
+ file = new File([byteArray], displayName, { type: 'application/pdf' });
+ }
+ }
+
+ if (file) {
+ pdfImages = await convertPDFToImage(file);
+ } else {
+ throw new Error('No PDF file available for conversion');
+ }
+ } catch (error) {
+ pdfImagesError = error instanceof Error ? error.message : 'Failed to load PDF images';
+ } finally {
+ pdfImagesLoading = false;
+ }
+ }
+
+ export function reset() {
+ pdfImages = [];
+ pdfImagesLoading = false;
+ pdfImagesError = null;
+ pdfViewMode = 'pages';
+ }
+
+ $effect(() => {
+ if (isPdf && pdfViewMode === 'pages') {
+ loadPdfImages();
+ }
+ });
+</script>
+
+<div class="space-y-4">
+ <div class="flex items-center justify-end gap-6">
+ {#if isPdf}
+ <div class="flex items-center gap-2">
+ <Button
+ variant={pdfViewMode === 'text' ? 'default' : 'outline'}
+ size="sm"
+ onclick={() => (pdfViewMode = 'text')}
+ disabled={pdfImagesLoading}
+ >
+ <FileText class="mr-1 h-4 w-4" />
+
+ Text
+ </Button>
+
+ <Button
+ variant={pdfViewMode === 'pages' ? 'default' : 'outline'}
+ size="sm"
+ onclick={() => {
+ pdfViewMode = 'pages';
+ loadPdfImages();
+ }}
+ disabled={pdfImagesLoading}
+ >
+ {#if pdfImagesLoading}
+ <div
+ class="mr-1 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
+ ></div>
+ {:else}
+ <Eye class="mr-1 h-4 w-4" />
+ {/if}
+
+ Pages
+ </Button>
+ </div>
+ {/if}
+ </div>
+
+ <div class="flex-1 overflow-auto">
+ {#if isImage && displayPreview}
+ <div class="flex items-center justify-center">
+ <img
+ src={displayPreview}
+ alt={displayName}
+ class="max-h-full rounded-lg object-contain shadow-lg"
+ />
+ </div>
+ {:else if isPdf && pdfViewMode === 'pages'}
+ {#if !hasVisionModality && activeModelId}
+ <Alert.Root class="mb-4">
+ <Info class="h-4 w-4" />
+ <Alert.Title>Preview only</Alert.Title>
+ <Alert.Description>
+ <span class="inline-flex">
+ The selected model does not support vision. Only the extracted
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <span class="mx-1 cursor-pointer underline" onclick={() => (pdfViewMode = 'text')}>
+ text
+ </span>
+ will be sent to the model.
+ </span>
+ </Alert.Description>
+ </Alert.Root>
+ {/if}
+
+ {#if pdfImagesLoading}
+ <div class="flex items-center justify-center p-8">
+ <div class="text-center">
+ <div
+ class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
+ ></div>
+
+ <p class="text-muted-foreground">Converting PDF to images...</p>
+ </div>
+ </div>
+ {:else if pdfImagesError}
+ <div class="flex items-center justify-center p-8">
+ <div class="text-center">
+ <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+
+ <p class="mb-4 text-muted-foreground">Failed to load PDF images</p>
+
+ <p class="text-sm text-muted-foreground">{pdfImagesError}</p>
+
+ <Button class="mt-4" onclick={() => (pdfViewMode = 'text')}>View as Text</Button>
+ </div>
+ </div>
+ {:else if pdfImages.length > 0}
+ <div class="max-h-[70vh] space-y-4 overflow-auto">
+ {#each pdfImages as image, index (image)}
+ <div class="text-center">
+ <p class="mb-2 text-sm text-muted-foreground">Page {index + 1}</p>
+
+ <img
+ src={image}
+ alt="PDF Page {index + 1}"
+ class="mx-auto max-w-full rounded-lg shadow-lg"
+ />
+ </div>
+ {/each}
+ </div>
+ {:else}
+ <div class="flex items-center justify-center p-8">
+ <div class="text-center">
+ <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+
+ <p class="mb-4 text-muted-foreground">No PDF pages available</p>
+ </div>
+ </div>
+ {/if}
+ {:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
+ <SyntaxHighlightedCode code={displayTextContent} {language} maxWidth="calc(69rem - 2rem)" />
+ {:else if isAudio}
+ <div class="flex items-center justify-center p-8">
+ <div class="w-full max-w-md text-center">
+ <Music class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+
+ {#if uploadedFile?.preview}
+ <audio controls class="mb-4 w-full" src={uploadedFile.preview}>
+ Your browser does not support the audio element.
+ </audio>
+ {:else if isAudio && attachment && 'mimeType' in attachment && 'base64Data' in attachment}
+ <audio
+ controls
+ class="mb-4 w-full"
+ src={`data:${attachment.mimeType};base64,${attachment.base64Data}`}
+ >
+ Your browser does not support the audio element.
+ </audio>
+ {:else}
+ <p class="mb-4 text-muted-foreground">Audio preview not available</p>
+ {/if}
+
+ <p class="text-sm text-muted-foreground">
+ {displayName}
+ </p>
+ </div>
+ </div>
+ {:else}
+ <div class="flex items-center justify-center p-8">
+ <div class="text-center">
+ {#if IconComponent}
+ <IconComponent class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+ {/if}
+
+ <p class="mb-4 text-muted-foreground">Preview not available for this file type</p>
+ </div>
+ </div>
+ {/if}
+ </div>
+</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte
new file mode 100644
index 0000000..908db58
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte
@@ -0,0 +1,165 @@
+<script lang="ts">
+ import { RemoveButton } from '$lib/components/app';
+ import { formatFileSize, getFileTypeLabel, getPreviewText, isTextFile } from '$lib/utils';
+ import { AttachmentType } from '$lib/enums';
+
+ interface Props {
+ class?: string;
+ id: string;
+ onClick?: (event?: MouseEvent) => void;
+ onRemove?: (id: string) => void;
+ name: string;
+ readonly?: boolean;
+ size?: number;
+ textContent?: string;
+ // Either uploaded file or stored attachment
+ uploadedFile?: ChatUploadedFile;
+ attachment?: DatabaseMessageExtra;
+ }
+
+ let {
+ class: className = '',
+ id,
+ onClick,
+ onRemove,
+ name,
+ readonly = false,
+ size,
+ textContent,
+ uploadedFile,
+ attachment
+ }: Props = $props();
+
+ let isText = $derived(isTextFile(attachment, uploadedFile));
+
+ let fileTypeLabel = $derived.by(() => {
+ if (uploadedFile?.type) {
+ return getFileTypeLabel(uploadedFile.type);
+ }
+
+ if (attachment) {
+ if ('mimeType' in attachment && attachment.mimeType) {
+ return getFileTypeLabel(attachment.mimeType);
+ }
+
+ if (attachment.type) {
+ return getFileTypeLabel(attachment.type);
+ }
+ }
+
+ return getFileTypeLabel(name);
+ });
+
+ let pdfProcessingMode = $derived.by(() => {
+ if (attachment?.type === AttachmentType.PDF) {
+ const pdfAttachment = attachment as DatabaseMessageExtraPdfFile;
+
+ return pdfAttachment.processedAsImages ? 'Sent as Image' : 'Sent as Text';
+ }
+ return null;
+ });
+</script>
+
+{#if isText}
+ {#if readonly}
+ <!-- Readonly mode (ChatMessage) -->
+ <button
+ class="cursor-pointer rounded-lg border border-border bg-muted p-3 transition-shadow hover:shadow-md {className} w-full max-w-2xl"
+ onclick={onClick}
+ aria-label={`Preview ${name}`}
+ type="button"
+ >
+ <div class="flex items-start gap-3">
+ <div class="flex min-w-0 flex-1 flex-col items-start text-left">
+ <span class="w-full truncate text-sm font-medium text-foreground">{name}</span>
+
+ {#if size}
+ <span class="text-xs text-muted-foreground">{formatFileSize(size)}</span>
+ {/if}
+
+ {#if textContent}
+ <div class="relative mt-2 w-full">
+ <div
+ class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
+ >
+ {getPreviewText(textContent)}
+ </div>
+
+ {#if textContent.length > 150}
+ <div
+ class="pointer-events-none absolute right-0 bottom-0 left-0 h-6 bg-gradient-to-t from-muted to-transparent"
+ ></div>
+ {/if}
+ </div>
+ {/if}
+ </div>
+ </div>
+ </button>
+ {:else}
+ <!-- Non-readonly mode (ChatForm) -->
+ <button
+ class="group relative rounded-lg border border-border bg-muted p-3 {className} {textContent
+ ? 'max-h-24 max-w-72'
+ : 'max-w-36'} cursor-pointer text-left"
+ onclick={onClick}
+ >
+ <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
+ <RemoveButton {id} {onRemove} />
+ </div>
+
+ <div class="pr-8">
+ <span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span>
+
+ {#if textContent}
+ <div class="relative">
+ <div
+ class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
+ style="max-height: 3rem; line-height: 1.2em;"
+ >
+ {getPreviewText(textContent)}
+ </div>
+
+ {#if textContent.length > 150}
+ <div
+ class="pointer-events-none absolute right-0 bottom-0 left-0 h-4 bg-gradient-to-t from-muted to-transparent"
+ ></div>
+ {/if}
+ </div>
+ {/if}
+ </div>
+ </button>
+ {/if}
+{:else}
+ <button
+ class="group flex items-center gap-3 rounded-lg border border-border bg-muted p-3 {className} relative"
+ onclick={onClick}
+ >
+ <div
+ class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
+ >
+ {fileTypeLabel}
+ </div>
+
+ <div class="flex flex-col gap-0.5">
+ <span
+ class="max-w-24 truncate text-sm font-medium text-foreground {readonly
+ ? ''
+ : 'group-hover:pr-6'} md:max-w-32"
+ >
+ {name}
+ </span>
+
+ {#if pdfProcessingMode}
+ <span class="text-left text-xs text-muted-foreground">{pdfProcessingMode}</span>
+ {:else if size}
+ <span class="text-left text-xs text-muted-foreground">{formatFileSize(size)}</span>
+ {/if}
+ </div>
+
+ {#if !readonly}
+ <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
+ <RemoveButton {id} {onRemove} />
+ </div>
+ {/if}
+ </button>
+{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte
new file mode 100644
index 0000000..ba711a9
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte
@@ -0,0 +1,64 @@
+<script lang="ts">
+ import { RemoveButton } from '$lib/components/app';
+
+ interface Props {
+ id: string;
+ name: string;
+ preview: string;
+ readonly?: boolean;
+ onRemove?: (id: string) => void;
+ onClick?: (event?: MouseEvent) => void;
+ class?: string;
+ // Customizable size props
+ width?: string;
+ height?: string;
+ imageClass?: string;
+ }
+
+ let {
+ id,
+ name,
+ preview,
+ readonly = false,
+ onRemove,
+ onClick,
+ class: className = '',
+ // Default to small size for form previews
+ width = 'w-auto',
+ height = 'h-16',
+ imageClass = ''
+ }: Props = $props();
+</script>
+
+<div
+ class="group relative overflow-hidden rounded-lg bg-muted shadow-lg dark:border dark:border-muted {className}"
+>
+ {#if onClick}
+ <button
+ type="button"
+ class="block h-full w-full rounded-lg focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:outline-none"
+ onclick={onClick}
+ aria-label="Preview {name}"
+ >
+ <img
+ src={preview}
+ alt={name}
+ class="{height} {width} cursor-pointer object-cover {imageClass}"
+ />
+ </button>
+ {:else}
+ <img
+ src={preview}
+ alt={name}
+ class="{height} {width} cursor-pointer object-cover {imageClass}"
+ />
+ {/if}
+
+ {#if !readonly}
+ <div
+ class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
+ >
+ <RemoveButton {id} {onRemove} class="text-white" />
+ </div>
+ {/if}
+</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte
new file mode 100644
index 0000000..a1f5af5
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte
@@ -0,0 +1,243 @@
+<script lang="ts">
+ import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app';
+ import { Button } from '$lib/components/ui/button';
+ import { ChevronLeft, ChevronRight } from '@lucide/svelte';
+ import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
+ import { getAttachmentDisplayItems } from '$lib/utils';
+
+ interface Props {
+ class?: string;
+ style?: string;
+ // For ChatMessage - stored attachments
+ attachments?: DatabaseMessageExtra[];
+ readonly?: boolean;
+ // For ChatForm - pending uploads
+ onFileRemove?: (fileId: string) => void;
+ uploadedFiles?: ChatUploadedFile[];
+ // Image size customization
+ imageClass?: string;
+ imageHeight?: string;
+ imageWidth?: string;
+ // Limit display to single row with "+ X more" button
+ limitToSingleRow?: boolean;
+ // For vision modality check
+ activeModelId?: string;
+ }
+
+ let {
+ class: className = '',
+ style = '',
+ attachments = [],
+ readonly = false,
+ onFileRemove,
+ uploadedFiles = $bindable([]),
+ // Default to small size for form previews
+ imageClass = '',
+ imageHeight = 'h-24',
+ imageWidth = 'w-auto',
+ limitToSingleRow = false,
+ activeModelId
+ }: Props = $props();
+
+ let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
+
+ let canScrollLeft = $state(false);
+ let canScrollRight = $state(false);
+ let isScrollable = $state(false);
+ let previewDialogOpen = $state(false);
+ let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
+ let scrollContainer: HTMLDivElement | undefined = $state();
+ let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
+ let viewAllDialogOpen = $state(false);
+
+ function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) {
+ event?.stopPropagation();
+ event?.preventDefault();
+
+ previewItem = {
+ uploadedFile: item.uploadedFile,
+ attachment: item.attachment,
+ preview: item.preview,
+ name: item.name,
+ size: item.size,
+ textContent: item.textContent
+ };
+ previewDialogOpen = true;
+ }
+
+ function scrollLeft(event?: MouseEvent) {
+ event?.stopPropagation();
+ event?.preventDefault();
+
+ if (!scrollContainer) return;
+
+ scrollContainer.scrollBy({ left: scrollContainer.clientWidth * -0.67, behavior: 'smooth' });
+ }
+
+ function scrollRight(event?: MouseEvent) {
+ event?.stopPropagation();
+ event?.preventDefault();
+
+ if (!scrollContainer) return;
+
+ scrollContainer.scrollBy({ left: scrollContainer.clientWidth * 0.67, behavior: 'smooth' });
+ }
+
+ function updateScrollButtons() {
+ if (!scrollContainer) return;
+
+ const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
+
+ canScrollLeft = scrollLeft > 0;
+ canScrollRight = scrollLeft < scrollWidth - clientWidth - 1;
+ isScrollable = scrollWidth > clientWidth;
+ }
+
+ $effect(() => {
+ if (scrollContainer && displayItems.length) {
+ scrollContainer.scrollLeft = 0;
+
+ setTimeout(() => {
+ updateScrollButtons();
+ }, 0);
+ }
+ });
+</script>
+
+{#if displayItems.length > 0}
+ <div class={className} {style}>
+ {#if limitToSingleRow}
+ <div class="relative">
+ <button
+ class="absolute top-1/2 left-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollLeft
+ ? 'opacity-100'
+ : 'pointer-events-none opacity-0'}"
+ onclick={scrollLeft}
+ aria-label="Scroll left"
+ >
+ <ChevronLeft class="h-4 w-4" />
+ </button>
+
+ <div
+ class="scrollbar-hide flex items-start gap-3 overflow-x-auto"
+ bind:this={scrollContainer}
+ onscroll={updateScrollButtons}
+ >
+ {#each displayItems as item (item.id)}
+ {#if item.isImage && item.preview}
+ <ChatAttachmentThumbnailImage
+ class="flex-shrink-0 cursor-pointer {limitToSingleRow
+ ? 'first:ml-4 last:mr-4'
+ : ''}"
+ id={item.id}
+ name={item.name}
+ preview={item.preview}
+ {readonly}
+ onRemove={onFileRemove}
+ height={imageHeight}
+ width={imageWidth}
+ {imageClass}
+ onClick={(event) => openPreview(item, event)}
+ />
+ {:else}
+ <ChatAttachmentThumbnailFile
+ class="flex-shrink-0 cursor-pointer {limitToSingleRow
+ ? 'first:ml-4 last:mr-4'
+ : ''}"
+ id={item.id}
+ name={item.name}
+ size={item.size}
+ {readonly}
+ onRemove={onFileRemove}
+ textContent={item.textContent}
+ attachment={item.attachment}
+ uploadedFile={item.uploadedFile}
+ onClick={(event) => openPreview(item, event)}
+ />
+ {/if}
+ {/each}
+ </div>
+
+ <button
+ class="absolute top-1/2 right-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollRight
+ ? 'opacity-100'
+ : 'pointer-events-none opacity-0'}"
+ onclick={scrollRight}
+ aria-label="Scroll right"
+ >
+ <ChevronRight class="h-4 w-4" />
+ </button>
+ </div>
+
+ {#if showViewAll}
+ <div class="mt-2 -mr-2 flex justify-end px-4">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ class="h-6 text-xs text-muted-foreground hover:text-foreground"
+ onclick={() => (viewAllDialogOpen = true)}
+ >
+ View all ({displayItems.length})
+ </Button>
+ </div>
+ {/if}
+ {:else}
+ <div class="flex flex-wrap items-start justify-end gap-3">
+ {#each displayItems as item (item.id)}
+ {#if item.isImage && item.preview}
+ <ChatAttachmentThumbnailImage
+ class="cursor-pointer"
+ id={item.id}
+ name={item.name}
+ preview={item.preview}
+ {readonly}
+ onRemove={onFileRemove}
+ height={imageHeight}
+ width={imageWidth}
+ {imageClass}
+ onClick={(event) => openPreview(item, event)}
+ />
+ {:else}
+ <ChatAttachmentThumbnailFile
+ class="cursor-pointer"
+ id={item.id}
+ name={item.name}
+ size={item.size}
+ {readonly}
+ onRemove={onFileRemove}
+ textContent={item.textContent}
+ attachment={item.attachment}
+ uploadedFile={item.uploadedFile}
+ onClick={(event?: MouseEvent) => openPreview(item, event)}
+ />
+ {/if}
+ {/each}
+ </div>
+ {/if}
+ </div>
+{/if}
+
+{#if previewItem}
+ <DialogChatAttachmentPreview
+ bind:open={previewDialogOpen}
+ uploadedFile={previewItem.uploadedFile}
+ attachment={previewItem.attachment}
+ preview={previewItem.preview}
+ name={previewItem.name}
+ size={previewItem.size}
+ textContent={previewItem.textContent}
+ {activeModelId}
+ />
+{/if}
+
+<DialogChatAttachmentsViewAll
+ bind:open={viewAllDialogOpen}
+ {uploadedFiles}
+ {attachments}
+ {readonly}
+ {onFileRemove}
+ imageHeight="h-64"
+ {imageClass}
+ {activeModelId}
+/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte
new file mode 100644
index 0000000..279b2e2
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte
@@ -0,0 +1,117 @@
+<script lang="ts">
+ import {
+ ChatAttachmentThumbnailImage,
+ ChatAttachmentThumbnailFile,
+ DialogChatAttachmentPreview
+ } from '$lib/components/app';
+ import { getAttachmentDisplayItems } from '$lib/utils';
+
+ interface Props {
+ uploadedFiles?: ChatUploadedFile[];
+ attachments?: DatabaseMessageExtra[];
+ readonly?: boolean;
+ onFileRemove?: (fileId: string) => void;
+ imageHeight?: string;
+ imageWidth?: string;
+ imageClass?: string;
+ activeModelId?: string;
+ }
+
+ let {
+ uploadedFiles = [],
+ attachments = [],
+ readonly = false,
+ onFileRemove,
+ imageHeight = 'h-24',
+ imageWidth = 'w-auto',
+ imageClass = '',
+ activeModelId
+ }: Props = $props();
+
+ let previewDialogOpen = $state(false);
+ let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
+
+ let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
+ let imageItems = $derived(displayItems.filter((item) => item.isImage));
+ let fileItems = $derived(displayItems.filter((item) => !item.isImage));
+
+ function openPreview(item: (typeof displayItems)[0], event?: Event) {
+ if (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ previewItem = {
+ uploadedFile: item.uploadedFile,
+ attachment: item.attachment,
+ preview: item.preview,
+ name: item.name,
+ size: item.size,
+ textContent: item.textContent
+ };
+ previewDialogOpen = true;
+ }
+</script>
+
+<div class="space-y-4">
+ <div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-1">
+ {#if fileItems.length > 0}
+ <div>
+ <h3 class="mb-3 text-sm font-medium text-foreground">Files ({fileItems.length})</h3>
+ <div class="flex flex-wrap items-start gap-3">
+ {#each fileItems as item (item.id)}
+ <ChatAttachmentThumbnailFile
+ class="cursor-pointer"
+ id={item.id}
+ name={item.name}
+ size={item.size}
+ {readonly}
+ onRemove={onFileRemove}
+ textContent={item.textContent}
+ attachment={item.attachment}
+ uploadedFile={item.uploadedFile}
+ onClick={(event?: MouseEvent) => openPreview(item, event)}
+ />
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ {#if imageItems.length > 0}
+ <div>
+ <h3 class="mb-3 text-sm font-medium text-foreground">Images ({imageItems.length})</h3>
+ <div class="flex flex-wrap items-start gap-3">
+ {#each imageItems as item (item.id)}
+ {#if item.preview}
+ <ChatAttachmentThumbnailImage
+ class="cursor-pointer"
+ id={item.id}
+ name={item.name}
+ preview={item.preview}
+ {readonly}
+ onRemove={onFileRemove}
+ height={imageHeight}
+ width={imageWidth}
+ {imageClass}
+ onClick={(event) => openPreview(item, event)}
+ />
+ {/if}
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+</div>
+
+{#if previewItem}
+ <DialogChatAttachmentPreview
+ bind:open={previewDialogOpen}
+ uploadedFile={previewItem.uploadedFile}
+ attachment={previewItem.attachment}
+ preview={previewItem.preview}
+ name={previewItem.name}
+ size={previewItem.size}
+ textContent={previewItem.textContent}
+ {activeModelId}
+ />
+{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
new file mode 100644
index 0000000..27ab975
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
@@ -0,0 +1,315 @@
+<script lang="ts">
+ import { afterNavigate } from '$app/navigation';
+ import {
+ ChatAttachmentsList,
+ ChatFormActions,
+ ChatFormFileInputInvisible,
+ ChatFormHelperText,
+ ChatFormTextarea
+ } from '$lib/components/app';
+ import { INPUT_CLASSES } from '$lib/constants/input-classes';
+ import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
+ import { config } from '$lib/stores/settings.svelte';
+ import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
+ import { isRouterMode } from '$lib/stores/server.svelte';
+ import { chatStore } from '$lib/stores/chat.svelte';
+ import { activeMessages } from '$lib/stores/conversations.svelte';
+ import { MimeTypeText } from '$lib/enums';
+ import { isIMEComposing, parseClipboardContent } from '$lib/utils';
+ import {
+ AudioRecorder,
+ convertToWav,
+ createAudioFile,
+ isAudioRecordingSupported
+ } from '$lib/utils/browser-only';
+ import { onMount } from 'svelte';
+
+ interface Props {
+ class?: string;
+ disabled?: boolean;
+ isLoading?: boolean;
+ onFileRemove?: (fileId: string) => void;
+ onFileUpload?: (files: File[]) => void;
+ onSend?: (message: string, files?: ChatUploadedFile[]) => Promise<boolean>;
+ onStop?: () => void;
+ showHelperText?: boolean;
+ uploadedFiles?: ChatUploadedFile[];
+ }
+
+ let {
+ class: className,
+ disabled = false,
+ isLoading = false,
+ onFileRemove,
+ onFileUpload,
+ onSend,
+ onStop,
+ showHelperText = true,
+ uploadedFiles = $bindable([])
+ }: Props = $props();
+
+ let audioRecorder: AudioRecorder | undefined;
+ let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
+ let currentConfig = $derived(config());
+ let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
+ let isRecording = $state(false);
+ let message = $state('');
+ let pasteLongTextToFileLength = $derived.by(() => {
+ const n = Number(currentConfig.pasteLongTextToFileLen);
+ return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
+ });
+ let previousIsLoading = $state(isLoading);
+ let recordingSupported = $state(false);
+ let textareaRef: ChatFormTextarea | undefined = $state(undefined);
+
+ // Check if model is selected (in ROUTER mode)
+ let conversationModel = $derived(
+ chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
+ );
+ let isRouter = $derived(isRouterMode());
+ let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
+
+ // Get active model ID for capability detection
+ let activeModelId = $derived.by(() => {
+ const options = modelOptions();
+
+ if (!isRouter) {
+ return options.length > 0 ? options[0].model : null;
+ }
+
+ // First try user-selected model
+ const selectedId = selectedModelId();
+ if (selectedId) {
+ const model = options.find((m) => m.id === selectedId);
+ if (model) return model.model;
+ }
+
+ // Fallback to conversation model
+ if (conversationModel) {
+ const model = options.find((m) => m.model === conversationModel);
+ if (model) return model.model;
+ }
+
+ return null;
+ });
+
+ function checkModelSelected(): boolean {
+ if (!hasModelSelected) {
+ // Open the model selector
+ chatFormActionsRef?.openModelSelector();
+ return false;
+ }
+
+ return true;
+ }
+
+ function handleFileSelect(files: File[]) {
+ onFileUpload?.(files);
+ }
+
+ function handleFileUpload() {
+ fileInputRef?.click();
+ }
+
+ async function handleKeydown(event: KeyboardEvent) {
+ if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
+ event.preventDefault();
+
+ if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
+
+ if (!checkModelSelected()) return;
+
+ const messageToSend = message.trim();
+ const filesToSend = [...uploadedFiles];
+
+ message = '';
+ uploadedFiles = [];
+
+ textareaRef?.resetHeight();
+
+ const success = await onSend?.(messageToSend, filesToSend);
+
+ if (!success) {
+ message = messageToSend;
+ uploadedFiles = filesToSend;
+ }
+ }
+ }
+
+ function handlePaste(event: ClipboardEvent) {
+ if (!event.clipboardData) return;
+
+ const files = Array.from(event.clipboardData.items)
+ .filter((item) => item.kind === 'file')
+ .map((item) => item.getAsFile())
+ .filter((file): file is File => file !== null);
+
+ if (files.length > 0) {
+ event.preventDefault();
+ onFileUpload?.(files);
+
+ return;
+ }
+
+ const text = event.clipboardData.getData(MimeTypeText.PLAIN);
+
+ if (text.startsWith('"')) {
+ const parsed = parseClipboardContent(text);
+
+ if (parsed.textAttachments.length > 0) {
+ event.preventDefault();
+
+ message = parsed.message;
+
+ const attachmentFiles = parsed.textAttachments.map(
+ (att) =>
+ new File([att.content], att.name, {
+ type: MimeTypeText.PLAIN
+ })
+ );
+
+ onFileUpload?.(attachmentFiles);
+
+ setTimeout(() => {
+ textareaRef?.focus();
+ }, 10);
+
+ return;
+ }
+ }
+
+ if (
+ text.length > 0 &&
+ pasteLongTextToFileLength > 0 &&
+ text.length > pasteLongTextToFileLength
+ ) {
+ event.preventDefault();
+
+ const textFile = new File([text], 'Pasted', {
+ type: MimeTypeText.PLAIN
+ });
+
+ onFileUpload?.([textFile]);
+ }
+ }
+
+ async function handleMicClick() {
+ if (!audioRecorder || !recordingSupported) {
+ console.warn('Audio recording not supported');
+
+ return;
+ }
+
+ if (isRecording) {
+ try {
+ const audioBlob = await audioRecorder.stopRecording();
+ const wavBlob = await convertToWav(audioBlob);
+ const audioFile = createAudioFile(wavBlob);
+
+ onFileUpload?.([audioFile]);
+ isRecording = false;
+ } catch (error) {
+ console.error('Failed to stop recording:', error);
+ isRecording = false;
+ }
+ } else {
+ try {
+ await audioRecorder.startRecording();
+ isRecording = true;
+ } catch (error) {
+ console.error('Failed to start recording:', error);
+ }
+ }
+ }
+
+ function handleStop() {
+ onStop?.();
+ }
+
+ async function handleSubmit(event: SubmitEvent) {
+ event.preventDefault();
+ if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
+
+ // Check if model is selected first
+ if (!checkModelSelected()) return;
+
+ const messageToSend = message.trim();
+ const filesToSend = [...uploadedFiles];
+
+ message = '';
+ uploadedFiles = [];
+
+ textareaRef?.resetHeight();
+
+ const success = await onSend?.(messageToSend, filesToSend);
+
+ if (!success) {
+ message = messageToSend;
+ uploadedFiles = filesToSend;
+ }
+ }
+
+ onMount(() => {
+ setTimeout(() => textareaRef?.focus(), 10);
+ recordingSupported = isAudioRecordingSupported();
+ audioRecorder = new AudioRecorder();
+ });
+
+ afterNavigate(() => {
+ setTimeout(() => textareaRef?.focus(), 10);
+ });
+
+ $effect(() => {
+ if (previousIsLoading && !isLoading) {
+ setTimeout(() => textareaRef?.focus(), 10);
+ }
+
+ previousIsLoading = isLoading;
+ });
+</script>
+
+<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
+
+<form
+ onsubmit={handleSubmit}
+ class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled
+ ? 'cursor-not-allowed opacity-60'
+ : ''} {className}"
+ data-slot="chat-form"
+>
+ <ChatAttachmentsList
+ bind:uploadedFiles
+ {onFileRemove}
+ limitToSingleRow
+ class="py-5"
+ style="scroll-padding: 1rem;"
+ activeModelId={activeModelId ?? undefined}
+ />
+
+ <div
+ class="flex-column relative min-h-[48px] items-center rounded-3xl px-5 py-3 shadow-sm transition-all focus-within:shadow-md"
+ onpaste={handlePaste}
+ >
+ <ChatFormTextarea
+ bind:this={textareaRef}
+ bind:value={message}
+ onKeydown={handleKeydown}
+ {disabled}
+ />
+
+ <ChatFormActions
+ bind:this={chatFormActionsRef}
+ canSend={message.trim().length > 0 || uploadedFiles.length > 0}
+ hasText={message.trim().length > 0}
+ {disabled}
+ {isLoading}
+ {isRecording}
+ {uploadedFiles}
+ onFileUpload={handleFileUpload}
+ onMicClick={handleMicClick}
+ onStop={handleStop}
+ />
+ </div>
+</form>
+
+<ChatFormHelperText show={showHelperText} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte
new file mode 100644
index 0000000..dd37268
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte
@@ -0,0 +1,123 @@
+<script lang="ts">
+ import { Paperclip } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { FILE_TYPE_ICONS } from '$lib/constants/icons';
+
+ interface Props {
+ class?: string;
+ disabled?: boolean;
+ hasAudioModality?: boolean;
+ hasVisionModality?: boolean;
+ onFileUpload?: () => void;
+ }
+
+ let {
+ class: className = '',
+ disabled = false,
+ hasAudioModality = false,
+ hasVisionModality = false,
+ onFileUpload
+ }: Props = $props();
+
+ const fileUploadTooltipText = $derived.by(() => {
+ return !hasVisionModality
+ ? 'Text files and PDFs supported. Images, audio, and video require vision models.'
+ : 'Attach files';
+ });
+</script>
+
+<div class="flex items-center gap-1 {className}">
+ <DropdownMenu.Root>
+ <DropdownMenu.Trigger name="Attach files" {disabled}>
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <Button
+ class="file-upload-button h-8 w-8 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
+ {disabled}
+ type="button"
+ >
+ <span class="sr-only">Attach files</span>
+
+ <Paperclip class="h-4 w-4" />
+ </Button>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>{fileUploadTooltipText}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ </DropdownMenu.Trigger>
+
+ <DropdownMenu.Content align="start" class="w-48">
+ <Tooltip.Root>
+ <Tooltip.Trigger class="w-full">
+ <DropdownMenu.Item
+ class="images-button flex cursor-pointer items-center gap-2"
+ disabled={!hasVisionModality}
+ onclick={() => onFileUpload?.()}
+ >
+ <FILE_TYPE_ICONS.image class="h-4 w-4" />
+
+ <span>Images</span>
+ </DropdownMenu.Item>
+ </Tooltip.Trigger>
+
+ {#if !hasVisionModality}
+ <Tooltip.Content>
+ <p>Images require vision models to be processed</p>
+ </Tooltip.Content>
+ {/if}
+ </Tooltip.Root>
+
+ <Tooltip.Root>
+ <Tooltip.Trigger class="w-full">
+ <DropdownMenu.Item
+ class="audio-button flex cursor-pointer items-center gap-2"
+ disabled={!hasAudioModality}
+ onclick={() => onFileUpload?.()}
+ >
+ <FILE_TYPE_ICONS.audio class="h-4 w-4" />
+
+ <span>Audio Files</span>
+ </DropdownMenu.Item>
+ </Tooltip.Trigger>
+
+ {#if !hasAudioModality}
+ <Tooltip.Content>
+ <p>Audio files require audio models to be processed</p>
+ </Tooltip.Content>
+ {/if}
+ </Tooltip.Root>
+
+ <DropdownMenu.Item
+ class="flex cursor-pointer items-center gap-2"
+ onclick={() => onFileUpload?.()}
+ >
+ <FILE_TYPE_ICONS.text class="h-4 w-4" />
+
+ <span>Text Files</span>
+ </DropdownMenu.Item>
+
+ <Tooltip.Root>
+ <Tooltip.Trigger class="w-full">
+ <DropdownMenu.Item
+ class="flex cursor-pointer items-center gap-2"
+ onclick={() => onFileUpload?.()}
+ >
+ <FILE_TYPE_ICONS.pdf class="h-4 w-4" />
+
+ <span>PDF Files</span>
+ </DropdownMenu.Item>
+ </Tooltip.Trigger>
+
+ {#if !hasVisionModality}
+ <Tooltip.Content>
+ <p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
+ </Tooltip.Content>
+ {/if}
+ </Tooltip.Root>
+ </DropdownMenu.Content>
+ </DropdownMenu.Root>
+</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte
new file mode 100644
index 0000000..f1b0849
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte
@@ -0,0 +1,52 @@
+<script lang="ts">
+ import { Mic, Square } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+
+ interface Props {
+ class?: string;
+ disabled?: boolean;
+ hasAudioModality?: boolean;
+ isLoading?: boolean;
+ isRecording?: boolean;
+ onMicClick?: () => void;
+ }
+
+ let {
+ class: className = '',
+ disabled = false,
+ hasAudioModality = false,
+ isLoading = false,
+ isRecording = false,
+ onMicClick
+ }: Props = $props();
+</script>
+
+<div class="flex items-center gap-1 {className}">
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <Button
+ class="h-8 w-8 rounded-full p-0 {isRecording
+ ? 'animate-pulse bg-red-500 text-white hover:bg-red-600'
+ : ''}"
+ disabled={disabled || isLoading || !hasAudioModality}
+ onclick={onMicClick}
+ type="button"
+ >
+ <span class="sr-only">{isRecording ? 'Stop recording' : 'Start recording'}</span>
+
+ {#if isRecording}
+ <Square class="h-4 w-4 animate-pulse fill-white" />
+ {:else}
+ <Mic class="h-4 w-4" />
+ {/if}
+ </Button>
+ </Tooltip.Trigger>
+
+ {#if !hasAudioModality}
+ <Tooltip.Content>
+ <p>Current model does not support audio</p>
+ </Tooltip.Content>
+ {/if}
+ </Tooltip.Root>
+</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte
new file mode 100644
index 0000000..861cd18
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte
@@ -0,0 +1,55 @@
+<script lang="ts">
+ import { ArrowUp } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { cn } from '$lib/components/ui/utils';
+
+ interface Props {
+ canSend?: boolean;
+ disabled?: boolean;
+ isLoading?: boolean;
+ showErrorState?: boolean;
+ tooltipLabel?: string;
+ }
+
+ let {
+ canSend = false,
+ disabled = false,
+ isLoading = false,
+ showErrorState = false,
+ tooltipLabel
+ }: Props = $props();
+
+ let isDisabled = $derived(!canSend || disabled || isLoading);
+</script>
+
+{#snippet submitButton(props = {})}
+ <Button
+ type="submit"
+ disabled={isDisabled}
+ class={cn(
+ 'h-8 w-8 rounded-full p-0',
+ showErrorState
+ ? 'bg-red-400/10 text-red-400 hover:bg-red-400/20 hover:text-red-400 disabled:opacity-100'
+ : ''
+ )}
+ {...props}
+ >
+ <span class="sr-only">Send</span>
+ <ArrowUp class="h-12 w-12" />
+ </Button>
+{/snippet}
+
+{#if tooltipLabel}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ {@render submitButton()}
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>{tooltipLabel}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+{:else}
+ {@render submitButton()}
+{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
new file mode 100644
index 0000000..dde9bda
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
@@ -0,0 +1,204 @@
+<script lang="ts">
+ import { Square } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import {
+ ChatFormActionFileAttachments,
+ ChatFormActionRecord,
+ ChatFormActionSubmit,
+ ModelsSelector
+ } from '$lib/components/app';
+ import { FileTypeCategory } from '$lib/enums';
+ import { getFileTypeCategory } from '$lib/utils';
+ import { config } from '$lib/stores/settings.svelte';
+ import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
+ import { isRouterMode } from '$lib/stores/server.svelte';
+ import { chatStore } from '$lib/stores/chat.svelte';
+ import { activeMessages, usedModalities } from '$lib/stores/conversations.svelte';
+ import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
+
+ interface Props {
+ canSend?: boolean;
+ class?: string;
+ disabled?: boolean;
+ isLoading?: boolean;
+ isRecording?: boolean;
+ hasText?: boolean;
+ uploadedFiles?: ChatUploadedFile[];
+ onFileUpload?: () => void;
+ onMicClick?: () => void;
+ onStop?: () => void;
+ }
+
+ let {
+ canSend = false,
+ class: className = '',
+ disabled = false,
+ isLoading = false,
+ isRecording = false,
+ hasText = false,
+ uploadedFiles = [],
+ onFileUpload,
+ onMicClick,
+ onStop
+ }: Props = $props();
+
+ let currentConfig = $derived(config());
+ let isRouter = $derived(isRouterMode());
+
+ let conversationModel = $derived(
+ chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
+ );
+
+ let previousConversationModel: string | null = null;
+
+ $effect(() => {
+ if (conversationModel && conversationModel !== previousConversationModel) {
+ previousConversationModel = conversationModel;
+ modelsStore.selectModelByName(conversationModel);
+ }
+ });
+
+ let activeModelId = $derived.by(() => {
+ const options = modelOptions();
+
+ if (!isRouter) {
+ return options.length > 0 ? options[0].model : null;
+ }
+
+ const selectedId = selectedModelId();
+ if (selectedId) {
+ const model = options.find((m) => m.id === selectedId);
+ if (model) return model.model;
+ }
+
+ if (conversationModel) {
+ const model = options.find((m) => m.model === conversationModel);
+ if (model) return model.model;
+ }
+
+ return null;
+ });
+
+ let modelPropsVersion = $state(0); // Used to trigger reactivity after fetch
+
+ $effect(() => {
+ if (activeModelId) {
+ const cached = modelsStore.getModelProps(activeModelId);
+
+ if (!cached) {
+ modelsStore.fetchModelProps(activeModelId).then(() => {
+ modelPropsVersion++;
+ });
+ }
+ }
+ });
+
+ let hasAudioModality = $derived.by(() => {
+ if (activeModelId) {
+ void modelPropsVersion;
+
+ return modelsStore.modelSupportsAudio(activeModelId);
+ }
+
+ return false;
+ });
+
+ let hasVisionModality = $derived.by(() => {
+ if (activeModelId) {
+ void modelPropsVersion;
+
+ return modelsStore.modelSupportsVision(activeModelId);
+ }
+
+ return false;
+ });
+
+ let hasAudioAttachments = $derived(
+ uploadedFiles.some((file) => getFileTypeCategory(file.type) === FileTypeCategory.AUDIO)
+ );
+ let shouldShowRecordButton = $derived(
+ hasAudioModality && !hasText && !hasAudioAttachments && currentConfig.autoMicOnEmpty
+ );
+
+ let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
+
+ let isSelectedModelInCache = $derived.by(() => {
+ if (!isRouter) return true;
+
+ if (conversationModel) {
+ return modelOptions().some((option) => option.model === conversationModel);
+ }
+
+ const currentModelId = selectedModelId();
+ if (!currentModelId) return false;
+
+ return modelOptions().some((option) => option.id === currentModelId);
+ });
+
+ let submitTooltip = $derived.by(() => {
+ if (!hasModelSelected) {
+ return 'Please select a model first';
+ }
+
+ if (!isSelectedModelInCache) {
+ return 'Selected model is not available, please select another';
+ }
+
+ return '';
+ });
+
+ let selectorModelRef: ModelsSelector | undefined = $state(undefined);
+
+ export function openModelSelector() {
+ selectorModelRef?.open();
+ }
+
+ const { handleModelChange } = useModelChangeValidation({
+ getRequiredModalities: () => usedModalities(),
+ onValidationFailure: async (previousModelId) => {
+ if (previousModelId) {
+ await modelsStore.selectModelById(previousModelId);
+ }
+ }
+ });
+</script>
+
+<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
+ <ChatFormActionFileAttachments
+ class="mr-auto"
+ {disabled}
+ {hasAudioModality}
+ {hasVisionModality}
+ {onFileUpload}
+ />
+
+ <ModelsSelector
+ {disabled}
+ bind:this={selectorModelRef}
+ currentModel={conversationModel}
+ forceForegroundText={true}
+ useGlobalSelection={true}
+ onModelChange={handleModelChange}
+ />
+
+ {#if isLoading}
+ <Button
+ type="button"
+ onclick={onStop}
+ class="h-8 w-8 bg-transparent p-0 hover:bg-destructive/20"
+ >
+ <span class="sr-only">Stop</span>
+ <Square class="h-8 w-8 fill-destructive stroke-destructive" />
+ </Button>
+ {:else if shouldShowRecordButton}
+ <ChatFormActionRecord {disabled} {hasAudioModality} {isLoading} {isRecording} {onMicClick} />
+ {:else}
+ <ChatFormActionSubmit
+ canSend={canSend && hasModelSelected && isSelectedModelInCache}
+ {disabled}
+ {isLoading}
+ tooltipLabel={submitTooltip}
+ showErrorState={hasModelSelected && !isSelectedModelInCache}
+ />
+ {/if}
+</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte
new file mode 100644
index 0000000..d758822
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ interface Props {
+ class?: string;
+ multiple?: boolean;
+ onFileSelect?: (files: File[]) => void;
+ }
+
+ let { class: className = '', multiple = true, onFileSelect }: Props = $props();
+
+ let fileInputElement: HTMLInputElement | undefined;
+
+ export function click() {
+ fileInputElement?.click();
+ }
+
+ function handleFileSelect(event: Event) {
+ const input = event.target as HTMLInputElement;
+ if (input.files) {
+ onFileSelect?.(Array.from(input.files));
+ }
+ }
+</script>
+
+<input
+ bind:this={fileInputElement}
+ type="file"
+ {multiple}
+ onchange={handleFileSelect}
+ class="hidden {className}"
+/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormHelperText.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormHelperText.svelte
new file mode 100644
index 0000000..f8246f2
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormHelperText.svelte
@@ -0,0 +1,17 @@
+<script lang="ts">
+ interface Props {
+ class?: string;
+ show?: boolean;
+ }
+
+ let { class: className = '', show = true }: Props = $props();
+</script>
+
+{#if show}
+ <div class="mt-4 flex items-center justify-center {className}">
+ <p class="text-xs text-muted-foreground">
+ Press <kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">Enter</kbd> to send,
+ <kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">Shift + Enter</kbd> for new line
+ </p>
+ </div>
+{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte
new file mode 100644
index 0000000..19b763f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte
@@ -0,0 +1,59 @@
+<script lang="ts">
+ import { autoResizeTextarea } from '$lib/utils';
+ import { onMount } from 'svelte';
+
+ interface Props {
+ class?: string;
+ disabled?: boolean;
+ onKeydown?: (event: KeyboardEvent) => void;
+ onPaste?: (event: ClipboardEvent) => void;
+ placeholder?: string;
+ value?: string;
+ }
+
+ let {
+ class: className = '',
+ disabled = false,
+ onKeydown,
+ onPaste,
+ placeholder = 'Ask anything...',
+ value = $bindable('')
+ }: Props = $props();
+
+ let textareaElement: HTMLTextAreaElement | undefined;
+
+ onMount(() => {
+ if (textareaElement) {
+ textareaElement.focus();
+ }
+ });
+
+ // Expose the textarea element for external access
+ export function getElement() {
+ return textareaElement;
+ }
+
+ export function focus() {
+ textareaElement?.focus();
+ }
+
+ export function resetHeight() {
+ if (textareaElement) {
+ textareaElement.style.height = '1rem';
+ }
+ }
+</script>
+
+<div class="flex-1 {className}">
+ <textarea
+ bind:this={textareaElement}
+ bind:value
+ class="text-md max-h-32 min-h-12 w-full resize-none border-0 bg-transparent p-0 leading-6 outline-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0"
+ class:cursor-not-allowed={disabled}
+ {disabled}
+ onkeydown={onKeydown}
+ oninput={(event) => autoResizeTextarea(event.currentTarget)}
+ onpaste={onPaste}
+ {placeholder}
+ ></textarea>
+</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
new file mode 100644
index 0000000..220276f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
@@ -0,0 +1,286 @@
+<script lang="ts">
+ import { chatStore } from '$lib/stores/chat.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+ import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils';
+ import ChatMessageAssistant from './ChatMessageAssistant.svelte';
+ import ChatMessageUser from './ChatMessageUser.svelte';
+ import ChatMessageSystem from './ChatMessageSystem.svelte';
+
+ interface Props {
+ class?: string;
+ message: DatabaseMessage;
+ onCopy?: (message: DatabaseMessage) => void;
+ onContinueAssistantMessage?: (message: DatabaseMessage) => void;
+ onDelete?: (message: DatabaseMessage) => void;
+ onEditWithBranching?: (
+ message: DatabaseMessage,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ) => void;
+ onEditWithReplacement?: (
+ message: DatabaseMessage,
+ newContent: string,
+ shouldBranch: boolean
+ ) => void;
+ onEditUserMessagePreserveResponses?: (
+ message: DatabaseMessage,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ) => void;
+ onNavigateToSibling?: (siblingId: string) => void;
+ onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
+ siblingInfo?: ChatMessageSiblingInfo | null;
+ }
+
+ let {
+ class: className = '',
+ message,
+ onCopy,
+ onContinueAssistantMessage,
+ onDelete,
+ onEditWithBranching,
+ onEditWithReplacement,
+ onEditUserMessagePreserveResponses,
+ onNavigateToSibling,
+ onRegenerateWithBranching,
+ siblingInfo = null
+ }: Props = $props();
+
+ let deletionInfo = $state<{
+ totalCount: number;
+ userMessages: number;
+ assistantMessages: number;
+ messageTypes: string[];
+ } | null>(null);
+ let editedContent = $state(message.content);
+ let editedExtras = $state<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
+ let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
+ let isEditing = $state(false);
+ let showDeleteDialog = $state(false);
+ let shouldBranchAfterEdit = $state(false);
+ let textareaElement: HTMLTextAreaElement | undefined = $state();
+
+ let thinkingContent = $derived.by(() => {
+ if (message.role === 'assistant') {
+ const trimmedThinking = message.thinking?.trim();
+
+ return trimmedThinking ? trimmedThinking : null;
+ }
+ return null;
+ });
+
+ let toolCallContent = $derived.by((): ApiChatCompletionToolCall[] | string | null => {
+ if (message.role === 'assistant') {
+ const trimmedToolCalls = message.toolCalls?.trim();
+
+ if (!trimmedToolCalls) {
+ return null;
+ }
+
+ try {
+ const parsed = JSON.parse(trimmedToolCalls);
+
+ if (Array.isArray(parsed)) {
+ return parsed as ApiChatCompletionToolCall[];
+ }
+ } catch {
+ // Harmony-only path: fall back to the raw string so issues surface visibly.
+ }
+
+ return trimmedToolCalls;
+ }
+ return null;
+ });
+
+ function handleCancelEdit() {
+ isEditing = false;
+ editedContent = message.content;
+ editedExtras = message.extra ? [...message.extra] : [];
+ editedUploadedFiles = [];
+ }
+
+ function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) {
+ editedExtras = extras;
+ }
+
+ function handleEditedUploadedFilesChange(files: ChatUploadedFile[]) {
+ editedUploadedFiles = files;
+ }
+
+ async function handleCopy() {
+ const asPlainText = Boolean(config().copyTextAttachmentsAsPlainText);
+ const clipboardContent = formatMessageForClipboard(message.content, message.extra, asPlainText);
+ await copyToClipboard(clipboardContent, 'Message copied to clipboard');
+ onCopy?.(message);
+ }
+
+ function handleConfirmDelete() {
+ onDelete?.(message);
+ showDeleteDialog = false;
+ }
+
+ async function handleDelete() {
+ deletionInfo = await chatStore.getDeletionInfo(message.id);
+ showDeleteDialog = true;
+ }
+
+ function handleEdit() {
+ isEditing = true;
+ editedContent = message.content;
+ editedExtras = message.extra ? [...message.extra] : [];
+ editedUploadedFiles = [];
+
+ setTimeout(() => {
+ if (textareaElement) {
+ textareaElement.focus();
+ textareaElement.setSelectionRange(
+ textareaElement.value.length,
+ textareaElement.value.length
+ );
+ }
+ }, 0);
+ }
+
+ function handleEditedContentChange(content: string) {
+ editedContent = content;
+ }
+
+ function handleEditKeydown(event: KeyboardEvent) {
+ // Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari)
+ // This prevents saving edit when confirming IME word selection (e.g., Japanese/Chinese input)
+ if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
+ event.preventDefault();
+ handleSaveEdit();
+ } else if (event.key === 'Escape') {
+ event.preventDefault();
+ handleCancelEdit();
+ }
+ }
+
+ function handleRegenerate(modelOverride?: string) {
+ onRegenerateWithBranching?.(message, modelOverride);
+ }
+
+ function handleContinue() {
+ onContinueAssistantMessage?.(message);
+ }
+
+ async function handleSaveEdit() {
+ if (message.role === 'user' || message.role === 'system') {
+ const finalExtras = await getMergedExtras();
+ onEditWithBranching?.(message, editedContent.trim(), finalExtras);
+ } else {
+ // For assistant messages, preserve exact content including trailing whitespace
+ // This is important for the Continue feature to work properly
+ onEditWithReplacement?.(message, editedContent, shouldBranchAfterEdit);
+ }
+
+ isEditing = false;
+ shouldBranchAfterEdit = false;
+ editedUploadedFiles = [];
+ }
+
+ async function handleSaveEditOnly() {
+ if (message.role === 'user') {
+ // For user messages, trim to avoid accidental whitespace
+ const finalExtras = await getMergedExtras();
+ onEditUserMessagePreserveResponses?.(message, editedContent.trim(), finalExtras);
+ }
+
+ isEditing = false;
+ editedUploadedFiles = [];
+ }
+
+ async function getMergedExtras(): Promise<DatabaseMessageExtra[]> {
+ if (editedUploadedFiles.length === 0) {
+ return editedExtras;
+ }
+
+ const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only');
+ const result = await parseFilesToMessageExtras(editedUploadedFiles);
+ const newExtras = result?.extras || [];
+
+ return [...editedExtras, ...newExtras];
+ }
+
+ function handleShowDeleteDialogChange(show: boolean) {
+ showDeleteDialog = show;
+ }
+</script>
+
+{#if message.role === 'system'}
+ <ChatMessageSystem
+ bind:textareaElement
+ class={className}
+ {deletionInfo}
+ {editedContent}
+ {isEditing}
+ {message}
+ onCancelEdit={handleCancelEdit}
+ onConfirmDelete={handleConfirmDelete}
+ onCopy={handleCopy}
+ onDelete={handleDelete}
+ onEdit={handleEdit}
+ onEditKeydown={handleEditKeydown}
+ onEditedContentChange={handleEditedContentChange}
+ {onNavigateToSibling}
+ onSaveEdit={handleSaveEdit}
+ onShowDeleteDialogChange={handleShowDeleteDialogChange}
+ {showDeleteDialog}
+ {siblingInfo}
+ />
+{:else if message.role === 'user'}
+ <ChatMessageUser
+ bind:textareaElement
+ class={className}
+ {deletionInfo}
+ {editedContent}
+ {editedExtras}
+ {editedUploadedFiles}
+ {isEditing}
+ {message}
+ onCancelEdit={handleCancelEdit}
+ onConfirmDelete={handleConfirmDelete}
+ onCopy={handleCopy}
+ onDelete={handleDelete}
+ onEdit={handleEdit}
+ onEditKeydown={handleEditKeydown}
+ onEditedContentChange={handleEditedContentChange}
+ onEditedExtrasChange={handleEditedExtrasChange}
+ onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
+ {onNavigateToSibling}
+ onSaveEdit={handleSaveEdit}
+ onSaveEditOnly={handleSaveEditOnly}
+ onShowDeleteDialogChange={handleShowDeleteDialogChange}
+ {showDeleteDialog}
+ {siblingInfo}
+ />
+{:else}
+ <ChatMessageAssistant
+ bind:textareaElement
+ class={className}
+ {deletionInfo}
+ {editedContent}
+ {isEditing}
+ {message}
+ messageContent={message.content}
+ onCancelEdit={handleCancelEdit}
+ onConfirmDelete={handleConfirmDelete}
+ onContinue={handleContinue}
+ onCopy={handleCopy}
+ onDelete={handleDelete}
+ onEdit={handleEdit}
+ onEditKeydown={handleEditKeydown}
+ onEditedContentChange={handleEditedContentChange}
+ {onNavigateToSibling}
+ onRegenerate={handleRegenerate}
+ onSaveEdit={handleSaveEdit}
+ onShowDeleteDialogChange={handleShowDeleteDialogChange}
+ {shouldBranchAfterEdit}
+ onShouldBranchAfterEditChange={(value) => (shouldBranchAfterEdit = value)}
+ {showDeleteDialog}
+ {siblingInfo}
+ {thinkingContent}
+ {toolCallContent}
+ />
+{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte
new file mode 100644
index 0000000..3cb4815
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte
@@ -0,0 +1,100 @@
+<script lang="ts">
+ import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
+ import {
+ ActionButton,
+ ChatMessageBranchingControls,
+ DialogConfirmation
+ } from '$lib/components/app';
+
+ interface Props {
+ role: 'user' | 'assistant';
+ justify: 'start' | 'end';
+ actionsPosition: 'left' | 'right';
+ siblingInfo?: ChatMessageSiblingInfo | null;
+ showDeleteDialog: boolean;
+ deletionInfo: {
+ totalCount: number;
+ userMessages: number;
+ assistantMessages: number;
+ messageTypes: string[];
+ } | null;
+ onCopy: () => void;
+ onEdit?: () => void;
+ onRegenerate?: () => void;
+ onContinue?: () => void;
+ onDelete: () => void;
+ onConfirmDelete: () => void;
+ onNavigateToSibling?: (siblingId: string) => void;
+ onShowDeleteDialogChange: (show: boolean) => void;
+ }
+
+ let {
+ actionsPosition,
+ deletionInfo,
+ justify,
+ onCopy,
+ onEdit,
+ onConfirmDelete,
+ onContinue,
+ onDelete,
+ onNavigateToSibling,
+ onShowDeleteDialogChange,
+ onRegenerate,
+ role,
+ siblingInfo = null,
+ showDeleteDialog
+ }: Props = $props();
+
+ function handleConfirmDelete() {
+ onConfirmDelete();
+ onShowDeleteDialogChange(false);
+ }
+</script>
+
+<div class="relative {justify === 'start' ? 'mt-2' : ''} flex h-6 items-center justify-{justify}">
+ <div
+ class="absolute top-0 {actionsPosition === 'left'
+ ? 'left-0'
+ : 'right-0'} flex items-center gap-2 opacity-100 transition-opacity"
+ >
+ {#if siblingInfo && siblingInfo.totalSiblings > 1}
+ <ChatMessageBranchingControls {siblingInfo} {onNavigateToSibling} />
+ {/if}
+
+ <div
+ class="pointer-events-auto inset-0 flex items-center gap-1 opacity-100 transition-all duration-150"
+ >
+ <ActionButton icon={Copy} tooltip="Copy" onclick={onCopy} />
+
+ {#if onEdit}
+ <ActionButton icon={Edit} tooltip="Edit" onclick={onEdit} />
+ {/if}
+
+ {#if role === 'assistant' && onRegenerate}
+ <ActionButton icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
+ {/if}
+
+ {#if role === 'assistant' && onContinue}
+ <ActionButton icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
+ {/if}
+
+ <ActionButton icon={Trash2} tooltip="Delete" onclick={onDelete} />
+ </div>
+ </div>
+</div>
+
+<DialogConfirmation
+ bind:open={showDeleteDialog}
+ title="Delete Message"
+ description={deletionInfo && deletionInfo.totalCount > 1
+ ? `This will delete ${deletionInfo.totalCount} messages including: ${deletionInfo.userMessages} user message${deletionInfo.userMessages > 1 ? 's' : ''} and ${deletionInfo.assistantMessages} assistant response${deletionInfo.assistantMessages > 1 ? 's' : ''}. All messages in this branch and their responses will be permanently removed. This action cannot be undone.`
+ : 'Are you sure you want to delete this message? This action cannot be undone.'}
+ confirmText={deletionInfo && deletionInfo.totalCount > 1
+ ? `Delete ${deletionInfo.totalCount} Messages`
+ : 'Delete'}
+ cancelText="Cancel"
+ variant="destructive"
+ icon={Trash2}
+ onConfirm={handleConfirmDelete}
+ onCancel={() => onShowDeleteDialogChange(false)}
+/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
new file mode 100644
index 0000000..2b34b1c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
@@ -0,0 +1,418 @@
+<script lang="ts">
+ import {
+ ModelBadge,
+ ChatMessageActions,
+ ChatMessageStatistics,
+ ChatMessageThinkingBlock,
+ CopyToClipboardIcon,
+ MarkdownContent,
+ ModelsSelector
+ } from '$lib/components/app';
+ import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
+ import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
+ import { isLoading } from '$lib/stores/chat.svelte';
+ import { autoResizeTextarea, copyToClipboard } from '$lib/utils';
+ import { fade } from 'svelte/transition';
+ import { Check, X, Wrench } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import { Checkbox } from '$lib/components/ui/checkbox';
+ import { INPUT_CLASSES } from '$lib/constants/input-classes';
+ import Label from '$lib/components/ui/label/label.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+ import { conversationsStore } from '$lib/stores/conversations.svelte';
+ import { isRouterMode } from '$lib/stores/server.svelte';
+
+ interface Props {
+ class?: string;
+ deletionInfo: {
+ totalCount: number;
+ userMessages: number;
+ assistantMessages: number;
+ messageTypes: string[];
+ } | null;
+ editedContent?: string;
+ isEditing?: boolean;
+ message: DatabaseMessage;
+ messageContent: string | undefined;
+ onCancelEdit?: () => void;
+ onCopy: () => void;
+ onConfirmDelete: () => void;
+ onContinue?: () => void;
+ onDelete: () => void;
+ onEdit?: () => void;
+ onEditKeydown?: (event: KeyboardEvent) => void;
+ onEditedContentChange?: (content: string) => void;
+ onNavigateToSibling?: (siblingId: string) => void;
+ onRegenerate: (modelOverride?: string) => void;
+ onSaveEdit?: () => void;
+ onShowDeleteDialogChange: (show: boolean) => void;
+ onShouldBranchAfterEditChange?: (value: boolean) => void;
+ showDeleteDialog: boolean;
+ shouldBranchAfterEdit?: boolean;
+ siblingInfo?: ChatMessageSiblingInfo | null;
+ textareaElement?: HTMLTextAreaElement;
+ thinkingContent: string | null;
+ toolCallContent: ApiChatCompletionToolCall[] | string | null;
+ }
+
+ let {
+ class: className = '',
+ deletionInfo,
+ editedContent = '',
+ isEditing = false,
+ message,
+ messageContent,
+ onCancelEdit,
+ onConfirmDelete,
+ onContinue,
+ onCopy,
+ onDelete,
+ onEdit,
+ onEditKeydown,
+ onEditedContentChange,
+ onNavigateToSibling,
+ onRegenerate,
+ onSaveEdit,
+ onShowDeleteDialogChange,
+ onShouldBranchAfterEditChange,
+ showDeleteDialog,
+ shouldBranchAfterEdit = false,
+ siblingInfo = null,
+ textareaElement = $bindable(),
+ thinkingContent,
+ toolCallContent = null
+ }: Props = $props();
+
+ const toolCalls = $derived(
+ Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null
+ );
+ const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null);
+
+ const processingState = useProcessingState();
+
+ let currentConfig = $derived(config());
+ let isRouter = $derived(isRouterMode());
+ let displayedModel = $derived((): string | null => {
+ if (message.model) {
+ return message.model;
+ }
+
+ return null;
+ });
+
+ const { handleModelChange } = useModelChangeValidation({
+ getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
+ onSuccess: (modelName) => onRegenerate(modelName)
+ });
+
+ function handleCopyModel() {
+ const model = displayedModel();
+
+ void copyToClipboard(model ?? '');
+ }
+
+ $effect(() => {
+ if (isEditing && textareaElement) {
+ autoResizeTextarea(textareaElement);
+ }
+ });
+
+ $effect(() => {
+ if (isLoading() && !message?.content?.trim()) {
+ processingState.startMonitoring();
+ }
+ });
+
+ function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) {
+ const callNumber = index + 1;
+ const functionName = toolCall.function?.name?.trim();
+ const label = functionName || `Call #${callNumber}`;
+
+ const payload: Record<string, unknown> = {};
+
+ const id = toolCall.id?.trim();
+ if (id) {
+ payload.id = id;
+ }
+
+ const type = toolCall.type?.trim();
+ if (type) {
+ payload.type = type;
+ }
+
+ if (toolCall.function) {
+ const fnPayload: Record<string, unknown> = {};
+
+ const name = toolCall.function.name?.trim();
+ if (name) {
+ fnPayload.name = name;
+ }
+
+ const rawArguments = toolCall.function.arguments?.trim();
+ if (rawArguments) {
+ try {
+ fnPayload.arguments = JSON.parse(rawArguments);
+ } catch {
+ fnPayload.arguments = rawArguments;
+ }
+ }
+
+ if (Object.keys(fnPayload).length > 0) {
+ payload.function = fnPayload;
+ }
+ }
+
+ const formattedPayload = JSON.stringify(payload, null, 2);
+
+ return {
+ label,
+ tooltip: formattedPayload,
+ copyValue: formattedPayload
+ };
+ }
+
+ function handleCopyToolCall(payload: string) {
+ void copyToClipboard(payload, 'Tool call copied to clipboard');
+ }
+</script>
+
+<div
+ class="text-md group w-full leading-7.5 {className}"
+ role="group"
+ aria-label="Assistant message with actions"
+>
+ {#if thinkingContent}
+ <ChatMessageThinkingBlock
+ reasoningContent={thinkingContent}
+ isStreaming={!message.timestamp}
+ hasRegularContent={!!messageContent?.trim()}
+ />
+ {/if}
+
+ {#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()}
+ <div class="mt-6 w-full max-w-[48rem]" in:fade>
+ <div class="processing-container">
+ <span class="processing-text">
+ {processingState.getPromptProgressText() ?? processingState.getProcessingMessage()}
+ </span>
+ </div>
+ </div>
+ {/if}
+
+ {#if isEditing}
+ <div class="w-full">
+ <textarea
+ bind:this={textareaElement}
+ bind:value={editedContent}
+ class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
+ onkeydown={onEditKeydown}
+ oninput={(e) => {
+ autoResizeTextarea(e.currentTarget);
+ onEditedContentChange?.(e.currentTarget.value);
+ }}
+ placeholder="Edit assistant message..."
+ ></textarea>
+
+ <div class="mt-2 flex items-center justify-between">
+ <div class="flex items-center space-x-2">
+ <Checkbox
+ id="branch-after-edit"
+ bind:checked={shouldBranchAfterEdit}
+ onCheckedChange={(checked) => onShouldBranchAfterEditChange?.(checked === true)}
+ />
+ <Label for="branch-after-edit" class="cursor-pointer text-sm text-muted-foreground">
+ Branch conversation after edit
+ </Label>
+ </div>
+ <div class="flex gap-2">
+ <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
+ <X class="mr-1 h-3 w-3" />
+ Cancel
+ </Button>
+
+ <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent?.trim()} size="sm">
+ <Check class="mr-1 h-3 w-3" />
+ Save
+ </Button>
+ </div>
+ </div>
+ </div>
+ {:else if message.role === 'assistant'}
+ {#if config().disableReasoningFormat}
+ <pre class="raw-output">{messageContent || ''}</pre>
+ {:else}
+ <MarkdownContent content={messageContent || ''} />
+ {/if}
+ {:else}
+ <div class="text-sm whitespace-pre-wrap">
+ {messageContent}
+ </div>
+ {/if}
+
+ <div class="info my-6 grid gap-4 tabular-nums">
+ {#if displayedModel()}
+ <div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
+ {#if isRouter}
+ <ModelsSelector
+ currentModel={displayedModel()}
+ onModelChange={handleModelChange}
+ disabled={isLoading()}
+ upToMessageId={message.id}
+ />
+ {:else}
+ <ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} />
+ {/if}
+
+ {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
+ <ChatMessageStatistics
+ promptTokens={message.timings.prompt_n}
+ promptMs={message.timings.prompt_ms}
+ predictedTokens={message.timings.predicted_n}
+ predictedMs={message.timings.predicted_ms}
+ />
+ {:else if isLoading() && currentConfig.showMessageStats}
+ {@const liveStats = processingState.getLiveProcessingStats()}
+ {@const genStats = processingState.getLiveGenerationStats()}
+ {@const promptProgress = processingState.processingState?.promptProgress}
+ {@const isStillProcessingPrompt =
+ promptProgress && promptProgress.processed < promptProgress.total}
+
+ {#if liveStats || genStats}
+ <ChatMessageStatistics
+ isLive={true}
+ isProcessingPrompt={!!isStillProcessingPrompt}
+ promptTokens={liveStats?.tokensProcessed}
+ promptMs={liveStats?.timeMs}
+ predictedTokens={genStats?.tokensGenerated}
+ predictedMs={genStats?.timeMs}
+ />
+ {/if}
+ {/if}
+ </div>
+ {/if}
+
+ {#if config().showToolCalls}
+ {#if (toolCalls && toolCalls.length > 0) || fallbackToolCalls}
+ <span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
+ <span class="inline-flex items-center gap-1">
+ <Wrench class="h-3.5 w-3.5" />
+
+ <span>Tool calls:</span>
+ </span>
+
+ {#if toolCalls && toolCalls.length > 0}
+ {#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)}
+ {@const badge = formatToolCallBadge(toolCall, index)}
+ <button
+ type="button"
+ class="tool-call-badge inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
+ title={badge.tooltip}
+ aria-label={`Copy tool call ${badge.label}`}
+ onclick={() => handleCopyToolCall(badge.copyValue)}
+ >
+ {badge.label}
+ <CopyToClipboardIcon
+ text={badge.copyValue}
+ ariaLabel={`Copy tool call ${badge.label}`}
+ />
+ </button>
+ {/each}
+ {:else if fallbackToolCalls}
+ <button
+ type="button"
+ class="tool-call-badge tool-call-badge--fallback inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
+ title={fallbackToolCalls}
+ aria-label="Copy tool call payload"
+ onclick={() => handleCopyToolCall(fallbackToolCalls)}
+ >
+ {fallbackToolCalls}
+ <CopyToClipboardIcon text={fallbackToolCalls} ariaLabel="Copy tool call payload" />
+ </button>
+ {/if}
+ </span>
+ {/if}
+ {/if}
+ </div>
+
+ {#if message.timestamp && !isEditing}
+ <ChatMessageActions
+ role="assistant"
+ justify="start"
+ actionsPosition="left"
+ {siblingInfo}
+ {showDeleteDialog}
+ {deletionInfo}
+ {onCopy}
+ {onEdit}
+ {onRegenerate}
+ onContinue={currentConfig.enableContinueGeneration && !thinkingContent
+ ? onContinue
+ : undefined}
+ {onDelete}
+ {onConfirmDelete}
+ {onNavigateToSibling}
+ {onShowDeleteDialogChange}
+ />
+ {/if}
+</div>
+
+<style>
+ .processing-container {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+
+ .processing-text {
+ background: linear-gradient(
+ 90deg,
+ var(--muted-foreground),
+ var(--foreground),
+ var(--muted-foreground)
+ );
+ background-size: 200% 100%;
+ background-clip: text;
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ animation: shine 1s linear infinite;
+ font-weight: 500;
+ font-size: 0.875rem;
+ }
+
+ @keyframes shine {
+ to {
+ background-position: -200% 0;
+ }
+ }
+
+ .raw-output {
+ width: 100%;
+ max-width: 48rem;
+ margin-top: 1.5rem;
+ padding: 1rem 1.25rem;
+ border-radius: 1rem;
+ background: hsl(var(--muted) / 0.3);
+ color: var(--foreground);
+ font-family:
+ ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
+ 'Liberation Mono', Menlo, monospace;
+ font-size: 0.875rem;
+ line-height: 1.6;
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+
+ .tool-call-badge {
+ max-width: 12rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .tool-call-badge--fallback {
+ max-width: 20rem;
+ white-space: normal;
+ word-break: break-word;
+ }
+</style>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageBranchingControls.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageBranchingControls.svelte
new file mode 100644
index 0000000..7420bb1
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageBranchingControls.svelte
@@ -0,0 +1,84 @@
+<script lang="ts">
+ import { ChevronLeft, ChevronRight } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+
+ interface Props {
+ class?: string;
+ siblingInfo: ChatMessageSiblingInfo | null;
+ onNavigateToSibling?: (siblingId: string) => void;
+ }
+
+ let { class: className = '', siblingInfo, onNavigateToSibling }: Props = $props();
+
+ let hasPrevious = $derived(siblingInfo && siblingInfo.currentIndex > 0);
+ let hasNext = $derived(siblingInfo && siblingInfo.currentIndex < siblingInfo.totalSiblings - 1);
+ let nextSiblingId = $derived(
+ hasNext ? siblingInfo!.siblingIds[siblingInfo!.currentIndex + 1] : null
+ );
+ let previousSiblingId = $derived(
+ hasPrevious ? siblingInfo!.siblingIds[siblingInfo!.currentIndex - 1] : null
+ );
+
+ function handleNext() {
+ if (nextSiblingId) {
+ onNavigateToSibling?.(nextSiblingId);
+ }
+ }
+
+ function handlePrevious() {
+ if (previousSiblingId) {
+ onNavigateToSibling?.(previousSiblingId);
+ }
+ }
+</script>
+
+{#if siblingInfo && siblingInfo.totalSiblings > 1}
+ <div
+ aria-label="Message version {siblingInfo.currentIndex + 1} of {siblingInfo.totalSiblings}"
+ class="flex items-center gap-1 text-xs text-muted-foreground {className}"
+ role="navigation"
+ >
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <Button
+ aria-label="Previous message version"
+ class="h-5 w-5 p-0 {!hasPrevious ? 'cursor-not-allowed opacity-30' : ''}"
+ disabled={!hasPrevious}
+ onclick={handlePrevious}
+ size="sm"
+ variant="ghost"
+ >
+ <ChevronLeft class="h-3 w-3" />
+ </Button>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>Previous version</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+
+ <span class="px-1 font-mono text-xs">
+ {siblingInfo.currentIndex + 1}/{siblingInfo.totalSiblings}
+ </span>
+
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <Button
+ aria-label="Next message version"
+ class="h-5 w-5 p-0 {!hasNext ? 'cursor-not-allowed opacity-30' : ''}"
+ disabled={!hasNext}
+ onclick={handleNext}
+ size="sm"
+ variant="ghost"
+ >
+ <ChevronRight class="h-3 w-3" />
+ </Button>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>Next version</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ </div>
+{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte
new file mode 100644
index 0000000..f812ea2
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte
@@ -0,0 +1,391 @@
+<script lang="ts">
+ import { X, ArrowUp, Paperclip, AlertTriangle } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import { Switch } from '$lib/components/ui/switch';
+ import { ChatAttachmentsList, DialogConfirmation, ModelsSelector } from '$lib/components/app';
+ import { INPUT_CLASSES } from '$lib/constants/input-classes';
+ import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
+ import { AttachmentType, FileTypeCategory, MimeTypeText } from '$lib/enums';
+ import { config } from '$lib/stores/settings.svelte';
+ import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
+ import { setEditModeActive, clearEditMode } from '$lib/stores/chat.svelte';
+ import { conversationsStore } from '$lib/stores/conversations.svelte';
+ import { modelsStore } from '$lib/stores/models.svelte';
+ import { isRouterMode } from '$lib/stores/server.svelte';
+ import {
+ autoResizeTextarea,
+ getFileTypeCategory,
+ getFileTypeCategoryByExtension,
+ parseClipboardContent
+ } from '$lib/utils';
+
+ interface Props {
+ messageId: string;
+ editedContent: string;
+ editedExtras?: DatabaseMessageExtra[];
+ editedUploadedFiles?: ChatUploadedFile[];
+ originalContent: string;
+ originalExtras?: DatabaseMessageExtra[];
+ showSaveOnlyOption?: boolean;
+ onCancelEdit: () => void;
+ onSaveEdit: () => void;
+ onSaveEditOnly?: () => void;
+ onEditKeydown: (event: KeyboardEvent) => void;
+ onEditedContentChange: (content: string) => void;
+ onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
+ onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
+ textareaElement?: HTMLTextAreaElement;
+ }
+
+ let {
+ messageId,
+ editedContent,
+ editedExtras = [],
+ editedUploadedFiles = [],
+ originalContent,
+ originalExtras = [],
+ showSaveOnlyOption = false,
+ onCancelEdit,
+ onSaveEdit,
+ onSaveEditOnly,
+ onEditKeydown,
+ onEditedContentChange,
+ onEditedExtrasChange,
+ onEditedUploadedFilesChange,
+ textareaElement = $bindable()
+ }: Props = $props();
+
+ let fileInputElement: HTMLInputElement | undefined = $state();
+ let saveWithoutRegenerate = $state(false);
+ let showDiscardDialog = $state(false);
+ let isRouter = $derived(isRouterMode());
+ let currentConfig = $derived(config());
+
+ let pasteLongTextToFileLength = $derived.by(() => {
+ const n = Number(currentConfig.pasteLongTextToFileLen);
+
+ return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
+ });
+
+ let hasUnsavedChanges = $derived.by(() => {
+ if (editedContent !== originalContent) return true;
+ if (editedUploadedFiles.length > 0) return true;
+
+ const extrasChanged =
+ editedExtras.length !== originalExtras.length ||
+ editedExtras.some((extra, i) => extra !== originalExtras[i]);
+
+ if (extrasChanged) return true;
+
+ return false;
+ });
+
+ let hasAttachments = $derived(
+ (editedExtras && editedExtras.length > 0) ||
+ (editedUploadedFiles && editedUploadedFiles.length > 0)
+ );
+
+ let canSubmit = $derived(editedContent.trim().length > 0 || hasAttachments);
+
+ function getEditedAttachmentsModalities(): ModelModalities {
+ const modalities: ModelModalities = { vision: false, audio: false };
+
+ for (const extra of editedExtras) {
+ if (extra.type === AttachmentType.IMAGE) {
+ modalities.vision = true;
+ }
+
+ if (
+ extra.type === AttachmentType.PDF &&
+ 'processedAsImages' in extra &&
+ extra.processedAsImages
+ ) {
+ modalities.vision = true;
+ }
+
+ if (extra.type === AttachmentType.AUDIO) {
+ modalities.audio = true;
+ }
+ }
+
+ for (const file of editedUploadedFiles) {
+ const category = getFileTypeCategory(file.type) || getFileTypeCategoryByExtension(file.name);
+ if (category === FileTypeCategory.IMAGE) {
+ modalities.vision = true;
+ }
+ if (category === FileTypeCategory.AUDIO) {
+ modalities.audio = true;
+ }
+ }
+
+ return modalities;
+ }
+
+ function getRequiredModalities(): ModelModalities {
+ const beforeModalities = conversationsStore.getModalitiesUpToMessage(messageId);
+ const editedModalities = getEditedAttachmentsModalities();
+
+ return {
+ vision: beforeModalities.vision || editedModalities.vision,
+ audio: beforeModalities.audio || editedModalities.audio
+ };
+ }
+
+ const { handleModelChange } = useModelChangeValidation({
+ getRequiredModalities,
+ onValidationFailure: async (previousModelId) => {
+ if (previousModelId) {
+ await modelsStore.selectModelById(previousModelId);
+ }
+ }
+ });
+
+ function handleFileInputChange(event: Event) {
+ const input = event.target as HTMLInputElement;
+ if (!input.files || input.files.length === 0) return;
+
+ const files = Array.from(input.files);
+
+ processNewFiles(files);
+ input.value = '';
+ }
+
+ function handleGlobalKeydown(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ attemptCancel();
+ }
+ }
+
+ function attemptCancel() {
+ if (hasUnsavedChanges) {
+ showDiscardDialog = true;
+ } else {
+ onCancelEdit();
+ }
+ }
+
+ function handleRemoveExistingAttachment(index: number) {
+ if (!onEditedExtrasChange) return;
+
+ const newExtras = [...editedExtras];
+
+ newExtras.splice(index, 1);
+ onEditedExtrasChange(newExtras);
+ }
+
+ function handleRemoveUploadedFile(fileId: string) {
+ if (!onEditedUploadedFilesChange) return;
+
+ const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId);
+
+ onEditedUploadedFilesChange(newFiles);
+ }
+
+ function handleSubmit() {
+ if (!canSubmit) return;
+
+ if (saveWithoutRegenerate && onSaveEditOnly) {
+ onSaveEditOnly();
+ } else {
+ onSaveEdit();
+ }
+
+ saveWithoutRegenerate = false;
+ }
+
+ async function processNewFiles(files: File[]) {
+ if (!onEditedUploadedFilesChange) return;
+
+ const { processFilesToChatUploaded } = await import('$lib/utils/browser-only');
+ const processed = await processFilesToChatUploaded(files);
+
+ onEditedUploadedFilesChange([...editedUploadedFiles, ...processed]);
+ }
+
+ function handlePaste(event: ClipboardEvent) {
+ if (!event.clipboardData) return;
+
+ const files = Array.from(event.clipboardData.items)
+ .filter((item) => item.kind === 'file')
+ .map((item) => item.getAsFile())
+ .filter((file): file is File => file !== null);
+
+ if (files.length > 0) {
+ event.preventDefault();
+ processNewFiles(files);
+
+ return;
+ }
+
+ const text = event.clipboardData.getData(MimeTypeText.PLAIN);
+
+ if (text.startsWith('"')) {
+ const parsed = parseClipboardContent(text);
+
+ if (parsed.textAttachments.length > 0) {
+ event.preventDefault();
+ onEditedContentChange(parsed.message);
+
+ const attachmentFiles = parsed.textAttachments.map(
+ (att) =>
+ new File([att.content], att.name, {
+ type: MimeTypeText.PLAIN
+ })
+ );
+
+ processNewFiles(attachmentFiles);
+
+ setTimeout(() => {
+ textareaElement?.focus();
+ }, 10);
+
+ return;
+ }
+ }
+
+ if (
+ text.length > 0 &&
+ pasteLongTextToFileLength > 0 &&
+ text.length > pasteLongTextToFileLength
+ ) {
+ event.preventDefault();
+
+ const textFile = new File([text], 'Pasted', {
+ type: MimeTypeText.PLAIN
+ });
+
+ processNewFiles([textFile]);
+ }
+ }
+
+ $effect(() => {
+ if (textareaElement) {
+ autoResizeTextarea(textareaElement);
+ }
+ });
+
+ $effect(() => {
+ setEditModeActive(processNewFiles);
+
+ return () => {
+ clearEditMode();
+ };
+ });
+</script>
+
+<svelte:window onkeydown={handleGlobalKeydown} />
+
+<input
+ bind:this={fileInputElement}
+ type="file"
+ multiple
+ class="hidden"
+ onchange={handleFileInputChange}
+/>
+
+<div
+ class="{INPUT_CLASSES} w-full max-w-[80%] overflow-hidden rounded-3xl backdrop-blur-md"
+ data-slot="edit-form"
+>
+ <ChatAttachmentsList
+ attachments={editedExtras}
+ uploadedFiles={editedUploadedFiles}
+ readonly={false}
+ onFileRemove={(fileId) => {
+ if (fileId.startsWith('attachment-')) {
+ const index = parseInt(fileId.replace('attachment-', ''), 10);
+ if (!isNaN(index) && index >= 0 && index < editedExtras.length) {
+ handleRemoveExistingAttachment(index);
+ }
+ } else {
+ handleRemoveUploadedFile(fileId);
+ }
+ }}
+ limitToSingleRow
+ class="py-5"
+ style="scroll-padding: 1rem;"
+ />
+
+ <div class="relative min-h-[48px] px-5 py-3">
+ <textarea
+ bind:this={textareaElement}
+ bind:value={editedContent}
+ class="field-sizing-content max-h-80 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
+ onkeydown={onEditKeydown}
+ oninput={(e) => {
+ autoResizeTextarea(e.currentTarget);
+ onEditedContentChange(e.currentTarget.value);
+ }}
+ onpaste={handlePaste}
+ placeholder="Edit your message..."
+ ></textarea>
+
+ <div class="flex w-full items-center gap-3" style="container-type: inline-size">
+ <Button
+ class="h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
+ onclick={() => fileInputElement?.click()}
+ type="button"
+ title="Add attachment"
+ >
+ <span class="sr-only">Attach files</span>
+
+ <Paperclip class="h-4 w-4" />
+ </Button>
+
+ <div class="flex-1"></div>
+
+ {#if isRouter}
+ <ModelsSelector
+ forceForegroundText={true}
+ useGlobalSelection={true}
+ onModelChange={handleModelChange}
+ />
+ {/if}
+
+ <Button
+ class="h-8 w-8 shrink-0 rounded-full p-0"
+ onclick={handleSubmit}
+ disabled={!canSubmit}
+ type="button"
+ title={saveWithoutRegenerate ? 'Save changes' : 'Send and regenerate'}
+ >
+ <span class="sr-only">{saveWithoutRegenerate ? 'Save' : 'Send'}</span>
+
+ <ArrowUp class="h-5 w-5" />
+ </Button>
+ </div>
+ </div>
+</div>
+
+<div class="mt-2 flex w-full max-w-[80%] items-center justify-between">
+ {#if showSaveOnlyOption && onSaveEditOnly}
+ <div class="flex items-center gap-2">
+ <Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" />
+
+ <label for="save-only-switch" class="cursor-pointer text-xs text-muted-foreground">
+ Update without re-sending
+ </label>
+ </div>
+ {:else}
+ <div></div>
+ {/if}
+
+ <Button class="h-7 px-3 text-xs" onclick={attemptCancel} size="sm" variant="ghost">
+ <X class="mr-1 h-3 w-3" />
+
+ Cancel
+ </Button>
+</div>
+
+<DialogConfirmation
+ bind:open={showDiscardDialog}
+ title="Discard changes?"
+ description="You have unsaved changes. Are you sure you want to discard them?"
+ confirmText="Discard"
+ cancelText="Keep editing"
+ variant="destructive"
+ icon={AlertTriangle}
+ onConfirm={onCancelEdit}
+ onCancel={() => (showDiscardDialog = false)}
+/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte
new file mode 100644
index 0000000..24fe592
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte
@@ -0,0 +1,175 @@
+<script lang="ts">
+ import { Clock, Gauge, WholeWord, BookOpenText, Sparkles } from '@lucide/svelte';
+ import { BadgeChatStatistic } from '$lib/components/app';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { ChatMessageStatsView } from '$lib/enums';
+
+ interface Props {
+ predictedTokens?: number;
+ predictedMs?: number;
+ promptTokens?: number;
+ promptMs?: number;
+ // Live mode: when true, shows stats during streaming
+ isLive?: boolean;
+ // Whether prompt processing is still in progress
+ isProcessingPrompt?: boolean;
+ // Initial view to show (defaults to READING in live mode)
+ initialView?: ChatMessageStatsView;
+ }
+
+ let {
+ predictedTokens,
+ predictedMs,
+ promptTokens,
+ promptMs,
+ isLive = false,
+ isProcessingPrompt = false,
+ initialView = ChatMessageStatsView.GENERATION
+ }: Props = $props();
+
+ let activeView: ChatMessageStatsView = $state(initialView);
+ let hasAutoSwitchedToGeneration = $state(false);
+
+ // In live mode: auto-switch to GENERATION tab when prompt processing completes
+ $effect(() => {
+ if (isLive) {
+ // Auto-switch to generation tab only when prompt processing is done (once)
+ if (
+ !hasAutoSwitchedToGeneration &&
+ !isProcessingPrompt &&
+ predictedTokens &&
+ predictedTokens > 0
+ ) {
+ activeView = ChatMessageStatsView.GENERATION;
+ hasAutoSwitchedToGeneration = true;
+ } else if (!hasAutoSwitchedToGeneration) {
+ // Stay on READING while prompt is still being processed
+ activeView = ChatMessageStatsView.READING;
+ }
+ }
+ });
+
+ let hasGenerationStats = $derived(
+ predictedTokens !== undefined &&
+ predictedTokens > 0 &&
+ predictedMs !== undefined &&
+ predictedMs > 0
+ );
+
+ let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0);
+ let timeInSeconds = $derived(
+ predictedMs !== undefined ? (predictedMs / 1000).toFixed(2) : '0.00'
+ );
+
+ let promptTokensPerSecond = $derived(
+ promptTokens !== undefined && promptMs !== undefined && promptMs > 0
+ ? (promptTokens / promptMs) * 1000
+ : undefined
+ );
+
+ let promptTimeInSeconds = $derived(
+ promptMs !== undefined ? (promptMs / 1000).toFixed(2) : undefined
+ );
+
+ let hasPromptStats = $derived(
+ promptTokens !== undefined &&
+ promptMs !== undefined &&
+ promptTokensPerSecond !== undefined &&
+ promptTimeInSeconds !== undefined
+ );
+
+ // In live mode, generation tab is disabled until we have generation stats
+ let isGenerationDisabled = $derived(isLive && !hasGenerationStats);
+</script>
+
+<div class="inline-flex items-center text-xs text-muted-foreground">
+ <div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5">
+ {#if hasPromptStats || isLive}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <button
+ type="button"
+ class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
+ ChatMessageStatsView.READING
+ ? 'bg-background text-foreground shadow-sm'
+ : 'hover:text-foreground'}"
+ onclick={() => (activeView = ChatMessageStatsView.READING)}
+ >
+ <BookOpenText class="h-3 w-3" />
+ <span class="sr-only">Reading</span>
+ </button>
+ </Tooltip.Trigger>
+ <Tooltip.Content>
+ <p>Reading (prompt processing)</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/if}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <button
+ type="button"
+ class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
+ ChatMessageStatsView.GENERATION
+ ? 'bg-background text-foreground shadow-sm'
+ : isGenerationDisabled
+ ? 'cursor-not-allowed opacity-40'
+ : 'hover:text-foreground'}"
+ onclick={() => !isGenerationDisabled && (activeView = ChatMessageStatsView.GENERATION)}
+ disabled={isGenerationDisabled}
+ >
+ <Sparkles class="h-3 w-3" />
+ <span class="sr-only">Generation</span>
+ </button>
+ </Tooltip.Trigger>
+ <Tooltip.Content>
+ <p>
+ {isGenerationDisabled
+ ? 'Generation (waiting for tokens...)'
+ : 'Generation (token output)'}
+ </p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ </div>
+
+ <div class="flex items-center gap-1 px-2">
+ {#if activeView === ChatMessageStatsView.GENERATION && hasGenerationStats}
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={WholeWord}
+ value="{predictedTokens?.toLocaleString()} tokens"
+ tooltipLabel="Generated tokens"
+ />
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={Clock}
+ value="{timeInSeconds}s"
+ tooltipLabel="Generation time"
+ />
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={Gauge}
+ value="{tokensPerSecond.toFixed(2)} tokens/s"
+ tooltipLabel="Generation speed"
+ />
+ {:else if hasPromptStats}
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={WholeWord}
+ value="{promptTokens} tokens"
+ tooltipLabel="Prompt tokens"
+ />
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={Clock}
+ value="{promptTimeInSeconds}s"
+ tooltipLabel="Prompt processing time"
+ />
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={Gauge}
+ value="{promptTokensPerSecond!.toFixed(2)} tokens/s"
+ tooltipLabel="Prompt processing speed"
+ />
+ {/if}
+ </div>
+</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte
new file mode 100644
index 0000000..c203822
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte
@@ -0,0 +1,216 @@
+<script lang="ts">
+ import { Check, X } from '@lucide/svelte';
+ import { Card } from '$lib/components/ui/card';
+ import { Button } from '$lib/components/ui/button';
+ import { MarkdownContent } from '$lib/components/app';
+ import { INPUT_CLASSES } from '$lib/constants/input-classes';
+ import { config } from '$lib/stores/settings.svelte';
+ import ChatMessageActions from './ChatMessageActions.svelte';
+
+ interface Props {
+ class?: string;
+ message: DatabaseMessage;
+ isEditing: boolean;
+ editedContent: string;
+ siblingInfo?: ChatMessageSiblingInfo | null;
+ showDeleteDialog: boolean;
+ deletionInfo: {
+ totalCount: number;
+ userMessages: number;
+ assistantMessages: number;
+ messageTypes: string[];
+ } | null;
+ onCancelEdit: () => void;
+ onSaveEdit: () => void;
+ onEditKeydown: (event: KeyboardEvent) => void;
+ onEditedContentChange: (content: string) => void;
+ onCopy: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+ onConfirmDelete: () => void;
+ onNavigateToSibling?: (siblingId: string) => void;
+ onShowDeleteDialogChange: (show: boolean) => void;
+ textareaElement?: HTMLTextAreaElement;
+ }
+
+ let {
+ class: className = '',
+ message,
+ isEditing,
+ editedContent,
+ siblingInfo = null,
+ showDeleteDialog,
+ deletionInfo,
+ onCancelEdit,
+ onSaveEdit,
+ onEditKeydown,
+ onEditedContentChange,
+ onCopy,
+ onEdit,
+ onDelete,
+ onConfirmDelete,
+ onNavigateToSibling,
+ onShowDeleteDialogChange,
+ textareaElement = $bindable()
+ }: Props = $props();
+
+ let isMultiline = $state(false);
+ let messageElement: HTMLElement | undefined = $state();
+ let isExpanded = $state(false);
+ let contentHeight = $state(0);
+ const MAX_HEIGHT = 200; // pixels
+ const currentConfig = config();
+
+ let showExpandButton = $derived(contentHeight > MAX_HEIGHT);
+
+ $effect(() => {
+ if (!messageElement || !message.content.trim()) return;
+
+ if (message.content.includes('\n')) {
+ isMultiline = true;
+ }
+
+ const resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const element = entry.target as HTMLElement;
+ const estimatedSingleLineHeight = 24;
+
+ isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5;
+ contentHeight = element.scrollHeight;
+ }
+ });
+
+ resizeObserver.observe(messageElement);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ });
+
+ function toggleExpand() {
+ isExpanded = !isExpanded;
+ }
+</script>
+
+<div
+ aria-label="System message with actions"
+ class="group flex flex-col items-end gap-3 md:gap-2 {className}"
+ role="group"
+>
+ {#if isEditing}
+ <div class="w-full max-w-[80%]">
+ <textarea
+ bind:this={textareaElement}
+ bind:value={editedContent}
+ class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
+ onkeydown={onEditKeydown}
+ oninput={(e) => onEditedContentChange(e.currentTarget.value)}
+ placeholder="Edit system message..."
+ ></textarea>
+
+ <div class="mt-2 flex justify-end gap-2">
+ <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
+ <X class="mr-1 h-3 w-3" />
+ Cancel
+ </Button>
+
+ <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent.trim()} size="sm">
+ <Check class="mr-1 h-3 w-3" />
+ Send
+ </Button>
+ </div>
+ </div>
+ {:else}
+ {#if message.content.trim()}
+ <div class="relative max-w-[80%]">
+ <button
+ class="group/expand w-full text-left {!isExpanded && showExpandButton
+ ? 'cursor-pointer'
+ : 'cursor-auto'}"
+ onclick={showExpandButton && !isExpanded ? toggleExpand : undefined}
+ type="button"
+ >
+ <Card
+ class="rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5"
+ data-multiline={isMultiline ? '' : undefined}
+ style="border: 2px dashed hsl(var(--border));"
+ >
+ <div
+ class="relative overflow-hidden transition-all duration-300 {isExpanded
+ ? 'cursor-text select-text'
+ : 'select-none'}"
+ style={!isExpanded && showExpandButton
+ ? `max-height: ${MAX_HEIGHT}px;`
+ : 'max-height: none;'}
+ >
+ {#if currentConfig.renderUserContentAsMarkdown}
+ <div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}">
+ <MarkdownContent class="markdown-system-content" content={message.content} />
+ </div>
+ {:else}
+ <span
+ bind:this={messageElement}
+ class="text-md whitespace-pre-wrap {isExpanded ? 'cursor-text' : ''}"
+ >
+ {message.content}
+ </span>
+ {/if}
+
+ {#if !isExpanded && showExpandButton}
+ <div
+ class="pointer-events-none absolute right-0 bottom-0 left-0 h-48 bg-gradient-to-t from-muted to-transparent"
+ ></div>
+ <div
+ class="pointer-events-none absolute right-0 bottom-4 left-0 flex justify-center opacity-0 transition-opacity group-hover/expand:opacity-100"
+ >
+ <Button
+ class="rounded-full px-4 py-1.5 text-xs shadow-md"
+ size="sm"
+ variant="outline"
+ >
+ Show full system message
+ </Button>
+ </div>
+ {/if}
+ </div>
+
+ {#if isExpanded && showExpandButton}
+ <div class="mb-2 flex justify-center">
+ <Button
+ class="rounded-full px-4 py-1.5 text-xs"
+ onclick={(e) => {
+ e.stopPropagation();
+ toggleExpand();
+ }}
+ size="sm"
+ variant="outline"
+ >
+ Collapse System Message
+ </Button>
+ </div>
+ {/if}
+ </Card>
+ </button>
+ </div>
+ {/if}
+
+ {#if message.timestamp}
+ <div class="max-w-[80%]">
+ <ChatMessageActions
+ actionsPosition="right"
+ {deletionInfo}
+ justify="end"
+ {onConfirmDelete}
+ {onCopy}
+ {onDelete}
+ {onEdit}
+ {onNavigateToSibling}
+ {onShowDeleteDialogChange}
+ {siblingInfo}
+ {showDeleteDialog}
+ role="user"
+ />
+ </div>
+ {/if}
+ {/if}
+</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte
new file mode 100644
index 0000000..9245ad5
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte
@@ -0,0 +1,68 @@
+<script lang="ts">
+ import { Brain } from '@lucide/svelte';
+ import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
+ import * as Collapsible from '$lib/components/ui/collapsible/index.js';
+ import { buttonVariants } from '$lib/components/ui/button/index.js';
+ import { Card } from '$lib/components/ui/card';
+ import { config } from '$lib/stores/settings.svelte';
+
+ interface Props {
+ class?: string;
+ hasRegularContent?: boolean;
+ isStreaming?: boolean;
+ reasoningContent: string | null;
+ }
+
+ let {
+ class: className = '',
+ hasRegularContent = false,
+ isStreaming = false,
+ reasoningContent
+ }: Props = $props();
+
+ const currentConfig = config();
+
+ let isExpanded = $state(currentConfig.showThoughtInProgress);
+
+ $effect(() => {
+ if (hasRegularContent && reasoningContent && currentConfig.showThoughtInProgress) {
+ isExpanded = false;
+ }
+ });
+</script>
+
+<Collapsible.Root bind:open={isExpanded} class="mb-6 {className}">
+ <Card class="gap-0 border-muted bg-muted/30 py-0">
+ <Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3">
+ <div class="flex items-center gap-2 text-muted-foreground">
+ <Brain class="h-4 w-4" />
+
+ <span class="text-sm font-medium">
+ {isStreaming ? 'Reasoning...' : 'Reasoning'}
+ </span>
+ </div>
+
+ <div
+ class={buttonVariants({
+ variant: 'ghost',
+ size: 'sm',
+ class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
+ })}
+ >
+ <ChevronsUpDownIcon class="h-4 w-4" />
+
+ <span class="sr-only">Toggle reasoning content</span>
+ </div>
+ </Collapsible.Trigger>
+
+ <Collapsible.Content>
+ <div class="border-t border-muted px-3 pb-3">
+ <div class="pt-3">
+ <div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
+ {reasoningContent ?? ''}
+ </div>
+ </div>
+ </div>
+ </Collapsible.Content>
+ </Card>
+</Collapsible.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte
new file mode 100644
index 0000000..041c6bd
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte
@@ -0,0 +1,163 @@
+<script lang="ts">
+ import { Card } from '$lib/components/ui/card';
+ import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
+ import { config } from '$lib/stores/settings.svelte';
+ import ChatMessageActions from './ChatMessageActions.svelte';
+ import ChatMessageEditForm from './ChatMessageEditForm.svelte';
+
+ interface Props {
+ class?: string;
+ message: DatabaseMessage;
+ isEditing: boolean;
+ editedContent: string;
+ editedExtras?: DatabaseMessageExtra[];
+ editedUploadedFiles?: ChatUploadedFile[];
+ siblingInfo?: ChatMessageSiblingInfo | null;
+ showDeleteDialog: boolean;
+ deletionInfo: {
+ totalCount: number;
+ userMessages: number;
+ assistantMessages: number;
+ messageTypes: string[];
+ } | null;
+ onCancelEdit: () => void;
+ onSaveEdit: () => void;
+ onSaveEditOnly?: () => void;
+ onEditKeydown: (event: KeyboardEvent) => void;
+ onEditedContentChange: (content: string) => void;
+ onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
+ onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
+ onCopy: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+ onConfirmDelete: () => void;
+ onNavigateToSibling?: (siblingId: string) => void;
+ onShowDeleteDialogChange: (show: boolean) => void;
+ textareaElement?: HTMLTextAreaElement;
+ }
+
+ let {
+ class: className = '',
+ message,
+ isEditing,
+ editedContent,
+ editedExtras = [],
+ editedUploadedFiles = [],
+ siblingInfo = null,
+ showDeleteDialog,
+ deletionInfo,
+ onCancelEdit,
+ onSaveEdit,
+ onSaveEditOnly,
+ onEditKeydown,
+ onEditedContentChange,
+ onEditedExtrasChange,
+ onEditedUploadedFilesChange,
+ onCopy,
+ onEdit,
+ onDelete,
+ onConfirmDelete,
+ onNavigateToSibling,
+ onShowDeleteDialogChange,
+ textareaElement = $bindable()
+ }: Props = $props();
+
+ let isMultiline = $state(false);
+ let messageElement: HTMLElement | undefined = $state();
+ const currentConfig = config();
+
+ $effect(() => {
+ if (!messageElement || !message.content.trim()) return;
+
+ if (message.content.includes('\n')) {
+ isMultiline = true;
+ return;
+ }
+
+ const resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const element = entry.target as HTMLElement;
+ const estimatedSingleLineHeight = 24; // Typical line height for text-md
+
+ isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5;
+ }
+ });
+
+ resizeObserver.observe(messageElement);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ });
+</script>
+
+<div
+ aria-label="User message with actions"
+ class="group flex flex-col items-end gap-3 md:gap-2 {className}"
+ role="group"
+>
+ {#if isEditing}
+ <ChatMessageEditForm
+ bind:textareaElement
+ messageId={message.id}
+ {editedContent}
+ {editedExtras}
+ {editedUploadedFiles}
+ originalContent={message.content}
+ originalExtras={message.extra}
+ showSaveOnlyOption={!!onSaveEditOnly}
+ {onCancelEdit}
+ {onSaveEdit}
+ {onSaveEditOnly}
+ {onEditKeydown}
+ {onEditedContentChange}
+ {onEditedExtrasChange}
+ {onEditedUploadedFilesChange}
+ />
+ {:else}
+ {#if message.extra && message.extra.length > 0}
+ <div class="mb-2 max-w-[80%]">
+ <ChatAttachmentsList attachments={message.extra} readonly={true} imageHeight="h-80" />
+ </div>
+ {/if}
+
+ {#if message.content.trim()}
+ <Card
+ class="max-w-[80%] rounded-[1.125rem] border-none bg-primary px-3.75 py-1.5 text-primary-foreground data-[multiline]:py-2.5"
+ data-multiline={isMultiline ? '' : undefined}
+ >
+ {#if currentConfig.renderUserContentAsMarkdown}
+ <div bind:this={messageElement} class="text-md">
+ <MarkdownContent
+ class="markdown-user-content text-primary-foreground"
+ content={message.content}
+ />
+ </div>
+ {:else}
+ <span bind:this={messageElement} class="text-md whitespace-pre-wrap">
+ {message.content}
+ </span>
+ {/if}
+ </Card>
+ {/if}
+
+ {#if message.timestamp}
+ <div class="max-w-[80%]">
+ <ChatMessageActions
+ actionsPosition="right"
+ {deletionInfo}
+ justify="end"
+ {onConfirmDelete}
+ {onCopy}
+ {onDelete}
+ {onEdit}
+ {onNavigateToSibling}
+ {onShowDeleteDialogChange}
+ {siblingInfo}
+ {showDeleteDialog}
+ role="user"
+ />
+ </div>
+ {/if}
+ {/if}
+</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
new file mode 100644
index 0000000..c203f10
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
@@ -0,0 +1,143 @@
+<script lang="ts">
+ import { ChatMessage } from '$lib/components/app';
+ import { chatStore } from '$lib/stores/chat.svelte';
+ import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+ import { getMessageSiblings } from '$lib/utils';
+
+ interface Props {
+ class?: string;
+ messages?: DatabaseMessage[];
+ onUserAction?: () => void;
+ }
+
+ let { class: className, messages = [], onUserAction }: Props = $props();
+
+ let allConversationMessages = $state<DatabaseMessage[]>([]);
+ const currentConfig = config();
+
+ function refreshAllMessages() {
+ const conversation = activeConversation();
+
+ if (conversation) {
+ conversationsStore.getConversationMessages(conversation.id).then((messages) => {
+ allConversationMessages = messages;
+ });
+ } else {
+ allConversationMessages = [];
+ }
+ }
+
+ // Single effect that tracks both conversation and message changes
+ $effect(() => {
+ const conversation = activeConversation();
+
+ if (conversation) {
+ refreshAllMessages();
+ }
+ });
+
+ let displayMessages = $derived.by(() => {
+ if (!messages.length) {
+ return [];
+ }
+
+ // Filter out system messages if showSystemMessage is false
+ const filteredMessages = currentConfig.showSystemMessage
+ ? messages
+ : messages.filter((msg) => msg.type !== 'system');
+
+ return filteredMessages.map((message) => {
+ const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
+
+ return {
+ message,
+ siblingInfo: siblingInfo || {
+ message,
+ siblingIds: [message.id],
+ currentIndex: 0,
+ totalSiblings: 1
+ }
+ };
+ });
+ });
+
+ async function handleNavigateToSibling(siblingId: string) {
+ await conversationsStore.navigateToSibling(siblingId);
+ }
+
+ async function handleEditWithBranching(
+ message: DatabaseMessage,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ) {
+ onUserAction?.();
+
+ await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
+
+ refreshAllMessages();
+ }
+
+ async function handleEditWithReplacement(
+ message: DatabaseMessage,
+ newContent: string,
+ shouldBranch: boolean
+ ) {
+ onUserAction?.();
+
+ await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
+
+ refreshAllMessages();
+ }
+
+ async function handleRegenerateWithBranching(message: DatabaseMessage, modelOverride?: string) {
+ onUserAction?.();
+
+ await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
+
+ refreshAllMessages();
+ }
+
+ async function handleContinueAssistantMessage(message: DatabaseMessage) {
+ onUserAction?.();
+
+ await chatStore.continueAssistantMessage(message.id);
+
+ refreshAllMessages();
+ }
+
+ async function handleEditUserMessagePreserveResponses(
+ message: DatabaseMessage,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ) {
+ onUserAction?.();
+
+ await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
+
+ refreshAllMessages();
+ }
+
+ async function handleDeleteMessage(message: DatabaseMessage) {
+ await chatStore.deleteMessage(message.id);
+
+ refreshAllMessages();
+ }
+</script>
+
+<div class="flex h-full flex-col space-y-10 pt-16 md:pt-24 {className}" style="height: auto; ">
+ {#each displayMessages as { message, siblingInfo } (message.id)}
+ <ChatMessage
+ class="mx-auto w-full max-w-[48rem]"
+ {message}
+ {siblingInfo}
+ onDelete={handleDeleteMessage}
+ onNavigateToSibling={handleNavigateToSibling}
+ onEditWithBranching={handleEditWithBranching}
+ onEditWithReplacement={handleEditWithReplacement}
+ onEditUserMessagePreserveResponses={handleEditUserMessagePreserveResponses}
+ onRegenerateWithBranching={handleRegenerateWithBranching}
+ onContinueAssistantMessage={handleContinueAssistantMessage}
+ />
+ {/each}
+</div>
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 @@
+<script lang="ts">
+ import { afterNavigate } from '$app/navigation';
+ import {
+ ChatForm,
+ ChatScreenHeader,
+ ChatMessages,
+ ChatScreenProcessingInfo,
+ DialogEmptyFileAlert,
+ DialogChatError,
+ ServerLoadingSplash,
+ DialogConfirmation
+ } from '$lib/components/app';
+ import * as Alert from '$lib/components/ui/alert';
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import {
+ AUTO_SCROLL_AT_BOTTOM_THRESHOLD,
+ AUTO_SCROLL_INTERVAL,
+ INITIAL_SCROLL_DELAY
+ } from '$lib/constants/auto-scroll';
+ import {
+ chatStore,
+ errorDialog,
+ isLoading,
+ isEditing,
+ getAddFilesHandler
+ } from '$lib/stores/chat.svelte';
+ import {
+ conversationsStore,
+ activeMessages,
+ activeConversation
+ } from '$lib/stores/conversations.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+ import { serverLoading, serverError, serverStore, isRouterMode } from '$lib/stores/server.svelte';
+ import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
+ import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
+ import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
+ import { onMount } from 'svelte';
+ import { fade, fly, slide } from 'svelte/transition';
+ import { Trash2, AlertTriangle, RefreshCw } from '@lucide/svelte';
+ import ChatScreenDragOverlay from './ChatScreenDragOverlay.svelte';
+
+ let { showCenteredEmpty = false } = $props();
+
+ let disableAutoScroll = $derived(Boolean(config().disableAutoScroll));
+ let autoScrollEnabled = $state(true);
+ let chatScrollContainer: HTMLDivElement | undefined = $state();
+ let dragCounter = $state(0);
+ let isDragOver = $state(false);
+ let lastScrollTop = $state(0);
+ let scrollInterval: ReturnType<typeof setInterval> | undefined;
+ let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
+ let showFileErrorDialog = $state(false);
+ let uploadedFiles = $state<ChatUploadedFile[]>([]);
+ let userScrolledUp = $state(false);
+
+ let fileErrorData = $state<{
+ generallyUnsupported: File[];
+ modalityUnsupported: File[];
+ modalityReasons: Record<string, string>;
+ supportedTypes: string[];
+ }>({
+ generallyUnsupported: [],
+ modalityUnsupported: [],
+ modalityReasons: {},
+ supportedTypes: []
+ });
+
+ let showDeleteDialog = $state(false);
+
+ let showEmptyFileDialog = $state(false);
+
+ let emptyFileNames = $state<string[]>([]);
+
+ let isEmpty = $derived(
+ showCenteredEmpty && !activeConversation() && activeMessages().length === 0 && !isLoading()
+ );
+
+ let activeErrorDialog = $derived(errorDialog());
+ let isServerLoading = $derived(serverLoading());
+ let hasPropsError = $derived(!!serverError());
+
+ let isCurrentConversationLoading = $derived(isLoading());
+
+ let isRouter = $derived(isRouterMode());
+
+ let conversationModel = $derived(
+ chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
+ );
+
+ let activeModelId = $derived.by(() => {
+ const options = modelOptions();
+
+ if (!isRouter) {
+ return options.length > 0 ? options[0].model : null;
+ }
+
+ const selectedId = selectedModelId();
+ if (selectedId) {
+ const model = options.find((m) => m.id === selectedId);
+ if (model) return model.model;
+ }
+
+ if (conversationModel) {
+ const model = options.find((m) => m.model === conversationModel);
+ if (model) return model.model;
+ }
+
+ return null;
+ });
+
+ let modelPropsVersion = $state(0);
+
+ $effect(() => {
+ if (activeModelId) {
+ const cached = modelsStore.getModelProps(activeModelId);
+ if (!cached) {
+ modelsStore.fetchModelProps(activeModelId).then(() => {
+ modelPropsVersion++;
+ });
+ }
+ }
+ });
+
+ let hasAudioModality = $derived.by(() => {
+ if (activeModelId) {
+ void modelPropsVersion;
+ return modelsStore.modelSupportsAudio(activeModelId);
+ }
+
+ return false;
+ });
+
+ let hasVisionModality = $derived.by(() => {
+ if (activeModelId) {
+ void modelPropsVersion;
+
+ return modelsStore.modelSupportsVision(activeModelId);
+ }
+
+ return false;
+ });
+
+ async function handleDeleteConfirm() {
+ const conversation = activeConversation();
+
+ if (conversation) {
+ await conversationsStore.deleteConversation(conversation.id);
+ }
+
+ showDeleteDialog = false;
+ }
+
+ function handleDragEnter(event: DragEvent) {
+ event.preventDefault();
+
+ dragCounter++;
+
+ if (event.dataTransfer?.types.includes('Files')) {
+ isDragOver = true;
+ }
+ }
+
+ function handleDragLeave(event: DragEvent) {
+ event.preventDefault();
+
+ dragCounter--;
+
+ if (dragCounter === 0) {
+ isDragOver = false;
+ }
+ }
+
+ function handleErrorDialogOpenChange(open: boolean) {
+ if (!open) {
+ chatStore.dismissErrorDialog();
+ }
+ }
+
+ function handleDragOver(event: DragEvent) {
+ event.preventDefault();
+ }
+
+ function handleDrop(event: DragEvent) {
+ event.preventDefault();
+
+ isDragOver = false;
+ dragCounter = 0;
+
+ if (event.dataTransfer?.files) {
+ const files = Array.from(event.dataTransfer.files);
+
+ if (isEditing()) {
+ const handler = getAddFilesHandler();
+
+ if (handler) {
+ handler(files);
+ return;
+ }
+ }
+
+ processFiles(files);
+ }
+ }
+
+ function handleFileRemove(fileId: string) {
+ uploadedFiles = uploadedFiles.filter((f) => f.id !== fileId);
+ }
+
+ function handleFileUpload(files: File[]) {
+ processFiles(files);
+ }
+
+ function handleKeydown(event: KeyboardEvent) {
+ const isCtrlOrCmd = event.ctrlKey || event.metaKey;
+
+ if (isCtrlOrCmd && event.shiftKey && (event.key === 'd' || event.key === 'D')) {
+ event.preventDefault();
+ if (activeConversation()) {
+ showDeleteDialog = true;
+ }
+ }
+ }
+
+ function handleScroll() {
+ if (disableAutoScroll || !chatScrollContainer) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = chatScrollContainer;
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
+ const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
+
+ if (scrollTop < lastScrollTop && !isAtBottom) {
+ userScrolledUp = true;
+ autoScrollEnabled = false;
+ } else if (isAtBottom && userScrolledUp) {
+ userScrolledUp = false;
+ autoScrollEnabled = true;
+ }
+
+ if (scrollTimeout) {
+ clearTimeout(scrollTimeout);
+ }
+
+ scrollTimeout = setTimeout(() => {
+ if (isAtBottom) {
+ userScrolledUp = false;
+ autoScrollEnabled = true;
+ }
+ }, AUTO_SCROLL_INTERVAL);
+
+ lastScrollTop = scrollTop;
+ }
+
+ async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
+ const result = files
+ ? await parseFilesToMessageExtras(files, activeModelId ?? undefined)
+ : undefined;
+
+ if (result?.emptyFiles && result.emptyFiles.length > 0) {
+ emptyFileNames = result.emptyFiles;
+ showEmptyFileDialog = true;
+
+ if (files) {
+ const emptyFileNamesSet = new Set(result.emptyFiles);
+ uploadedFiles = uploadedFiles.filter((file) => !emptyFileNamesSet.has(file.name));
+ }
+ return false;
+ }
+
+ const extras = result?.extras;
+
+ // Enable autoscroll for user-initiated message sending
+ if (!disableAutoScroll) {
+ userScrolledUp = false;
+ autoScrollEnabled = true;
+ }
+ await chatStore.sendMessage(message, extras);
+ scrollChatToBottom();
+
+ return true;
+ }
+
+ async function processFiles(files: File[]) {
+ const generallySupported: File[] = [];
+ const generallyUnsupported: File[] = [];
+
+ for (const file of files) {
+ if (isFileTypeSupported(file.name, file.type)) {
+ generallySupported.push(file);
+ } else {
+ generallyUnsupported.push(file);
+ }
+ }
+
+ // Use model-specific capabilities for file validation
+ const capabilities = { hasVision: hasVisionModality, hasAudio: hasAudioModality };
+ const { supportedFiles, unsupportedFiles, modalityReasons } = filterFilesByModalities(
+ generallySupported,
+ capabilities
+ );
+
+ const allUnsupportedFiles = [...generallyUnsupported, ...unsupportedFiles];
+
+ if (allUnsupportedFiles.length > 0) {
+ const supportedTypes: string[] = ['text files', 'PDFs'];
+
+ if (hasVisionModality) supportedTypes.push('images');
+ if (hasAudioModality) supportedTypes.push('audio files');
+
+ fileErrorData = {
+ generallyUnsupported,
+ modalityUnsupported: unsupportedFiles,
+ modalityReasons,
+ supportedTypes
+ };
+ showFileErrorDialog = true;
+ }
+
+ if (supportedFiles.length > 0) {
+ const processed = await processFilesToChatUploaded(
+ supportedFiles,
+ activeModelId ?? undefined
+ );
+ uploadedFiles = [...uploadedFiles, ...processed];
+ }
+ }
+
+ function scrollChatToBottom(behavior: ScrollBehavior = 'smooth') {
+ if (disableAutoScroll) return;
+
+ chatScrollContainer?.scrollTo({
+ top: chatScrollContainer?.scrollHeight,
+ behavior
+ });
+ }
+
+ afterNavigate(() => {
+ if (!disableAutoScroll) {
+ setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
+ }
+ });
+
+ onMount(() => {
+ if (!disableAutoScroll) {
+ setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
+ }
+ });
+
+ $effect(() => {
+ if (disableAutoScroll) {
+ autoScrollEnabled = false;
+ if (scrollInterval) {
+ clearInterval(scrollInterval);
+ scrollInterval = undefined;
+ }
+ return;
+ }
+
+ if (isCurrentConversationLoading && autoScrollEnabled) {
+ scrollInterval = setInterval(scrollChatToBottom, AUTO_SCROLL_INTERVAL);
+ } else if (scrollInterval) {
+ clearInterval(scrollInterval);
+ scrollInterval = undefined;
+ }
+ });
+</script>
+
+{#if isDragOver}
+ <ChatScreenDragOverlay />
+{/if}
+
+<svelte:window onkeydown={handleKeydown} />
+
+<ChatScreenHeader />
+
+{#if !isEmpty}
+ <div
+ bind:this={chatScrollContainer}
+ aria-label="Chat interface with file drop zone"
+ class="flex h-full flex-col overflow-y-auto px-4 md:px-6"
+ ondragenter={handleDragEnter}
+ ondragleave={handleDragLeave}
+ ondragover={handleDragOver}
+ ondrop={handleDrop}
+ onscroll={handleScroll}
+ role="main"
+ >
+ <ChatMessages
+ class="mb-16 md:mb-24"
+ messages={activeMessages()}
+ onUserAction={() => {
+ if (!disableAutoScroll) {
+ userScrolledUp = false;
+ autoScrollEnabled = true;
+ scrollChatToBottom();
+ }
+ }}
+ />
+
+ <div
+ class="pointer-events-none sticky right-0 bottom-0 left-0 mt-auto"
+ in:slide={{ duration: 150, axis: 'y' }}
+ >
+ <ChatScreenProcessingInfo />
+
+ {#if hasPropsError}
+ <div
+ class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
+ in:fly={{ y: 10, duration: 250 }}
+ >
+ <Alert.Root variant="destructive">
+ <AlertTriangle class="h-4 w-4" />
+ <Alert.Title class="flex items-center justify-between">
+ <span>Server unavailable</span>
+ <button
+ onclick={() => serverStore.fetch()}
+ disabled={isServerLoading}
+ 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"
+ >
+ <RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
+ {isServerLoading ? 'Retrying...' : 'Retry'}
+ </button>
+ </Alert.Title>
+ <Alert.Description>{serverError()}</Alert.Description>
+ </Alert.Root>
+ </div>
+ {/if}
+
+ <div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
+ <ChatForm
+ disabled={hasPropsError || isEditing()}
+ isLoading={isCurrentConversationLoading}
+ onFileRemove={handleFileRemove}
+ onFileUpload={handleFileUpload}
+ onSend={handleSendMessage}
+ onStop={() => chatStore.stopGeneration()}
+ showHelperText={false}
+ bind:uploadedFiles
+ />
+ </div>
+ </div>
+ </div>
+{:else if isServerLoading}
+ <!-- Server Loading State -->
+ <ServerLoadingSplash />
+{:else}
+ <div
+ aria-label="Welcome screen with file drop zone"
+ class="flex h-full items-center justify-center"
+ ondragenter={handleDragEnter}
+ ondragleave={handleDragLeave}
+ ondragover={handleDragOver}
+ ondrop={handleDrop}
+ role="main"
+ >
+ <div class="w-full max-w-[48rem] px-4">
+ <div class="mb-10 text-center" in:fade={{ duration: 300 }}>
+ <h1 class="mb-4 text-3xl font-semibold tracking-tight">llama.cpp</h1>
+
+ <p class="text-lg text-muted-foreground">
+ {serverStore.props?.modalities?.audio
+ ? 'Record audio, type a message '
+ : 'Type a message'} or upload files to get started
+ </p>
+ </div>
+
+ {#if hasPropsError}
+ <div class="mb-4" in:fly={{ y: 10, duration: 250 }}>
+ <Alert.Root variant="destructive">
+ <AlertTriangle class="h-4 w-4" />
+ <Alert.Title class="flex items-center justify-between">
+ <span>Server unavailable</span>
+ <button
+ onclick={() => serverStore.fetch()}
+ disabled={isServerLoading}
+ 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"
+ >
+ <RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
+ {isServerLoading ? 'Retrying...' : 'Retry'}
+ </button>
+ </Alert.Title>
+ <Alert.Description>{serverError()}</Alert.Description>
+ </Alert.Root>
+ </div>
+ {/if}
+
+ <div in:fly={{ y: 10, duration: 250, delay: hasPropsError ? 0 : 300 }}>
+ <ChatForm
+ disabled={hasPropsError}
+ isLoading={isCurrentConversationLoading}
+ onFileRemove={handleFileRemove}
+ onFileUpload={handleFileUpload}
+ onSend={handleSendMessage}
+ onStop={() => chatStore.stopGeneration()}
+ showHelperText={true}
+ bind:uploadedFiles
+ />
+ </div>
+ </div>
+ </div>
+{/if}
+
+<!-- File Upload Error Alert Dialog -->
+<AlertDialog.Root bind:open={showFileErrorDialog}>
+ <AlertDialog.Portal>
+ <AlertDialog.Overlay />
+
+ <AlertDialog.Content class="flex max-w-md flex-col">
+ <AlertDialog.Header>
+ <AlertDialog.Title>File Upload Error</AlertDialog.Title>
+
+ <AlertDialog.Description class="text-sm text-muted-foreground">
+ Some files cannot be uploaded with the current model.
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+
+ <div class="!max-h-[50vh] min-h-0 flex-1 space-y-4 overflow-y-auto">
+ {#if fileErrorData.generallyUnsupported.length > 0}
+ <div class="space-y-2">
+ <h4 class="text-sm font-medium text-destructive">Unsupported File Types</h4>
+
+ <div class="space-y-1">
+ {#each fileErrorData.generallyUnsupported as file (file.name)}
+ <div class="rounded-md bg-destructive/10 px-3 py-2">
+ <p class="font-mono text-sm break-all text-destructive">
+ {file.name}
+ </p>
+
+ <p class="mt-1 text-xs text-muted-foreground">File type not supported</p>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ {#if fileErrorData.modalityUnsupported.length > 0}
+ <div class="space-y-2">
+ <div class="space-y-1">
+ {#each fileErrorData.modalityUnsupported as file (file.name)}
+ <div class="rounded-md bg-destructive/10 px-3 py-2">
+ <p class="font-mono text-sm break-all text-destructive">
+ {file.name}
+ </p>
+
+ <p class="mt-1 text-xs text-muted-foreground">
+ {fileErrorData.modalityReasons[file.name] || 'Not supported by current model'}
+ </p>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+
+ <div class="rounded-md bg-muted/50 p-3">
+ <h4 class="mb-2 text-sm font-medium">This model supports:</h4>
+
+ <p class="text-sm text-muted-foreground">
+ {fileErrorData.supportedTypes.join(', ')}
+ </p>
+ </div>
+
+ <AlertDialog.Footer>
+ <AlertDialog.Action onclick={() => (showFileErrorDialog = false)}>
+ Got it
+ </AlertDialog.Action>
+ </AlertDialog.Footer>
+ </AlertDialog.Content>
+ </AlertDialog.Portal>
+</AlertDialog.Root>
+
+<DialogConfirmation
+ bind:open={showDeleteDialog}
+ title="Delete Conversation"
+ description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
+ confirmText="Delete"
+ cancelText="Cancel"
+ variant="destructive"
+ icon={Trash2}
+ onConfirm={handleDeleteConfirm}
+ onCancel={() => (showDeleteDialog = false)}
+/>
+
+<DialogEmptyFileAlert
+ bind:open={showEmptyFileDialog}
+ emptyFiles={emptyFileNames}
+ onOpenChange={(open) => {
+ if (!open) {
+ emptyFileNames = [];
+ }
+ }}
+/>
+
+<DialogChatError
+ message={activeErrorDialog?.message ?? ''}
+ contextInfo={activeErrorDialog?.contextInfo}
+ onOpenChange={handleErrorDialogOpenChange}
+ open={Boolean(activeErrorDialog)}
+ type={activeErrorDialog?.type ?? 'server'}
+/>
+
+<style>
+ .conversation-chat-form {
+ position: relative;
+
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ z-index: -1;
+ left: 0;
+ right: 0;
+ width: 100%;
+ height: 2.375rem;
+ background-color: var(--background);
+ }
+ }
+</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 @@
+<script>
+ import { Upload } from '@lucide/svelte';
+</script>
+
+<div
+ class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
+>
+ <div
+ class="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-border bg-background p-12 shadow-lg"
+ >
+ <Upload class="mb-4 h-12 w-12 text-muted-foreground" />
+
+ <p class="text-lg font-medium text-foreground">Attach a file</p>
+
+ <p class="text-sm text-muted-foreground">Drop your files here to upload</p>
+ </div>
+</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 @@
+<script lang="ts">
+ import { Settings } from '@lucide/svelte';
+ import { DialogChatSettings } from '$lib/components/app';
+ import { Button } from '$lib/components/ui/button';
+ import { useSidebar } from '$lib/components/ui/sidebar';
+
+ const sidebar = useSidebar();
+
+ let settingsOpen = $state(false);
+
+ function toggleSettings() {
+ settingsOpen = true;
+ }
+</script>
+
+<header
+ 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
+ ? 'md:left-[var(--sidebar-width)]'
+ : ''}"
+>
+ <div class="pointer-events-auto flex items-center space-x-2">
+ <Button variant="ghost" size="sm" onclick={toggleSettings}>
+ <Settings class="h-4 w-4" />
+ </Button>
+ </div>
+</header>
+
+<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 @@
+<script lang="ts">
+ import { untrack } from 'svelte';
+ import { PROCESSING_INFO_TIMEOUT } from '$lib/constants/processing-info';
+ import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
+ import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
+ import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+
+ const processingState = useProcessingState();
+
+ let isCurrentConversationLoading = $derived(isLoading());
+ let isStreaming = $derived(isChatStreaming());
+ let hasProcessingData = $derived(processingState.processingState !== null);
+ let processingDetails = $derived(processingState.getProcessingDetails());
+
+ let showProcessingInfo = $derived(
+ isCurrentConversationLoading || isStreaming || config().keepStatsVisible || hasProcessingData
+ );
+
+ $effect(() => {
+ const conversation = activeConversation();
+
+ untrack(() => chatStore.setActiveProcessingConversation(conversation?.id ?? null));
+ });
+
+ $effect(() => {
+ const keepStatsVisible = config().keepStatsVisible;
+ const shouldMonitor = keepStatsVisible || isCurrentConversationLoading || isStreaming;
+
+ if (shouldMonitor) {
+ processingState.startMonitoring();
+ }
+
+ if (!isCurrentConversationLoading && !isStreaming && !keepStatsVisible) {
+ const timeout = setTimeout(() => {
+ if (!config().keepStatsVisible && !isChatStreaming()) {
+ processingState.stopMonitoring();
+ }
+ }, PROCESSING_INFO_TIMEOUT);
+
+ return () => clearTimeout(timeout);
+ }
+ });
+
+ $effect(() => {
+ const conversation = activeConversation();
+ const messages = activeMessages() as DatabaseMessage[];
+ const keepStatsVisible = config().keepStatsVisible;
+
+ if (keepStatsVisible && conversation) {
+ if (messages.length === 0) {
+ untrack(() => chatStore.clearProcessingState(conversation.id));
+ return;
+ }
+
+ if (!isCurrentConversationLoading && !isStreaming) {
+ untrack(() => chatStore.restoreProcessingStateFromMessages(messages, conversation.id));
+ }
+ }
+ });
+</script>
+
+<div class="chat-processing-info-container pointer-events-none" class:visible={showProcessingInfo}>
+ <div class="chat-processing-info-content">
+ {#each processingDetails as detail (detail)}
+ <span class="chat-processing-info-detail pointer-events-auto">{detail}</span>
+ {/each}
+ </div>
+</div>
+
+<style>
+ .chat-processing-info-container {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ padding: 1.5rem 1rem;
+ opacity: 0;
+ transform: translateY(50%);
+ transition:
+ opacity 300ms ease-out,
+ transform 300ms ease-out;
+ }
+
+ .chat-processing-info-container.visible {
+ opacity: 1;
+ transform: translateY(0);
+ }
+
+ .chat-processing-info-content {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 1rem;
+ justify-content: center;
+ max-width: 48rem;
+ margin: 0 auto;
+ }
+
+ .chat-processing-info-detail {
+ color: var(--muted-foreground);
+ font-size: 0.75rem;
+ padding: 0.25rem 0.75rem;
+ background: var(--muted);
+ border-radius: 0.375rem;
+ font-family:
+ ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
+ white-space: nowrap;
+ }
+
+ @media (max-width: 768px) {
+ .chat-processing-info-content {
+ gap: 0.5rem;
+ }
+
+ .chat-processing-info-detail {
+ font-size: 0.7rem;
+ padding: 0.2rem 0.5rem;
+ }
+ }
+</style>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte
new file mode 100644
index 0000000..5a668aa
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte
@@ -0,0 +1,508 @@
+<script lang="ts">
+ import {
+ Settings,
+ Funnel,
+ AlertTriangle,
+ Code,
+ Monitor,
+ Sun,
+ Moon,
+ ChevronLeft,
+ ChevronRight,
+ Database
+ } from '@lucide/svelte';
+ import {
+ ChatSettingsFooter,
+ ChatSettingsImportExportTab,
+ ChatSettingsFields
+ } from '$lib/components/app';
+ import { ScrollArea } from '$lib/components/ui/scroll-area';
+ import { config, settingsStore } from '$lib/stores/settings.svelte';
+ import { setMode } from 'mode-watcher';
+ import type { Component } from 'svelte';
+
+ interface Props {
+ onSave?: () => void;
+ }
+
+ let { onSave }: Props = $props();
+
+ const settingSections: Array<{
+ fields: SettingsFieldConfig[];
+ icon: Component;
+ title: string;
+ }> = [
+ {
+ title: 'General',
+ icon: Settings,
+ fields: [
+ {
+ key: 'theme',
+ label: 'Theme',
+ type: 'select',
+ options: [
+ { value: 'system', label: 'System', icon: Monitor },
+ { value: 'light', label: 'Light', icon: Sun },
+ { value: 'dark', label: 'Dark', icon: Moon }
+ ]
+ },
+ { key: 'apiKey', label: 'API Key', type: 'input' },
+ {
+ key: 'systemMessage',
+ label: 'System Message',
+ type: 'textarea'
+ },
+ {
+ key: 'pasteLongTextToFileLen',
+ label: 'Paste long text to file length',
+ type: 'input'
+ },
+ {
+ key: 'copyTextAttachmentsAsPlainText',
+ label: 'Copy text attachments as plain text',
+ type: 'checkbox'
+ },
+ {
+ key: 'enableContinueGeneration',
+ label: 'Enable "Continue" button',
+ type: 'checkbox',
+ isExperimental: true
+ },
+ {
+ key: 'pdfAsImage',
+ label: 'Parse PDF as image',
+ type: 'checkbox'
+ },
+ {
+ key: 'askForTitleConfirmation',
+ label: 'Ask for confirmation before changing conversation title',
+ type: 'checkbox'
+ }
+ ]
+ },
+ {
+ title: 'Display',
+ icon: Monitor,
+ fields: [
+ {
+ key: 'showMessageStats',
+ label: 'Show message generation statistics',
+ type: 'checkbox'
+ },
+ {
+ key: 'showThoughtInProgress',
+ label: 'Show thought in progress',
+ type: 'checkbox'
+ },
+ {
+ key: 'keepStatsVisible',
+ label: 'Keep stats visible after generation',
+ type: 'checkbox'
+ },
+ {
+ key: 'autoMicOnEmpty',
+ label: 'Show microphone on empty input',
+ type: 'checkbox',
+ isExperimental: true
+ },
+ {
+ key: 'renderUserContentAsMarkdown',
+ label: 'Render user content as Markdown',
+ type: 'checkbox'
+ },
+ {
+ key: 'disableAutoScroll',
+ label: 'Disable automatic scroll',
+ type: 'checkbox'
+ },
+ {
+ key: 'alwaysShowSidebarOnDesktop',
+ label: 'Always show sidebar on desktop',
+ type: 'checkbox'
+ },
+ {
+ key: 'autoShowSidebarOnNewChat',
+ label: 'Auto-show sidebar on new chat',
+ type: 'checkbox'
+ }
+ ]
+ },
+ {
+ title: 'Sampling',
+ icon: Funnel,
+ fields: [
+ {
+ key: 'temperature',
+ label: 'Temperature',
+ type: 'input'
+ },
+ {
+ key: 'dynatemp_range',
+ label: 'Dynamic temperature range',
+ type: 'input'
+ },
+ {
+ key: 'dynatemp_exponent',
+ label: 'Dynamic temperature exponent',
+ type: 'input'
+ },
+ {
+ key: 'top_k',
+ label: 'Top K',
+ type: 'input'
+ },
+ {
+ key: 'top_p',
+ label: 'Top P',
+ type: 'input'
+ },
+ {
+ key: 'min_p',
+ label: 'Min P',
+ type: 'input'
+ },
+ {
+ key: 'xtc_probability',
+ label: 'XTC probability',
+ type: 'input'
+ },
+ {
+ key: 'xtc_threshold',
+ label: 'XTC threshold',
+ type: 'input'
+ },
+ {
+ key: 'typ_p',
+ label: 'Typical P',
+ type: 'input'
+ },
+ {
+ key: 'max_tokens',
+ label: 'Max tokens',
+ type: 'input'
+ },
+ {
+ key: 'samplers',
+ label: 'Samplers',
+ type: 'input'
+ },
+ {
+ key: 'backend_sampling',
+ label: 'Backend sampling',
+ type: 'checkbox'
+ }
+ ]
+ },
+ {
+ title: 'Penalties',
+ icon: AlertTriangle,
+ fields: [
+ {
+ key: 'repeat_last_n',
+ label: 'Repeat last N',
+ type: 'input'
+ },
+ {
+ key: 'repeat_penalty',
+ label: 'Repeat penalty',
+ type: 'input'
+ },
+ {
+ key: 'presence_penalty',
+ label: 'Presence penalty',
+ type: 'input'
+ },
+ {
+ key: 'frequency_penalty',
+ label: 'Frequency penalty',
+ type: 'input'
+ },
+ {
+ key: 'dry_multiplier',
+ label: 'DRY multiplier',
+ type: 'input'
+ },
+ {
+ key: 'dry_base',
+ label: 'DRY base',
+ type: 'input'
+ },
+ {
+ key: 'dry_allowed_length',
+ label: 'DRY allowed length',
+ type: 'input'
+ },
+ {
+ key: 'dry_penalty_last_n',
+ label: 'DRY penalty last N',
+ type: 'input'
+ }
+ ]
+ },
+ {
+ title: 'Import/Export',
+ icon: Database,
+ fields: []
+ },
+ {
+ title: 'Developer',
+ icon: Code,
+ fields: [
+ {
+ key: 'showToolCalls',
+ label: 'Show tool call labels',
+ type: 'checkbox'
+ },
+ {
+ key: 'disableReasoningFormat',
+ label: 'Show raw LLM output',
+ type: 'checkbox'
+ },
+ {
+ key: 'custom',
+ label: 'Custom JSON',
+ type: 'textarea'
+ }
+ ]
+ }
+ // TODO: Experimental features section will be implemented after initial release
+ // This includes Python interpreter (Pyodide integration) and other experimental features
+ // {
+ // title: 'Experimental',
+ // icon: Beaker,
+ // fields: [
+ // {
+ // key: 'pyInterpreterEnabled',
+ // label: 'Enable Python interpreter',
+ // type: 'checkbox'
+ // }
+ // ]
+ // }
+ ];
+
+ let activeSection = $state('General');
+ let currentSection = $derived(
+ settingSections.find((section) => section.title === activeSection) || settingSections[0]
+ );
+ let localConfig: SettingsConfigType = $state({ ...config() });
+
+ let canScrollLeft = $state(false);
+ let canScrollRight = $state(false);
+ let scrollContainer: HTMLDivElement | undefined = $state();
+
+ function handleThemeChange(newTheme: string) {
+ localConfig.theme = newTheme;
+
+ setMode(newTheme as 'light' | 'dark' | 'system');
+ }
+
+ function handleConfigChange(key: string, value: string | boolean) {
+ localConfig[key] = value;
+ }
+
+ function handleReset() {
+ localConfig = { ...config() };
+
+ setMode(localConfig.theme as 'light' | 'dark' | 'system');
+ }
+
+ function handleSave() {
+ if (localConfig.custom && typeof localConfig.custom === 'string' && localConfig.custom.trim()) {
+ try {
+ JSON.parse(localConfig.custom);
+ } catch (error) {
+ alert('Invalid JSON in custom parameters. Please check the format and try again.');
+ console.error(error);
+ return;
+ }
+ }
+
+ // Convert numeric strings to numbers for numeric fields
+ const processedConfig = { ...localConfig };
+ const numericFields = [
+ 'temperature',
+ 'top_k',
+ 'top_p',
+ 'min_p',
+ 'max_tokens',
+ 'pasteLongTextToFileLen',
+ 'dynatemp_range',
+ 'dynatemp_exponent',
+ 'typ_p',
+ 'xtc_probability',
+ 'xtc_threshold',
+ 'repeat_last_n',
+ 'repeat_penalty',
+ 'presence_penalty',
+ 'frequency_penalty',
+ 'dry_multiplier',
+ 'dry_base',
+ 'dry_allowed_length',
+ 'dry_penalty_last_n'
+ ];
+
+ for (const field of numericFields) {
+ if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
+ const numValue = Number(processedConfig[field]);
+ if (!isNaN(numValue)) {
+ processedConfig[field] = numValue;
+ } else {
+ alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
+ return;
+ }
+ }
+ }
+
+ settingsStore.updateMultipleConfig(processedConfig);
+ onSave?.();
+ }
+
+ function scrollToCenter(element: HTMLElement) {
+ if (!scrollContainer) return;
+
+ const containerRect = scrollContainer.getBoundingClientRect();
+ const elementRect = element.getBoundingClientRect();
+
+ const elementCenter = elementRect.left + elementRect.width / 2;
+ const containerCenter = containerRect.left + containerRect.width / 2;
+ const scrollOffset = elementCenter - containerCenter;
+
+ scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
+ }
+
+ function scrollLeft() {
+ if (!scrollContainer) return;
+
+ scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
+ }
+
+ function scrollRight() {
+ if (!scrollContainer) return;
+
+ scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
+ }
+
+ function updateScrollButtons() {
+ if (!scrollContainer) return;
+
+ const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
+ canScrollLeft = scrollLeft > 0;
+ canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
+ }
+
+ export function reset() {
+ localConfig = { ...config() };
+
+ setTimeout(updateScrollButtons, 100);
+ }
+
+ $effect(() => {
+ if (scrollContainer) {
+ updateScrollButtons();
+ }
+ });
+</script>
+
+<div class="flex h-full flex-col overflow-hidden md:flex-row">
+ <!-- Desktop Sidebar -->
+ <div class="hidden w-64 border-r border-border/30 p-6 md:block">
+ <nav class="space-y-1 py-2">
+ {#each settingSections as section (section.title)}
+ <button
+ class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {activeSection ===
+ section.title
+ ? 'bg-accent text-accent-foreground'
+ : 'text-muted-foreground'}"
+ onclick={() => (activeSection = section.title)}
+ >
+ <section.icon class="h-4 w-4" />
+
+ <span class="ml-2">{section.title}</span>
+ </button>
+ {/each}
+ </nav>
+ </div>
+
+ <!-- Mobile Header with Horizontal Scrollable Menu -->
+ <div class="flex flex-col pt-6 md:hidden">
+ <div class="border-b border-border/30 py-4">
+ <!-- Horizontal Scrollable Category Menu with Navigation -->
+ <div class="relative flex items-center" style="scroll-padding: 1rem;">
+ <button
+ class="absolute left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollLeft
+ ? 'opacity-100'
+ : 'pointer-events-none opacity-0'}"
+ onclick={scrollLeft}
+ aria-label="Scroll left"
+ >
+ <ChevronLeft class="h-4 w-4" />
+ </button>
+
+ <div
+ class="scrollbar-hide overflow-x-auto py-2"
+ bind:this={scrollContainer}
+ onscroll={updateScrollButtons}
+ >
+ <div class="flex min-w-max gap-2">
+ {#each settingSections as section (section.title)}
+ <button
+ class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {activeSection ===
+ section.title
+ ? 'bg-accent text-accent-foreground'
+ : 'text-muted-foreground'}"
+ onclick={(e: MouseEvent) => {
+ activeSection = section.title;
+ scrollToCenter(e.currentTarget as HTMLElement);
+ }}
+ >
+ <section.icon class="h-4 w-4 flex-shrink-0" />
+ <span>{section.title}</span>
+ </button>
+ {/each}
+ </div>
+ </div>
+
+ <button
+ class="absolute right-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollRight
+ ? 'opacity-100'
+ : 'pointer-events-none opacity-0'}"
+ onclick={scrollRight}
+ aria-label="Scroll right"
+ >
+ <ChevronRight class="h-4 w-4" />
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
+ <div class="space-y-6 p-4 md:p-6">
+ <div class="grid">
+ <div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
+ <currentSection.icon class="h-5 w-5" />
+
+ <h3 class="text-lg font-semibold">{currentSection.title}</h3>
+ </div>
+
+ {#if currentSection.title === 'Import/Export'}
+ <ChatSettingsImportExportTab />
+ {:else}
+ <div class="space-y-6">
+ <ChatSettingsFields
+ fields={currentSection.fields}
+ {localConfig}
+ onConfigChange={handleConfigChange}
+ onThemeChange={handleThemeChange}
+ />
+ </div>
+ {/if}
+ </div>
+
+ <div class="mt-8 border-t pt-6">
+ <p class="text-xs text-muted-foreground">Settings are saved in browser's localStorage</p>
+ </div>
+ </div>
+ </ScrollArea>
+</div>
+
+<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte
new file mode 100644
index 0000000..a6f51f4
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte
@@ -0,0 +1,255 @@
+<script lang="ts">
+ import { RotateCcw, FlaskConical } from '@lucide/svelte';
+ import { Checkbox } from '$lib/components/ui/checkbox';
+ import { Input } from '$lib/components/ui/input';
+ import Label from '$lib/components/ui/label/label.svelte';
+ import * as Select from '$lib/components/ui/select';
+ import { Textarea } from '$lib/components/ui/textarea';
+ import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config';
+ import { settingsStore } from '$lib/stores/settings.svelte';
+ import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
+ import type { Component } from 'svelte';
+
+ interface Props {
+ fields: SettingsFieldConfig[];
+ localConfig: SettingsConfigType;
+ onConfigChange: (key: string, value: string | boolean) => void;
+ onThemeChange?: (theme: string) => void;
+ }
+
+ let { fields, localConfig, onConfigChange, onThemeChange }: Props = $props();
+
+ // Helper function to get parameter source info for syncable parameters
+ function getParameterSourceInfo(key: string) {
+ if (!settingsStore.canSyncParameter(key)) {
+ return null;
+ }
+
+ return settingsStore.getParameterInfo(key);
+ }
+</script>
+
+{#each fields as field (field.key)}
+ <div class="space-y-2">
+ {#if field.type === 'input'}
+ {@const paramInfo = getParameterSourceInfo(field.key)}
+ {@const currentValue = String(localConfig[field.key] ?? '')}
+ {@const propsDefault = paramInfo?.serverDefault}
+ {@const isCustomRealTime = (() => {
+ if (!paramInfo || propsDefault === undefined) return false;
+
+ // Apply same rounding logic for real-time comparison
+ const inputValue = currentValue;
+ const numericInput = parseFloat(inputValue);
+ const normalizedInput = !isNaN(numericInput)
+ ? Math.round(numericInput * 1000000) / 1000000
+ : inputValue;
+ const normalizedDefault =
+ typeof propsDefault === 'number'
+ ? Math.round(propsDefault * 1000000) / 1000000
+ : propsDefault;
+
+ return normalizedInput !== normalizedDefault;
+ })()}
+
+ <div class="flex items-center gap-2">
+ <Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
+ {field.label}
+
+ {#if field.isExperimental}
+ <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+ {/if}
+ </Label>
+ {#if isCustomRealTime}
+ <ChatSettingsParameterSourceIndicator />
+ {/if}
+ </div>
+
+ <div class="relative w-full md:max-w-md">
+ <Input
+ id={field.key}
+ value={currentValue}
+ oninput={(e) => {
+ // Update local config immediately for real-time badge feedback
+ onConfigChange(field.key, e.currentTarget.value);
+ }}
+ placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] ?? 'none'}`}
+ class="w-full {isCustomRealTime ? 'pr-8' : ''}"
+ />
+ {#if isCustomRealTime}
+ <button
+ type="button"
+ onclick={() => {
+ settingsStore.resetParameterToServerDefault(field.key);
+ // Trigger UI update by calling onConfigChange with the default value
+ const defaultValue = propsDefault ?? SETTING_CONFIG_DEFAULT[field.key];
+ onConfigChange(field.key, String(defaultValue));
+ }}
+ class="absolute top-1/2 right-2 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded transition-colors hover:bg-muted"
+ aria-label="Reset to default"
+ title="Reset to default"
+ >
+ <RotateCcw class="h-3 w-3" />
+ </button>
+ {/if}
+ </div>
+ {#if field.help || SETTING_CONFIG_INFO[field.key]}
+ <p class="mt-1 text-xs text-muted-foreground">
+ {@html field.help || SETTING_CONFIG_INFO[field.key]}
+ </p>
+ {/if}
+ {:else if field.type === 'textarea'}
+ <Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
+ {field.label}
+
+ {#if field.isExperimental}
+ <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+ {/if}
+ </Label>
+
+ <Textarea
+ id={field.key}
+ value={String(localConfig[field.key] ?? '')}
+ onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
+ placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] ?? 'none'}`}
+ class="min-h-[10rem] w-full md:max-w-2xl"
+ />
+
+ {#if field.help || SETTING_CONFIG_INFO[field.key]}
+ <p class="mt-1 text-xs text-muted-foreground">
+ {field.help || SETTING_CONFIG_INFO[field.key]}
+ </p>
+ {/if}
+
+ {#if field.key === 'systemMessage'}
+ <div class="mt-3 flex items-center gap-2">
+ <Checkbox
+ id="showSystemMessage"
+ checked={Boolean(localConfig.showSystemMessage ?? true)}
+ onCheckedChange={(checked) => onConfigChange('showSystemMessage', Boolean(checked))}
+ />
+
+ <Label for="showSystemMessage" class="cursor-pointer text-sm font-normal">
+ Show system message in conversations
+ </Label>
+ </div>
+ {/if}
+ {:else if field.type === 'select'}
+ {@const selectedOption = field.options?.find(
+ (opt: { value: string; label: string; icon?: Component }) =>
+ opt.value === localConfig[field.key]
+ )}
+ {@const paramInfo = getParameterSourceInfo(field.key)}
+ {@const currentValue = localConfig[field.key]}
+ {@const propsDefault = paramInfo?.serverDefault}
+ {@const isCustomRealTime = (() => {
+ if (!paramInfo || propsDefault === undefined) return false;
+
+ // For select fields, do direct comparison (no rounding needed)
+ return currentValue !== propsDefault;
+ })()}
+
+ <div class="flex items-center gap-2">
+ <Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
+ {field.label}
+
+ {#if field.isExperimental}
+ <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+ {/if}
+ </Label>
+ {#if isCustomRealTime}
+ <ChatSettingsParameterSourceIndicator />
+ {/if}
+ </div>
+
+ <Select.Root
+ type="single"
+ value={currentValue}
+ onValueChange={(value) => {
+ if (field.key === 'theme' && value && onThemeChange) {
+ onThemeChange(value);
+ } else {
+ onConfigChange(field.key, value);
+ }
+ }}
+ >
+ <div class="relative w-full md:w-auto md:max-w-md">
+ <Select.Trigger class="w-full">
+ <div class="flex items-center gap-2">
+ {#if selectedOption?.icon}
+ {@const IconComponent = selectedOption.icon}
+ <IconComponent class="h-4 w-4" />
+ {/if}
+
+ {selectedOption?.label || `Select ${field.label.toLowerCase()}`}
+ </div>
+ </Select.Trigger>
+ {#if isCustomRealTime}
+ <button
+ type="button"
+ onclick={() => {
+ settingsStore.resetParameterToServerDefault(field.key);
+ // Trigger UI update by calling onConfigChange with the default value
+ const defaultValue = propsDefault ?? SETTING_CONFIG_DEFAULT[field.key];
+ onConfigChange(field.key, String(defaultValue));
+ }}
+ class="absolute top-1/2 right-8 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded transition-colors hover:bg-muted"
+ aria-label="Reset to default"
+ title="Reset to default"
+ >
+ <RotateCcw class="h-3 w-3" />
+ </button>
+ {/if}
+ </div>
+ <Select.Content>
+ {#if field.options}
+ {#each field.options as option (option.value)}
+ <Select.Item value={option.value} label={option.label}>
+ <div class="flex items-center gap-2">
+ {#if option.icon}
+ {@const IconComponent = option.icon}
+ <IconComponent class="h-4 w-4" />
+ {/if}
+ {option.label}
+ </div>
+ </Select.Item>
+ {/each}
+ {/if}
+ </Select.Content>
+ </Select.Root>
+ {#if field.help || SETTING_CONFIG_INFO[field.key]}
+ <p class="mt-1 text-xs text-muted-foreground">
+ {field.help || SETTING_CONFIG_INFO[field.key]}
+ </p>
+ {/if}
+ {:else if field.type === 'checkbox'}
+ <div class="flex items-start space-x-3">
+ <Checkbox
+ id={field.key}
+ checked={Boolean(localConfig[field.key])}
+ onCheckedChange={(checked) => onConfigChange(field.key, checked)}
+ class="mt-1"
+ />
+
+ <div class="space-y-1">
+ <label
+ for={field.key}
+ class="flex cursor-pointer items-center gap-1.5 pt-1 pb-0.5 text-sm leading-none font-medium"
+ >
+ {field.label}
+
+ {#if field.isExperimental}
+ <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+ {/if}
+ </label>
+
+ {#if field.help || SETTING_CONFIG_INFO[field.key]}
+ <p class="text-xs text-muted-foreground">
+ {field.help || SETTING_CONFIG_INFO[field.key]}
+ </p>
+ {/if}
+ </div>
+ </div>
+ {/if}
+ </div>
+{/each}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFooter.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFooter.svelte
new file mode 100644
index 0000000..1f7eb4e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFooter.svelte
@@ -0,0 +1,59 @@
+<script lang="ts">
+ import { Button } from '$lib/components/ui/button';
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import { settingsStore } from '$lib/stores/settings.svelte';
+ import { RotateCcw } from '@lucide/svelte';
+
+ interface Props {
+ onReset?: () => void;
+ onSave?: () => void;
+ }
+
+ let { onReset, onSave }: Props = $props();
+
+ let showResetDialog = $state(false);
+
+ function handleResetClick() {
+ showResetDialog = true;
+ }
+
+ function handleConfirmReset() {
+ settingsStore.forceSyncWithServerDefaults();
+ onReset?.();
+
+ showResetDialog = false;
+ }
+
+ function handleSave() {
+ onSave?.();
+ }
+</script>
+
+<div class="flex justify-between border-t border-border/30 p-6">
+ <div class="flex gap-2">
+ <Button variant="outline" onclick={handleResetClick}>
+ <RotateCcw class="h-3 w-3" />
+
+ Reset to default
+ </Button>
+ </div>
+
+ <Button onclick={handleSave}>Save settings</Button>
+</div>
+
+<AlertDialog.Root bind:open={showResetDialog}>
+ <AlertDialog.Content>
+ <AlertDialog.Header>
+ <AlertDialog.Title>Reset Settings to Default</AlertDialog.Title>
+ <AlertDialog.Description>
+ Are you sure you want to reset all settings to their default values? This will reset all
+ parameters to the values provided by the server's /props endpoint and remove all your custom
+ configurations.
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+ <AlertDialog.Footer>
+ <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
+ <AlertDialog.Action onclick={handleConfirmReset}>Reset to Default</AlertDialog.Action>
+ </AlertDialog.Footer>
+ </AlertDialog.Content>
+</AlertDialog.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte
new file mode 100644
index 0000000..1c8b411
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte
@@ -0,0 +1,317 @@
+<script lang="ts">
+ import { Download, Upload, Trash2 } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import { DialogConversationSelection } from '$lib/components/app';
+ import { createMessageCountMap } from '$lib/utils';
+ import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
+ import { toast } from 'svelte-sonner';
+ import DialogConfirmation from '$lib/components/app/dialogs/DialogConfirmation.svelte';
+
+ let exportedConversations = $state<DatabaseConversation[]>([]);
+ let importedConversations = $state<DatabaseConversation[]>([]);
+ let showExportSummary = $state(false);
+ let showImportSummary = $state(false);
+
+ let showExportDialog = $state(false);
+ let showImportDialog = $state(false);
+ let availableConversations = $state<DatabaseConversation[]>([]);
+ let messageCountMap = $state<Map<string, number>>(new Map());
+ let fullImportData = $state<Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }>>(
+ []
+ );
+
+ // Delete functionality state
+ let showDeleteDialog = $state(false);
+
+ async function handleExportClick() {
+ try {
+ const allConversations = conversations();
+ if (allConversations.length === 0) {
+ toast.info('No conversations to export');
+ return;
+ }
+
+ const conversationsWithMessages = await Promise.all(
+ allConversations.map(async (conv: DatabaseConversation) => {
+ const messages = await conversationsStore.getConversationMessages(conv.id);
+ return { conv, messages };
+ })
+ );
+
+ messageCountMap = createMessageCountMap(conversationsWithMessages);
+ availableConversations = allConversations;
+ showExportDialog = true;
+ } catch (err) {
+ console.error('Failed to load conversations:', err);
+ alert('Failed to load conversations');
+ }
+ }
+
+ async function handleExportConfirm(selectedConversations: DatabaseConversation[]) {
+ try {
+ const allData: ExportedConversations = await Promise.all(
+ selectedConversations.map(async (conv) => {
+ const messages = await conversationsStore.getConversationMessages(conv.id);
+ return { conv: $state.snapshot(conv), messages: $state.snapshot(messages) };
+ })
+ );
+
+ const blob = new Blob([JSON.stringify(allData, null, 2)], {
+ type: 'application/json'
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+
+ a.href = url;
+ a.download = `conversations_${new Date().toISOString().split('T')[0]}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ exportedConversations = selectedConversations;
+ showExportSummary = true;
+ showImportSummary = false;
+ showExportDialog = false;
+ } catch (err) {
+ console.error('Export failed:', err);
+ alert('Failed to export conversations');
+ }
+ }
+
+ async function handleImportClick() {
+ try {
+ const input = document.createElement('input');
+
+ input.type = 'file';
+ input.accept = '.json';
+
+ input.onchange = async (e) => {
+ const file = (e.target as HTMLInputElement)?.files?.[0];
+ if (!file) return;
+
+ try {
+ const text = await file.text();
+ const parsedData = JSON.parse(text);
+ let importedData: ExportedConversations;
+
+ if (Array.isArray(parsedData)) {
+ importedData = parsedData;
+ } else if (
+ parsedData &&
+ typeof parsedData === 'object' &&
+ 'conv' in parsedData &&
+ 'messages' in parsedData
+ ) {
+ // Single conversation object
+ importedData = [parsedData];
+ } else {
+ throw new Error(
+ 'Invalid file format: expected array of conversations or single conversation object'
+ );
+ }
+
+ fullImportData = importedData;
+ availableConversations = importedData.map(
+ (item: { conv: DatabaseConversation; messages: DatabaseMessage[] }) => item.conv
+ );
+ messageCountMap = createMessageCountMap(importedData);
+ showImportDialog = true;
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Unknown error';
+
+ console.error('Failed to parse file:', err);
+ alert(`Failed to parse file: ${message}`);
+ }
+ };
+
+ input.click();
+ } catch (err) {
+ console.error('Import failed:', err);
+ alert('Failed to import conversations');
+ }
+ }
+
+ async function handleImportConfirm(selectedConversations: DatabaseConversation[]) {
+ try {
+ const selectedIds = new Set(selectedConversations.map((c) => c.id));
+ const selectedData = $state
+ .snapshot(fullImportData)
+ .filter((item) => selectedIds.has(item.conv.id));
+
+ await conversationsStore.importConversationsData(selectedData);
+
+ importedConversations = selectedConversations;
+ showImportSummary = true;
+ showExportSummary = false;
+ showImportDialog = false;
+ } catch (err) {
+ console.error('Import failed:', err);
+ alert('Failed to import conversations. Please check the file format.');
+ }
+ }
+
+ async function handleDeleteAllClick() {
+ try {
+ const allConversations = conversations();
+
+ if (allConversations.length === 0) {
+ toast.info('No conversations to delete');
+ return;
+ }
+
+ showDeleteDialog = true;
+ } catch (err) {
+ console.error('Failed to load conversations for deletion:', err);
+ toast.error('Failed to load conversations');
+ }
+ }
+
+ async function handleDeleteAllConfirm() {
+ try {
+ await conversationsStore.deleteAll();
+
+ showDeleteDialog = false;
+ } catch (err) {
+ console.error('Failed to delete conversations:', err);
+ }
+ }
+
+ function handleDeleteAllCancel() {
+ showDeleteDialog = false;
+ }
+</script>
+
+<div class="space-y-6">
+ <div class="space-y-4">
+ <div class="grid">
+ <h4 class="mb-2 text-sm font-medium">Export Conversations</h4>
+
+ <p class="mb-4 text-sm text-muted-foreground">
+ Download all your conversations as a JSON file. This includes all messages, attachments, and
+ conversation history.
+ </p>
+
+ <Button
+ class="w-full justify-start justify-self-start md:w-auto"
+ onclick={handleExportClick}
+ variant="outline"
+ >
+ <Download class="mr-2 h-4 w-4" />
+
+ Export conversations
+ </Button>
+
+ {#if showExportSummary && exportedConversations.length > 0}
+ <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
+ <h5 class="mb-2 text-sm font-medium">
+ Exported {exportedConversations.length} conversation{exportedConversations.length === 1
+ ? ''
+ : 's'}
+ </h5>
+
+ <ul class="space-y-1 text-sm text-muted-foreground">
+ {#each exportedConversations.slice(0, 10) as conv (conv.id)}
+ <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
+ {/each}
+
+ {#if exportedConversations.length > 10}
+ <li class="italic">
+ ... and {exportedConversations.length - 10} more
+ </li>
+ {/if}
+ </ul>
+ </div>
+ {/if}
+ </div>
+
+ <div class="grid border-t border-border/30 pt-4">
+ <h4 class="mb-2 text-sm font-medium">Import Conversations</h4>
+
+ <p class="mb-4 text-sm text-muted-foreground">
+ Import one or more conversations from a previously exported JSON file. This will merge with
+ your existing conversations.
+ </p>
+
+ <Button
+ class="w-full justify-start justify-self-start md:w-auto"
+ onclick={handleImportClick}
+ variant="outline"
+ >
+ <Upload class="mr-2 h-4 w-4" />
+ Import conversations
+ </Button>
+
+ {#if showImportSummary && importedConversations.length > 0}
+ <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
+ <h5 class="mb-2 text-sm font-medium">
+ Imported {importedConversations.length} conversation{importedConversations.length === 1
+ ? ''
+ : 's'}
+ </h5>
+
+ <ul class="space-y-1 text-sm text-muted-foreground">
+ {#each importedConversations.slice(0, 10) as conv (conv.id)}
+ <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
+ {/each}
+
+ {#if importedConversations.length > 10}
+ <li class="italic">
+ ... and {importedConversations.length - 10} more
+ </li>
+ {/if}
+ </ul>
+ </div>
+ {/if}
+ </div>
+
+ <div class="grid border-t border-border/30 pt-4">
+ <h4 class="mb-2 text-sm font-medium text-destructive">Delete All Conversations</h4>
+
+ <p class="mb-4 text-sm text-muted-foreground">
+ Permanently delete all conversations and their messages. This action cannot be undone.
+ Consider exporting your conversations first if you want to keep a backup.
+ </p>
+
+ <Button
+ class="text-destructive-foreground w-full justify-start justify-self-start bg-destructive hover:bg-destructive/80 md:w-auto"
+ onclick={handleDeleteAllClick}
+ variant="destructive"
+ >
+ <Trash2 class="mr-2 h-4 w-4" />
+
+ Delete all conversations
+ </Button>
+ </div>
+ </div>
+</div>
+
+<DialogConversationSelection
+ conversations={availableConversations}
+ {messageCountMap}
+ mode="export"
+ bind:open={showExportDialog}
+ onCancel={() => (showExportDialog = false)}
+ onConfirm={handleExportConfirm}
+/>
+
+<DialogConversationSelection
+ conversations={availableConversations}
+ {messageCountMap}
+ mode="import"
+ bind:open={showImportDialog}
+ onCancel={() => (showImportDialog = false)}
+ onConfirm={handleImportConfirm}
+/>
+
+<DialogConfirmation
+ bind:open={showDeleteDialog}
+ title="Delete all conversations"
+ description="Are you sure you want to delete all conversations? This action cannot be undone and will permanently remove all your conversations and messages."
+ confirmText="Delete All"
+ cancelText="Cancel"
+ variant="destructive"
+ icon={Trash2}
+ onConfirm={handleDeleteAllConfirm}
+ onCancel={handleDeleteAllCancel}
+/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte
new file mode 100644
index 0000000..b566985
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte
@@ -0,0 +1,18 @@
+<script lang="ts">
+ import { Wrench } from '@lucide/svelte';
+ import { Badge } from '$lib/components/ui/badge';
+
+ interface Props {
+ class?: string;
+ }
+
+ let { class: className = '' }: Props = $props();
+</script>
+
+<Badge
+ variant="secondary"
+ class="h-5 bg-orange-100 px-1.5 py-0.5 text-xs text-orange-800 dark:bg-orange-900 dark:text-orange-200 {className}"
+>
+ <Wrench class="mr-1 h-3 w-3" />
+ Custom
+</Badge>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte
new file mode 100644
index 0000000..aa0c27f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte
@@ -0,0 +1,211 @@
+<script lang="ts">
+ import { goto } from '$app/navigation';
+ import { page } from '$app/state';
+ import { Trash2 } from '@lucide/svelte';
+ import { ChatSidebarConversationItem, DialogConfirmation } from '$lib/components/app';
+ import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
+ import * as Sidebar from '$lib/components/ui/sidebar';
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import Input from '$lib/components/ui/input/input.svelte';
+ import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
+ import { chatStore } from '$lib/stores/chat.svelte';
+ import { getPreviewText } from '$lib/utils/text';
+ import ChatSidebarActions from './ChatSidebarActions.svelte';
+
+ const sidebar = Sidebar.useSidebar();
+
+ let currentChatId = $derived(page.params.id);
+ let isSearchModeActive = $state(false);
+ let searchQuery = $state('');
+ let showDeleteDialog = $state(false);
+ let showEditDialog = $state(false);
+ let selectedConversation = $state<DatabaseConversation | null>(null);
+ let editedName = $state('');
+ let selectedConversationNamePreview = $derived.by(() =>
+ selectedConversation ? getPreviewText(selectedConversation.name) : ''
+ );
+
+ let filteredConversations = $derived.by(() => {
+ if (searchQuery.trim().length > 0) {
+ return conversations().filter((conversation: { name: string }) =>
+ conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+ }
+
+ return conversations();
+ });
+
+ async function handleDeleteConversation(id: string) {
+ const conversation = conversations().find((conv) => conv.id === id);
+ if (conversation) {
+ selectedConversation = conversation;
+ showDeleteDialog = true;
+ }
+ }
+
+ async function handleEditConversation(id: string) {
+ const conversation = conversations().find((conv) => conv.id === id);
+ if (conversation) {
+ selectedConversation = conversation;
+ editedName = conversation.name;
+ showEditDialog = true;
+ }
+ }
+
+ function handleConfirmDelete() {
+ if (selectedConversation) {
+ showDeleteDialog = false;
+
+ setTimeout(() => {
+ conversationsStore.deleteConversation(selectedConversation.id);
+ selectedConversation = null;
+ }, 100); // Wait for animation to finish
+ }
+ }
+
+ function handleConfirmEdit() {
+ if (!editedName.trim() || !selectedConversation) return;
+
+ showEditDialog = false;
+
+ conversationsStore.updateConversationName(selectedConversation.id, editedName);
+ selectedConversation = null;
+ }
+
+ export function handleMobileSidebarItemClick() {
+ if (sidebar.isMobile) {
+ sidebar.toggle();
+ }
+ }
+
+ export function activateSearchMode() {
+ isSearchModeActive = true;
+ }
+
+ export function editActiveConversation() {
+ if (currentChatId) {
+ const activeConversation = filteredConversations.find((conv) => conv.id === currentChatId);
+
+ if (activeConversation) {
+ const event = new CustomEvent('edit-active-conversation', {
+ detail: { conversationId: currentChatId }
+ });
+ document.dispatchEvent(event);
+ }
+ }
+ }
+
+ async function selectConversation(id: string) {
+ if (isSearchModeActive) {
+ isSearchModeActive = false;
+ searchQuery = '';
+ }
+
+ await goto(`#/chat/${id}`);
+ }
+
+ function handleStopGeneration(id: string) {
+ chatStore.stopGenerationForChat(id);
+ }
+</script>
+
+<ScrollArea class="h-[100vh]">
+ <Sidebar.Header class=" top-0 z-10 gap-6 bg-sidebar/50 px-4 py-4 pb-2 backdrop-blur-lg md:sticky">
+ <a href="#/" onclick={handleMobileSidebarItemClick}>
+ <h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
+ </a>
+
+ <ChatSidebarActions {handleMobileSidebarItemClick} bind:isSearchModeActive bind:searchQuery />
+ </Sidebar.Header>
+
+ <Sidebar.Group class="mt-4 space-y-2 p-0 px-4">
+ {#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive}
+ <Sidebar.GroupLabel>
+ {isSearchModeActive ? 'Search results' : 'Conversations'}
+ </Sidebar.GroupLabel>
+ {/if}
+
+ <Sidebar.GroupContent>
+ <Sidebar.Menu>
+ {#each filteredConversations as conversation (conversation.id)}
+ <Sidebar.MenuItem class="mb-1">
+ <ChatSidebarConversationItem
+ conversation={{
+ id: conversation.id,
+ name: conversation.name,
+ lastModified: conversation.lastModified,
+ currNode: conversation.currNode
+ }}
+ {handleMobileSidebarItemClick}
+ isActive={currentChatId === conversation.id}
+ onSelect={selectConversation}
+ onEdit={handleEditConversation}
+ onDelete={handleDeleteConversation}
+ onStop={handleStopGeneration}
+ />
+ </Sidebar.MenuItem>
+ {/each}
+
+ {#if filteredConversations.length === 0}
+ <div class="px-2 py-4 text-center">
+ <p class="mb-4 p-4 text-sm text-muted-foreground">
+ {searchQuery.length > 0
+ ? 'No results found'
+ : isSearchModeActive
+ ? 'Start typing to see results'
+ : 'No conversations yet'}
+ </p>
+ </div>
+ {/if}
+ </Sidebar.Menu>
+ </Sidebar.GroupContent>
+ </Sidebar.Group>
+</ScrollArea>
+
+<DialogConfirmation
+ bind:open={showDeleteDialog}
+ title="Delete Conversation"
+ description={selectedConversation
+ ? `Are you sure you want to delete "${selectedConversationNamePreview}"? This action cannot be undone and will permanently remove all messages in this conversation.`
+ : ''}
+ confirmText="Delete"
+ cancelText="Cancel"
+ variant="destructive"
+ icon={Trash2}
+ onConfirm={handleConfirmDelete}
+ onCancel={() => {
+ showDeleteDialog = false;
+ selectedConversation = null;
+ }}
+/>
+
+<AlertDialog.Root bind:open={showEditDialog}>
+ <AlertDialog.Content>
+ <AlertDialog.Header>
+ <AlertDialog.Title>Edit Conversation Name</AlertDialog.Title>
+ <AlertDialog.Description>
+ <Input
+ class="mt-4 text-foreground"
+ onkeydown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleConfirmEdit();
+ }
+ }}
+ placeholder="Enter a new name"
+ type="text"
+ bind:value={editedName}
+ />
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+ <AlertDialog.Footer>
+ <AlertDialog.Cancel
+ onclick={() => {
+ showEditDialog = false;
+ selectedConversation = null;
+ }}>Cancel</AlertDialog.Cancel
+ >
+ <AlertDialog.Action onclick={handleConfirmEdit}>Save</AlertDialog.Action>
+ </AlertDialog.Footer>
+ </AlertDialog.Content>
+</AlertDialog.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte
new file mode 100644
index 0000000..30d1f9d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte
@@ -0,0 +1,81 @@
+<script lang="ts">
+ import { Search, SquarePen, X } from '@lucide/svelte';
+ import { KeyboardShortcutInfo } from '$lib/components/app';
+ import { Button } from '$lib/components/ui/button';
+ import { Input } from '$lib/components/ui/input';
+
+ interface Props {
+ handleMobileSidebarItemClick: () => void;
+ isSearchModeActive: boolean;
+ searchQuery: string;
+ }
+
+ let {
+ handleMobileSidebarItemClick,
+ isSearchModeActive = $bindable(),
+ searchQuery = $bindable()
+ }: Props = $props();
+
+ let searchInput: HTMLInputElement | null = $state(null);
+
+ function handleSearchModeDeactivate() {
+ isSearchModeActive = false;
+ searchQuery = '';
+ }
+
+ $effect(() => {
+ if (isSearchModeActive) {
+ searchInput?.focus();
+ }
+ });
+</script>
+
+<div class="space-y-0.5">
+ {#if isSearchModeActive}
+ <div class="relative">
+ <Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
+
+ <Input
+ bind:ref={searchInput}
+ bind:value={searchQuery}
+ onkeydown={(e) => e.key === 'Escape' && handleSearchModeDeactivate()}
+ placeholder="Search conversations..."
+ class="pl-8"
+ />
+
+ <X
+ class="cursor-pointertext-muted-foreground absolute top-2.5 right-2 h-4 w-4"
+ onclick={handleSearchModeDeactivate}
+ />
+ </div>
+ {:else}
+ <Button
+ class="w-full justify-between hover:[&>kbd]:opacity-100"
+ href="?new_chat=true#/"
+ onclick={handleMobileSidebarItemClick}
+ variant="ghost"
+ >
+ <div class="flex items-center gap-2">
+ <SquarePen class="h-4 w-4" />
+ New chat
+ </div>
+
+ <KeyboardShortcutInfo keys={['shift', 'cmd', 'o']} />
+ </Button>
+
+ <Button
+ class="w-full justify-between hover:[&>kbd]:opacity-100"
+ onclick={() => {
+ isSearchModeActive = true;
+ }}
+ variant="ghost"
+ >
+ <div class="flex items-center gap-2">
+ <Search class="h-4 w-4" />
+ Search conversations
+ </div>
+
+ <KeyboardShortcutInfo keys={['cmd', 'k']} />
+ </Button>
+ {/if}
+</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte
new file mode 100644
index 0000000..bf2fa4f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte
@@ -0,0 +1,200 @@
+<script lang="ts">
+ import { Trash2, Pencil, MoreHorizontal, Download, Loader2, Square } from '@lucide/svelte';
+ import { ActionDropdown } from '$lib/components/app';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { getAllLoadingChats } from '$lib/stores/chat.svelte';
+ import { conversationsStore } from '$lib/stores/conversations.svelte';
+ import { onMount } from 'svelte';
+
+ interface Props {
+ isActive?: boolean;
+ conversation: DatabaseConversation;
+ handleMobileSidebarItemClick?: () => void;
+ onDelete?: (id: string) => void;
+ onEdit?: (id: string) => void;
+ onSelect?: (id: string) => void;
+ onStop?: (id: string) => void;
+ }
+
+ let {
+ conversation,
+ handleMobileSidebarItemClick,
+ onDelete,
+ onEdit,
+ onSelect,
+ onStop,
+ isActive = false
+ }: Props = $props();
+
+ let renderActionsDropdown = $state(false);
+ let dropdownOpen = $state(false);
+
+ let isLoading = $derived(getAllLoadingChats().includes(conversation.id));
+
+ function handleEdit(event: Event) {
+ event.stopPropagation();
+ onEdit?.(conversation.id);
+ }
+
+ function handleDelete(event: Event) {
+ event.stopPropagation();
+ onDelete?.(conversation.id);
+ }
+
+ function handleStop(event: Event) {
+ event.stopPropagation();
+ onStop?.(conversation.id);
+ }
+
+ function handleGlobalEditEvent(event: Event) {
+ const customEvent = event as CustomEvent<{ conversationId: string }>;
+
+ if (customEvent.detail.conversationId === conversation.id && isActive) {
+ handleEdit(event);
+ }
+ }
+
+ function handleMouseLeave() {
+ if (!dropdownOpen) {
+ renderActionsDropdown = false;
+ }
+ }
+
+ function handleMouseOver() {
+ renderActionsDropdown = true;
+ }
+
+ function handleSelect() {
+ onSelect?.(conversation.id);
+ }
+
+ $effect(() => {
+ if (!dropdownOpen) {
+ renderActionsDropdown = false;
+ }
+ });
+
+ onMount(() => {
+ document.addEventListener('edit-active-conversation', handleGlobalEditEvent as EventListener);
+
+ return () => {
+ document.removeEventListener(
+ 'edit-active-conversation',
+ handleGlobalEditEvent as EventListener
+ );
+ };
+ });
+</script>
+
+<!-- svelte-ignore a11y_mouse_events_have_key_events -->
+<button
+ class="group flex min-h-9 w-full cursor-pointer items-center justify-between space-x-3 rounded-lg px-3 py-1.5 text-left transition-colors hover:bg-foreground/10 {isActive
+ ? 'bg-foreground/5 text-accent-foreground'
+ : ''}"
+ onclick={handleSelect}
+ onmouseover={handleMouseOver}
+ onmouseleave={handleMouseLeave}
+>
+ <div class="flex min-w-0 flex-1 items-center gap-2">
+ {#if isLoading}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <div
+ class="stop-button flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
+ onclick={handleStop}
+ onkeydown={(e) => e.key === 'Enter' && handleStop(e)}
+ role="button"
+ tabindex="0"
+ aria-label="Stop generation"
+ >
+ <Loader2 class="loading-icon h-3.5 w-3.5 animate-spin" />
+
+ <Square class="stop-icon hidden h-3 w-3 fill-current text-destructive" />
+ </div>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>Stop generation</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/if}
+
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <span class="truncate text-sm font-medium" onclick={handleMobileSidebarItemClick}>
+ {conversation.name}
+ </span>
+ </div>
+
+ {#if renderActionsDropdown}
+ <div class="actions flex items-center">
+ <ActionDropdown
+ triggerIcon={MoreHorizontal}
+ triggerTooltip="More actions"
+ bind:open={dropdownOpen}
+ actions={[
+ {
+ icon: Pencil,
+ label: 'Edit',
+ onclick: handleEdit,
+ shortcut: ['shift', 'cmd', 'e']
+ },
+ {
+ icon: Download,
+ label: 'Export',
+ onclick: (e) => {
+ e.stopPropagation();
+ conversationsStore.downloadConversation(conversation.id);
+ },
+ shortcut: ['shift', 'cmd', 's']
+ },
+ {
+ icon: Trash2,
+ label: 'Delete',
+ onclick: handleDelete,
+ variant: 'destructive',
+ shortcut: ['shift', 'cmd', 'd'],
+ separator: true
+ }
+ ]}
+ />
+ </div>
+ {/if}
+</button>
+
+<style>
+ button {
+ :global([data-slot='dropdown-menu-trigger']:not([data-state='open'])) {
+ opacity: 0;
+ }
+
+ &:is(:hover) :global([data-slot='dropdown-menu-trigger']) {
+ opacity: 1;
+ }
+ @media (max-width: 768px) {
+ :global([data-slot='dropdown-menu-trigger']) {
+ opacity: 1 !important;
+ }
+ }
+
+ .stop-button {
+ :global(.stop-icon) {
+ display: none;
+ }
+
+ :global(.loading-icon) {
+ display: block;
+ }
+ }
+
+ &:is(:hover) .stop-button {
+ :global(.stop-icon) {
+ display: block;
+ }
+
+ :global(.loading-icon) {
+ display: none;
+ }
+ }
+ }
+</style>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte
new file mode 100644
index 0000000..afc9847
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte
@@ -0,0 +1,19 @@
+<script lang="ts">
+ import { SearchInput } from '$lib/components/app';
+
+ interface Props {
+ value?: string;
+ placeholder?: string;
+ onInput?: (value: string) => void;
+ class?: string;
+ }
+
+ let {
+ value = $bindable(''),
+ placeholder = 'Search conversations...',
+ onInput,
+ class: className
+ }: Props = $props();
+</script>
+
+<SearchInput bind:value {placeholder} {onInput} class="mb-4 {className}" />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/handle-mobile-sidebar-item-click.ts b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/handle-mobile-sidebar-item-click.ts
new file mode 100644
index 0000000..4b9b876
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/handle-mobile-sidebar-item-click.ts
@@ -0,0 +1,9 @@
+import { useSidebar } from '$lib/components/ui/sidebar';
+
+const sidebar = useSidebar();
+
+export function handleMobileSidebarItemClick() {
+ if (sidebar.isMobile) {
+ sidebar.toggle();
+ }
+}