summaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/lib/components/app/misc
diff options
context:
space:
mode:
authorMitja Felicijan <mitja.felicijan@gmail.com>2026-02-12 20:57:17 +0100
committerMitja Felicijan <mitja.felicijan@gmail.com>2026-02-12 20:57:17 +0100
commitb333b06772c89d96aacb5490d6a219fba7c09cc6 (patch)
tree211df60083a5946baa2ed61d33d8121b7e251b06 /llama.cpp/tools/server/webui/src/lib/components/app/misc
downloadllmnpc-b333b06772c89d96aacb5490d6a219fba7c09cc6.tar.gz
Engage!
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components/app/misc')
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/ActionButton.svelte47
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/ActionDropdown.svelte86
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeChatStatistic.svelte44
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeInfo.svelte27
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeModality.svelte39
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/CodePreviewDialog.svelte93
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte205
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/CopyToClipboardIcon.svelte18
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/KeyboardShortcutInfo.svelte31
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte870
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/RemoveButton.svelte26
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/SearchInput.svelte73
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/SyntaxHighlightedCode.svelte97
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+ }
+ });
+</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>