diff options
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components')
200 files changed, 13123 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(); + } +} diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentPreview.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentPreview.svelte new file mode 100644 index 0000000..012ba00 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentPreview.svelte @@ -0,0 +1,67 @@ +<script lang="ts"> + import * as Dialog from '$lib/components/ui/dialog'; + import { ChatAttachmentPreview } from '$lib/components/app'; + import { formatFileSize } from '$lib/utils'; + + interface Props { + open: boolean; + onOpenChange?: (open: boolean) => void; + // Either an uploaded file or a stored attachment + uploadedFile?: ChatUploadedFile; + attachment?: DatabaseMessageExtra; + // For uploaded files + preview?: string; + name?: string; + size?: number; + textContent?: string; + // For vision modality check + activeModelId?: string; + } + + let { + open = $bindable(), + onOpenChange, + uploadedFile, + attachment, + preview, + name, + size, + textContent, + activeModelId + }: Props = $props(); + + let chatAttachmentPreviewRef: ChatAttachmentPreview | undefined = $state(); + + let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File'); + + let displaySize = $derived(uploadedFile?.size || size); + + $effect(() => { + if (open && chatAttachmentPreviewRef) { + chatAttachmentPreviewRef.reset(); + } + }); +</script> + +<Dialog.Root bind:open {onOpenChange}> + <Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden sm:w-auto sm:max-w-6xl"> + <Dialog.Header> + <Dialog.Title class="pr-8">{displayName}</Dialog.Title> + <Dialog.Description> + {#if displaySize} + {formatFileSize(displaySize)} + {/if} + </Dialog.Description> + </Dialog.Header> + + <ChatAttachmentPreview + bind:this={chatAttachmentPreviewRef} + {uploadedFile} + {attachment} + {preview} + name={displayName} + {textContent} + {activeModelId} + /> + </Dialog.Content> +</Dialog.Root> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte new file mode 100644 index 0000000..33ab0fe --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte @@ -0,0 +1,54 @@ +<script lang="ts"> + import * as Dialog from '$lib/components/ui/dialog'; + import { ChatAttachmentsViewAll } from '$lib/components/app'; + + interface Props { + open?: boolean; + uploadedFiles?: ChatUploadedFile[]; + attachments?: DatabaseMessageExtra[]; + readonly?: boolean; + onFileRemove?: (fileId: string) => void; + imageHeight?: string; + imageWidth?: string; + imageClass?: string; + activeModelId?: string; + } + + let { + open = $bindable(false), + uploadedFiles = [], + attachments = [], + readonly = false, + onFileRemove, + imageHeight = 'h-24', + imageWidth = 'w-auto', + imageClass = '', + activeModelId + }: Props = $props(); + + let totalCount = $derived(uploadedFiles.length + attachments.length); +</script> + +<Dialog.Root bind:open> + <Dialog.Portal> + <Dialog.Overlay /> + + <Dialog.Content class="flex !max-h-[90vh] !max-w-6xl flex-col"> + <Dialog.Header> + <Dialog.Title>All Attachments ({totalCount})</Dialog.Title> + <Dialog.Description>View and manage all attached files</Dialog.Description> + </Dialog.Header> + + <ChatAttachmentsViewAll + {uploadedFiles} + {attachments} + {readonly} + {onFileRemove} + {imageHeight} + {imageWidth} + {imageClass} + {activeModelId} + /> + </Dialog.Content> + </Dialog.Portal> +</Dialog.Root> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte new file mode 100644 index 0000000..b4340e8 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte @@ -0,0 +1,70 @@ +<script lang="ts"> + import * as AlertDialog from '$lib/components/ui/alert-dialog'; + import { AlertTriangle, TimerOff } from '@lucide/svelte'; + + interface Props { + open: boolean; + type: 'timeout' | 'server'; + message: string; + contextInfo?: { n_prompt_tokens: number; n_ctx: number }; + onOpenChange?: (open: boolean) => void; + } + + let { open = $bindable(), type, message, contextInfo, onOpenChange }: Props = $props(); + + const isTimeout = $derived(type === 'timeout'); + const title = $derived(isTimeout ? 'TCP Timeout' : 'Server Error'); + const description = $derived( + isTimeout + ? 'The request did not receive a response from the server before timing out.' + : 'The server responded with an error message. Review the details below.' + ); + const iconClass = $derived(isTimeout ? 'text-destructive' : 'text-amber-500'); + const badgeClass = $derived( + isTimeout + ? 'border-destructive/40 bg-destructive/10 text-destructive' + : 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400' + ); + + function handleOpenChange(newOpen: boolean) { + open = newOpen; + onOpenChange?.(newOpen); + } +</script> + +<AlertDialog.Root {open} onOpenChange={handleOpenChange}> + <AlertDialog.Content> + <AlertDialog.Header> + <AlertDialog.Title class="flex items-center gap-2"> + {#if isTimeout} + <TimerOff class={`h-5 w-5 ${iconClass}`} /> + {:else} + <AlertTriangle class={`h-5 w-5 ${iconClass}`} /> + {/if} + + {title} + </AlertDialog.Title> + + <AlertDialog.Description> + {description} + </AlertDialog.Description> + </AlertDialog.Header> + + <div class={`rounded-lg border px-4 py-3 text-sm ${badgeClass}`}> + <p class="font-medium">{message}</p> + {#if contextInfo} + <div class="mt-2 space-y-1 text-xs opacity-80"> + <p> + <span class="font-medium">Prompt tokens:</span> + {contextInfo.n_prompt_tokens.toLocaleString()} + </p> + <p><span class="font-medium">Context size:</span> {contextInfo.n_ctx.toLocaleString()}</p> + </div> + {/if} + </div> + + <AlertDialog.Footer> + <AlertDialog.Action onclick={() => handleOpenChange(false)}>Close</AlertDialog.Action> + </AlertDialog.Footer> + </AlertDialog.Content> +</AlertDialog.Root> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte new file mode 100644 index 0000000..e9aaa10 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import * as Dialog from '$lib/components/ui/dialog'; + import { ChatSettings } from '$lib/components/app'; + + interface Props { + onOpenChange?: (open: boolean) => void; + open?: boolean; + } + + let { onOpenChange, open = false }: Props = $props(); + + let chatSettingsRef: ChatSettings | undefined = $state(); + + function handleClose() { + onOpenChange?.(false); + } + + function handleSave() { + onOpenChange?.(false); + } + + $effect(() => { + if (open && chatSettingsRef) { + chatSettingsRef.reset(); + } + }); +</script> + +<Dialog.Root {open} onOpenChange={handleClose}> + <Dialog.Content + class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0 + md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg" + style="max-width: 48rem;" + > + <ChatSettings bind:this={chatSettingsRef} onSave={handleSave} /> + </Dialog.Content> +</Dialog.Root> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte new file mode 100644 index 0000000..b5175a9 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte @@ -0,0 +1,72 @@ +<script lang="ts"> + import * as AlertDialog from '$lib/components/ui/alert-dialog'; + import type { Component } from 'svelte'; + + interface Props { + open: boolean; + title: string; + description: string; + confirmText?: string; + cancelText?: string; + variant?: 'default' | 'destructive'; + icon?: Component; + onConfirm: () => void; + onCancel: () => void; + onKeydown?: (event: KeyboardEvent) => void; + } + + let { + open = $bindable(), + title, + description, + confirmText = 'Confirm', + cancelText = 'Cancel', + variant = 'default', + icon, + onConfirm, + onCancel, + onKeydown + }: Props = $props(); + + function handleKeydown(event: KeyboardEvent) { + if (event.key === 'Enter') { + event.preventDefault(); + onConfirm(); + } + onKeydown?.(event); + } + + function handleOpenChange(newOpen: boolean) { + if (!newOpen) { + onCancel(); + } + } +</script> + +<AlertDialog.Root {open} onOpenChange={handleOpenChange}> + <AlertDialog.Content onkeydown={handleKeydown}> + <AlertDialog.Header> + <AlertDialog.Title class="flex items-center gap-2"> + {#if icon} + {@const IconComponent = icon} + <IconComponent class="h-5 w-5 {variant === 'destructive' ? 'text-destructive' : ''}" /> + {/if} + {title} + </AlertDialog.Title> + + <AlertDialog.Description> + {description} + </AlertDialog.Description> + </AlertDialog.Header> + + <AlertDialog.Footer> + <AlertDialog.Cancel onclick={onCancel}>{cancelText}</AlertDialog.Cancel> + <AlertDialog.Action + onclick={onConfirm} + class={variant === 'destructive' ? 'bg-destructive text-white hover:bg-destructive/80' : ''} + > + {confirmText} + </AlertDialog.Action> + </AlertDialog.Footer> + </AlertDialog.Content> +</AlertDialog.Root> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte new file mode 100644 index 0000000..1f8ea64 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte @@ -0,0 +1,68 @@ +<script lang="ts"> + import * as Dialog from '$lib/components/ui/dialog'; + import { ConversationSelection } from '$lib/components/app'; + + interface Props { + conversations: DatabaseConversation[]; + messageCountMap?: Map<string, number>; + mode: 'export' | 'import'; + onCancel: () => void; + onConfirm: (selectedConversations: DatabaseConversation[]) => void; + open?: boolean; + } + + let { + conversations, + messageCountMap = new Map(), + mode, + onCancel, + onConfirm, + open = $bindable(false) + }: Props = $props(); + + let conversationSelectionRef: ConversationSelection | undefined = $state(); + + let previousOpen = $state(false); + + $effect(() => { + if (open && !previousOpen && conversationSelectionRef) { + conversationSelectionRef.reset(); + } else if (!open && previousOpen) { + onCancel(); + } + + previousOpen = open; + }); +</script> + +<Dialog.Root bind:open> + <Dialog.Portal> + <Dialog.Overlay class="z-[1000000]" /> + + <Dialog.Content class="z-[1000001] max-w-2xl"> + <Dialog.Header> + <Dialog.Title> + Select Conversations to {mode === 'export' ? 'Export' : 'Import'} + </Dialog.Title> + <Dialog.Description> + {#if mode === 'export'} + Choose which conversations you want to export. Selected conversations will be downloaded + as a JSON file. + {:else} + Choose which conversations you want to import. Selected conversations will be merged + with your existing conversations. + {/if} + </Dialog.Description> + </Dialog.Header> + + <ConversationSelection + bind:this={conversationSelectionRef} + {conversations} + {messageCountMap} + {mode} + {onCancel} + {onConfirm} + /> + </Dialog.Content> + </Dialog.Portal> +</Dialog.Root> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte new file mode 100644 index 0000000..4a9ecce --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte @@ -0,0 +1,46 @@ +<script lang="ts"> + import * as AlertDialog from '$lib/components/ui/alert-dialog'; + import { Button } from '$lib/components/ui/button'; + + interface Props { + open: boolean; + currentTitle: string; + newTitle: string; + onConfirm: () => void; + onCancel: () => void; + } + + let { open = $bindable(), currentTitle, newTitle, onConfirm, onCancel }: Props = $props(); +</script> + +<AlertDialog.Root bind:open> + <AlertDialog.Content> + <AlertDialog.Header> + <AlertDialog.Title>Update Conversation Title?</AlertDialog.Title> + + <AlertDialog.Description> + Do you want to update the conversation title to match the first message content? + </AlertDialog.Description> + </AlertDialog.Header> + + <div class="space-y-4 pt-2 pb-6"> + <div class="space-y-2"> + <p class="text-sm font-medium text-muted-foreground">Current title:</p> + + <p class="rounded-md bg-muted/50 p-3 text-sm font-medium">{currentTitle}</p> + </div> + + <div class="space-y-2"> + <p class="text-sm font-medium text-muted-foreground">New title would be:</p> + + <p class="rounded-md bg-muted/50 p-3 text-sm font-medium">{newTitle}</p> + </div> + </div> + + <AlertDialog.Footer> + <Button variant="outline" onclick={onCancel}>Keep Current Title</Button> + + <Button onclick={onConfirm}>Update Title</Button> + </AlertDialog.Footer> + </AlertDialog.Content> +</AlertDialog.Root> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte new file mode 100644 index 0000000..f875b0a --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte @@ -0,0 +1,61 @@ +<script lang="ts"> + import * as AlertDialog from '$lib/components/ui/alert-dialog'; + import { FileX } from '@lucide/svelte'; + + interface Props { + open: boolean; + emptyFiles: string[]; + onOpenChange?: (open: boolean) => void; + } + + let { open = $bindable(), emptyFiles, onOpenChange }: Props = $props(); + + function handleOpenChange(newOpen: boolean) { + open = newOpen; + onOpenChange?.(newOpen); + } +</script> + +<AlertDialog.Root {open} onOpenChange={handleOpenChange}> + <AlertDialog.Content> + <AlertDialog.Header> + <AlertDialog.Title class="flex items-center gap-2"> + <FileX class="h-5 w-5 text-destructive" /> + + Empty Files Detected + </AlertDialog.Title> + + <AlertDialog.Description> + The following files are empty and have been removed from your attachments: + </AlertDialog.Description> + </AlertDialog.Header> + + <div class="space-y-3 text-sm"> + <div class="rounded-lg bg-muted p-3"> + <div class="mb-2 font-medium">Empty Files:</div> + + <ul class="list-inside list-disc space-y-1 text-muted-foreground"> + {#each emptyFiles as fileName (fileName)} + <li class="font-mono text-sm">{fileName}</li> + {/each} + </ul> + </div> + + <div> + <div class="mb-2 font-medium">What happened:</div> + + <ul class="list-inside list-disc space-y-1 text-muted-foreground"> + <li>Empty files cannot be processed or sent to the AI model</li> + + <li>These files have been automatically removed from your attachments</li> + + <li>You can try uploading files with content instead</li> + </ul> + </div> + </div> + + <AlertDialog.Footer> + <AlertDialog.Action onclick={() => handleOpenChange(false)}>Got it</AlertDialog.Action> + </AlertDialog.Footer> + </AlertDialog.Content> +</AlertDialog.Root> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte new file mode 100644 index 0000000..dfea47c --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte @@ -0,0 +1,211 @@ +<script lang="ts"> + import * as Dialog from '$lib/components/ui/dialog'; + import * as Table from '$lib/components/ui/table'; + import { BadgeModality, CopyToClipboardIcon } from '$lib/components/app'; + import { serverStore } from '$lib/stores/server.svelte'; + import { modelsStore, modelOptions, modelsLoading } from '$lib/stores/models.svelte'; + import { formatFileSize, formatParameters, formatNumber } from '$lib/utils'; + + interface Props { + open?: boolean; + onOpenChange?: (open: boolean) => void; + } + + let { open = $bindable(), onOpenChange }: Props = $props(); + + let serverProps = $derived(serverStore.props); + let modelName = $derived(modelsStore.singleModelName); + let models = $derived(modelOptions()); + let isLoadingModels = $derived(modelsLoading()); + + // Get the first model for single-model mode display + let firstModel = $derived(models[0] ?? null); + + // Get modalities from modelStore using the model ID from the first model + let modalities = $derived.by(() => { + if (!firstModel?.id) return []; + return modelsStore.getModelModalitiesArray(firstModel.id); + }); + + // Ensure models are fetched when dialog opens + $effect(() => { + if (open && models.length === 0) { + modelsStore.fetch(); + } + }); +</script> + +<Dialog.Root bind:open {onOpenChange}> + <Dialog.Content class="@container z-9999 !max-w-[60rem] max-w-full"> + <style> + @container (max-width: 56rem) { + .resizable-text-container { + max-width: calc(100vw - var(--threshold)); + } + } + </style> + + <Dialog.Header> + <Dialog.Title>Model Information</Dialog.Title> + <Dialog.Description>Current model details and capabilities</Dialog.Description> + </Dialog.Header> + + <div class="space-y-6 py-4"> + {#if isLoadingModels} + <div class="flex items-center justify-center py-8"> + <div class="text-sm text-muted-foreground">Loading model information...</div> + </div> + {:else if firstModel} + {@const modelMeta = firstModel.meta} + + {#if serverProps} + <Table.Root> + <Table.Header> + <Table.Row> + <Table.Head class="w-[10rem]">Model</Table.Head> + + <Table.Head> + <div class="inline-flex items-center gap-2"> + <span + class="resizable-text-container min-w-0 flex-1 truncate" + style:--threshold="12rem" + > + {modelName} + </span> + + <CopyToClipboardIcon + text={modelName || ''} + canCopy={!!modelName} + ariaLabel="Copy model name to clipboard" + /> + </div> + </Table.Head> + </Table.Row> + </Table.Header> + <Table.Body> + <!-- Model Path --> + <Table.Row> + <Table.Cell class="h-10 align-middle font-medium">File Path</Table.Cell> + + <Table.Cell + class="inline-flex h-10 items-center gap-2 align-middle font-mono text-xs" + > + <span + class="resizable-text-container min-w-0 flex-1 truncate" + style:--threshold="14rem" + > + {serverProps.model_path} + </span> + + <CopyToClipboardIcon + text={serverProps.model_path} + ariaLabel="Copy model path to clipboard" + /> + </Table.Cell> + </Table.Row> + + <!-- Context Size --> + <Table.Row> + <Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell> + <Table.Cell + >{formatNumber(serverProps.default_generation_settings.n_ctx)} tokens</Table.Cell + > + </Table.Row> + + <!-- Training Context --> + {#if modelMeta?.n_ctx_train} + <Table.Row> + <Table.Cell class="h-10 align-middle font-medium">Training Context</Table.Cell> + <Table.Cell>{formatNumber(modelMeta.n_ctx_train)} tokens</Table.Cell> + </Table.Row> + {/if} + + <!-- Model Size --> + {#if modelMeta?.size} + <Table.Row> + <Table.Cell class="h-10 align-middle font-medium">Model Size</Table.Cell> + <Table.Cell>{formatFileSize(modelMeta.size)}</Table.Cell> + </Table.Row> + {/if} + + <!-- Parameters --> + {#if modelMeta?.n_params} + <Table.Row> + <Table.Cell class="h-10 align-middle font-medium">Parameters</Table.Cell> + <Table.Cell>{formatParameters(modelMeta.n_params)}</Table.Cell> + </Table.Row> + {/if} + + <!-- Embedding Size --> + {#if modelMeta?.n_embd} + <Table.Row> + <Table.Cell class="align-middle font-medium">Embedding Size</Table.Cell> + <Table.Cell>{formatNumber(modelMeta.n_embd)}</Table.Cell> + </Table.Row> + {/if} + + <!-- Vocabulary Size --> + {#if modelMeta?.n_vocab} + <Table.Row> + <Table.Cell class="align-middle font-medium">Vocabulary Size</Table.Cell> + <Table.Cell>{formatNumber(modelMeta.n_vocab)} tokens</Table.Cell> + </Table.Row> + {/if} + + <!-- Vocabulary Type --> + {#if modelMeta?.vocab_type} + <Table.Row> + <Table.Cell class="align-middle font-medium">Vocabulary Type</Table.Cell> + <Table.Cell class="align-middle capitalize">{modelMeta.vocab_type}</Table.Cell> + </Table.Row> + {/if} + + <!-- Total Slots --> + <Table.Row> + <Table.Cell class="align-middle font-medium">Parallel Slots</Table.Cell> + <Table.Cell>{serverProps.total_slots}</Table.Cell> + </Table.Row> + + <!-- Modalities --> + {#if modalities.length > 0} + <Table.Row> + <Table.Cell class="align-middle font-medium">Modalities</Table.Cell> + <Table.Cell> + <div class="flex flex-wrap gap-1"> + <BadgeModality {modalities} /> + </div> + </Table.Cell> + </Table.Row> + {/if} + + <!-- Build Info --> + <Table.Row> + <Table.Cell class="align-middle font-medium">Build Info</Table.Cell> + <Table.Cell class="align-middle font-mono text-xs" + >{serverProps.build_info}</Table.Cell + > + </Table.Row> + + <!-- Chat Template --> + {#if serverProps.chat_template} + <Table.Row> + <Table.Cell class="align-middle font-medium">Chat Template</Table.Cell> + <Table.Cell class="py-10"> + <div class="max-h-120 overflow-y-auto rounded-md bg-muted p-4"> + <pre + class="font-mono text-xs whitespace-pre-wrap">{serverProps.chat_template}</pre> + </div> + </Table.Cell> + </Table.Row> + {/if} + </Table.Body> + </Table.Root> + {/if} + {:else if !isLoadingModels} + <div class="flex items-center justify-center py-8"> + <div class="text-sm text-muted-foreground">No model information available</div> + </div> + {/if} + </div> + </Dialog.Content> +</Dialog.Root> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte new file mode 100644 index 0000000..a6c2029 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte @@ -0,0 +1,76 @@ +<script lang="ts"> + import * as AlertDialog from '$lib/components/ui/alert-dialog'; + import { AlertTriangle, ArrowRight } from '@lucide/svelte'; + import { goto } from '$app/navigation'; + import { page } from '$app/state'; + + interface Props { + open: boolean; + modelName: string; + availableModels?: string[]; + onOpenChange?: (open: boolean) => void; + } + + let { open = $bindable(), modelName, availableModels = [], onOpenChange }: Props = $props(); + + function handleOpenChange(newOpen: boolean) { + open = newOpen; + onOpenChange?.(newOpen); + } + + function handleSelectModel(model: string) { + // Build URL with selected model, preserving other params + const url = new URL(page.url); + url.searchParams.set('model', model); + + handleOpenChange(false); + goto(url.toString()); + } +</script> + +<AlertDialog.Root {open} onOpenChange={handleOpenChange}> + <AlertDialog.Content class="max-w-lg"> + <AlertDialog.Header> + <AlertDialog.Title class="flex items-center gap-2"> + <AlertTriangle class="h-5 w-5 text-amber-500" /> + Model Not Available + </AlertDialog.Title> + + <AlertDialog.Description> + The requested model could not be found. Select an available model to continue. + </AlertDialog.Description> + </AlertDialog.Header> + + <div class="space-y-3"> + <div class="rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm"> + <p class="font-medium text-amber-600 dark:text-amber-400"> + Requested: <code class="rounded bg-amber-500/20 px-1.5 py-0.5">{modelName}</code> + </p> + </div> + + {#if availableModels.length > 0} + <div class="text-sm"> + <p class="mb-2 font-medium text-muted-foreground">Select an available model:</p> + <div class="max-h-48 space-y-1 overflow-y-auto rounded-md border p-1"> + {#each availableModels as model (model)} + <button + type="button" + class="group flex w-full items-center justify-between gap-2 rounded-sm px-3 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground" + onclick={() => handleSelectModel(model)} + > + <span class="min-w-0 truncate font-mono text-xs">{model}</span> + <ArrowRight + class="h-4 w-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" + /> + </button> + {/each} + </div> + </div> + {/if} + </div> + + <AlertDialog.Footer> + <AlertDialog.Action onclick={() => handleOpenChange(false)}>Cancel</AlertDialog.Action> + </AlertDialog.Footer> + </AlertDialog.Content> +</AlertDialog.Root> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/index.ts b/llama.cpp/tools/server/webui/src/lib/components/app/index.ts new file mode 100644 index 0000000..8631d4f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/index.ts @@ -0,0 +1,75 @@ +// Chat + +export { default as ChatAttachmentPreview } from './chat/ChatAttachments/ChatAttachmentPreview.svelte'; +export { default as ChatAttachmentThumbnailFile } from './chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte'; +export { default as ChatAttachmentThumbnailImage } from './chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte'; +export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttachmentsList.svelte'; +export { default as ChatAttachmentsViewAll } from './chat/ChatAttachments/ChatAttachmentsViewAll.svelte'; + +export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte'; +export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte'; +export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte'; +export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte'; +export { default as ChatFormActionSubmit } from './chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte'; +export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte'; +export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte'; +export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte'; + +export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte'; +export { default as ChatMessageActions } from './chat/ChatMessages/ChatMessageActions.svelte'; +export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte'; +export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.svelte'; +export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte'; +export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte'; +export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte'; +export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte'; + +export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte'; +export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte'; +export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte'; + +export { default as ChatSettings } from './chat/ChatSettings/ChatSettings.svelte'; +export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte'; +export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte'; +export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte'; +export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte'; + +export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte'; +export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte'; +export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte'; + +// Dialogs + +export { default as DialogChatAttachmentPreview } from './dialogs/DialogChatAttachmentPreview.svelte'; +export { default as DialogChatAttachmentsViewAll } from './dialogs/DialogChatAttachmentsViewAll.svelte'; +export { default as DialogChatError } from './dialogs/DialogChatError.svelte'; +export { default as DialogChatSettings } from './dialogs/DialogChatSettings.svelte'; +export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svelte'; +export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte'; +export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte'; +export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.svelte'; +export { default as DialogModelInformation } from './dialogs/DialogModelInformation.svelte'; +export { default as DialogModelNotAvailable } from './dialogs/DialogModelNotAvailable.svelte'; + +// Miscellanous + +export { default as ActionButton } from './misc/ActionButton.svelte'; +export { default as ActionDropdown } from './misc/ActionDropdown.svelte'; +export { default as BadgeChatStatistic } from './misc/BadgeChatStatistic.svelte'; +export { default as BadgeInfo } from './misc/BadgeInfo.svelte'; +export { default as ModelBadge } from './models/ModelBadge.svelte'; +export { default as BadgeModality } from './misc/BadgeModality.svelte'; +export { default as ConversationSelection } from './misc/ConversationSelection.svelte'; +export { default as CopyToClipboardIcon } from './misc/CopyToClipboardIcon.svelte'; +export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte'; +export { default as MarkdownContent } from './misc/MarkdownContent.svelte'; +export { default as RemoveButton } from './misc/RemoveButton.svelte'; +export { default as SearchInput } from './misc/SearchInput.svelte'; +export { default as SyntaxHighlightedCode } from './misc/SyntaxHighlightedCode.svelte'; +export { default as ModelsSelector } from './models/ModelsSelector.svelte'; + +// Server + +export { default as ServerStatus } from './server/ServerStatus.svelte'; +export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte'; +export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte'; diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/ActionButton.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/ActionButton.svelte new file mode 100644 index 0000000..411a8b6 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/ActionButton.svelte @@ -0,0 +1,47 @@ +<script lang="ts"> + import { Button } from '$lib/components/ui/button'; + import * as Tooltip from '$lib/components/ui/tooltip'; + import type { Component } from 'svelte'; + + interface Props { + icon: Component; + tooltip: string; + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg' | 'icon'; + class?: string; + disabled?: boolean; + onclick: () => void; + 'aria-label'?: string; + } + + let { + icon, + tooltip, + variant = 'ghost', + size = 'sm', + class: className = '', + disabled = false, + onclick, + 'aria-label': ariaLabel + }: Props = $props(); +</script> + +<Tooltip.Root> + <Tooltip.Trigger> + <Button + {variant} + {size} + {disabled} + {onclick} + class="h-6 w-6 p-0 {className} flex" + aria-label={ariaLabel || tooltip} + > + {@const IconComponent = icon} + <IconComponent class="h-3 w-3" /> + </Button> + </Tooltip.Trigger> + + <Tooltip.Content> + <p>{tooltip}</p> + </Tooltip.Content> +</Tooltip.Root> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/ActionDropdown.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/ActionDropdown.svelte new file mode 100644 index 0000000..83d856d --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/ActionDropdown.svelte @@ -0,0 +1,86 @@ +<script lang="ts"> + import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; + import * as Tooltip from '$lib/components/ui/tooltip'; + import { KeyboardShortcutInfo } from '$lib/components/app'; + import type { Component } from 'svelte'; + + interface ActionItem { + icon: Component; + label: string; + onclick: (event: Event) => void; + variant?: 'default' | 'destructive'; + disabled?: boolean; + shortcut?: string[]; + separator?: boolean; + } + + interface Props { + triggerIcon: Component; + triggerTooltip?: string; + triggerClass?: string; + actions: ActionItem[]; + align?: 'start' | 'center' | 'end'; + open?: boolean; + } + + let { + triggerIcon, + triggerTooltip, + triggerClass = '', + actions, + align = 'end', + open = $bindable(false) + }: Props = $props(); +</script> + +<DropdownMenu.Root bind:open> + <DropdownMenu.Trigger + class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md p-0 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground {triggerClass}" + onclick={(e) => e.stopPropagation()} + > + {#if triggerTooltip} + <Tooltip.Root> + <Tooltip.Trigger> + {@render iconComponent(triggerIcon, 'h-3 w-3')} + <span class="sr-only">{triggerTooltip}</span> + </Tooltip.Trigger> + <Tooltip.Content> + <p>{triggerTooltip}</p> + </Tooltip.Content> + </Tooltip.Root> + {:else} + {@render iconComponent(triggerIcon, 'h-3 w-3')} + {/if} + </DropdownMenu.Trigger> + + <DropdownMenu.Content {align} class="z-[999999] w-48"> + {#each actions as action, index (action.label)} + {#if action.separator && index > 0} + <DropdownMenu.Separator /> + {/if} + + <DropdownMenu.Item + onclick={action.onclick} + variant={action.variant} + disabled={action.disabled} + class="flex items-center justify-between hover:[&>kbd]:opacity-100" + > + <div class="flex items-center gap-2"> + {@render iconComponent( + action.icon, + `h-4 w-4 ${action.variant === 'destructive' ? 'text-destructive' : ''}` + )} + {action.label} + </div> + + {#if action.shortcut} + <KeyboardShortcutInfo keys={action.shortcut} variant={action.variant} /> + {/if} + </DropdownMenu.Item> + {/each} + </DropdownMenu.Content> +</DropdownMenu.Root> + +{#snippet iconComponent(IconComponent: Component, className: string)} + <IconComponent class={className} /> +{/snippet} diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeChatStatistic.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeChatStatistic.svelte new file mode 100644 index 0000000..a2b28d2 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeChatStatistic.svelte @@ -0,0 +1,44 @@ +<script lang="ts"> + import { BadgeInfo } from '$lib/components/app'; + import * as Tooltip from '$lib/components/ui/tooltip'; + import { copyToClipboard } from '$lib/utils'; + import type { Component } from 'svelte'; + + interface Props { + class?: string; + icon: Component; + value: string | number; + tooltipLabel?: string; + } + + let { class: className = '', icon: Icon, value, tooltipLabel }: Props = $props(); + + function handleClick() { + void copyToClipboard(String(value)); + } +</script> + +{#if tooltipLabel} + <Tooltip.Root> + <Tooltip.Trigger> + <BadgeInfo class={className} onclick={handleClick}> + {#snippet icon()} + <Icon class="h-3 w-3" /> + {/snippet} + + {value} + </BadgeInfo> + </Tooltip.Trigger> + <Tooltip.Content> + <p>{tooltipLabel}</p> + </Tooltip.Content> + </Tooltip.Root> +{:else} + <BadgeInfo class={className} onclick={handleClick}> + {#snippet icon()} + <Icon class="h-3 w-3" /> + {/snippet} + + {value} + </BadgeInfo> +{/if} diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeInfo.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeInfo.svelte new file mode 100644 index 0000000..c70af6f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeInfo.svelte @@ -0,0 +1,27 @@ +<script lang="ts"> + import { cn } from '$lib/components/ui/utils'; + import type { Snippet } from 'svelte'; + + interface Props { + children: Snippet; + class?: string; + icon?: Snippet; + onclick?: () => void; + } + + let { children, class: className = '', icon, onclick }: Props = $props(); +</script> + +<button + class={cn( + 'inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75', + className + )} + {onclick} +> + {#if icon} + {@render icon()} + {/if} + + {@render children()} +</button> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeModality.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeModality.svelte new file mode 100644 index 0000000..a0d5e86 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeModality.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + import { ModelModality } from '$lib/enums'; + import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants/icons'; + import { cn } from '$lib/components/ui/utils'; + + type DisplayableModality = ModelModality.VISION | ModelModality.AUDIO; + + interface Props { + modalities: ModelModality[]; + class?: string; + } + + let { modalities, class: className = '' }: Props = $props(); + + // Filter to only modalities that have icons (VISION, AUDIO) + const displayableModalities = $derived( + modalities.filter( + (m): m is DisplayableModality => m === ModelModality.VISION || m === ModelModality.AUDIO + ) + ); +</script> + +{#each displayableModalities as modality, index (index)} + {@const IconComponent = MODALITY_ICONS[modality]} + {@const label = MODALITY_LABELS[modality]} + + <span + class={cn( + 'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium', + className + )} + > + {#if IconComponent} + <IconComponent class="h-3 w-3" /> + {/if} + + {label} + </span> +{/each} diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/CodePreviewDialog.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/CodePreviewDialog.svelte new file mode 100644 index 0000000..702519f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/CodePreviewDialog.svelte @@ -0,0 +1,93 @@ +<script lang="ts"> + import { Dialog as DialogPrimitive } from 'bits-ui'; + import XIcon from '@lucide/svelte/icons/x'; + + interface Props { + open: boolean; + code: string; + language: string; + onOpenChange?: (open: boolean) => void; + } + + let { open = $bindable(), code, language, onOpenChange }: Props = $props(); + + let iframeRef = $state<HTMLIFrameElement | null>(null); + + $effect(() => { + if (!iframeRef) return; + + if (open) { + iframeRef.srcdoc = code; + } else { + iframeRef.srcdoc = ''; + } + }); + + function handleOpenChange(nextOpen: boolean) { + open = nextOpen; + onOpenChange?.(nextOpen); + } +</script> + +<DialogPrimitive.Root {open} onOpenChange={handleOpenChange}> + <DialogPrimitive.Portal> + <DialogPrimitive.Overlay class="code-preview-overlay" /> + + <DialogPrimitive.Content class="code-preview-content"> + <iframe + bind:this={iframeRef} + title="Preview {language}" + sandbox="allow-scripts" + class="code-preview-iframe" + ></iframe> + + <DialogPrimitive.Close + class="code-preview-close absolute top-4 right-4 border-none bg-transparent text-white opacity-70 mix-blend-difference transition-opacity hover:opacity-100 focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-8" + aria-label="Close preview" + > + <XIcon /> + <span class="sr-only">Close preview</span> + </DialogPrimitive.Close> + </DialogPrimitive.Content> + </DialogPrimitive.Portal> +</DialogPrimitive.Root> + +<style lang="postcss"> + :global(.code-preview-overlay) { + position: fixed; + inset: 0; + background-color: transparent; + z-index: 100000; + } + + :global(.code-preview-content) { + position: fixed; + inset: 0; + top: 0 !important; + left: 0 !important; + width: 100dvw; + height: 100dvh; + margin: 0; + padding: 0; + border: none; + border-radius: 0; + background-color: transparent; + box-shadow: none; + display: block; + overflow: hidden; + transform: none !important; + z-index: 100001; + } + + :global(.code-preview-iframe) { + display: block; + width: 100dvw; + height: 100dvh; + border: 0; + } + + :global(.code-preview-close) { + position: absolute; + z-index: 100002; + } +</style> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte new file mode 100644 index 0000000..e2095e0 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte @@ -0,0 +1,205 @@ +<script lang="ts"> + import { Search, X } from '@lucide/svelte'; + import { Button } from '$lib/components/ui/button'; + import { Input } from '$lib/components/ui/input'; + import { Checkbox } from '$lib/components/ui/checkbox'; + import { ScrollArea } from '$lib/components/ui/scroll-area'; + import { SvelteSet } from 'svelte/reactivity'; + + interface Props { + conversations: DatabaseConversation[]; + messageCountMap?: Map<string, number>; + mode: 'export' | 'import'; + onCancel: () => void; + onConfirm: (selectedConversations: DatabaseConversation[]) => void; + } + + let { conversations, messageCountMap = new Map(), mode, onCancel, onConfirm }: Props = $props(); + + let searchQuery = $state(''); + let selectedIds = $state.raw<SvelteSet<string>>(new SvelteSet(conversations.map((c) => c.id))); + let lastClickedId = $state<string | null>(null); + + let filteredConversations = $derived( + conversations.filter((conv) => { + const name = conv.name || 'Untitled conversation'; + return name.toLowerCase().includes(searchQuery.toLowerCase()); + }) + ); + + let allSelected = $derived( + filteredConversations.length > 0 && + filteredConversations.every((conv) => selectedIds.has(conv.id)) + ); + + let someSelected = $derived( + filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected + ); + + function toggleConversation(id: string, shiftKey: boolean = false) { + const newSet = new SvelteSet(selectedIds); + + if (shiftKey && lastClickedId !== null) { + const lastIndex = filteredConversations.findIndex((c) => c.id === lastClickedId); + const currentIndex = filteredConversations.findIndex((c) => c.id === id); + + if (lastIndex !== -1 && currentIndex !== -1) { + const start = Math.min(lastIndex, currentIndex); + const end = Math.max(lastIndex, currentIndex); + + const shouldSelect = !newSet.has(id); + + for (let i = start; i <= end; i++) { + if (shouldSelect) { + newSet.add(filteredConversations[i].id); + } else { + newSet.delete(filteredConversations[i].id); + } + } + + selectedIds = newSet; + return; + } + } + + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + + selectedIds = newSet; + lastClickedId = id; + } + + function toggleAll() { + if (allSelected) { + const newSet = new SvelteSet(selectedIds); + + filteredConversations.forEach((conv) => newSet.delete(conv.id)); + selectedIds = newSet; + } else { + const newSet = new SvelteSet(selectedIds); + + filteredConversations.forEach((conv) => newSet.add(conv.id)); + selectedIds = newSet; + } + } + + function handleConfirm() { + const selected = conversations.filter((conv) => selectedIds.has(conv.id)); + onConfirm(selected); + } + + function handleCancel() { + selectedIds = new SvelteSet(conversations.map((c) => c.id)); + searchQuery = ''; + lastClickedId = null; + + onCancel(); + } + + export function reset() { + selectedIds = new SvelteSet(conversations.map((c) => c.id)); + searchQuery = ''; + lastClickedId = null; + } +</script> + +<div class="space-y-4"> + <div class="relative"> + <Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> + + <Input bind:value={searchQuery} placeholder="Search conversations..." class="pr-9 pl-9" /> + + {#if searchQuery} + <button + class="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground" + onclick={() => (searchQuery = '')} + type="button" + > + <X class="h-4 w-4" /> + </button> + {/if} + </div> + + <div class="flex items-center justify-between text-sm text-muted-foreground"> + <span> + {selectedIds.size} of {conversations.length} selected + {#if searchQuery} + ({filteredConversations.length} shown) + {/if} + </span> + </div> + + <div class="overflow-hidden rounded-md border"> + <ScrollArea class="h-[400px]"> + <table class="w-full"> + <thead class="sticky top-0 z-10 bg-muted"> + <tr class="border-b"> + <th class="w-12 p-3 text-left"> + <Checkbox + checked={allSelected} + indeterminate={someSelected} + onCheckedChange={toggleAll} + /> + </th> + + <th class="p-3 text-left text-sm font-medium">Conversation Name</th> + + <th class="w-32 p-3 text-left text-sm font-medium">Messages</th> + </tr> + </thead> + <tbody> + {#if filteredConversations.length === 0} + <tr> + <td colspan="3" class="p-8 text-center text-sm text-muted-foreground"> + {#if searchQuery} + No conversations found matching "{searchQuery}" + {:else} + No conversations available + {/if} + </td> + </tr> + {:else} + {#each filteredConversations as conv (conv.id)} + <tr + class="cursor-pointer border-b transition-colors hover:bg-muted/50" + onclick={(e) => toggleConversation(conv.id, e.shiftKey)} + > + <td class="p-3"> + <Checkbox + checked={selectedIds.has(conv.id)} + onclick={(e) => { + e.preventDefault(); + e.stopPropagation(); + toggleConversation(conv.id, e.shiftKey); + }} + /> + </td> + + <td class="p-3 text-sm"> + <div class="max-w-[17rem] truncate" title={conv.name || 'Untitled conversation'}> + {conv.name || 'Untitled conversation'} + </div> + </td> + + <td class="p-3 text-sm text-muted-foreground"> + {messageCountMap.get(conv.id) ?? 0} + </td> + </tr> + {/each} + {/if} + </tbody> + </table> + </ScrollArea> + </div> + + <div class="flex justify-end gap-2"> + <Button variant="outline" onclick={handleCancel}>Cancel</Button> + + <Button onclick={handleConfirm} disabled={selectedIds.size === 0}> + {mode === 'export' ? 'Export' : 'Import'} ({selectedIds.size}) + </Button> + </div> +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/CopyToClipboardIcon.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/CopyToClipboardIcon.svelte new file mode 100644 index 0000000..bf6cd4f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/CopyToClipboardIcon.svelte @@ -0,0 +1,18 @@ +<script lang="ts"> + import { Copy } from '@lucide/svelte'; + import { copyToClipboard } from '$lib/utils'; + + interface Props { + ariaLabel?: string; + canCopy?: boolean; + text: string; + } + + let { ariaLabel = 'Copy to clipboard', canCopy = true, text }: Props = $props(); +</script> + +<Copy + class="h-3 w-3 flex-shrink-0 cursor-{canCopy ? 'pointer' : 'not-allowed'}" + aria-label={ariaLabel} + onclick={() => canCopy && copyToClipboard(text)} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/KeyboardShortcutInfo.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/KeyboardShortcutInfo.svelte new file mode 100644 index 0000000..5b7522f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/KeyboardShortcutInfo.svelte @@ -0,0 +1,31 @@ +<script lang="ts"> + import { ArrowBigUp } from '@lucide/svelte'; + + interface Props { + keys: string[]; + variant?: 'default' | 'destructive'; + class?: string; + } + + let { keys, variant = 'default', class: className = '' }: Props = $props(); + + let baseClasses = + 'px-1 pointer-events-none inline-flex select-none items-center gap-0.5 font-sans text-md font-medium opacity-0 transition-opacity -my-1'; + let variantClasses = variant === 'destructive' ? 'text-destructive' : 'text-muted-foreground'; +</script> + +<kbd class="{baseClasses} {variantClasses} {className}"> + {#each keys as key, index (index)} + {#if key === 'shift'} + <ArrowBigUp class="h-1 w-1 {variant === 'destructive' ? 'text-destructive' : ''} -mr-1" /> + {:else if key === 'cmd'} + <span class={variant === 'destructive' ? 'text-destructive' : ''}>⌘</span> + {:else} + {key.toUpperCase()} + {/if} + + {#if index < keys.length - 1} + <span> </span> + {/if} + {/each} +</kbd> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte new file mode 100644 index 0000000..cb3ae17 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte @@ -0,0 +1,870 @@ +<script lang="ts"> + import { remark } from 'remark'; + import remarkBreaks from 'remark-breaks'; + import remarkGfm from 'remark-gfm'; + import remarkMath from 'remark-math'; + import rehypeHighlight from 'rehype-highlight'; + import remarkRehype from 'remark-rehype'; + import rehypeKatex from 'rehype-katex'; + import rehypeStringify from 'rehype-stringify'; + import type { Root as HastRoot, RootContent as HastRootContent } from 'hast'; + import type { Root as MdastRoot } from 'mdast'; + import { browser } from '$app/environment'; + import { onDestroy, tick } from 'svelte'; + import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer'; + import { rehypeEnhanceLinks } from '$lib/markdown/enhance-links'; + import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks'; + import { remarkLiteralHtml } from '$lib/markdown/literal-html'; + import { copyCodeToClipboard, preprocessLaTeX } from '$lib/utils'; + import '$styles/katex-custom.scss'; + import githubDarkCss from 'highlight.js/styles/github-dark.css?inline'; + import githubLightCss from 'highlight.js/styles/github.css?inline'; + import { mode } from 'mode-watcher'; + import CodePreviewDialog from './CodePreviewDialog.svelte'; + + interface Props { + content: string; + class?: string; + } + + interface MarkdownBlock { + id: string; + html: string; + } + + let { content, class: className = '' }: Props = $props(); + + let containerRef = $state<HTMLDivElement>(); + let renderedBlocks = $state<MarkdownBlock[]>([]); + let unstableBlockHtml = $state(''); + let previewDialogOpen = $state(false); + let previewCode = $state(''); + let previewLanguage = $state('text'); + + let pendingMarkdown: string | null = null; + let isProcessing = false; + + const themeStyleId = `highlight-theme-${(window.idxThemeStyle = (window.idxThemeStyle ?? 0) + 1)}`; + + let processor = $derived(() => { + return remark() + .use(remarkGfm) // GitHub Flavored Markdown + .use(remarkMath) // Parse $inline$ and $$block$$ math + .use(remarkBreaks) // Convert line breaks to <br> + .use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation + .use(remarkRehype) // Convert Markdown AST to rehype + .use(rehypeKatex) // Render math using KaTeX + .use(rehypeHighlight) // Add syntax highlighting + .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables + .use(rehypeEnhanceLinks) // Add target="_blank" to links + .use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions + .use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string + }); + + /** + * Removes click event listeners from copy and preview buttons. + * Called on component destroy. + */ + function cleanupEventListeners() { + if (!containerRef) return; + + const copyButtons = containerRef.querySelectorAll<HTMLButtonElement>('.copy-code-btn'); + const previewButtons = containerRef.querySelectorAll<HTMLButtonElement>('.preview-code-btn'); + + for (const button of copyButtons) { + button.removeEventListener('click', handleCopyClick); + } + + for (const button of previewButtons) { + button.removeEventListener('click', handlePreviewClick); + } + } + + /** + * Removes this component's highlight.js theme style from the document head. + * Called on component destroy to clean up injected styles. + */ + function cleanupHighlightTheme() { + if (!browser) return; + + const existingTheme = document.getElementById(themeStyleId); + existingTheme?.remove(); + } + + /** + * Loads the appropriate highlight.js theme based on dark/light mode. + * Injects a scoped style element into the document head. + * @param isDark - Whether to load the dark theme (true) or light theme (false) + */ + function loadHighlightTheme(isDark: boolean) { + if (!browser) return; + + const existingTheme = document.getElementById(themeStyleId); + existingTheme?.remove(); + + const style = document.createElement('style'); + style.id = themeStyleId; + style.textContent = isDark ? githubDarkCss : githubLightCss; + + document.head.appendChild(style); + } + + /** + * Extracts code information from a button click target within a code block. + * @param target - The clicked button element + * @returns Object with rawCode and language, or null if extraction fails + */ + function getCodeInfoFromTarget(target: HTMLElement) { + const wrapper = target.closest('.code-block-wrapper'); + + if (!wrapper) { + console.error('No wrapper found'); + return null; + } + + const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]'); + + if (!codeElement) { + console.error('No code element found in wrapper'); + return null; + } + + const rawCode = codeElement.textContent ?? ''; + + const languageLabel = wrapper.querySelector<HTMLElement>('.code-language'); + const language = languageLabel?.textContent?.trim() || 'text'; + + return { rawCode, language }; + } + + /** + * Generates a unique identifier for a HAST node based on its position. + * Used for stable block identification during incremental rendering. + * @param node - The HAST root content node + * @param indexFallback - Fallback index if position is unavailable + * @returns Unique string identifier for the node + */ + function getHastNodeId(node: HastRootContent, indexFallback: number): string { + const position = node.position; + + if (position?.start?.offset != null && position?.end?.offset != null) { + return `hast-${position.start.offset}-${position.end.offset}`; + } + + return `${node.type}-${indexFallback}`; + } + + /** + * Handles click events on copy buttons within code blocks. + * Copies the raw code content to the clipboard. + * @param event - The click event from the copy button + */ + async function handleCopyClick(event: Event) { + event.preventDefault(); + event.stopPropagation(); + + const target = event.currentTarget as HTMLButtonElement | null; + + if (!target) { + return; + } + + const info = getCodeInfoFromTarget(target); + + if (!info) { + return; + } + + try { + await copyCodeToClipboard(info.rawCode); + } catch (error) { + console.error('Failed to copy code:', error); + } + } + + /** + * Handles preview dialog open state changes. + * Clears preview content when dialog is closed. + * @param open - Whether the dialog is being opened or closed + */ + function handlePreviewDialogOpenChange(open: boolean) { + previewDialogOpen = open; + + if (!open) { + previewCode = ''; + previewLanguage = 'text'; + } + } + + /** + * Handles click events on preview buttons within HTML code blocks. + * Opens a preview dialog with the rendered HTML content. + * @param event - The click event from the preview button + */ + function handlePreviewClick(event: Event) { + event.preventDefault(); + event.stopPropagation(); + + const target = event.currentTarget as HTMLButtonElement | null; + + if (!target) { + return; + } + + const info = getCodeInfoFromTarget(target); + + if (!info) { + return; + } + + previewCode = info.rawCode; + previewLanguage = info.language; + previewDialogOpen = true; + } + + /** + * Processes markdown content into stable and unstable HTML blocks. + * Uses incremental rendering: stable blocks are cached, unstable block is re-rendered. + * @param markdown - The raw markdown string to process + */ + async function processMarkdown(markdown: string) { + if (!markdown) { + renderedBlocks = []; + unstableBlockHtml = ''; + return; + } + + const normalized = preprocessLaTeX(markdown); + const processorInstance = processor(); + const ast = processorInstance.parse(normalized) as MdastRoot; + const processedRoot = (await processorInstance.run(ast)) as HastRoot; + const processedChildren = processedRoot.children ?? []; + const stableCount = Math.max(processedChildren.length - 1, 0); + const nextBlocks: MarkdownBlock[] = []; + + for (let index = 0; index < stableCount; index++) { + const hastChild = processedChildren[index]; + const id = getHastNodeId(hastChild, index); + const existing = renderedBlocks[index]; + + if (existing && existing.id === id) { + nextBlocks.push(existing); + continue; + } + + const html = stringifyProcessedNode( + processorInstance, + processedRoot, + processedChildren[index] + ); + + nextBlocks.push({ id, html }); + } + + let unstableHtml = ''; + + if (processedChildren.length > stableCount) { + const unstableChild = processedChildren[stableCount]; + unstableHtml = stringifyProcessedNode(processorInstance, processedRoot, unstableChild); + } + + renderedBlocks = nextBlocks; + await tick(); // Force DOM sync before updating unstable HTML block + unstableBlockHtml = unstableHtml; + } + + /** + * Attaches click event listeners to copy and preview buttons in code blocks. + * Uses data-listener-bound attribute to prevent duplicate bindings. + */ + function setupCodeBlockActions() { + if (!containerRef) return; + + const wrappers = containerRef.querySelectorAll<HTMLElement>('.code-block-wrapper'); + + for (const wrapper of wrappers) { + const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn'); + const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn'); + + if (copyButton && copyButton.dataset.listenerBound !== 'true') { + copyButton.dataset.listenerBound = 'true'; + copyButton.addEventListener('click', handleCopyClick); + } + + if (previewButton && previewButton.dataset.listenerBound !== 'true') { + previewButton.dataset.listenerBound = 'true'; + previewButton.addEventListener('click', handlePreviewClick); + } + } + } + + /** + * Converts a single HAST node to an enhanced HTML string. + * Applies link and code block enhancements to the output. + * @param processorInstance - The remark/rehype processor instance + * @param processedRoot - The full processed HAST root (for context) + * @param child - The specific HAST child node to stringify + * @returns Enhanced HTML string representation of the node + */ + function stringifyProcessedNode( + processorInstance: ReturnType<typeof processor>, + processedRoot: HastRoot, + child: unknown + ) { + const root: HastRoot = { + ...(processedRoot as HastRoot), + children: [child as never] + }; + + return processorInstance.stringify(root); + } + + /** + * Queues markdown for processing with coalescing support. + * Only processes the latest markdown when multiple updates arrive quickly. + * @param markdown - The markdown content to render + */ + async function updateRenderedBlocks(markdown: string) { + pendingMarkdown = markdown; + + if (isProcessing) { + return; + } + + isProcessing = true; + + try { + while (pendingMarkdown !== null) { + const nextMarkdown = pendingMarkdown; + pendingMarkdown = null; + + await processMarkdown(nextMarkdown); + } + } catch (error) { + console.error('Failed to process markdown:', error); + renderedBlocks = []; + unstableBlockHtml = markdown.replace(/\n/g, '<br>'); + } finally { + isProcessing = false; + } + } + + $effect(() => { + const currentMode = mode.current; + const isDark = currentMode === 'dark'; + + loadHighlightTheme(isDark); + }); + + $effect(() => { + updateRenderedBlocks(content); + }); + + $effect(() => { + const hasRenderedBlocks = renderedBlocks.length > 0; + const hasUnstableBlock = Boolean(unstableBlockHtml); + + if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) { + setupCodeBlockActions(); + } + }); + + onDestroy(() => { + cleanupEventListeners(); + cleanupHighlightTheme(); + }); +</script> + +<div bind:this={containerRef} class={className}> + {#each renderedBlocks as block (block.id)} + <div class="markdown-block" data-block-id={block.id}> + <!-- eslint-disable-next-line no-at-html-tags --> + {@html block.html} + </div> + {/each} + + {#if unstableBlockHtml} + <div class="markdown-block markdown-block--unstable" data-block-id="unstable"> + <!-- eslint-disable-next-line no-at-html-tags --> + {@html unstableBlockHtml} + </div> + {/if} +</div> + +<CodePreviewDialog + open={previewDialogOpen} + code={previewCode} + language={previewLanguage} + onOpenChange={handlePreviewDialogOpenChange} +/> + +<style> + .markdown-block, + .markdown-block--unstable { + display: contents; + } + + /* Base typography styles */ + div :global(p:not(:last-child)) { + margin-bottom: 1rem; + line-height: 1.75; + } + + div :global(:is(h1, h2, h3, h4, h5, h6):first-child) { + margin-top: 0; + } + + /* Headers with consistent spacing */ + div :global(h1) { + font-size: 1.875rem; + font-weight: 700; + line-height: 1.2; + margin: 1.5rem 0 0.75rem 0; + } + + div :global(h2) { + font-size: 1.5rem; + font-weight: 600; + line-height: 1.3; + margin: 1.25rem 0 0.5rem 0; + } + + div :global(h3) { + font-size: 1.25rem; + font-weight: 600; + margin: 1.5rem 0 0.5rem 0; + line-height: 1.4; + } + + div :global(h4) { + font-size: 1.125rem; + font-weight: 600; + margin: 0.75rem 0 0.25rem 0; + } + + div :global(h5) { + font-size: 1rem; + font-weight: 600; + margin: 0.5rem 0 0.25rem 0; + } + + div :global(h6) { + font-size: 0.875rem; + font-weight: 600; + margin: 0.5rem 0 0.25rem 0; + } + + /* Text formatting */ + div :global(strong) { + font-weight: 600; + } + + div :global(em) { + font-style: italic; + } + + div :global(del) { + text-decoration: line-through; + opacity: 0.7; + } + + /* Inline code */ + div :global(code:not(pre code)) { + background: var(--muted); + color: var(--muted-foreground); + padding: 0.125rem 0.375rem; + border-radius: 0.375rem; + font-size: 0.875rem; + font-family: + ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, + 'Liberation Mono', Menlo, monospace; + } + + /* Links */ + div :global(a) { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 2px; + transition: color 0.2s ease; + } + + div :global(a:hover) { + color: var(--primary); + } + + /* Lists */ + div :global(ul) { + list-style-type: disc; + margin-left: 1.5rem; + margin-bottom: 1rem; + } + + div :global(ol) { + list-style-type: decimal; + margin-left: 1.5rem; + margin-bottom: 1rem; + } + + div :global(li) { + margin-bottom: 0.25rem; + padding-left: 0.5rem; + } + + div :global(li::marker) { + color: var(--muted-foreground); + } + + /* Nested lists */ + div :global(ul ul) { + list-style-type: circle; + margin-top: 0.25rem; + margin-bottom: 0.25rem; + } + + div :global(ol ol) { + list-style-type: lower-alpha; + margin-top: 0.25rem; + margin-bottom: 0.25rem; + } + + /* Task lists */ + div :global(.task-list-item) { + list-style: none; + margin-left: 0; + padding-left: 0; + } + + div :global(.task-list-item-checkbox) { + margin-right: 0.5rem; + margin-top: 0.125rem; + } + + /* Blockquotes */ + div :global(blockquote) { + border-left: 4px solid var(--border); + padding: 0.5rem 1rem; + margin: 1.5rem 0; + font-style: italic; + color: var(--muted-foreground); + background: var(--muted); + border-radius: 0 0.375rem 0.375rem 0; + } + + /* Tables */ + div :global(table) { + width: 100%; + margin: 1.5rem 0; + border-collapse: collapse; + border: 1px solid var(--border); + border-radius: 0.375rem; + overflow: hidden; + } + + div :global(th) { + background: hsl(var(--muted) / 0.3); + border: 1px solid var(--border); + padding: 0.5rem 0.75rem; + text-align: left; + font-weight: 600; + } + + div :global(td) { + border: 1px solid var(--border); + padding: 0.5rem 0.75rem; + } + + div :global(tr:nth-child(even)) { + background: hsl(var(--muted) / 0.1); + } + + /* User message markdown should keep table borders visible on light primary backgrounds */ + div.markdown-user-content :global(table), + div.markdown-user-content :global(th), + div.markdown-user-content :global(td), + div.markdown-user-content :global(.table-wrapper) { + border-color: currentColor; + } + + /* Horizontal rules */ + div :global(hr) { + border: none; + border-top: 1px solid var(--border); + margin: 1.5rem 0; + } + + /* Images */ + div :global(img) { + border-radius: 0.5rem; + box-shadow: + 0 1px 3px 0 rgb(0 0 0 / 0.1), + 0 1px 2px -1px rgb(0 0 0 / 0.1); + margin: 1.5rem 0; + max-width: 100%; + height: auto; + } + + /* Code blocks */ + + div :global(.code-block-wrapper) { + margin: 1.5rem 0; + border-radius: 0.75rem; + overflow: hidden; + border: 1px solid var(--border); + background: var(--code-background); + } + + div :global(.code-block-header) { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1rem; + background: hsl(var(--muted) / 0.5); + border-bottom: 1px solid var(--border); + font-size: 0.875rem; + } + + div :global(.code-language) { + color: var(--code-foreground); + font-weight: 500; + font-family: + ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, + 'Liberation Mono', Menlo, monospace; + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.05em; + } + + div :global(.code-block-actions) { + display: flex; + align-items: center; + gap: 0.5rem; + } + + div :global(.copy-code-btn), + div :global(.preview-code-btn) { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: transparent; + color: var(--code-foreground); + cursor: pointer; + transition: all 0.2s ease; + } + + div :global(.copy-code-btn:hover), + div :global(.preview-code-btn:hover) { + transform: scale(1.05); + } + + div :global(.copy-code-btn:active), + div :global(.preview-code-btn:active) { + transform: scale(0.95); + } + + div :global(.code-block-wrapper pre) { + background: transparent; + padding: 1rem; + margin: 0; + overflow-x: auto; + border-radius: 0; + border: none; + font-size: 0.875rem; + line-height: 1.5; + } + + div :global(pre) { + background: var(--muted); + margin: 1.5rem 0; + overflow-x: auto; + border-radius: 1rem; + border: none; + } + + div :global(code) { + background: transparent; + color: var(--code-foreground); + } + + /* Mentions and hashtags */ + div :global(.mention) { + color: hsl(var(--primary)); + font-weight: 500; + text-decoration: none; + } + + div :global(.mention:hover) { + text-decoration: underline; + } + + div :global(.hashtag) { + color: hsl(var(--primary)); + font-weight: 500; + text-decoration: none; + } + + div :global(.hashtag:hover) { + text-decoration: underline; + } + + /* Advanced table enhancements */ + div :global(table) { + transition: all 0.2s ease; + } + + div :global(table:hover) { + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.1), + 0 2px 4px -2px rgb(0 0 0 / 0.1); + } + + div :global(th:hover), + div :global(td:hover) { + background: var(--muted); + } + + /* Disable hover effects when rendering user messages */ + .markdown-user-content :global(a), + .markdown-user-content :global(a:hover) { + color: var(--primary-foreground); + } + + .markdown-user-content :global(table:hover) { + box-shadow: none; + } + + .markdown-user-content :global(th:hover), + .markdown-user-content :global(td:hover) { + background: inherit; + } + + /* Enhanced blockquotes */ + div :global(blockquote) { + transition: all 0.2s ease; + position: relative; + } + + div :global(blockquote:hover) { + border-left-width: 6px; + background: var(--muted); + transform: translateX(2px); + } + + div :global(blockquote::before) { + content: '"'; + position: absolute; + top: -0.5rem; + left: 0.5rem; + font-size: 3rem; + color: var(--muted-foreground); + font-family: serif; + line-height: 1; + } + + /* Enhanced images */ + div :global(img) { + transition: all 0.3s ease; + cursor: pointer; + } + + div :global(img:hover) { + transform: scale(1.02); + box-shadow: + 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1); + } + + /* Image zoom overlay */ + div :global(.image-zoom-overlay) { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + cursor: pointer; + } + + div :global(.image-zoom-overlay img) { + max-width: 90vw; + max-height: 90vh; + border-radius: 0.5rem; + box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); + } + + /* Enhanced horizontal rules */ + div :global(hr) { + border: none; + height: 2px; + background: linear-gradient(to right, transparent, var(--border), transparent); + margin: 2rem 0; + position: relative; + } + + div :global(hr::after) { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 1rem; + height: 1rem; + background: var(--border); + border-radius: 50%; + } + + /* Scrollable tables */ + div :global(.table-wrapper) { + overflow-x: auto; + margin: 1.5rem 0; + border-radius: 0.5rem; + border: 1px solid var(--border); + } + + div :global(.table-wrapper table) { + margin: 0; + border: none; + } + + /* Responsive adjustments */ + @media (max-width: 640px) { + div :global(h1) { + font-size: 1.5rem; + } + + div :global(h2) { + font-size: 1.25rem; + } + + div :global(h3) { + font-size: 1.125rem; + } + + div :global(table) { + font-size: 0.875rem; + } + + div :global(th), + div :global(td) { + padding: 0.375rem 0.5rem; + } + + div :global(.table-wrapper) { + margin: 0.5rem -1rem; + border-radius: 0; + border-left: none; + border-right: none; + } + } + + /* Dark mode adjustments */ + @media (prefers-color-scheme: dark) { + div :global(blockquote:hover) { + background: var(--muted); + } + } +</style> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/RemoveButton.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/RemoveButton.svelte new file mode 100644 index 0000000..1736855 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/RemoveButton.svelte @@ -0,0 +1,26 @@ +<script lang="ts"> + import { X } from '@lucide/svelte'; + import { Button } from '$lib/components/ui/button'; + + interface Props { + id: string; + onRemove?: (id: string) => void; + class?: string; + } + + let { id, onRemove, class: className = '' }: Props = $props(); +</script> + +<Button + type="button" + variant="ghost" + size="sm" + class="h-6 w-6 bg-white/20 p-0 hover:bg-white/30 {className}" + onclick={(e) => { + e.stopPropagation(); + onRemove?.(id); + }} + aria-label="Remove file" +> + <X class="h-3 w-3" /> +</Button> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/SearchInput.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/SearchInput.svelte new file mode 100644 index 0000000..15cd6ab --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/SearchInput.svelte @@ -0,0 +1,73 @@ +<script lang="ts"> + import { Input } from '$lib/components/ui/input'; + import { Search, X } from '@lucide/svelte'; + + interface Props { + value?: string; + placeholder?: string; + onInput?: (value: string) => void; + onClose?: () => void; + onKeyDown?: (event: KeyboardEvent) => void; + class?: string; + id?: string; + ref?: HTMLInputElement | null; + } + + let { + value = $bindable(''), + placeholder = 'Search...', + onInput, + onClose, + onKeyDown, + class: className, + id, + ref = $bindable(null) + }: Props = $props(); + + let showClearButton = $derived(!!value || !!onClose); + + function handleInput(event: Event) { + const target = event.target as HTMLInputElement; + + value = target.value; + onInput?.(target.value); + } + + function handleClear() { + if (value) { + value = ''; + onInput?.(''); + ref?.focus(); + } else { + onClose?.(); + } + } +</script> + +<div class="relative {className}"> + <Search + class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-muted-foreground" + /> + + <Input + {id} + bind:value + bind:ref + class="pl-9 {showClearButton ? 'pr-9' : ''}" + oninput={handleInput} + onkeydown={onKeyDown} + {placeholder} + type="search" + /> + + {#if showClearButton} + <button + type="button" + class="absolute top-1/2 right-3 -translate-y-1/2 transform text-muted-foreground transition-colors hover:text-foreground" + onclick={handleClear} + aria-label={value ? 'Clear search' : 'Close'} + > + <X class="h-4 w-4" /> + </button> + {/if} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/SyntaxHighlightedCode.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/SyntaxHighlightedCode.svelte new file mode 100644 index 0000000..bc42f9d --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/SyntaxHighlightedCode.svelte @@ -0,0 +1,97 @@ +<script lang="ts"> + import hljs from 'highlight.js'; + import { browser } from '$app/environment'; + import { mode } from 'mode-watcher'; + + import githubDarkCss from 'highlight.js/styles/github-dark.css?inline'; + import githubLightCss from 'highlight.js/styles/github.css?inline'; + + interface Props { + code: string; + language?: string; + class?: string; + maxHeight?: string; + maxWidth?: string; + } + + let { + code, + language = 'text', + class: className = '', + maxHeight = '60vh', + maxWidth = '' + }: Props = $props(); + + let highlightedHtml = $state(''); + + function loadHighlightTheme(isDark: boolean) { + if (!browser) return; + + const existingThemes = document.querySelectorAll('style[data-highlight-theme-preview]'); + existingThemes.forEach((style) => style.remove()); + + const style = document.createElement('style'); + style.setAttribute('data-highlight-theme-preview', 'true'); + style.textContent = isDark ? githubDarkCss : githubLightCss; + + document.head.appendChild(style); + } + + $effect(() => { + const currentMode = mode.current; + const isDark = currentMode === 'dark'; + + loadHighlightTheme(isDark); + }); + + $effect(() => { + if (!code) { + highlightedHtml = ''; + return; + } + + try { + // Check if the language is supported + const lang = language.toLowerCase(); + const isSupported = hljs.getLanguage(lang); + + if (isSupported) { + const result = hljs.highlight(code, { language: lang }); + highlightedHtml = result.value; + } else { + // Try auto-detection or fallback to plain text + const result = hljs.highlightAuto(code); + highlightedHtml = result.value; + } + } catch { + // Fallback to escaped plain text + highlightedHtml = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); + } + }); +</script> + +<div + class="code-preview-wrapper overflow-auto rounded-lg border border-border bg-muted {className}" + style="max-height: {maxHeight}; max-width: {maxWidth};" +> + <!-- Needs to be formatted as single line for proper rendering --> + <pre class="m-0 overflow-x-auto p-4"><code class="hljs text-sm leading-relaxed" + >{@html highlightedHtml}</code + ></pre> +</div> + +<style> + .code-preview-wrapper { + font-family: + ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, + 'Liberation Mono', Menlo, monospace; + } + + .code-preview-wrapper pre { + background: transparent; + } + + .code-preview-wrapper code { + background: transparent; + } +</style> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte new file mode 100644 index 0000000..bea1bf6 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte @@ -0,0 +1,56 @@ +<script lang="ts"> + import { Package } from '@lucide/svelte'; + import { BadgeInfo, CopyToClipboardIcon } from '$lib/components/app'; + import { modelsStore } from '$lib/stores/models.svelte'; + import { serverStore } from '$lib/stores/server.svelte'; + import * as Tooltip from '$lib/components/ui/tooltip'; + + interface Props { + class?: string; + model?: string; + onclick?: () => void; + showCopyIcon?: boolean; + showTooltip?: boolean; + } + + let { + class: className = '', + model: modelProp, + onclick, + showCopyIcon = false, + showTooltip = false + }: Props = $props(); + + let model = $derived(modelProp || modelsStore.singleModelName); + let isModelMode = $derived(serverStore.isModelMode); +</script> + +{#snippet badgeContent()} + <BadgeInfo class={className} {onclick}> + {#snippet icon()} + <Package class="h-3 w-3" /> + {/snippet} + + {model} + + {#if showCopyIcon} + <CopyToClipboardIcon text={model || ''} ariaLabel="Copy model name" /> + {/if} + </BadgeInfo> +{/snippet} + +{#if model && isModelMode} + {#if showTooltip} + <Tooltip.Root> + <Tooltip.Trigger> + {@render badgeContent()} + </Tooltip.Trigger> + + <Tooltip.Content> + {onclick ? 'Click for model details' : model} + </Tooltip.Content> + </Tooltip.Root> + {:else} + {@render badgeContent()} + {/if} +{/if} diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte new file mode 100644 index 0000000..efc9cd4 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte @@ -0,0 +1,555 @@ +<script lang="ts"> + import { onMount, tick } from 'svelte'; + import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/svelte'; + import * as Tooltip from '$lib/components/ui/tooltip'; + import * as Popover from '$lib/components/ui/popover'; + import { cn } from '$lib/components/ui/utils'; + import { + modelsStore, + modelOptions, + modelsLoading, + modelsUpdating, + selectedModelId, + routerModels, + propsCacheVersion, + singleModelName + } from '$lib/stores/models.svelte'; + import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte'; + import { ServerModelStatus } from '$lib/enums'; + import { isRouterMode } from '$lib/stores/server.svelte'; + import { DialogModelInformation, SearchInput } from '$lib/components/app'; + import type { ModelOption } from '$lib/types/models'; + + interface Props { + class?: string; + currentModel?: string | null; + /** Callback when model changes. Return false to keep menu open (e.g., for validation failures) */ + onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void; + disabled?: boolean; + forceForegroundText?: boolean; + /** When true, user's global selection takes priority over currentModel (for form selector) */ + useGlobalSelection?: boolean; + /** + * When provided, only consider modalities from messages BEFORE this message. + * Used for regeneration - allows selecting models that don't support modalities + * used in later messages. + */ + upToMessageId?: string; + } + + let { + class: className = '', + currentModel = null, + onModelChange, + disabled = false, + forceForegroundText = false, + useGlobalSelection = false, + upToMessageId + }: Props = $props(); + + let options = $derived(modelOptions()); + let loading = $derived(modelsLoading()); + let updating = $derived(modelsUpdating()); + let activeId = $derived(selectedModelId()); + let isRouter = $derived(isRouterMode()); + let serverModel = $derived(singleModelName()); + + // Reactive router models state - needed for proper reactivity of status checks + let currentRouterModels = $derived(routerModels()); + + let requiredModalities = $derived( + upToMessageId ? conversationsStore.getModalitiesUpToMessage(upToMessageId) : usedModalities() + ); + + function getModelStatus(modelId: string): ServerModelStatus | null { + const model = currentRouterModels.find((m) => m.id === modelId); + return (model?.status?.value as ServerModelStatus) ?? null; + } + + /** + * Checks if a model supports all modalities used in the conversation. + * Returns true if the model can be selected, false if it should be disabled. + */ + function isModelCompatible(option: ModelOption): boolean { + void propsCacheVersion(); + + const modelModalities = modelsStore.getModelModalities(option.model); + + if (!modelModalities) { + const status = getModelStatus(option.model); + + if (status === ServerModelStatus.LOADED) { + if (requiredModalities.vision || requiredModalities.audio) return false; + } + + return true; + } + + if (requiredModalities.vision && !modelModalities.vision) return false; + if (requiredModalities.audio && !modelModalities.audio) return false; + + return true; + } + + /** + * Gets missing modalities for a model. + * Returns object with vision/audio booleans indicating what's missing. + */ + function getMissingModalities(option: ModelOption): { vision: boolean; audio: boolean } | null { + void propsCacheVersion(); + + const modelModalities = modelsStore.getModelModalities(option.model); + + if (!modelModalities) { + const status = getModelStatus(option.model); + + if (status === ServerModelStatus.LOADED) { + const missing = { + vision: requiredModalities.vision, + audio: requiredModalities.audio + }; + + if (missing.vision || missing.audio) return missing; + } + + return null; + } + + const missing = { + vision: requiredModalities.vision && !modelModalities.vision, + audio: requiredModalities.audio && !modelModalities.audio + }; + + if (!missing.vision && !missing.audio) return null; + + return missing; + } + + let isHighlightedCurrentModelActive = $derived( + !isRouter || !currentModel + ? false + : (() => { + const currentOption = options.find((option) => option.model === currentModel); + + return currentOption ? currentOption.id === activeId : false; + })() + ); + + let isCurrentModelInCache = $derived(() => { + if (!isRouter || !currentModel) return true; + + return options.some((option) => option.model === currentModel); + }); + + let searchTerm = $state(''); + let searchInputRef = $state<HTMLInputElement | null>(null); + let highlightedIndex = $state<number>(-1); + + let filteredOptions: ModelOption[] = $derived( + (() => { + const term = searchTerm.trim().toLowerCase(); + if (!term) return options; + + return options.filter( + (option) => + option.model.toLowerCase().includes(term) || option.name?.toLowerCase().includes(term) + ); + })() + ); + + // Get indices of compatible options for keyboard navigation + let compatibleIndices = $derived( + filteredOptions + .map((option, index) => (isModelCompatible(option) ? index : -1)) + .filter((i) => i !== -1) + ); + + // Reset highlighted index when search term changes + $effect(() => { + void searchTerm; + highlightedIndex = -1; + }); + + let isOpen = $state(false); + let showModelDialog = $state(false); + + onMount(() => { + modelsStore.fetch().catch((error) => { + console.error('Unable to load models:', error); + }); + }); + + // Handle changes to the model selector pop-down or the model dialog, depending on if the server is in + // router mode or not. + function handleOpenChange(open: boolean) { + if (loading || updating) return; + + if (isRouter) { + if (open) { + isOpen = true; + searchTerm = ''; + highlightedIndex = -1; + + // Focus search input after popover opens + tick().then(() => { + requestAnimationFrame(() => searchInputRef?.focus()); + }); + + modelsStore.fetchRouterModels().then(() => { + modelsStore.fetchModalitiesForLoadedModels(); + }); + } else { + isOpen = false; + searchTerm = ''; + highlightedIndex = -1; + } + } else { + showModelDialog = open; + } + } + + export function open() { + handleOpenChange(true); + } + + function handleSearchKeyDown(event: KeyboardEvent) { + if (event.isComposing) return; + + if (event.key === 'ArrowDown') { + event.preventDefault(); + if (compatibleIndices.length === 0) return; + + const currentPos = compatibleIndices.indexOf(highlightedIndex); + if (currentPos === -1 || currentPos === compatibleIndices.length - 1) { + highlightedIndex = compatibleIndices[0]; + } else { + highlightedIndex = compatibleIndices[currentPos + 1]; + } + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + if (compatibleIndices.length === 0) return; + + const currentPos = compatibleIndices.indexOf(highlightedIndex); + if (currentPos === -1 || currentPos === 0) { + highlightedIndex = compatibleIndices[compatibleIndices.length - 1]; + } else { + highlightedIndex = compatibleIndices[currentPos - 1]; + } + } else if (event.key === 'Enter') { + event.preventDefault(); + if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) { + const option = filteredOptions[highlightedIndex]; + if (isModelCompatible(option)) { + handleSelect(option.id); + } + } else if (compatibleIndices.length > 0) { + // No selection - highlight first compatible option + highlightedIndex = compatibleIndices[0]; + } + } + } + + async function handleSelect(modelId: string) { + const option = options.find((opt) => opt.id === modelId); + if (!option) return; + + let shouldCloseMenu = true; + + if (onModelChange) { + // If callback provided, use it (for regenerate functionality) + const result = await onModelChange(option.id, option.model); + + // If callback returns false, keep menu open (validation failed) + if (result === false) { + shouldCloseMenu = false; + } + } else { + // Update global selection + await modelsStore.selectModelById(option.id); + + // Load the model if not already loaded (router mode) + if (isRouter && getModelStatus(option.model) !== ServerModelStatus.LOADED) { + try { + await modelsStore.loadModel(option.model); + } catch (error) { + console.error('Failed to load model:', error); + } + } + } + + if (shouldCloseMenu) { + handleOpenChange(false); + + // Focus the chat textarea after model selection + requestAnimationFrame(() => { + const textarea = document.querySelector<HTMLTextAreaElement>( + '[data-slot="chat-form"] textarea' + ); + textarea?.focus(); + }); + } + } + + function getDisplayOption(): ModelOption | undefined { + if (!isRouter) { + if (serverModel) { + return { + id: 'current', + model: serverModel, + name: serverModel.split('/').pop() || serverModel, + capabilities: [] // Empty array for single model mode + }; + } + + return undefined; + } + + // When useGlobalSelection is true (form selector), prioritize user selection + // Otherwise (message display), prioritize currentModel + if (useGlobalSelection && activeId) { + const selected = options.find((option) => option.id === activeId); + if (selected) return selected; + } + + // Show currentModel (from message payload or conversation) + if (currentModel) { + if (!isCurrentModelInCache()) { + return { + id: 'not-in-cache', + model: currentModel, + name: currentModel.split('/').pop() || currentModel, + capabilities: [] + }; + } + + return options.find((option) => option.model === currentModel); + } + + // Fallback to user selection (for new chats before first message) + if (activeId) { + return options.find((option) => option.id === activeId); + } + + // No selection - return undefined to show "Select model" + return undefined; + } +</script> + +<div class={cn('relative inline-flex flex-col items-end gap-1', className)}> + {#if loading && options.length === 0 && isRouter} + <div class="flex items-center gap-2 text-xs text-muted-foreground"> + <Loader2 class="h-3.5 w-3.5 animate-spin" /> + Loading models… + </div> + {:else if options.length === 0 && isRouter} + <p class="text-xs text-muted-foreground">No models available.</p> + {:else} + {@const selectedOption = getDisplayOption()} + + {#if isRouter} + <Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}> + <Popover.Trigger + class={cn( + `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`, + !isCurrentModelInCache() + ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400' + : forceForegroundText + ? 'text-foreground' + : isHighlightedCurrentModelActive + ? 'text-foreground' + : 'text-muted-foreground', + isOpen ? 'text-foreground' : '' + )} + style="max-width: min(calc(100cqw - 6.5rem), 32rem)" + disabled={disabled || updating} + > + <Package class="h-3.5 w-3.5" /> + + <span class="truncate font-medium"> + {selectedOption?.model || 'Select model'} + </span> + + {#if updating} + <Loader2 class="h-3 w-3.5 animate-spin" /> + {:else} + <ChevronDown class="h-3 w-3.5" /> + {/if} + </Popover.Trigger> + + <Popover.Content + class="group/popover-content w-96 max-w-[calc(100vw-2rem)] p-0" + align="end" + sideOffset={8} + collisionPadding={16} + > + <div class="flex max-h-[50dvh] flex-col overflow-hidden"> + <div + class="order-1 shrink-0 border-b p-4 group-data-[side=top]/popover-content:order-2 group-data-[side=top]/popover-content:border-t group-data-[side=top]/popover-content:border-b-0" + > + <SearchInput + id="model-search" + placeholder="Search models..." + bind:value={searchTerm} + bind:ref={searchInputRef} + onClose={() => handleOpenChange(false)} + onKeyDown={handleSearchKeyDown} + /> + </div> + <div + class="models-list order-2 min-h-0 flex-1 overflow-y-auto group-data-[side=top]/popover-content:order-1" + > + {#if !isCurrentModelInCache() && currentModel} + <!-- Show unavailable model as first option (disabled) --> + <button + type="button" + class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-4 py-2 text-left text-sm text-red-400" + role="option" + aria-selected="true" + aria-disabled="true" + disabled + > + <span class="truncate">{selectedOption?.name || currentModel}</span> + <span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span> + </button> + <div class="my-1 h-px bg-border"></div> + {/if} + {#if filteredOptions.length === 0} + <p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p> + {/if} + {#each filteredOptions as option, index (option.id)} + {@const status = getModelStatus(option.model)} + {@const isLoaded = status === ServerModelStatus.LOADED} + {@const isLoading = status === ServerModelStatus.LOADING} + {@const isSelected = currentModel === option.model || activeId === option.id} + {@const isCompatible = isModelCompatible(option)} + {@const isHighlighted = index === highlightedIndex} + {@const missingModalities = getMissingModalities(option)} + + <div + class={cn( + 'group flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition focus:outline-none', + isCompatible + ? 'cursor-pointer hover:bg-muted focus:bg-muted' + : 'cursor-not-allowed opacity-50', + isSelected || isHighlighted + ? 'bg-accent text-accent-foreground' + : isCompatible + ? 'hover:bg-accent hover:text-accent-foreground' + : '', + isLoaded ? 'text-popover-foreground' : 'text-muted-foreground' + )} + role="option" + aria-selected={isSelected || isHighlighted} + aria-disabled={!isCompatible} + tabindex={isCompatible ? 0 : -1} + onclick={() => isCompatible && handleSelect(option.id)} + onmouseenter={() => (highlightedIndex = index)} + onkeydown={(e) => { + if (isCompatible && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + handleSelect(option.id); + } + }} + > + <span class="min-w-0 flex-1 truncate">{option.model}</span> + + {#if missingModalities} + <span class="flex shrink-0 items-center gap-1 text-muted-foreground/70"> + {#if missingModalities.vision} + <Tooltip.Root> + <Tooltip.Trigger> + <EyeOff class="h-3.5 w-3.5" /> + </Tooltip.Trigger> + <Tooltip.Content class="z-[9999]"> + <p>No vision support</p> + </Tooltip.Content> + </Tooltip.Root> + {/if} + {#if missingModalities.audio} + <Tooltip.Root> + <Tooltip.Trigger> + <MicOff class="h-3.5 w-3.5" /> + </Tooltip.Trigger> + <Tooltip.Content class="z-[9999]"> + <p>No audio support</p> + </Tooltip.Content> + </Tooltip.Root> + {/if} + </span> + {/if} + + {#if isLoading} + <Tooltip.Root> + <Tooltip.Trigger> + <Loader2 class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" /> + </Tooltip.Trigger> + <Tooltip.Content class="z-[9999]"> + <p>Loading model...</p> + </Tooltip.Content> + </Tooltip.Root> + {:else if isLoaded} + <Tooltip.Root> + <Tooltip.Trigger> + <button + type="button" + class="relative ml-2 flex h-4 w-4 shrink-0 items-center justify-center" + onclick={(e) => { + e.stopPropagation(); + modelsStore.unloadModel(option.model); + }} + > + <span + class="mr-2 h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0" + ></span> + <Power + class="absolute mr-2 h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600" + /> + </button> + </Tooltip.Trigger> + <Tooltip.Content class="z-[9999]"> + <p>Unload model</p> + </Tooltip.Content> + </Tooltip.Root> + {:else} + <span class="mx-2 h-2 w-2 rounded-full bg-muted-foreground/50"></span> + {/if} + </div> + {/each} + </div> + </div> + </Popover.Content> + </Popover.Root> + {:else} + <button + class={cn( + `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`, + !isCurrentModelInCache() + ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400' + : forceForegroundText + ? 'text-foreground' + : isHighlightedCurrentModelActive + ? 'text-foreground' + : 'text-muted-foreground', + isOpen ? 'text-foreground' : '' + )} + style="max-width: min(calc(100cqw - 6.5rem), 32rem)" + onclick={() => handleOpenChange(true)} + disabled={disabled || updating} + > + <Package class="h-3.5 w-3.5" /> + + <span class="truncate font-medium"> + {selectedOption?.model} + </span> + + {#if updating} + <Loader2 class="h-3 w-3.5 animate-spin" /> + {/if} + </button> + {/if} + {/if} +</div> + +{#if showModelDialog && !isRouter} + <DialogModelInformation bind:open={showModelDialog} /> +{/if} diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte new file mode 100644 index 0000000..fa4c284 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte @@ -0,0 +1,282 @@ +<script lang="ts"> + import { base } from '$app/paths'; + import { AlertTriangle, RefreshCw, Key, CheckCircle, XCircle } from '@lucide/svelte'; + import { goto } from '$app/navigation'; + import { Button } from '$lib/components/ui/button'; + import { Input } from '$lib/components/ui/input'; + import Label from '$lib/components/ui/label/label.svelte'; + import { serverStore, serverLoading } from '$lib/stores/server.svelte'; + import { config, settingsStore } from '$lib/stores/settings.svelte'; + import { fade, fly, scale } from 'svelte/transition'; + + interface Props { + class?: string; + error: string; + onRetry?: () => void; + showRetry?: boolean; + showTroubleshooting?: boolean; + } + + let { + class: className = '', + error, + onRetry, + showRetry = true, + showTroubleshooting = false + }: Props = $props(); + + let isServerLoading = $derived(serverLoading()); + let isAccessDeniedError = $derived( + error.toLowerCase().includes('access denied') || + error.toLowerCase().includes('invalid api key') || + error.toLowerCase().includes('unauthorized') || + error.toLowerCase().includes('401') || + error.toLowerCase().includes('403') + ); + + let apiKeyInput = $state(''); + let showApiKeyInput = $state(false); + let apiKeyState = $state<'idle' | 'validating' | 'success' | 'error'>('idle'); + let apiKeyError = $state(''); + + function handleRetryConnection() { + if (onRetry) { + onRetry(); + } else { + serverStore.fetch(); + } + } + + function handleShowApiKeyInput() { + showApiKeyInput = true; + // Pre-fill with current API key if it exists + const currentConfig = config(); + apiKeyInput = currentConfig.apiKey?.toString() || ''; + } + + async function handleSaveApiKey() { + if (!apiKeyInput.trim()) return; + + apiKeyState = 'validating'; + apiKeyError = ''; + + try { + // Update the API key in settings first + settingsStore.updateConfig('apiKey', apiKeyInput.trim()); + + // Test the API key by making a real request to the server + const response = await fetch(`${base}/props`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKeyInput.trim()}` + } + }); + + if (response.ok) { + // API key is valid - User Story B + apiKeyState = 'success'; + + // Show success state briefly, then navigate to home + setTimeout(() => { + goto(`#/`); + }, 1000); + } else { + // API key is invalid - User Story A + apiKeyState = 'error'; + + if (response.status === 401 || response.status === 403) { + apiKeyError = 'Invalid API key - please check and try again'; + } else { + apiKeyError = `Authentication failed (${response.status})`; + } + + // Reset to idle state after showing error (don't reload UI) + setTimeout(() => { + apiKeyState = 'idle'; + }, 3000); + } + } catch (error) { + // Network or other errors - User Story A + apiKeyState = 'error'; + + if (error instanceof Error) { + if (error.message.includes('fetch')) { + apiKeyError = 'Cannot connect to server - check if server is running'; + } else { + apiKeyError = error.message; + } + } else { + apiKeyError = 'Connection error - please try again'; + } + + // Reset to idle state after showing error (don't reload UI) + setTimeout(() => { + apiKeyState = 'idle'; + }, 3000); + } + } + + function handleApiKeyKeydown(event: KeyboardEvent) { + if (event.key === 'Enter') { + handleSaveApiKey(); + } + } +</script> + +<div class="flex h-full items-center justify-center {className}"> + <div class="w-full max-w-md px-4 text-center"> + <div class="mb-6" in:fade={{ duration: 300 }}> + <div + class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10" + > + <AlertTriangle class="h-8 w-8 text-destructive" /> + </div> + + <h2 class="mb-2 text-xl font-semibold">Server Connection Error</h2> + + <p class="mb-4 text-sm text-muted-foreground"> + {error} + </p> + </div> + + {#if isAccessDeniedError && !showApiKeyInput} + <div in:fly={{ y: 10, duration: 300, delay: 200 }} class="mb-4"> + <Button onclick={handleShowApiKeyInput} variant="outline" class="w-full"> + <Key class="h-4 w-4" /> + Enter API Key + </Button> + </div> + {/if} + + {#if showApiKeyInput} + <div in:fly={{ y: 10, duration: 300, delay: 200 }} class="mb-4 space-y-3 text-left"> + <div class="space-y-2"> + <Label for="api-key-input" class="text-sm font-medium">API Key</Label> + + <div class="relative"> + <Input + id="api-key-input" + placeholder="Enter your API key..." + bind:value={apiKeyInput} + onkeydown={handleApiKeyKeydown} + class="w-full pr-10 {apiKeyState === 'error' + ? 'border-destructive' + : apiKeyState === 'success' + ? 'border-green-500' + : ''}" + disabled={apiKeyState === 'validating'} + /> + {#if apiKeyState === 'validating'} + <div class="absolute top-1/2 right-3 -translate-y-1/2"> + <RefreshCw class="h-4 w-4 animate-spin text-muted-foreground" /> + </div> + {:else if apiKeyState === 'success'} + <div + class="absolute top-1/2 right-3 -translate-y-1/2" + in:scale={{ duration: 200, start: 0.8 }} + > + <CheckCircle class="h-4 w-4 text-green-500" /> + </div> + {:else if apiKeyState === 'error'} + <div + class="absolute top-1/2 right-3 -translate-y-1/2" + in:scale={{ duration: 200, start: 0.8 }} + > + <XCircle class="h-4 w-4 text-destructive" /> + </div> + {/if} + </div> + {#if apiKeyError} + <p class="text-sm text-destructive" in:fly={{ y: -10, duration: 200 }}> + {apiKeyError} + </p> + {/if} + {#if apiKeyState === 'success'} + <p class="text-sm text-green-600" in:fly={{ y: -10, duration: 200 }}> + ✓ API key validated successfully! Connecting... + </p> + {/if} + </div> + <div class="flex gap-2"> + <Button + onclick={handleSaveApiKey} + disabled={!apiKeyInput.trim() || + apiKeyState === 'validating' || + apiKeyState === 'success'} + class="flex-1" + > + {#if apiKeyState === 'validating'} + <RefreshCw class="h-4 w-4 animate-spin" /> + Validating... + {:else if apiKeyState === 'success'} + Success! + {:else} + Save & Retry + {/if} + </Button> + <Button + onclick={() => { + showApiKeyInput = false; + apiKeyState = 'idle'; + apiKeyError = ''; + }} + variant="outline" + class="flex-1" + disabled={apiKeyState === 'validating'} + > + Cancel + </Button> + </div> + </div> + {/if} + + {#if showRetry} + <div in:fly={{ y: 10, duration: 300, delay: 200 }}> + <Button onclick={handleRetryConnection} disabled={isServerLoading} class="w-full"> + {#if isServerLoading} + <RefreshCw class="h-4 w-4 animate-spin" /> + + Connecting... + {:else} + <RefreshCw class="h-4 w-4" /> + + Retry Connection + {/if} + </Button> + </div> + {/if} + + {#if showTroubleshooting} + <div class="mt-4 text-left" in:fly={{ y: 10, duration: 300, delay: 400 }}> + <details class="text-sm"> + <summary class="cursor-pointer text-muted-foreground hover:text-foreground"> + Troubleshooting + </summary> + + <div class="mt-2 space-y-3 text-xs text-muted-foreground"> + <div class="space-y-2"> + <p class="mb-4 font-medium">Start the llama-server:</p> + + <div class="rounded bg-muted/50 px-2 py-1 font-mono text-xs"> + <p>llama-server -hf ggml-org/gemma-3-4b-it-GGUF</p> + </div> + + <p>or</p> + + <div class="rounded bg-muted/50 px-2 py-1 font-mono text-xs"> + <p class="mt-1">llama-server -m locally-stored-model.gguf</p> + </div> + </div> + <ul class="list-disc space-y-1 pl-4"> + <li>Check that the server is accessible at the correct URL</li> + + <li>Verify your network connection</li> + + <li>Check server logs for any error messages</li> + </ul> + </div> + </details> + </div> + {/if} + </div> +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerLoadingSplash.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerLoadingSplash.svelte new file mode 100644 index 0000000..505325d --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerLoadingSplash.svelte @@ -0,0 +1,33 @@ +<script lang="ts"> + import { Server } from '@lucide/svelte'; + import { ServerStatus } from '$lib/components/app'; + import { fade } from 'svelte/transition'; + + interface Props { + class?: string; + message?: string; + } + + let { class: className = '', message = 'Initializing connection to llama.cpp server...' }: Props = + $props(); +</script> + +<div class="flex h-full items-center justify-center {className}"> + <div class="text-center"> + <div class="mb-4" in:fade={{ duration: 300 }}> + <div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted"> + <Server class="h-8 w-8 animate-pulse text-muted-foreground" /> + </div> + + <h2 class="mb-2 text-xl font-semibold">Connecting to Server</h2> + + <p class="text-sm text-muted-foreground"> + {message} + </p> + </div> + + <div class="mt-4"> + <ServerStatus class="justify-center" /> + </div> + </div> +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte new file mode 100644 index 0000000..d9f6d4a --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte @@ -0,0 +1,65 @@ +<script lang="ts"> + import { AlertTriangle, Server } from '@lucide/svelte'; + import { Badge } from '$lib/components/ui/badge'; + import { Button } from '$lib/components/ui/button'; + import { serverProps, serverLoading, serverError } from '$lib/stores/server.svelte'; + import { singleModelName } from '$lib/stores/models.svelte'; + + interface Props { + class?: string; + showActions?: boolean; + } + + let { class: className = '', showActions = false }: Props = $props(); + + let error = $derived(serverError()); + let loading = $derived(serverLoading()); + let model = $derived(singleModelName()); + let serverData = $derived(serverProps()); + + function getStatusColor() { + if (loading) return 'bg-yellow-500'; + if (error) return 'bg-red-500'; + if (serverData) return 'bg-green-500'; + + return 'bg-gray-500'; + } + + function getStatusText() { + if (loading) return 'Connecting...'; + if (error) return 'Connection Error'; + if (serverData) return 'Connected'; + + return 'Unknown'; + } +</script> + +<div class="flex items-center space-x-3 {className}"> + <div class="flex items-center space-x-2"> + <div class="h-2 w-2 rounded-full {getStatusColor()}"></div> + + <span class="text-sm text-muted-foreground">{getStatusText()}</span> + </div> + + {#if serverData && !error} + <Badge variant="outline" class="text-xs"> + <Server class="mr-1 h-3 w-3" /> + + {model || 'Unknown Model'} + </Badge> + + {#if serverData.default_generation_settings.n_ctx} + <Badge variant="secondary" class="text-xs"> + ctx: {serverData.default_generation_settings.n_ctx.toLocaleString()} + </Badge> + {/if} + {/if} + + {#if showActions && error} + <Button variant="outline" size="sm" class="text-destructive"> + <AlertTriangle class="h-4 w-4" /> + + {error} + </Button> + {/if} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte new file mode 100644 index 0000000..162107e --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte @@ -0,0 +1,18 @@ +<script lang="ts"> + import { AlertDialog as AlertDialogPrimitive } from 'bits-ui'; + import { buttonVariants } from '$lib/components/ui/button/index.js'; + import { cn } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: AlertDialogPrimitive.ActionProps = $props(); +</script> + +<AlertDialogPrimitive.Action + bind:ref + data-slot="alert-dialog-action" + class={cn(buttonVariants(), className)} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte new file mode 100644 index 0000000..6b3f354 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte @@ -0,0 +1,18 @@ +<script lang="ts"> + import { AlertDialog as AlertDialogPrimitive } from 'bits-ui'; + import { buttonVariants } from '$lib/components/ui/button/index.js'; + import { cn } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: AlertDialogPrimitive.CancelProps = $props(); +</script> + +<AlertDialogPrimitive.Cancel + bind:ref + data-slot="alert-dialog-cancel" + class={cn(buttonVariants({ variant: 'outline' }), className)} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte new file mode 100644 index 0000000..2398dae --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte @@ -0,0 +1,35 @@ +<script lang="ts"> + import { AlertDialog as AlertDialogPrimitive } from 'bits-ui'; + import AlertDialogOverlay from './alert-dialog-overlay.svelte'; + import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + portalProps, + ...restProps + }: WithoutChild<AlertDialogPrimitive.ContentProps> & { + portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>; + } = $props(); +</script> + +<AlertDialogPrimitive.Portal {...portalProps}> + <AlertDialogOverlay /> + <AlertDialogPrimitive.Content + bind:ref + data-slot="alert-dialog-content" + class={cn( + 'fixed z-[999999] grid w-full gap-4 border bg-background p-6 shadow-lg duration-200', + // Mobile: Bottom sheet behavior + 'right-0 bottom-0 left-0 max-h-[100dvh] translate-x-0 translate-y-0 overflow-y-auto rounded-t-lg', + 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-bottom-full', + 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-bottom-full', + // Desktop: Centered dialog behavior + 'sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-h-[100vh] sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-lg', + 'sm:data-[state=closed]:slide-out-to-bottom-0 sm:data-[state=closed]:zoom-out-95', + 'sm:data-[state=open]:slide-in-from-bottom-0 sm:data-[state=open]:zoom-in-95', + className + )} + {...restProps} + /> +</AlertDialogPrimitive.Portal> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte new file mode 100644 index 0000000..84735d8 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { AlertDialog as AlertDialogPrimitive } from 'bits-ui'; + import { cn } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: AlertDialogPrimitive.DescriptionProps = $props(); +</script> + +<AlertDialogPrimitive.Description + bind:ref + data-slot="alert-dialog-description" + class={cn('text-sm text-muted-foreground', className)} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte new file mode 100644 index 0000000..da0f7be --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="alert-dialog-footer" + class={cn( + 'mt-6 flex flex-row gap-2 sm:mt-0 sm:justify-end [&>*]:flex-1 sm:[&>*]:flex-none', + className + )} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte new file mode 100644 index 0000000..fa6539d --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="alert-dialog-header" + class={cn('flex flex-col gap-2 text-center sm:text-left', className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte new file mode 100644 index 0000000..71f166d --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { AlertDialog as AlertDialogPrimitive } from 'bits-ui'; + import { cn } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: AlertDialogPrimitive.OverlayProps = $props(); +</script> + +<AlertDialogPrimitive.Overlay + bind:ref + data-slot="alert-dialog-overlay" + class={cn( + 'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0', + className + )} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte new file mode 100644 index 0000000..4c610aa --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { AlertDialog as AlertDialogPrimitive } from 'bits-ui'; + import { cn } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: AlertDialogPrimitive.TitleProps = $props(); +</script> + +<AlertDialogPrimitive.Title + bind:ref + data-slot="alert-dialog-title" + class={cn('text-lg font-semibold', className)} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte new file mode 100644 index 0000000..51a3da1 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { AlertDialog as AlertDialogPrimitive } from 'bits-ui'; + + let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props(); +</script> + +<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} /> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/index.ts new file mode 100644 index 0000000..a4439bc --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/index.ts @@ -0,0 +1,39 @@ +import { AlertDialog as AlertDialogPrimitive } from 'bits-ui'; +import Trigger from './alert-dialog-trigger.svelte'; +import Title from './alert-dialog-title.svelte'; +import Action from './alert-dialog-action.svelte'; +import Cancel from './alert-dialog-cancel.svelte'; +import Footer from './alert-dialog-footer.svelte'; +import Header from './alert-dialog-header.svelte'; +import Overlay from './alert-dialog-overlay.svelte'; +import Content from './alert-dialog-content.svelte'; +import Description from './alert-dialog-description.svelte'; + +const Root = AlertDialogPrimitive.Root; +const Portal = AlertDialogPrimitive.Portal; + +export { + Root, + Title, + Action, + Cancel, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + // + Root as AlertDialog, + Title as AlertDialogTitle, + Action as AlertDialogAction, + Cancel as AlertDialogCancel, + Portal as AlertDialogPortal, + Footer as AlertDialogFooter, + Header as AlertDialogHeader, + Trigger as AlertDialogTrigger, + Overlay as AlertDialogOverlay, + Content as AlertDialogContent, + Description as AlertDialogDescription +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert-description.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert-description.svelte new file mode 100644 index 0000000..440d006 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert-description.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="alert-description" + class={cn( + 'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed', + className + )} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert-title.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert-title.svelte new file mode 100644 index 0000000..0721aeb --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert-title.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="alert-title" + class={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert.svelte new file mode 100644 index 0000000..7d79e4b --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert.svelte @@ -0,0 +1,44 @@ +<script lang="ts" module> + import { type VariantProps, tv } from 'tailwind-variants'; + + export const alertVariants = tv({ + base: 'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', + variants: { + variant: { + default: 'bg-card text-card-foreground', + destructive: + 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current' + } + }, + defaultVariants: { + variant: 'default' + } + }); + + export type AlertVariant = VariantProps<typeof alertVariants>['variant']; +</script> + +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + variant = 'default', + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { + variant?: AlertVariant; + } = $props(); +</script> + +<div + bind:this={ref} + data-slot="alert" + class={cn(alertVariants({ variant }), className)} + {...restProps} + role="alert" +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/alert/index.ts new file mode 100644 index 0000000..5e0f854 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert/index.ts @@ -0,0 +1,14 @@ +import Root from './alert.svelte'; +import Description from './alert-description.svelte'; +import Title from './alert-title.svelte'; +export { alertVariants, type AlertVariant } from './alert.svelte'; + +export { + Root, + Description, + Title, + // + Root as Alert, + Description as AlertDescription, + Title as AlertTitle +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/badge/badge.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/badge/badge.svelte new file mode 100644 index 0000000..4d15145 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/badge/badge.svelte @@ -0,0 +1,49 @@ +<script lang="ts" module> + import { type VariantProps, tv } from 'tailwind-variants'; + + export const badgeVariants = tv({ + base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3', + variants: { + variant: { + default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent', + secondary: + 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent', + destructive: + 'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white', + outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground' + } + }, + defaultVariants: { + variant: 'default' + } + }); + + export type BadgeVariant = VariantProps<typeof badgeVariants>['variant']; +</script> + +<script lang="ts"> + import type { HTMLAnchorAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils'; + + let { + ref = $bindable(null), + href, + class: className, + variant = 'default', + children, + ...restProps + }: WithElementRef<HTMLAnchorAttributes> & { + variant?: BadgeVariant; + } = $props(); +</script> + +<svelte:element + this={href ? 'a' : 'span'} + bind:this={ref} + data-slot="badge" + {href} + class={cn(badgeVariants({ variant }), className)} + {...restProps} +> + {@render children?.()} +</svelte:element> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/badge/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/badge/index.ts new file mode 100644 index 0000000..f05fb87 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/badge/index.ts @@ -0,0 +1,2 @@ +export { default as Badge } from './badge.svelte'; +export { badgeVariants, type BadgeVariant } from './badge.svelte'; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/button/button.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/button/button.svelte new file mode 100644 index 0000000..d12c8de --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/button/button.svelte @@ -0,0 +1,87 @@ +<script lang="ts" module> + import { cn, type WithElementRef } from '$lib/components/ui/utils'; + import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'; + import { type VariantProps, tv } from 'tailwind-variants'; + + export const buttonVariants = tv({ + base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + variants: { + variant: { + default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', + destructive: + 'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white', + outline: + 'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border', + secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 hover:underline' + }, + size: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9' + } + }, + defaultVariants: { + variant: 'default', + size: 'default' + } + }); + + export type ButtonVariant = VariantProps<typeof buttonVariants>['variant']; + export type ButtonSize = VariantProps<typeof buttonVariants>['size']; + + export type ButtonProps = WithElementRef<HTMLButtonAttributes> & + WithElementRef<HTMLAnchorAttributes> & { + variant?: ButtonVariant; + size?: ButtonSize; + }; +</script> + +<script lang="ts"> + let { + class: className, + variant = 'default', + size = 'default', + ref = $bindable(null), + href = undefined, + type = 'button', + disabled, + children, + ...restProps + }: ButtonProps = $props(); +</script> + +{#if href} + <a + bind:this={ref} + data-slot="button" + class={cn(buttonVariants({ variant, size }), className)} + href={disabled ? undefined : href} + aria-disabled={disabled} + role={disabled ? 'link' : undefined} + tabindex={disabled ? -1 : undefined} + {...restProps} + > + {@render children?.()} + </a> +{:else} + <button + bind:this={ref} + data-slot="button" + class={cn(buttonVariants({ variant, size }), className)} + {type} + {disabled} + {...restProps} + > + {@render children?.()} + </button> +{/if} + +<style> + a, + button { + cursor: pointer; + } +</style> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/button/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/button/index.ts new file mode 100644 index 0000000..5414d9d --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants +} from './button.svelte'; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-action.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-action.svelte new file mode 100644 index 0000000..0d4e965 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-action.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="card-action" + class={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 0000000..c68f613 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,15 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); +</script> + +<div bind:this={ref} data-slot="card-content" class={cn('px-6', className)} {...restProps}> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-description.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 0000000..81578df --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props(); +</script> + +<p + bind:this={ref} + data-slot="card-description" + class={cn('text-sm text-muted-foreground', className)} + {...restProps} +> + {@render children?.()} +</p> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-footer.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 0000000..0366459 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="card-footer" + class={cn('flex items-center px-6 [.border-t]:pt-6', className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-header.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 0000000..74ab163 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="card-header" + class={cn( + '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', + className + )} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-title.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 0000000..8dfc062 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="card-title" + class={cn('leading-none font-semibold', className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/card/card.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card.svelte new file mode 100644 index 0000000..c40d143 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="card" + class={cn( + 'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm', + className + )} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/card/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/card/index.ts new file mode 100644 index 0000000..77d3674 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/card/index.ts @@ -0,0 +1,25 @@ +import Root from './card.svelte'; +import Content from './card-content.svelte'; +import Description from './card-description.svelte'; +import Footer from './card-footer.svelte'; +import Header from './card-header.svelte'; +import Title from './card-title.svelte'; +import Action from './card-action.svelte'; + +export { + Root, + Content, + Description, + Footer, + Header, + Title, + Action, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, + Action as CardAction +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/checkbox/checkbox.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/checkbox/checkbox.svelte new file mode 100644 index 0000000..aafa071 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/checkbox/checkbox.svelte @@ -0,0 +1,36 @@ +<script lang="ts"> + import { Checkbox as CheckboxPrimitive } from 'bits-ui'; + import CheckIcon from '@lucide/svelte/icons/check'; + import MinusIcon from '@lucide/svelte/icons/minus'; + import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + checked = $bindable(false), + indeterminate = $bindable(false), + class: className, + ...restProps + }: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props(); +</script> + +<CheckboxPrimitive.Root + bind:ref + data-slot="checkbox" + class={cn( + 'peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary', + className + )} + bind:checked + bind:indeterminate + {...restProps} +> + {#snippet children({ checked, indeterminate })} + <div data-slot="checkbox-indicator" class="text-current transition-none"> + {#if checked} + <CheckIcon class="size-3.5" /> + {:else if indeterminate} + <MinusIcon class="size-3.5" /> + {/if} + </div> + {/snippet} +</CheckboxPrimitive.Root> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/checkbox/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/checkbox/index.ts new file mode 100644 index 0000000..5c27671 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/checkbox/index.ts @@ -0,0 +1,6 @@ +import Root from './checkbox.svelte'; +export { + Root, + // + Root as Checkbox +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible-content.svelte new file mode 100644 index 0000000..59b068c --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible-content.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { Collapsible as CollapsiblePrimitive } from 'bits-ui'; + + let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props(); +</script> + +<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} /> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible-trigger.svelte new file mode 100644 index 0000000..c88ceba --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible-trigger.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { Collapsible as CollapsiblePrimitive } from 'bits-ui'; + + let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props(); +</script> + +<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} /> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible.svelte new file mode 100644 index 0000000..7a8c5da --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible.svelte @@ -0,0 +1,11 @@ +<script lang="ts"> + import { Collapsible as CollapsiblePrimitive } from 'bits-ui'; + + let { + ref = $bindable(null), + open = $bindable(false), + ...restProps + }: CollapsiblePrimitive.RootProps = $props(); +</script> + +<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} /> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/index.ts new file mode 100644 index 0000000..8181f64 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/index.ts @@ -0,0 +1,13 @@ +import Root from './collapsible.svelte'; +import Trigger from './collapsible-trigger.svelte'; +import Content from './collapsible-content.svelte'; + +export { + Root, + Content, + Trigger, + // + Root as Collapsible, + Content as CollapsibleContent, + Trigger as CollapsibleTrigger +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-close.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-close.svelte new file mode 100644 index 0000000..e8a96a7 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-close.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { Dialog as DialogPrimitive } from 'bits-ui'; + + let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props(); +</script> + +<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} /> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-content.svelte new file mode 100644 index 0000000..74df0ea --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-content.svelte @@ -0,0 +1,43 @@ +<script lang="ts"> + import { Dialog as DialogPrimitive } from 'bits-ui'; + import XIcon from '@lucide/svelte/icons/x'; + import type { Snippet } from 'svelte'; + import * as Dialog from './index.js'; + import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils'; + + let { + ref = $bindable(null), + class: className, + portalProps, + children, + showCloseButton = true, + ...restProps + }: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & { + portalProps?: DialogPrimitive.PortalProps; + children: Snippet; + showCloseButton?: boolean; + } = $props(); +</script> + +<Dialog.Portal {...portalProps}> + <Dialog.Overlay /> + <DialogPrimitive.Content + bind:ref + data-slot="dialog-content" + class={cn( + `fixed top-[50%] left-[50%] z-50 grid max-h-[100dvh] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border border-border/30 bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg md:max-h-[100vh]`, + className + )} + {...restProps} + > + {@render children?.()} + {#if showCloseButton} + <DialogPrimitive.Close + class="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" + > + <XIcon /> + <span class="sr-only">Close</span> + </DialogPrimitive.Close> + {/if} + </DialogPrimitive.Content> +</Dialog.Portal> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-description.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-description.svelte new file mode 100644 index 0000000..6c0c192 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-description.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { Dialog as DialogPrimitive } from 'bits-ui'; + import { cn } from '$lib/components/ui/utils'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: DialogPrimitive.DescriptionProps = $props(); +</script> + +<DialogPrimitive.Description + bind:ref + data-slot="dialog-description" + class={cn('text-sm text-muted-foreground', className)} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-footer.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-footer.svelte new file mode 100644 index 0000000..abf948f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-footer.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="dialog-footer" + class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-header.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-header.svelte new file mode 100644 index 0000000..7ba9ba1 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-header.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="dialog-header" + class={cn('flex flex-col gap-2 text-center sm:text-left', className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-overlay.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-overlay.svelte new file mode 100644 index 0000000..a6e9a10 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { Dialog as DialogPrimitive } from 'bits-ui'; + import { cn } from '$lib/components/ui/utils'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: DialogPrimitive.OverlayProps = $props(); +</script> + +<DialogPrimitive.Overlay + bind:ref + data-slot="dialog-overlay" + class={cn( + 'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0', + className + )} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-title.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-title.svelte new file mode 100644 index 0000000..e8c99c5 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-title.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { Dialog as DialogPrimitive } from 'bits-ui'; + import { cn } from '$lib/components/ui/utils'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: DialogPrimitive.TitleProps = $props(); +</script> + +<DialogPrimitive.Title + bind:ref + data-slot="dialog-title" + class={cn('text-lg leading-none font-semibold', className)} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-trigger.svelte new file mode 100644 index 0000000..ac04d9f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-trigger.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { Dialog as DialogPrimitive } from 'bits-ui'; + + let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props(); +</script> + +<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} /> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/index.ts new file mode 100644 index 0000000..d9e5fb8 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/index.ts @@ -0,0 +1,37 @@ +import { Dialog as DialogPrimitive } from 'bits-ui'; + +import Title from './dialog-title.svelte'; +import Footer from './dialog-footer.svelte'; +import Header from './dialog-header.svelte'; +import Overlay from './dialog-overlay.svelte'; +import Content from './dialog-content.svelte'; +import Description from './dialog-description.svelte'; +import Trigger from './dialog-trigger.svelte'; +import Close from './dialog-close.svelte'; + +const Root = DialogPrimitive.Root; +const Portal = DialogPrimitive.Portal; + +export { + Root, + Title, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + Close, + // + Root as Dialog, + Title as DialogTitle, + Portal as DialogPortal, + Footer as DialogFooter, + Header as DialogHeader, + Trigger as DialogTrigger, + Overlay as DialogOverlay, + Content as DialogContent, + Description as DialogDescription, + Close as DialogClose +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 0000000..e71acef --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,41 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'; + import CheckIcon from '@lucide/svelte/icons/check'; + import MinusIcon from '@lucide/svelte/icons/minus'; + import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js'; + import type { Snippet } from 'svelte'; + + let { + ref = $bindable(null), + checked = $bindable(false), + indeterminate = $bindable(false), + class: className, + children: childrenProp, + ...restProps + }: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & { + children?: Snippet; + } = $props(); +</script> + +<DropdownMenuPrimitive.CheckboxItem + bind:ref + bind:checked + bind:indeterminate + data-slot="dropdown-menu-checkbox-item" + class={cn( + "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className + )} + {...restProps} +> + {#snippet children({ checked, indeterminate })} + <span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> + {#if indeterminate} + <MinusIcon class="size-4" /> + {:else} + <CheckIcon class={cn('size-4', !checked && 'text-transparent')} /> + {/if} + </span> + {@render childrenProp?.()} + {/snippet} +</DropdownMenuPrimitive.CheckboxItem> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 0000000..869c38e --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,27 @@ +<script lang="ts"> + import { cn } from '$lib/components/ui/utils.js'; + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'; + + let { + ref = $bindable(null), + sideOffset = 4, + portalProps, + class: className, + ...restProps + }: DropdownMenuPrimitive.ContentProps & { + portalProps?: DropdownMenuPrimitive.PortalProps; + } = $props(); +</script> + +<DropdownMenuPrimitive.Portal {...portalProps}> + <DropdownMenuPrimitive.Content + bind:ref + data-slot="dropdown-menu-content" + {sideOffset} + class={cn( + 'z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20', + className + )} + {...restProps} + /> +</DropdownMenuPrimitive.Portal> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte new file mode 100644 index 0000000..f217966 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'; + import { cn } from '$lib/components/ui/utils.js'; + import type { ComponentProps } from 'svelte'; + + let { + ref = $bindable(null), + class: className, + inset, + ...restProps + }: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & { + inset?: boolean; + } = $props(); +</script> + +<DropdownMenuPrimitive.GroupHeading + bind:ref + data-slot="dropdown-menu-group-heading" + data-inset={inset} + class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte new file mode 100644 index 0000000..261ab7e --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'; + + let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props(); +</script> + +<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} /> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 0000000..1ac5615 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,27 @@ +<script lang="ts"> + import { cn } from '$lib/components/ui/utils.js'; + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'; + + let { + ref = $bindable(null), + class: className, + inset, + variant = 'default', + ...restProps + }: DropdownMenuPrimitive.ItemProps & { + inset?: boolean; + variant?: 'default' | 'destructive'; + } = $props(); +</script> + +<DropdownMenuPrimitive.Item + bind:ref + data-slot="dropdown-menu-item" + data-inset={inset} + data-variant={variant} + class={cn( + "relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 data-[variant=destructive]:data-highlighted:text-destructive dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:!text-destructive", + className + )} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte new file mode 100644 index 0000000..15b546e --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + inset, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { + inset?: boolean; + } = $props(); +</script> + +<div + bind:this={ref} + data-slot="dropdown-menu-label" + data-inset={inset} + class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte new file mode 100644 index 0000000..3e98749 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -0,0 +1,16 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'; + + let { + ref = $bindable(null), + value = $bindable(), + ...restProps + }: DropdownMenuPrimitive.RadioGroupProps = $props(); +</script> + +<DropdownMenuPrimitive.RadioGroup + bind:ref + bind:value + data-slot="dropdown-menu-radio-group" + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 0000000..97ba772 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,31 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'; + import CircleIcon from '@lucide/svelte/icons/circle'; + import { cn, type WithoutChild } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + children: childrenProp, + ...restProps + }: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props(); +</script> + +<DropdownMenuPrimitive.RadioItem + bind:ref + data-slot="dropdown-menu-radio-item" + class={cn( + "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className + )} + {...restProps} +> + {#snippet children({ checked })} + <span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> + {#if checked} + <CircleIcon class="size-2 fill-current" /> + {/if} + </span> + {@render childrenProp?.({ checked })} + {/snippet} +</DropdownMenuPrimitive.RadioItem> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 0000000..17b64ac --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'; + import { cn } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: DropdownMenuPrimitive.SeparatorProps = $props(); +</script> + +<DropdownMenuPrimitive.Separator + bind:ref + data-slot="dropdown-menu-separator" + class={cn('-mx-1 my-1 h-px bg-border/20', className)} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte new file mode 100644 index 0000000..c3ccc21 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props(); +</script> + +<span + bind:this={ref} + data-slot="dropdown-menu-shortcut" + class={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} + {...restProps} +> + {@render children?.()} +</span> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 0000000..3ceb165 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'; + import { cn } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: DropdownMenuPrimitive.SubContentProps = $props(); +</script> + +<DropdownMenuPrimitive.SubContent + bind:ref + data-slot="dropdown-menu-sub-content" + class={cn( + 'z-50 min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', + className + )} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 0000000..550a789 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,29 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'; + import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'; + import { cn } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + inset, + children, + ...restProps + }: DropdownMenuPrimitive.SubTriggerProps & { + inset?: boolean; + } = $props(); +</script> + +<DropdownMenuPrimitive.SubTrigger + bind:ref + data-slot="dropdown-menu-sub-trigger" + data-inset={inset} + class={cn( + "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", + className + )} + {...restProps} +> + {@render children?.()} + <ChevronRightIcon class="ml-auto size-4" /> +</DropdownMenuPrimitive.SubTrigger> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte new file mode 100644 index 0000000..032b645 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'; + + let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props(); +</script> + +<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} /> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/index.ts new file mode 100644 index 0000000..aeb398e --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/index.ts @@ -0,0 +1,49 @@ +import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'; +import CheckboxItem from './dropdown-menu-checkbox-item.svelte'; +import Content from './dropdown-menu-content.svelte'; +import Group from './dropdown-menu-group.svelte'; +import Item from './dropdown-menu-item.svelte'; +import Label from './dropdown-menu-label.svelte'; +import RadioGroup from './dropdown-menu-radio-group.svelte'; +import RadioItem from './dropdown-menu-radio-item.svelte'; +import Separator from './dropdown-menu-separator.svelte'; +import Shortcut from './dropdown-menu-shortcut.svelte'; +import Trigger from './dropdown-menu-trigger.svelte'; +import SubContent from './dropdown-menu-sub-content.svelte'; +import SubTrigger from './dropdown-menu-sub-trigger.svelte'; +import GroupHeading from './dropdown-menu-group-heading.svelte'; +const Sub = DropdownMenuPrimitive.Sub; +const Root = DropdownMenuPrimitive.Root; + +export { + CheckboxItem, + Content, + Root as DropdownMenu, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + GroupHeading as DropdownMenuGroupHeading, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/input/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/input/index.ts new file mode 100644 index 0000000..15c0933 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/input/index.ts @@ -0,0 +1,7 @@ +import Root from './input.svelte'; + +export { + Root, + // + Root as Input +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/input/input.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..889b720 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/input/input.svelte @@ -0,0 +1,51 @@ +<script lang="ts"> + import type { HTMLInputAttributes, HTMLInputTypeAttribute } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils'; + + type InputType = Exclude<HTMLInputTypeAttribute, 'file'>; + + type Props = WithElementRef< + Omit<HTMLInputAttributes, 'type'> & + ({ type: 'file'; files?: FileList } | { type?: InputType; files?: undefined }) + >; + + let { + ref = $bindable(null), + value = $bindable(), + type, + files = $bindable(), + class: className, + ...restProps + }: Props = $props(); +</script> + +{#if type === 'file'} + <input + bind:this={ref} + data-slot="input" + class={cn( + 'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30', + 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50', + 'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40', + className + )} + type="file" + bind:files + bind:value + {...restProps} + /> +{:else} + <input + bind:this={ref} + data-slot="input" + class={cn( + 'flex h-9 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30', + 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50', + 'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40', + className + )} + {type} + bind:value + {...restProps} + /> +{/if} diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/label/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/label/index.ts new file mode 100644 index 0000000..808d141 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/label/index.ts @@ -0,0 +1,7 @@ +import Root from './label.svelte'; + +export { + Root, + // + Root as Label +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/label/label.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/label/label.svelte new file mode 100644 index 0000000..9da4ae3 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/label/label.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { Label as LabelPrimitive } from 'bits-ui'; + import { cn } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: LabelPrimitive.RootProps = $props(); +</script> + +<LabelPrimitive.Root + bind:ref + data-slot="label" + class={cn( + 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50', + className + )} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/popover/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/index.ts new file mode 100644 index 0000000..c5937fb --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/index.ts @@ -0,0 +1,19 @@ +import Root from './popover.svelte'; +import Close from './popover-close.svelte'; +import Content from './popover-content.svelte'; +import Trigger from './popover-trigger.svelte'; +import Portal from './popover-portal.svelte'; + +export { + Root, + Content, + Trigger, + Close, + Portal, + // + Root as Popover, + Content as PopoverContent, + Trigger as PopoverTrigger, + Close as PopoverClose, + Portal as PopoverPortal +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-close.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-close.svelte new file mode 100644 index 0000000..dc4dec4 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-close.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { Popover as PopoverPrimitive } from 'bits-ui'; + + let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props(); +</script> + +<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} /> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-content.svelte new file mode 100644 index 0000000..2d3513d --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-content.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import { Popover as PopoverPrimitive } from 'bits-ui'; + import PopoverPortal from './popover-portal.svelte'; + import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js'; + import type { ComponentProps } from 'svelte'; + + let { + ref = $bindable(null), + class: className, + sideOffset = 4, + side, + align = 'center', + collisionPadding = 8, + avoidCollisions = true, + portalProps, + ...restProps + }: PopoverPrimitive.ContentProps & { + portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>; + } = $props(); +</script> + +<PopoverPortal {...portalProps}> + <PopoverPrimitive.Content + bind:ref + data-slot="popover-content" + {sideOffset} + {side} + {align} + {collisionPadding} + {avoidCollisions} + class={cn( + 'z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', + className + )} + {...restProps} + /> +</PopoverPortal> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-portal.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-portal.svelte new file mode 100644 index 0000000..25efb87 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-portal.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { Popover as PopoverPrimitive } from 'bits-ui'; + + let { ...restProps }: PopoverPrimitive.PortalProps = $props(); +</script> + +<PopoverPrimitive.Portal {...restProps} /> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-trigger.svelte new file mode 100644 index 0000000..5ef3d0e --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-trigger.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { cn } from '$lib/components/ui/utils.js'; + import { Popover as PopoverPrimitive } from 'bits-ui'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: PopoverPrimitive.TriggerProps = $props(); +</script> + +<PopoverPrimitive.Trigger + bind:ref + data-slot="popover-trigger" + class={cn('', className)} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover.svelte new file mode 100644 index 0000000..f39b867 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { Popover as PopoverPrimitive } from 'bits-ui'; + + let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props(); +</script> + +<PopoverPrimitive.Root bind:open {...restProps} /> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/index.ts new file mode 100644 index 0000000..d546806 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/index.ts @@ -0,0 +1,10 @@ +import Scrollbar from './scroll-area-scrollbar.svelte'; +import Root from './scroll-area.svelte'; + +export { + Root, + Scrollbar, + //, + Root as ScrollArea, + Scrollbar as ScrollAreaScrollbar +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte new file mode 100644 index 0000000..3f0d00d --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte @@ -0,0 +1,31 @@ +<script lang="ts"> + import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui'; + import { cn, type WithoutChild } from '$lib/components/ui/utils'; + + let { + ref = $bindable(null), + class: className, + orientation = 'vertical', + children, + ...restProps + }: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props(); +</script> + +<ScrollAreaPrimitive.Scrollbar + bind:ref + data-slot="scroll-area-scrollbar" + {orientation} + class={cn( + 'flex touch-none p-px transition-colors select-none', + orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent', + orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent', + className + )} + {...restProps} +> + {@render children?.()} + <ScrollAreaPrimitive.Thumb + data-slot="scroll-area-thumb" + class="relative flex-1 rounded-full bg-border" + /> +</ScrollAreaPrimitive.Scrollbar> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/scroll-area.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/scroll-area.svelte new file mode 100644 index 0000000..ba6f838 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/scroll-area.svelte @@ -0,0 +1,40 @@ +<script lang="ts"> + import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui'; + import { Scrollbar } from './index.js'; + import { cn, type WithoutChild } from '$lib/components/ui/utils'; + + let { + ref = $bindable(null), + class: className, + orientation = 'vertical', + scrollbarXClasses = '', + scrollbarYClasses = '', + children, + ...restProps + }: WithoutChild<ScrollAreaPrimitive.RootProps> & { + orientation?: 'vertical' | 'horizontal' | 'both' | undefined; + scrollbarXClasses?: string | undefined; + scrollbarYClasses?: string | undefined; + } = $props(); +</script> + +<ScrollAreaPrimitive.Root + bind:ref + data-slot="scroll-area" + class={cn('relative', className)} + {...restProps} +> + <ScrollAreaPrimitive.Viewport + data-slot="scroll-area-viewport" + class="size-full rounded-[inherit] ring-ring/10 outline-ring/50 transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 dark:ring-ring/20 dark:outline-ring/40" + > + {@render children?.()} + </ScrollAreaPrimitive.Viewport> + {#if orientation === 'vertical' || orientation === 'both'} + <Scrollbar orientation="vertical" class={scrollbarYClasses} /> + {/if} + {#if orientation === 'horizontal' || orientation === 'both'} + <Scrollbar orientation="horizontal" class={scrollbarXClasses} /> + {/if} + <ScrollAreaPrimitive.Corner /> +</ScrollAreaPrimitive.Root> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/select/index.ts new file mode 100644 index 0000000..bfa73d9 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/index.ts @@ -0,0 +1,37 @@ +import { Select as SelectPrimitive } from 'bits-ui'; + +import Group from './select-group.svelte'; +import Label from './select-label.svelte'; +import Item from './select-item.svelte'; +import Content from './select-content.svelte'; +import Trigger from './select-trigger.svelte'; +import Separator from './select-separator.svelte'; +import ScrollDownButton from './select-scroll-down-button.svelte'; +import ScrollUpButton from './select-scroll-up-button.svelte'; +import GroupHeading from './select-group-heading.svelte'; + +const Root = SelectPrimitive.Root; + +export { + Root, + Group, + Label, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + GroupHeading, + // + Root as Select, + Group as SelectGroup, + Label as SelectLabel, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, + GroupHeading as SelectGroupHeading +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 0000000..4050628 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,111 @@ +<script lang="ts"> + import { onDestroy, onMount } from 'svelte'; + import { Select as SelectPrimitive } from 'bits-ui'; + import SelectScrollUpButton from './select-scroll-up-button.svelte'; + import SelectScrollDownButton from './select-scroll-down-button.svelte'; + import { cn, type WithoutChild } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + sideOffset = 4, + portalProps, + children, + ...restProps + }: WithoutChild<SelectPrimitive.ContentProps> & { + portalProps?: SelectPrimitive.PortalProps; + } = $props(); + + let cleanupInternalListeners: (() => void) | undefined; + + onMount(() => { + const listenerOptions: AddEventListenerOptions = { passive: false }; + + const blockOutsideWheel = (event: WheelEvent) => { + if (!ref) { + return; + } + + const target = event.target as Node | null; + + if (!target || !ref.contains(target)) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + const blockOutsideTouchMove = (event: TouchEvent) => { + if (!ref) { + return; + } + + const target = event.target as Node | null; + + if (!target || !ref.contains(target)) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + document.addEventListener('wheel', blockOutsideWheel, listenerOptions); + document.addEventListener('touchmove', blockOutsideTouchMove, listenerOptions); + + return () => { + document.removeEventListener('wheel', blockOutsideWheel, listenerOptions); + document.removeEventListener('touchmove', blockOutsideTouchMove, listenerOptions); + }; + }); + + $effect(() => { + const element = ref; + + cleanupInternalListeners?.(); + + if (!element) { + return; + } + + const stopWheelPropagation = (event: WheelEvent) => { + event.stopPropagation(); + }; + + const stopTouchPropagation = (event: TouchEvent) => { + event.stopPropagation(); + }; + + element.addEventListener('wheel', stopWheelPropagation); + element.addEventListener('touchmove', stopTouchPropagation); + + cleanupInternalListeners = () => { + element.removeEventListener('wheel', stopWheelPropagation); + element.removeEventListener('touchmove', stopTouchPropagation); + }; + }); + + onDestroy(() => { + cleanupInternalListeners?.(); + }); +</script> + +<SelectPrimitive.Portal {...portalProps}> + <SelectPrimitive.Content + bind:ref + {sideOffset} + data-slot="select-content" + class={cn( + 'relative z-[var(--layer-popover,1000000)] max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:-translate-x-1 data-[side=left]:slide-in-from-right-2 data-[side=right]:translate-x-1 data-[side=right]:slide-in-from-left-2 data-[side=top]:-translate-y-1 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', + className + )} + {...restProps} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + class={cn( + 'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1' + )} + > + {@render children?.()} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> +</SelectPrimitive.Portal> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-group-heading.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..77c2042 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import { Select as SelectPrimitive } from 'bits-ui'; + import { cn } from '$lib/components/ui/utils.js'; + import type { ComponentProps } from 'svelte'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props(); +</script> + +<SelectPrimitive.GroupHeading + bind:ref + data-slot="select-group-heading" + class={cn('px-2 py-1.5 text-xs text-muted-foreground', className)} + {...restProps} +> + {@render children?.()} +</SelectPrimitive.GroupHeading> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-group.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-group.svelte new file mode 100644 index 0000000..2520795 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-group.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { Select as SelectPrimitive } from 'bits-ui'; + + let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props(); +</script> + +<SelectPrimitive.Group data-slot="select-group" {...restProps} /> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-item.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 0000000..02543c1 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,38 @@ +<script lang="ts"> + import CheckIcon from '@lucide/svelte/icons/check'; + import { Select as SelectPrimitive } from 'bits-ui'; + import { cn, type WithoutChild } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + value, + label, + children: childrenProp, + ...restProps + }: WithoutChild<SelectPrimitive.ItemProps> = $props(); +</script> + +<SelectPrimitive.Item + bind:ref + {value} + data-slot="select-item" + class={cn( + "relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", + className + )} + {...restProps} +> + {#snippet children({ selected, highlighted })} + <span class="absolute right-2 flex size-3.5 items-center justify-center"> + {#if selected} + <CheckIcon class="size-4" /> + {/if} + </span> + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + {/snippet} +</SelectPrimitive.Item> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-label.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-label.svelte new file mode 100644 index 0000000..e2b830c --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-label.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props(); +</script> + +<div + bind:this={ref} + data-slot="select-label" + class={cn('px-2 py-1.5 text-xs text-muted-foreground', className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-scroll-down-button.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..9256dd8 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; + import { Select as SelectPrimitive } from 'bits-ui'; + import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props(); +</script> + +<SelectPrimitive.ScrollDownButton + bind:ref + data-slot="select-scroll-down-button" + class={cn('flex cursor-default items-center justify-center py-1', className)} + {...restProps} +> + <ChevronDownIcon class="size-4" /> +</SelectPrimitive.ScrollDownButton> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-scroll-up-button.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..552e527 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import ChevronUpIcon from '@lucide/svelte/icons/chevron-up'; + import { Select as SelectPrimitive } from 'bits-ui'; + import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props(); +</script> + +<SelectPrimitive.ScrollUpButton + bind:ref + data-slot="select-scroll-up-button" + class={cn('flex cursor-default items-center justify-center py-1', className)} + {...restProps} +> + <ChevronUpIcon class="size-4" /> +</SelectPrimitive.ScrollUpButton> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-separator.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 0000000..7daaa8d --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,18 @@ +<script lang="ts"> + import type { Separator as SeparatorPrimitive } from 'bits-ui'; + import { Separator } from '$lib/components/ui/separator/index.js'; + import { cn } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: SeparatorPrimitive.RootProps = $props(); +</script> + +<Separator + bind:ref + data-slot="select-separator" + class={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 0000000..5bc28ee --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,40 @@ +<script lang="ts"> + import { Select as SelectPrimitive } from 'bits-ui'; + import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; + import { cn, type WithoutChild } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + children, + size = 'default', + variant = 'default', + ...restProps + }: WithoutChild<SelectPrimitive.TriggerProps> & { + size?: 'sm' | 'default'; + variant?: 'default' | 'plain'; + } = $props(); + + const baseClasses = $derived( + variant === 'plain' + ? "group inline-flex w-full items-center justify-end gap-2 whitespace-nowrap px-0 py-0 text-sm font-medium text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3 [&_svg:not([class*='text-'])]:text-muted-foreground" + : "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground" + ); + + const chevronClasses = $derived( + variant === 'plain' + ? 'size-3 opacity-60 transition-transform group-data-[state=open]:-rotate-180' + : 'size-4 opacity-50' + ); +</script> + +<SelectPrimitive.Trigger + bind:ref + data-slot="select-trigger" + data-size={size} + class={cn(baseClasses, className)} + {...restProps} +> + {@render children?.()} + <ChevronDownIcon class={chevronClasses} /> +</SelectPrimitive.Trigger> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/separator/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..768efac --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from './separator.svelte'; + +export { + Root, + // + Root as Separator +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/separator/separator.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..00307fd --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { Separator as SeparatorPrimitive } from 'bits-ui'; + import { cn } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: SeparatorPrimitive.RootProps = $props(); +</script> + +<SeparatorPrimitive.Root + bind:ref + data-slot="separator" + class={cn( + 'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px', + className + )} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/index.ts new file mode 100644 index 0000000..139e2d2 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/index.ts @@ -0,0 +1,36 @@ +import { Dialog as SheetPrimitive } from 'bits-ui'; +import Trigger from './sheet-trigger.svelte'; +import Close from './sheet-close.svelte'; +import Overlay from './sheet-overlay.svelte'; +import Content from './sheet-content.svelte'; +import Header from './sheet-header.svelte'; +import Footer from './sheet-footer.svelte'; +import Title from './sheet-title.svelte'; +import Description from './sheet-description.svelte'; + +const Root = SheetPrimitive.Root; +const Portal = SheetPrimitive.Portal; + +export { + Root, + Close, + Trigger, + Portal, + Overlay, + Content, + Header, + Footer, + Title, + Description, + // + Root as Sheet, + Close as SheetClose, + Trigger as SheetTrigger, + Portal as SheetPortal, + Overlay as SheetOverlay, + Content as SheetContent, + Header as SheetHeader, + Footer as SheetFooter, + Title as SheetTitle, + Description as SheetDescription +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-close.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-close.svelte new file mode 100644 index 0000000..b0180c0 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-close.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { Dialog as SheetPrimitive } from 'bits-ui'; + + let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props(); +</script> + +<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} /> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-content.svelte new file mode 100644 index 0000000..f16a0e0 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-content.svelte @@ -0,0 +1,60 @@ +<script lang="ts" module> + import { tv, type VariantProps } from 'tailwind-variants'; + export const sheetVariants = tv({ + base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500', + variants: { + side: { + top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b', + bottom: + 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t', + left: 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm', + right: + 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm' + } + }, + defaultVariants: { + side: 'right' + } + }); + + export type Side = VariantProps<typeof sheetVariants>['side']; +</script> + +<script lang="ts"> + import { Dialog as SheetPrimitive } from 'bits-ui'; + import XIcon from '@lucide/svelte/icons/x'; + import type { Snippet } from 'svelte'; + import SheetOverlay from './sheet-overlay.svelte'; + import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + side = 'right', + portalProps, + children, + ...restProps + }: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & { + portalProps?: SheetPrimitive.PortalProps; + side?: Side; + children: Snippet; + } = $props(); +</script> + +<SheetPrimitive.Portal {...portalProps}> + <SheetOverlay /> + <SheetPrimitive.Content + bind:ref + data-slot="sheet-content" + class={cn(sheetVariants({ side }), className)} + {...restProps} + > + {@render children?.()} + <SheetPrimitive.Close + class="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none" + > + <XIcon class="size-4" /> + <span class="sr-only">Close</span> + </SheetPrimitive.Close> + </SheetPrimitive.Content> +</SheetPrimitive.Portal> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-description.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-description.svelte new file mode 100644 index 0000000..ef4d58f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-description.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { Dialog as SheetPrimitive } from 'bits-ui'; + import { cn } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: SheetPrimitive.DescriptionProps = $props(); +</script> + +<SheetPrimitive.Description + bind:ref + data-slot="sheet-description" + class={cn('text-sm text-muted-foreground', className)} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-footer.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-footer.svelte new file mode 100644 index 0000000..4e1b927 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-footer.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="sheet-footer" + class={cn('mt-auto flex flex-col gap-2 p-4', className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-header.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-header.svelte new file mode 100644 index 0000000..6c6c1ec --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-header.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="sheet-header" + class={cn('flex flex-col gap-1.5 p-4', className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-overlay.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-overlay.svelte new file mode 100644 index 0000000..a6a064f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-overlay.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { Dialog as SheetPrimitive } from 'bits-ui'; + import { cn } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: SheetPrimitive.OverlayProps = $props(); +</script> + +<SheetPrimitive.Overlay + bind:ref + data-slot="sheet-overlay" + class={cn( + 'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0', + className + )} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-title.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-title.svelte new file mode 100644 index 0000000..0efcc7a --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-title.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { Dialog as SheetPrimitive } from 'bits-ui'; + import { cn } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: SheetPrimitive.TitleProps = $props(); +</script> + +<SheetPrimitive.Title + bind:ref + data-slot="sheet-title" + class={cn('font-semibold text-foreground', className)} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-trigger.svelte new file mode 100644 index 0000000..d95719a --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-trigger.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { Dialog as SheetPrimitive } from 'bits-ui'; + + let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props(); +</script> + +<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} /> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/constants.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/constants.ts new file mode 100644 index 0000000..c7e827b --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/constants.ts @@ -0,0 +1,6 @@ +export const SIDEBAR_COOKIE_NAME = 'sidebar:state'; +export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +export const SIDEBAR_WIDTH = '18rem'; +export const SIDEBAR_WIDTH_MOBILE = '18rem'; +export const SIDEBAR_WIDTH_ICON = '3rem'; +export const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/context.svelte.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/context.svelte.ts new file mode 100644 index 0000000..6fa2aa3 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/context.svelte.ts @@ -0,0 +1,79 @@ +import { IsMobile } from '$lib/hooks/is-mobile.svelte.js'; +import { getContext, setContext } from 'svelte'; +import { SIDEBAR_KEYBOARD_SHORTCUT } from './constants.js'; + +type Getter<T> = () => T; + +export type SidebarStateProps = { + /** + * A getter function that returns the current open state of the sidebar. + * We use a getter function here to support `bind:open` on the `Sidebar.Provider` + * component. + */ + open: Getter<boolean>; + + /** + * A function that sets the open state of the sidebar. To support `bind:open`, we need + * a source of truth for changing the open state to ensure it will be synced throughout + * the sub-components and any `bind:` references. + */ + setOpen: (open: boolean) => void; +}; + +class SidebarState { + readonly props: SidebarStateProps; + open = $derived.by(() => this.props.open()); + openMobile = $state(false); + setOpen: SidebarStateProps['setOpen']; + #isMobile: IsMobile; + state = $derived.by(() => (this.open ? 'expanded' : 'collapsed')); + + constructor(props: SidebarStateProps) { + this.setOpen = props.setOpen; + this.#isMobile = new IsMobile(); + this.props = props; + } + + // Convenience getter for checking if the sidebar is mobile + // without this, we would need to use `sidebar.isMobile.current` everywhere + get isMobile() { + return this.#isMobile.current; + } + + // Event handler to apply to the `<svelte:window>` + handleShortcutKeydown = (e: KeyboardEvent) => { + if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + this.toggle(); + } + }; + + setOpenMobile = (value: boolean) => { + this.openMobile = value; + }; + + toggle = () => { + return this.#isMobile.current ? (this.openMobile = !this.openMobile) : this.setOpen(!this.open); + }; +} + +const SYMBOL_KEY = 'scn-sidebar'; + +/** + * Instantiates a new `SidebarState` instance and sets it in the context. + * + * @param props The constructor props for the `SidebarState` class. + * @returns The `SidebarState` instance. + */ +export function setSidebar(props: SidebarStateProps): SidebarState { + return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props)); +} + +/** + * Retrieves the `SidebarState` instance from the context. This is a class instance, + * so you cannot destructure it. + * @returns The `SidebarState` instance. + */ +export function useSidebar(): SidebarState { + return getContext(Symbol.for(SYMBOL_KEY)); +} diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/index.ts new file mode 100644 index 0000000..280e640 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/index.ts @@ -0,0 +1,75 @@ +import { useSidebar } from './context.svelte.js'; +import Content from './sidebar-content.svelte'; +import Footer from './sidebar-footer.svelte'; +import GroupAction from './sidebar-group-action.svelte'; +import GroupContent from './sidebar-group-content.svelte'; +import GroupLabel from './sidebar-group-label.svelte'; +import Group from './sidebar-group.svelte'; +import Header from './sidebar-header.svelte'; +import Input from './sidebar-input.svelte'; +import Inset from './sidebar-inset.svelte'; +import MenuAction from './sidebar-menu-action.svelte'; +import MenuBadge from './sidebar-menu-badge.svelte'; +import MenuButton from './sidebar-menu-button.svelte'; +import MenuItem from './sidebar-menu-item.svelte'; +import MenuSkeleton from './sidebar-menu-skeleton.svelte'; +import MenuSubButton from './sidebar-menu-sub-button.svelte'; +import MenuSubItem from './sidebar-menu-sub-item.svelte'; +import MenuSub from './sidebar-menu-sub.svelte'; +import Menu from './sidebar-menu.svelte'; +import Provider from './sidebar-provider.svelte'; +import Rail from './sidebar-rail.svelte'; +import Separator from './sidebar-separator.svelte'; +import Trigger from './sidebar-trigger.svelte'; +import Root from './sidebar.svelte'; + +export { + Content, + Footer, + Group, + GroupAction, + GroupContent, + GroupLabel, + Header, + Input, + Inset, + Menu, + MenuAction, + MenuBadge, + MenuButton, + MenuItem, + MenuSkeleton, + MenuSub, + MenuSubButton, + MenuSubItem, + Provider, + Rail, + Root, + Separator, + // + Root as Sidebar, + Content as SidebarContent, + Footer as SidebarFooter, + Group as SidebarGroup, + GroupAction as SidebarGroupAction, + GroupContent as SidebarGroupContent, + GroupLabel as SidebarGroupLabel, + Header as SidebarHeader, + Input as SidebarInput, + Inset as SidebarInset, + Menu as SidebarMenu, + MenuAction as SidebarMenuAction, + MenuBadge as SidebarMenuBadge, + MenuButton as SidebarMenuButton, + MenuItem as SidebarMenuItem, + MenuSkeleton as SidebarMenuSkeleton, + MenuSub as SidebarMenuSub, + MenuSubButton as SidebarMenuSubButton, + MenuSubItem as SidebarMenuSubItem, + Provider as SidebarProvider, + Rail as SidebarRail, + Separator as SidebarSeparator, + Trigger as SidebarTrigger, + Trigger, + useSidebar +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-content.svelte new file mode 100644 index 0000000..0e5f75e --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-content.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="sidebar-content" + data-sidebar="content" + class={cn( + 'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', + className + )} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-footer.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-footer.svelte new file mode 100644 index 0000000..67be0a4 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-footer.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="sidebar-footer" + data-sidebar="footer" + class={cn('flex flex-col gap-2 p-2', className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-action.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-action.svelte new file mode 100644 index 0000000..027a711 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-action.svelte @@ -0,0 +1,36 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { Snippet } from 'svelte'; + import type { HTMLButtonAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + child, + ...restProps + }: WithElementRef<HTMLButtonAttributes> & { + child?: Snippet<[{ props: Record<string, unknown> }]>; + } = $props(); + + const mergedProps = $derived({ + class: cn( + 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground outline-hidden absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', + // Increases the hit area of the button on mobile. + 'after:absolute after:-inset-2 md:after:hidden', + 'group-data-[collapsible=icon]:hidden', + className + ), + 'data-slot': 'sidebar-group-action', + 'data-sidebar': 'group-action', + ...restProps + }); +</script> + +{#if child} + {@render child({ props: mergedProps })} +{:else} + <button bind:this={ref} {...mergedProps}> + {@render children?.()} + </button> +{/if} diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-content.svelte new file mode 100644 index 0000000..9e018fb --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-content.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="sidebar-group-content" + data-sidebar="group-content" + class={cn('w-full text-sm', className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-label.svelte new file mode 100644 index 0000000..79f47d7 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-label.svelte @@ -0,0 +1,34 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { Snippet } from 'svelte'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + children, + child, + class: className, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLElement>> & { + child?: Snippet<[{ props: Record<string, unknown> }]>; + } = $props(); + + const mergedProps = $derived({ + class: cn( + 'text-sidebar-foreground/70 ring-sidebar-ring outline-hidden flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', + 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0', + className + ), + 'data-slot': 'sidebar-group-label', + 'data-sidebar': 'group-label', + ...restProps + }); +</script> + +{#if child} + {@render child({ props: mergedProps })} +{:else} + <div bind:this={ref} {...mergedProps}> + {@render children?.()} + </div> +{/if} diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group.svelte new file mode 100644 index 0000000..eed5ace --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="sidebar-group" + data-sidebar="group" + class={cn('relative flex w-full min-w-0 flex-col p-2', className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-header.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-header.svelte new file mode 100644 index 0000000..0651550 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-header.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="sidebar-header" + data-sidebar="header" + class={cn('flex flex-col gap-2 p-2', className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-input.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-input.svelte new file mode 100644 index 0000000..fa57473 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-input.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import type { ComponentProps } from 'svelte'; + import { Input } from '$lib/components/ui/input/index.js'; + import { cn } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + value = $bindable(''), + class: className, + ...restProps + }: ComponentProps<typeof Input> = $props(); +</script> + +<Input + bind:ref + bind:value + data-slot="sidebar-input" + data-sidebar="input" + class={cn('h-8 w-full bg-background shadow-none', className)} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-inset.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-inset.svelte new file mode 100644 index 0000000..f55d2f4 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-inset.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLElement>> = $props(); +</script> + +<main + bind:this={ref} + data-slot="sidebar-inset" + class={cn( + 'relative flex w-full flex-1 flex-col', + 'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2', + className + )} + {...restProps} +> + {@render children?.()} +</main> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-action.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-action.svelte new file mode 100644 index 0000000..ded1ffd --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-action.svelte @@ -0,0 +1,43 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { Snippet } from 'svelte'; + import type { HTMLButtonAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + showOnHover = false, + children, + child, + ...restProps + }: WithElementRef<HTMLButtonAttributes> & { + child?: Snippet<[{ props: Record<string, unknown> }]>; + showOnHover?: boolean; + } = $props(); + + const mergedProps = $derived({ + class: cn( + 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground outline-hidden absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', + // Increases the hit area of the button on mobile. + 'after:absolute after:-inset-2 md:after:hidden', + 'peer-data-[size=sm]/menu-button:top-1', + 'peer-data-[size=default]/menu-button:top-1.5', + 'peer-data-[size=lg]/menu-button:top-2.5', + 'group-data-[collapsible=icon]:hidden', + showOnHover && + 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0', + className + ), + 'data-slot': 'sidebar-menu-action', + 'data-sidebar': 'menu-action', + ...restProps + }); +</script> + +{#if child} + {@render child({ props: mergedProps })} +{:else} + <button bind:this={ref} {...mergedProps}> + {@render children?.()} + </button> +{/if} diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte new file mode 100644 index 0000000..f4525a1 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte @@ -0,0 +1,29 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLElement>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="sidebar-menu-badge" + data-sidebar="menu-badge" + class={cn( + 'pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none', + 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground', + 'peer-data-[size=sm]/menu-button:top-1', + 'peer-data-[size=default]/menu-button:top-1.5', + 'peer-data-[size=lg]/menu-button:top-2.5', + 'group-data-[collapsible=icon]:hidden', + className + )} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-button.svelte new file mode 100644 index 0000000..2ce0305 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-button.svelte @@ -0,0 +1,106 @@ +<script lang="ts" module> + import { tv, type VariantProps } from 'tailwind-variants'; + + export const sidebarMenuButtonVariants = tv({ + base: 'peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pr-8 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', + variants: { + variant: { + default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', + outline: + 'bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]' + }, + size: { + default: 'h-8 text-sm', + sm: 'h-7 text-xs', + lg: 'group-data-[collapsible=icon]:p-0! h-12 text-sm' + } + }, + defaultVariants: { + variant: 'default', + size: 'default' + } + }); + + export type SidebarMenuButtonVariant = VariantProps<typeof sidebarMenuButtonVariants>['variant']; + export type SidebarMenuButtonSize = VariantProps<typeof sidebarMenuButtonVariants>['size']; +</script> + +<script lang="ts"> + import * as Tooltip from '$lib/components/ui/tooltip/index.js'; + import { + cn, + type WithElementRef, + type WithoutChildrenOrChild + } from '$lib/components/ui/utils.js'; + import { mergeProps } from 'bits-ui'; + import type { ComponentProps, Snippet } from 'svelte'; + import type { HTMLAttributes } from 'svelte/elements'; + import { useSidebar } from './context.svelte.js'; + + let { + ref = $bindable(null), + class: className, + children, + child, + variant = 'default', + size = 'default', + isActive = false, + tooltipContent, + tooltipContentProps, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & { + isActive?: boolean; + variant?: SidebarMenuButtonVariant; + size?: SidebarMenuButtonSize; + tooltipContent?: Snippet | string; + tooltipContentProps?: WithoutChildrenOrChild<ComponentProps<typeof Tooltip.Content>>; + child?: Snippet<[{ props: Record<string, unknown> }]>; + } = $props(); + + const sidebar = useSidebar(); + + const buttonProps = $derived({ + class: cn(sidebarMenuButtonVariants({ variant, size }), className), + 'data-slot': 'sidebar-menu-button', + 'data-sidebar': 'menu-button', + 'data-size': size, + 'data-active': isActive, + ...restProps + }); +</script> + +{#snippet Button({ props }: { props?: Record<string, unknown> })} + {@const mergedProps = mergeProps(buttonProps, props)} + {#if child} + {@render child({ props: mergedProps })} + {:else} + <button bind:this={ref} {...mergedProps}> + {@render children?.()} + </button> + {/if} +{/snippet} + +{#if !tooltipContent} + {@render Button({})} +{:else} + <Tooltip.Root> + <Tooltip.Trigger> + {#snippet child({ props })} + {@render Button({ props })} + {/snippet} + </Tooltip.Trigger> + + <Tooltip.Content + side="right" + align="center" + hidden={sidebar.state !== 'collapsed' || sidebar.isMobile} + {...tooltipContentProps} + > + {#if typeof tooltipContent === 'string'} + {tooltipContent} + {:else if tooltipContent} + {@render tooltipContent()} + {/if} + </Tooltip.Content> + </Tooltip.Root> +{/if} diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-item.svelte new file mode 100644 index 0000000..5adbedd --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-item.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLLIElement>, HTMLLIElement> = $props(); +</script> + +<li + bind:this={ref} + data-slot="sidebar-menu-item" + data-sidebar="menu-item" + class={cn('group/menu-item relative', className)} + {...restProps} +> + {@render children?.()} +</li> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte new file mode 100644 index 0000000..2b2acd6 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte @@ -0,0 +1,36 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import { Skeleton } from '$lib/components/ui/skeleton/index.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + showIcon = false, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLElement>> & { + showIcon?: boolean; + } = $props(); + + // Random width between 50% and 90% + const width = `${Math.floor(Math.random() * 40) + 50}%`; +</script> + +<div + bind:this={ref} + data-slot="sidebar-menu-skeleton" + data-sidebar="menu-skeleton" + class={cn('flex h-8 items-center gap-2 rounded-md px-2', className)} + {...restProps} +> + {#if showIcon} + <Skeleton class="size-4 rounded-md" data-sidebar="menu-skeleton-icon" /> + {/if} + <Skeleton + class="h-4 max-w-(--skeleton-width) flex-1" + data-sidebar="menu-skeleton-text" + style="--skeleton-width: {width};" + /> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte new file mode 100644 index 0000000..dabfe0f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte @@ -0,0 +1,43 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { Snippet } from 'svelte'; + import type { HTMLAnchorAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + children, + child, + class: className, + size = 'md', + isActive = false, + ...restProps + }: WithElementRef<HTMLAnchorAttributes> & { + child?: Snippet<[{ props: Record<string, unknown> }]>; + size?: 'sm' | 'md'; + isActive?: boolean; + } = $props(); + + const mergedProps = $derived({ + class: cn( + 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground outline-hidden flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', + 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground', + size === 'sm' && 'text-xs', + size === 'md' && 'text-sm', + 'group-data-[collapsible=icon]:hidden', + className + ), + 'data-slot': 'sidebar-menu-sub-button', + 'data-sidebar': 'menu-sub-button', + 'data-size': size, + 'data-active': isActive, + ...restProps + }); +</script> + +{#if child} + {@render child({ props: mergedProps })} +{:else} + <a bind:this={ref} {...mergedProps}> + {@render children?.()} + </a> +{/if} diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte new file mode 100644 index 0000000..cca870e --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + children, + class: className, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLLIElement>> = $props(); +</script> + +<li + bind:this={ref} + data-slot="sidebar-menu-sub-item" + data-sidebar="menu-sub-item" + class={cn('group/menu-sub-item relative', className)} + {...restProps} +> + {@render children?.()} +</li> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte new file mode 100644 index 0000000..5458ced --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLUListElement>> = $props(); +</script> + +<ul + bind:this={ref} + data-slot="sidebar-menu-sub" + data-sidebar="menu-sub" + class={cn( + 'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5', + 'group-data-[collapsible=icon]:hidden', + className + )} + {...restProps} +> + {@render children?.()} +</ul> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu.svelte new file mode 100644 index 0000000..fee96ed --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLUListElement>, HTMLUListElement> = $props(); +</script> + +<ul + bind:this={ref} + data-slot="sidebar-menu" + data-sidebar="menu" + class={cn('flex w-full min-w-0 flex-col gap-1', className)} + {...restProps} +> + {@render children?.()} +</ul> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-provider.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-provider.svelte new file mode 100644 index 0000000..364235a --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-provider.svelte @@ -0,0 +1,50 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + import { + SIDEBAR_COOKIE_MAX_AGE, + SIDEBAR_COOKIE_NAME, + SIDEBAR_WIDTH, + SIDEBAR_WIDTH_ICON + } from './constants.js'; + import { setSidebar } from './context.svelte.js'; + + let { + ref = $bindable(null), + open = $bindable(true), + onOpenChange = () => {}, + class: className, + style, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { + open?: boolean; + onOpenChange?: (open: boolean) => void; + } = $props(); + + const sidebar = setSidebar({ + open: () => open, + setOpen: (value: boolean) => { + open = value; + onOpenChange(value); + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + } + }); +</script> + +<svelte:window onkeydown={sidebar.handleShortcutKeydown} /> + +<div + data-slot="sidebar-wrapper" + style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}" + class={cn( + 'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar', + className + )} + bind:this={ref} + {...restProps} +> + {@render children?.()} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-rail.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-rail.svelte new file mode 100644 index 0000000..cde9307 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-rail.svelte @@ -0,0 +1,36 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + import { useSidebar } from './context.svelte.js'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> = $props(); + + const sidebar = useSidebar(); +</script> + +<button + bind:this={ref} + data-sidebar="rail" + data-slot="sidebar-rail" + aria-label="Toggle Sidebar" + tabIndex={-1} + onclick={sidebar.toggle} + title="Toggle Sidebar" + class={cn( + 'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-[calc(1/2*100%-1px)] after:w-[2px] hover:after:bg-sidebar-border sm:flex', + 'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize', + '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize', + 'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar', + '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2', + '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2', + className + )} + {...restProps} +> + {@render children?.()} +</button> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-separator.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-separator.svelte new file mode 100644 index 0000000..8fc2065 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-separator.svelte @@ -0,0 +1,19 @@ +<script lang="ts"> + import { Separator } from '$lib/components/ui/separator/index.js'; + import { cn } from '$lib/components/ui/utils.js'; + import type { ComponentProps } from 'svelte'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: ComponentProps<typeof Separator> = $props(); +</script> + +<Separator + bind:ref + data-slot="sidebar-separator" + data-sidebar="separator" + class={cn('bg-sidebar-border', className)} + {...restProps} +/> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-trigger.svelte new file mode 100644 index 0000000..29d3a9c --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-trigger.svelte @@ -0,0 +1,35 @@ +<script lang="ts"> + import { Button } from '$lib/components/ui/button/index.js'; + import { cn } from '$lib/components/ui/utils.js'; + import PanelLeftIcon from '@lucide/svelte/icons/panel-left'; + import type { ComponentProps } from 'svelte'; + import { useSidebar } from './context.svelte.js'; + + let { + ref = $bindable(null), + class: className, + onclick, + ...restProps + }: ComponentProps<typeof Button> & { + onclick?: (e: MouseEvent) => void; + } = $props(); + + const sidebar = useSidebar(); +</script> + +<Button + data-sidebar="trigger" + data-slot="sidebar-trigger" + variant="ghost" + size="icon" + class={cn('size-7', className)} + type="button" + onclick={(e) => { + onclick?.(e); + sidebar.toggle(); + }} + {...restProps} +> + <PanelLeftIcon /> + <span class="sr-only">Toggle Sidebar</span> +</Button> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar.svelte new file mode 100644 index 0000000..e2c4a75 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar.svelte @@ -0,0 +1,101 @@ +<script lang="ts"> + import * as Sheet from '$lib/components/ui/sheet/index.js'; + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + import { SIDEBAR_WIDTH_MOBILE } from './constants.js'; + import { useSidebar } from './context.svelte.js'; + + let { + ref = $bindable(null), + side = 'left', + variant = 'sidebar', + collapsible = 'offcanvas', + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { + side?: 'left' | 'right'; + variant?: 'sidebar' | 'floating' | 'inset'; + collapsible?: 'offcanvas' | 'icon' | 'none'; + } = $props(); + + const sidebar = useSidebar(); +</script> + +{#if collapsible === 'none'} + <div + class={cn( + 'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground', + className + )} + bind:this={ref} + {...restProps} + > + {@render children?.()} + </div> +{:else if sidebar.isMobile} + <Sheet.Root bind:open={() => sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} {...restProps}> + <Sheet.Content + data-sidebar="sidebar" + data-slot="sidebar" + data-mobile="true" + class="z-99999 w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground sm:z-99 [&>button]:hidden" + style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};" + {side} + > + <Sheet.Header class="sr-only"> + <Sheet.Title>Sidebar</Sheet.Title> + <Sheet.Description>Displays the mobile sidebar.</Sheet.Description> + </Sheet.Header> + <div class="flex h-full w-full flex-col"> + {@render children?.()} + </div> + </Sheet.Content> + </Sheet.Root> +{:else} + <div + bind:this={ref} + class="group peer hidden text-sidebar-foreground md:block" + data-state={sidebar.state} + data-collapsible={sidebar.state === 'collapsed' ? collapsible : ''} + data-variant={variant} + data-side={side} + data-slot="sidebar" + > + <!-- This is what handles the sidebar gap on desktop --> + <div + data-slot="sidebar-gap" + class={cn( + 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', + 'group-data-[collapsible=offcanvas]:w-0', + 'group-data-[side=right]:rotate-180', + variant === 'floating' || variant === 'inset' + ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]' + : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)' + )} + ></div> + <div + data-slot="sidebar-container" + class={cn( + 'fixed inset-y-0 z-999 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:z-0 md:flex', + side === 'left' + ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]' + : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]', + // Adjust the padding for floating and inset variants. + variant === 'floating' || variant === 'inset' + ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]' + : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)', + className + )} + {...restProps} + > + <div + data-sidebar="sidebar" + data-slot="sidebar-inner" + class="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm" + > + {@render children?.()} + </div> + </div> + </div> +{/if} diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/skeleton/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/skeleton/index.ts new file mode 100644 index 0000000..3120ce1 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from './skeleton.svelte'; + +export { + Root, + // + Root as Skeleton +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/skeleton/skeleton.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/skeleton/skeleton.svelte new file mode 100644 index 0000000..62b6f80 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/skeleton/skeleton.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { cn, type WithElementRef, type WithoutChildren } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props(); +</script> + +<div + bind:this={ref} + data-slot="skeleton" + class={cn('animate-pulse rounded-md bg-accent', className)} + {...restProps} +></div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/switch/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/switch/index.ts new file mode 100644 index 0000000..129f8f5 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/switch/index.ts @@ -0,0 +1,7 @@ +import Root from './switch.svelte'; + +export { + Root, + // + Root as Switch +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/switch/switch.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/switch/switch.svelte new file mode 100644 index 0000000..5a5975e --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/switch/switch.svelte @@ -0,0 +1,29 @@ +<script lang="ts"> + import { Switch as SwitchPrimitive } from 'bits-ui'; + import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + checked = $bindable(false), + ...restProps + }: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props(); +</script> + +<SwitchPrimitive.Root + bind:ref + bind:checked + data-slot="switch" + class={cn( + 'peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80', + className + )} + {...restProps} +> + <SwitchPrimitive.Thumb + data-slot="switch-thumb" + class={cn( + 'pointer-events-none block size-4 rounded-full bg-background ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground' + )} + /> +</SwitchPrimitive.Root> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/table/index.ts new file mode 100644 index 0000000..99239ae --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/index.ts @@ -0,0 +1,28 @@ +import Root from './table.svelte'; +import Body from './table-body.svelte'; +import Caption from './table-caption.svelte'; +import Cell from './table-cell.svelte'; +import Footer from './table-footer.svelte'; +import Head from './table-head.svelte'; +import Header from './table-header.svelte'; +import Row from './table-row.svelte'; + +export { + Root, + Body, + Caption, + Cell, + Footer, + Head, + Header, + Row, + // + Root as Table, + Body as TableBody, + Caption as TableCaption, + Cell as TableCell, + Footer as TableFooter, + Head as TableHead, + Header as TableHeader, + Row as TableRow +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-body.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-body.svelte new file mode 100644 index 0000000..f8df65c --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-body.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props(); +</script> + +<tbody + bind:this={ref} + data-slot="table-body" + class={cn('[&_tr:last-child]:border-0', className)} + {...restProps} +> + {@render children?.()} +</tbody> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-caption.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-caption.svelte new file mode 100644 index 0000000..0fdcc64 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-caption.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLElement>> = $props(); +</script> + +<caption + bind:this={ref} + data-slot="table-caption" + class={cn('mt-4 text-sm text-muted-foreground', className)} + {...restProps} +> + {@render children?.()} +</caption> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-cell.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-cell.svelte new file mode 100644 index 0000000..4506fdf --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-cell.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLTdAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLTdAttributes> = $props(); +</script> + +<td + bind:this={ref} + data-slot="table-cell" + class={cn( + 'bg-clip-padding p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0', + className + )} + {...restProps} +> + {@render children?.()} +</td> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-footer.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-footer.svelte new file mode 100644 index 0000000..77e4a64 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-footer.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props(); +</script> + +<tfoot + bind:this={ref} + data-slot="table-footer" + class={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)} + {...restProps} +> + {@render children?.()} +</tfoot> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-head.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-head.svelte new file mode 100644 index 0000000..c1c57ad --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-head.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLThAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLThAttributes> = $props(); +</script> + +<th + bind:this={ref} + data-slot="table-head" + class={cn( + 'h-10 bg-clip-padding px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pe-0', + className + )} + {...restProps} +> + {@render children?.()} +</th> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-header.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-header.svelte new file mode 100644 index 0000000..eb36673 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-header.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props(); +</script> + +<thead + bind:this={ref} + data-slot="table-header" + class={cn('[&_tr]:border-b', className)} + {...restProps} +> + {@render children?.()} +</thead> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-row.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-row.svelte new file mode 100644 index 0000000..4131d36 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-row.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props(); +</script> + +<tr + bind:this={ref} + data-slot="table-row" + class={cn( + 'border-b transition-colors data-[state=selected]:bg-muted hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50', + className + )} + {...restProps} +> + {@render children?.()} +</tr> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/table.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table.svelte new file mode 100644 index 0000000..c11a6a6 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + import type { HTMLTableAttributes } from 'svelte/elements'; + import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLTableAttributes> = $props(); +</script> + +<div data-slot="table-container" class="relative w-full overflow-x-auto"> + <table + bind:this={ref} + data-slot="table" + class={cn('w-full caption-bottom text-sm', className)} + {...restProps} + > + {@render children?.()} + </table> +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/textarea/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/textarea/index.ts new file mode 100644 index 0000000..9ccb3bf --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/textarea/index.ts @@ -0,0 +1,7 @@ +import Root from './textarea.svelte'; + +export { + Root, + // + Root as Textarea +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/textarea/textarea.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/textarea/textarea.svelte new file mode 100644 index 0000000..bf83882 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/textarea/textarea.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + import { cn, type WithElementRef, type WithoutChildren } from '$lib/components/ui/utils'; + import type { HTMLTextareaAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + value = $bindable(), + class: className, + ...restProps + }: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props(); +</script> + +<textarea + bind:this={ref} + data-slot="textarea" + class={cn( + 'flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40', + className + )} + bind:value + {...restProps} +></textarea> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/index.ts new file mode 100644 index 0000000..273d831 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/index.ts @@ -0,0 +1,21 @@ +import { Tooltip as TooltipPrimitive } from 'bits-ui'; +import Trigger from './tooltip-trigger.svelte'; +import Content from './tooltip-content.svelte'; + +const Root = TooltipPrimitive.Root; +const Provider = TooltipPrimitive.Provider; +const Portal = TooltipPrimitive.Portal; + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal +}; diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/tooltip-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 0000000..72ea93a --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,47 @@ +<script lang="ts"> + import { Tooltip as TooltipPrimitive } from 'bits-ui'; + import { cn } from '$lib/components/ui/utils.js'; + + let { + ref = $bindable(null), + class: className, + sideOffset = 0, + side = 'top', + children, + arrowClasses, + ...restProps + }: TooltipPrimitive.ContentProps & { + arrowClasses?: string; + } = $props(); +</script> + +<TooltipPrimitive.Portal> + <TooltipPrimitive.Content + bind:ref + data-slot="tooltip-content" + {sideOffset} + {side} + class={cn( + 'z-50 w-fit origin-(--bits-tooltip-content-transform-origin) animate-in rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95', + className + )} + {...restProps} + > + {@render children?.()} + <TooltipPrimitive.Arrow> + {#snippet child({ props })} + <div + class={cn( + 'z-50 size-2.5 rotate-45 rounded-[2px] bg-primary', + 'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]', + 'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]', + 'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2', + 'data-[side=left]:-translate-y-[calc(50%_-_3px)]', + arrowClasses + )} + {...props} + ></div> + {/snippet} + </TooltipPrimitive.Arrow> + </TooltipPrimitive.Content> +</TooltipPrimitive.Portal> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 0000000..5631d1b --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { Tooltip as TooltipPrimitive } from 'bits-ui'; + + let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props(); +</script> + +<TooltipPrimitive.Trigger bind:ref data-slot="tooltip-trigger" {...restProps} /> diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/utils.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/utils.ts new file mode 100644 index 0000000..f92bfcb --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/ui/utils.ts @@ -0,0 +1,13 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T; +export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>; +export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null }; |
