diff options
| author | Mitja Felicijan <mitja.felicijan@gmail.com> | 2026-02-12 20:57:17 +0100 |
|---|---|---|
| committer | Mitja Felicijan <mitja.felicijan@gmail.com> | 2026-02-12 20:57:17 +0100 |
| commit | b333b06772c89d96aacb5490d6a219fba7c09cc6 (patch) | |
| tree | 211df60083a5946baa2ed61d33d8121b7e251b06 /llama.cpp/tools/server/webui/src | |
| download | llmnpc-b333b06772c89d96aacb5490d6a219fba7c09cc6.tar.gz | |
Engage!
Diffstat (limited to 'llama.cpp/tools/server/webui/src')
288 files changed, 24466 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/app.css b/llama.cpp/tools/server/webui/src/app.css new file mode 100644 index 0000000..9705040 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/app.css @@ -0,0 +1,138 @@ +@import 'tailwindcss'; + +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.875 0 0); + --input: oklch(0.92 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.987 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + --code-background: oklch(0.975 0 0); + --code-foreground: oklch(0.145 0 0); + --layer-popover: 1000000; +} + +.dark { + --background: oklch(0.16 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 30%); + --input: oklch(1 0 0 / 30%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.19 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + --code-background: oklch(0.225 0 0); + --code-foreground: oklch(0.875 0 0); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +@layer utilities { + .scrollbar-hide { + /* Hide scrollbar for Chrome, Safari and Opera */ + &::-webkit-scrollbar { + display: none; + } + /* Hide scrollbar for IE, Edge and Firefox */ + -ms-overflow-style: none; + scrollbar-width: none; + } +} diff --git a/llama.cpp/tools/server/webui/src/app.d.ts b/llama.cpp/tools/server/webui/src/app.d.ts new file mode 100644 index 0000000..73287d9 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/app.d.ts @@ -0,0 +1,133 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces + +// Import chat types from dedicated module + +import type { + // API types + ApiChatCompletionRequest, + ApiChatCompletionResponse, + ApiChatCompletionStreamChunk, + ApiChatCompletionToolCall, + ApiChatCompletionToolCallDelta, + ApiChatMessageData, + ApiChatMessageContentPart, + ApiContextSizeError, + ApiErrorResponse, + ApiLlamaCppServerProps, + ApiModelDataEntry, + ApiModelListResponse, + ApiProcessingState, + ApiRouterModelMeta, + ApiRouterModelsLoadRequest, + ApiRouterModelsLoadResponse, + ApiRouterModelsStatusRequest, + ApiRouterModelsStatusResponse, + ApiRouterModelsListResponse, + ApiRouterModelsUnloadRequest, + ApiRouterModelsUnloadResponse, + // Chat types + ChatAttachmentDisplayItem, + ChatAttachmentPreviewItem, + ChatMessageType, + ChatRole, + ChatUploadedFile, + ChatMessageSiblingInfo, + ChatMessagePromptProgress, + ChatMessageTimings, + // Database types + DatabaseConversation, + DatabaseMessage, + DatabaseMessageExtra, + DatabaseMessageExtraAudioFile, + DatabaseMessageExtraImageFile, + DatabaseMessageExtraTextFile, + DatabaseMessageExtraPdfFile, + DatabaseMessageExtraLegacyContext, + ExportedConversation, + ExportedConversations, + // Model types + ModelModalities, + ModelOption, + // Settings types + SettingsChatServiceOptions, + SettingsConfigValue, + SettingsFieldConfig, + SettingsConfigType +} from '$lib/types'; + +import { ServerRole, ServerModelStatus, ModelModality } from '$lib/enums'; + +declare global { + // namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + // } + + export { + // API types + ApiChatCompletionRequest, + ApiChatCompletionResponse, + ApiChatCompletionStreamChunk, + ApiChatCompletionToolCall, + ApiChatCompletionToolCallDelta, + ApiChatMessageData, + ApiChatMessageContentPart, + ApiContextSizeError, + ApiErrorResponse, + ApiLlamaCppServerProps, + ApiModelDataEntry, + ApiModelListResponse, + ApiProcessingState, + ApiRouterModelMeta, + ApiRouterModelsLoadRequest, + ApiRouterModelsLoadResponse, + ApiRouterModelsStatusRequest, + ApiRouterModelsStatusResponse, + ApiRouterModelsListResponse, + ApiRouterModelsUnloadRequest, + ApiRouterModelsUnloadResponse, + // Chat types + ChatAttachmentDisplayItem, + ChatAttachmentPreviewItem, + ChatMessagePromptProgress, + ChatMessageSiblingInfo, + ChatMessageTimings, + ChatMessageType, + ChatRole, + ChatUploadedFile, + // Database types + DatabaseConversation, + DatabaseMessage, + DatabaseMessageExtra, + DatabaseMessageExtraAudioFile, + DatabaseMessageExtraImageFile, + DatabaseMessageExtraTextFile, + DatabaseMessageExtraPdfFile, + DatabaseMessageExtraLegacyContext, + ExportedConversation, + ExportedConversations, + // Enum types + ModelModality, + ServerRole, + ServerModelStatus, + // Model types + ModelModalities, + ModelOption, + // Settings types + SettingsChatServiceOptions, + SettingsConfigValue, + SettingsFieldConfig, + SettingsConfigType + }; +} + +declare global { + interface Window { + idxThemeStyle?: number; + idxCodeBlock?: number; + } +} diff --git a/llama.cpp/tools/server/webui/src/app.html b/llama.cpp/tools/server/webui/src/app.html new file mode 100644 index 0000000..1391f88 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/app.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <link rel="icon" href="%sveltekit.assets%/favicon.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + %sveltekit.head% + </head> + <body data-sveltekit-preload-data="hover"> + <div style="display: contents">%sveltekit.body%</div> + </body> +</html> 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 }; diff --git a/llama.cpp/tools/server/webui/src/lib/constants/auto-scroll.ts b/llama.cpp/tools/server/webui/src/lib/constants/auto-scroll.ts new file mode 100644 index 0000000..098f435 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/auto-scroll.ts @@ -0,0 +1,3 @@ +export const AUTO_SCROLL_INTERVAL = 100; +export const INITIAL_SCROLL_DELAY = 50; +export const AUTO_SCROLL_AT_BOTTOM_THRESHOLD = 10; diff --git a/llama.cpp/tools/server/webui/src/lib/constants/binary-detection.ts b/llama.cpp/tools/server/webui/src/lib/constants/binary-detection.ts new file mode 100644 index 0000000..a4440fd --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/binary-detection.ts @@ -0,0 +1,14 @@ +export interface BinaryDetectionOptions { + /** Number of characters to check from the beginning of the file */ + prefixLength: number; + /** Maximum ratio of suspicious characters allowed (0.0 to 1.0) */ + suspiciousCharThresholdRatio: number; + /** Maximum absolute number of null bytes allowed */ + maxAbsoluteNullBytes: number; +} + +export const DEFAULT_BINARY_DETECTION_OPTIONS: BinaryDetectionOptions = { + prefixLength: 1024 * 10, // Check the first 10KB of the string + suspiciousCharThresholdRatio: 0.15, // Allow up to 15% suspicious chars + maxAbsoluteNullBytes: 2 +}; diff --git a/llama.cpp/tools/server/webui/src/lib/constants/default-context.ts b/llama.cpp/tools/server/webui/src/lib/constants/default-context.ts new file mode 100644 index 0000000..78f3111 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/default-context.ts @@ -0,0 +1 @@ +export const DEFAULT_CONTEXT = 4096; diff --git a/llama.cpp/tools/server/webui/src/lib/constants/floating-ui-constraints.ts b/llama.cpp/tools/server/webui/src/lib/constants/floating-ui-constraints.ts new file mode 100644 index 0000000..003fc77 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/floating-ui-constraints.ts @@ -0,0 +1,2 @@ +export const VIEWPORT_GUTTER = 8; +export const MENU_OFFSET = 6; diff --git a/llama.cpp/tools/server/webui/src/lib/constants/icons.ts b/llama.cpp/tools/server/webui/src/lib/constants/icons.ts new file mode 100644 index 0000000..1e88ab5 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/icons.ts @@ -0,0 +1,32 @@ +/** + * Icon mappings for file types and model modalities + * Centralized configuration to ensure consistent icon usage across the app + */ + +import { + File as FileIcon, + FileText as FileTextIcon, + Image as ImageIcon, + Eye as VisionIcon, + Mic as AudioIcon +} from '@lucide/svelte'; +import { FileTypeCategory, ModelModality } from '$lib/enums'; + +export const FILE_TYPE_ICONS = { + [FileTypeCategory.IMAGE]: ImageIcon, + [FileTypeCategory.AUDIO]: AudioIcon, + [FileTypeCategory.TEXT]: FileTextIcon, + [FileTypeCategory.PDF]: FileIcon +} as const; + +export const DEFAULT_FILE_ICON = FileIcon; + +export const MODALITY_ICONS = { + [ModelModality.VISION]: VisionIcon, + [ModelModality.AUDIO]: AudioIcon +} as const; + +export const MODALITY_LABELS = { + [ModelModality.VISION]: 'Vision', + [ModelModality.AUDIO]: 'Audio' +} as const; diff --git a/llama.cpp/tools/server/webui/src/lib/constants/input-classes.ts b/llama.cpp/tools/server/webui/src/lib/constants/input-classes.ts new file mode 100644 index 0000000..a541cfc --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/input-classes.ts @@ -0,0 +1,6 @@ +export const INPUT_CLASSES = ` + bg-muted/70 dark:bg-muted/85 + border border-border/30 focus-within:border-border dark:border-border/20 dark:focus-within:border-border + outline-none + text-foreground +`; diff --git a/llama.cpp/tools/server/webui/src/lib/constants/latex-protection.ts b/llama.cpp/tools/server/webui/src/lib/constants/latex-protection.ts new file mode 100644 index 0000000..27c88e7 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/latex-protection.ts @@ -0,0 +1,35 @@ +/** + * Matches common Markdown code blocks to exclude them from further processing (e.g. LaTeX). + * - Fenced: ```...``` + * - Inline: `...` (does NOT support nested backticks or multi-backtick syntax) + * + * Note: This pattern does not handle advanced cases like: + * `` `code with `backticks` `` or \\``...\\`` + */ +export const CODE_BLOCK_REGEXP = /(```[\s\S]*?```|`[^`\n]+`)/g; + +/** + * Matches LaTeX math delimiters \(...\) and \[...\] only when not preceded by a backslash (i.e., not escaped), + * while also capturing code blocks (```, `...`) so they can be skipped during processing. + * + * Uses negative lookbehind `(?<!\\)` to avoid matching \\( or \\[. + * Using the look‑behind pattern `(?<!\\)` we skip matches + * that are preceded by a backslash, e.g. + * `Definitions\\(also called macros)` (title of chapter 20 in The TeXbook) + * or `\\[4pt]` (LaTeX line-break). + * + * group 1: code-block + * group 2: square-bracket + * group 3: round-bracket + */ +export const LATEX_MATH_AND_CODE_PATTERN = + /(```[\S\s]*?```|`.*?`)|(?<!\\)\\\[([\S\s]*?[^\\])\\]|(?<!\\)\\\((.*?)\\\)/g; + +/** Regex to capture the content of a $$...\\\\...$$ block (display-formula with line-break) */ +export const LATEX_LINEBREAK_REGEXP = /\$\$([\s\S]*?\\\\[\s\S]*?)\$\$/; + +/** map from mchem-regexp to replacement */ +export const MHCHEM_PATTERN_MAP: readonly [RegExp, string][] = [ + [/(\s)\$\\ce{/g, '$1$\\\\ce{'], + [/(\s)\$\\pu{/g, '$1$\\\\pu{'] +] as const; diff --git a/llama.cpp/tools/server/webui/src/lib/constants/literal-html.ts b/llama.cpp/tools/server/webui/src/lib/constants/literal-html.ts new file mode 100644 index 0000000..ed1b0cf --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/literal-html.ts @@ -0,0 +1,15 @@ +export const LINE_BREAK = /\r?\n/; + +export const PHRASE_PARENTS = new Set([ + 'paragraph', + 'heading', + 'emphasis', + 'strong', + 'delete', + 'link', + 'linkReference', + 'tableCell' +]); + +export const NBSP = '\u00a0'; +export const TAB_AS_SPACES = NBSP.repeat(4); diff --git a/llama.cpp/tools/server/webui/src/lib/constants/localstorage-keys.ts b/llama.cpp/tools/server/webui/src/lib/constants/localstorage-keys.ts new file mode 100644 index 0000000..919b6ea --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/localstorage-keys.ts @@ -0,0 +1,2 @@ +export const CONFIG_LOCALSTORAGE_KEY = 'LlamaCppWebui.config'; +export const USER_OVERRIDES_LOCALSTORAGE_KEY = 'LlamaCppWebui.userOverrides'; diff --git a/llama.cpp/tools/server/webui/src/lib/constants/max-bundle-size.ts b/llama.cpp/tools/server/webui/src/lib/constants/max-bundle-size.ts new file mode 100644 index 0000000..e04348f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/max-bundle-size.ts @@ -0,0 +1 @@ +export const MAX_BUNDLE_SIZE = 2 * 1024 * 1024; diff --git a/llama.cpp/tools/server/webui/src/lib/constants/precision.ts b/llama.cpp/tools/server/webui/src/lib/constants/precision.ts new file mode 100644 index 0000000..8df5c4f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/precision.ts @@ -0,0 +1,2 @@ +export const PRECISION_MULTIPLIER = 1000000; +export const PRECISION_DECIMAL_PLACES = 6; diff --git a/llama.cpp/tools/server/webui/src/lib/constants/processing-info.ts b/llama.cpp/tools/server/webui/src/lib/constants/processing-info.ts new file mode 100644 index 0000000..7264392 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/processing-info.ts @@ -0,0 +1 @@ +export const PROCESSING_INFO_TIMEOUT = 2000; diff --git a/llama.cpp/tools/server/webui/src/lib/constants/settings-config.ts b/llama.cpp/tools/server/webui/src/lib/constants/settings-config.ts new file mode 100644 index 0000000..cac48a5 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/settings-config.ts @@ -0,0 +1,117 @@ +export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> = { + // Note: in order not to introduce breaking changes, please keep the same data type (number, string, etc) if you want to change the default value. Do not use null or undefined for default value. + // Do not use nested objects, keep it single level. Prefix the key if you need to group them. + apiKey: '', + systemMessage: '', + showSystemMessage: true, + theme: 'system', + showThoughtInProgress: false, + showToolCalls: false, + disableReasoningFormat: false, + keepStatsVisible: false, + showMessageStats: true, + askForTitleConfirmation: false, + pasteLongTextToFileLen: 2500, + copyTextAttachmentsAsPlainText: false, + pdfAsImage: false, + disableAutoScroll: false, + renderUserContentAsMarkdown: false, + alwaysShowSidebarOnDesktop: false, + autoShowSidebarOnNewChat: true, + autoMicOnEmpty: false, + // make sure these default values are in sync with `common.h` + samplers: 'top_k;typ_p;top_p;min_p;temperature', + backend_sampling: false, + temperature: 0.8, + dynatemp_range: 0.0, + dynatemp_exponent: 1.0, + top_k: 40, + top_p: 0.95, + min_p: 0.05, + xtc_probability: 0.0, + xtc_threshold: 0.1, + typ_p: 1.0, + repeat_last_n: 64, + repeat_penalty: 1.0, + presence_penalty: 0.0, + frequency_penalty: 0.0, + dry_multiplier: 0.0, + dry_base: 1.75, + dry_allowed_length: 2, + dry_penalty_last_n: -1, + max_tokens: -1, + custom: '', // custom json-stringified object + // experimental features + pyInterpreterEnabled: false, + enableContinueGeneration: false +}; + +export const SETTING_CONFIG_INFO: Record<string, string> = { + apiKey: 'Set the API Key if you are using <code>--api-key</code> option for the server.', + systemMessage: 'The starting message that defines how model should behave.', + showSystemMessage: 'Display the system message at the top of each conversation.', + theme: + 'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.', + pasteLongTextToFileLen: + 'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.', + copyTextAttachmentsAsPlainText: + 'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.', + samplers: + 'The order at which samplers are applied, in simplified way. Default is "top_k;typ_p;top_p;min_p;temperature": top_k->typ_p->top_p->min_p->temperature', + backend_sampling: + 'Enable backend-based samplers. When enabled, supported samplers run on the accelerator backend for faster sampling.', + temperature: + 'Controls the randomness of the generated text by affecting the probability distribution of the output tokens. Higher = more random, lower = more focused.', + dynatemp_range: + 'Addon for the temperature sampler. The added value to the range of dynamic temperature, which adjusts probabilities by entropy of tokens.', + dynatemp_exponent: + 'Addon for the temperature sampler. Smoothes out the probability redistribution based on the most probable token.', + top_k: 'Keeps only k top tokens.', + top_p: 'Limits tokens to those that together have a cumulative probability of at least p', + min_p: + 'Limits tokens based on the minimum probability for a token to be considered, relative to the probability of the most likely token.', + xtc_probability: + 'XTC sampler cuts out top tokens; this parameter controls the chance of cutting tokens at all. 0 disables XTC.', + xtc_threshold: + 'XTC sampler cuts out top tokens; this parameter controls the token probability that is required to cut that token.', + typ_p: 'Sorts and limits tokens based on the difference between log-probability and entropy.', + repeat_last_n: 'Last n tokens to consider for penalizing repetition', + repeat_penalty: 'Controls the repetition of token sequences in the generated text', + presence_penalty: 'Limits tokens based on whether they appear in the output or not.', + frequency_penalty: 'Limits tokens based on how often they appear in the output.', + dry_multiplier: + 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling multiplier.', + dry_base: + 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling base value.', + dry_allowed_length: + 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the allowed length for DRY sampling.', + dry_penalty_last_n: + 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets DRY penalty for the last n tokens.', + max_tokens: 'The maximum number of token per output. Use -1 for infinite (no limit).', + custom: 'Custom JSON parameters to send to the API. Must be valid JSON format.', + showThoughtInProgress: 'Expand thought process by default when generating messages.', + showToolCalls: + 'Display tool call labels and payloads from Harmony-compatible delta.tool_calls data below assistant messages.', + disableReasoningFormat: + 'Show raw LLM output without backend parsing and frontend Markdown rendering to inspect streaming across different models.', + keepStatsVisible: 'Keep processing statistics visible after generation finishes.', + showMessageStats: + 'Display generation statistics (tokens/second, token count, duration) below each assistant message.', + askForTitleConfirmation: + 'Ask for confirmation before automatically changing conversation title when editing the first message.', + pdfAsImage: + 'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.', + disableAutoScroll: + 'Disable automatic scrolling while messages stream so you can control the viewport position manually.', + renderUserContentAsMarkdown: 'Render user messages using markdown formatting in the chat.', + alwaysShowSidebarOnDesktop: + 'Always keep the sidebar visible on desktop instead of auto-hiding it.', + autoShowSidebarOnNewChat: + 'Automatically show sidebar when starting a new chat. Disable to keep the sidebar hidden until you click on it.', + autoMicOnEmpty: + 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.', + pyInterpreterEnabled: + 'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.', + enableContinueGeneration: + 'Enable "Continue" button for assistant messages. Currently works only with non-reasoning models.' +}; diff --git a/llama.cpp/tools/server/webui/src/lib/constants/supported-file-types.ts b/llama.cpp/tools/server/webui/src/lib/constants/supported-file-types.ts new file mode 100644 index 0000000..0d955ad --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/supported-file-types.ts @@ -0,0 +1,217 @@ +/** + * Comprehensive dictionary of all supported file types in webui + * Organized by category with TypeScript enums for better type safety + */ + +import { + FileExtensionAudio, + FileExtensionImage, + FileExtensionPdf, + FileExtensionText, + FileTypeAudio, + FileTypeImage, + FileTypePdf, + FileTypeText, + MimeTypeAudio, + MimeTypeImage, + MimeTypeApplication, + MimeTypeText +} from '$lib/enums'; + +// File type configuration using enums +export const AUDIO_FILE_TYPES = { + [FileTypeAudio.MP3]: { + extensions: [FileExtensionAudio.MP3], + mimeTypes: [MimeTypeAudio.MP3_MPEG, MimeTypeAudio.MP3] + }, + [FileTypeAudio.WAV]: { + extensions: [FileExtensionAudio.WAV], + mimeTypes: [MimeTypeAudio.WAV] + } +} as const; + +export const IMAGE_FILE_TYPES = { + [FileTypeImage.JPEG]: { + extensions: [FileExtensionImage.JPG, FileExtensionImage.JPEG], + mimeTypes: [MimeTypeImage.JPEG] + }, + [FileTypeImage.PNG]: { + extensions: [FileExtensionImage.PNG], + mimeTypes: [MimeTypeImage.PNG] + }, + [FileTypeImage.GIF]: { + extensions: [FileExtensionImage.GIF], + mimeTypes: [MimeTypeImage.GIF] + }, + [FileTypeImage.WEBP]: { + extensions: [FileExtensionImage.WEBP], + mimeTypes: [MimeTypeImage.WEBP] + }, + [FileTypeImage.SVG]: { + extensions: [FileExtensionImage.SVG], + mimeTypes: [MimeTypeImage.SVG] + } +} as const; + +export const PDF_FILE_TYPES = { + [FileTypePdf.PDF]: { + extensions: [FileExtensionPdf.PDF], + mimeTypes: [MimeTypeApplication.PDF] + } +} as const; + +export const TEXT_FILE_TYPES = { + [FileTypeText.PLAIN_TEXT]: { + extensions: [FileExtensionText.TXT], + mimeTypes: [MimeTypeText.PLAIN] + }, + [FileTypeText.MARKDOWN]: { + extensions: [FileExtensionText.MD], + mimeTypes: [MimeTypeText.MARKDOWN] + }, + [FileTypeText.ASCIIDOC]: { + extensions: [FileExtensionText.ADOC], + mimeTypes: [MimeTypeText.ASCIIDOC] + }, + [FileTypeText.JAVASCRIPT]: { + extensions: [FileExtensionText.JS], + mimeTypes: [MimeTypeText.JAVASCRIPT, MimeTypeText.JAVASCRIPT_APP] + }, + [FileTypeText.TYPESCRIPT]: { + extensions: [FileExtensionText.TS], + mimeTypes: [MimeTypeText.TYPESCRIPT] + }, + [FileTypeText.JSX]: { + extensions: [FileExtensionText.JSX], + mimeTypes: [MimeTypeText.JSX] + }, + [FileTypeText.TSX]: { + extensions: [FileExtensionText.TSX], + mimeTypes: [MimeTypeText.TSX] + }, + [FileTypeText.CSS]: { + extensions: [FileExtensionText.CSS], + mimeTypes: [MimeTypeText.CSS] + }, + [FileTypeText.HTML]: { + extensions: [FileExtensionText.HTML, FileExtensionText.HTM], + mimeTypes: [MimeTypeText.HTML] + }, + [FileTypeText.JSON]: { + extensions: [FileExtensionText.JSON], + mimeTypes: [MimeTypeText.JSON] + }, + [FileTypeText.XML]: { + extensions: [FileExtensionText.XML], + mimeTypes: [MimeTypeText.XML_TEXT, MimeTypeText.XML_APP] + }, + [FileTypeText.YAML]: { + extensions: [FileExtensionText.YAML, FileExtensionText.YML], + mimeTypes: [MimeTypeText.YAML_TEXT, MimeTypeText.YAML_APP] + }, + [FileTypeText.CSV]: { + extensions: [FileExtensionText.CSV], + mimeTypes: [MimeTypeText.CSV] + }, + [FileTypeText.LOG]: { + extensions: [FileExtensionText.LOG], + mimeTypes: [MimeTypeText.PLAIN] + }, + [FileTypeText.PYTHON]: { + extensions: [FileExtensionText.PY], + mimeTypes: [MimeTypeText.PYTHON] + }, + [FileTypeText.JAVA]: { + extensions: [FileExtensionText.JAVA], + mimeTypes: [MimeTypeText.JAVA] + }, + [FileTypeText.CPP]: { + extensions: [ + FileExtensionText.CPP, + FileExtensionText.C, + FileExtensionText.H, + FileExtensionText.HPP + ], + mimeTypes: [MimeTypeText.CPP_SRC, MimeTypeText.CPP_HDR, MimeTypeText.C_SRC, MimeTypeText.C_HDR] + }, + [FileTypeText.PHP]: { + extensions: [FileExtensionText.PHP], + mimeTypes: [MimeTypeText.PHP] + }, + [FileTypeText.RUBY]: { + extensions: [FileExtensionText.RB], + mimeTypes: [MimeTypeText.RUBY] + }, + [FileTypeText.GO]: { + extensions: [FileExtensionText.GO], + mimeTypes: [MimeTypeText.GO] + }, + [FileTypeText.RUST]: { + extensions: [FileExtensionText.RS], + mimeTypes: [MimeTypeText.RUST] + }, + [FileTypeText.SHELL]: { + extensions: [FileExtensionText.SH, FileExtensionText.BAT], + mimeTypes: [MimeTypeText.SHELL, MimeTypeText.BAT] + }, + [FileTypeText.SQL]: { + extensions: [FileExtensionText.SQL], + mimeTypes: [MimeTypeText.SQL] + }, + [FileTypeText.R]: { + extensions: [FileExtensionText.R], + mimeTypes: [MimeTypeText.R] + }, + [FileTypeText.SCALA]: { + extensions: [FileExtensionText.SCALA], + mimeTypes: [MimeTypeText.SCALA] + }, + [FileTypeText.KOTLIN]: { + extensions: [FileExtensionText.KT], + mimeTypes: [MimeTypeText.KOTLIN] + }, + [FileTypeText.SWIFT]: { + extensions: [FileExtensionText.SWIFT], + mimeTypes: [MimeTypeText.SWIFT] + }, + [FileTypeText.DART]: { + extensions: [FileExtensionText.DART], + mimeTypes: [MimeTypeText.DART] + }, + [FileTypeText.VUE]: { + extensions: [FileExtensionText.VUE], + mimeTypes: [MimeTypeText.VUE] + }, + [FileTypeText.SVELTE]: { + extensions: [FileExtensionText.SVELTE], + mimeTypes: [MimeTypeText.SVELTE] + }, + [FileTypeText.LATEX]: { + extensions: [FileExtensionText.TEX], + mimeTypes: [MimeTypeText.LATEX, MimeTypeText.TEX, MimeTypeText.TEX_APP] + }, + [FileTypeText.BIBTEX]: { + extensions: [FileExtensionText.BIB], + mimeTypes: [MimeTypeText.BIBTEX] + }, + [FileTypeText.CUDA]: { + extensions: [FileExtensionText.CU, FileExtensionText.CUH], + mimeTypes: [MimeTypeText.CUDA] + }, + [FileTypeText.VULKAN]: { + extensions: [FileExtensionText.COMP], + mimeTypes: [MimeTypeText.PLAIN] + }, + [FileTypeText.HASKELL]: { + extensions: [FileExtensionText.HS], + mimeTypes: [MimeTypeText.HASKELL] + }, + [FileTypeText.CSHARP]: { + extensions: [FileExtensionText.CS], + mimeTypes: [MimeTypeText.CSHARP] + }, + [FileTypeText.PROPERTIES]: { + extensions: [FileExtensionText.PROPERTIES], + mimeTypes: [MimeTypeText.PROPERTIES] + } +} as const; diff --git a/llama.cpp/tools/server/webui/src/lib/constants/table-html-restorer.ts b/llama.cpp/tools/server/webui/src/lib/constants/table-html-restorer.ts new file mode 100644 index 0000000..e5d5b12 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/table-html-restorer.ts @@ -0,0 +1,20 @@ +/** + * Matches <br>, <br/>, <br /> tags (case-insensitive). + * Used to detect line breaks in table cell text content. + */ +export const BR_PATTERN = /<br\s*\/?\s*>/gi; + +/** + * Matches a complete <ul>...</ul> block. + * Captures the inner content (group 1) for further <li> extraction. + * Case-insensitive, allows multiline content. + */ +export const LIST_PATTERN = /^<ul>([\s\S]*)<\/ul>$/i; + +/** + * Matches individual <li>...</li> elements within a list. + * Captures the inner content (group 1) of each list item. + * Non-greedy to handle multiple consecutive items. + * Case-insensitive, allows multiline content. + */ +export const LI_PATTERN = /<li>([\s\S]*?)<\/li>/gi; diff --git a/llama.cpp/tools/server/webui/src/lib/constants/tooltip-config.ts b/llama.cpp/tools/server/webui/src/lib/constants/tooltip-config.ts new file mode 100644 index 0000000..3c30c8c --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/tooltip-config.ts @@ -0,0 +1 @@ +export const TOOLTIP_DELAY_DURATION = 100; diff --git a/llama.cpp/tools/server/webui/src/lib/constants/viewport.ts b/llama.cpp/tools/server/webui/src/lib/constants/viewport.ts new file mode 100644 index 0000000..26e202c --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/constants/viewport.ts @@ -0,0 +1 @@ +export const DEFAULT_MOBILE_BREAKPOINT = 768; diff --git a/llama.cpp/tools/server/webui/src/lib/enums/attachment.ts b/llama.cpp/tools/server/webui/src/lib/enums/attachment.ts new file mode 100644 index 0000000..7c7d0da --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/enums/attachment.ts @@ -0,0 +1,10 @@ +/** + * Attachment type enum for database message extras + */ +export enum AttachmentType { + AUDIO = 'AUDIO', + IMAGE = 'IMAGE', + PDF = 'PDF', + TEXT = 'TEXT', + LEGACY_CONTEXT = 'context' // Legacy attachment type for backward compatibility +} diff --git a/llama.cpp/tools/server/webui/src/lib/enums/chat.ts b/llama.cpp/tools/server/webui/src/lib/enums/chat.ts new file mode 100644 index 0000000..2b9eb7b --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/enums/chat.ts @@ -0,0 +1,4 @@ +export enum ChatMessageStatsView { + GENERATION = 'generation', + READING = 'reading' +} diff --git a/llama.cpp/tools/server/webui/src/lib/enums/files.ts b/llama.cpp/tools/server/webui/src/lib/enums/files.ts new file mode 100644 index 0000000..a4f079d --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/enums/files.ts @@ -0,0 +1,206 @@ +/** + * Comprehensive dictionary of all supported file types in webui + * Organized by category with TypeScript enums for better type safety + */ + +// File type category enum +export enum FileTypeCategory { + IMAGE = 'image', + AUDIO = 'audio', + PDF = 'pdf', + TEXT = 'text' +} + +// Specific file type enums for each category +export enum FileTypeImage { + JPEG = 'jpeg', + PNG = 'png', + GIF = 'gif', + WEBP = 'webp', + SVG = 'svg' +} + +export enum FileTypeAudio { + MP3 = 'mp3', + WAV = 'wav', + WEBM = 'webm' +} + +export enum FileTypePdf { + PDF = 'pdf' +} + +export enum FileTypeText { + PLAIN_TEXT = 'plainText', + MARKDOWN = 'md', + ASCIIDOC = 'asciidoc', + JAVASCRIPT = 'js', + TYPESCRIPT = 'ts', + JSX = 'jsx', + TSX = 'tsx', + CSS = 'css', + HTML = 'html', + JSON = 'json', + XML = 'xml', + YAML = 'yaml', + CSV = 'csv', + LOG = 'log', + PYTHON = 'python', + JAVA = 'java', + CPP = 'cpp', + PHP = 'php', + RUBY = 'ruby', + GO = 'go', + RUST = 'rust', + SHELL = 'shell', + SQL = 'sql', + R = 'r', + SCALA = 'scala', + KOTLIN = 'kotlin', + SWIFT = 'swift', + DART = 'dart', + VUE = 'vue', + SVELTE = 'svelte', + LATEX = 'latex', + BIBTEX = 'bibtex', + CUDA = 'cuda', + VULKAN = 'vulkan', + HASKELL = 'haskell', + CSHARP = 'csharp', + PROPERTIES = 'properties' +} + +// File extension enums +export enum FileExtensionImage { + JPG = '.jpg', + JPEG = '.jpeg', + PNG = '.png', + GIF = '.gif', + WEBP = '.webp', + SVG = '.svg' +} + +export enum FileExtensionAudio { + MP3 = '.mp3', + WAV = '.wav' +} + +export enum FileExtensionPdf { + PDF = '.pdf' +} + +export enum FileExtensionText { + TXT = '.txt', + MD = '.md', + ADOC = '.adoc', + JS = '.js', + TS = '.ts', + JSX = '.jsx', + TSX = '.tsx', + CSS = '.css', + HTML = '.html', + HTM = '.htm', + JSON = '.json', + XML = '.xml', + YAML = '.yaml', + YML = '.yml', + CSV = '.csv', + LOG = '.log', + PY = '.py', + JAVA = '.java', + CPP = '.cpp', + C = '.c', + H = '.h', + PHP = '.php', + RB = '.rb', + GO = '.go', + RS = '.rs', + SH = '.sh', + BAT = '.bat', + SQL = '.sql', + R = '.r', + SCALA = '.scala', + KT = '.kt', + SWIFT = '.swift', + DART = '.dart', + VUE = '.vue', + SVELTE = '.svelte', + TEX = '.tex', + BIB = '.bib', + CU = '.cu', + CUH = '.cuh', + COMP = '.comp', + HPP = '.hpp', + HS = '.hs', + PROPERTIES = '.properties', + CS = '.cs' +} + +// MIME type enums +export enum MimeTypeApplication { + PDF = 'application/pdf' +} + +export enum MimeTypeAudio { + MP3_MPEG = 'audio/mpeg', + MP3 = 'audio/mp3', + MP4 = 'audio/mp4', + WAV = 'audio/wav', + WEBM = 'audio/webm', + WEBM_OPUS = 'audio/webm;codecs=opus' +} + +export enum MimeTypeImage { + JPEG = 'image/jpeg', + PNG = 'image/png', + GIF = 'image/gif', + WEBP = 'image/webp', + SVG = 'image/svg+xml' +} + +export enum MimeTypeText { + PLAIN = 'text/plain', + MARKDOWN = 'text/markdown', + ASCIIDOC = 'text/asciidoc', + JAVASCRIPT = 'text/javascript', + JAVASCRIPT_APP = 'application/javascript', + TYPESCRIPT = 'text/typescript', + JSX = 'text/jsx', + TSX = 'text/tsx', + CSS = 'text/css', + HTML = 'text/html', + JSON = 'application/json', + XML_TEXT = 'text/xml', + XML_APP = 'application/xml', + YAML_TEXT = 'text/yaml', + YAML_APP = 'application/yaml', + CSV = 'text/csv', + PYTHON = 'text/x-python', + JAVA = 'text/x-java-source', + CPP_HDR = 'text/x-c++hdr', + CPP_SRC = 'text/x-c++src', + CSHARP = 'text/x-csharp', + HASKELL = 'text/x-haskell', + C_SRC = 'text/x-csrc', + C_HDR = 'text/x-chdr', + PHP = 'text/x-php', + RUBY = 'text/x-ruby', + GO = 'text/x-go', + RUST = 'text/x-rust', + SHELL = 'text/x-shellscript', + BAT = 'application/x-bat', + SQL = 'text/x-sql', + R = 'text/x-r', + SCALA = 'text/x-scala', + KOTLIN = 'text/x-kotlin', + SWIFT = 'text/x-swift', + DART = 'text/x-dart', + VUE = 'text/x-vue', + SVELTE = 'text/x-svelte', + TEX = 'text/x-tex', + TEX_APP = 'application/x-tex', + LATEX = 'application/x-latex', + BIBTEX = 'text/x-bibtex', + CUDA = 'text/x-cuda', + PROPERTIES = 'text/properties' +} diff --git a/llama.cpp/tools/server/webui/src/lib/enums/index.ts b/llama.cpp/tools/server/webui/src/lib/enums/index.ts new file mode 100644 index 0000000..83c86ca --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/enums/index.ts @@ -0,0 +1,23 @@ +export { AttachmentType } from './attachment'; + +export { ChatMessageStatsView } from './chat'; + +export { + FileTypeCategory, + FileTypeImage, + FileTypeAudio, + FileTypePdf, + FileTypeText, + FileExtensionImage, + FileExtensionAudio, + FileExtensionPdf, + FileExtensionText, + MimeTypeApplication, + MimeTypeAudio, + MimeTypeImage, + MimeTypeText +} from './files'; + +export { ModelModality } from './model'; + +export { ServerRole, ServerModelStatus } from './server'; diff --git a/llama.cpp/tools/server/webui/src/lib/enums/model.ts b/llama.cpp/tools/server/webui/src/lib/enums/model.ts new file mode 100644 index 0000000..7729ecf --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/enums/model.ts @@ -0,0 +1,5 @@ +export enum ModelModality { + TEXT = 'TEXT', + AUDIO = 'AUDIO', + VISION = 'VISION' +} diff --git a/llama.cpp/tools/server/webui/src/lib/enums/server.ts b/llama.cpp/tools/server/webui/src/lib/enums/server.ts new file mode 100644 index 0000000..7f30eab --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/enums/server.ts @@ -0,0 +1,20 @@ +/** + * Server role enum - used for single/multi-model mode + */ +export enum ServerRole { + /** Single model mode - server running with a specific model loaded */ + MODEL = 'model', + /** Router mode - server managing multiple model instances */ + ROUTER = 'router' +} + +/** + * Model status enum - matches tools/server/server-models.h from C++ server + * Used as the `value` field in the status object from /models endpoint + */ +export enum ServerModelStatus { + UNLOADED = 'unloaded', + LOADING = 'loading', + LOADED = 'loaded', + FAILED = 'failed' +} diff --git a/llama.cpp/tools/server/webui/src/lib/hooks/is-mobile.svelte.ts b/llama.cpp/tools/server/webui/src/lib/hooks/is-mobile.svelte.ts new file mode 100644 index 0000000..22c74f4 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/hooks/is-mobile.svelte.ts @@ -0,0 +1,8 @@ +import { DEFAULT_MOBILE_BREAKPOINT } from '$lib/constants/viewport'; +import { MediaQuery } from 'svelte/reactivity'; + +export class IsMobile extends MediaQuery { + constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) { + super(`max-width: ${breakpoint - 1}px`); + } +} diff --git a/llama.cpp/tools/server/webui/src/lib/hooks/use-model-change-validation.svelte.ts b/llama.cpp/tools/server/webui/src/lib/hooks/use-model-change-validation.svelte.ts new file mode 100644 index 0000000..bb66615 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/hooks/use-model-change-validation.svelte.ts @@ -0,0 +1,118 @@ +import { modelsStore } from '$lib/stores/models.svelte'; +import { isRouterMode } from '$lib/stores/server.svelte'; +import { toast } from 'svelte-sonner'; + +interface UseModelChangeValidationOptions { + /** + * Function to get required modalities for validation. + * For ChatForm: () => usedModalities() - all messages + * For ChatMessageAssistant: () => getModalitiesUpToMessage(messageId) - messages before + */ + getRequiredModalities: () => ModelModalities; + + /** + * Optional callback to execute after successful validation. + * For ChatForm: undefined - just select model + * For ChatMessageAssistant: (modelName) => onRegenerate(modelName) + */ + onSuccess?: (modelName: string) => void; + + /** + * Optional callback for rollback on validation failure. + * For ChatForm: (previousId) => selectModelById(previousId) + * For ChatMessageAssistant: undefined - no rollback needed + */ + onValidationFailure?: (previousModelId: string | null) => Promise<void>; +} + +export function useModelChangeValidation(options: UseModelChangeValidationOptions) { + const { getRequiredModalities, onSuccess, onValidationFailure } = options; + + let previousSelectedModelId: string | null = null; + const isRouter = $derived(isRouterMode()); + + async function handleModelChange(modelId: string, modelName: string): Promise<boolean> { + try { + // Store previous selection for potential rollback + if (onValidationFailure) { + previousSelectedModelId = modelsStore.selectedModelId; + } + + // Load model if not already loaded (router mode only) + let hasLoadedModel = false; + const isModelLoadedBefore = modelsStore.isModelLoaded(modelName); + + if (isRouter && !isModelLoadedBefore) { + try { + await modelsStore.loadModel(modelName); + hasLoadedModel = true; + } catch { + toast.error(`Failed to load model "${modelName}"`); + return false; + } + } + + // Fetch model props to validate modalities + const props = await modelsStore.fetchModelProps(modelName); + + if (props?.modalities) { + const requiredModalities = getRequiredModalities(); + + // Check if model supports required modalities + const missingModalities: string[] = []; + if (requiredModalities.vision && !props.modalities.vision) { + missingModalities.push('vision'); + } + if (requiredModalities.audio && !props.modalities.audio) { + missingModalities.push('audio'); + } + + if (missingModalities.length > 0) { + toast.error( + `Model "${modelName}" doesn't support required modalities: ${missingModalities.join(', ')}. Please select a different model.` + ); + + // Unload the model if we just loaded it + if (isRouter && hasLoadedModel) { + try { + await modelsStore.unloadModel(modelName); + } catch (error) { + console.error('Failed to unload incompatible model:', error); + } + } + + // Execute rollback callback if provided + if (onValidationFailure && previousSelectedModelId) { + await onValidationFailure(previousSelectedModelId); + } + + return false; + } + } + + // Select the model (validation passed) + await modelsStore.selectModelById(modelId); + + // Execute success callback if provided + if (onSuccess) { + onSuccess(modelName); + } + + return true; + } catch (error) { + console.error('Failed to change model:', error); + toast.error('Failed to validate model capabilities'); + + // Execute rollback callback on error if provided + if (onValidationFailure && previousSelectedModelId) { + await onValidationFailure(previousSelectedModelId); + } + + return false; + } + } + + return { + handleModelChange + }; +} diff --git a/llama.cpp/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts b/llama.cpp/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts new file mode 100644 index 0000000..c06cf28 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts @@ -0,0 +1,262 @@ +import { activeProcessingState } from '$lib/stores/chat.svelte'; +import { config } from '$lib/stores/settings.svelte'; + +export interface LiveProcessingStats { + tokensProcessed: number; + totalTokens: number; + timeMs: number; + tokensPerSecond: number; + etaSecs?: number; +} + +export interface LiveGenerationStats { + tokensGenerated: number; + timeMs: number; + tokensPerSecond: number; +} + +export interface UseProcessingStateReturn { + readonly processingState: ApiProcessingState | null; + getProcessingDetails(): string[]; + getProcessingMessage(): string; + getPromptProgressText(): string | null; + getLiveProcessingStats(): LiveProcessingStats | null; + getLiveGenerationStats(): LiveGenerationStats | null; + shouldShowDetails(): boolean; + startMonitoring(): void; + stopMonitoring(): void; +} + +/** + * useProcessingState - Reactive processing state hook + * + * This hook provides reactive access to the processing state of the server. + * It directly reads from chatStore's reactive state and provides + * formatted processing details for UI display. + * + * **Features:** + * - Real-time processing state via direct reactive state binding + * - Context and output token tracking + * - Tokens per second calculation + * - Automatic updates when streaming data arrives + * - Supports multiple concurrent conversations + * + * @returns Hook interface with processing state and control methods + */ +export function useProcessingState(): UseProcessingStateReturn { + let isMonitoring = $state(false); + let lastKnownState = $state<ApiProcessingState | null>(null); + let lastKnownProcessingStats = $state<LiveProcessingStats | null>(null); + + // Derive processing state reactively from chatStore's direct state + const processingState = $derived.by(() => { + if (!isMonitoring) { + return lastKnownState; + } + // Read directly from the reactive state export + return activeProcessingState(); + }); + + // Track last known state for keepStatsVisible functionality + $effect(() => { + if (processingState && isMonitoring) { + lastKnownState = processingState; + } + }); + + // Track last known processing stats for when promptProgress disappears + $effect(() => { + if (processingState?.promptProgress) { + const { processed, total, time_ms, cache } = processingState.promptProgress; + const actualProcessed = processed - cache; + const actualTotal = total - cache; + + if (actualProcessed > 0 && time_ms > 0) { + const tokensPerSecond = actualProcessed / (time_ms / 1000); + lastKnownProcessingStats = { + tokensProcessed: actualProcessed, + totalTokens: actualTotal, + timeMs: time_ms, + tokensPerSecond + }; + } + } + }); + + function getETASecs(done: number, total: number, elapsedMs: number): number | undefined { + const elapsedSecs = elapsedMs / 1000; + const progressETASecs = + done === 0 || elapsedSecs < 0.5 + ? undefined // can be the case for the 0% progress report + : elapsedSecs * (total / done - 1); + return progressETASecs; + } + + function startMonitoring(): void { + if (isMonitoring) return; + isMonitoring = true; + } + + function stopMonitoring(): void { + if (!isMonitoring) return; + isMonitoring = false; + + // Only clear last known state if keepStatsVisible is disabled + const currentConfig = config(); + if (!currentConfig.keepStatsVisible) { + lastKnownState = null; + lastKnownProcessingStats = null; + } + } + + function getProcessingMessage(): string { + if (!processingState) { + return 'Processing...'; + } + + switch (processingState.status) { + case 'initializing': + return 'Initializing...'; + case 'preparing': + if (processingState.progressPercent !== undefined) { + return `Processing (${processingState.progressPercent}%)`; + } + return 'Preparing response...'; + case 'generating': + return ''; + default: + return 'Processing...'; + } + } + + function getProcessingDetails(): string[] { + // Use current processing state or fall back to last known state + const stateToUse = processingState || lastKnownState; + if (!stateToUse) { + return []; + } + + const details: string[] = []; + + // Always show context info when we have valid data + if (stateToUse.contextUsed >= 0 && stateToUse.contextTotal > 0) { + const contextPercent = Math.round((stateToUse.contextUsed / stateToUse.contextTotal) * 100); + + details.push( + `Context: ${stateToUse.contextUsed}/${stateToUse.contextTotal} (${contextPercent}%)` + ); + } + + if (stateToUse.outputTokensUsed > 0) { + // Handle infinite max_tokens (-1) case + if (stateToUse.outputTokensMax <= 0) { + details.push(`Output: ${stateToUse.outputTokensUsed}/∞`); + } else { + const outputPercent = Math.round( + (stateToUse.outputTokensUsed / stateToUse.outputTokensMax) * 100 + ); + + details.push( + `Output: ${stateToUse.outputTokensUsed}/${stateToUse.outputTokensMax} (${outputPercent}%)` + ); + } + } + + if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) { + details.push(`${stateToUse.tokensPerSecond.toFixed(1)} tokens/sec`); + } + + if (stateToUse.speculative) { + details.push('Speculative decoding enabled'); + } + + return details; + } + + function shouldShowDetails(): boolean { + return processingState !== null && processingState.status !== 'idle'; + } + + /** + * Returns a short progress message with percent + */ + function getPromptProgressText(): string | null { + if (!processingState?.promptProgress) return null; + + const { processed, total, cache } = processingState.promptProgress; + + const actualProcessed = processed - cache; + const actualTotal = total - cache; + const percent = Math.round((actualProcessed / actualTotal) * 100); + const eta = getETASecs(actualProcessed, actualTotal, processingState.promptProgress.time_ms); + + if (eta !== undefined) { + const etaSecs = Math.ceil(eta); + return `Processing ${percent}% (ETA: ${etaSecs}s)`; + } + + return `Processing ${percent}%`; + } + + /** + * Returns live processing statistics for display (prompt processing phase) + * Returns last known stats when promptProgress becomes unavailable + */ + function getLiveProcessingStats(): LiveProcessingStats | null { + if (processingState?.promptProgress) { + const { processed, total, time_ms, cache } = processingState.promptProgress; + + const actualProcessed = processed - cache; + const actualTotal = total - cache; + + if (actualProcessed > 0 && time_ms > 0) { + const tokensPerSecond = actualProcessed / (time_ms / 1000); + + return { + tokensProcessed: actualProcessed, + totalTokens: actualTotal, + timeMs: time_ms, + tokensPerSecond + }; + } + } + + // Return last known stats if promptProgress is no longer available + return lastKnownProcessingStats; + } + + /** + * Returns live generation statistics for display (token generation phase) + */ + function getLiveGenerationStats(): LiveGenerationStats | null { + if (!processingState) return null; + + const { tokensDecoded, tokensPerSecond } = processingState; + + if (tokensDecoded <= 0) return null; + + // Calculate time from tokens and speed + const timeMs = + tokensPerSecond && tokensPerSecond > 0 ? (tokensDecoded / tokensPerSecond) * 1000 : 0; + + return { + tokensGenerated: tokensDecoded, + timeMs, + tokensPerSecond: tokensPerSecond || 0 + }; + } + + return { + get processingState() { + return processingState; + }, + getProcessingDetails, + getProcessingMessage, + getPromptProgressText, + getLiveProcessingStats, + getLiveGenerationStats, + shouldShowDetails, + startMonitoring, + stopMonitoring + }; +} diff --git a/llama.cpp/tools/server/webui/src/lib/markdown/enhance-code-blocks.ts b/llama.cpp/tools/server/webui/src/lib/markdown/enhance-code-blocks.ts new file mode 100644 index 0000000..6f0e03e --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/markdown/enhance-code-blocks.ts @@ -0,0 +1,162 @@ +/** + * Rehype plugin to enhance code blocks with wrapper, header, and action buttons. + * + * Wraps <pre><code> elements with a container that includes: + * - Language label + * - Copy button + * - Preview button (for HTML code blocks) + * + * This operates directly on the HAST tree for better performance, + * avoiding the need to stringify and re-parse HTML. + */ + +import type { Plugin } from 'unified'; +import type { Root, Element, ElementContent } from 'hast'; +import { visit } from 'unist-util-visit'; + +declare global { + interface Window { + idxCodeBlock?: number; + } +} + +const COPY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`; + +const PREVIEW_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye lucide-eye-icon"><path d="M2.062 12.345a1 1 0 0 1 0-.69C3.5 7.73 7.36 5 12 5s8.5 2.73 9.938 6.655a1 1 0 0 1 0 .69C20.5 16.27 16.64 19 12 19s-8.5-2.73-9.938-6.655"/><circle cx="12" cy="12" r="3"/></svg>`; + +/** + * Creates an SVG element node from raw SVG string. + * Since we can't parse HTML in HAST directly, we use the raw property. + */ +function createRawHtmlElement(html: string): Element { + return { + type: 'element', + tagName: 'span', + properties: {}, + children: [{ type: 'raw', value: html } as unknown as ElementContent] + }; +} + +function createCopyButton(codeId: string): Element { + return { + type: 'element', + tagName: 'button', + properties: { + className: ['copy-code-btn'], + 'data-code-id': codeId, + title: 'Copy code', + type: 'button' + }, + children: [createRawHtmlElement(COPY_ICON_SVG)] + }; +} + +function createPreviewButton(codeId: string): Element { + return { + type: 'element', + tagName: 'button', + properties: { + className: ['preview-code-btn'], + 'data-code-id': codeId, + title: 'Preview code', + type: 'button' + }, + children: [createRawHtmlElement(PREVIEW_ICON_SVG)] + }; +} + +function createHeader(language: string, codeId: string): Element { + const actions: Element[] = [createCopyButton(codeId)]; + + if (language.toLowerCase() === 'html') { + actions.push(createPreviewButton(codeId)); + } + + return { + type: 'element', + tagName: 'div', + properties: { className: ['code-block-header'] }, + children: [ + { + type: 'element', + tagName: 'span', + properties: { className: ['code-language'] }, + children: [{ type: 'text', value: language }] + }, + { + type: 'element', + tagName: 'div', + properties: { className: ['code-block-actions'] }, + children: actions + } + ] + }; +} + +function createWrapper(header: Element, preElement: Element): Element { + return { + type: 'element', + tagName: 'div', + properties: { className: ['code-block-wrapper'] }, + children: [header, preElement] + }; +} + +function extractLanguage(codeElement: Element): string { + const className = codeElement.properties?.className; + if (!Array.isArray(className)) return 'text'; + + for (const cls of className) { + if (typeof cls === 'string' && cls.startsWith('language-')) { + return cls.replace('language-', ''); + } + } + + return 'text'; +} + +/** + * Generates a unique code block ID using a global counter. + */ +function generateCodeId(): string { + if (typeof window !== 'undefined') { + return `code-${(window.idxCodeBlock = (window.idxCodeBlock ?? 0) + 1)}`; + } + // Fallback for SSR - use timestamp + random + return `code-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; +} + +/** + * Rehype plugin to enhance code blocks with wrapper, header, and action buttons. + * This plugin wraps <pre><code> elements with a container that includes: + * - Language label + * - Copy button + * - Preview button (for HTML code blocks) + */ +export const rehypeEnhanceCodeBlocks: Plugin<[], Root> = () => { + return (tree: Root) => { + visit(tree, 'element', (node: Element, index, parent) => { + if (node.tagName !== 'pre' || !parent || index === undefined) return; + + const codeElement = node.children.find( + (child): child is Element => child.type === 'element' && child.tagName === 'code' + ); + + if (!codeElement) return; + + const language = extractLanguage(codeElement); + const codeId = generateCodeId(); + + codeElement.properties = { + ...codeElement.properties, + 'data-code-id': codeId + }; + + const header = createHeader(language, codeId); + const wrapper = createWrapper(header, node); + + // Replace pre with wrapper in parent + (parent.children as ElementContent[])[index] = wrapper; + }); + }; +}; diff --git a/llama.cpp/tools/server/webui/src/lib/markdown/enhance-links.ts b/llama.cpp/tools/server/webui/src/lib/markdown/enhance-links.ts new file mode 100644 index 0000000..b5fbcbd --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/markdown/enhance-links.ts @@ -0,0 +1,33 @@ +/** + * Rehype plugin to enhance links with security attributes. + * + * Adds target="_blank" and rel="noopener noreferrer" to all anchor elements, + * ensuring external links open in new tabs safely. + */ + +import type { Plugin } from 'unified'; +import type { Root, Element } from 'hast'; +import { visit } from 'unist-util-visit'; + +/** + * Rehype plugin that adds security attributes to all links. + * This plugin ensures external links open in new tabs safely by adding: + * - target="_blank" + * - rel="noopener noreferrer" + */ +export const rehypeEnhanceLinks: Plugin<[], Root> = () => { + return (tree: Root) => { + visit(tree, 'element', (node: Element) => { + if (node.tagName !== 'a') return; + + const props = node.properties ?? {}; + + // Only modify if href exists + if (!props.href) return; + + props.target = '_blank'; + props.rel = 'noopener noreferrer'; + node.properties = props; + }); + }; +}; diff --git a/llama.cpp/tools/server/webui/src/lib/markdown/literal-html.ts b/llama.cpp/tools/server/webui/src/lib/markdown/literal-html.ts new file mode 100644 index 0000000..d4ace01 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/markdown/literal-html.ts @@ -0,0 +1,121 @@ +import type { Plugin } from 'unified'; +import { visit } from 'unist-util-visit'; +import type { Break, Content, Paragraph, PhrasingContent, Root, Text } from 'mdast'; +import { LINE_BREAK, NBSP, PHRASE_PARENTS, TAB_AS_SPACES } from '$lib/constants/literal-html'; + +/** + * remark plugin that rewrites raw HTML nodes into plain-text equivalents. + * + * remark parses inline HTML into `html` nodes even when we do not want to render + * them. We turn each of those nodes into regular text (plus `<br>` break markers) + * so the downstream rehype pipeline escapes the characters instead of executing + * them. Leading spaces and tab characters are converted to non‑breaking spaces to + * keep indentation identical to the original author input. + */ + +function preserveIndent(line: string): string { + let index = 0; + let output = ''; + + while (index < line.length) { + const char = line[index]; + + if (char === ' ') { + output += NBSP; + index += 1; + continue; + } + + if (char === '\t') { + output += TAB_AS_SPACES; + index += 1; + continue; + } + + break; + } + + return output + line.slice(index); +} + +function createLiteralChildren(value: string): PhrasingContent[] { + const lines = value.split(LINE_BREAK); + const nodes: PhrasingContent[] = []; + + for (const [lineIndex, rawLine] of lines.entries()) { + if (lineIndex > 0) { + nodes.push({ type: 'break' } as Break as unknown as PhrasingContent); + } + + nodes.push({ + type: 'text', + value: preserveIndent(rawLine) + } as Text as unknown as PhrasingContent); + } + + if (!nodes.length) { + nodes.push({ type: 'text', value: '' } as Text as unknown as PhrasingContent); + } + + return nodes; +} + +export const remarkLiteralHtml: Plugin<[], Root> = () => { + return (tree) => { + visit(tree, 'html', (node, index, parent) => { + if (!parent || typeof index !== 'number') { + return; + } + + const replacement = createLiteralChildren(node.value); + + if (!PHRASE_PARENTS.has(parent.type as string)) { + const paragraph: Paragraph = { + type: 'paragraph', + children: replacement as Paragraph['children'], + data: { literalHtml: true } + }; + + const siblings = parent.children as unknown as Content[]; + siblings.splice(index, 1, paragraph as unknown as Content); + + if (index > 0) { + const previous = siblings[index - 1] as Paragraph | undefined; + + if ( + previous?.type === 'paragraph' && + (previous.data as { literalHtml?: boolean } | undefined)?.literalHtml + ) { + const prevChildren = previous.children as unknown as PhrasingContent[]; + + if (prevChildren.length) { + const lastChild = prevChildren[prevChildren.length - 1]; + + if (lastChild.type !== 'break') { + prevChildren.push({ + type: 'break' + } as Break as unknown as PhrasingContent); + } + } + + prevChildren.push(...(paragraph.children as unknown as PhrasingContent[])); + + siblings.splice(index, 1); + + return index; + } + } + + return index + 1; + } + + (parent.children as unknown as PhrasingContent[]).splice( + index, + 1, + ...(replacement as unknown as PhrasingContent[]) + ); + + return index + replacement.length; + }); + }; +}; diff --git a/llama.cpp/tools/server/webui/src/lib/markdown/table-html-restorer.ts b/llama.cpp/tools/server/webui/src/lib/markdown/table-html-restorer.ts new file mode 100644 index 0000000..918aa46 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/markdown/table-html-restorer.ts @@ -0,0 +1,181 @@ +/** + * Rehype plugin to restore limited HTML elements inside Markdown table cells. + * + * ## Problem + * The remark/rehype pipeline neutralizes inline HTML as literal text + * (remarkLiteralHtml) so that XML/HTML snippets in LLM responses display + * as-is instead of being rendered. This causes <br> and <ul> markup in + * table cells to show as plain text. + * + * ## Solution + * This plugin traverses the HAST post-conversion, parses whitelisted HTML + * patterns from text nodes, and replaces them with actual HAST element nodes + * that will be rendered as real HTML. + * + * ## Supported HTML + * - `<br>` / `<br/>` / `<br />` - Line breaks (inline) + * - `<ul><li>...</li></ul>` - Unordered lists (block) + * + * ## Key Implementation Details + * + * ### 1. Sibling Combination (Critical) + * The Markdown pipeline may fragment content across multiple text nodes and `<br>` + * elements. For example, `<ul><li>a</li></ul>` might arrive as: + * - Text: `"<ul>"` + * - Element: `<br>` + * - Text: `"<li>a</li></ul>"` + * + * We must combine consecutive text nodes and `<br>` elements into a single string + * before attempting to parse list markup. Without this, list detection fails. + * + * ### 2. visitParents for Deep Traversal + * Table cell content may be wrapped in intermediate elements (e.g., `<p>` tags). + * Using `visitParents` instead of direct child iteration ensures we find text + * nodes at any depth within the cell. + * + * ### 3. Reference Comparison for No-Op Detection + * When checking if `<br>` expansion changed anything, we compare: + * `expanded.length !== 1 || expanded[0] !== textNode` + * + * This catches both cases: + * - Multiple nodes created (text was split) + * - Single NEW node created (original had only `<br>`, now it's an element) + * + * A simple `length > 1` check would miss the single `<br>` case. + * + * ### 4. Strict List Validation + * `parseList()` rejects malformed markup by checking for garbage text between + * `<li>` elements. This prevents creating broken DOM from partial matches like + * `<ul>garbage<li>a</li></ul>`. + * + * ### 5. Newline Substitution for `<br>` in Combined String + * When combining siblings, existing `<br>` elements become `\n` in the combined + * string. This allows list content to span visual lines while still being parsed + * as a single unit. + * + * @example + * // Input Markdown: + * // | Feature | Notes | + * // |---------|-------| + * // | Multi-line | First<br>Second | + * // | List | <ul><li>A</li><li>B</li></ul> | + * // + * // Without this plugin: <br> and <ul> render as literal text + * // With this plugin: <br> becomes line break, <ul> becomes actual list + */ + +import type { Plugin } from 'unified'; +import type { Element, ElementContent, Root, Text } from 'hast'; +import { visit } from 'unist-util-visit'; +import { visitParents } from 'unist-util-visit-parents'; +import { BR_PATTERN, LIST_PATTERN, LI_PATTERN } from '$lib/constants/table-html-restorer'; + +/** + * Expands text containing `<br>` tags into an array of text nodes and br elements. + */ +function expandBrTags(value: string): ElementContent[] { + const matches = [...value.matchAll(BR_PATTERN)]; + if (!matches.length) return [{ type: 'text', value } as Text]; + + const result: ElementContent[] = []; + let cursor = 0; + + for (const m of matches) { + if (m.index! > cursor) { + result.push({ type: 'text', value: value.slice(cursor, m.index) } as Text); + } + result.push({ type: 'element', tagName: 'br', properties: {}, children: [] } as Element); + cursor = m.index! + m[0].length; + } + + if (cursor < value.length) { + result.push({ type: 'text', value: value.slice(cursor) } as Text); + } + + return result; +} + +/** + * Parses a `<ul><li>...</li></ul>` string into a HAST element. + * Returns null if the markup is malformed or contains unexpected content. + */ +function parseList(value: string): Element | null { + const match = value.trim().match(LIST_PATTERN); + if (!match) return null; + + const body = match[1]; + const items: ElementContent[] = []; + let cursor = 0; + + for (const liMatch of body.matchAll(LI_PATTERN)) { + // Reject if there's non-whitespace between list items + if (body.slice(cursor, liMatch.index!).trim()) return null; + + items.push({ + type: 'element', + tagName: 'li', + properties: {}, + children: expandBrTags(liMatch[1] ?? '') + } as Element); + + cursor = liMatch.index! + liMatch[0].length; + } + + // Reject if no items found or trailing garbage exists + if (!items.length || body.slice(cursor).trim()) return null; + + return { type: 'element', tagName: 'ul', properties: {}, children: items } as Element; +} + +/** + * Processes a single table cell, restoring HTML elements from text content. + */ +function processCell(cell: Element) { + visitParents(cell, 'text', (textNode: Text, ancestors) => { + const parent = ancestors[ancestors.length - 1]; + if (!parent || parent.type !== 'element') return; + + const parentEl = parent as Element; + const siblings = parentEl.children as ElementContent[]; + const startIndex = siblings.indexOf(textNode as ElementContent); + if (startIndex === -1) return; + + // Combine consecutive text nodes and <br> elements into one string + let combined = ''; + let endIndex = startIndex; + + for (let i = startIndex; i < siblings.length; i++) { + const sib = siblings[i]; + if (sib.type === 'text') { + combined += (sib as Text).value; + endIndex = i; + } else if (sib.type === 'element' && (sib as Element).tagName === 'br') { + combined += '\n'; + endIndex = i; + } else { + break; + } + } + + // Try parsing as list first (replaces entire combined range) + const list = parseList(combined); + if (list) { + siblings.splice(startIndex, endIndex - startIndex + 1, list); + return; + } + + // Otherwise, just expand <br> tags in this text node + const expanded = expandBrTags(textNode.value); + if (expanded.length !== 1 || expanded[0] !== textNode) { + siblings.splice(startIndex, 1, ...expanded); + } + }); +} + +export const rehypeRestoreTableHtml: Plugin<[], Root> = () => (tree) => { + visit(tree, 'element', (node: Element) => { + if (node.tagName === 'td' || node.tagName === 'th') { + processCell(node); + } + }); +}; diff --git a/llama.cpp/tools/server/webui/src/lib/services/chat.ts b/llama.cpp/tools/server/webui/src/lib/services/chat.ts new file mode 100644 index 0000000..02fc638 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/services/chat.ts @@ -0,0 +1,784 @@ +import { getJsonHeaders } from '$lib/utils'; +import { AttachmentType } from '$lib/enums'; + +/** + * ChatService - Low-level API communication layer for Chat Completions + * + * **Terminology - Chat vs Conversation:** + * - **Chat**: The active interaction space with the Chat Completions API. This service + * handles the real-time communication with the AI backend - sending messages, receiving + * streaming responses, and managing request lifecycles. "Chat" is ephemeral and runtime-focused. + * - **Conversation**: The persistent database entity storing all messages and metadata. + * Managed by ConversationsService/Store, conversations persist across sessions. + * + * This service handles direct communication with the llama-server's Chat Completions API. + * It provides the network layer abstraction for AI model interactions while remaining + * stateless and focused purely on API communication. + * + * **Architecture & Relationships:** + * - **ChatService** (this class): Stateless API communication layer + * - Handles HTTP requests/responses with the llama-server + * - Manages streaming and non-streaming response parsing + * - Provides per-conversation request abortion capabilities + * - Converts database messages to API format + * - Handles error translation for server responses + * + * - **chatStore**: Uses ChatService for all AI model communication + * - **conversationsStore**: Provides message context for API requests + * + * **Key Responsibilities:** + * - Message format conversion (DatabaseMessage → API format) + * - Streaming response handling with real-time callbacks + * - Reasoning content extraction and processing + * - File attachment processing (images, PDFs, audio, text) + * - Request lifecycle management (abort via AbortSignal) + */ +export class ChatService { + // ───────────────────────────────────────────────────────────────────────────── + // Messaging + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Sends a chat completion request to the llama.cpp server. + * Supports both streaming and non-streaming responses with comprehensive parameter configuration. + * Automatically converts database messages with attachments to the appropriate API format. + * + * @param messages - Array of chat messages to send to the API (supports both ApiChatMessageData and DatabaseMessage with attachments) + * @param options - Configuration options for the chat completion request. See `SettingsChatServiceOptions` type for details. + * @returns {Promise<string | void>} that resolves to the complete response string (non-streaming) or void (streaming) + * @throws {Error} if the request fails or is aborted + */ + static async sendMessage( + messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[], + options: SettingsChatServiceOptions = {}, + conversationId?: string, + signal?: AbortSignal + ): Promise<string | void> { + const { + stream, + onChunk, + onComplete, + onError, + onReasoningChunk, + onToolCallChunk, + onModel, + onTimings, + // Generation parameters + temperature, + max_tokens, + // Sampling parameters + dynatemp_range, + dynatemp_exponent, + top_k, + top_p, + min_p, + xtc_probability, + xtc_threshold, + typ_p, + // Penalty parameters + repeat_last_n, + repeat_penalty, + presence_penalty, + frequency_penalty, + dry_multiplier, + dry_base, + dry_allowed_length, + dry_penalty_last_n, + // Other parameters + samplers, + backend_sampling, + custom, + timings_per_token, + // Config options + disableReasoningFormat + } = options; + + const normalizedMessages: ApiChatMessageData[] = messages + .map((msg) => { + if ('id' in msg && 'convId' in msg && 'timestamp' in msg) { + const dbMsg = msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }; + return ChatService.convertDbMessageToApiChatMessageData(dbMsg); + } else { + return msg as ApiChatMessageData; + } + }) + .filter((msg) => { + // Filter out empty system messages + if (msg.role === 'system') { + const content = typeof msg.content === 'string' ? msg.content : ''; + + return content.trim().length > 0; + } + + return true; + }); + + const requestBody: ApiChatCompletionRequest = { + messages: normalizedMessages.map((msg: ApiChatMessageData) => ({ + role: msg.role, + content: msg.content + })), + stream, + return_progress: stream ? true : undefined + }; + + // Include model in request if provided (required in ROUTER mode) + if (options.model) { + requestBody.model = options.model; + } + + requestBody.reasoning_format = disableReasoningFormat ? 'none' : 'auto'; + + if (temperature !== undefined) requestBody.temperature = temperature; + if (max_tokens !== undefined) { + // Set max_tokens to -1 (infinite) when explicitly configured as 0 or null + requestBody.max_tokens = max_tokens !== null && max_tokens !== 0 ? max_tokens : -1; + } + + if (dynatemp_range !== undefined) requestBody.dynatemp_range = dynatemp_range; + if (dynatemp_exponent !== undefined) requestBody.dynatemp_exponent = dynatemp_exponent; + if (top_k !== undefined) requestBody.top_k = top_k; + if (top_p !== undefined) requestBody.top_p = top_p; + if (min_p !== undefined) requestBody.min_p = min_p; + if (xtc_probability !== undefined) requestBody.xtc_probability = xtc_probability; + if (xtc_threshold !== undefined) requestBody.xtc_threshold = xtc_threshold; + if (typ_p !== undefined) requestBody.typ_p = typ_p; + + if (repeat_last_n !== undefined) requestBody.repeat_last_n = repeat_last_n; + if (repeat_penalty !== undefined) requestBody.repeat_penalty = repeat_penalty; + if (presence_penalty !== undefined) requestBody.presence_penalty = presence_penalty; + if (frequency_penalty !== undefined) requestBody.frequency_penalty = frequency_penalty; + if (dry_multiplier !== undefined) requestBody.dry_multiplier = dry_multiplier; + if (dry_base !== undefined) requestBody.dry_base = dry_base; + if (dry_allowed_length !== undefined) requestBody.dry_allowed_length = dry_allowed_length; + if (dry_penalty_last_n !== undefined) requestBody.dry_penalty_last_n = dry_penalty_last_n; + + if (samplers !== undefined) { + requestBody.samplers = + typeof samplers === 'string' + ? samplers.split(';').filter((s: string) => s.trim()) + : samplers; + } + + if (backend_sampling !== undefined) requestBody.backend_sampling = backend_sampling; + + if (timings_per_token !== undefined) requestBody.timings_per_token = timings_per_token; + + if (custom) { + try { + const customParams = typeof custom === 'string' ? JSON.parse(custom) : custom; + Object.assign(requestBody, customParams); + } catch (error) { + console.warn('Failed to parse custom parameters:', error); + } + } + + try { + const response = await fetch(`./v1/chat/completions`, { + method: 'POST', + headers: getJsonHeaders(), + body: JSON.stringify(requestBody), + signal + }); + + if (!response.ok) { + const error = await ChatService.parseErrorResponse(response); + if (onError) { + onError(error); + } + throw error; + } + + if (stream) { + await ChatService.handleStreamResponse( + response, + onChunk, + onComplete, + onError, + onReasoningChunk, + onToolCallChunk, + onModel, + onTimings, + conversationId, + signal + ); + return; + } else { + return ChatService.handleNonStreamResponse( + response, + onComplete, + onError, + onToolCallChunk, + onModel + ); + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + console.log('Chat completion request was aborted'); + return; + } + + let userFriendlyError: Error; + + if (error instanceof Error) { + if (error.name === 'TypeError' && error.message.includes('fetch')) { + userFriendlyError = new Error( + 'Unable to connect to server - please check if the server is running' + ); + userFriendlyError.name = 'NetworkError'; + } else if (error.message.includes('ECONNREFUSED')) { + userFriendlyError = new Error('Connection refused - server may be offline'); + userFriendlyError.name = 'NetworkError'; + } else if (error.message.includes('ETIMEDOUT')) { + userFriendlyError = new Error('Request timed out - the server took too long to respond'); + userFriendlyError.name = 'TimeoutError'; + } else { + userFriendlyError = error; + } + } else { + userFriendlyError = new Error('Unknown error occurred while sending message'); + } + + console.error('Error in sendMessage:', error); + if (onError) { + onError(userFriendlyError); + } + throw userFriendlyError; + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Streaming + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Handles streaming response from the chat completion API + * @param response - The Response object from the fetch request + * @param onChunk - Optional callback invoked for each content chunk received + * @param onComplete - Optional callback invoked when the stream is complete with full response + * @param onError - Optional callback invoked if an error occurs during streaming + * @param onReasoningChunk - Optional callback invoked for each reasoning content chunk + * @param conversationId - Optional conversation ID for per-conversation state tracking + * @returns {Promise<void>} Promise that resolves when streaming is complete + * @throws {Error} if the stream cannot be read or parsed + */ + private static async handleStreamResponse( + response: Response, + onChunk?: (chunk: string) => void, + onComplete?: ( + response: string, + reasoningContent?: string, + timings?: ChatMessageTimings, + toolCalls?: string + ) => void, + onError?: (error: Error) => void, + onReasoningChunk?: (chunk: string) => void, + onToolCallChunk?: (chunk: string) => void, + onModel?: (model: string) => void, + onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void, + conversationId?: string, + abortSignal?: AbortSignal + ): Promise<void> { + const reader = response.body?.getReader(); + + if (!reader) { + throw new Error('No response body'); + } + + const decoder = new TextDecoder(); + let aggregatedContent = ''; + let fullReasoningContent = ''; + let aggregatedToolCalls: ApiChatCompletionToolCall[] = []; + let lastTimings: ChatMessageTimings | undefined; + let streamFinished = false; + let modelEmitted = false; + let toolCallIndexOffset = 0; + let hasOpenToolCallBatch = false; + + const finalizeOpenToolCallBatch = () => { + if (!hasOpenToolCallBatch) { + return; + } + + toolCallIndexOffset = aggregatedToolCalls.length; + hasOpenToolCallBatch = false; + }; + + const processToolCallDelta = (toolCalls?: ApiChatCompletionToolCallDelta[]) => { + if (!toolCalls || toolCalls.length === 0) { + return; + } + + aggregatedToolCalls = ChatService.mergeToolCallDeltas( + aggregatedToolCalls, + toolCalls, + toolCallIndexOffset + ); + + if (aggregatedToolCalls.length === 0) { + return; + } + + hasOpenToolCallBatch = true; + + const serializedToolCalls = JSON.stringify(aggregatedToolCalls); + + if (!serializedToolCalls) { + return; + } + + if (!abortSignal?.aborted) { + onToolCallChunk?.(serializedToolCalls); + } + }; + + try { + let chunk = ''; + while (true) { + if (abortSignal?.aborted) break; + + const { done, value } = await reader.read(); + if (done) break; + + if (abortSignal?.aborted) break; + + chunk += decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + chunk = lines.pop() || ''; + + for (const line of lines) { + if (abortSignal?.aborted) break; + + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') { + streamFinished = true; + continue; + } + + try { + const parsed: ApiChatCompletionStreamChunk = JSON.parse(data); + const content = parsed.choices[0]?.delta?.content; + const reasoningContent = parsed.choices[0]?.delta?.reasoning_content; + const toolCalls = parsed.choices[0]?.delta?.tool_calls; + const timings = parsed.timings; + const promptProgress = parsed.prompt_progress; + + const chunkModel = ChatService.extractModelName(parsed); + if (chunkModel && !modelEmitted) { + modelEmitted = true; + onModel?.(chunkModel); + } + + if (promptProgress) { + ChatService.notifyTimings(undefined, promptProgress, onTimings); + } + + if (timings) { + ChatService.notifyTimings(timings, promptProgress, onTimings); + lastTimings = timings; + } + + if (content) { + finalizeOpenToolCallBatch(); + aggregatedContent += content; + if (!abortSignal?.aborted) { + onChunk?.(content); + } + } + + if (reasoningContent) { + finalizeOpenToolCallBatch(); + fullReasoningContent += reasoningContent; + if (!abortSignal?.aborted) { + onReasoningChunk?.(reasoningContent); + } + } + + processToolCallDelta(toolCalls); + } catch (e) { + console.error('Error parsing JSON chunk:', e); + } + } + } + + if (abortSignal?.aborted) break; + } + + if (abortSignal?.aborted) return; + + if (streamFinished) { + finalizeOpenToolCallBatch(); + + const finalToolCalls = + aggregatedToolCalls.length > 0 ? JSON.stringify(aggregatedToolCalls) : undefined; + + onComplete?.( + aggregatedContent, + fullReasoningContent || undefined, + lastTimings, + finalToolCalls + ); + } + } catch (error) { + const err = error instanceof Error ? error : new Error('Stream error'); + + onError?.(err); + + throw err; + } finally { + reader.releaseLock(); + } + } + + /** + * Handles non-streaming response from the chat completion API. + * Parses the JSON response and extracts the generated content. + * + * @param response - The fetch Response object containing the JSON data + * @param onComplete - Optional callback invoked when response is successfully parsed + * @param onError - Optional callback invoked if an error occurs during parsing + * @returns {Promise<string>} Promise that resolves to the generated content string + * @throws {Error} if the response cannot be parsed or is malformed + */ + private static async handleNonStreamResponse( + response: Response, + onComplete?: ( + response: string, + reasoningContent?: string, + timings?: ChatMessageTimings, + toolCalls?: string + ) => void, + onError?: (error: Error) => void, + onToolCallChunk?: (chunk: string) => void, + onModel?: (model: string) => void + ): Promise<string> { + try { + const responseText = await response.text(); + + if (!responseText.trim()) { + const noResponseError = new Error('No response received from server. Please try again.'); + throw noResponseError; + } + + const data: ApiChatCompletionResponse = JSON.parse(responseText); + + const responseModel = ChatService.extractModelName(data); + if (responseModel) { + onModel?.(responseModel); + } + + const content = data.choices[0]?.message?.content || ''; + const reasoningContent = data.choices[0]?.message?.reasoning_content; + const toolCalls = data.choices[0]?.message?.tool_calls; + + if (reasoningContent) { + console.log('Full reasoning content:', reasoningContent); + } + + let serializedToolCalls: string | undefined; + + if (toolCalls && toolCalls.length > 0) { + const mergedToolCalls = ChatService.mergeToolCallDeltas([], toolCalls); + + if (mergedToolCalls.length > 0) { + serializedToolCalls = JSON.stringify(mergedToolCalls); + if (serializedToolCalls) { + onToolCallChunk?.(serializedToolCalls); + } + } + } + + if (!content.trim() && !serializedToolCalls) { + const noResponseError = new Error('No response received from server. Please try again.'); + throw noResponseError; + } + + onComplete?.(content, reasoningContent, undefined, serializedToolCalls); + + return content; + } catch (error) { + const err = error instanceof Error ? error : new Error('Parse error'); + + onError?.(err); + + throw err; + } + } + + /** + * Merges tool call deltas into an existing array of tool calls. + * Handles both existing and new tool calls, updating existing ones and adding new ones. + * + * @param existing - The existing array of tool calls to merge into + * @param deltas - The array of tool call deltas to merge + * @param indexOffset - Optional offset to apply to the index of new tool calls + * @returns {ApiChatCompletionToolCall[]} The merged array of tool calls + */ + private static mergeToolCallDeltas( + existing: ApiChatCompletionToolCall[], + deltas: ApiChatCompletionToolCallDelta[], + indexOffset = 0 + ): ApiChatCompletionToolCall[] { + const result = existing.map((call) => ({ + ...call, + function: call.function ? { ...call.function } : undefined + })); + + for (const delta of deltas) { + const index = + typeof delta.index === 'number' && delta.index >= 0 + ? delta.index + indexOffset + : result.length; + + while (result.length <= index) { + result.push({ function: undefined }); + } + + const target = result[index]!; + + if (delta.id) { + target.id = delta.id; + } + + if (delta.type) { + target.type = delta.type; + } + + if (delta.function) { + const fn = target.function ? { ...target.function } : {}; + + if (delta.function.name) { + fn.name = delta.function.name; + } + + if (delta.function.arguments) { + fn.arguments = (fn.arguments ?? '') + delta.function.arguments; + } + + target.function = fn; + } + } + + return result; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Conversion + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Converts a database message with attachments to API chat message format. + * Processes various attachment types (images, text files, PDFs) and formats them + * as content parts suitable for the chat completion API. + * + * @param message - Database message object with optional extra attachments + * @param message.content - The text content of the message + * @param message.role - The role of the message sender (user, assistant, system) + * @param message.extra - Optional array of message attachments (images, files, etc.) + * @returns {ApiChatMessageData} object formatted for the chat completion API + * @static + */ + static convertDbMessageToApiChatMessageData( + message: DatabaseMessage & { extra?: DatabaseMessageExtra[] } + ): ApiChatMessageData { + if (!message.extra || message.extra.length === 0) { + return { + role: message.role as 'user' | 'assistant' | 'system', + content: message.content + }; + } + + const contentParts: ApiChatMessageContentPart[] = []; + + if (message.content) { + contentParts.push({ + type: 'text', + text: message.content + }); + } + + const imageFiles = message.extra.filter( + (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraImageFile => + extra.type === AttachmentType.IMAGE + ); + + for (const image of imageFiles) { + contentParts.push({ + type: 'image_url', + image_url: { url: image.base64Url } + }); + } + + const textFiles = message.extra.filter( + (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraTextFile => + extra.type === AttachmentType.TEXT + ); + + for (const textFile of textFiles) { + contentParts.push({ + type: 'text', + text: `\n\n--- File: ${textFile.name} ---\n${textFile.content}` + }); + } + + // Handle legacy 'context' type from old webui (pasted content) + const legacyContextFiles = message.extra.filter( + (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraLegacyContext => + extra.type === AttachmentType.LEGACY_CONTEXT + ); + + for (const legacyContextFile of legacyContextFiles) { + contentParts.push({ + type: 'text', + text: `\n\n--- File: ${legacyContextFile.name} ---\n${legacyContextFile.content}` + }); + } + + const audioFiles = message.extra.filter( + (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraAudioFile => + extra.type === AttachmentType.AUDIO + ); + + for (const audio of audioFiles) { + contentParts.push({ + type: 'input_audio', + input_audio: { + data: audio.base64Data, + format: audio.mimeType.includes('wav') ? 'wav' : 'mp3' + } + }); + } + + const pdfFiles = message.extra.filter( + (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraPdfFile => + extra.type === AttachmentType.PDF + ); + + for (const pdfFile of pdfFiles) { + if (pdfFile.processedAsImages && pdfFile.images) { + for (let i = 0; i < pdfFile.images.length; i++) { + contentParts.push({ + type: 'image_url', + image_url: { url: pdfFile.images[i] } + }); + } + } else { + contentParts.push({ + type: 'text', + text: `\n\n--- PDF File: ${pdfFile.name} ---\n${pdfFile.content}` + }); + } + } + + return { + role: message.role as 'user' | 'assistant' | 'system', + content: contentParts + }; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Utilities + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Parses error response and creates appropriate error with context information + * @param response - HTTP response object + * @returns Promise<Error> - Parsed error with context info if available + */ + private static async parseErrorResponse( + response: Response + ): Promise<Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }> { + try { + const errorText = await response.text(); + const errorData: ApiErrorResponse = JSON.parse(errorText); + + const message = errorData.error?.message || 'Unknown server error'; + const error = new Error(message) as Error & { + contextInfo?: { n_prompt_tokens: number; n_ctx: number }; + }; + error.name = response.status === 400 ? 'ServerError' : 'HttpError'; + + if (errorData.error && 'n_prompt_tokens' in errorData.error && 'n_ctx' in errorData.error) { + error.contextInfo = { + n_prompt_tokens: errorData.error.n_prompt_tokens, + n_ctx: errorData.error.n_ctx + }; + } + + return error; + } catch { + const fallback = new Error( + `Server error (${response.status}): ${response.statusText}` + ) as Error & { + contextInfo?: { n_prompt_tokens: number; n_ctx: number }; + }; + fallback.name = 'HttpError'; + return fallback; + } + } + + /** + * Extracts model name from Chat Completions API response data. + * Handles various response formats including streaming chunks and final responses. + * + * WORKAROUND: In single model mode, llama-server returns a default/incorrect model name + * in the response. We override it with the actual model name from serverStore. + * + * @param data - Raw response data from the Chat Completions API + * @returns Model name string if found, undefined otherwise + * @private + */ + private static extractModelName(data: unknown): string | undefined { + const asRecord = (value: unknown): Record<string, unknown> | undefined => { + return typeof value === 'object' && value !== null + ? (value as Record<string, unknown>) + : undefined; + }; + + const getTrimmedString = (value: unknown): string | undefined => { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + }; + + const root = asRecord(data); + if (!root) return undefined; + + // 1) root (some implementations provide `model` at the top level) + const rootModel = getTrimmedString(root.model); + if (rootModel) return rootModel; + + // 2) streaming choice (delta) or final response (message) + const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined; + if (!firstChoice) return undefined; + + // priority: delta.model (first chunk) else message.model (final response) + const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model); + if (deltaModel) return deltaModel; + + const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model); + if (messageModel) return messageModel; + + // avoid guessing from non-standard locations (metadata, etc.) + return undefined; + } + + /** + * Calls the onTimings callback with timing data from streaming response. + * + * @param timings - Timing information from the Chat Completions API response + * @param promptProgress - Prompt processing progress data + * @param onTimingsCallback - Callback function to invoke with timing data + * @private + */ + private static notifyTimings( + timings: ChatMessageTimings | undefined, + promptProgress: ChatMessagePromptProgress | undefined, + onTimingsCallback: + | ((timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void) + | undefined + ): void { + if (!onTimingsCallback || (!timings && !promptProgress)) return; + + onTimingsCallback(timings, promptProgress); + } +} diff --git a/llama.cpp/tools/server/webui/src/lib/services/database.ts b/llama.cpp/tools/server/webui/src/lib/services/database.ts new file mode 100644 index 0000000..3b24628 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/services/database.ts @@ -0,0 +1,400 @@ +import Dexie, { type EntityTable } from 'dexie'; +import { findDescendantMessages } from '$lib/utils'; + +class LlamacppDatabase extends Dexie { + conversations!: EntityTable<DatabaseConversation, string>; + messages!: EntityTable<DatabaseMessage, string>; + + constructor() { + super('LlamacppWebui'); + + this.version(1).stores({ + conversations: 'id, lastModified, currNode, name', + messages: 'id, convId, type, role, timestamp, parent, children' + }); + } +} + +const db = new LlamacppDatabase(); +import { v4 as uuid } from 'uuid'; + +/** + * DatabaseService - Stateless IndexedDB communication layer + * + * **Terminology - Chat vs Conversation:** + * - **Chat**: The active interaction space with the Chat Completions API (ephemeral, runtime). + * - **Conversation**: The persistent database entity storing all messages and metadata. + * This service handles raw database operations for conversations - the lowest layer + * in the persistence stack. + * + * This service provides a stateless data access layer built on IndexedDB using Dexie ORM. + * It handles all low-level storage operations for conversations and messages with support + * for complex branching and message threading. All methods are static - no instance state. + * + * **Architecture & Relationships (bottom to top):** + * - **DatabaseService** (this class): Stateless IndexedDB operations + * - Lowest layer - direct Dexie/IndexedDB communication + * - Pure CRUD operations without business logic + * - Handles branching tree structure (parent-child relationships) + * - Provides transaction safety for multi-table operations + * + * - **ConversationsService**: Stateless business logic layer + * - Uses DatabaseService for all persistence operations + * - Adds import/export, navigation, and higher-level operations + * + * - **conversationsStore**: Reactive state management for conversations + * - Uses ConversationsService for database operations + * - Manages conversation list, active conversation, and messages in memory + * + * - **chatStore**: Active AI interaction management + * - Uses conversationsStore for conversation context + * - Directly uses DatabaseService for message CRUD during streaming + * + * **Key Features:** + * - **Conversation CRUD**: Create, read, update, delete conversations + * - **Message CRUD**: Add, update, delete messages with branching support + * - **Branch Operations**: Create branches, find descendants, cascade deletions + * - **Transaction Safety**: Atomic operations for data consistency + * + * **Database Schema:** + * - `conversations`: id, lastModified, currNode, name + * - `messages`: id, convId, type, role, timestamp, parent, children + * + * **Branching Model:** + * Messages form a tree structure where each message can have multiple children, + * enabling conversation branching and alternative response paths. The conversation's + * `currNode` tracks the currently active branch endpoint. + */ +export class DatabaseService { + // ───────────────────────────────────────────────────────────────────────────── + // Conversations + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Creates a new conversation. + * + * @param name - Name of the conversation + * @returns The created conversation + */ + static async createConversation(name: string): Promise<DatabaseConversation> { + const conversation: DatabaseConversation = { + id: uuid(), + name, + lastModified: Date.now(), + currNode: '' + }; + + await db.conversations.add(conversation); + return conversation; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Messages + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Creates a new message branch by adding a message and updating parent/child relationships. + * Also updates the conversation's currNode to point to the new message. + * + * @param message - Message to add (without id) + * @param parentId - Parent message ID to attach to + * @returns The created message + */ + static async createMessageBranch( + message: Omit<DatabaseMessage, 'id'>, + parentId: string | null + ): Promise<DatabaseMessage> { + return await db.transaction('rw', [db.conversations, db.messages], async () => { + // Handle null parent (root message case) + if (parentId !== null) { + const parentMessage = await db.messages.get(parentId); + if (!parentMessage) { + throw new Error(`Parent message ${parentId} not found`); + } + } + + const newMessage: DatabaseMessage = { + ...message, + id: uuid(), + parent: parentId, + toolCalls: message.toolCalls ?? '', + children: [] + }; + + await db.messages.add(newMessage); + + // Update parent's children array if parent exists + if (parentId !== null) { + const parentMessage = await db.messages.get(parentId); + if (parentMessage) { + await db.messages.update(parentId, { + children: [...parentMessage.children, newMessage.id] + }); + } + } + + await this.updateConversation(message.convId, { + currNode: newMessage.id + }); + + return newMessage; + }); + } + + /** + * Creates a root message for a new conversation. + * Root messages are not displayed but serve as the tree root for branching. + * + * @param convId - Conversation ID + * @returns The created root message + */ + static async createRootMessage(convId: string): Promise<string> { + const rootMessage: DatabaseMessage = { + id: uuid(), + convId, + type: 'root', + timestamp: Date.now(), + role: 'system', + content: '', + parent: null, + thinking: '', + toolCalls: '', + children: [] + }; + + await db.messages.add(rootMessage); + return rootMessage.id; + } + + /** + * Creates a system prompt message for a conversation. + * + * @param convId - Conversation ID + * @param systemPrompt - The system prompt content (must be non-empty) + * @param parentId - Parent message ID (typically the root message) + * @returns The created system message + * @throws Error if systemPrompt is empty + */ + static async createSystemMessage( + convId: string, + systemPrompt: string, + parentId: string + ): Promise<DatabaseMessage> { + const trimmedPrompt = systemPrompt.trim(); + if (!trimmedPrompt) { + throw new Error('Cannot create system message with empty content'); + } + + const systemMessage: DatabaseMessage = { + id: uuid(), + convId, + type: 'system', + timestamp: Date.now(), + role: 'system', + content: trimmedPrompt, + parent: parentId, + thinking: '', + children: [] + }; + + await db.messages.add(systemMessage); + + const parentMessage = await db.messages.get(parentId); + if (parentMessage) { + await db.messages.update(parentId, { + children: [...parentMessage.children, systemMessage.id] + }); + } + + return systemMessage; + } + + /** + * Deletes a conversation and all its messages. + * + * @param id - Conversation ID + */ + static async deleteConversation(id: string): Promise<void> { + await db.transaction('rw', [db.conversations, db.messages], async () => { + await db.conversations.delete(id); + await db.messages.where('convId').equals(id).delete(); + }); + } + + /** + * Deletes a message and removes it from its parent's children array. + * + * @param messageId - ID of the message to delete + */ + static async deleteMessage(messageId: string): Promise<void> { + await db.transaction('rw', db.messages, async () => { + const message = await db.messages.get(messageId); + if (!message) return; + + // Remove this message from its parent's children array + if (message.parent) { + const parent = await db.messages.get(message.parent); + if (parent) { + parent.children = parent.children.filter((childId: string) => childId !== messageId); + await db.messages.put(parent); + } + } + + // Delete the message + await db.messages.delete(messageId); + }); + } + + /** + * Deletes a message and all its descendant messages (cascading deletion). + * This removes the entire branch starting from the specified message. + * + * @param conversationId - ID of the conversation containing the message + * @param messageId - ID of the root message to delete (along with all descendants) + * @returns Array of all deleted message IDs + */ + static async deleteMessageCascading( + conversationId: string, + messageId: string + ): Promise<string[]> { + return await db.transaction('rw', db.messages, async () => { + // Get all messages in the conversation to find descendants + const allMessages = await db.messages.where('convId').equals(conversationId).toArray(); + + // Find all descendant messages + const descendants = findDescendantMessages(allMessages, messageId); + const allToDelete = [messageId, ...descendants]; + + // Get the message to delete for parent cleanup + const message = await db.messages.get(messageId); + if (message && message.parent) { + const parent = await db.messages.get(message.parent); + if (parent) { + parent.children = parent.children.filter((childId: string) => childId !== messageId); + await db.messages.put(parent); + } + } + + // Delete all messages in the branch + await db.messages.bulkDelete(allToDelete); + + return allToDelete; + }); + } + + /** + * Gets all conversations, sorted by last modified time (newest first). + * + * @returns Array of conversations + */ + static async getAllConversations(): Promise<DatabaseConversation[]> { + return await db.conversations.orderBy('lastModified').reverse().toArray(); + } + + /** + * Gets a conversation by ID. + * + * @param id - Conversation ID + * @returns The conversation if found, otherwise undefined + */ + static async getConversation(id: string): Promise<DatabaseConversation | undefined> { + return await db.conversations.get(id); + } + + /** + * Gets all messages in a conversation, sorted by timestamp (oldest first). + * + * @param convId - Conversation ID + * @returns Array of messages in the conversation + */ + static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> { + return await db.messages.where('convId').equals(convId).sortBy('timestamp'); + } + + /** + * Updates a conversation. + * + * @param id - Conversation ID + * @param updates - Partial updates to apply + * @returns Promise that resolves when the conversation is updated + */ + static async updateConversation( + id: string, + updates: Partial<Omit<DatabaseConversation, 'id'>> + ): Promise<void> { + await db.conversations.update(id, { + ...updates, + lastModified: Date.now() + }); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Navigation + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Updates the conversation's current node (active branch). + * This determines which conversation path is currently being viewed. + * + * @param convId - Conversation ID + * @param nodeId - Message ID to set as current node + */ + static async updateCurrentNode(convId: string, nodeId: string): Promise<void> { + await this.updateConversation(convId, { + currNode: nodeId + }); + } + + /** + * Updates a message. + * + * @param id - Message ID + * @param updates - Partial updates to apply + * @returns Promise that resolves when the message is updated + */ + static async updateMessage( + id: string, + updates: Partial<Omit<DatabaseMessage, 'id'>> + ): Promise<void> { + await db.messages.update(id, updates); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Import + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Imports multiple conversations and their messages. + * Skips conversations that already exist. + * + * @param data - Array of { conv, messages } objects + */ + static async importConversations( + data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[] + ): Promise<{ imported: number; skipped: number }> { + let importedCount = 0; + let skippedCount = 0; + + return await db.transaction('rw', [db.conversations, db.messages], async () => { + for (const item of data) { + const { conv, messages } = item; + + const existing = await db.conversations.get(conv.id); + if (existing) { + console.warn(`Conversation "${conv.name}" already exists, skipping...`); + skippedCount++; + continue; + } + + await db.conversations.add(conv); + for (const msg of messages) { + await db.messages.put(msg); + } + + importedCount++; + } + + return { imported: importedCount, skipped: skippedCount }; + }); + } +} diff --git a/llama.cpp/tools/server/webui/src/lib/services/index.ts b/llama.cpp/tools/server/webui/src/lib/services/index.ts new file mode 100644 index 0000000..c36c64a --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/services/index.ts @@ -0,0 +1,5 @@ +export { ChatService } from './chat'; +export { DatabaseService } from './database'; +export { ModelsService } from './models'; +export { PropsService } from './props'; +export { ParameterSyncService } from './parameter-sync'; diff --git a/llama.cpp/tools/server/webui/src/lib/services/models.ts b/llama.cpp/tools/server/webui/src/lib/services/models.ts new file mode 100644 index 0000000..eecb7fa --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/services/models.ts @@ -0,0 +1,124 @@ +import { base } from '$app/paths'; +import { ServerModelStatus } from '$lib/enums'; +import { getJsonHeaders } from '$lib/utils'; + +/** + * ModelsService - Stateless service for model management API communication + * + * This service handles communication with model-related endpoints: + * - `/v1/models` - OpenAI-compatible model list (MODEL + ROUTER mode) + * - `/models/load`, `/models/unload` - Router-specific model management (ROUTER mode only) + * + * **Responsibilities:** + * - List available models + * - Load/unload models (ROUTER mode) + * - Check model status (ROUTER mode) + * + * **Used by:** + * - modelsStore: Primary consumer for model state management + */ +export class ModelsService { + // ───────────────────────────────────────────────────────────────────────────── + // Listing + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Fetch list of models from OpenAI-compatible endpoint + * Works in both MODEL and ROUTER modes + */ + static async list(): Promise<ApiModelListResponse> { + const response = await fetch(`${base}/v1/models`, { + headers: getJsonHeaders() + }); + + if (!response.ok) { + throw new Error(`Failed to fetch model list (status ${response.status})`); + } + + return response.json() as Promise<ApiModelListResponse>; + } + + /** + * Fetch list of all models with detailed metadata (ROUTER mode) + * Returns models with load status, paths, and other metadata + */ + static async listRouter(): Promise<ApiRouterModelsListResponse> { + const response = await fetch(`${base}/v1/models`, { + headers: getJsonHeaders() + }); + + if (!response.ok) { + throw new Error(`Failed to fetch router models list (status ${response.status})`); + } + + return response.json() as Promise<ApiRouterModelsListResponse>; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Load/Unload + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Load a model (ROUTER mode) + * POST /models/load + * @param modelId - Model identifier to load + * @param extraArgs - Optional additional arguments to pass to the model instance + */ + static async load(modelId: string, extraArgs?: string[]): Promise<ApiRouterModelsLoadResponse> { + const payload: { model: string; extra_args?: string[] } = { model: modelId }; + if (extraArgs && extraArgs.length > 0) { + payload.extra_args = extraArgs; + } + + const response = await fetch(`${base}/models/load`, { + method: 'POST', + headers: getJsonHeaders(), + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `Failed to load model (status ${response.status})`); + } + + return response.json() as Promise<ApiRouterModelsLoadResponse>; + } + + /** + * Unload a model (ROUTER mode) + * POST /models/unload + * @param modelId - Model identifier to unload + */ + static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> { + const response = await fetch(`${base}/models/unload`, { + method: 'POST', + headers: getJsonHeaders(), + body: JSON.stringify({ model: modelId }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `Failed to unload model (status ${response.status})`); + } + + return response.json() as Promise<ApiRouterModelsUnloadResponse>; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Status + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Check if a model is loaded based on its metadata + */ + static isModelLoaded(model: ApiModelDataEntry): boolean { + return model.status.value === ServerModelStatus.LOADED; + } + + /** + * Check if a model is currently loading + */ + static isModelLoading(model: ApiModelDataEntry): boolean { + return model.status.value === ServerModelStatus.LOADING; + } +} diff --git a/llama.cpp/tools/server/webui/src/lib/services/parameter-sync.spec.ts b/llama.cpp/tools/server/webui/src/lib/services/parameter-sync.spec.ts new file mode 100644 index 0000000..6b5c58a --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/services/parameter-sync.spec.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import { ParameterSyncService } from './parameter-sync'; + +describe('ParameterSyncService', () => { + describe('roundFloatingPoint', () => { + it('should fix JavaScript floating-point precision issues', () => { + // Test the specific values from the screenshot + const mockServerParams = { + top_p: 0.949999988079071, + min_p: 0.009999999776482582, + temperature: 0.800000011920929, + top_k: 40, + samplers: ['top_k', 'typ_p', 'top_p', 'min_p', 'temperature'] + }; + + const result = ParameterSyncService.extractServerDefaults({ + ...mockServerParams, + // Add other required fields to match the API type + n_predict: 512, + seed: -1, + dynatemp_range: 0.0, + dynatemp_exponent: 1.0, + xtc_probability: 0.0, + xtc_threshold: 0.1, + typ_p: 1.0, + repeat_last_n: 64, + repeat_penalty: 1.0, + presence_penalty: 0.0, + frequency_penalty: 0.0, + dry_multiplier: 0.0, + dry_base: 1.75, + dry_allowed_length: 2, + dry_penalty_last_n: -1, + mirostat: 0, + mirostat_tau: 5.0, + mirostat_eta: 0.1, + stop: [], + max_tokens: -1, + n_keep: 0, + n_discard: 0, + ignore_eos: false, + stream: true, + logit_bias: [], + n_probs: 0, + min_keep: 0, + grammar: '', + grammar_lazy: false, + grammar_triggers: [], + preserved_tokens: [], + chat_format: '', + reasoning_format: '', + reasoning_in_content: false, + thinking_forced_open: false, + 'speculative.n_max': 0, + 'speculative.n_min': 0, + 'speculative.p_min': 0.0, + timings_per_token: false, + post_sampling_probs: false, + lora: [], + top_n_sigma: 0.0, + dry_sequence_breakers: [] + } as ApiLlamaCppServerProps['default_generation_settings']['params']); + + // Check that the problematic floating-point values are rounded correctly + expect(result.top_p).toBe(0.95); + expect(result.min_p).toBe(0.01); + expect(result.temperature).toBe(0.8); + expect(result.top_k).toBe(40); // Integer should remain unchanged + expect(result.samplers).toBe('top_k;typ_p;top_p;min_p;temperature'); + }); + + it('should preserve non-numeric values', () => { + const mockServerParams = { + samplers: ['top_k', 'temperature'], + max_tokens: -1, + temperature: 0.7 + }; + + const result = ParameterSyncService.extractServerDefaults({ + ...mockServerParams, + // Minimal required fields + n_predict: 512, + seed: -1, + dynatemp_range: 0.0, + dynatemp_exponent: 1.0, + top_k: 40, + top_p: 0.95, + min_p: 0.05, + xtc_probability: 0.0, + xtc_threshold: 0.1, + typ_p: 1.0, + repeat_last_n: 64, + repeat_penalty: 1.0, + presence_penalty: 0.0, + frequency_penalty: 0.0, + dry_multiplier: 0.0, + dry_base: 1.75, + dry_allowed_length: 2, + dry_penalty_last_n: -1, + mirostat: 0, + mirostat_tau: 5.0, + mirostat_eta: 0.1, + stop: [], + n_keep: 0, + n_discard: 0, + ignore_eos: false, + stream: true, + logit_bias: [], + n_probs: 0, + min_keep: 0, + grammar: '', + grammar_lazy: false, + grammar_triggers: [], + preserved_tokens: [], + chat_format: '', + reasoning_format: '', + reasoning_in_content: false, + thinking_forced_open: false, + 'speculative.n_max': 0, + 'speculative.n_min': 0, + 'speculative.p_min': 0.0, + timings_per_token: false, + post_sampling_probs: false, + lora: [], + top_n_sigma: 0.0, + dry_sequence_breakers: [] + } as ApiLlamaCppServerProps['default_generation_settings']['params']); + + expect(result.samplers).toBe('top_k;temperature'); + expect(result.max_tokens).toBe(-1); + expect(result.temperature).toBe(0.7); + }); + + it('should merge webui settings from props when provided', () => { + const result = ParameterSyncService.extractServerDefaults(null, { + pasteLongTextToFileLen: 0, + pdfAsImage: true, + renderUserContentAsMarkdown: false, + theme: 'dark' + }); + + expect(result.pasteLongTextToFileLen).toBe(0); + expect(result.pdfAsImage).toBe(true); + expect(result.renderUserContentAsMarkdown).toBe(false); + expect(result.theme).toBeUndefined(); + }); + }); +}); diff --git a/llama.cpp/tools/server/webui/src/lib/services/parameter-sync.ts b/llama.cpp/tools/server/webui/src/lib/services/parameter-sync.ts new file mode 100644 index 0000000..d124cf5 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/services/parameter-sync.ts @@ -0,0 +1,279 @@ +/** + * ParameterSyncService - Handles synchronization between server defaults and user settings + * + * This service manages the complex logic of merging server-provided default parameters + * with user-configured overrides, ensuring the UI reflects the actual server state + * while preserving user customizations. + * + * **Key Responsibilities:** + * - Extract syncable parameters from server props + * - Merge server defaults with user overrides + * - Track parameter sources (server, user, default) + * - Provide sync utilities for settings store integration + */ + +import { normalizeFloatingPoint } from '$lib/utils'; + +export type ParameterSource = 'default' | 'custom'; +export type ParameterValue = string | number | boolean; +export type ParameterRecord = Record<string, ParameterValue>; + +export interface ParameterInfo { + value: string | number | boolean; + source: ParameterSource; + serverDefault?: string | number | boolean; + userOverride?: string | number | boolean; +} + +export interface SyncableParameter { + key: string; + serverKey: string; + type: 'number' | 'string' | 'boolean'; + canSync: boolean; +} + +/** + * Mapping of webui setting keys to server parameter keys + * Only parameters that should be synced from server are included + */ +export const SYNCABLE_PARAMETERS: SyncableParameter[] = [ + { key: 'temperature', serverKey: 'temperature', type: 'number', canSync: true }, + { key: 'top_k', serverKey: 'top_k', type: 'number', canSync: true }, + { key: 'top_p', serverKey: 'top_p', type: 'number', canSync: true }, + { key: 'min_p', serverKey: 'min_p', type: 'number', canSync: true }, + { key: 'dynatemp_range', serverKey: 'dynatemp_range', type: 'number', canSync: true }, + { key: 'dynatemp_exponent', serverKey: 'dynatemp_exponent', type: 'number', canSync: true }, + { key: 'xtc_probability', serverKey: 'xtc_probability', type: 'number', canSync: true }, + { key: 'xtc_threshold', serverKey: 'xtc_threshold', type: 'number', canSync: true }, + { key: 'typ_p', serverKey: 'typ_p', type: 'number', canSync: true }, + { key: 'repeat_last_n', serverKey: 'repeat_last_n', type: 'number', canSync: true }, + { key: 'repeat_penalty', serverKey: 'repeat_penalty', type: 'number', canSync: true }, + { key: 'presence_penalty', serverKey: 'presence_penalty', type: 'number', canSync: true }, + { key: 'frequency_penalty', serverKey: 'frequency_penalty', type: 'number', canSync: true }, + { key: 'dry_multiplier', serverKey: 'dry_multiplier', type: 'number', canSync: true }, + { key: 'dry_base', serverKey: 'dry_base', type: 'number', canSync: true }, + { key: 'dry_allowed_length', serverKey: 'dry_allowed_length', type: 'number', canSync: true }, + { key: 'dry_penalty_last_n', serverKey: 'dry_penalty_last_n', type: 'number', canSync: true }, + { key: 'max_tokens', serverKey: 'max_tokens', type: 'number', canSync: true }, + { key: 'samplers', serverKey: 'samplers', type: 'string', canSync: true }, + { + key: 'pasteLongTextToFileLen', + serverKey: 'pasteLongTextToFileLen', + type: 'number', + canSync: true + }, + { key: 'pdfAsImage', serverKey: 'pdfAsImage', type: 'boolean', canSync: true }, + { + key: 'showThoughtInProgress', + serverKey: 'showThoughtInProgress', + type: 'boolean', + canSync: true + }, + { key: 'showToolCalls', serverKey: 'showToolCalls', type: 'boolean', canSync: true }, + { + key: 'disableReasoningFormat', + serverKey: 'disableReasoningFormat', + type: 'boolean', + canSync: true + }, + { key: 'keepStatsVisible', serverKey: 'keepStatsVisible', type: 'boolean', canSync: true }, + { key: 'showMessageStats', serverKey: 'showMessageStats', type: 'boolean', canSync: true }, + { + key: 'askForTitleConfirmation', + serverKey: 'askForTitleConfirmation', + type: 'boolean', + canSync: true + }, + { key: 'disableAutoScroll', serverKey: 'disableAutoScroll', type: 'boolean', canSync: true }, + { + key: 'renderUserContentAsMarkdown', + serverKey: 'renderUserContentAsMarkdown', + type: 'boolean', + canSync: true + }, + { key: 'autoMicOnEmpty', serverKey: 'autoMicOnEmpty', type: 'boolean', canSync: true }, + { + key: 'pyInterpreterEnabled', + serverKey: 'pyInterpreterEnabled', + type: 'boolean', + canSync: true + }, + { + key: 'enableContinueGeneration', + serverKey: 'enableContinueGeneration', + type: 'boolean', + canSync: true + } +]; + +export class ParameterSyncService { + // ───────────────────────────────────────────────────────────────────────────── + // Extraction + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Round floating-point numbers to avoid JavaScript precision issues + */ + private static roundFloatingPoint(value: ParameterValue): ParameterValue { + return normalizeFloatingPoint(value) as ParameterValue; + } + + /** + * Extract server default parameters that can be synced + */ + static extractServerDefaults( + serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null, + webuiSettings?: Record<string, string | number | boolean> + ): ParameterRecord { + const extracted: ParameterRecord = {}; + + if (serverParams) { + for (const param of SYNCABLE_PARAMETERS) { + if (param.canSync && param.serverKey in serverParams) { + const value = (serverParams as unknown as Record<string, ParameterValue>)[ + param.serverKey + ]; + if (value !== undefined) { + // Apply precision rounding to avoid JavaScript floating-point issues + extracted[param.key] = this.roundFloatingPoint(value); + } + } + } + + // Handle samplers array conversion to string + if (serverParams.samplers && Array.isArray(serverParams.samplers)) { + extracted.samplers = serverParams.samplers.join(';'); + } + } + + if (webuiSettings) { + for (const param of SYNCABLE_PARAMETERS) { + if (param.canSync && param.serverKey in webuiSettings) { + const value = webuiSettings[param.serverKey]; + if (value !== undefined) { + extracted[param.key] = this.roundFloatingPoint(value); + } + } + } + } + + return extracted; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Merging + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Merge server defaults with current user settings + * Returns updated settings that respect user overrides while using server defaults + */ + static mergeWithServerDefaults( + currentSettings: ParameterRecord, + serverDefaults: ParameterRecord, + userOverrides: Set<string> = new Set() + ): ParameterRecord { + const merged = { ...currentSettings }; + + for (const [key, serverValue] of Object.entries(serverDefaults)) { + // Only update if user hasn't explicitly overridden this parameter + if (!userOverrides.has(key)) { + merged[key] = this.roundFloatingPoint(serverValue); + } + } + + return merged; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Info + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Get parameter information including source and values + */ + static getParameterInfo( + key: string, + currentValue: ParameterValue, + propsDefaults: ParameterRecord, + userOverrides: Set<string> + ): ParameterInfo { + const hasPropsDefault = propsDefaults[key] !== undefined; + const isUserOverride = userOverrides.has(key); + + // Simple logic: either using default (from props) or custom (user override) + const source: ParameterSource = isUserOverride ? 'custom' : 'default'; + + return { + value: currentValue, + source, + serverDefault: hasPropsDefault ? propsDefaults[key] : undefined, // Keep same field name for compatibility + userOverride: isUserOverride ? currentValue : undefined + }; + } + + /** + * Check if a parameter can be synced from server + */ + static canSyncParameter(key: string): boolean { + return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync); + } + + /** + * Get all syncable parameter keys + */ + static getSyncableParameterKeys(): string[] { + return SYNCABLE_PARAMETERS.filter((param) => param.canSync).map((param) => param.key); + } + + /** + * Validate server parameter value + */ + static validateServerParameter(key: string, value: ParameterValue): boolean { + const param = SYNCABLE_PARAMETERS.find((p) => p.key === key); + if (!param) return false; + + switch (param.type) { + case 'number': + return typeof value === 'number' && !isNaN(value); + case 'string': + return typeof value === 'string'; + case 'boolean': + return typeof value === 'boolean'; + default: + return false; + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Diff + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Create a diff between current settings and server defaults + */ + static createParameterDiff( + currentSettings: ParameterRecord, + serverDefaults: ParameterRecord + ): Record<string, { current: ParameterValue; server: ParameterValue; differs: boolean }> { + const diff: Record< + string, + { current: ParameterValue; server: ParameterValue; differs: boolean } + > = {}; + + for (const key of this.getSyncableParameterKeys()) { + const currentValue = currentSettings[key]; + const serverValue = serverDefaults[key]; + + if (serverValue !== undefined) { + diff[key] = { + current: currentValue, + server: serverValue, + differs: currentValue !== serverValue + }; + } + } + + return diff; + } +} diff --git a/llama.cpp/tools/server/webui/src/lib/services/props.ts b/llama.cpp/tools/server/webui/src/lib/services/props.ts new file mode 100644 index 0000000..01fead9 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/services/props.ts @@ -0,0 +1,77 @@ +import { getAuthHeaders } from '$lib/utils'; + +/** + * PropsService - Server properties management + * + * This service handles communication with the /props endpoint to retrieve + * server configuration, model information, and capabilities. + * + * **Responsibilities:** + * - Fetch server properties from /props endpoint + * - Handle API authentication + * - Parse and validate server response + * + * **Used by:** + * - serverStore: Primary consumer for server state management + */ +export class PropsService { + // ───────────────────────────────────────────────────────────────────────────── + // Fetching + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Fetches server properties from the /props endpoint + * + * @param autoload - If false, prevents automatic model loading (default: false) + * @returns {Promise<ApiLlamaCppServerProps>} Server properties + * @throws {Error} If the request fails or returns invalid data + */ + static async fetch(autoload = false): Promise<ApiLlamaCppServerProps> { + const url = new URL('./props', window.location.href); + if (!autoload) { + url.searchParams.set('autoload', 'false'); + } + + const response = await fetch(url.toString(), { + headers: getAuthHeaders() + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch server properties: ${response.status} ${response.statusText}` + ); + } + + const data = await response.json(); + return data as ApiLlamaCppServerProps; + } + + /** + * Fetches server properties for a specific model (ROUTER mode) + * + * @param modelId - The model ID to fetch properties for + * @param autoload - If false, prevents automatic model loading (default: false) + * @returns {Promise<ApiLlamaCppServerProps>} Server properties for the model + * @throws {Error} If the request fails or returns invalid data + */ + static async fetchForModel(modelId: string, autoload = false): Promise<ApiLlamaCppServerProps> { + const url = new URL('./props', window.location.href); + url.searchParams.set('model', modelId); + if (!autoload) { + url.searchParams.set('autoload', 'false'); + } + + const response = await fetch(url.toString(), { + headers: getAuthHeaders() + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch model properties: ${response.status} ${response.statusText}` + ); + } + + const data = await response.json(); + return data as ApiLlamaCppServerProps; + } +} diff --git a/llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts new file mode 100644 index 0000000..879b2f3 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts @@ -0,0 +1,1487 @@ +import { DatabaseService, ChatService } from '$lib/services'; +import { conversationsStore } from '$lib/stores/conversations.svelte'; +import { config } from '$lib/stores/settings.svelte'; +import { contextSize, isRouterMode } from '$lib/stores/server.svelte'; +import { + selectedModelName, + modelsStore, + selectedModelContextSize +} from '$lib/stores/models.svelte'; +import { + normalizeModelName, + filterByLeafNodeId, + findDescendantMessages, + findLeafNode +} from '$lib/utils'; +import { SvelteMap } from 'svelte/reactivity'; +import { DEFAULT_CONTEXT } from '$lib/constants/default-context'; + +/** + * chatStore - Active AI interaction and streaming state management + * + * **Terminology - Chat vs Conversation:** + * - **Chat**: The active interaction space with the Chat Completions API. Represents the + * real-time streaming session, loading states, and UI visualization of AI communication. + * A "chat" is ephemeral - it exists only while the user is actively interacting with the AI. + * - **Conversation**: The persistent database entity storing all messages and metadata. + * Managed by conversationsStore, conversations persist across sessions and page reloads. + * + * This store manages all active AI interactions including real-time streaming, response + * generation, and per-chat loading states. It handles the runtime layer between UI and + * AI backend, supporting concurrent streaming across multiple conversations. + * + * **Architecture & Relationships:** + * - **chatStore** (this class): Active AI session and streaming management + * - Manages real-time AI response streaming via ChatService + * - Tracks per-chat loading and streaming states for concurrent sessions + * - Handles message operations (send, edit, regenerate, branch) + * - Coordinates with conversationsStore for persistence + * + * - **conversationsStore**: Provides conversation data and message arrays for chat context + * - **ChatService**: Low-level API communication with llama.cpp server + * - **DatabaseService**: Message persistence and retrieval + * + * **Key Features:** + * - **AI Streaming**: Real-time token streaming with abort support + * - **Concurrent Chats**: Independent loading/streaming states per conversation + * - **Message Branching**: Edit, regenerate, and branch conversation trees + * - **Error Handling**: Timeout and server error recovery with user feedback + * - **Graceful Stop**: Save partial responses when stopping generation + * + * **State Management:** + * - Global `isLoading` and `currentResponse` for active chat UI + * - `chatLoadingStates` Map for per-conversation streaming tracking + * - `chatStreamingStates` Map for per-conversation streaming content + * - `processingStates` Map for per-conversation processing state (timing/context info) + * - Automatic state sync when switching between conversations + */ +class ChatStore { + // ───────────────────────────────────────────────────────────────────────────── + // State + // ───────────────────────────────────────────────────────────────────────────── + + activeProcessingState = $state<ApiProcessingState | null>(null); + currentResponse = $state(''); + errorDialogState = $state<{ + type: 'timeout' | 'server'; + message: string; + contextInfo?: { n_prompt_tokens: number; n_ctx: number }; + } | null>(null); + isLoading = $state(false); + chatLoadingStates = new SvelteMap<string, boolean>(); + chatStreamingStates = new SvelteMap<string, { response: string; messageId: string }>(); + private abortControllers = new SvelteMap<string, AbortController>(); + private processingStates = new SvelteMap<string, ApiProcessingState | null>(); + private activeConversationId = $state<string | null>(null); + private isStreamingActive = $state(false); + private isEditModeActive = $state(false); + private addFilesHandler: ((files: File[]) => void) | null = $state(null); + + // ───────────────────────────────────────────────────────────────────────────── + // Loading State + // ───────────────────────────────────────────────────────────────────────────── + + private setChatLoading(convId: string, loading: boolean): void { + if (loading) { + this.chatLoadingStates.set(convId, true); + if (conversationsStore.activeConversation?.id === convId) this.isLoading = true; + } else { + this.chatLoadingStates.delete(convId); + if (conversationsStore.activeConversation?.id === convId) this.isLoading = false; + } + } + + private isChatLoading(convId: string): boolean { + return this.chatLoadingStates.get(convId) || false; + } + + private setChatStreaming(convId: string, response: string, messageId: string): void { + this.chatStreamingStates.set(convId, { response, messageId }); + if (conversationsStore.activeConversation?.id === convId) this.currentResponse = response; + } + + private clearChatStreaming(convId: string): void { + this.chatStreamingStates.delete(convId); + if (conversationsStore.activeConversation?.id === convId) this.currentResponse = ''; + } + + private getChatStreaming(convId: string): { response: string; messageId: string } | undefined { + return this.chatStreamingStates.get(convId); + } + + syncLoadingStateForChat(convId: string): void { + this.isLoading = this.isChatLoading(convId); + const streamingState = this.getChatStreaming(convId); + this.currentResponse = streamingState?.response || ''; + } + + /** + * Clears global UI state without affecting background streaming. + * Used when navigating to empty/new chat while other chats stream in background. + */ + clearUIState(): void { + this.isLoading = false; + this.currentResponse = ''; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Processing State + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Set the active conversation for statistics display + */ + setActiveProcessingConversation(conversationId: string | null): void { + this.activeConversationId = conversationId; + + if (conversationId) { + this.activeProcessingState = this.processingStates.get(conversationId) || null; + } else { + this.activeProcessingState = null; + } + } + + /** + * Get processing state for a specific conversation + */ + getProcessingState(conversationId: string): ApiProcessingState | null { + return this.processingStates.get(conversationId) || null; + } + + /** + * Clear processing state for a specific conversation + */ + clearProcessingState(conversationId: string): void { + this.processingStates.delete(conversationId); + + if (conversationId === this.activeConversationId) { + this.activeProcessingState = null; + } + } + + /** + * Get the current processing state for the active conversation (reactive) + * Returns the direct reactive state for UI binding + */ + getActiveProcessingState(): ApiProcessingState | null { + return this.activeProcessingState; + } + + /** + * Updates processing state with timing data from streaming response + */ + updateProcessingStateFromTimings( + timingData: { + prompt_n: number; + prompt_ms?: number; + predicted_n: number; + predicted_per_second: number; + cache_n: number; + prompt_progress?: ChatMessagePromptProgress; + }, + conversationId?: string + ): void { + const processingState = this.parseTimingData(timingData); + + if (processingState === null) { + console.warn('Failed to parse timing data - skipping update'); + return; + } + + const targetId = conversationId || this.activeConversationId; + if (targetId) { + this.processingStates.set(targetId, processingState); + + if (targetId === this.activeConversationId) { + this.activeProcessingState = processingState; + } + } + } + + /** + * Get current processing state (sync version for reactive access) + */ + getCurrentProcessingStateSync(): ApiProcessingState | null { + return this.activeProcessingState; + } + + /** + * Restore processing state from last assistant message timings + * Call this when keepStatsVisible is enabled and we need to show last known stats + */ + restoreProcessingStateFromMessages(messages: DatabaseMessage[], conversationId: string): void { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role === 'assistant' && message.timings) { + const restoredState = this.parseTimingData({ + prompt_n: message.timings.prompt_n || 0, + prompt_ms: message.timings.prompt_ms, + predicted_n: message.timings.predicted_n || 0, + predicted_per_second: + message.timings.predicted_n && message.timings.predicted_ms + ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000 + : 0, + cache_n: message.timings.cache_n || 0 + }); + + if (restoredState) { + this.processingStates.set(conversationId, restoredState); + + if (conversationId === this.activeConversationId) { + this.activeProcessingState = restoredState; + } + + return; + } + } + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Streaming + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Start streaming session tracking + */ + startStreaming(): void { + this.isStreamingActive = true; + } + + /** + * Stop streaming session tracking + */ + stopStreaming(): void { + this.isStreamingActive = false; + } + + /** + * Check if currently in a streaming session + */ + isStreaming(): boolean { + return this.isStreamingActive; + } + + private getContextTotal(): number { + const activeState = this.getActiveProcessingState(); + + if (activeState && activeState.contextTotal > 0) { + return activeState.contextTotal; + } + + if (isRouterMode()) { + const modelContextSize = selectedModelContextSize(); + if (modelContextSize && modelContextSize > 0) { + return modelContextSize; + } + } + + const propsContextSize = contextSize(); + if (propsContextSize && propsContextSize > 0) { + return propsContextSize; + } + + return DEFAULT_CONTEXT; + } + + private parseTimingData(timingData: Record<string, unknown>): ApiProcessingState | null { + const promptTokens = (timingData.prompt_n as number) || 0; + const promptMs = (timingData.prompt_ms as number) || undefined; + const predictedTokens = (timingData.predicted_n as number) || 0; + const tokensPerSecond = (timingData.predicted_per_second as number) || 0; + const cacheTokens = (timingData.cache_n as number) || 0; + const promptProgress = timingData.prompt_progress as + | { + total: number; + cache: number; + processed: number; + time_ms: number; + } + | undefined; + + const contextTotal = this.getContextTotal(); + const currentConfig = config(); + const outputTokensMax = currentConfig.max_tokens || -1; + + // Note: for timings data, the n_prompt does NOT include cache tokens + const contextUsed = promptTokens + cacheTokens + predictedTokens; + const outputTokensUsed = predictedTokens; + + // Note: for prompt progress, the "processed" DOES include cache tokens + // we need to exclude them to get the real prompt tokens processed count + const progressCache = promptProgress?.cache || 0; + const progressActualDone = (promptProgress?.processed ?? 0) - progressCache; + const progressActualTotal = (promptProgress?.total ?? 0) - progressCache; + const progressPercent = promptProgress + ? Math.round((progressActualDone / progressActualTotal) * 100) + : undefined; + + return { + status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle', + tokensDecoded: predictedTokens, + tokensRemaining: outputTokensMax - predictedTokens, + contextUsed, + contextTotal, + outputTokensUsed, + outputTokensMax, + hasNextToken: predictedTokens > 0, + tokensPerSecond, + temperature: currentConfig.temperature ?? 0.8, + topP: currentConfig.top_p ?? 0.95, + speculative: false, + progressPercent, + promptProgress, + promptTokens, + promptMs, + cacheTokens + }; + } + + /** + * Gets the model used in a conversation based on the latest assistant message. + * Returns the model from the most recent assistant message that has a model field set. + * + * @param messages - Array of messages to search through + * @returns The model name or null if no model found + */ + getConversationModel(messages: DatabaseMessage[]): string | null { + // Search backwards through messages to find most recent assistant message with model + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role === 'assistant' && message.model) { + return message.model; + } + } + return null; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Error Handling + // ───────────────────────────────────────────────────────────────────────────── + + private isAbortError(error: unknown): boolean { + return error instanceof Error && (error.name === 'AbortError' || error instanceof DOMException); + } + + private showErrorDialog( + type: 'timeout' | 'server', + message: string, + contextInfo?: { n_prompt_tokens: number; n_ctx: number } + ): void { + this.errorDialogState = { type, message, contextInfo }; + } + + dismissErrorDialog(): void { + this.errorDialogState = null; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Message Operations + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Finds a message by ID and optionally validates its role. + * Returns message and index, or null if not found or role doesn't match. + */ + private getMessageByIdWithRole( + messageId: string, + expectedRole?: ChatRole + ): { message: DatabaseMessage; index: number } | null { + const index = conversationsStore.findMessageIndex(messageId); + if (index === -1) return null; + + const message = conversationsStore.activeMessages[index]; + if (expectedRole && message.role !== expectedRole) return null; + + return { message, index }; + } + + async addMessage( + role: ChatRole, + content: string, + type: ChatMessageType = 'text', + parent: string = '-1', + extras?: DatabaseMessageExtra[] + ): Promise<DatabaseMessage | null> { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) { + console.error('No active conversation when trying to add message'); + return null; + } + + try { + let parentId: string | null = null; + + if (parent === '-1') { + const activeMessages = conversationsStore.activeMessages; + if (activeMessages.length > 0) { + parentId = activeMessages[activeMessages.length - 1].id; + } else { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const rootMessage = allMessages.find((m) => m.parent === null && m.type === 'root'); + if (!rootMessage) { + parentId = await DatabaseService.createRootMessage(activeConv.id); + } else { + parentId = rootMessage.id; + } + } + } else { + parentId = parent; + } + + const message = await DatabaseService.createMessageBranch( + { + convId: activeConv.id, + role, + content, + type, + timestamp: Date.now(), + thinking: '', + toolCalls: '', + children: [], + extra: extras + }, + parentId + ); + + conversationsStore.addMessageToActive(message); + await conversationsStore.updateCurrentNode(message.id); + conversationsStore.updateConversationTimestamp(); + + return message; + } catch (error) { + console.error('Failed to add message:', error); + return null; + } + } + + private async createAssistantMessage(parentId?: string): Promise<DatabaseMessage | null> { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return null; + + return await DatabaseService.createMessageBranch( + { + convId: activeConv.id, + type: 'text', + role: 'assistant', + content: '', + timestamp: Date.now(), + thinking: '', + toolCalls: '', + children: [], + model: null + }, + parentId || null + ); + } + + private async streamChatCompletion( + allMessages: DatabaseMessage[], + assistantMessage: DatabaseMessage, + onComplete?: (content: string) => Promise<void>, + onError?: (error: Error) => void, + modelOverride?: string | null + ): Promise<void> { + // Ensure model props are cached before streaming (for correct n_ctx in processing info) + if (isRouterMode()) { + const modelName = modelOverride || selectedModelName(); + if (modelName && !modelsStore.getModelProps(modelName)) { + await modelsStore.fetchModelProps(modelName); + } + } + + let streamedContent = ''; + let streamedReasoningContent = ''; + let streamedToolCallContent = ''; + let resolvedModel: string | null = null; + let modelPersisted = false; + + const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => { + if (!modelName) return; + const normalizedModel = normalizeModelName(modelName); + if (!normalizedModel || normalizedModel === resolvedModel) return; + resolvedModel = normalizedModel; + const messageIndex = conversationsStore.findMessageIndex(assistantMessage.id); + conversationsStore.updateMessageAtIndex(messageIndex, { model: normalizedModel }); + if (persistImmediately && !modelPersisted) { + modelPersisted = true; + DatabaseService.updateMessage(assistantMessage.id, { model: normalizedModel }).catch(() => { + modelPersisted = false; + resolvedModel = null; + }); + } + }; + + this.startStreaming(); + this.setActiveProcessingConversation(assistantMessage.convId); + + const abortController = this.getOrCreateAbortController(assistantMessage.convId); + + await ChatService.sendMessage( + allMessages, + { + ...this.getApiOptions(), + ...(modelOverride ? { model: modelOverride } : {}), + onChunk: (chunk: string) => { + streamedContent += chunk; + this.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id); + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + conversationsStore.updateMessageAtIndex(idx, { content: streamedContent }); + }, + onReasoningChunk: (reasoningChunk: string) => { + streamedReasoningContent += reasoningChunk; + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + conversationsStore.updateMessageAtIndex(idx, { thinking: streamedReasoningContent }); + }, + onToolCallChunk: (toolCallChunk: string) => { + const chunk = toolCallChunk.trim(); + if (!chunk) return; + streamedToolCallContent = chunk; + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + conversationsStore.updateMessageAtIndex(idx, { toolCalls: streamedToolCallContent }); + }, + onModel: (modelName: string) => recordModel(modelName), + onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => { + const tokensPerSecond = + timings?.predicted_ms && timings?.predicted_n + ? (timings.predicted_n / timings.predicted_ms) * 1000 + : 0; + this.updateProcessingStateFromTimings( + { + prompt_n: timings?.prompt_n || 0, + prompt_ms: timings?.prompt_ms, + predicted_n: timings?.predicted_n || 0, + predicted_per_second: tokensPerSecond, + cache_n: timings?.cache_n || 0, + prompt_progress: promptProgress + }, + assistantMessage.convId + ); + }, + onComplete: async ( + finalContent?: string, + reasoningContent?: string, + timings?: ChatMessageTimings, + toolCallContent?: string + ) => { + this.stopStreaming(); + + const updateData: Record<string, unknown> = { + content: finalContent || streamedContent, + thinking: reasoningContent || streamedReasoningContent, + toolCalls: toolCallContent || streamedToolCallContent, + timings + }; + if (resolvedModel && !modelPersisted) { + updateData.model = resolvedModel; + } + await DatabaseService.updateMessage(assistantMessage.id, updateData); + + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + const uiUpdate: Partial<DatabaseMessage> = { + content: updateData.content as string, + toolCalls: updateData.toolCalls as string + }; + if (timings) uiUpdate.timings = timings; + if (resolvedModel) uiUpdate.model = resolvedModel; + + conversationsStore.updateMessageAtIndex(idx, uiUpdate); + await conversationsStore.updateCurrentNode(assistantMessage.id); + + if (onComplete) await onComplete(streamedContent); + this.setChatLoading(assistantMessage.convId, false); + this.clearChatStreaming(assistantMessage.convId); + this.clearProcessingState(assistantMessage.convId); + + if (isRouterMode()) { + modelsStore.fetchRouterModels().catch(console.error); + } + }, + onError: (error: Error) => { + this.stopStreaming(); + + if (this.isAbortError(error)) { + this.setChatLoading(assistantMessage.convId, false); + this.clearChatStreaming(assistantMessage.convId); + this.clearProcessingState(assistantMessage.convId); + + return; + } + + console.error('Streaming error:', error); + + this.setChatLoading(assistantMessage.convId, false); + this.clearChatStreaming(assistantMessage.convId); + this.clearProcessingState(assistantMessage.convId); + + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + + if (idx !== -1) { + const failedMessage = conversationsStore.removeMessageAtIndex(idx); + if (failedMessage) DatabaseService.deleteMessage(failedMessage.id).catch(console.error); + } + + const contextInfo = ( + error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } } + ).contextInfo; + + this.showErrorDialog( + error.name === 'TimeoutError' ? 'timeout' : 'server', + error.message, + contextInfo + ); + + if (onError) onError(error); + } + }, + assistantMessage.convId, + abortController.signal + ); + } + + async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise<void> { + if (!content.trim() && (!extras || extras.length === 0)) return; + const activeConv = conversationsStore.activeConversation; + if (activeConv && this.isChatLoading(activeConv.id)) return; + + let isNewConversation = false; + if (!activeConv) { + await conversationsStore.createConversation(); + isNewConversation = true; + } + const currentConv = conversationsStore.activeConversation; + if (!currentConv) return; + + this.errorDialogState = null; + this.setChatLoading(currentConv.id, true); + this.clearChatStreaming(currentConv.id); + + try { + if (isNewConversation) { + const rootId = await DatabaseService.createRootMessage(currentConv.id); + const currentConfig = config(); + const systemPrompt = currentConfig.systemMessage?.toString().trim(); + + if (systemPrompt) { + const systemMessage = await DatabaseService.createSystemMessage( + currentConv.id, + systemPrompt, + rootId + ); + + conversationsStore.addMessageToActive(systemMessage); + } + } + + const userMessage = await this.addMessage('user', content, 'text', '-1', extras); + if (!userMessage) throw new Error('Failed to add user message'); + if (isNewConversation && content) + await conversationsStore.updateConversationName(currentConv.id, content.trim()); + + const assistantMessage = await this.createAssistantMessage(userMessage.id); + + if (!assistantMessage) throw new Error('Failed to create assistant message'); + + conversationsStore.addMessageToActive(assistantMessage); + await this.streamChatCompletion( + conversationsStore.activeMessages.slice(0, -1), + assistantMessage + ); + } catch (error) { + if (this.isAbortError(error)) { + this.setChatLoading(currentConv.id, false); + return; + } + console.error('Failed to send message:', error); + this.setChatLoading(currentConv.id, false); + if (!this.errorDialogState) { + const dialogType = + error instanceof Error && error.name === 'TimeoutError' ? 'timeout' : 'server'; + const contextInfo = ( + error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } } + ).contextInfo; + + this.showErrorDialog( + dialogType, + error instanceof Error ? error.message : 'Unknown error', + contextInfo + ); + } + } + } + + async stopGeneration(): Promise<void> { + const activeConv = conversationsStore.activeConversation; + + if (!activeConv) return; + + await this.stopGenerationForChat(activeConv.id); + } + + async stopGenerationForChat(convId: string): Promise<void> { + await this.savePartialResponseIfNeeded(convId); + + this.stopStreaming(); + this.abortRequest(convId); + this.setChatLoading(convId, false); + this.clearChatStreaming(convId); + this.clearProcessingState(convId); + } + + /** + * Gets or creates an AbortController for a conversation + */ + private getOrCreateAbortController(convId: string): AbortController { + let controller = this.abortControllers.get(convId); + if (!controller || controller.signal.aborted) { + controller = new AbortController(); + this.abortControllers.set(convId, controller); + } + return controller; + } + + /** + * Aborts any ongoing request for a conversation + */ + private abortRequest(convId?: string): void { + if (convId) { + const controller = this.abortControllers.get(convId); + if (controller) { + controller.abort(); + this.abortControllers.delete(convId); + } + } else { + for (const controller of this.abortControllers.values()) { + controller.abort(); + } + this.abortControllers.clear(); + } + } + + private async savePartialResponseIfNeeded(convId?: string): Promise<void> { + const conversationId = convId || conversationsStore.activeConversation?.id; + + if (!conversationId) return; + + const streamingState = this.chatStreamingStates.get(conversationId); + + if (!streamingState || !streamingState.response.trim()) return; + + const messages = + conversationId === conversationsStore.activeConversation?.id + ? conversationsStore.activeMessages + : await conversationsStore.getConversationMessages(conversationId); + + if (!messages.length) return; + + const lastMessage = messages[messages.length - 1]; + + if (lastMessage?.role === 'assistant') { + try { + const updateData: { content: string; thinking?: string; timings?: ChatMessageTimings } = { + content: streamingState.response + }; + if (lastMessage.thinking?.trim()) updateData.thinking = lastMessage.thinking; + const lastKnownState = this.getProcessingState(conversationId); + if (lastKnownState) { + updateData.timings = { + prompt_n: lastKnownState.promptTokens || 0, + prompt_ms: lastKnownState.promptMs, + predicted_n: lastKnownState.tokensDecoded || 0, + cache_n: lastKnownState.cacheTokens || 0, + predicted_ms: + lastKnownState.tokensPerSecond && lastKnownState.tokensDecoded + ? (lastKnownState.tokensDecoded / lastKnownState.tokensPerSecond) * 1000 + : undefined + }; + } + + await DatabaseService.updateMessage(lastMessage.id, updateData); + + lastMessage.content = this.currentResponse; + + if (updateData.thinking) lastMessage.thinking = updateData.thinking; + + if (updateData.timings) lastMessage.timings = updateData.timings; + } catch (error) { + lastMessage.content = this.currentResponse; + console.error('Failed to save partial response:', error); + } + } + } + + async updateMessage(messageId: string, newContent: string): Promise<void> { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return; + if (this.isLoading) this.stopGeneration(); + + const result = this.getMessageByIdWithRole(messageId, 'user'); + if (!result) return; + const { message: messageToUpdate, index: messageIndex } = result; + const originalContent = messageToUpdate.content; + + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + const isFirstUserMessage = rootMessage && messageToUpdate.parent === rootMessage.id; + + conversationsStore.updateMessageAtIndex(messageIndex, { content: newContent }); + await DatabaseService.updateMessage(messageId, { content: newContent }); + + if (isFirstUserMessage && newContent.trim()) { + await conversationsStore.updateConversationTitleWithConfirmation( + activeConv.id, + newContent.trim(), + conversationsStore.titleUpdateConfirmationCallback + ); + } + + const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1); + + for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id); + + conversationsStore.sliceActiveMessages(messageIndex + 1); + conversationsStore.updateConversationTimestamp(); + + this.setChatLoading(activeConv.id, true); + this.clearChatStreaming(activeConv.id); + + const assistantMessage = await this.createAssistantMessage(); + + if (!assistantMessage) throw new Error('Failed to create assistant message'); + + conversationsStore.addMessageToActive(assistantMessage); + + await conversationsStore.updateCurrentNode(assistantMessage.id); + await this.streamChatCompletion( + conversationsStore.activeMessages.slice(0, -1), + assistantMessage, + undefined, + () => { + conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(messageId), { + content: originalContent + }); + } + ); + } catch (error) { + if (!this.isAbortError(error)) console.error('Failed to update message:', error); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Regeneration + // ───────────────────────────────────────────────────────────────────────────── + + async regenerateMessage(messageId: string): Promise<void> { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isLoading) return; + + const result = this.getMessageByIdWithRole(messageId, 'assistant'); + if (!result) return; + const { index: messageIndex } = result; + + try { + const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex); + for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id); + conversationsStore.sliceActiveMessages(messageIndex); + conversationsStore.updateConversationTimestamp(); + + this.setChatLoading(activeConv.id, true); + this.clearChatStreaming(activeConv.id); + + const parentMessageId = + conversationsStore.activeMessages.length > 0 + ? conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1].id + : undefined; + const assistantMessage = await this.createAssistantMessage(parentMessageId); + if (!assistantMessage) throw new Error('Failed to create assistant message'); + conversationsStore.addMessageToActive(assistantMessage); + await this.streamChatCompletion( + conversationsStore.activeMessages.slice(0, -1), + assistantMessage + ); + } catch (error) { + if (!this.isAbortError(error)) console.error('Failed to regenerate message:', error); + this.setChatLoading(activeConv?.id || '', false); + } + } + + async getDeletionInfo(messageId: string): Promise<{ + totalCount: number; + userMessages: number; + assistantMessages: number; + messageTypes: string[]; + }> { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) + return { totalCount: 0, userMessages: 0, assistantMessages: 0, messageTypes: [] }; + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const descendants = findDescendantMessages(allMessages, messageId); + const allToDelete = [messageId, ...descendants]; + const messagesToDelete = allMessages.filter((m) => allToDelete.includes(m.id)); + let userMessages = 0, + assistantMessages = 0; + const messageTypes: string[] = []; + for (const msg of messagesToDelete) { + if (msg.role === 'user') { + userMessages++; + if (!messageTypes.includes('user message')) messageTypes.push('user message'); + } else if (msg.role === 'assistant') { + assistantMessages++; + if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response'); + } + } + return { totalCount: allToDelete.length, userMessages, assistantMessages, messageTypes }; + } + + async deleteMessage(messageId: string): Promise<void> { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return; + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const messageToDelete = allMessages.find((m) => m.id === messageId); + if (!messageToDelete) return; + + const currentPath = filterByLeafNodeId(allMessages, activeConv.currNode || '', false); + const isInCurrentPath = currentPath.some((m) => m.id === messageId); + + if (isInCurrentPath && messageToDelete.parent) { + const siblings = allMessages.filter( + (m) => m.parent === messageToDelete.parent && m.id !== messageId + ); + + if (siblings.length > 0) { + const latestSibling = siblings.reduce((latest, sibling) => + sibling.timestamp > latest.timestamp ? sibling : latest + ); + await conversationsStore.updateCurrentNode(findLeafNode(allMessages, latestSibling.id)); + } else if (messageToDelete.parent) { + await conversationsStore.updateCurrentNode( + findLeafNode(allMessages, messageToDelete.parent) + ); + } + } + await DatabaseService.deleteMessageCascading(activeConv.id, messageId); + await conversationsStore.refreshActiveMessages(); + + conversationsStore.updateConversationTimestamp(); + } catch (error) { + console.error('Failed to delete message:', error); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Editing + // ───────────────────────────────────────────────────────────────────────────── + + clearEditMode(): void { + this.isEditModeActive = false; + this.addFilesHandler = null; + } + + async continueAssistantMessage(messageId: string): Promise<void> { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isLoading) return; + + const result = this.getMessageByIdWithRole(messageId, 'assistant'); + if (!result) return; + const { message: msg, index: idx } = result; + + if (this.isChatLoading(activeConv.id)) return; + + try { + this.errorDialogState = null; + this.setChatLoading(activeConv.id, true); + this.clearChatStreaming(activeConv.id); + + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const dbMessage = allMessages.find((m) => m.id === messageId); + + if (!dbMessage) { + this.setChatLoading(activeConv.id, false); + + return; + } + + const originalContent = dbMessage.content; + const originalThinking = dbMessage.thinking || ''; + + const conversationContext = conversationsStore.activeMessages.slice(0, idx); + const contextWithContinue = [ + ...conversationContext, + { role: 'assistant' as const, content: originalContent } + ]; + + let appendedContent = '', + appendedThinking = '', + hasReceivedContent = false; + + const abortController = this.getOrCreateAbortController(msg.convId); + + await ChatService.sendMessage( + contextWithContinue, + { + ...this.getApiOptions(), + + onChunk: (chunk: string) => { + hasReceivedContent = true; + appendedContent += chunk; + const fullContent = originalContent + appendedContent; + this.setChatStreaming(msg.convId, fullContent, msg.id); + conversationsStore.updateMessageAtIndex(idx, { content: fullContent }); + }, + + onReasoningChunk: (reasoningChunk: string) => { + hasReceivedContent = true; + appendedThinking += reasoningChunk; + conversationsStore.updateMessageAtIndex(idx, { + thinking: originalThinking + appendedThinking + }); + }, + + onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => { + const tokensPerSecond = + timings?.predicted_ms && timings?.predicted_n + ? (timings.predicted_n / timings.predicted_ms) * 1000 + : 0; + this.updateProcessingStateFromTimings( + { + prompt_n: timings?.prompt_n || 0, + prompt_ms: timings?.prompt_ms, + predicted_n: timings?.predicted_n || 0, + predicted_per_second: tokensPerSecond, + cache_n: timings?.cache_n || 0, + prompt_progress: promptProgress + }, + msg.convId + ); + }, + + onComplete: async ( + finalContent?: string, + reasoningContent?: string, + timings?: ChatMessageTimings + ) => { + const fullContent = originalContent + (finalContent || appendedContent); + const fullThinking = originalThinking + (reasoningContent || appendedThinking); + await DatabaseService.updateMessage(msg.id, { + content: fullContent, + thinking: fullThinking, + timestamp: Date.now(), + timings + }); + conversationsStore.updateMessageAtIndex(idx, { + content: fullContent, + thinking: fullThinking, + timestamp: Date.now(), + timings + }); + conversationsStore.updateConversationTimestamp(); + this.setChatLoading(msg.convId, false); + this.clearChatStreaming(msg.convId); + this.clearProcessingState(msg.convId); + }, + + onError: async (error: Error) => { + if (this.isAbortError(error)) { + if (hasReceivedContent && appendedContent) { + await DatabaseService.updateMessage(msg.id, { + content: originalContent + appendedContent, + thinking: originalThinking + appendedThinking, + timestamp: Date.now() + }); + conversationsStore.updateMessageAtIndex(idx, { + content: originalContent + appendedContent, + thinking: originalThinking + appendedThinking, + timestamp: Date.now() + }); + } + this.setChatLoading(msg.convId, false); + this.clearChatStreaming(msg.convId); + this.clearProcessingState(msg.convId); + return; + } + console.error('Continue generation error:', error); + conversationsStore.updateMessageAtIndex(idx, { + content: originalContent, + thinking: originalThinking + }); + await DatabaseService.updateMessage(msg.id, { + content: originalContent, + thinking: originalThinking + }); + this.setChatLoading(msg.convId, false); + this.clearChatStreaming(msg.convId); + this.clearProcessingState(msg.convId); + this.showErrorDialog( + error.name === 'TimeoutError' ? 'timeout' : 'server', + error.message + ); + } + }, + msg.convId, + abortController.signal + ); + } catch (error) { + if (!this.isAbortError(error)) console.error('Failed to continue message:', error); + if (activeConv) this.setChatLoading(activeConv.id, false); + } + } + + async editAssistantMessage( + messageId: string, + newContent: string, + shouldBranch: boolean + ): Promise<void> { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isLoading) return; + + const result = this.getMessageByIdWithRole(messageId, 'assistant'); + if (!result) return; + const { message: msg, index: idx } = result; + + try { + if (shouldBranch) { + const newMessage = await DatabaseService.createMessageBranch( + { + convId: msg.convId, + type: msg.type, + timestamp: Date.now(), + role: msg.role, + content: newContent, + thinking: msg.thinking || '', + toolCalls: msg.toolCalls || '', + children: [], + model: msg.model + }, + msg.parent! + ); + await conversationsStore.updateCurrentNode(newMessage.id); + } else { + await DatabaseService.updateMessage(msg.id, { content: newContent }); + await conversationsStore.updateCurrentNode(msg.id); + conversationsStore.updateMessageAtIndex(idx, { + content: newContent + }); + } + conversationsStore.updateConversationTimestamp(); + await conversationsStore.refreshActiveMessages(); + } catch (error) { + console.error('Failed to edit assistant message:', error); + } + } + + async editUserMessagePreserveResponses( + messageId: string, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ): Promise<void> { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return; + + const result = this.getMessageByIdWithRole(messageId, 'user'); + if (!result) return; + const { message: msg, index: idx } = result; + + try { + const updateData: Partial<DatabaseMessage> = { + content: newContent + }; + + // Update extras if provided (including empty array to clear attachments) + // Deep clone to avoid Proxy objects from Svelte reactivity + if (newExtras !== undefined) { + updateData.extra = JSON.parse(JSON.stringify(newExtras)); + } + + await DatabaseService.updateMessage(messageId, updateData); + conversationsStore.updateMessageAtIndex(idx, updateData); + + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + + if (rootMessage && msg.parent === rootMessage.id && newContent.trim()) { + await conversationsStore.updateConversationTitleWithConfirmation( + activeConv.id, + newContent.trim(), + conversationsStore.titleUpdateConfirmationCallback + ); + } + conversationsStore.updateConversationTimestamp(); + } catch (error) { + console.error('Failed to edit user message:', error); + } + } + + async editMessageWithBranching( + messageId: string, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ): Promise<void> { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isLoading) return; + + let result = this.getMessageByIdWithRole(messageId, 'user'); + + if (!result) { + result = this.getMessageByIdWithRole(messageId, 'system'); + } + + if (!result) return; + const { message: msg } = result; + + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + const isFirstUserMessage = + msg.role === 'user' && rootMessage && msg.parent === rootMessage.id; + + const parentId = msg.parent || rootMessage?.id; + if (!parentId) return; + + // Use newExtras if provided, otherwise copy existing extras + // Deep clone to avoid Proxy objects from Svelte reactivity + const extrasToUse = + newExtras !== undefined + ? JSON.parse(JSON.stringify(newExtras)) + : msg.extra + ? JSON.parse(JSON.stringify(msg.extra)) + : undefined; + + const newMessage = await DatabaseService.createMessageBranch( + { + convId: msg.convId, + type: msg.type, + timestamp: Date.now(), + role: msg.role, + content: newContent, + thinking: msg.thinking || '', + toolCalls: msg.toolCalls || '', + children: [], + extra: extrasToUse, + model: msg.model + }, + parentId + ); + await conversationsStore.updateCurrentNode(newMessage.id); + conversationsStore.updateConversationTimestamp(); + + if (isFirstUserMessage && newContent.trim()) { + await conversationsStore.updateConversationTitleWithConfirmation( + activeConv.id, + newContent.trim(), + conversationsStore.titleUpdateConfirmationCallback + ); + } + await conversationsStore.refreshActiveMessages(); + + if (msg.role === 'user') { + await this.generateResponseForMessage(newMessage.id); + } + } catch (error) { + console.error('Failed to edit message with branching:', error); + } + } + + async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise<void> { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isLoading) return; + try { + const idx = conversationsStore.findMessageIndex(messageId); + if (idx === -1) return; + const msg = conversationsStore.activeMessages[idx]; + if (msg.role !== 'assistant') return; + + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const parentMessage = allMessages.find((m) => m.id === msg.parent); + if (!parentMessage) return; + + this.setChatLoading(activeConv.id, true); + this.clearChatStreaming(activeConv.id); + + const newAssistantMessage = await DatabaseService.createMessageBranch( + { + convId: activeConv.id, + type: 'text', + timestamp: Date.now(), + role: 'assistant', + content: '', + thinking: '', + toolCalls: '', + children: [], + model: null + }, + parentMessage.id + ); + await conversationsStore.updateCurrentNode(newAssistantMessage.id); + conversationsStore.updateConversationTimestamp(); + await conversationsStore.refreshActiveMessages(); + + const conversationPath = filterByLeafNodeId( + allMessages, + parentMessage.id, + false + ) as DatabaseMessage[]; + // Use modelOverride if provided, otherwise use the original message's model + // If neither is available, don't pass model (will use global selection) + const modelToUse = modelOverride || msg.model || undefined; + await this.streamChatCompletion( + conversationPath, + newAssistantMessage, + undefined, + undefined, + modelToUse + ); + } catch (error) { + if (!this.isAbortError(error)) + console.error('Failed to regenerate message with branching:', error); + this.setChatLoading(activeConv?.id || '', false); + } + } + + private async generateResponseForMessage(userMessageId: string): Promise<void> { + const activeConv = conversationsStore.activeConversation; + + if (!activeConv) return; + + this.errorDialogState = null; + this.setChatLoading(activeConv.id, true); + this.clearChatStreaming(activeConv.id); + + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const conversationPath = filterByLeafNodeId( + allMessages, + userMessageId, + false + ) as DatabaseMessage[]; + const assistantMessage = await DatabaseService.createMessageBranch( + { + convId: activeConv.id, + type: 'text', + timestamp: Date.now(), + role: 'assistant', + content: '', + thinking: '', + toolCalls: '', + children: [], + model: null + }, + userMessageId + ); + conversationsStore.addMessageToActive(assistantMessage); + await this.streamChatCompletion(conversationPath, assistantMessage); + } catch (error) { + console.error('Failed to generate response:', error); + this.setChatLoading(activeConv.id, false); + } + } + + getAddFilesHandler(): ((files: File[]) => void) | null { + return this.addFilesHandler; + } + + public getAllLoadingChats(): string[] { + return Array.from(this.chatLoadingStates.keys()); + } + + public getAllStreamingChats(): string[] { + return Array.from(this.chatStreamingStates.keys()); + } + + public getChatStreamingPublic( + convId: string + ): { response: string; messageId: string } | undefined { + return this.getChatStreaming(convId); + } + + public isChatLoadingPublic(convId: string): boolean { + return this.isChatLoading(convId); + } + + isEditing(): boolean { + return this.isEditModeActive; + } + + setEditModeActive(handler: (files: File[]) => void): void { + this.isEditModeActive = true; + this.addFilesHandler = handler; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Utilities + // ───────────────────────────────────────────────────────────────────────────── + + private getApiOptions(): Record<string, unknown> { + const currentConfig = config(); + const hasValue = (value: unknown): boolean => + value !== undefined && value !== null && value !== ''; + + const apiOptions: Record<string, unknown> = { stream: true, timings_per_token: true }; + + // Model selection (required in ROUTER mode) + if (isRouterMode()) { + const modelName = selectedModelName(); + if (modelName) apiOptions.model = modelName; + } + + // Config options needed by ChatService + if (currentConfig.systemMessage) apiOptions.systemMessage = currentConfig.systemMessage; + if (currentConfig.disableReasoningFormat) apiOptions.disableReasoningFormat = true; + + if (hasValue(currentConfig.temperature)) + apiOptions.temperature = Number(currentConfig.temperature); + if (hasValue(currentConfig.max_tokens)) + apiOptions.max_tokens = Number(currentConfig.max_tokens); + if (hasValue(currentConfig.dynatemp_range)) + apiOptions.dynatemp_range = Number(currentConfig.dynatemp_range); + if (hasValue(currentConfig.dynatemp_exponent)) + apiOptions.dynatemp_exponent = Number(currentConfig.dynatemp_exponent); + if (hasValue(currentConfig.top_k)) apiOptions.top_k = Number(currentConfig.top_k); + if (hasValue(currentConfig.top_p)) apiOptions.top_p = Number(currentConfig.top_p); + if (hasValue(currentConfig.min_p)) apiOptions.min_p = Number(currentConfig.min_p); + if (hasValue(currentConfig.xtc_probability)) + apiOptions.xtc_probability = Number(currentConfig.xtc_probability); + if (hasValue(currentConfig.xtc_threshold)) + apiOptions.xtc_threshold = Number(currentConfig.xtc_threshold); + if (hasValue(currentConfig.typ_p)) apiOptions.typ_p = Number(currentConfig.typ_p); + if (hasValue(currentConfig.repeat_last_n)) + apiOptions.repeat_last_n = Number(currentConfig.repeat_last_n); + if (hasValue(currentConfig.repeat_penalty)) + apiOptions.repeat_penalty = Number(currentConfig.repeat_penalty); + if (hasValue(currentConfig.presence_penalty)) + apiOptions.presence_penalty = Number(currentConfig.presence_penalty); + if (hasValue(currentConfig.frequency_penalty)) + apiOptions.frequency_penalty = Number(currentConfig.frequency_penalty); + if (hasValue(currentConfig.dry_multiplier)) + apiOptions.dry_multiplier = Number(currentConfig.dry_multiplier); + if (hasValue(currentConfig.dry_base)) apiOptions.dry_base = Number(currentConfig.dry_base); + if (hasValue(currentConfig.dry_allowed_length)) + apiOptions.dry_allowed_length = Number(currentConfig.dry_allowed_length); + if (hasValue(currentConfig.dry_penalty_last_n)) + apiOptions.dry_penalty_last_n = Number(currentConfig.dry_penalty_last_n); + if (currentConfig.samplers) apiOptions.samplers = currentConfig.samplers; + if (currentConfig.backend_sampling) + apiOptions.backend_sampling = currentConfig.backend_sampling; + if (currentConfig.custom) apiOptions.custom = currentConfig.custom; + + return apiOptions; + } +} + +export const chatStore = new ChatStore(); + +export const activeProcessingState = () => chatStore.activeProcessingState; +export const clearEditMode = () => chatStore.clearEditMode(); +export const currentResponse = () => chatStore.currentResponse; +export const errorDialog = () => chatStore.errorDialogState; +export const getAddFilesHandler = () => chatStore.getAddFilesHandler(); +export const getAllLoadingChats = () => chatStore.getAllLoadingChats(); +export const getAllStreamingChats = () => chatStore.getAllStreamingChats(); +export const getChatStreaming = (convId: string) => chatStore.getChatStreamingPublic(convId); +export const isChatLoading = (convId: string) => chatStore.isChatLoadingPublic(convId); +export const isChatStreaming = () => chatStore.isStreaming(); +export const isEditing = () => chatStore.isEditing(); +export const isLoading = () => chatStore.isLoading; +export const setEditModeActive = (handler: (files: File[]) => void) => + chatStore.setEditModeActive(handler); diff --git a/llama.cpp/tools/server/webui/src/lib/stores/conversations.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/conversations.svelte.ts new file mode 100644 index 0000000..3300eb3 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/stores/conversations.svelte.ts @@ -0,0 +1,662 @@ +import { browser } from '$app/environment'; +import { goto } from '$app/navigation'; +import { toast } from 'svelte-sonner'; +import { DatabaseService } from '$lib/services/database'; +import { config } from '$lib/stores/settings.svelte'; +import { filterByLeafNodeId, findLeafNode } from '$lib/utils'; +import { AttachmentType } from '$lib/enums'; + +/** + * conversationsStore - Persistent conversation data and lifecycle management + * + * **Terminology - Chat vs Conversation:** + * - **Chat**: The active interaction space with the Chat Completions API. Represents the + * real-time streaming session, loading states, and UI visualization of AI communication. + * Managed by chatStore, a "chat" is ephemeral and exists during active AI interactions. + * - **Conversation**: The persistent database entity storing all messages and metadata. + * A "conversation" survives across sessions, page reloads, and browser restarts. + * It contains the complete message history, branching structure, and conversation metadata. + * + * This store manages all conversation-level data and operations including creation, loading, + * deletion, and navigation. It maintains the list of conversations and the currently active + * conversation with its message history, providing reactive state for UI components. + * + * **Architecture & Relationships:** + * - **conversationsStore** (this class): Persistent conversation data management + * - Manages conversation list and active conversation state + * - Handles conversation CRUD operations via DatabaseService + * - Maintains active message array for current conversation + * - Coordinates branching navigation (currNode tracking) + * + * - **chatStore**: Uses conversation data as context for active AI streaming + * - **DatabaseService**: Low-level IndexedDB storage for conversations and messages + * + * **Key Features:** + * - **Conversation Lifecycle**: Create, load, update, delete conversations + * - **Message Management**: Active message array with branching support + * - **Import/Export**: JSON-based conversation backup and restore + * - **Branch Navigation**: Navigate between message tree branches + * - **Title Management**: Auto-update titles with confirmation dialogs + * - **Reactive State**: Svelte 5 runes for automatic UI updates + * + * **State Properties:** + * - `conversations`: All conversations sorted by last modified + * - `activeConversation`: Currently viewed conversation + * - `activeMessages`: Messages in current conversation path + * - `isInitialized`: Store initialization status + */ +class ConversationsStore { + // ───────────────────────────────────────────────────────────────────────────── + // State + // ───────────────────────────────────────────────────────────────────────────── + + /** List of all conversations */ + conversations = $state<DatabaseConversation[]>([]); + + /** Currently active conversation */ + activeConversation = $state<DatabaseConversation | null>(null); + + /** Messages in the active conversation (filtered by currNode path) */ + activeMessages = $state<DatabaseMessage[]>([]); + + /** Whether the store has been initialized */ + isInitialized = $state(false); + + /** Callback for title update confirmation dialog */ + titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>; + + // ───────────────────────────────────────────────────────────────────────────── + // Modalities + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Modalities used in the active conversation. + * Computed from attachments in activeMessages. + * Used to filter available models - models must support all used modalities. + */ + usedModalities: ModelModalities = $derived.by(() => { + return this.calculateModalitiesFromMessages(this.activeMessages); + }); + + /** + * Calculate modalities from a list of messages. + * Helper method used by both usedModalities and getModalitiesUpToMessage. + */ + private calculateModalitiesFromMessages(messages: DatabaseMessage[]): ModelModalities { + const modalities: ModelModalities = { vision: false, audio: false }; + + for (const message of messages) { + if (!message.extra) continue; + + for (const extra of message.extra) { + if (extra.type === AttachmentType.IMAGE) { + modalities.vision = true; + } + + // PDF only requires vision if processed as images + if (extra.type === AttachmentType.PDF) { + const pdfExtra = extra as DatabaseMessageExtraPdfFile; + + if (pdfExtra.processedAsImages) { + modalities.vision = true; + } + } + + if (extra.type === AttachmentType.AUDIO) { + modalities.audio = true; + } + } + + if (modalities.vision && modalities.audio) break; + } + + return modalities; + } + + /** + * Get modalities used in messages BEFORE the specified message. + * Used for regeneration - only consider context that was available when generating this message. + */ + getModalitiesUpToMessage(messageId: string): ModelModalities { + const messageIndex = this.activeMessages.findIndex((m) => m.id === messageId); + + if (messageIndex === -1) { + return this.usedModalities; + } + + const messagesBefore = this.activeMessages.slice(0, messageIndex); + return this.calculateModalitiesFromMessages(messagesBefore); + } + + constructor() { + if (browser) { + this.initialize(); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Lifecycle + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Initializes the conversations store by loading conversations from the database + */ + async initialize(): Promise<void> { + try { + await this.loadConversations(); + this.isInitialized = true; + } catch (error) { + console.error('Failed to initialize conversations store:', error); + } + } + + /** + * Loads all conversations from the database + */ + async loadConversations(): Promise<void> { + this.conversations = await DatabaseService.getAllConversations(); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Conversation CRUD + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Creates a new conversation and navigates to it + * @param name - Optional name for the conversation + * @returns The ID of the created conversation + */ + async createConversation(name?: string): Promise<string> { + const conversationName = name || `Chat ${new Date().toLocaleString()}`; + const conversation = await DatabaseService.createConversation(conversationName); + + this.conversations.unshift(conversation); + this.activeConversation = conversation; + this.activeMessages = []; + + await goto(`#/chat/${conversation.id}`); + + return conversation.id; + } + + /** + * Loads a specific conversation and its messages + * @param convId - The conversation ID to load + * @returns True if conversation was loaded successfully + */ + async loadConversation(convId: string): Promise<boolean> { + try { + const conversation = await DatabaseService.getConversation(convId); + + if (!conversation) { + return false; + } + + this.activeConversation = conversation; + + if (conversation.currNode) { + const allMessages = await DatabaseService.getConversationMessages(convId); + this.activeMessages = filterByLeafNodeId( + allMessages, + conversation.currNode, + false + ) as DatabaseMessage[]; + } else { + this.activeMessages = await DatabaseService.getConversationMessages(convId); + } + + return true; + } catch (error) { + console.error('Failed to load conversation:', error); + return false; + } + } + + /** + * Clears the active conversation and messages + * Used when navigating away from chat or starting fresh + */ + clearActiveConversation(): void { + this.activeConversation = null; + this.activeMessages = []; + // Active processing conversation is now managed by chatStore + } + + // ───────────────────────────────────────────────────────────────────────────── + // Message Management + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Refreshes active messages based on currNode after branch navigation + */ + async refreshActiveMessages(): Promise<void> { + if (!this.activeConversation) return; + + const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id); + + if (allMessages.length === 0) { + this.activeMessages = []; + return; + } + + const leafNodeId = + this.activeConversation.currNode || + allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id; + + const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[]; + + this.activeMessages.length = 0; + this.activeMessages.push(...currentPath); + } + + /** + * Updates the name of a conversation + * @param convId - The conversation ID to update + * @param name - The new name for the conversation + */ + async updateConversationName(convId: string, name: string): Promise<void> { + try { + await DatabaseService.updateConversation(convId, { name }); + + const convIndex = this.conversations.findIndex((c) => c.id === convId); + + if (convIndex !== -1) { + this.conversations[convIndex].name = name; + } + + if (this.activeConversation?.id === convId) { + this.activeConversation.name = name; + } + } catch (error) { + console.error('Failed to update conversation name:', error); + } + } + + /** + * Updates conversation title with optional confirmation dialog based on settings + * @param convId - The conversation ID to update + * @param newTitle - The new title content + * @param onConfirmationNeeded - Callback when user confirmation is needed + * @returns True if title was updated, false if cancelled + */ + async updateConversationTitleWithConfirmation( + convId: string, + newTitle: string, + onConfirmationNeeded?: (currentTitle: string, newTitle: string) => Promise<boolean> + ): Promise<boolean> { + try { + const currentConfig = config(); + + if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) { + const conversation = await DatabaseService.getConversation(convId); + if (!conversation) return false; + + const shouldUpdate = await onConfirmationNeeded(conversation.name, newTitle); + if (!shouldUpdate) return false; + } + + await this.updateConversationName(convId, newTitle); + return true; + } catch (error) { + console.error('Failed to update conversation title with confirmation:', error); + return false; + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Navigation + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Updates the current node of the active conversation + * @param nodeId - The new current node ID + */ + async updateCurrentNode(nodeId: string): Promise<void> { + if (!this.activeConversation) return; + + await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId); + this.activeConversation.currNode = nodeId; + } + + /** + * Updates conversation lastModified timestamp and moves it to top of list + */ + updateConversationTimestamp(): void { + if (!this.activeConversation) return; + + const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id); + + if (chatIndex !== -1) { + this.conversations[chatIndex].lastModified = Date.now(); + const updatedConv = this.conversations.splice(chatIndex, 1)[0]; + this.conversations.unshift(updatedConv); + } + } + + /** + * Navigates to a specific sibling branch by updating currNode and refreshing messages + * @param siblingId - The sibling message ID to navigate to + */ + async navigateToSibling(siblingId: string): Promise<void> { + if (!this.activeConversation) return; + + const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + const currentFirstUserMessage = this.activeMessages.find( + (m) => m.role === 'user' && m.parent === rootMessage?.id + ); + + const currentLeafNodeId = findLeafNode(allMessages, siblingId); + + await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId); + this.activeConversation.currNode = currentLeafNodeId; + await this.refreshActiveMessages(); + + // Only show title dialog if we're navigating between different first user message siblings + if (rootMessage && this.activeMessages.length > 0) { + const newFirstUserMessage = this.activeMessages.find( + (m) => m.role === 'user' && m.parent === rootMessage.id + ); + + if ( + newFirstUserMessage && + newFirstUserMessage.content.trim() && + (!currentFirstUserMessage || + newFirstUserMessage.id !== currentFirstUserMessage.id || + newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim()) + ) { + await this.updateConversationTitleWithConfirmation( + this.activeConversation.id, + newFirstUserMessage.content.trim(), + this.titleUpdateConfirmationCallback + ); + } + } + } + + /** + * Deletes a conversation and all its messages + * @param convId - The conversation ID to delete + */ + async deleteConversation(convId: string): Promise<void> { + try { + await DatabaseService.deleteConversation(convId); + + this.conversations = this.conversations.filter((c) => c.id !== convId); + + if (this.activeConversation?.id === convId) { + this.clearActiveConversation(); + await goto(`?new_chat=true#/`); + } + } catch (error) { + console.error('Failed to delete conversation:', error); + } + } + + /** + * Deletes all conversations and their messages + */ + async deleteAll(): Promise<void> { + try { + const allConversations = await DatabaseService.getAllConversations(); + + for (const conv of allConversations) { + await DatabaseService.deleteConversation(conv.id); + } + + this.clearActiveConversation(); + this.conversations = []; + + toast.success('All conversations deleted'); + + await goto(`?new_chat=true#/`); + } catch (error) { + console.error('Failed to delete all conversations:', error); + toast.error('Failed to delete conversations'); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Import/Export + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Downloads a conversation as JSON file + * @param convId - The conversation ID to download + */ + async downloadConversation(convId: string): Promise<void> { + let conversation: DatabaseConversation | null; + let messages: DatabaseMessage[]; + + if (this.activeConversation?.id === convId) { + conversation = this.activeConversation; + messages = this.activeMessages; + } else { + conversation = await DatabaseService.getConversation(convId); + if (!conversation) return; + messages = await DatabaseService.getConversationMessages(convId); + } + + this.triggerDownload({ conv: conversation, messages }); + } + + /** + * Exports all conversations with their messages as a JSON file + * @returns The list of exported conversations + */ + async exportAllConversations(): Promise<DatabaseConversation[]> { + const allConversations = await DatabaseService.getAllConversations(); + + if (allConversations.length === 0) { + throw new Error('No conversations to export'); + } + + const allData = await Promise.all( + allConversations.map(async (conv) => { + const messages = await DatabaseService.getConversationMessages(conv.id); + return { conv, 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 = `all_conversations_${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success(`All conversations (${allConversations.length}) prepared for download`); + + return allConversations; + } + + /** + * Imports conversations from a JSON file + * Opens file picker and processes the selected file + * @returns The list of imported conversations + */ + async importConversations(): Promise<DatabaseConversation[]> { + return new Promise((resolve, reject) => { + 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) { + reject(new Error('No file selected')); + 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 + ) { + importedData = [parsedData]; + } else { + throw new Error('Invalid file format'); + } + + const result = await DatabaseService.importConversations(importedData); + toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`); + + await this.loadConversations(); + + const importedConversations = ( + Array.isArray(importedData) ? importedData : [importedData] + ).map((item) => item.conv); + + resolve(importedConversations); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.error('Failed to import conversations:', err); + toast.error('Import failed', { description: message }); + reject(new Error(`Import failed: ${message}`)); + } + }; + + input.click(); + }); + } + + /** + * Gets all messages for a specific conversation + * @param convId - The conversation ID + * @returns Array of messages + */ + async getConversationMessages(convId: string): Promise<DatabaseMessage[]> { + return await DatabaseService.getConversationMessages(convId); + } + + /** + * Imports conversations from provided data (without file picker) + * @param data - Array of conversation data with messages + * @returns Import result with counts + */ + async importConversationsData( + data: ExportedConversations + ): Promise<{ imported: number; skipped: number }> { + const result = await DatabaseService.importConversations(data); + await this.loadConversations(); + return result; + } + + /** + * Adds a message to the active messages array + * Used by chatStore when creating new messages + * @param message - The message to add + */ + addMessageToActive(message: DatabaseMessage): void { + this.activeMessages.push(message); + } + + /** + * Updates a message at a specific index in active messages + * Creates a new object to trigger Svelte 5 reactivity + * @param index - The index of the message to update + * @param updates - Partial message data to update + */ + updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void { + if (index !== -1 && this.activeMessages[index]) { + // Create new object to trigger Svelte 5 reactivity + this.activeMessages[index] = { ...this.activeMessages[index], ...updates }; + } + } + + /** + * Finds the index of a message in active messages + * @param messageId - The message ID to find + * @returns The index of the message, or -1 if not found + */ + findMessageIndex(messageId: string): number { + return this.activeMessages.findIndex((m) => m.id === messageId); + } + + /** + * Removes messages from active messages starting at an index + * @param startIndex - The index to start removing from + */ + sliceActiveMessages(startIndex: number): void { + this.activeMessages = this.activeMessages.slice(0, startIndex); + } + + /** + * Removes a message from active messages by index + * @param index - The index to remove + * @returns The removed message or undefined + */ + removeMessageAtIndex(index: number): DatabaseMessage | undefined { + if (index !== -1) { + return this.activeMessages.splice(index, 1)[0]; + } + return undefined; + } + + /** + * Triggers file download in browser + * @param data - The data to download + * @param filename - Optional filename for the download + */ + private triggerDownload(data: ExportedConversations, filename?: string): void { + const conversation = + 'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined; + + if (!conversation) { + console.error('Invalid data: missing conversation'); + return; + } + + const conversationName = conversation.name?.trim() || ''; + const truncatedSuffix = conversationName + .toLowerCase() + .replace(/[^a-z0-9]/gi, '_') + .replace(/_+/g, '_') + .substring(0, 20); + const downloadFilename = filename || `conversation_${conversation.id}_${truncatedSuffix}.json`; + + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = downloadFilename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Utilities + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Sets the callback function for title update confirmations + * @param callback - Function to call when confirmation is needed + */ + setTitleUpdateConfirmationCallback( + callback: (currentTitle: string, newTitle: string) => Promise<boolean> + ): void { + this.titleUpdateConfirmationCallback = callback; + } +} + +export const conversationsStore = new ConversationsStore(); + +export const conversations = () => conversationsStore.conversations; +export const activeConversation = () => conversationsStore.activeConversation; +export const activeMessages = () => conversationsStore.activeMessages; +export const isConversationsInitialized = () => conversationsStore.isInitialized; +export const usedModalities = () => conversationsStore.usedModalities; diff --git a/llama.cpp/tools/server/webui/src/lib/stores/models.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/models.svelte.ts new file mode 100644 index 0000000..34b2640 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/stores/models.svelte.ts @@ -0,0 +1,605 @@ +import { SvelteSet } from 'svelte/reactivity'; +import { ModelsService } from '$lib/services/models'; +import { PropsService } from '$lib/services/props'; +import { ServerModelStatus, ModelModality } from '$lib/enums'; +import { serverStore } from '$lib/stores/server.svelte'; + +/** + * modelsStore - Reactive store for model management in both MODEL and ROUTER modes + * + * This store manages: + * - Available models list + * - Selected model for new conversations + * - Loaded models tracking (ROUTER mode) + * - Model usage tracking per conversation + * - Automatic unloading of unused models + * + * **Architecture & Relationships:** + * - **ModelsService**: Stateless service for model API communication + * - **PropsService**: Stateless service for props/modalities fetching + * - **modelsStore** (this class): Reactive store for model state + * - **conversationsStore**: Tracks which conversations use which models + * + * **API Inconsistency Workaround:** + * In MODEL mode, `/props` returns modalities for the single model. + * In ROUTER mode, `/props` has no modalities - must use `/props?model=<id>` per model. + * This store normalizes this behavior so consumers don't need to know the server mode. + * + * **Key Features:** + * - **MODEL mode**: Single model, always loaded + * - **ROUTER mode**: Multi-model with load/unload capability + * - **Auto-unload**: Automatically unloads models not used by any conversation + * - **Lazy loading**: ensureModelLoaded() loads models on demand + */ +class ModelsStore { + // ───────────────────────────────────────────────────────────────────────────── + // State + // ───────────────────────────────────────────────────────────────────────────── + + models = $state<ModelOption[]>([]); + routerModels = $state<ApiModelDataEntry[]>([]); + loading = $state(false); + updating = $state(false); + error = $state<string | null>(null); + selectedModelId = $state<string | null>(null); + selectedModelName = $state<string | null>(null); + + private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map()); + private modelLoadingStates = $state<Map<string, boolean>>(new Map()); + + /** + * Model-specific props cache + * Key: modelId, Value: props data including modalities + */ + private modelPropsCache = $state<Map<string, ApiLlamaCppServerProps>>(new Map()); + private modelPropsFetching = $state<Set<string>>(new Set()); + + /** + * Version counter for props cache - used to trigger reactivity when props are updated + */ + propsCacheVersion = $state(0); + + // ───────────────────────────────────────────────────────────────────────────── + // Computed Getters + // ───────────────────────────────────────────────────────────────────────────── + + get selectedModel(): ModelOption | null { + if (!this.selectedModelId) return null; + return this.models.find((model) => model.id === this.selectedModelId) ?? null; + } + + get loadedModelIds(): string[] { + return this.routerModels + .filter((m) => m.status.value === ServerModelStatus.LOADED) + .map((m) => m.id); + } + + get loadingModelIds(): string[] { + return Array.from(this.modelLoadingStates.entries()) + .filter(([, loading]) => loading) + .map(([id]) => id); + } + + /** + * Get model name in MODEL mode (single model). + * Extracts from model_path or model_alias from server props. + * In ROUTER mode, returns null (model is per-conversation). + */ + get singleModelName(): string | null { + if (serverStore.isRouterMode) return null; + + const props = serverStore.props; + if (props?.model_alias) return props.model_alias; + if (!props?.model_path) return null; + + return props.model_path.split(/(\\|\/)/).pop() || null; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Modalities + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Get modalities for a specific model + * Returns cached modalities from model props + */ + getModelModalities(modelId: string): ModelModalities | null { + // First check if modalities are stored in the model option + const model = this.models.find((m) => m.model === modelId || m.id === modelId); + if (model?.modalities) { + return model.modalities; + } + + // Fall back to props cache + const props = this.modelPropsCache.get(modelId); + if (props?.modalities) { + return { + vision: props.modalities.vision ?? false, + audio: props.modalities.audio ?? false + }; + } + + return null; + } + + /** + * Check if a model supports vision modality + */ + modelSupportsVision(modelId: string): boolean { + return this.getModelModalities(modelId)?.vision ?? false; + } + + /** + * Check if a model supports audio modality + */ + modelSupportsAudio(modelId: string): boolean { + return this.getModelModalities(modelId)?.audio ?? false; + } + + /** + * Get model modalities as an array of ModelModality enum values + */ + getModelModalitiesArray(modelId: string): ModelModality[] { + const modalities = this.getModelModalities(modelId); + if (!modalities) return []; + + const result: ModelModality[] = []; + + if (modalities.vision) result.push(ModelModality.VISION); + if (modalities.audio) result.push(ModelModality.AUDIO); + + return result; + } + + /** + * Get props for a specific model (from cache) + */ + getModelProps(modelId: string): ApiLlamaCppServerProps | null { + return this.modelPropsCache.get(modelId) ?? null; + } + + /** + * Get context size (n_ctx) for a specific model from cached props + */ + getModelContextSize(modelId: string): number | null { + const props = this.modelPropsCache.get(modelId); + return props?.default_generation_settings?.n_ctx ?? null; + } + + /** + * Get context size for the currently selected model or null if no model is selected + */ + get selectedModelContextSize(): number | null { + if (!this.selectedModelName) return null; + return this.getModelContextSize(this.selectedModelName); + } + + /** + * Check if props are being fetched for a model + */ + isModelPropsFetching(modelId: string): boolean { + return this.modelPropsFetching.has(modelId); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Status Queries + // ───────────────────────────────────────────────────────────────────────────── + + isModelLoaded(modelId: string): boolean { + const model = this.routerModels.find((m) => m.id === modelId); + return model?.status.value === ServerModelStatus.LOADED || false; + } + + isModelOperationInProgress(modelId: string): boolean { + return this.modelLoadingStates.get(modelId) ?? false; + } + + getModelStatus(modelId: string): ServerModelStatus | null { + const model = this.routerModels.find((m) => m.id === modelId); + return model?.status.value ?? null; + } + + getModelUsage(modelId: string): SvelteSet<string> { + return this.modelUsage.get(modelId) ?? new SvelteSet<string>(); + } + + isModelInUse(modelId: string): boolean { + const usage = this.modelUsage.get(modelId); + return usage !== undefined && usage.size > 0; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Data Fetching + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Fetch list of models from server and detect server role + * Also fetches modalities for MODEL mode (single model) + */ + async fetch(force = false): Promise<void> { + if (this.loading) return; + if (this.models.length > 0 && !force) return; + + this.loading = true; + this.error = null; + + try { + // Ensure server props are loaded (for role detection and MODEL mode modalities) + if (!serverStore.props) { + await serverStore.fetch(); + } + + const response = await ModelsService.list(); + + const models: ModelOption[] = response.data.map((item: ApiModelDataEntry, index: number) => { + const details = response.models?.[index]; + const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : []; + const displayNameSource = + details?.name && details.name.trim().length > 0 ? details.name : item.id; + const displayName = this.toDisplayName(displayNameSource); + + return { + id: item.id, + name: displayName, + model: details?.model || item.id, + description: details?.description, + capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)), + details: details?.details, + meta: item.meta ?? null + } satisfies ModelOption; + }); + + this.models = models; + + // In MODEL mode, populate modalities from serverStore.props (single model) + // WORKAROUND: In MODEL mode, /props returns modalities for the single model, + // but /v1/models doesn't include modalities. We bridge this gap here. + const serverProps = serverStore.props; + if (serverStore.isModelMode && this.models.length > 0 && serverProps?.modalities) { + const modalities: ModelModalities = { + vision: serverProps.modalities.vision ?? false, + audio: serverProps.modalities.audio ?? false + }; + // Cache props for the single model + this.modelPropsCache.set(this.models[0].model, serverProps); + // Update model with modalities + this.models = this.models.map((model, index) => + index === 0 ? { ...model, modalities } : model + ); + } + } catch (error) { + this.models = []; + this.error = error instanceof Error ? error.message : 'Failed to load models'; + throw error; + } finally { + this.loading = false; + } + } + + /** + * Fetch router models with full metadata (ROUTER mode only) + * This fetches the /models endpoint which returns status info for each model + */ + async fetchRouterModels(): Promise<void> { + try { + const response = await ModelsService.listRouter(); + this.routerModels = response.data; + await this.fetchModalitiesForLoadedModels(); + } catch (error) { + console.warn('Failed to fetch router models:', error); + this.routerModels = []; + } + } + + /** + * Fetch props for a specific model from /props endpoint + * Uses caching to avoid redundant requests + * + * In ROUTER mode, this will only fetch props if the model is loaded, + * since unloaded models return 400 from /props endpoint. + * + * @param modelId - Model identifier to fetch props for + * @returns Props data or null if fetch failed or model not loaded + */ + async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> { + // Return cached props if available + const cached = this.modelPropsCache.get(modelId); + if (cached) return cached; + + if (serverStore.isRouterMode && !this.isModelLoaded(modelId)) { + return null; + } + + // Avoid duplicate fetches + if (this.modelPropsFetching.has(modelId)) return null; + + this.modelPropsFetching.add(modelId); + + try { + const props = await PropsService.fetchForModel(modelId); + this.modelPropsCache.set(modelId, props); + return props; + } catch (error) { + console.warn(`Failed to fetch props for model ${modelId}:`, error); + return null; + } finally { + this.modelPropsFetching.delete(modelId); + } + } + + /** + * Fetch modalities for all loaded models from /props endpoint + * This updates the modalities field in models array + */ + async fetchModalitiesForLoadedModels(): Promise<void> { + const loadedModelIds = this.loadedModelIds; + if (loadedModelIds.length === 0) return; + + // Fetch props for each loaded model in parallel + const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId)); + + try { + const results = await Promise.all(propsPromises); + + // Update models with modalities + this.models = this.models.map((model) => { + const modelIndex = loadedModelIds.indexOf(model.model); + if (modelIndex === -1) return model; + + const props = results[modelIndex]; + if (!props?.modalities) return model; + + const modalities: ModelModalities = { + vision: props.modalities.vision ?? false, + audio: props.modalities.audio ?? false + }; + + return { ...model, modalities }; + }); + + // Increment version to trigger reactivity + this.propsCacheVersion++; + } catch (error) { + console.warn('Failed to fetch modalities for loaded models:', error); + } + } + + /** + * Update modalities for a specific model + * Called when a model is loaded or when we need fresh modality data + */ + async updateModelModalities(modelId: string): Promise<void> { + try { + const props = await this.fetchModelProps(modelId); + if (!props?.modalities) return; + + const modalities: ModelModalities = { + vision: props.modalities.vision ?? false, + audio: props.modalities.audio ?? false + }; + + this.models = this.models.map((model) => + model.model === modelId ? { ...model, modalities } : model + ); + + // Increment version to trigger reactivity + this.propsCacheVersion++; + } catch (error) { + console.warn(`Failed to update modalities for model ${modelId}:`, error); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Model Selection + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Select a model for new conversations + */ + async selectModelById(modelId: string): Promise<void> { + if (!modelId || this.updating) return; + if (this.selectedModelId === modelId) return; + + const option = this.models.find((model) => model.id === modelId); + if (!option) throw new Error('Selected model is not available'); + + this.updating = true; + this.error = null; + + try { + this.selectedModelId = option.id; + this.selectedModelName = option.model; + } finally { + this.updating = false; + } + } + + /** + * Select a model by its model name (used for syncing with conversation model) + * @param modelName - Model name to select (e.g., "unsloth/gemma-3-12b-it-GGUF:latest") + */ + selectModelByName(modelName: string): void { + const option = this.models.find((model) => model.model === modelName); + if (option) { + this.selectedModelId = option.id; + this.selectedModelName = option.model; + } + } + + clearSelection(): void { + this.selectedModelId = null; + this.selectedModelName = null; + } + + findModelByName(modelName: string): ModelOption | null { + return this.models.find((model) => model.model === modelName) ?? null; + } + + findModelById(modelId: string): ModelOption | null { + return this.models.find((model) => model.id === modelId) ?? null; + } + + hasModel(modelName: string): boolean { + return this.models.some((model) => model.model === modelName); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Loading/Unloading Models + // ───────────────────────────────────────────────────────────────────────────── + + /** + * WORKAROUND: Polling for model status after load/unload operations. + * + * Currently, the `/models/load` and `/models/unload` endpoints return success + * before the operation actually completes on the server. This means an immediate + * request to `/models` returns stale status (e.g., "loading" after load request, + * "loaded" after unload request). + * + * TODO: Remove this polling once llama-server properly waits for the operation + * to complete before returning success from `/load` and `/unload` endpoints. + * At that point, a single `fetchRouterModels()` call after the operation will + * be sufficient to get the correct status. + */ + + /** Polling interval in ms for checking model status */ + private static readonly STATUS_POLL_INTERVAL = 500; + /** Maximum polling attempts before giving up */ + private static readonly STATUS_POLL_MAX_ATTEMPTS = 60; // 30 seconds max + + /** + * Poll for expected model status after load/unload operation. + * Keeps polling until the model reaches the expected status or max attempts reached. + * + * @param modelId - Model identifier to check + * @param expectedStatus - Expected status to wait for + * @returns Promise that resolves when expected status is reached + */ + private async pollForModelStatus( + modelId: string, + expectedStatus: ServerModelStatus + ): Promise<void> { + for (let attempt = 0; attempt < ModelsStore.STATUS_POLL_MAX_ATTEMPTS; attempt++) { + await this.fetchRouterModels(); + + const currentStatus = this.getModelStatus(modelId); + if (currentStatus === expectedStatus) { + return; + } + + // Wait before next poll + await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL)); + } + + console.warn( + `Model ${modelId} did not reach expected status ${expectedStatus} after ${ModelsStore.STATUS_POLL_MAX_ATTEMPTS} attempts` + ); + } + + /** + * Load a model (ROUTER mode) + * @param modelId - Model identifier to load + */ + async loadModel(modelId: string): Promise<void> { + if (this.isModelLoaded(modelId)) { + return; + } + + if (this.modelLoadingStates.get(modelId)) return; + + this.modelLoadingStates.set(modelId, true); + this.error = null; + + try { + await ModelsService.load(modelId); + + // Poll until model is loaded + await this.pollForModelStatus(modelId, ServerModelStatus.LOADED); + + await this.updateModelModalities(modelId); + } catch (error) { + this.error = error instanceof Error ? error.message : 'Failed to load model'; + throw error; + } finally { + this.modelLoadingStates.set(modelId, false); + } + } + + /** + * Unload a model (ROUTER mode) + * @param modelId - Model identifier to unload + */ + async unloadModel(modelId: string): Promise<void> { + if (!this.isModelLoaded(modelId)) { + return; + } + + if (this.modelLoadingStates.get(modelId)) return; + + this.modelLoadingStates.set(modelId, true); + this.error = null; + + try { + await ModelsService.unload(modelId); + + await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED); + } catch (error) { + this.error = error instanceof Error ? error.message : 'Failed to unload model'; + throw error; + } finally { + this.modelLoadingStates.set(modelId, false); + } + } + + /** + * Ensure a model is loaded before use + * @param modelId - Model identifier to ensure is loaded + */ + async ensureModelLoaded(modelId: string): Promise<void> { + if (this.isModelLoaded(modelId)) { + return; + } + + await this.loadModel(modelId); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Utilities + // ───────────────────────────────────────────────────────────────────────────── + + private toDisplayName(id: string): string { + const segments = id.split(/\\|\//); + const candidate = segments.pop(); + + return candidate && candidate.trim().length > 0 ? candidate : id; + } + + clear(): void { + this.models = []; + this.routerModels = []; + this.loading = false; + this.updating = false; + this.error = null; + this.selectedModelId = null; + this.selectedModelName = null; + this.modelUsage.clear(); + this.modelLoadingStates.clear(); + this.modelPropsCache.clear(); + this.modelPropsFetching.clear(); + } +} + +export const modelsStore = new ModelsStore(); + +export const modelOptions = () => modelsStore.models; +export const routerModels = () => modelsStore.routerModels; +export const modelsLoading = () => modelsStore.loading; +export const modelsUpdating = () => modelsStore.updating; +export const modelsError = () => modelsStore.error; +export const selectedModelId = () => modelsStore.selectedModelId; +export const selectedModelName = () => modelsStore.selectedModelName; +export const selectedModelOption = () => modelsStore.selectedModel; +export const loadedModelIds = () => modelsStore.loadedModelIds; +export const loadingModelIds = () => modelsStore.loadingModelIds; +export const propsCacheVersion = () => modelsStore.propsCacheVersion; +export const singleModelName = () => modelsStore.singleModelName; +export const selectedModelContextSize = () => modelsStore.selectedModelContextSize; diff --git a/llama.cpp/tools/server/webui/src/lib/stores/persisted.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/persisted.svelte.ts new file mode 100644 index 0000000..1e07f80 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/stores/persisted.svelte.ts @@ -0,0 +1,50 @@ +import { browser } from '$app/environment'; + +type PersistedValue<T> = { + get value(): T; + set value(newValue: T); +}; + +export function persisted<T>(key: string, initialValue: T): PersistedValue<T> { + let value = initialValue; + + if (browser) { + try { + const stored = localStorage.getItem(key); + + if (stored !== null) { + value = JSON.parse(stored) as T; + } + } catch (error) { + console.warn(`Failed to load ${key}:`, error); + } + } + + const persist = (next: T) => { + if (!browser) { + return; + } + + try { + if (next === null || next === undefined) { + localStorage.removeItem(key); + return; + } + + localStorage.setItem(key, JSON.stringify(next)); + } catch (error) { + console.warn(`Failed to persist ${key}:`, error); + } + }; + + return { + get value() { + return value; + }, + + set value(newValue: T) { + value = newValue; + persist(newValue); + } + }; +} diff --git a/llama.cpp/tools/server/webui/src/lib/stores/server.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/server.svelte.ts new file mode 100644 index 0000000..facfd33 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/stores/server.svelte.ts @@ -0,0 +1,140 @@ +import { PropsService } from '$lib/services/props'; +import { ServerRole } from '$lib/enums'; + +/** + * serverStore - Server connection state, configuration, and role detection + * + * This store manages the server connection state and properties fetched from `/props`. + * It provides reactive state for server configuration and role detection. + * + * **Architecture & Relationships:** + * - **PropsService**: Stateless service for fetching `/props` data + * - **serverStore** (this class): Reactive store for server state + * - **modelsStore**: Independent store for model management (uses PropsService directly) + * + * **Key Features:** + * - **Server State**: Connection status, loading, error handling + * - **Role Detection**: MODEL (single model) vs ROUTER (multi-model) + * - **Default Params**: Server-wide generation defaults + */ +class ServerStore { + // ───────────────────────────────────────────────────────────────────────────── + // State + // ───────────────────────────────────────────────────────────────────────────── + + props = $state<ApiLlamaCppServerProps | null>(null); + loading = $state(false); + error = $state<string | null>(null); + role = $state<ServerRole | null>(null); + private fetchPromise: Promise<void> | null = null; + + // ───────────────────────────────────────────────────────────────────────────── + // Getters + // ───────────────────────────────────────────────────────────────────────────── + + get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null { + return this.props?.default_generation_settings?.params || null; + } + + get contextSize(): number | null { + return this.props?.default_generation_settings?.n_ctx ?? null; + } + + get webuiSettings(): Record<string, string | number | boolean> | undefined { + return this.props?.webui_settings; + } + + get isRouterMode(): boolean { + return this.role === ServerRole.ROUTER; + } + + get isModelMode(): boolean { + return this.role === ServerRole.MODEL; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Data Handling + // ───────────────────────────────────────────────────────────────────────────── + + async fetch(): Promise<void> { + if (this.fetchPromise) return this.fetchPromise; + + this.loading = true; + this.error = null; + + const fetchPromise = (async () => { + try { + const props = await PropsService.fetch(); + this.props = props; + this.error = null; + this.detectRole(props); + } catch (error) { + this.error = this.getErrorMessage(error); + console.error('Error fetching server properties:', error); + } finally { + this.loading = false; + this.fetchPromise = null; + } + })(); + + this.fetchPromise = fetchPromise; + await fetchPromise; + } + + private getErrorMessage(error: unknown): string { + if (error instanceof Error) { + const message = error.message || ''; + + if (error.name === 'TypeError' && message.includes('fetch')) { + return 'Server is not running or unreachable'; + } else if (message.includes('ECONNREFUSED')) { + return 'Connection refused - server may be offline'; + } else if (message.includes('ENOTFOUND')) { + return 'Server not found - check server address'; + } else if (message.includes('ETIMEDOUT')) { + return 'Request timed out'; + } else if (message.includes('503')) { + return 'Server temporarily unavailable'; + } else if (message.includes('500')) { + return 'Server error - check server logs'; + } else if (message.includes('404')) { + return 'Server endpoint not found'; + } else if (message.includes('403') || message.includes('401')) { + return 'Access denied'; + } + } + + return 'Failed to connect to server'; + } + + clear(): void { + this.props = null; + this.error = null; + this.loading = false; + this.role = null; + this.fetchPromise = null; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Utilities + // ───────────────────────────────────────────────────────────────────────────── + + private detectRole(props: ApiLlamaCppServerProps): void { + const newRole = props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL; + if (this.role !== newRole) { + this.role = newRole; + console.info(`Server running in ${newRole === ServerRole.ROUTER ? 'ROUTER' : 'MODEL'} mode`); + } + } +} + +export const serverStore = new ServerStore(); + +export const serverProps = () => serverStore.props; +export const serverLoading = () => serverStore.loading; +export const serverError = () => serverStore.error; +export const serverRole = () => serverStore.role; +export const defaultParams = () => serverStore.defaultParams; +export const contextSize = () => serverStore.contextSize; +export const isRouterMode = () => serverStore.isRouterMode; +export const isModelMode = () => serverStore.isModelMode; diff --git a/llama.cpp/tools/server/webui/src/lib/stores/settings.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/settings.svelte.ts new file mode 100644 index 0000000..cda940b --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/stores/settings.svelte.ts @@ -0,0 +1,421 @@ +/** + * settingsStore - Application configuration and theme management + * + * This store manages all application settings including AI model parameters, UI preferences, + * and theme configuration. It provides persistent storage through localStorage with reactive + * state management using Svelte 5 runes. + * + * **Architecture & Relationships:** + * - **settingsStore** (this class): Configuration state management + * - Manages AI model parameters (temperature, max tokens, etc.) + * - Handles theme switching and persistence + * - Provides localStorage synchronization + * - Offers reactive configuration access + * + * - **ChatService**: Reads model parameters for API requests + * - **UI Components**: Subscribe to theme and configuration changes + * + * **Key Features:** + * - **Model Parameters**: Temperature, max tokens, top-p, top-k, repeat penalty + * - **Theme Management**: Auto, light, dark theme switching + * - **Persistence**: Automatic localStorage synchronization + * - **Reactive State**: Svelte 5 runes for automatic UI updates + * - **Default Handling**: Graceful fallback to defaults for missing settings + * - **Batch Updates**: Efficient multi-setting updates + * - **Reset Functionality**: Restore defaults for individual or all settings + * + * **Configuration Categories:** + * - Generation parameters (temperature, tokens, sampling) + * - UI preferences (theme, display options) + * - System settings (model selection, prompts) + * - Advanced options (seed, penalties, context handling) + */ + +import { browser } from '$app/environment'; +import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config'; +import { ParameterSyncService } from '$lib/services/parameter-sync'; +import { serverStore } from '$lib/stores/server.svelte'; +import { + configToParameterRecord, + normalizeFloatingPoint, + getConfigValue, + setConfigValue +} from '$lib/utils'; +import { + CONFIG_LOCALSTORAGE_KEY, + USER_OVERRIDES_LOCALSTORAGE_KEY +} from '$lib/constants/localstorage-keys'; + +class SettingsStore { + // ───────────────────────────────────────────────────────────────────────────── + // State + // ───────────────────────────────────────────────────────────────────────────── + + config = $state<SettingsConfigType>({ ...SETTING_CONFIG_DEFAULT }); + theme = $state<string>('auto'); + isInitialized = $state(false); + userOverrides = $state<Set<string>>(new Set()); + + // ───────────────────────────────────────────────────────────────────────────── + // Utilities (private helpers) + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Helper method to get server defaults with null safety + * Centralizes the pattern of getting and extracting server defaults + */ + private getServerDefaults(): Record<string, string | number | boolean> { + const serverParams = serverStore.defaultParams; + const webuiSettings = serverStore.webuiSettings; + return ParameterSyncService.extractServerDefaults(serverParams, webuiSettings); + } + + constructor() { + if (browser) { + this.initialize(); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Lifecycle + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Initialize the settings store by loading from localStorage + */ + initialize() { + try { + this.loadConfig(); + this.loadTheme(); + this.isInitialized = true; + } catch (error) { + console.error('Failed to initialize settings store:', error); + } + } + + /** + * Load configuration from localStorage + * Returns default values for missing keys to prevent breaking changes + */ + private loadConfig() { + if (!browser) return; + + try { + const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY); + const savedVal = JSON.parse(storedConfigRaw || '{}'); + + // Merge with defaults to prevent breaking changes + this.config = { + ...SETTING_CONFIG_DEFAULT, + ...savedVal + }; + + // Load user overrides + const savedOverrides = JSON.parse( + localStorage.getItem(USER_OVERRIDES_LOCALSTORAGE_KEY) || '[]' + ); + this.userOverrides = new Set(savedOverrides); + } catch (error) { + console.warn('Failed to parse config from localStorage, using defaults:', error); + this.config = { ...SETTING_CONFIG_DEFAULT }; + this.userOverrides = new Set(); + } + } + + /** + * Load theme from localStorage + */ + private loadTheme() { + if (!browser) return; + + this.theme = localStorage.getItem('theme') || 'auto'; + } + // ───────────────────────────────────────────────────────────────────────────── + // Config Updates + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Update a specific configuration setting + * @param key - The configuration key to update + * @param value - The new value for the configuration key + */ + updateConfig<K extends keyof SettingsConfigType>(key: K, value: SettingsConfigType[K]): void { + this.config[key] = value; + + if (ParameterSyncService.canSyncParameter(key as string)) { + const propsDefaults = this.getServerDefaults(); + const propsDefault = propsDefaults[key as string]; + + if (propsDefault !== undefined) { + const normalizedValue = normalizeFloatingPoint(value); + const normalizedDefault = normalizeFloatingPoint(propsDefault); + + if (normalizedValue === normalizedDefault) { + this.userOverrides.delete(key as string); + } else { + this.userOverrides.add(key as string); + } + } + } + + this.saveConfig(); + } + + /** + * Update multiple configuration settings at once + * @param updates - Object containing the configuration updates + */ + updateMultipleConfig(updates: Partial<SettingsConfigType>) { + Object.assign(this.config, updates); + + const propsDefaults = this.getServerDefaults(); + + for (const [key, value] of Object.entries(updates)) { + if (ParameterSyncService.canSyncParameter(key)) { + const propsDefault = propsDefaults[key]; + + if (propsDefault !== undefined) { + const normalizedValue = normalizeFloatingPoint(value); + const normalizedDefault = normalizeFloatingPoint(propsDefault); + + if (normalizedValue === normalizedDefault) { + this.userOverrides.delete(key); + } else { + this.userOverrides.add(key); + } + } + } + } + + this.saveConfig(); + } + + /** + * Save the current configuration to localStorage + */ + private saveConfig() { + if (!browser) return; + + try { + localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(this.config)); + + localStorage.setItem( + USER_OVERRIDES_LOCALSTORAGE_KEY, + JSON.stringify(Array.from(this.userOverrides)) + ); + } catch (error) { + console.error('Failed to save config to localStorage:', error); + } + } + + /** + * Update the theme setting + * @param newTheme - The new theme value + */ + updateTheme(newTheme: string) { + this.theme = newTheme; + this.saveTheme(); + } + + /** + * Save the current theme to localStorage + */ + private saveTheme() { + if (!browser) return; + + try { + if (this.theme === 'auto') { + localStorage.removeItem('theme'); + } else { + localStorage.setItem('theme', this.theme); + } + } catch (error) { + console.error('Failed to save theme to localStorage:', error); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Reset + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Reset configuration to defaults + */ + resetConfig() { + this.config = { ...SETTING_CONFIG_DEFAULT }; + this.saveConfig(); + } + + /** + * Reset theme to auto + */ + resetTheme() { + this.theme = 'auto'; + this.saveTheme(); + } + + /** + * Reset all settings to defaults + */ + resetAll() { + this.resetConfig(); + this.resetTheme(); + } + + /** + * Reset a parameter to server default (or webui default if no server default) + */ + resetParameterToServerDefault(key: string): void { + const serverDefaults = this.getServerDefaults(); + + if (serverDefaults[key] !== undefined) { + const value = normalizeFloatingPoint(serverDefaults[key]); + + this.config[key as keyof SettingsConfigType] = + value as SettingsConfigType[keyof SettingsConfigType]; + } else { + if (key in SETTING_CONFIG_DEFAULT) { + const defaultValue = getConfigValue(SETTING_CONFIG_DEFAULT, key); + + setConfigValue(this.config, key, defaultValue); + } + } + + this.userOverrides.delete(key); + this.saveConfig(); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Server Sync + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Initialize settings with props defaults when server properties are first loaded + * This sets up the default values from /props endpoint + */ + syncWithServerDefaults(): void { + const propsDefaults = this.getServerDefaults(); + + if (Object.keys(propsDefaults).length === 0) { + console.warn('No server defaults available for initialization'); + + return; + } + + for (const [key, propsValue] of Object.entries(propsDefaults)) { + const currentValue = getConfigValue(this.config, key); + + const normalizedCurrent = normalizeFloatingPoint(currentValue); + const normalizedDefault = normalizeFloatingPoint(propsValue); + + if (normalizedCurrent === normalizedDefault) { + this.userOverrides.delete(key); + setConfigValue(this.config, key, propsValue); + } else if (!this.userOverrides.has(key)) { + setConfigValue(this.config, key, propsValue); + } + } + + this.saveConfig(); + console.log('Settings initialized with props defaults:', propsDefaults); + console.log('Current user overrides after sync:', Array.from(this.userOverrides)); + } + + /** + * Reset all parameters to their default values (from props) + * This is used by the "Reset to Default" functionality + * Prioritizes server defaults from /props, falls back to webui defaults + */ + forceSyncWithServerDefaults(): void { + const propsDefaults = this.getServerDefaults(); + const syncableKeys = ParameterSyncService.getSyncableParameterKeys(); + + for (const key of syncableKeys) { + if (propsDefaults[key] !== undefined) { + const normalizedValue = normalizeFloatingPoint(propsDefaults[key]); + + setConfigValue(this.config, key, normalizedValue); + } else { + if (key in SETTING_CONFIG_DEFAULT) { + const defaultValue = getConfigValue(SETTING_CONFIG_DEFAULT, key); + + setConfigValue(this.config, key, defaultValue); + } + } + + this.userOverrides.delete(key); + } + + this.saveConfig(); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Utilities + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Get a specific configuration value + * @param key - The configuration key to get + * @returns The configuration value + */ + getConfig<K extends keyof SettingsConfigType>(key: K): SettingsConfigType[K] { + return this.config[key]; + } + + /** + * Get the entire configuration object + * @returns The complete configuration object + */ + getAllConfig(): SettingsConfigType { + return { ...this.config }; + } + + canSyncParameter(key: string): boolean { + return ParameterSyncService.canSyncParameter(key); + } + + /** + * Get parameter information including source for a specific parameter + */ + getParameterInfo(key: string) { + const propsDefaults = this.getServerDefaults(); + const currentValue = getConfigValue(this.config, key); + + return ParameterSyncService.getParameterInfo( + key, + currentValue ?? '', + propsDefaults, + this.userOverrides + ); + } + + /** + * Get diff between current settings and server defaults + */ + getParameterDiff() { + const serverDefaults = this.getServerDefaults(); + if (Object.keys(serverDefaults).length === 0) return {}; + + const configAsRecord = configToParameterRecord( + this.config, + ParameterSyncService.getSyncableParameterKeys() + ); + + return ParameterSyncService.createParameterDiff(configAsRecord, serverDefaults); + } + + /** + * Clear all user overrides (for debugging) + */ + clearAllUserOverrides(): void { + this.userOverrides.clear(); + this.saveConfig(); + console.log('Cleared all user overrides'); + } +} + +export const settingsStore = new SettingsStore(); + +export const config = () => settingsStore.config; +export const theme = () => settingsStore.theme; +export const isInitialized = () => settingsStore.isInitialized; diff --git a/llama.cpp/tools/server/webui/src/lib/types/api.d.ts b/llama.cpp/tools/server/webui/src/lib/types/api.d.ts new file mode 100644 index 0000000..714509f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/types/api.d.ts @@ -0,0 +1,430 @@ +import type { ServerModelStatus, ServerRole } from '$lib/enums'; +import type { ChatMessagePromptProgress } from './chat'; + +export interface ApiChatMessageContentPart { + type: 'text' | 'image_url' | 'input_audio'; + text?: string; + image_url?: { + url: string; + }; + input_audio?: { + data: string; + format: 'wav' | 'mp3'; + }; +} + +export interface ApiContextSizeError { + code: number; + message: string; + type: 'exceed_context_size_error'; + n_prompt_tokens: number; + n_ctx: number; +} + +export interface ApiErrorResponse { + error: + | ApiContextSizeError + | { + code: number; + message: string; + type?: string; + }; +} + +export interface ApiChatMessageData { + role: ChatRole; + content: string | ApiChatMessageContentPart[]; + timestamp?: number; +} + +/** + * Model status object from /models endpoint + */ +export interface ApiModelStatus { + /** Status value: loaded, unloaded, loading, failed */ + value: ServerModelStatus; + /** Command line arguments used when loading (only for loaded models) */ + args?: string[]; +} + +/** + * Model entry from /models endpoint (ROUTER mode) + * Based on actual API response structure + */ +export interface ApiModelDataEntry { + /** Model identifier (e.g., "ggml-org/Qwen2.5-Omni-7B-GGUF:latest") */ + id: string; + /** Model name (optional, usually same as id - not always returned by API) */ + name?: string; + /** Object type, always "model" */ + object: string; + /** Owner, usually "llamacpp" */ + owned_by: string; + /** Creation timestamp */ + created: number; + /** Whether model files are in HuggingFace cache */ + in_cache: boolean; + /** Path to model manifest file */ + path: string; + /** Current status of the model */ + status: ApiModelStatus; + /** Legacy meta field (may be present in older responses) */ + meta?: Record<string, unknown> | null; +} + +export interface ApiModelDetails { + name: string; + model: string; + modified_at?: string; + size?: string | number; + digest?: string; + type?: string; + description?: string; + tags?: string[]; + capabilities?: string[]; + parameters?: string; + details?: { + parent_model?: string; + format?: string; + family?: string; + families?: string[]; + parameter_size?: string; + quantization_level?: string; + }; +} + +export interface ApiModelListResponse { + object: string; + data: ApiModelDataEntry[]; + models?: ApiModelDetails[]; +} + +export interface ApiLlamaCppServerProps { + default_generation_settings: { + id: number; + id_task: number; + n_ctx: number; + speculative: boolean; + is_processing: boolean; + params: { + n_predict: number; + seed: number; + temperature: number; + dynatemp_range: number; + dynatemp_exponent: number; + top_k: number; + top_p: number; + min_p: number; + top_n_sigma: number; + xtc_probability: number; + xtc_threshold: number; + typ_p: number; + repeat_last_n: number; + repeat_penalty: number; + presence_penalty: number; + frequency_penalty: number; + dry_multiplier: number; + dry_base: number; + dry_allowed_length: number; + dry_penalty_last_n: number; + dry_sequence_breakers: string[]; + mirostat: number; + mirostat_tau: number; + mirostat_eta: number; + stop: string[]; + max_tokens: number; + n_keep: number; + n_discard: number; + ignore_eos: boolean; + stream: boolean; + logit_bias: Array<[number, number]>; + n_probs: number; + min_keep: number; + grammar: string; + grammar_lazy: boolean; + grammar_triggers: string[]; + preserved_tokens: number[]; + chat_format: string; + reasoning_format: string; + reasoning_in_content: boolean; + thinking_forced_open: boolean; + samplers: string[]; + backend_sampling: boolean; + 'speculative.n_max': number; + 'speculative.n_min': number; + 'speculative.p_min': number; + timings_per_token: boolean; + post_sampling_probs: boolean; + lora: Array<{ name: string; scale: number }>; + }; + prompt: string; + next_token: { + has_next_token: boolean; + has_new_line: boolean; + n_remain: number; + n_decoded: number; + stopping_word: string; + }; + }; + total_slots: number; + model_path: string; + role: ServerRole; + modalities: { + vision: boolean; + audio: boolean; + }; + chat_template: string; + bos_token: string; + eos_token: string; + build_info: string; + webui_settings?: Record<string, string | number | boolean>; +} + +export interface ApiChatCompletionRequest { + messages: Array<{ + role: ChatRole; + content: string | ApiChatMessageContentPart[]; + }>; + stream?: boolean; + model?: string; + return_progress?: boolean; + // Reasoning parameters + reasoning_format?: string; + // Generation parameters + temperature?: number; + max_tokens?: number; + // Sampling parameters + dynatemp_range?: number; + dynatemp_exponent?: number; + top_k?: number; + top_p?: number; + min_p?: number; + xtc_probability?: number; + xtc_threshold?: number; + typ_p?: number; + // Penalty parameters + repeat_last_n?: number; + repeat_penalty?: number; + presence_penalty?: number; + frequency_penalty?: number; + dry_multiplier?: number; + dry_base?: number; + dry_allowed_length?: number; + dry_penalty_last_n?: number; + // Sampler configuration + samplers?: string[]; + backend_sampling?: boolean; + // Custom parameters (JSON string) + custom?: Record<string, unknown>; + timings_per_token?: boolean; +} + +export interface ApiChatCompletionToolCallFunctionDelta { + name?: string; + arguments?: string; +} + +export interface ApiChatCompletionToolCallDelta { + index?: number; + id?: string; + type?: string; + function?: ApiChatCompletionToolCallFunctionDelta; +} + +export interface ApiChatCompletionToolCall extends ApiChatCompletionToolCallDelta { + function?: ApiChatCompletionToolCallFunctionDelta & { arguments?: string }; +} + +export interface ApiChatCompletionStreamChunk { + object?: string; + model?: string; + choices: Array<{ + model?: string; + metadata?: { model?: string }; + delta: { + content?: string; + reasoning_content?: string; + model?: string; + tool_calls?: ApiChatCompletionToolCallDelta[]; + }; + }>; + timings?: { + prompt_n?: number; + prompt_ms?: number; + predicted_n?: number; + predicted_ms?: number; + cache_n?: number; + }; + prompt_progress?: ChatMessagePromptProgress; +} + +export interface ApiChatCompletionResponse { + model?: string; + choices: Array<{ + model?: string; + metadata?: { model?: string }; + message: { + content: string; + reasoning_content?: string; + model?: string; + tool_calls?: ApiChatCompletionToolCallDelta[]; + }; + }>; +} + +export interface ApiSlotData { + id: number; + id_task: number; + n_ctx: number; + speculative: boolean; + is_processing: boolean; + params: { + n_predict: number; + seed: number; + temperature: number; + dynatemp_range: number; + dynatemp_exponent: number; + top_k: number; + top_p: number; + min_p: number; + top_n_sigma: number; + xtc_probability: number; + xtc_threshold: number; + typical_p: number; + repeat_last_n: number; + repeat_penalty: number; + presence_penalty: number; + frequency_penalty: number; + dry_multiplier: number; + dry_base: number; + dry_allowed_length: number; + dry_penalty_last_n: number; + mirostat: number; + mirostat_tau: number; + mirostat_eta: number; + max_tokens: number; + n_keep: number; + n_discard: number; + ignore_eos: boolean; + stream: boolean; + n_probs: number; + min_keep: number; + chat_format: string; + reasoning_format: string; + reasoning_in_content: boolean; + thinking_forced_open: boolean; + samplers: string[]; + backend_sampling: boolean; + 'speculative.n_max': number; + 'speculative.n_min': number; + 'speculative.p_min': number; + timings_per_token: boolean; + post_sampling_probs: boolean; + lora: Array<{ name: string; scale: number }>; + }; + next_token: { + has_next_token: boolean; + has_new_line: boolean; + n_remain: number; + n_decoded: number; + }; +} + +export interface ApiProcessingState { + status: 'initializing' | 'generating' | 'preparing' | 'idle'; + tokensDecoded: number; + tokensRemaining: number; + contextUsed: number; + contextTotal: number; + outputTokensUsed: number; // Total output tokens (thinking + regular content) + outputTokensMax: number; // Max output tokens allowed + temperature: number; + topP: number; + speculative: boolean; + hasNextToken: boolean; + tokensPerSecond?: number; + // Progress information from prompt_progress + progressPercent?: number; + promptProgress?: ChatMessagePromptProgress; + promptTokens?: number; + promptMs?: number; + cacheTokens?: number; +} + +/** + * Router model metadata - extended from ApiModelDataEntry with additional router-specific fields + * @deprecated Use ApiModelDataEntry instead - the /models endpoint returns this structure directly + */ +export interface ApiRouterModelMeta { + /** Model identifier (e.g., "ggml-org/Qwen2.5-Omni-7B-GGUF:latest") */ + name: string; + /** Path to model file or manifest */ + path: string; + /** Optional path to multimodal projector */ + path_mmproj?: string; + /** Whether model is in HuggingFace cache */ + in_cache: boolean; + /** Port where model instance is running (0 if not loaded) */ + port?: number; + /** Current status of the model */ + status: ApiModelStatus; + /** Error message if status is FAILED */ + error?: string; +} + +/** + * Request to load a model + */ +export interface ApiRouterModelsLoadRequest { + model: string; +} + +/** + * Response from loading a model + */ +export interface ApiRouterModelsLoadResponse { + success: boolean; + error?: string; +} + +/** + * Request to check model status + */ +export interface ApiRouterModelsStatusRequest { + model: string; +} + +/** + * Response with model status + */ +export interface ApiRouterModelsStatusResponse { + model: string; + status: ModelStatus; + port?: number; + error?: string; +} + +/** + * Response with list of all models from /models endpoint + * Note: This is the same as ApiModelListResponse - the endpoint returns the same structure + * regardless of server mode (MODEL or ROUTER) + */ +export interface ApiRouterModelsListResponse { + object: string; + data: ApiModelDataEntry[]; +} + +/** + * Request to unload a model + */ +export interface ApiRouterModelsUnloadRequest { + model: string; +} + +/** + * Response from unloading a model + */ +export interface ApiRouterModelsUnloadResponse { + success: boolean; + error?: string; +} diff --git a/llama.cpp/tools/server/webui/src/lib/types/chat.d.ts b/llama.cpp/tools/server/webui/src/lib/types/chat.d.ts new file mode 100644 index 0000000..0e706b7 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/types/chat.d.ts @@ -0,0 +1,55 @@ +export type ChatMessageType = 'root' | 'text' | 'think' | 'system'; +export type ChatRole = 'user' | 'assistant' | 'system'; + +export interface ChatUploadedFile { + id: string; + name: string; + size: number; + type: string; + file: File; + preview?: string; + textContent?: string; +} + +export interface ChatAttachmentDisplayItem { + id: string; + name: string; + size?: number; + preview?: string; + isImage: boolean; + uploadedFile?: ChatUploadedFile; + attachment?: DatabaseMessageExtra; + attachmentIndex?: number; + textContent?: string; +} + +export interface ChatAttachmentPreviewItem { + uploadedFile?: ChatUploadedFile; + attachment?: DatabaseMessageExtra; + preview?: string; + name?: string; + size?: number; + textContent?: string; +} + +export interface ChatMessageSiblingInfo { + message: DatabaseMessage; + siblingIds: string[]; + currentIndex: number; + totalSiblings: number; +} + +export interface ChatMessagePromptProgress { + cache: number; + processed: number; + time_ms: number; + total: number; +} + +export interface ChatMessageTimings { + cache_n?: number; + predicted_ms?: number; + predicted_n?: number; + prompt_ms?: number; + prompt_n?: number; +} diff --git a/llama.cpp/tools/server/webui/src/lib/types/database.d.ts b/llama.cpp/tools/server/webui/src/lib/types/database.d.ts new file mode 100644 index 0000000..1a336e0 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/types/database.d.ts @@ -0,0 +1,85 @@ +import type { ChatMessageTimings, ChatRole, ChatMessageType } from '$lib/types/chat'; +import { AttachmentType } from '$lib/enums'; + +export interface DatabaseConversation { + currNode: string | null; + id: string; + lastModified: number; + name: string; +} + +export interface DatabaseMessageExtraAudioFile { + type: AttachmentType.AUDIO; + name: string; + base64Data: string; + mimeType: string; +} + +export interface DatabaseMessageExtraImageFile { + type: AttachmentType.IMAGE; + name: string; + base64Url: string; +} + +/** + * Legacy format from old webui - pasted content was stored as "context" type + * @deprecated Use DatabaseMessageExtraTextFile instead + */ +export interface DatabaseMessageExtraLegacyContext { + type: AttachmentType.LEGACY_CONTEXT; + name: string; + content: string; +} + +export interface DatabaseMessageExtraPdfFile { + type: AttachmentType.PDF; + base64Data: string; + name: string; + content: string; // Text content extracted from PDF + images?: string[]; // Optional: PDF pages as base64 images + processedAsImages: boolean; // Whether PDF was processed as images +} + +export interface DatabaseMessageExtraTextFile { + type: AttachmentType.TEXT; + name: string; + content: string; +} + +export type DatabaseMessageExtra = + | DatabaseMessageExtraImageFile + | DatabaseMessageExtraTextFile + | DatabaseMessageExtraAudioFile + | DatabaseMessageExtraPdfFile + | DatabaseMessageExtraLegacyContext; + +export interface DatabaseMessage { + id: string; + convId: string; + type: ChatMessageType; + timestamp: number; + role: ChatRole; + content: string; + parent: string; + thinking: string; + toolCalls?: string; + children: string[]; + extra?: DatabaseMessageExtra[]; + timings?: ChatMessageTimings; + model?: string; +} + +/** + * Represents a single conversation with its associated messages, + * typically used for import/export operations. + */ +export type ExportedConversation = { + conv: DatabaseConversation; + messages: DatabaseMessage[]; +}; + +/** + * Type representing one or more exported conversations. + * Can be a single conversation object or an array of them. + */ +export type ExportedConversations = ExportedConversation | ExportedConversation[]; diff --git a/llama.cpp/tools/server/webui/src/lib/types/index.ts b/llama.cpp/tools/server/webui/src/lib/types/index.ts new file mode 100644 index 0000000..2a21c6d --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/types/index.ts @@ -0,0 +1,70 @@ +/** + * Unified exports for all type definitions + * Import types from '$lib/types' for cleaner imports + */ + +// API types +export type { + ApiChatMessageContentPart, + ApiContextSizeError, + ApiErrorResponse, + ApiChatMessageData, + ApiModelStatus, + ApiModelDataEntry, + ApiModelDetails, + ApiModelListResponse, + ApiLlamaCppServerProps, + ApiChatCompletionRequest, + ApiChatCompletionToolCallFunctionDelta, + ApiChatCompletionToolCallDelta, + ApiChatCompletionToolCall, + ApiChatCompletionStreamChunk, + ApiChatCompletionResponse, + ApiSlotData, + ApiProcessingState, + ApiRouterModelMeta, + ApiRouterModelsLoadRequest, + ApiRouterModelsLoadResponse, + ApiRouterModelsStatusRequest, + ApiRouterModelsStatusResponse, + ApiRouterModelsListResponse, + ApiRouterModelsUnloadRequest, + ApiRouterModelsUnloadResponse +} from './api'; + +// Chat types +export type { + ChatMessageType, + ChatRole, + ChatUploadedFile, + ChatAttachmentDisplayItem, + ChatAttachmentPreviewItem, + ChatMessageSiblingInfo, + ChatMessagePromptProgress, + ChatMessageTimings +} from './chat'; + +// Database types +export type { + DatabaseConversation, + DatabaseMessageExtraAudioFile, + DatabaseMessageExtraImageFile, + DatabaseMessageExtraLegacyContext, + DatabaseMessageExtraPdfFile, + DatabaseMessageExtraTextFile, + DatabaseMessageExtra, + DatabaseMessage, + ExportedConversation, + ExportedConversations +} from './database'; + +// Model types +export type { ModelModalities, ModelOption } from './models'; + +// Settings types +export type { + SettingsConfigValue, + SettingsFieldConfig, + SettingsChatServiceOptions, + SettingsConfigType +} from './settings'; diff --git a/llama.cpp/tools/server/webui/src/lib/types/models.d.ts b/llama.cpp/tools/server/webui/src/lib/types/models.d.ts new file mode 100644 index 0000000..ef44a2c --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/types/models.d.ts @@ -0,0 +1,21 @@ +import type { ApiModelDataEntry, ApiModelDetails } from '$lib/types/api'; + +/** + * Model modalities - vision and audio capabilities + */ +export interface ModelModalities { + vision: boolean; + audio: boolean; +} + +export interface ModelOption { + id: string; + name: string; + model: string; + description?: string; + capabilities: string[]; + /** Model modalities from /props endpoint */ + modalities?: ModelModalities; + details?: ApiModelDetails['details']; + meta?: ApiModelDataEntry['meta']; +} diff --git a/llama.cpp/tools/server/webui/src/lib/types/settings.d.ts b/llama.cpp/tools/server/webui/src/lib/types/settings.d.ts new file mode 100644 index 0000000..38b3047 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/types/settings.d.ts @@ -0,0 +1,67 @@ +import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config'; +import type { ChatMessageTimings } from './chat'; + +export type SettingsConfigValue = string | number | boolean; + +export interface SettingsFieldConfig { + key: string; + label: string; + type: 'input' | 'textarea' | 'checkbox' | 'select'; + isExperimental?: boolean; + help?: string; + options?: Array<{ value: string; label: string; icon?: typeof import('@lucide/svelte').Icon }>; +} + +export interface SettingsChatServiceOptions { + stream?: boolean; + // Model (required in ROUTER mode, optional in MODEL mode) + model?: string; + // System message to inject + systemMessage?: string; + // Disable reasoning format (use 'none' instead of 'auto') + disableReasoningFormat?: boolean; + // Generation parameters + temperature?: number; + max_tokens?: number; + // Sampling parameters + dynatemp_range?: number; + dynatemp_exponent?: number; + top_k?: number; + top_p?: number; + min_p?: number; + xtc_probability?: number; + xtc_threshold?: number; + typ_p?: number; + // Penalty parameters + repeat_last_n?: number; + repeat_penalty?: number; + presence_penalty?: number; + frequency_penalty?: number; + dry_multiplier?: number; + dry_base?: number; + dry_allowed_length?: number; + dry_penalty_last_n?: number; + // Sampler configuration + samplers?: string | string[]; + backend_sampling?: boolean; + // Custom parameters + custom?: string; + timings_per_token?: boolean; + // Callbacks + onChunk?: (chunk: string) => void; + onReasoningChunk?: (chunk: string) => void; + onToolCallChunk?: (chunk: string) => void; + onModel?: (model: string) => void; + onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void; + onComplete?: ( + response: string, + reasoningContent?: string, + timings?: ChatMessageTimings, + toolCalls?: string + ) => void; + onError?: (error: Error) => void; +} + +export type SettingsConfigType = typeof SETTING_CONFIG_DEFAULT & { + [key: string]: SettingsConfigValue; +}; diff --git a/llama.cpp/tools/server/webui/src/lib/utils/api-headers.ts b/llama.cpp/tools/server/webui/src/lib/utils/api-headers.ts new file mode 100644 index 0000000..77ce3e8 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/api-headers.ts @@ -0,0 +1,22 @@ +import { config } from '$lib/stores/settings.svelte'; + +/** + * Get authorization headers for API requests + * Includes Bearer token if API key is configured + */ +export function getAuthHeaders(): Record<string, string> { + const currentConfig = config(); + const apiKey = currentConfig.apiKey?.toString().trim(); + + return apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; +} + +/** + * Get standard JSON headers with optional authorization + */ +export function getJsonHeaders(): Record<string, string> { + return { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/api-key-validation.ts b/llama.cpp/tools/server/webui/src/lib/utils/api-key-validation.ts new file mode 100644 index 0000000..948b7d7 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/api-key-validation.ts @@ -0,0 +1,45 @@ +import { base } from '$app/paths'; +import { error } from '@sveltejs/kit'; +import { browser } from '$app/environment'; +import { config } from '$lib/stores/settings.svelte'; + +/** + * Validates API key by making a request to the server props endpoint + * Throws SvelteKit errors for authentication failures or server issues + */ +export async function validateApiKey(fetch: typeof globalThis.fetch): Promise<void> { + if (!browser) { + return; + } + + try { + const apiKey = config().apiKey; + + const headers: Record<string, string> = { + 'Content-Type': 'application/json' + }; + + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + const response = await fetch(`${base}/props`, { headers }); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw error(401, 'Access denied'); + } + + console.warn(`Server responded with status ${response.status} during API key validation`); + return; + } + } catch (err) { + // If it's already a SvelteKit error, re-throw it + if (err && typeof err === 'object' && 'status' in err) { + throw err; + } + + // Network or other errors + console.warn('Cannot connect to server for API key validation:', err); + } +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/attachment-display.ts b/llama.cpp/tools/server/webui/src/lib/utils/attachment-display.ts new file mode 100644 index 0000000..750aaa3 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/attachment-display.ts @@ -0,0 +1,61 @@ +import { FileTypeCategory } from '$lib/enums'; +import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils'; + +export interface AttachmentDisplayItemsOptions { + uploadedFiles?: ChatUploadedFile[]; + attachments?: DatabaseMessageExtra[]; +} + +/** + * Gets the file type category from an uploaded file, checking both MIME type and extension + */ +function getUploadedFileCategory(file: ChatUploadedFile): FileTypeCategory | null { + const categoryByMime = getFileTypeCategory(file.type); + + if (categoryByMime) { + return categoryByMime; + } + + return getFileTypeCategoryByExtension(file.name); +} + +/** + * Creates a unified list of display items from uploaded files and stored attachments. + * Items are returned in reverse order (newest first). + */ +export function getAttachmentDisplayItems( + options: AttachmentDisplayItemsOptions +): ChatAttachmentDisplayItem[] { + const { uploadedFiles = [], attachments = [] } = options; + const items: ChatAttachmentDisplayItem[] = []; + + // Add uploaded files (ChatForm) + for (const file of uploadedFiles) { + items.push({ + id: file.id, + name: file.name, + size: file.size, + preview: file.preview, + isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE, + uploadedFile: file, + textContent: file.textContent + }); + } + + // Add stored attachments (ChatMessage) + for (const [index, attachment] of attachments.entries()) { + const isImage = isImageFile(attachment); + + items.push({ + id: `attachment-${index}`, + name: attachment.name, + preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined, + isImage, + attachment, + attachmentIndex: index, + textContent: 'content' in attachment ? attachment.content : undefined + }); + } + + return items.reverse(); +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/attachment-type.ts b/llama.cpp/tools/server/webui/src/lib/utils/attachment-type.ts new file mode 100644 index 0000000..9e9f096 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/attachment-type.ts @@ -0,0 +1,105 @@ +import { AttachmentType, FileTypeCategory } from '$lib/enums'; +import { getFileTypeCategory, getFileTypeCategoryByExtension } from '$lib/utils'; + +/** + * Gets the file type category from an uploaded file, checking both MIME type and extension + * @param uploadedFile - The uploaded file to check + * @returns The file type category or null if not recognized + */ +function getUploadedFileCategory(uploadedFile: ChatUploadedFile): FileTypeCategory | null { + // First try MIME type + const categoryByMime = getFileTypeCategory(uploadedFile.type); + + if (categoryByMime) { + return categoryByMime; + } + + // Fallback to extension (browsers don't always provide correct MIME types) + return getFileTypeCategoryByExtension(uploadedFile.name); +} + +/** + * Determines if an attachment or uploaded file is a text file + * @param uploadedFile - Optional uploaded file + * @param attachment - Optional database attachment + * @returns true if the file is a text file + */ +export function isTextFile( + attachment?: DatabaseMessageExtra, + uploadedFile?: ChatUploadedFile +): boolean { + if (uploadedFile) { + return getUploadedFileCategory(uploadedFile) === FileTypeCategory.TEXT; + } + + if (attachment) { + return ( + attachment.type === AttachmentType.TEXT || attachment.type === AttachmentType.LEGACY_CONTEXT + ); + } + + return false; +} + +/** + * Determines if an attachment or uploaded file is an image + * @param uploadedFile - Optional uploaded file + * @param attachment - Optional database attachment + * @returns true if the file is an image + */ +export function isImageFile( + attachment?: DatabaseMessageExtra, + uploadedFile?: ChatUploadedFile +): boolean { + if (uploadedFile) { + return getUploadedFileCategory(uploadedFile) === FileTypeCategory.IMAGE; + } + + if (attachment) { + return attachment.type === AttachmentType.IMAGE; + } + + return false; +} + +/** + * Determines if an attachment or uploaded file is a PDF + * @param uploadedFile - Optional uploaded file + * @param attachment - Optional database attachment + * @returns true if the file is a PDF + */ +export function isPdfFile( + attachment?: DatabaseMessageExtra, + uploadedFile?: ChatUploadedFile +): boolean { + if (uploadedFile) { + return getUploadedFileCategory(uploadedFile) === FileTypeCategory.PDF; + } + + if (attachment) { + return attachment.type === AttachmentType.PDF; + } + + return false; +} + +/** + * Determines if an attachment or uploaded file is an audio file + * @param uploadedFile - Optional uploaded file + * @param attachment - Optional database attachment + * @returns true if the file is an audio file + */ +export function isAudioFile( + attachment?: DatabaseMessageExtra, + uploadedFile?: ChatUploadedFile +): boolean { + if (uploadedFile) { + return getUploadedFileCategory(uploadedFile) === FileTypeCategory.AUDIO; + } + + if (attachment) { + return attachment.type === AttachmentType.AUDIO; + } + + return false; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/audio-recording.ts b/llama.cpp/tools/server/webui/src/lib/utils/audio-recording.ts new file mode 100644 index 0000000..2a21985 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/audio-recording.ts @@ -0,0 +1,226 @@ +import { MimeTypeAudio } from '$lib/enums'; + +/** + * AudioRecorder - Browser-based audio recording with MediaRecorder API + * + * This class provides a complete audio recording solution using the browser's MediaRecorder API. + * It handles microphone access, recording state management, and audio format optimization. + * + * **Features:** + * - Automatic microphone permission handling + * - Audio enhancement (echo cancellation, noise suppression, auto gain) + * - Multiple format support with fallback (WAV, WebM, MP4, AAC) + * - Real-time recording state tracking + * - Proper cleanup and resource management + */ +export class AudioRecorder { + private mediaRecorder: MediaRecorder | null = null; + private audioChunks: Blob[] = []; + private stream: MediaStream | null = null; + private recordingState: boolean = false; + + async startRecording(): Promise<void> { + try { + this.stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + } + }); + + this.initializeRecorder(this.stream); + + this.audioChunks = []; + // Start recording with a small timeslice to ensure we get data + this.mediaRecorder!.start(100); + this.recordingState = true; + } catch (error) { + console.error('Failed to start recording:', error); + throw new Error('Failed to access microphone. Please check permissions.'); + } + } + + async stopRecording(): Promise<Blob> { + return new Promise((resolve, reject) => { + if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive') { + reject(new Error('No active recording to stop')); + return; + } + + this.mediaRecorder.onstop = () => { + const mimeType = this.mediaRecorder?.mimeType || MimeTypeAudio.WAV; + const audioBlob = new Blob(this.audioChunks, { type: mimeType }); + + this.cleanup(); + + resolve(audioBlob); + }; + + this.mediaRecorder.onerror = (event) => { + console.error('Recording error:', event); + this.cleanup(); + reject(new Error('Recording failed')); + }; + + this.mediaRecorder.stop(); + }); + } + + isRecording(): boolean { + return this.recordingState; + } + + cancelRecording(): void { + if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') { + this.mediaRecorder.stop(); + } + this.cleanup(); + } + + private initializeRecorder(stream: MediaStream): void { + const options: MediaRecorderOptions = {}; + + if (MediaRecorder.isTypeSupported(MimeTypeAudio.WAV)) { + options.mimeType = MimeTypeAudio.WAV; + } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.WEBM_OPUS)) { + options.mimeType = MimeTypeAudio.WEBM_OPUS; + } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.WEBM)) { + options.mimeType = MimeTypeAudio.WEBM; + } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.MP4)) { + options.mimeType = MimeTypeAudio.MP4; + } else { + console.warn('No preferred audio format supported, using default'); + } + + this.mediaRecorder = new MediaRecorder(stream, options); + + this.mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + this.audioChunks.push(event.data); + } + }; + + this.mediaRecorder.onstop = () => { + this.recordingState = false; + }; + + this.mediaRecorder.onerror = (event) => { + console.error('MediaRecorder error:', event); + this.recordingState = false; + }; + } + + private cleanup(): void { + if (this.stream) { + for (const track of this.stream.getTracks()) { + track.stop(); + } + + this.stream = null; + } + this.mediaRecorder = null; + this.audioChunks = []; + this.recordingState = false; + } +} + +export async function convertToWav(audioBlob: Blob): Promise<Blob> { + try { + if (audioBlob.type.includes('wav')) { + return audioBlob; + } + + const arrayBuffer = await audioBlob.arrayBuffer(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); + + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + + const wavBlob = audioBufferToWav(audioBuffer); + + audioContext.close(); + + return wavBlob; + } catch (error) { + console.error('Failed to convert audio to WAV:', error); + return audioBlob; + } +} + +function audioBufferToWav(buffer: AudioBuffer): Blob { + const length = buffer.length; + const numberOfChannels = buffer.numberOfChannels; + const sampleRate = buffer.sampleRate; + const bytesPerSample = 2; // 16-bit + const blockAlign = numberOfChannels * bytesPerSample; + const byteRate = sampleRate * blockAlign; + const dataSize = length * blockAlign; + const bufferSize = 44 + dataSize; + + const arrayBuffer = new ArrayBuffer(bufferSize); + const view = new DataView(arrayBuffer); + + const writeString = (offset: number, string: string) => { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } + }; + + writeString(0, 'RIFF'); // ChunkID + view.setUint32(4, bufferSize - 8, true); // ChunkSize + writeString(8, 'WAVE'); // Format + writeString(12, 'fmt '); // Subchunk1ID + view.setUint32(16, 16, true); // Subchunk1Size + view.setUint16(20, 1, true); // AudioFormat (PCM) + view.setUint16(22, numberOfChannels, true); // NumChannels + view.setUint32(24, sampleRate, true); // SampleRate + view.setUint32(28, byteRate, true); // ByteRate + view.setUint16(32, blockAlign, true); // BlockAlign + view.setUint16(34, 16, true); // BitsPerSample + writeString(36, 'data'); // Subchunk2ID + view.setUint32(40, dataSize, true); // Subchunk2Size + + let offset = 44; + for (let i = 0; i < length; i++) { + for (let channel = 0; channel < numberOfChannels; channel++) { + const sample = Math.max(-1, Math.min(1, buffer.getChannelData(channel)[i])); + view.setInt16(offset, sample * 0x7fff, true); + offset += 2; + } + } + + return new Blob([arrayBuffer], { type: MimeTypeAudio.WAV }); +} + +/** + * Create a File object from audio blob with timestamp-based naming + * @param audioBlob - The audio blob to wrap + * @param filename - Optional custom filename + * @returns File object with appropriate name and metadata + */ +export function createAudioFile(audioBlob: Blob, filename?: string): File { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const extension = audioBlob.type.includes('wav') ? 'wav' : 'mp3'; + const defaultFilename = `recording-${timestamp}.${extension}`; + + return new File([audioBlob], filename || defaultFilename, { + type: audioBlob.type, + lastModified: Date.now() + }); +} + +/** + * Check if audio recording is supported in the current browser + * @returns True if MediaRecorder and getUserMedia are available + */ +export function isAudioRecordingSupported(): boolean { + return !!( + typeof navigator !== 'undefined' && + navigator.mediaDevices && + typeof navigator.mediaDevices.getUserMedia === 'function' && + typeof window !== 'undefined' && + window.MediaRecorder + ); +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/autoresize-textarea.ts b/llama.cpp/tools/server/webui/src/lib/utils/autoresize-textarea.ts new file mode 100644 index 0000000..cfee5ec --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/autoresize-textarea.ts @@ -0,0 +1,10 @@ +/** + * Automatically resizes a textarea element to fit its content + * @param textareaElement - The textarea element to resize + */ +export default function autoResizeTextarea(textareaElement: HTMLTextAreaElement | null): void { + if (textareaElement) { + textareaElement.style.height = '1rem'; + textareaElement.style.height = textareaElement.scrollHeight + 'px'; + } +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/branching.ts b/llama.cpp/tools/server/webui/src/lib/utils/branching.ts new file mode 100644 index 0000000..3be5604 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/branching.ts @@ -0,0 +1,283 @@ +/** + * Message branching utilities for conversation tree navigation. + * + * Conversation branching allows users to edit messages and create alternate paths + * while preserving the original conversation flow. Each message has parent/children + * relationships forming a tree structure. + * + * Example tree: + * root + * ├── message 1 (user) + * │ └── message 2 (assistant) + * │ ├── message 3 (user) + * │ └── message 6 (user) ← new branch + * └── message 4 (user) + * └── message 5 (assistant) + */ + +/** + * Filters messages to get the conversation path from root to a specific leaf node. + * If the leafNodeId doesn't exist, returns the path with the latest timestamp. + * + * @param messages - All messages in the conversation + * @param leafNodeId - The target leaf node ID to trace back from + * @param includeRoot - Whether to include root messages in the result + * @returns Array of messages from root to leaf, sorted by timestamp + */ +export function filterByLeafNodeId( + messages: readonly DatabaseMessage[], + leafNodeId: string, + includeRoot: boolean = false +): readonly DatabaseMessage[] { + const result: DatabaseMessage[] = []; + const nodeMap = new Map<string, DatabaseMessage>(); + + // Build node map for quick lookups + for (const msg of messages) { + nodeMap.set(msg.id, msg); + } + + // Find the starting node (leaf node or latest if not found) + let startNode: DatabaseMessage | undefined = nodeMap.get(leafNodeId); + if (!startNode) { + // If leaf node not found, use the message with latest timestamp + let latestTime = -1; + for (const msg of messages) { + if (msg.timestamp > latestTime) { + startNode = msg; + latestTime = msg.timestamp; + } + } + } + + // Traverse from leaf to root, collecting messages + let currentNode: DatabaseMessage | undefined = startNode; + while (currentNode) { + // Include message if it's not root, or if we want to include root + if (currentNode.type !== 'root' || includeRoot) { + result.push(currentNode); + } + + // Stop traversal if parent is null (reached root) + if (currentNode.parent === null) { + break; + } + currentNode = nodeMap.get(currentNode.parent); + } + + // Sort by timestamp to get chronological order (root to leaf) + result.sort((a, b) => a.timestamp - b.timestamp); + return result; +} + +/** + * Finds the leaf node (message with no children) for a given message branch. + * Traverses down the tree following the last child until reaching a leaf. + * + * @param messages - All messages in the conversation + * @param messageId - Starting message ID to find leaf for + * @returns The leaf node ID, or the original messageId if no children + */ +export function findLeafNode(messages: readonly DatabaseMessage[], messageId: string): string { + const nodeMap = new Map<string, DatabaseMessage>(); + + // Build node map for quick lookups + for (const msg of messages) { + nodeMap.set(msg.id, msg); + } + + let currentNode: DatabaseMessage | undefined = nodeMap.get(messageId); + while (currentNode && currentNode.children.length > 0) { + // Follow the last child (most recent branch) + const lastChildId = currentNode.children[currentNode.children.length - 1]; + currentNode = nodeMap.get(lastChildId); + } + + return currentNode?.id ?? messageId; +} + +/** + * Finds all descendant messages (children, grandchildren, etc.) of a given message. + * This is used for cascading deletion to remove all messages in a branch. + * + * @param messages - All messages in the conversation + * @param messageId - The root message ID to find descendants for + * @returns Array of all descendant message IDs + */ +export function findDescendantMessages( + messages: readonly DatabaseMessage[], + messageId: string +): string[] { + const nodeMap = new Map<string, DatabaseMessage>(); + + // Build node map for quick lookups + for (const msg of messages) { + nodeMap.set(msg.id, msg); + } + + const descendants: string[] = []; + const queue: string[] = [messageId]; + + while (queue.length > 0) { + const currentId = queue.shift()!; + const currentNode = nodeMap.get(currentId); + + if (currentNode) { + // Add all children to the queue and descendants list + for (const childId of currentNode.children) { + descendants.push(childId); + queue.push(childId); + } + } + } + + return descendants; +} + +/** + * Gets sibling information for a message, including all sibling IDs and current position. + * Siblings are messages that share the same parent. + * + * @param messages - All messages in the conversation + * @param messageId - The message to get sibling info for + * @returns Sibling information including leaf node IDs for navigation + */ +export function getMessageSiblings( + messages: readonly DatabaseMessage[], + messageId: string +): ChatMessageSiblingInfo | null { + const nodeMap = new Map<string, DatabaseMessage>(); + + // Build node map for quick lookups + for (const msg of messages) { + nodeMap.set(msg.id, msg); + } + + const message = nodeMap.get(messageId); + if (!message) { + return null; + } + + // Handle null parent (root message) case + if (message.parent === null) { + // No parent means this is likely a root node with no siblings + return { + message, + siblingIds: [messageId], + currentIndex: 0, + totalSiblings: 1 + }; + } + + const parentNode = nodeMap.get(message.parent); + if (!parentNode) { + // Parent not found - treat as single message + return { + message, + siblingIds: [messageId], + currentIndex: 0, + totalSiblings: 1 + }; + } + + // Get all sibling IDs (including self) + const siblingIds = parentNode.children; + + // Convert sibling message IDs to their corresponding leaf node IDs + // This allows navigation between different conversation branches + const siblingLeafIds = siblingIds.map((siblingId: string) => findLeafNode(messages, siblingId)); + + // Find current message's position among siblings + const currentIndex = siblingIds.indexOf(messageId); + + return { + message, + siblingIds: siblingLeafIds, + currentIndex, + totalSiblings: siblingIds.length + }; +} + +/** + * Creates a display-ready list of messages with sibling information for UI rendering. + * This is the main function used by chat components to render conversation branches. + * + * @param messages - All messages in the conversation + * @param leafNodeId - Current leaf node being viewed + * @returns Array of messages with sibling navigation info + */ +export function getMessageDisplayList( + messages: readonly DatabaseMessage[], + leafNodeId: string +): ChatMessageSiblingInfo[] { + // Get the current conversation path + const currentPath = filterByLeafNodeId(messages, leafNodeId, true); + const result: ChatMessageSiblingInfo[] = []; + + // Add sibling info for each message in the current path + for (const message of currentPath) { + if (message.type === 'root') { + continue; // Skip root messages in display + } + + const siblingInfo = getMessageSiblings(messages, message.id); + if (siblingInfo) { + result.push(siblingInfo); + } + } + + return result; +} + +/** + * Checks if a message has multiple siblings (indicating branching at that point). + * + * @param messages - All messages in the conversation + * @param messageId - The message to check + * @returns True if the message has siblings + */ +export function hasMessageSiblings( + messages: readonly DatabaseMessage[], + messageId: string +): boolean { + const siblingInfo = getMessageSiblings(messages, messageId); + return siblingInfo ? siblingInfo.totalSiblings > 1 : false; +} + +/** + * Gets the next sibling message ID for navigation. + * + * @param messages - All messages in the conversation + * @param messageId - Current message ID + * @returns Next sibling's leaf node ID, or null if at the end + */ +export function getNextSibling( + messages: readonly DatabaseMessage[], + messageId: string +): string | null { + const siblingInfo = getMessageSiblings(messages, messageId); + if (!siblingInfo || siblingInfo.currentIndex >= siblingInfo.totalSiblings - 1) { + return null; + } + + return siblingInfo.siblingIds[siblingInfo.currentIndex + 1]; +} + +/** + * Gets the previous sibling message ID for navigation. + * + * @param messages - All messages in the conversation + * @param messageId - Current message ID + * @returns Previous sibling's leaf node ID, or null if at the beginning + */ +export function getPreviousSibling( + messages: readonly DatabaseMessage[], + messageId: string +): string | null { + const siblingInfo = getMessageSiblings(messages, messageId); + if (!siblingInfo || siblingInfo.currentIndex <= 0) { + return null; + } + + return siblingInfo.siblingIds[siblingInfo.currentIndex - 1]; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/browser-only.ts b/llama.cpp/tools/server/webui/src/lib/utils/browser-only.ts new file mode 100644 index 0000000..0af8006 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/browser-only.ts @@ -0,0 +1,35 @@ +/** + * Browser-only utility exports + * + * These utilities require browser APIs (DOM, Canvas, MediaRecorder, etc.) + * and cannot be imported during SSR. Import from '$lib/utils/browser-only' + * only in client-side code or components that are not server-rendered. + */ + +// Audio utilities (MediaRecorder API) +export { + AudioRecorder, + convertToWav, + createAudioFile, + isAudioRecordingSupported +} from './audio-recording'; + +// PDF processing utilities (pdfjs-dist with DOMMatrix) +export { + convertPDFToText, + convertPDFToImage, + isPdfFile as isPdfFileFromFile, + isApplicationMimeType +} from './pdf-processing'; + +// File conversion utilities (depends on pdf-processing) +export { parseFilesToMessageExtras, type FileProcessingResult } from './convert-files-to-extra'; + +// File upload processing utilities (depends on pdf-processing, svg-to-png, webp-to-png) +export { processFilesToChatUploaded } from './process-uploaded-files'; + +// SVG utilities (Canvas/Image API) +export { svgBase64UrlToPngDataURL, isSvgFile, isSvgMimeType } from './svg-to-png'; + +// WebP utilities (Canvas/Image API) +export { webpBase64UrlToPngDataURL, isWebpFile, isWebpMimeType } from './webp-to-png'; diff --git a/llama.cpp/tools/server/webui/src/lib/utils/clipboard.ts b/llama.cpp/tools/server/webui/src/lib/utils/clipboard.ts new file mode 100644 index 0000000..940e64c --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/clipboard.ts @@ -0,0 +1,259 @@ +import { toast } from 'svelte-sonner'; +import { AttachmentType } from '$lib/enums'; +import type { + DatabaseMessageExtra, + DatabaseMessageExtraTextFile, + DatabaseMessageExtraLegacyContext +} from '$lib/types/database'; + +/** + * Copy text to clipboard with toast notification + * Uses modern clipboard API when available, falls back to legacy method for non-secure contexts + * @param text - Text to copy to clipboard + * @param successMessage - Custom success message (optional) + * @param errorMessage - Custom error message (optional) + * @returns Promise<boolean> - True if successful, false otherwise + */ +export async function copyToClipboard( + text: string, + successMessage = 'Copied to clipboard', + errorMessage = 'Failed to copy to clipboard' +): Promise<boolean> { + try { + // Try modern clipboard API first (secure contexts only) + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + toast.success(successMessage); + return true; + } + + // Fallback for non-secure contexts + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + + if (successful) { + toast.success(successMessage); + return true; + } else { + throw new Error('execCommand failed'); + } + } catch (error) { + console.error('Failed to copy to clipboard:', error); + toast.error(errorMessage); + return false; + } +} + +/** + * Copy code with HTML entity decoding and toast notification + * @param rawCode - Raw code string that may contain HTML entities + * @param successMessage - Custom success message (optional) + * @param errorMessage - Custom error message (optional) + * @returns Promise<boolean> - True if successful, false otherwise + */ +export async function copyCodeToClipboard( + rawCode: string, + successMessage = 'Code copied to clipboard', + errorMessage = 'Failed to copy code' +): Promise<boolean> { + return copyToClipboard(rawCode, successMessage, errorMessage); +} + +/** + * Format for text attachments when copied to clipboard + */ +export interface ClipboardTextAttachment { + type: typeof AttachmentType.TEXT; + name: string; + content: string; +} + +/** + * Parsed result from clipboard content + */ +export interface ParsedClipboardContent { + message: string; + textAttachments: ClipboardTextAttachment[]; +} + +/** + * Formats a message with text attachments for clipboard copying. + * + * Default format (asPlainText = false): + * ``` + * "Text message content" + * [ + * {"type":"TEXT","name":"filename.txt","content":"..."}, + * {"type":"TEXT","name":"another.txt","content":"..."} + * ] + * ``` + * + * Plain text format (asPlainText = true): + * ``` + * Text message content + * + * file content here + * + * another file content + * ``` + * + * @param content - The message text content + * @param extras - Optional array of message attachments + * @param asPlainText - If true, format as plain text without JSON structure + * @returns Formatted string for clipboard + */ +export function formatMessageForClipboard( + content: string, + extras?: DatabaseMessageExtra[], + asPlainText: boolean = false +): string { + // Filter only text attachments (TEXT type and legacy CONTEXT type) + const textAttachments = + extras?.filter( + (extra): extra is DatabaseMessageExtraTextFile | DatabaseMessageExtraLegacyContext => + extra.type === AttachmentType.TEXT || extra.type === AttachmentType.LEGACY_CONTEXT + ) ?? []; + + if (textAttachments.length === 0) { + return content; + } + + if (asPlainText) { + const parts = [content]; + for (const att of textAttachments) { + parts.push(att.content); + } + return parts.join('\n\n'); + } + + const clipboardAttachments: ClipboardTextAttachment[] = textAttachments.map((att) => ({ + type: AttachmentType.TEXT, + name: att.name, + content: att.content + })); + + return `${JSON.stringify(content)}\n${JSON.stringify(clipboardAttachments, null, 2)}`; +} + +/** + * Parses clipboard content to extract message and text attachments. + * Supports both plain text and the special format with attachments. + * + * @param clipboardText - Raw text from clipboard + * @returns Parsed content with message and attachments + */ +export function parseClipboardContent(clipboardText: string): ParsedClipboardContent { + const defaultResult: ParsedClipboardContent = { + message: clipboardText, + textAttachments: [] + }; + + if (!clipboardText.startsWith('"')) { + return defaultResult; + } + + try { + let stringEndIndex = -1; + let escaped = false; + + for (let i = 1; i < clipboardText.length; i++) { + const char = clipboardText[i]; + + if (escaped) { + escaped = false; + continue; + } + + if (char === '\\') { + escaped = true; + continue; + } + + if (char === '"') { + stringEndIndex = i; + break; + } + } + + if (stringEndIndex === -1) { + return defaultResult; + } + + const jsonStringPart = clipboardText.substring(0, stringEndIndex + 1); + const remainingPart = clipboardText.substring(stringEndIndex + 1).trim(); + + const message = JSON.parse(jsonStringPart) as string; + + if (!remainingPart || !remainingPart.startsWith('[')) { + return { + message, + textAttachments: [] + }; + } + + const attachments = JSON.parse(remainingPart) as unknown[]; + + const validAttachments: ClipboardTextAttachment[] = []; + + for (const att of attachments) { + if (isValidTextAttachment(att)) { + validAttachments.push({ + type: AttachmentType.TEXT, + name: att.name, + content: att.content + }); + } + } + + return { + message, + textAttachments: validAttachments + }; + } catch { + return defaultResult; + } +} + +/** + * Type guard to validate a text attachment object + * @param obj The object to validate + * @returns true if the object is a valid text attachment + */ +function isValidTextAttachment( + obj: unknown +): obj is { type: string; name: string; content: string } { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + const record = obj as Record<string, unknown>; + + return ( + (record.type === AttachmentType.TEXT || record.type === 'TEXT') && + typeof record.name === 'string' && + typeof record.content === 'string' + ); +} + +/** + * Checks if clipboard content contains our special format with attachments + * @param clipboardText - Raw text from clipboard + * @returns true if the clipboard content contains our special format with attachments + */ +export function hasClipboardAttachments(clipboardText: string): boolean { + if (!clipboardText.startsWith('"')) { + return false; + } + + const parsed = parseClipboardContent(clipboardText); + return parsed.textAttachments.length > 0; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/config-helpers.ts b/llama.cpp/tools/server/webui/src/lib/utils/config-helpers.ts new file mode 100644 index 0000000..b85242d --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/config-helpers.ts @@ -0,0 +1,51 @@ +/** + * Type-safe configuration helpers + * + * Provides utilities for safely accessing and modifying configuration objects + * with dynamic keys while maintaining TypeScript type safety. + */ + +/** + * Type-safe helper to access config properties dynamically + * Provides better type safety than direct casting to Record + */ +export function setConfigValue<T extends SettingsConfigType>( + config: T, + key: string, + value: unknown +): void { + if (key in config) { + (config as Record<string, unknown>)[key] = value; + } +} + +/** + * Type-safe helper to get config values dynamically + */ +export function getConfigValue<T extends SettingsConfigType>( + config: T, + key: string +): string | number | boolean | undefined { + const value = (config as Record<string, unknown>)[key]; + return value as string | number | boolean | undefined; +} + +/** + * Convert a SettingsConfigType to a ParameterRecord for specific keys + * Useful for parameter synchronization operations + */ +export function configToParameterRecord<T extends SettingsConfigType>( + config: T, + keys: string[] +): Record<string, string | number | boolean> { + const record: Record<string, string | number | boolean> = {}; + + for (const key of keys) { + const value = getConfigValue(config, key); + if (value !== undefined) { + record[key] = value; + } + } + + return record; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/conversation-utils.ts b/llama.cpp/tools/server/webui/src/lib/utils/conversation-utils.ts new file mode 100644 index 0000000..aee244a --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/conversation-utils.ts @@ -0,0 +1,30 @@ +/** + * Utility functions for conversation data manipulation + */ + +/** + * Creates a map of conversation IDs to their message counts from exported conversation data + * @param exportedData - Array of exported conversations with their messages + * @returns Map of conversation ID to message count + */ +export function createMessageCountMap( + exportedData: Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }> +): Map<string, number> { + const countMap = new Map<string, number>(); + + for (const item of exportedData) { + countMap.set(item.conv.id, item.messages.length); + } + + return countMap; +} + +/** + * Gets the message count for a specific conversation from the count map + * @param conversationId - The ID of the conversation + * @param countMap - Map of conversation IDs to message counts + * @returns The message count, or 0 if not found + */ +export function getMessageCount(conversationId: string, countMap: Map<string, number>): number { + return countMap.get(conversationId) ?? 0; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/convert-files-to-extra.ts b/llama.cpp/tools/server/webui/src/lib/utils/convert-files-to-extra.ts new file mode 100644 index 0000000..6eb50f6 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/convert-files-to-extra.ts @@ -0,0 +1,192 @@ +import { convertPDFToImage, convertPDFToText } from './pdf-processing'; +import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png'; +import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png'; +import { FileTypeCategory, AttachmentType } from '$lib/enums'; +import { config, settingsStore } from '$lib/stores/settings.svelte'; +import { modelsStore } from '$lib/stores/models.svelte'; +import { getFileTypeCategory } from '$lib/utils'; +import { readFileAsText, isLikelyTextFile } from './text-files'; +import { toast } from 'svelte-sonner'; + +function readFileAsBase64(file: File): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + // Extract base64 data without the data URL prefix + const dataUrl = reader.result as string; + const base64 = dataUrl.split(',')[1]; + resolve(base64); + }; + + reader.onerror = () => reject(reader.error); + + reader.readAsDataURL(file); + }); +} + +export interface FileProcessingResult { + extras: DatabaseMessageExtra[]; + emptyFiles: string[]; +} + +export async function parseFilesToMessageExtras( + files: ChatUploadedFile[], + activeModelId?: string +): Promise<FileProcessingResult> { + const extras: DatabaseMessageExtra[] = []; + const emptyFiles: string[] = []; + + for (const file of files) { + if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) { + if (file.preview) { + let base64Url = file.preview; + + if (isSvgMimeType(file.type)) { + try { + base64Url = await svgBase64UrlToPngDataURL(base64Url); + } catch (error) { + console.error('Failed to convert SVG to PNG for database storage:', error); + } + } else if (isWebpMimeType(file.type)) { + try { + base64Url = await webpBase64UrlToPngDataURL(base64Url); + } catch (error) { + console.error('Failed to convert WebP to PNG for database storage:', error); + } + } + + extras.push({ + type: AttachmentType.IMAGE, + name: file.name, + base64Url + }); + } + } else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) { + // Process audio files (MP3 and WAV) + try { + const base64Data = await readFileAsBase64(file.file); + + extras.push({ + type: AttachmentType.AUDIO, + name: file.name, + base64Data: base64Data, + mimeType: file.type + }); + } catch (error) { + console.error(`Failed to process audio file ${file.name}:`, error); + } + } else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) { + try { + // Always get base64 data for preview functionality + const base64Data = await readFileAsBase64(file.file); + const currentConfig = config(); + // Use per-model vision check for router mode + const hasVisionSupport = activeModelId + ? modelsStore.modelSupportsVision(activeModelId) + : false; + + // Force PDF-to-text for non-vision models + let shouldProcessAsImages = Boolean(currentConfig.pdfAsImage) && hasVisionSupport; + + // If user had pdfAsImage enabled but model doesn't support vision, update setting and notify + if (currentConfig.pdfAsImage && !hasVisionSupport) { + console.log('Non-vision model detected: forcing PDF-to-text mode and updating settings'); + + // Update the setting in localStorage + settingsStore.updateConfig('pdfAsImage', false); + + // Show toast notification to user + toast.warning( + 'PDF setting changed: Non-vision model detected, PDFs will be processed as text instead of images.', + { + duration: 5000 + } + ); + + shouldProcessAsImages = false; + } + + if (shouldProcessAsImages) { + // Process PDF as images (only for vision models) + try { + const images = await convertPDFToImage(file.file); + + // Show success toast for PDF image processing + toast.success( + `PDF "${file.name}" processed as ${images.length} images for vision model.`, + { + duration: 3000 + } + ); + + extras.push({ + type: AttachmentType.PDF, + name: file.name, + content: `PDF file with ${images.length} pages`, + images: images, + processedAsImages: true, + base64Data: base64Data + }); + } catch (imageError) { + console.warn( + `Failed to process PDF ${file.name} as images, falling back to text:`, + imageError + ); + + // Fallback to text processing + const content = await convertPDFToText(file.file); + + extras.push({ + type: AttachmentType.PDF, + name: file.name, + content: content, + processedAsImages: false, + base64Data: base64Data + }); + } + } else { + // Process PDF as text (default or forced for non-vision models) + const content = await convertPDFToText(file.file); + + // Show success toast for PDF text processing + toast.success(`PDF "${file.name}" processed as text content.`, { + duration: 3000 + }); + + extras.push({ + type: AttachmentType.PDF, + name: file.name, + content: content, + processedAsImages: false, + base64Data: base64Data + }); + } + } catch (error) { + console.error(`Failed to process PDF file ${file.name}:`, error); + } + } else { + try { + const content = await readFileAsText(file.file); + + // Check if file is empty + if (content.trim() === '') { + console.warn(`File ${file.name} is empty and will be skipped`); + emptyFiles.push(file.name); + } else if (isLikelyTextFile(content)) { + extras.push({ + type: AttachmentType.TEXT, + name: file.name, + content: content + }); + } else { + console.warn(`File ${file.name} appears to be binary and will be skipped`); + } + } catch (error) { + console.error(`Failed to read file ${file.name}:`, error); + } + } + } + + return { extras, emptyFiles }; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/file-preview.ts b/llama.cpp/tools/server/webui/src/lib/utils/file-preview.ts new file mode 100644 index 0000000..26a6053 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/file-preview.ts @@ -0,0 +1,36 @@ +/** + * Gets a display label for a file type from various input formats + * + * Handles: + * - MIME types: 'application/pdf' → 'PDF' + * - AttachmentType values: 'PDF', 'AUDIO' → 'PDF', 'AUDIO' + * - File names: 'document.pdf' → 'PDF' + * - Unknown: returns 'FILE' + * + * @param input - MIME type, AttachmentType value, or file name + * @returns Formatted file type label (uppercase) + */ +export function getFileTypeLabel(input: string | undefined): string { + if (!input) return 'FILE'; + + // Handle MIME types (contains '/') + if (input.includes('/')) { + const subtype = input.split('/').pop(); + if (subtype) { + // Handle special cases like 'vnd.ms-excel' → 'EXCEL' + if (subtype.includes('.')) { + return subtype.split('.').pop()?.toUpperCase() || 'FILE'; + } + return subtype.toUpperCase(); + } + } + + // Handle file names (contains '.') + if (input.includes('.')) { + const ext = input.split('.').pop(); + if (ext) return ext.toUpperCase(); + } + + // Handle AttachmentType or other plain strings + return input.toUpperCase(); +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/file-type.ts b/llama.cpp/tools/server/webui/src/lib/utils/file-type.ts new file mode 100644 index 0000000..9a9996d --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/file-type.ts @@ -0,0 +1,222 @@ +import { + AUDIO_FILE_TYPES, + IMAGE_FILE_TYPES, + PDF_FILE_TYPES, + TEXT_FILE_TYPES +} from '$lib/constants/supported-file-types'; +import { + FileExtensionAudio, + FileExtensionImage, + FileExtensionPdf, + FileExtensionText, + FileTypeCategory, + MimeTypeApplication, + MimeTypeAudio, + MimeTypeImage, + MimeTypeText +} from '$lib/enums'; + +export function getFileTypeCategory(mimeType: string): FileTypeCategory | null { + switch (mimeType) { + // Images + case MimeTypeImage.JPEG: + case MimeTypeImage.PNG: + case MimeTypeImage.GIF: + case MimeTypeImage.WEBP: + case MimeTypeImage.SVG: + return FileTypeCategory.IMAGE; + + // Audio + case MimeTypeAudio.MP3_MPEG: + case MimeTypeAudio.MP3: + case MimeTypeAudio.MP4: + case MimeTypeAudio.WAV: + case MimeTypeAudio.WEBM: + case MimeTypeAudio.WEBM_OPUS: + return FileTypeCategory.AUDIO; + + // PDF + case MimeTypeApplication.PDF: + return FileTypeCategory.PDF; + + // Text + case MimeTypeText.PLAIN: + case MimeTypeText.MARKDOWN: + case MimeTypeText.ASCIIDOC: + case MimeTypeText.JAVASCRIPT: + case MimeTypeText.JAVASCRIPT_APP: + case MimeTypeText.TYPESCRIPT: + case MimeTypeText.JSX: + case MimeTypeText.TSX: + case MimeTypeText.CSS: + case MimeTypeText.HTML: + case MimeTypeText.JSON: + case MimeTypeText.XML_TEXT: + case MimeTypeText.XML_APP: + case MimeTypeText.YAML_TEXT: + case MimeTypeText.YAML_APP: + case MimeTypeText.CSV: + case MimeTypeText.PYTHON: + case MimeTypeText.JAVA: + case MimeTypeText.CPP_SRC: + case MimeTypeText.C_SRC: + case MimeTypeText.C_HDR: + case MimeTypeText.PHP: + case MimeTypeText.RUBY: + case MimeTypeText.GO: + case MimeTypeText.RUST: + case MimeTypeText.SHELL: + case MimeTypeText.BAT: + case MimeTypeText.SQL: + case MimeTypeText.R: + case MimeTypeText.SCALA: + case MimeTypeText.KOTLIN: + case MimeTypeText.SWIFT: + case MimeTypeText.DART: + case MimeTypeText.VUE: + case MimeTypeText.SVELTE: + case MimeTypeText.LATEX: + case MimeTypeText.BIBTEX: + case MimeTypeText.CUDA: + case MimeTypeText.CPP_HDR: + case MimeTypeText.CSHARP: + case MimeTypeText.HASKELL: + case MimeTypeText.PROPERTIES: + case MimeTypeText.TEX: + case MimeTypeText.TEX_APP: + return FileTypeCategory.TEXT; + + default: + return null; + } +} + +export function getFileTypeCategoryByExtension(filename: string): FileTypeCategory | null { + const extension = filename.toLowerCase().substring(filename.lastIndexOf('.')); + + switch (extension) { + // Images + case FileExtensionImage.JPG: + case FileExtensionImage.JPEG: + case FileExtensionImage.PNG: + case FileExtensionImage.GIF: + case FileExtensionImage.WEBP: + case FileExtensionImage.SVG: + return FileTypeCategory.IMAGE; + + // Audio + case FileExtensionAudio.MP3: + case FileExtensionAudio.WAV: + return FileTypeCategory.AUDIO; + + // PDF + case FileExtensionPdf.PDF: + return FileTypeCategory.PDF; + + // Text + case FileExtensionText.TXT: + case FileExtensionText.MD: + case FileExtensionText.ADOC: + case FileExtensionText.JS: + case FileExtensionText.TS: + case FileExtensionText.JSX: + case FileExtensionText.TSX: + case FileExtensionText.CSS: + case FileExtensionText.HTML: + case FileExtensionText.HTM: + case FileExtensionText.JSON: + case FileExtensionText.XML: + case FileExtensionText.YAML: + case FileExtensionText.YML: + case FileExtensionText.CSV: + case FileExtensionText.LOG: + case FileExtensionText.PY: + case FileExtensionText.JAVA: + case FileExtensionText.CPP: + case FileExtensionText.C: + case FileExtensionText.H: + case FileExtensionText.PHP: + case FileExtensionText.RB: + case FileExtensionText.GO: + case FileExtensionText.RS: + case FileExtensionText.SH: + case FileExtensionText.BAT: + case FileExtensionText.SQL: + case FileExtensionText.R: + case FileExtensionText.SCALA: + case FileExtensionText.KT: + case FileExtensionText.SWIFT: + case FileExtensionText.DART: + case FileExtensionText.VUE: + case FileExtensionText.SVELTE: + case FileExtensionText.TEX: + case FileExtensionText.BIB: + case FileExtensionText.COMP: + case FileExtensionText.CU: + case FileExtensionText.CUH: + case FileExtensionText.HPP: + case FileExtensionText.HS: + case FileExtensionText.PROPERTIES: + return FileTypeCategory.TEXT; + + default: + return null; + } +} + +export function getFileTypeByExtension(filename: string): string | null { + const extension = filename.toLowerCase().substring(filename.lastIndexOf('.')); + + for (const [key, type] of Object.entries(IMAGE_FILE_TYPES)) { + if ((type.extensions as readonly string[]).includes(extension)) { + return `${FileTypeCategory.IMAGE}:${key}`; + } + } + + for (const [key, type] of Object.entries(AUDIO_FILE_TYPES)) { + if ((type.extensions as readonly string[]).includes(extension)) { + return `${FileTypeCategory.AUDIO}:${key}`; + } + } + + for (const [key, type] of Object.entries(PDF_FILE_TYPES)) { + if ((type.extensions as readonly string[]).includes(extension)) { + return `${FileTypeCategory.PDF}:${key}`; + } + } + + for (const [key, type] of Object.entries(TEXT_FILE_TYPES)) { + if ((type.extensions as readonly string[]).includes(extension)) { + return `${FileTypeCategory.TEXT}:${key}`; + } + } + + return null; +} + +export function isFileTypeSupported(filename: string, mimeType?: string): boolean { + // Images are detected and handled separately for vision models + if (mimeType) { + const category = getFileTypeCategory(mimeType); + if ( + category === FileTypeCategory.IMAGE || + category === FileTypeCategory.AUDIO || + category === FileTypeCategory.PDF + ) { + return true; + } + } + + // Check extension for known types (especially images without MIME) + const extCategory = getFileTypeCategoryByExtension(filename); + if ( + extCategory === FileTypeCategory.IMAGE || + extCategory === FileTypeCategory.AUDIO || + extCategory === FileTypeCategory.PDF + ) { + return true; + } + + // Fallback: treat everything else as text (inclusive by default) + return true; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/formatters.ts b/llama.cpp/tools/server/webui/src/lib/utils/formatters.ts new file mode 100644 index 0000000..ae9f59a --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/formatters.ts @@ -0,0 +1,53 @@ +/** + * Formats file size in bytes to human readable format + * Supports Bytes, KB, MB, and GB + * + * @param bytes - File size in bytes (or unknown for safety) + * @returns Formatted file size string + */ +export function formatFileSize(bytes: number | unknown): string { + if (typeof bytes !== 'number') return 'Unknown'; + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +/** + * Format parameter count to human-readable format (B, M, K) + * + * @param params - Parameter count + * @returns Human-readable parameter count + */ +export function formatParameters(params: number | unknown): string { + if (typeof params !== 'number') return 'Unknown'; + + if (params >= 1e9) { + return `${(params / 1e9).toFixed(2)}B`; + } + + if (params >= 1e6) { + return `${(params / 1e6).toFixed(2)}M`; + } + + if (params >= 1e3) { + return `${(params / 1e3).toFixed(2)}K`; + } + + return params.toString(); +} + +/** + * Format number with locale-specific thousands separators + * + * @param num - Number to format + * @returns Human-readable number + */ +export function formatNumber(num: number | unknown): string { + if (typeof num !== 'number') return 'Unknown'; + + return num.toLocaleString(); +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/index.ts b/llama.cpp/tools/server/webui/src/lib/utils/index.ts new file mode 100644 index 0000000..588167b --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/index.ts @@ -0,0 +1,95 @@ +/** + * Unified exports for all utility functions + * Import utilities from '$lib/utils' for cleaner imports + * + * For browser-only utilities (pdf-processing, audio-recording, svg-to-png, + * webp-to-png, process-uploaded-files, convert-files-to-extra), use: + * import { ... } from '$lib/utils/browser-only' + */ + +// API utilities +export { getAuthHeaders, getJsonHeaders } from './api-headers'; +export { validateApiKey } from './api-key-validation'; + +// Attachment utilities +export { + getAttachmentDisplayItems, + type AttachmentDisplayItemsOptions +} from './attachment-display'; +export { isTextFile, isImageFile, isPdfFile, isAudioFile } from './attachment-type'; + +// Textarea utilities +export { default as autoResizeTextarea } from './autoresize-textarea'; + +// Branching utilities +export { + filterByLeafNodeId, + findLeafNode, + findDescendantMessages, + getMessageSiblings, + getMessageDisplayList, + hasMessageSiblings, + getNextSibling, + getPreviousSibling +} from './branching'; + +// Config helpers +export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers'; + +// Conversation utilities +export { createMessageCountMap, getMessageCount } from './conversation-utils'; + +// Clipboard utilities +export { + copyToClipboard, + copyCodeToClipboard, + formatMessageForClipboard, + parseClipboardContent, + hasClipboardAttachments, + type ClipboardTextAttachment, + type ParsedClipboardContent +} from './clipboard'; + +// File preview utilities +export { getFileTypeLabel } from './file-preview'; +export { getPreviewText } from './text'; + +// File type utilities +export { + getFileTypeCategory, + getFileTypeCategoryByExtension, + getFileTypeByExtension, + isFileTypeSupported +} from './file-type'; + +// Formatting utilities +export { formatFileSize, formatParameters, formatNumber } from './formatters'; + +// IME utilities +export { isIMEComposing } from './is-ime-composing'; + +// LaTeX utilities +export { maskInlineLaTeX, preprocessLaTeX } from './latex-protection'; + +// Modality file validation utilities +export { + isFileTypeSupportedByModel, + filterFilesByModalities, + generateModalityErrorMessage, + type ModalityCapabilities +} from './modality-file-validation'; + +// Model name utilities +export { normalizeModelName, isValidModelName } from './model-names'; + +// Portal utilities +export { portalToBody } from './portal-to-body'; + +// Precision utilities +export { normalizeFloatingPoint, normalizeNumber } from './precision'; + +// Syntax highlighting utilities +export { getLanguageFromFilename } from './syntax-highlight-language'; + +// Text file utilities +export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files'; diff --git a/llama.cpp/tools/server/webui/src/lib/utils/is-ime-composing.ts b/llama.cpp/tools/server/webui/src/lib/utils/is-ime-composing.ts new file mode 100644 index 0000000..9182ea4 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/is-ime-composing.ts @@ -0,0 +1,5 @@ +export function isIMEComposing(event: KeyboardEvent) { + // Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari, which is notorious for not supporting KeyboardEvent.isComposing) + // This prevents form submission when confirming IME word selection (e.g., Japanese/Chinese input) + return event.isComposing || event.keyCode === 229; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/latex-protection.ts b/llama.cpp/tools/server/webui/src/lib/utils/latex-protection.ts new file mode 100644 index 0000000..cafa2d4 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/latex-protection.ts @@ -0,0 +1,270 @@ +import { + CODE_BLOCK_REGEXP, + LATEX_MATH_AND_CODE_PATTERN, + LATEX_LINEBREAK_REGEXP, + MHCHEM_PATTERN_MAP +} from '$lib/constants/latex-protection'; + +/** + * Replaces inline LaTeX expressions enclosed in `$...$` with placeholders, avoiding dollar signs + * that appear to be part of monetary values or identifiers. + * + * This function processes the input line by line and skips `$` sequences that are likely + * part of money amounts (e.g., `$5`, `$100.99`) or code-like tokens (e.g., `var$`, `$var`). + * Valid LaTeX inline math is replaced with a placeholder like `<<LATEX_0>>`, and the + * actual LaTeX content is stored in the provided `latexExpressions` array. + * + * @param content - The input text potentially containing LaTeX expressions. + * @param latexExpressions - An array used to collect extracted LaTeX expressions. + * @returns The processed string with LaTeX replaced by placeholders. + */ +export function maskInlineLaTeX(content: string, latexExpressions: string[]): string { + if (!content.includes('$')) { + return content; + } + return content + .split('\n') + .map((line) => { + if (line.indexOf('$') == -1) { + return line; + } + + let processedLine = ''; + let currentPosition = 0; + + while (currentPosition < line.length) { + const openDollarIndex = line.indexOf('$', currentPosition); + + if (openDollarIndex == -1) { + processedLine += line.slice(currentPosition); + break; + } + + // Is there a next $-sign? + const closeDollarIndex = line.indexOf('$', openDollarIndex + 1); + + if (closeDollarIndex == -1) { + processedLine += line.slice(currentPosition); + break; + } + + const charBeforeOpen = openDollarIndex > 0 ? line[openDollarIndex - 1] : ''; + const charAfterOpen = line[openDollarIndex + 1]; + const charBeforeClose = + openDollarIndex + 1 < closeDollarIndex ? line[closeDollarIndex - 1] : ''; + const charAfterClose = closeDollarIndex + 1 < line.length ? line[closeDollarIndex + 1] : ''; + + let shouldSkipAsNonLatex = false; + + if (closeDollarIndex == currentPosition + 1) { + // No content + shouldSkipAsNonLatex = true; + } + + if (/[A-Za-z0-9_$-]/.test(charBeforeOpen)) { + // Character, digit, $, _ or - before first '$', no TeX. + shouldSkipAsNonLatex = true; + } + + if ( + /[0-9]/.test(charAfterOpen) && + (/[A-Za-z0-9_$-]/.test(charAfterClose) || ' ' == charBeforeClose) + ) { + // First $ seems to belong to an amount. + shouldSkipAsNonLatex = true; + } + + if (shouldSkipAsNonLatex) { + processedLine += line.slice(currentPosition, openDollarIndex + 1); + currentPosition = openDollarIndex + 1; + + continue; + } + + // Treat as LaTeX + processedLine += line.slice(currentPosition, openDollarIndex); + const latexContent = line.slice(openDollarIndex, closeDollarIndex + 1); + latexExpressions.push(latexContent); + processedLine += `<<LATEX_${latexExpressions.length - 1}>>`; + currentPosition = closeDollarIndex + 1; + } + + return processedLine; + }) + .join('\n'); +} + +function escapeBrackets(text: string): string { + return text.replace( + LATEX_MATH_AND_CODE_PATTERN, + ( + match: string, + codeBlock: string | undefined, + squareBracket: string | undefined, + roundBracket: string | undefined + ): string => { + if (codeBlock != null) { + return codeBlock; + } else if (squareBracket != null) { + return `$$${squareBracket}$$`; + } else if (roundBracket != null) { + return `$${roundBracket}$`; + } + + return match; + } + ); +} + +// Escape $\\ce{...} → $\\ce{...} but with proper handling +function escapeMhchem(text: string): string { + return MHCHEM_PATTERN_MAP.reduce((result, [pattern, replacement]) => { + return result.replace(pattern, replacement); + }, text); +} + +const doEscapeMhchem = false; + +/** + * Preprocesses markdown content to safely handle LaTeX math expressions while protecting + * against false positives (e.g., dollar amounts like $5.99) and ensuring proper rendering. + * + * This function: + * - Protects code blocks (```) and inline code (`...`) + * - Safeguards block and inline LaTeX: \(...\), \[...\], $$...$$, and selective $...$ + * - Escapes standalone dollar signs before numbers (e.g., $5 → \$5) to prevent misinterpretation + * - Restores protected LaTeX and code blocks after processing + * - Converts \(...\) → $...$ and \[...\] → $$...$$ for compatibility with math renderers + * - Applies additional escaping for brackets and mhchem syntax if needed + * + * @param content - The raw text (e.g., markdown) that may contain LaTeX or code blocks. + * @returns The preprocessed string with properly escaped and normalized LaTeX. + * + * @example + * preprocessLaTeX("Price: $10. The equation is \\(x^2\\).") + * // → "Price: $10. The equation is $x^2$." + */ +export function preprocessLaTeX(content: string): string { + // See also: + // https://github.com/danny-avila/LibreChat/blob/main/client/src/utils/latex.ts + + // Step 0: Temporarily remove blockquote markers (>) to process LaTeX correctly + // Store the structure so we can restore it later + const blockquoteMarkers: Map<number, string> = new Map(); + const lines = content.split('\n'); + const processedLines = lines.map((line, index) => { + const match = line.match(/^(>\s*)/); + if (match) { + blockquoteMarkers.set(index, match[1]); + return line.slice(match[1].length); + } + return line; + }); + content = processedLines.join('\n'); + + // Step 1: Protect code blocks + const codeBlocks: string[] = []; + + content = content.replace(CODE_BLOCK_REGEXP, (match) => { + codeBlocks.push(match); + + return `<<CODE_BLOCK_${codeBlocks.length - 1}>>`; + }); + + // Step 2: Protect existing LaTeX expressions + const latexExpressions: string[] = []; + + // Match \S...\[...\] and protect them and insert a line-break. + content = content.replace(/([\S].*?)\\\[([\s\S]*?)\\\](.*)/g, (match, group1, group2, group3) => { + // Check if there are characters following the formula (display-formula in a table-cell?) + if (group1.endsWith('\\')) { + return match; // Backslash before \[, do nothing. + } + const hasSuffix = /\S/.test(group3); + let optBreak; + + if (hasSuffix) { + latexExpressions.push(`\\(${group2.trim()}\\)`); // Convert into inline. + optBreak = ''; + } else { + latexExpressions.push(`\\[${group2}\\]`); + optBreak = '\n'; + } + + return `${group1}${optBreak}<<LATEX_${latexExpressions.length - 1}>>${optBreak}${group3}`; + }); + + // Match \(...\), \[...\], $$...$$ and protect them + content = content.replace( + /(\$\$[\s\S]*?\$\$|(?<!\\)\\\[[\s\S]*?\\\]|(?<!\\)\\\(.*?\\\))/g, + (match) => { + latexExpressions.push(match); + + return `<<LATEX_${latexExpressions.length - 1}>>`; + } + ); + + // Protect inline $...$ but NOT if it looks like money (e.g., $10, $3.99) + content = maskInlineLaTeX(content, latexExpressions); + + // Step 3: Escape standalone $ before digits (currency like $5 → \$5) + // (Now that inline math is protected, this will only escape dollars not already protected) + content = content.replace(/\$(?=\d)/g, '\\$'); + + // Step 4: Restore protected LaTeX expressions (they are valid) + content = content.replace(/<<LATEX_(\d+)>>/g, (_, index) => { + let expr = latexExpressions[parseInt(index)]; + const match = expr.match(LATEX_LINEBREAK_REGEXP); + if (match) { + // Katex: The $$-delimiters should be in their own line + // if there are \\-line-breaks. + const formula = match[1]; + const prefix = formula.startsWith('\n') ? '' : '\n'; + const suffix = formula.endsWith('\n') ? '' : '\n'; + expr = '$$' + prefix + formula + suffix + '$$'; + } + return expr; + }); + + // Step 5: Apply additional escaping functions (brackets and mhchem) + // This must happen BEFORE restoring code blocks to avoid affecting code content + content = escapeBrackets(content); + + if (doEscapeMhchem && (content.includes('\\ce{') || content.includes('\\pu{'))) { + content = escapeMhchem(content); + } + + // Step 6: Convert remaining \(...\) → $...$, \[...\] → $$...$$ + // This must happen BEFORE restoring code blocks to avoid affecting code content + content = content + // Using the look‑behind pattern `(?<!\\)` we skip matches + // that are preceded by a backslash, e.g. + // `Definitions\\(also called macros)` (title of chapter 20 in The TeXbook). + .replace(/(?<!\\)\\\((.+?)\\\)/g, '$$$1$') // inline + .replace( + // Using the look‑behind pattern `(?<!\\)` we skip matches + // that are preceded by a backslash, e.g. `\\[4pt]`. + /(?<!\\)\\\[([\s\S]*?)\\\]/g, // display, see also PR #16599 + (_, content: string) => { + return `$$${content}$$`; + } + ); + + // Step 7: Restore code blocks + // This happens AFTER all LaTeX conversions to preserve code content + content = content.replace(/<<CODE_BLOCK_(\d+)>>/g, (_, index) => { + return codeBlocks[parseInt(index)]; + }); + + // Step 8: Restore blockquote markers + if (blockquoteMarkers.size > 0) { + const finalLines = content.split('\n'); + const restoredLines = finalLines.map((line, index) => { + const marker = blockquoteMarkers.get(index); + return marker ? marker + line : line; + }); + content = restoredLines.join('\n'); + } + + return content; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/modality-file-validation.ts b/llama.cpp/tools/server/webui/src/lib/utils/modality-file-validation.ts new file mode 100644 index 0000000..136c084 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/modality-file-validation.ts @@ -0,0 +1,162 @@ +/** + * File validation utilities based on model modalities + * Ensures only compatible file types are processed based on model capabilities + */ + +import { getFileTypeCategory } from '$lib/utils'; +import { FileTypeCategory } from '$lib/enums'; + +/** Modality capabilities for file validation */ +export interface ModalityCapabilities { + hasVision: boolean; + hasAudio: boolean; +} + +/** + * Check if a file type is supported by the given modalities + * @param filename - The filename to check + * @param mimeType - The MIME type of the file + * @param capabilities - The modality capabilities to check against + * @returns true if the file type is supported + */ +export function isFileTypeSupportedByModel( + filename: string, + mimeType: string | undefined, + capabilities: ModalityCapabilities +): boolean { + const category = mimeType ? getFileTypeCategory(mimeType) : null; + + // If we can't determine the category from MIME type, fall back to general support check + if (!category) { + // For unknown types, only allow if they might be text files + // This is a conservative approach for edge cases + return true; // Let the existing isFileTypeSupported handle this + } + + switch (category) { + case FileTypeCategory.TEXT: + // Text files are always supported + return true; + + case FileTypeCategory.PDF: + // PDFs are always supported (will be processed as text for non-vision models) + return true; + + case FileTypeCategory.IMAGE: + // Images require vision support + return capabilities.hasVision; + + case FileTypeCategory.AUDIO: + // Audio files require audio support + return capabilities.hasAudio; + + default: + // Unknown categories - be conservative and allow + return true; + } +} + +/** + * Filter files based on model modalities and return supported/unsupported lists + * @param files - Array of files to filter + * @param capabilities - The modality capabilities to check against + * @returns Object with supportedFiles and unsupportedFiles arrays + */ +export function filterFilesByModalities( + files: File[], + capabilities: ModalityCapabilities +): { + supportedFiles: File[]; + unsupportedFiles: File[]; + modalityReasons: Record<string, string>; +} { + const supportedFiles: File[] = []; + const unsupportedFiles: File[] = []; + const modalityReasons: Record<string, string> = {}; + + const { hasVision, hasAudio } = capabilities; + + for (const file of files) { + const category = getFileTypeCategory(file.type); + let isSupported = true; + let reason = ''; + + switch (category) { + case FileTypeCategory.IMAGE: + if (!hasVision) { + isSupported = false; + reason = 'Images require a vision-capable model'; + } + break; + + case FileTypeCategory.AUDIO: + if (!hasAudio) { + isSupported = false; + reason = 'Audio files require an audio-capable model'; + } + break; + + case FileTypeCategory.TEXT: + case FileTypeCategory.PDF: + // Always supported + break; + + default: + // For unknown types, check if it's a generally supported file type + // This handles edge cases and maintains backward compatibility + break; + } + + if (isSupported) { + supportedFiles.push(file); + } else { + unsupportedFiles.push(file); + modalityReasons[file.name] = reason; + } + } + + return { supportedFiles, unsupportedFiles, modalityReasons }; +} + +/** + * Generate a user-friendly error message for unsupported files + * @param unsupportedFiles - Array of unsupported files + * @param modalityReasons - Reasons why files are unsupported + * @param capabilities - The modality capabilities to check against + * @returns Formatted error message + */ +export function generateModalityErrorMessage( + unsupportedFiles: File[], + modalityReasons: Record<string, string>, + capabilities: ModalityCapabilities +): string { + if (unsupportedFiles.length === 0) return ''; + + const { hasVision, hasAudio } = capabilities; + + let message = ''; + + if (unsupportedFiles.length === 1) { + const file = unsupportedFiles[0]; + const reason = modalityReasons[file.name]; + message = `The file "${file.name}" cannot be uploaded: ${reason}.`; + } else { + const fileNames = unsupportedFiles.map((f) => f.name).join(', '); + message = `The following files cannot be uploaded: ${fileNames}.`; + } + + // Add helpful information about what is supported + const supportedTypes: string[] = ['text files', 'PDFs']; + if (hasVision) supportedTypes.push('images'); + if (hasAudio) supportedTypes.push('audio files'); + + message += ` This model supports: ${supportedTypes.join(', ')}.`; + + return message; +} + +/** + * Generate file input accept string based on model modalities + * @param capabilities - The modality capabilities to check against + * @returns Accept string for HTML file input element + */ diff --git a/llama.cpp/tools/server/webui/src/lib/utils/model-names.ts b/llama.cpp/tools/server/webui/src/lib/utils/model-names.ts new file mode 100644 index 0000000..c0a1e1c --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/model-names.ts @@ -0,0 +1,56 @@ +/** + * Normalizes a model name by extracting the filename from a path, but preserves Hugging Face repository format. + * + * Handles both forward slashes (/) and backslashes (\) as path separators. + * - If the model name has exactly one slash (org/model format), preserves the full "org/model" name + * - If the model name has no slash or multiple slashes, extracts just the filename + * - If the model name is just a filename (no path), returns it as-is. + * + * @param modelName - The model name or path to normalize + * @returns The normalized model name + * + * @example + * normalizeModelName('models/llama-3.1-8b') // Returns: 'llama-3.1-8b' (multiple slashes -> filename) + * normalizeModelName('C:\\Models\\gpt-4') // Returns: 'gpt-4' (multiple slashes -> filename) + * normalizeModelName('meta-llama/Llama-3.1-8B') // Returns: 'meta-llama/Llama-3.1-8B' (Hugging Face format) + * normalizeModelName('simple-model') // Returns: 'simple-model' (no slash) + * normalizeModelName(' spaced ') // Returns: 'spaced' + * normalizeModelName('') // Returns: '' + */ +export function normalizeModelName(modelName: string): string { + const trimmed = modelName.trim(); + + if (!trimmed) { + return ''; + } + + const segments = trimmed.split(/[\\/]/); + + // If we have exactly 2 segments (one slash), treat it as Hugging Face repo format + // and preserve the full "org/model" format + if (segments.length === 2) { + const [org, model] = segments; + const trimmedOrg = org?.trim(); + const trimmedModel = model?.trim(); + + if (trimmedOrg && trimmedModel) { + return `${trimmedOrg}/${trimmedModel}`; + } + } + + // For other cases (no slash, or multiple slashes), extract just the filename + const candidate = segments.pop(); + const normalized = candidate?.trim(); + + return normalized && normalized.length > 0 ? normalized : trimmed; +} + +/** + * Validates if a model name is valid (non-empty after normalization). + * + * @param modelName - The model name to validate + * @returns true if valid, false otherwise + */ +export function isValidModelName(modelName: string): boolean { + return normalizeModelName(modelName).length > 0; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/pdf-processing.ts b/llama.cpp/tools/server/webui/src/lib/utils/pdf-processing.ts new file mode 100644 index 0000000..84c456d --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/pdf-processing.ts @@ -0,0 +1,150 @@ +/** + * PDF processing utilities using PDF.js + * Handles PDF text extraction and image conversion in the browser + */ + +import { browser } from '$app/environment'; +import { MimeTypeApplication, MimeTypeImage } from '$lib/enums'; +import * as pdfjs from 'pdfjs-dist'; + +type TextContent = { + items: Array<{ str: string }>; +}; + +if (browser) { + // Import worker as text and create blob URL for inline bundling + import('pdfjs-dist/build/pdf.worker.min.mjs?raw') + .then((workerModule) => { + const workerBlob = new Blob([workerModule.default], { type: 'application/javascript' }); + pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(workerBlob); + }) + .catch(() => { + console.warn('Failed to load PDF.js worker, PDF processing may not work'); + }); +} + +/** + * Convert a File object to ArrayBuffer for PDF.js processing + * @param file - The PDF file to convert + * @returns Promise resolving to the file's ArrayBuffer + */ +async function getFileAsBuffer(file: File): Promise<ArrayBuffer> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + if (event.target?.result) { + resolve(event.target.result as ArrayBuffer); + } else { + reject(new Error('Failed to read file.')); + } + }; + reader.onerror = () => { + reject(new Error('Failed to read file.')); + }; + reader.readAsArrayBuffer(file); + }); +} + +/** + * Extract text content from a PDF file + * @param file - The PDF file to process + * @returns Promise resolving to the extracted text content + */ +export async function convertPDFToText(file: File): Promise<string> { + if (!browser) { + throw new Error('PDF processing is only available in the browser'); + } + + try { + const buffer = await getFileAsBuffer(file); + const pdf = await pdfjs.getDocument(buffer).promise; + const numPages = pdf.numPages; + + const textContentPromises: Promise<TextContent>[] = []; + + for (let i = 1; i <= numPages; i++) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + textContentPromises.push(pdf.getPage(i).then((page: any) => page.getTextContent())); + } + + const textContents = await Promise.all(textContentPromises); + const textItems = textContents.flatMap((textContent: TextContent) => + textContent.items.map((item) => item.str ?? '') + ); + + return textItems.join('\n'); + } catch (error) { + console.error('Error converting PDF to text:', error); + throw new Error( + `Failed to convert PDF to text: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Convert PDF pages to PNG images as data URLs + * @param file - The PDF file to convert + * @param scale - Rendering scale factor (default: 1.5) + * @returns Promise resolving to array of PNG data URLs + */ +export async function convertPDFToImage(file: File, scale: number = 1.5): Promise<string[]> { + if (!browser) { + throw new Error('PDF processing is only available in the browser'); + } + + try { + const buffer = await getFileAsBuffer(file); + const doc = await pdfjs.getDocument(buffer).promise; + const pages: Promise<string>[] = []; + + for (let i = 1; i <= doc.numPages; i++) { + const page = await doc.getPage(i); + const viewport = page.getViewport({ scale }); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + canvas.width = viewport.width; + canvas.height = viewport.height; + + if (!ctx) { + throw new Error('Failed to get 2D context from canvas'); + } + + const task = page.render({ + canvasContext: ctx, + viewport: viewport, + canvas: canvas + }); + pages.push( + task.promise.then(() => { + return canvas.toDataURL(MimeTypeImage.PNG); + }) + ); + } + + return await Promise.all(pages); + } catch (error) { + console.error('Error converting PDF to images:', error); + throw new Error( + `Failed to convert PDF to images: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Check if a file is a PDF based on its MIME type + * @param file - The file to check + * @returns True if the file is a PDF + */ +export function isPdfFile(file: File): boolean { + return file.type === MimeTypeApplication.PDF; +} + +/** + * Check if a MIME type represents a PDF + * @param mimeType - The MIME type to check + * @returns True if the MIME type is application/pdf + */ +export function isApplicationMimeType(mimeType: string): boolean { + return mimeType === MimeTypeApplication.PDF; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/portal-to-body.ts b/llama.cpp/tools/server/webui/src/lib/utils/portal-to-body.ts new file mode 100644 index 0000000..bffbe89 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/portal-to-body.ts @@ -0,0 +1,20 @@ +export function portalToBody(node: HTMLElement) { + if (typeof document === 'undefined') { + return; + } + + const target = document.body; + if (!target) { + return; + } + + target.appendChild(node); + + return { + destroy() { + if (node.parentNode === target) { + target.removeChild(node); + } + } + }; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/precision.ts b/llama.cpp/tools/server/webui/src/lib/utils/precision.ts new file mode 100644 index 0000000..6da200c --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/precision.ts @@ -0,0 +1,25 @@ +/** + * Floating-point precision utilities + * + * Provides functions to normalize floating-point numbers for consistent comparison + * and display, addressing JavaScript's floating-point precision issues. + */ + +import { PRECISION_MULTIPLIER } from '$lib/constants/precision'; + +/** + * Normalize floating-point numbers for consistent comparison + * Addresses JavaScript floating-point precision issues (e.g., 0.949999988079071 → 0.95) + */ +export function normalizeFloatingPoint(value: unknown): unknown { + return typeof value === 'number' + ? Math.round(value * PRECISION_MULTIPLIER) / PRECISION_MULTIPLIER + : value; +} + +/** + * Type-safe version that only accepts numbers + */ +export function normalizeNumber(value: number): number { + return Math.round(value * PRECISION_MULTIPLIER) / PRECISION_MULTIPLIER; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/process-uploaded-files.ts b/llama.cpp/tools/server/webui/src/lib/utils/process-uploaded-files.ts new file mode 100644 index 0000000..0342dce --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/process-uploaded-files.ts @@ -0,0 +1,136 @@ +import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png'; +import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png'; +import { FileTypeCategory } from '$lib/enums'; +import { modelsStore } from '$lib/stores/models.svelte'; +import { settingsStore } from '$lib/stores/settings.svelte'; +import { toast } from 'svelte-sonner'; +import { getFileTypeCategory } from '$lib/utils'; +import { convertPDFToText } from './pdf-processing'; + +/** + * Read a file as a data URL (base64 encoded) + * @param file - The file to read + * @returns Promise resolving to the data URL string + */ +function readFileAsDataURL(file: File): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); +} + +/** + * Read a file as UTF-8 text + * @param file - The file to read + * @returns Promise resolving to the text content + */ +function readFileAsUTF8(file: File): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }); +} + +/** + * Process uploaded files into ChatUploadedFile format with previews and content + * + * This function processes various file types and generates appropriate previews: + * - Images: Base64 data URLs with format normalization (SVG/WebP → PNG) + * - Text files: UTF-8 content extraction + * - PDFs: Metadata only (processed later in conversion pipeline) + * - Audio: Base64 data URLs for preview + * + * @param files - Array of File objects to process + * @returns Promise resolving to array of ChatUploadedFile objects + */ +export async function processFilesToChatUploaded( + files: File[], + activeModelId?: string +): Promise<ChatUploadedFile[]> { + const results: ChatUploadedFile[] = []; + + for (const file of files) { + const id = Date.now().toString() + Math.random().toString(36).substr(2, 9); + const base: ChatUploadedFile = { + id, + name: file.name, + size: file.size, + type: file.type, + file + }; + + try { + if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) { + let preview = await readFileAsDataURL(file); + + // Normalize SVG and WebP to PNG in previews + if (isSvgMimeType(file.type)) { + try { + preview = await svgBase64UrlToPngDataURL(preview); + } catch (err) { + console.error('Failed to convert SVG to PNG:', err); + } + } else if (isWebpMimeType(file.type)) { + try { + preview = await webpBase64UrlToPngDataURL(preview); + } catch (err) { + console.error('Failed to convert WebP to PNG:', err); + } + } + + results.push({ ...base, preview }); + } else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) { + // Extract text content from PDF for preview + try { + const textContent = await convertPDFToText(file); + results.push({ ...base, textContent }); + } catch (err) { + console.warn('Failed to extract text from PDF, adding without content:', err); + results.push(base); + } + + // Show suggestion toast if vision model is available but PDF as image is disabled + const hasVisionSupport = activeModelId + ? modelsStore.modelSupportsVision(activeModelId) + : false; + const currentConfig = settingsStore.config; + if (hasVisionSupport && !currentConfig.pdfAsImage) { + toast.info(`You can enable parsing PDF as images with vision models.`, { + duration: 8000, + action: { + label: 'Enable PDF as Images', + onClick: () => { + settingsStore.updateConfig('pdfAsImage', true); + toast.success('PDF parsing as images enabled!', { + duration: 3000 + }); + } + } + }); + } + } else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) { + // Generate preview URL for audio files + const preview = await readFileAsDataURL(file); + results.push({ ...base, preview }); + } else { + // Fallback: treat unknown files as text + try { + const textContent = await readFileAsUTF8(file); + results.push({ ...base, textContent }); + } catch (err) { + console.warn('Failed to read file as text, adding without content:', err); + results.push(base); + } + } + } catch (error) { + console.error('Error processing file', file.name, error); + results.push(base); + } + } + + return results; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/svg-to-png.ts b/llama.cpp/tools/server/webui/src/lib/utils/svg-to-png.ts new file mode 100644 index 0000000..d5a7f7d --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/svg-to-png.ts @@ -0,0 +1,71 @@ +import { MimeTypeImage } from '$lib/enums'; + +/** + * Convert an SVG base64 data URL to a PNG data URL + * @param base64UrlSvg - The SVG base64 data URL to convert + * @param backgroundColor - Background color for the PNG (default: 'white') + * @returns Promise resolving to PNG data URL + */ +export function svgBase64UrlToPngDataURL( + base64UrlSvg: string, + backgroundColor: string = 'white' +): Promise<string> { + return new Promise((resolve, reject) => { + try { + const img = new Image(); + + img.onload = () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + reject(new Error('Failed to get 2D canvas context.')); + return; + } + + const targetWidth = img.naturalWidth || 300; + const targetHeight = img.naturalHeight || 300; + + canvas.width = targetWidth; + canvas.height = targetHeight; + + if (backgroundColor) { + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + ctx.drawImage(img, 0, 0, targetWidth, targetHeight); + + resolve(canvas.toDataURL(MimeTypeImage.PNG)); + }; + + img.onerror = () => { + reject(new Error('Failed to load SVG image. Ensure the SVG data is valid.')); + }; + + img.src = base64UrlSvg; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const errorMessage = `Error converting SVG to PNG: ${message}`; + console.error(errorMessage, error); + reject(new Error(errorMessage)); + } + }); +} + +/** + * Check if a file is an SVG based on its MIME type + * @param file - The file to check + * @returns True if the file is an SVG + */ +export function isSvgFile(file: File): boolean { + return file.type === MimeTypeImage.SVG; +} + +/** + * Check if a MIME type represents an SVG + * @param mimeType - The MIME type to check + * @returns True if the MIME type is image/svg+xml + */ +export function isSvgMimeType(mimeType: string): boolean { + return mimeType === MimeTypeImage.SVG; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/syntax-highlight-language.ts b/llama.cpp/tools/server/webui/src/lib/utils/syntax-highlight-language.ts new file mode 100644 index 0000000..5384291 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/syntax-highlight-language.ts @@ -0,0 +1,145 @@ +/** + * Maps file extensions to highlight.js language identifiers + */ +export function getLanguageFromFilename(filename: string): string { + const extension = filename.toLowerCase().substring(filename.lastIndexOf('.')); + + switch (extension) { + // JavaScript / TypeScript + case '.js': + case '.mjs': + case '.cjs': + return 'javascript'; + case '.ts': + case '.mts': + case '.cts': + return 'typescript'; + case '.jsx': + return 'javascript'; + case '.tsx': + return 'typescript'; + + // Web + case '.html': + case '.htm': + return 'html'; + case '.css': + return 'css'; + case '.scss': + return 'scss'; + case '.less': + return 'less'; + case '.vue': + return 'html'; + case '.svelte': + return 'html'; + + // Data formats + case '.json': + return 'json'; + case '.xml': + return 'xml'; + case '.yaml': + case '.yml': + return 'yaml'; + case '.toml': + return 'ini'; + case '.csv': + return 'plaintext'; + + // Programming languages + case '.py': + return 'python'; + case '.java': + return 'java'; + case '.kt': + case '.kts': + return 'kotlin'; + case '.scala': + return 'scala'; + case '.cpp': + case '.cc': + case '.cxx': + case '.c++': + return 'cpp'; + case '.c': + return 'c'; + case '.h': + case '.hpp': + return 'cpp'; + case '.cs': + return 'csharp'; + case '.go': + return 'go'; + case '.rs': + return 'rust'; + case '.rb': + return 'ruby'; + case '.php': + return 'php'; + case '.swift': + return 'swift'; + case '.dart': + return 'dart'; + case '.r': + return 'r'; + case '.lua': + return 'lua'; + case '.pl': + case '.pm': + return 'perl'; + + // Shell + case '.sh': + case '.bash': + case '.zsh': + return 'bash'; + case '.bat': + case '.cmd': + return 'dos'; + case '.ps1': + return 'powershell'; + + // Database + case '.sql': + return 'sql'; + + // Markup / Documentation + case '.md': + case '.markdown': + return 'markdown'; + case '.tex': + case '.latex': + return 'latex'; + case '.adoc': + case '.asciidoc': + return 'asciidoc'; + + // Config + case '.ini': + case '.cfg': + case '.conf': + return 'ini'; + case '.dockerfile': + return 'dockerfile'; + case '.nginx': + return 'nginx'; + + // Other + case '.graphql': + case '.gql': + return 'graphql'; + case '.proto': + return 'protobuf'; + case '.diff': + case '.patch': + return 'diff'; + case '.log': + return 'plaintext'; + case '.txt': + return 'plaintext'; + + default: + return 'plaintext'; + } +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/text-files.ts b/llama.cpp/tools/server/webui/src/lib/utils/text-files.ts new file mode 100644 index 0000000..e8006de --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/text-files.ts @@ -0,0 +1,97 @@ +/** + * Text file processing utilities + * Handles text file detection, reading, and validation + */ + +import { + DEFAULT_BINARY_DETECTION_OPTIONS, + type BinaryDetectionOptions +} from '$lib/constants/binary-detection'; +import { FileExtensionText } from '$lib/enums'; + +/** + * Check if a filename indicates a text file based on its extension + * @param filename - The filename to check + * @returns True if the filename has a recognized text file extension + */ +export function isTextFileByName(filename: string): boolean { + const textExtensions = Object.values(FileExtensionText); + + return textExtensions.some((ext: FileExtensionText) => filename.toLowerCase().endsWith(ext)); +} + +/** + * Read a file's content as text + * @param file - The file to read + * @returns Promise resolving to the file's text content + */ +export async function readFileAsText(file: File): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (event) => { + if (event.target?.result !== null && event.target?.result !== undefined) { + resolve(event.target.result as string); + } else { + reject(new Error('Failed to read file')); + } + }; + + reader.onerror = () => reject(new Error('File reading error')); + + reader.readAsText(file); + }); +} + +/** + * Heuristic check to determine if content is likely from a text file + * Detects binary files by counting suspicious characters and null bytes + * @param content - The file content to analyze + * @param options - Optional configuration for detection parameters + * @returns True if the content appears to be text-based + */ +export function isLikelyTextFile( + content: string, + options: Partial<BinaryDetectionOptions> = {} +): boolean { + if (!content) return true; + + const config = { ...DEFAULT_BINARY_DETECTION_OPTIONS, ...options }; + const sample = content.substring(0, config.prefixLength); + + let nullCount = 0; + let suspiciousControlCount = 0; + + for (let i = 0; i < sample.length; i++) { + const charCode = sample.charCodeAt(i); + + // Count null bytes - these are strong indicators of binary files + if (charCode === 0) { + nullCount++; + + continue; + } + + // Count suspicious control characters + // Allow common whitespace characters: tab (9), newline (10), carriage return (13) + if (charCode < 32 && charCode !== 9 && charCode !== 10 && charCode !== 13) { + // Count most suspicious control characters + if (charCode < 8 || (charCode > 13 && charCode < 27)) { + suspiciousControlCount++; + } + } + + // Count replacement characters (indicates encoding issues) + if (charCode === 0xfffd) { + suspiciousControlCount++; + } + } + + // Reject if too many null bytes + if (nullCount > config.maxAbsoluteNullBytes) return false; + + // Reject if too many suspicious characters + if (suspiciousControlCount / sample.length > config.suspiciousCharThresholdRatio) return false; + + return true; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/text.ts b/llama.cpp/tools/server/webui/src/lib/utils/text.ts new file mode 100644 index 0000000..5c5dd0f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/text.ts @@ -0,0 +1,7 @@ +/** + * Returns a shortened preview of the provided content capped at the given length. + * Appends an ellipsis when the content exceeds the maximum. + */ +export function getPreviewText(content: string, max = 150): string { + return content.length > max ? content.slice(0, max) + '...' : content; +} diff --git a/llama.cpp/tools/server/webui/src/lib/utils/webp-to-png.ts b/llama.cpp/tools/server/webui/src/lib/utils/webp-to-png.ts new file mode 100644 index 0000000..ea51838 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/utils/webp-to-png.ts @@ -0,0 +1,73 @@ +import { FileExtensionImage, MimeTypeImage } from '$lib/enums'; + +/** + * Convert a WebP base64 data URL to a PNG data URL + * @param base64UrlWebp - The WebP base64 data URL to convert + * @param backgroundColor - Background color for the PNG (default: 'white') + * @returns Promise resolving to PNG data URL + */ +export function webpBase64UrlToPngDataURL( + base64UrlWebp: string, + backgroundColor: string = 'white' +): Promise<string> { + return new Promise((resolve, reject) => { + try { + const img = new Image(); + + img.onload = () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + reject(new Error('Failed to get 2D canvas context.')); + return; + } + + const targetWidth = img.naturalWidth || 300; + const targetHeight = img.naturalHeight || 300; + + canvas.width = targetWidth; + canvas.height = targetHeight; + + if (backgroundColor) { + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + ctx.drawImage(img, 0, 0, targetWidth, targetHeight); + + resolve(canvas.toDataURL(MimeTypeImage.PNG)); + }; + + img.onerror = () => { + reject(new Error('Failed to load WebP image. Ensure the WebP data is valid.')); + }; + + img.src = base64UrlWebp; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const errorMessage = `Error converting WebP to PNG: ${message}`; + console.error(errorMessage, error); + reject(new Error(errorMessage)); + } + }); +} + +/** + * Check if a file is a WebP based on its MIME type + * @param file - The file to check + * @returns True if the file is a WebP + */ +export function isWebpFile(file: File): boolean { + return ( + file.type === MimeTypeImage.WEBP || file.name.toLowerCase().endsWith(FileExtensionImage.WEBP) + ); +} + +/** + * Check if a MIME type represents a WebP + * @param mimeType - The MIME type to check + * @returns True if the MIME type is image/webp + */ +export function isWebpMimeType(mimeType: string): boolean { + return mimeType === MimeTypeImage.WEBP; +} diff --git a/llama.cpp/tools/server/webui/src/routes/+error.svelte b/llama.cpp/tools/server/webui/src/routes/+error.svelte new file mode 100644 index 0000000..faddf0b --- /dev/null +++ b/llama.cpp/tools/server/webui/src/routes/+error.svelte @@ -0,0 +1,70 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { goto } from '$app/navigation'; + import { ServerErrorSplash } from '$lib/components/app'; + + let error = $derived($page.error); + let status = $derived($page.status); + + // Check if this is an API key related error + let isApiKeyError = $derived( + status === 401 || + status === 403 || + error?.message?.toLowerCase().includes('access denied') || + error?.message?.toLowerCase().includes('unauthorized') || + error?.message?.toLowerCase().includes('invalid api key') + ); + + function handleRetry() { + // Navigate back to home page after successful API key validation + goto('#/'); + } +</script> + +<svelte:head> + <title>Error {status} - WebUI</title> +</svelte:head> + +{#if isApiKeyError} + <ServerErrorSplash + error={error?.message || 'Access denied - check server permissions'} + onRetry={handleRetry} + showRetry={false} + showTroubleshooting={false} + /> +{:else} + <!-- Generic error page for non-API key errors --> + <div class="flex h-full items-center justify-center"> + <div class="w-full max-w-md px-4 text-center"> + <div class="mb-6"> + <div + class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10" + > + <svg + class="h-8 w-8 text-destructive" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" + /> + </svg> + </div> + <h1 class="mb-2 text-2xl font-bold">Error {status}</h1> + <p class="text-muted-foreground"> + {error?.message || 'Something went wrong'} + </p> + </div> + <button + onclick={() => goto('#/')} + class="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90" + > + Go Home + </button> + </div> + </div> +{/if} diff --git a/llama.cpp/tools/server/webui/src/routes/+layout.svelte b/llama.cpp/tools/server/webui/src/routes/+layout.svelte new file mode 100644 index 0000000..095827b --- /dev/null +++ b/llama.cpp/tools/server/webui/src/routes/+layout.svelte @@ -0,0 +1,223 @@ +<script lang="ts"> + import '../app.css'; + import { base } from '$app/paths'; + import { page } from '$app/state'; + import { untrack } from 'svelte'; + import { ChatSidebar, DialogConversationTitleUpdate } from '$lib/components/app'; + import { isLoading } from '$lib/stores/chat.svelte'; + import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte'; + import * as Sidebar from '$lib/components/ui/sidebar/index.js'; + import * as Tooltip from '$lib/components/ui/tooltip'; + import { isRouterMode, serverStore } from '$lib/stores/server.svelte'; + import { config, settingsStore } from '$lib/stores/settings.svelte'; + import { ModeWatcher } from 'mode-watcher'; + import { Toaster } from 'svelte-sonner'; + import { goto } from '$app/navigation'; + import { modelsStore } from '$lib/stores/models.svelte'; + import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config'; + import { IsMobile } from '$lib/hooks/is-mobile.svelte'; + + let { children } = $props(); + + let isChatRoute = $derived(page.route.id === '/chat/[id]'); + let isHomeRoute = $derived(page.route.id === '/'); + let isNewChatMode = $derived(page.url.searchParams.get('new_chat') === 'true'); + let showSidebarByDefault = $derived(activeMessages().length > 0 || isLoading()); + let alwaysShowSidebarOnDesktop = $derived(config().alwaysShowSidebarOnDesktop); + let autoShowSidebarOnNewChat = $derived(config().autoShowSidebarOnNewChat); + let isMobile = new IsMobile(); + let isDesktop = $derived(!isMobile.current); + let sidebarOpen = $state(false); + let innerHeight = $state<number | undefined>(); + let chatSidebar: + | { activateSearchMode?: () => void; editActiveConversation?: () => void } + | undefined = $state(); + + // Conversation title update dialog state + let titleUpdateDialogOpen = $state(false); + let titleUpdateCurrentTitle = $state(''); + let titleUpdateNewTitle = $state(''); + let titleUpdateResolve: ((value: boolean) => void) | null = null; + + // Global keyboard shortcuts + function handleKeydown(event: KeyboardEvent) { + const isCtrlOrCmd = event.ctrlKey || event.metaKey; + + if (isCtrlOrCmd && event.key === 'k') { + event.preventDefault(); + if (chatSidebar?.activateSearchMode) { + chatSidebar.activateSearchMode(); + sidebarOpen = true; + } + } + + if (isCtrlOrCmd && event.shiftKey && event.key === 'O') { + event.preventDefault(); + goto('?new_chat=true#/'); + } + + if (event.shiftKey && isCtrlOrCmd && event.key === 'E') { + event.preventDefault(); + + if (chatSidebar?.editActiveConversation) { + chatSidebar.editActiveConversation(); + } + } + } + + function handleTitleUpdateCancel() { + titleUpdateDialogOpen = false; + if (titleUpdateResolve) { + titleUpdateResolve(false); + titleUpdateResolve = null; + } + } + + function handleTitleUpdateConfirm() { + titleUpdateDialogOpen = false; + if (titleUpdateResolve) { + titleUpdateResolve(true); + titleUpdateResolve = null; + } + } + + $effect(() => { + if (alwaysShowSidebarOnDesktop && isDesktop) { + sidebarOpen = true; + return; + } + + if (isHomeRoute && !isNewChatMode) { + // Auto-collapse sidebar when navigating to home route (but not in new chat mode) + sidebarOpen = false; + } else if (isHomeRoute && isNewChatMode) { + // Keep sidebar open in new chat mode + sidebarOpen = true; + } else if (isChatRoute) { + // On chat routes, only auto-show sidebar if setting is enabled + if (autoShowSidebarOnNewChat) { + sidebarOpen = true; + } + // If setting is disabled, don't change sidebar state - let user control it manually + } else { + // Other routes follow default behavior + sidebarOpen = showSidebarByDefault; + } + }); + + // Initialize server properties on app load (run once) + $effect(() => { + // Only fetch if we don't already have props + if (!serverStore.props) { + untrack(() => { + serverStore.fetch(); + }); + } + }); + + // Sync settings when server props are loaded + $effect(() => { + const serverProps = serverStore.props; + + if (serverProps) { + settingsStore.syncWithServerDefaults(); + } + }); + + // Fetch router models when in router mode (for status and modalities) + // Wait for models to be loaded first, run only once + let routerModelsFetched = false; + + $effect(() => { + const isRouter = isRouterMode(); + const modelsCount = modelsStore.models.length; + + // Only fetch router models once when we have models loaded and in router mode + if (isRouter && modelsCount > 0 && !routerModelsFetched) { + routerModelsFetched = true; + untrack(() => { + modelsStore.fetchRouterModels(); + }); + } + }); + + // Monitor API key changes and redirect to error page if removed or changed when required + $effect(() => { + const apiKey = config().apiKey; + + if ( + (page.route.id === '/' || page.route.id === '/chat/[id]') && + page.status !== 401 && + page.status !== 403 + ) { + const headers: Record<string, string> = { + 'Content-Type': 'application/json' + }; + + if (apiKey && apiKey.trim() !== '') { + headers.Authorization = `Bearer ${apiKey.trim()}`; + } + + fetch(`${base}/props`, { headers }) + .then((response) => { + if (response.status === 401 || response.status === 403) { + window.location.reload(); + } + }) + .catch((e) => { + console.error('Error checking API key:', e); + }); + } + }); + + // Set up title update confirmation callback + $effect(() => { + conversationsStore.setTitleUpdateConfirmationCallback( + async (currentTitle: string, newTitle: string) => { + return new Promise<boolean>((resolve) => { + titleUpdateCurrentTitle = currentTitle; + titleUpdateNewTitle = newTitle; + titleUpdateResolve = resolve; + titleUpdateDialogOpen = true; + }); + } + ); + }); +</script> + +<Tooltip.Provider delayDuration={TOOLTIP_DELAY_DURATION}> + <ModeWatcher /> + + <Toaster richColors /> + + <DialogConversationTitleUpdate + bind:open={titleUpdateDialogOpen} + currentTitle={titleUpdateCurrentTitle} + newTitle={titleUpdateNewTitle} + onConfirm={handleTitleUpdateConfirm} + onCancel={handleTitleUpdateCancel} + /> + + <Sidebar.Provider bind:open={sidebarOpen}> + <div class="flex h-screen w-full" style:height="{innerHeight}px"> + <Sidebar.Root class="h-full"> + <ChatSidebar bind:this={chatSidebar} /> + </Sidebar.Root> + + {#if !(alwaysShowSidebarOnDesktop && isDesktop)} + <Sidebar.Trigger + class="transition-left absolute left-0 z-[900] h-8 w-8 duration-200 ease-linear {sidebarOpen + ? 'md:left-[var(--sidebar-width)]' + : ''}" + style="translate: 1rem 1rem;" + /> + {/if} + + <Sidebar.Inset class="flex flex-1 flex-col overflow-hidden"> + {@render children?.()} + </Sidebar.Inset> + </div> + </Sidebar.Provider> +</Tooltip.Provider> + +<svelte:window onkeydown={handleKeydown} bind:innerHeight /> diff --git a/llama.cpp/tools/server/webui/src/routes/+page.svelte b/llama.cpp/tools/server/webui/src/routes/+page.svelte new file mode 100644 index 0000000..32a7c2e --- /dev/null +++ b/llama.cpp/tools/server/webui/src/routes/+page.svelte @@ -0,0 +1,91 @@ +<script lang="ts"> + import { ChatScreen, DialogModelNotAvailable } from '$lib/components/app'; + import { chatStore } from '$lib/stores/chat.svelte'; + import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte'; + import { modelsStore, modelOptions } from '$lib/stores/models.svelte'; + import { onMount } from 'svelte'; + import { page } from '$app/state'; + import { replaceState } from '$app/navigation'; + + let qParam = $derived(page.url.searchParams.get('q')); + let modelParam = $derived(page.url.searchParams.get('model')); + let newChatParam = $derived(page.url.searchParams.get('new_chat')); + + // Dialog state for model not available error + let showModelNotAvailable = $state(false); + let requestedModelName = $state(''); + let availableModelNames = $derived(modelOptions().map((m) => m.model)); + + /** + * Clear URL params after message is sent to prevent re-sending on refresh + */ + function clearUrlParams() { + const url = new URL(page.url); + + url.searchParams.delete('q'); + url.searchParams.delete('model'); + url.searchParams.delete('new_chat'); + + replaceState(url.toString(), {}); + } + + async function handleUrlParams() { + await modelsStore.fetch(); + + if (modelParam) { + const model = modelsStore.findModelByName(modelParam); + + if (model) { + try { + await modelsStore.selectModelById(model.id); + } catch (error) { + console.error('Failed to select model:', error); + requestedModelName = modelParam; + showModelNotAvailable = true; + + return; + } + } else { + requestedModelName = modelParam; + showModelNotAvailable = true; + + return; + } + } + + // Handle ?q= parameter - create new conversation and send message + if (qParam !== null) { + await conversationsStore.createConversation(); + await chatStore.sendMessage(qParam); + clearUrlParams(); + } else if (modelParam || newChatParam === 'true') { + clearUrlParams(); + } + } + + onMount(async () => { + if (!isConversationsInitialized()) { + await conversationsStore.initialize(); + } + + conversationsStore.clearActiveConversation(); + chatStore.clearUIState(); + + // Handle URL params only if we have ?q= or ?model= or ?new_chat=true + if (qParam !== null || modelParam !== null || newChatParam === 'true') { + await handleUrlParams(); + } + }); +</script> + +<svelte:head> + <title>llama.cpp - AI Chat Interface</title> +</svelte:head> + +<ChatScreen showCenteredEmpty={true} /> + +<DialogModelNotAvailable + bind:open={showModelNotAvailable} + modelName={requestedModelName} + availableModels={availableModelNames} +/> diff --git a/llama.cpp/tools/server/webui/src/routes/+page.ts b/llama.cpp/tools/server/webui/src/routes/+page.ts new file mode 100644 index 0000000..7905af6 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/routes/+page.ts @@ -0,0 +1,6 @@ +import type { PageLoad } from './$types'; +import { validateApiKey } from '$lib/utils'; + +export const load: PageLoad = async ({ fetch }) => { + await validateApiKey(fetch); +}; diff --git a/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.svelte b/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.svelte new file mode 100644 index 0000000..b897ef5 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.svelte @@ -0,0 +1,176 @@ +<script lang="ts"> + import { goto, replaceState } from '$app/navigation'; + import { page } from '$app/state'; + import { afterNavigate } from '$app/navigation'; + import { ChatScreen, DialogModelNotAvailable } from '$lib/components/app'; + import { chatStore, isLoading } from '$lib/stores/chat.svelte'; + import { + conversationsStore, + activeConversation, + activeMessages + } from '$lib/stores/conversations.svelte'; + import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte'; + + let chatId = $derived(page.params.id); + let currentChatId: string | undefined = undefined; + + // URL parameters for prompt and model selection + let qParam = $derived(page.url.searchParams.get('q')); + let modelParam = $derived(page.url.searchParams.get('model')); + + // Dialog state for model not available error + let showModelNotAvailable = $state(false); + let requestedModelName = $state(''); + let availableModelNames = $derived(modelOptions().map((m) => m.model)); + + // Track if URL params have been processed for this chat + let urlParamsProcessed = $state(false); + + /** + * Clear URL params after message is sent to prevent re-sending on refresh + */ + function clearUrlParams() { + const url = new URL(page.url); + url.searchParams.delete('q'); + url.searchParams.delete('model'); + replaceState(url.toString(), {}); + } + + async function handleUrlParams() { + // Ensure models are loaded first + await modelsStore.fetch(); + + // Handle model parameter - select model if provided + if (modelParam) { + const model = modelsStore.findModelByName(modelParam); + if (model) { + try { + await modelsStore.selectModelById(model.id); + } catch (error) { + console.error('Failed to select model:', error); + requestedModelName = modelParam; + showModelNotAvailable = true; + return; + } + } else { + // Model not found - show error dialog + requestedModelName = modelParam; + showModelNotAvailable = true; + return; + } + } + + // Handle ?q= parameter - send message in current conversation + if (qParam !== null) { + await chatStore.sendMessage(qParam); + // Clear URL params after message is sent + clearUrlParams(); + } else if (modelParam) { + // Clear params even if no message was sent (just model selection) + clearUrlParams(); + } + + urlParamsProcessed = true; + } + + async function selectModelFromLastAssistantResponse() { + const messages = activeMessages(); + if (messages.length === 0) return; + + let lastMessageWithModel: DatabaseMessage | undefined; + + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].model) { + lastMessageWithModel = messages[i]; + break; + } + } + + if (!lastMessageWithModel) return; + + const currentModelId = selectedModelId(); + const currentModelName = modelOptions().find((m) => m.id === currentModelId)?.model; + + if (currentModelName === lastMessageWithModel.model) { + return; + } + + const matchingModel = modelOptions().find( + (option) => option.model === lastMessageWithModel.model + ); + + if (matchingModel) { + try { + await modelsStore.selectModelById(matchingModel.id); + console.log(`Automatically loaded model: ${lastMessageWithModel.model} from last message`); + } catch (error) { + console.warn('Failed to automatically select model from last message:', error); + } + } + } + + afterNavigate(() => { + setTimeout(() => { + selectModelFromLastAssistantResponse(); + }, 100); + }); + + $effect(() => { + if (chatId && chatId !== currentChatId) { + currentChatId = chatId; + urlParamsProcessed = false; // Reset for new chat + + // Skip loading if this conversation is already active (e.g., just created) + if (activeConversation()?.id === chatId) { + // Still handle URL params even if conversation is active + if ((qParam !== null || modelParam !== null) && !urlParamsProcessed) { + handleUrlParams(); + } + return; + } + + (async () => { + const success = await conversationsStore.loadConversation(chatId); + if (success) { + chatStore.syncLoadingStateForChat(chatId); + + // Handle URL params after conversation is loaded + if ((qParam !== null || modelParam !== null) && !urlParamsProcessed) { + await handleUrlParams(); + } + } else { + await goto('#/'); + } + })(); + } + }); + + $effect(() => { + if (typeof window !== 'undefined') { + const handleBeforeUnload = () => { + if (isLoading()) { + console.log('Page unload detected while streaming - aborting stream'); + chatStore.stopGeneration(); + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + } + }); +</script> + +<svelte:head> + <title>{activeConversation()?.name || 'Chat'} - llama.cpp</title> +</svelte:head> + +<ChatScreen /> + +<DialogModelNotAvailable + bind:open={showModelNotAvailable} + modelName={requestedModelName} + availableModels={availableModelNames} +/> diff --git a/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.ts b/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.ts new file mode 100644 index 0000000..7905af6 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.ts @@ -0,0 +1,6 @@ +import type { PageLoad } from './$types'; +import { validateApiKey } from '$lib/utils'; + +export const load: PageLoad = async ({ fetch }) => { + await validateApiKey(fetch); +}; diff --git a/llama.cpp/tools/server/webui/src/styles/katex-custom.scss b/llama.cpp/tools/server/webui/src/styles/katex-custom.scss new file mode 100644 index 0000000..9c8b96e --- /dev/null +++ b/llama.cpp/tools/server/webui/src/styles/katex-custom.scss @@ -0,0 +1,13 @@ +// Override KaTeX SCSS variables to disable ttf and woff fonts +// Only use woff2 format which is embedded in the bundle +$use-woff2: true; +$use-woff: false; +$use-ttf: false; + +// Use Vite alias for font folder +$font-folder: 'katex-fonts'; + +// Import KaTeX SCSS with overridden variables +// Note: @import is deprecated but required because KaTeX uses @import internally +// The deprecation warnings are from KaTeX's code and cannot be avoided +@import 'katex/src/styles/katex.scss'; |
