diff options
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components/app/chat')
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(); + } +} |
