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/lib/components/app/misc | |
| download | llmnpc-b333b06772c89d96aacb5490d6a219fba7c09cc6.tar.gz | |
Engage!
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components/app/misc')
13 files changed, 1656 insertions, 0 deletions
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> |
