import type { StorybookConfig } from '@storybook/sveltekit';
+import { dirname, resolve } from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
const config: StorybookConfig = {
stories: ['../tests/stories/**/*.mdx', '../tests/stories/**/*.stories.@(js|ts|svelte)'],
addons: [
'@storybook/addon-svelte-csf',
'@chromatic-com/storybook',
- '@storybook/addon-docs',
+ '@storybook/addon-vitest',
'@storybook/addon-a11y',
- '@storybook/addon-vitest'
+ '@storybook/addon-docs'
],
- framework: {
- name: '@storybook/sveltekit',
- options: {}
+ framework: '@storybook/sveltekit',
+ viteFinal: async (config) => {
+ config.server = config.server || {};
+ config.server.fs = config.server.fs || {};
+ config.server.fs.allow = [...(config.server.fs.allow || []), resolve(__dirname, '../tests')];
+ return config;
}
};
export default config;
},
backgrounds: {
- disable: true
+ disabled: true
},
a11y: {
settingsStore->>serverStore: defaultParams
serverStore-->>settingsStore: {temperature, top_p, top_k, ...}
- settingsStore->>ParamSvc: extractServerDefaults(defaultParams)
- ParamSvc-->>settingsStore: Record<string, value>
+ loop each SYNCABLE_PARAMETER
+ alt key NOT in userOverrides
+ settingsStore->>settingsStore: config[key] = serverDefault[key]
+ Note right of settingsStore: Non-overridden params adopt server default
+ else key in userOverrides
+ Note right of settingsStore: Keep user value, skip server default
+ end
+ end
- settingsStore->>ParamSvc: mergeWithServerDefaults(config, serverDefaults)
- Note right of ParamSvc: For each syncable parameter:<br/>- If NOT in userOverrides → use server default<br/>- If in userOverrides → keep user value
- ParamSvc-->>settingsStore: mergedConfig
+ alt serverStore.props has webuiSettings
+ settingsStore->>settingsStore: Apply webuiSettings from server
+ Note right of settingsStore: Server-provided UI settings<br/>(e.g. showRawOutputSwitch)
+ end
- settingsStore->>settingsStore: config = mergedConfig
settingsStore->>settingsStore: saveConfig()
deactivate settingsStore
UI->>settingsStore: updateConfig(key, value)
activate settingsStore
settingsStore->>settingsStore: config[key] = value
- settingsStore->>settingsStore: userOverrides.add(key)
- Note right of settingsStore: Mark as user-modified (won't be overwritten by server)
+
+ alt value matches server default for key
+ settingsStore->>settingsStore: userOverrides.delete(key)
+ Note right of settingsStore: Matches server default, remove override
+ else value differs from server default
+ settingsStore->>settingsStore: userOverrides.add(key)
+ Note right of settingsStore: Mark as user-modified (won't be overwritten)
+ end
+
settingsStore->>settingsStore: saveConfig()
- settingsStore->>LS: set("llama-config", config)
- settingsStore->>LS: set("llama-userOverrides", [...userOverrides])
+ settingsStore->>LS: set(CONFIG_LOCALSTORAGE_KEY, config)
+ settingsStore->>LS: set(USER_OVERRIDES_LOCALSTORAGE_KEY, [...userOverrides])
deactivate settingsStore
UI->>settingsStore: updateMultipleConfig({key1: val1, key2: val2})
UI->>settingsStore: resetConfig()
activate settingsStore
- settingsStore->>settingsStore: config = SETTING_CONFIG_DEFAULT
+ settingsStore->>settingsStore: config = {...SETTING_CONFIG_DEFAULT}
settingsStore->>settingsStore: userOverrides.clear()
- settingsStore->>settingsStore: syncWithServerDefaults()
- Note right of settingsStore: Apply server defaults for syncable params
+ Note right of settingsStore: All params reset to defaults<br/>Next syncWithServerDefaults will adopt server values
settingsStore->>settingsStore: saveConfig()
deactivate settingsStore
<script lang="ts">
import { Eye } from '@lucide/svelte';
- import ActionIconCopyToClipboard from '$lib/components/app/actions/ActionIconCopyToClipboard.svelte';
+ import { ActionIconCopyToClipboard } from '$lib/components/app';
import { FileTypeText } from '$lib/enums';
interface Props {
let currentConfig = $derived(config());
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
let isRecording = $state(false);
- let message = $state(initialMessage);
+ let message = $derived(initialMessage);
let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
});
- let previousIsLoading = $state(isLoading);
- let previousInitialMessage = $state(initialMessage);
+ let previousIsLoading = $derived(isLoading);
+ let previousInitialMessage = $derived(initialMessage);
let recordingSupported = $state(false);
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
<form
onsubmit={handleSubmit}
- class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled
+ class="relative {INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled
? 'cursor-not-allowed opacity-60'
: ''} {className}"
data-slot="chat-form"
/>
<div
- class="flex-column relative min-h-[48px] items-center rounded-3xl px-5 py-3 shadow-sm transition-all focus-within:shadow-md"
+ class="flex-column relative min-h-[48px] items-center rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:!py-3"
onpaste={handlePaste}
>
<ChatFormTextarea
+ class="px-5 py-1.5 md:pt-0"
bind:this={textareaRef}
bind:value={message}
onKeydown={handleKeydown}
/>
<ChatFormActions
+ class="px-3"
bind:this={chatFormActionsRef}
canSend={message.trim().length > 0 || uploadedFiles.length > 0}
hasText={message.trim().length > 0}
--- /dev/null
+<script lang="ts">
+ import { page } from '$app/state';
+ import { MessageSquare, Plus } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { FILE_TYPE_ICONS } from '$lib/constants/icons';
+ import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
+
+ interface Props {
+ class?: string;
+ disabled?: boolean;
+ hasAudioModality?: boolean;
+ hasVisionModality?: boolean;
+ onFileUpload?: () => void;
+ onSystemPromptClick?: () => void;
+ }
+
+ type AttachmentActionId = 'images' | 'audio' | 'text' | 'pdf' | 'system';
+
+ interface AttachmentAction {
+ id: AttachmentActionId;
+ label: string;
+ disabled?: boolean;
+ disabledReason?: string;
+ tooltip?: string;
+ }
+
+ let {
+ class: className = '',
+ disabled = false,
+ hasAudioModality = false,
+ hasVisionModality = false,
+ onFileUpload,
+ onSystemPromptClick
+ }: Props = $props();
+
+ let isNewChat = $derived(!page.params.id);
+ let systemMessageTooltip = $derived(
+ isNewChat
+ ? 'Add custom system message for a new conversation'
+ : 'Inject custom system message at the beginning of the conversation'
+ );
+
+ let actions = $derived.by<AttachmentAction[]>(() => [
+ {
+ id: 'images',
+ label: 'Images',
+ disabled: !hasVisionModality,
+ disabledReason: !hasVisionModality
+ ? 'Images require vision models to be processed'
+ : undefined
+ },
+ {
+ id: 'audio',
+ label: 'Audio Files',
+ disabled: !hasAudioModality,
+ disabledReason: !hasAudioModality
+ ? 'Audio files require audio models to be processed'
+ : undefined
+ },
+ {
+ id: 'text',
+ label: 'Text Files'
+ },
+ {
+ id: 'pdf',
+ label: 'PDF Files',
+ tooltip: !hasVisionModality
+ ? 'PDFs will be converted to text. Image-based PDFs may not work properly.'
+ : undefined
+ },
+ {
+ id: 'system',
+ label: 'System Message',
+ tooltip: systemMessageTooltip
+ }
+ ]);
+
+ function handleActionClick(id: AttachmentActionId) {
+ if (id === 'system') {
+ onSystemPromptClick?.();
+ return;
+ }
+
+ onFileUpload?.();
+ }
+
+ const triggerTooltipText = 'Add files or system message';
+ const itemClass = 'flex cursor-pointer items-center gap-2';
+</script>
+
+<div class="flex items-center gap-1 {className}">
+ <DropdownMenu.Root>
+ <DropdownMenu.Trigger name="Attach files" {disabled}>
+ <Tooltip.Root>
+ <Tooltip.Trigger class="w-full">
+ <Button
+ class="file-upload-button h-8 w-8 rounded-full p-0"
+ {disabled}
+ variant="secondary"
+ type="button"
+ >
+ <span class="sr-only">{triggerTooltipText}</span>
+
+ <Plus class="h-4 w-4" />
+ </Button>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>{triggerTooltipText}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ </DropdownMenu.Trigger>
+
+ <DropdownMenu.Content align="start" class="w-56">
+ {#each actions as item (item.id)}
+ {@const hasDisabledTooltip = !!item.disabled && !!item.disabledReason}
+ {@const hasEnabledTooltip = !item.disabled && !!item.tooltip}
+
+ {#if hasDisabledTooltip}
+ <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
+ <Tooltip.Trigger class="w-full">
+ <DropdownMenu.Item class={itemClass} disabled>
+ {#if item.id === 'images'}
+ <FILE_TYPE_ICONS.image class="h-4 w-4" />
+ {:else if item.id === 'audio'}
+ <FILE_TYPE_ICONS.audio class="h-4 w-4" />
+ {:else if item.id === 'text'}
+ <FILE_TYPE_ICONS.text class="h-4 w-4" />
+ {:else if item.id === 'pdf'}
+ <FILE_TYPE_ICONS.pdf class="h-4 w-4" />
+ {:else}
+ <MessageSquare class="h-4 w-4" />
+ {/if}
+
+ <span>{item.label}</span>
+ </DropdownMenu.Item>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content side="right">
+ <p>{item.disabledReason}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {:else if hasEnabledTooltip}
+ <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
+ <Tooltip.Trigger class="w-full">
+ <DropdownMenu.Item class={itemClass} onclick={() => handleActionClick(item.id)}>
+ {#if item.id === 'images'}
+ <FILE_TYPE_ICONS.image class="h-4 w-4" />
+ {:else if item.id === 'audio'}
+ <FILE_TYPE_ICONS.audio class="h-4 w-4" />
+ {:else if item.id === 'text'}
+ <FILE_TYPE_ICONS.text class="h-4 w-4" />
+ {:else if item.id === 'pdf'}
+ <FILE_TYPE_ICONS.pdf class="h-4 w-4" />
+ {:else}
+ <MessageSquare class="h-4 w-4" />
+ {/if}
+
+ <span>{item.label}</span>
+ </DropdownMenu.Item>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content side="right">
+ <p>{item.tooltip}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {:else}
+ <DropdownMenu.Item class={itemClass} onclick={() => handleActionClick(item.id)}>
+ {#if item.id === 'images'}
+ <FILE_TYPE_ICONS.image class="h-4 w-4" />
+ {:else if item.id === 'audio'}
+ <FILE_TYPE_ICONS.audio class="h-4 w-4" />
+ {:else if item.id === 'text'}
+ <FILE_TYPE_ICONS.text class="h-4 w-4" />
+ {:else if item.id === 'pdf'}
+ <FILE_TYPE_ICONS.pdf class="h-4 w-4" />
+ {:else}
+ <MessageSquare class="h-4 w-4" />
+ {/if}
+
+ <span>{item.label}</span>
+ </DropdownMenu.Item>
+ {/if}
+ {/each}
+ </DropdownMenu.Content>
+ </DropdownMenu.Root>
+</div>
import { Square } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import {
- ChatFormActionFileAttachments,
+ ChatFormActionAttachmentsDropdown,
ChatFormActionRecord,
ChatFormActionSubmit,
ModelsSelector
const { handleModelChange } = useModelChangeValidation({
getRequiredModalities: () => usedModalities(),
- onValidationFailure: async (previousModelId) => {
+ onValidationFailure: async (previousModelId: string | null) => {
if (previousModelId) {
await modelsStore.selectModelById(previousModelId);
}
</script>
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
- <ChatFormActionFileAttachments
- class="mr-auto"
- {disabled}
- {hasAudioModality}
- {hasVisionModality}
- {onFileUpload}
- {onSystemPromptClick}
- />
-
- <ModelsSelector
- {disabled}
- bind:this={selectorModelRef}
- currentModel={conversationModel}
- forceForegroundText={true}
- useGlobalSelection={true}
- onModelChange={handleModelChange}
- />
+ <div class="mr-auto flex items-center gap-2">
+ <ChatFormActionAttachmentsDropdown
+ {disabled}
+ {hasAudioModality}
+ {hasVisionModality}
+ {onFileUpload}
+ {onSystemPromptClick}
+ />
+ </div>
+
+ <div class="ml-auto flex items-center gap-1.5">
+ <ModelsSelector
+ {disabled}
+ bind:this={selectorModelRef}
+ currentModel={conversationModel}
+ forceForegroundText={true}
+ useGlobalSelection={true}
+ onModelChange={handleModelChange}
+ />
+ </div>
{#if isLoading}
<Button
type="button"
+ variant="secondary"
onclick={onStop}
- class="h-8 w-8 bg-transparent p-0 hover:bg-destructive/20"
+ class="group h-8 w-8 rounded-full p-0 hover:bg-destructive/10!"
>
<span class="sr-only">Stop</span>
- <Square class="h-8 w-8 fill-destructive stroke-destructive" />
+
+ <Square
+ class="h-8 w-8 fill-muted-foreground stroke-muted-foreground group-hover:fill-destructive group-hover:stroke-destructive hover:fill-destructive hover:stroke-destructive"
+ />
</Button>
{:else if shouldShowRecordButton}
<ChatFormActionRecord {disabled} {hasAudioModality} {isLoading} {isRecording} {onMicClick} />
assistantMessages: number;
messageTypes: string[];
} | null>(null);
- let editedContent = $state(message.content);
- let editedExtras = $state<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
+ let editedContent = $derived(message.content);
+ let editedExtras = $derived<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
let isEditing = $state(false);
let showDeleteDialog = $state(false);
const { handleModelChange } = useModelChangeValidation({
getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
- onSuccess: (modelName) => onRegenerate(modelName)
+ onSuccess: (modelName: string) => onRegenerate(modelName)
});
function handleCopyModel() {
const { handleModelChange } = useModelChangeValidation({
getRequiredModalities,
- onValidationFailure: async (previousModelId) => {
+ onValidationFailure: async (previousModelId: string | null) => {
if (previousModelId) {
await modelsStore.selectModelById(previousModelId);
}
initialView = ChatMessageStatsView.GENERATION
}: Props = $props();
- let activeView: ChatMessageStatsView = $state(initialView);
+ let activeView: ChatMessageStatsView = $derived(initialView);
let hasAutoSwitchedToGeneration = $state(false);
// In live mode: auto-switch to GENERATION tab when prompt processing completes
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
+ import { ErrorDialogType } from '$lib/enums';
import { onMount } from 'svelte';
import { fade, fly, slide } from 'svelte/transition';
import { Trash2, AlertTriangle, RefreshCw } from '@lucide/svelte';
contextInfo={activeErrorDialog?.contextInfo}
onOpenChange={handleErrorDialogOpenChange}
open={Boolean(activeErrorDialog)}
- type={activeErrorDialog?.type ?? 'server'}
+ type={(activeErrorDialog?.type as ErrorDialogType) ?? ErrorDialogType.SERVER}
/>
<style>
--- /dev/null
+<script lang="ts">
+ import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
+
+ interface Props {
+ class?: string;
+ disabled?: boolean;
+ initialMessage?: string;
+ isLoading?: boolean;
+ onFileRemove?: (fileId: string) => void;
+ onFileUpload?: (files: File[]) => void;
+ onSend?: (message: string, files?: ChatUploadedFile[]) => Promise<boolean>;
+ onStop?: () => void;
+ onSystemPromptAdd?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
+ showHelperText?: boolean;
+ uploadedFiles?: ChatUploadedFile[];
+ }
+
+ let {
+ class: className,
+ disabled = false,
+ initialMessage = '',
+ isLoading = false,
+ onFileRemove,
+ onFileUpload,
+ onSend,
+ onStop,
+ onSystemPromptAdd,
+ showHelperText = true,
+ uploadedFiles = $bindable([])
+ }: Props = $props();
+</script>
+
+<div class="relative mx-auto max-w-[48rem]">
+ <ChatForm
+ class={className}
+ {disabled}
+ {initialMessage}
+ {isLoading}
+ {onFileRemove}
+ {onFileUpload}
+ {onSend}
+ {onStop}
+ {onSystemPromptAdd}
+ {showHelperText}
+ bind:uploadedFiles
+ />
+</div>
} from '$lib/components/app';
import { ScrollArea } from '$lib/components/ui/scroll-area';
import { config, settingsStore } from '$lib/stores/settings.svelte';
+ import {
+ SETTINGS_SECTION_TITLES,
+ type SettingsSectionTitle
+ } from '$lib/constants/settings-sections';
import { setMode } from 'mode-watcher';
import type { Component } from 'svelte';
interface Props {
onSave?: () => void;
+ initialSection?: SettingsSectionTitle;
}
- let { onSave }: Props = $props();
+ let { onSave, initialSection }: Props = $props();
const settingSections: Array<{
fields: SettingsFieldConfig[];
icon: Component;
- title: string;
+ title: SettingsSectionTitle;
}> = [
{
title: 'General',
// }
];
- let activeSection = $state('General');
+ let activeSection = $derived<SettingsSectionTitle>(
+ initialSection ?? SETTINGS_SECTION_TITLES.GENERAL
+ );
let currentSection = $derived(
settingSections.find((section) => section.title === activeSection) || settingSections[0]
);
let canScrollRight = $state(false);
let scrollContainer: HTMLDivElement | undefined = $state();
+ $effect(() => {
+ if (!initialSection) {
+ return;
+ }
+
+ if (settingSections.some((section) => section.title === initialSection)) {
+ activeSection = initialSection;
+ }
+ });
+
function handleThemeChange(newTheme: string) {
localConfig.theme = newTheme;
{
icon: Download,
label: 'Export',
- onclick: (e) => {
+ onclick: (e: Event) => {
e.stopPropagation();
conversationsStore.downloadConversation(conversation.id);
},
import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
import { rehypeEnhanceLinks } from '$lib/markdown/enhance-links';
import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks';
+ import { rehypeResolveAttachmentImages } from '$lib/markdown/resolve-attachment-images';
import { remarkLiteralHtml } from '$lib/markdown/literal-html';
import { copyCodeToClipboard, preprocessLaTeX, getImageErrorFallbackHtml } from '$lib/utils';
import {
DATA_ERROR_HANDLED_ATTR,
BOOL_TRUE_STRING
} from '$lib/constants/markdown';
+ import { UrlPrefix } from '$lib/enums';
import { FileTypeText } from '$lib/enums/files';
import {
highlightCode,
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 ActionIconsCodeBlock from '$lib/components/app/actions/ActionIconsCodeBlock.svelte';
- import DialogCodePreview from '$lib/components/app/misc/CodePreviewDialog.svelte';
+ import { ActionIconsCodeBlock, DialogCodePreview } from '$lib/components/app';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import type { DatabaseMessageExtra } from '$lib/types/database';
.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(rehypeResolveAttachmentImages, { attachments })
.use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
});
if (!img || !img.src) return;
// Don't handle data URLs or already-handled images
- if (img.src.startsWith('data:') || img.dataset[DATA_ERROR_HANDLED_ATTR] === BOOL_TRUE_STRING)
+ if (
+ img.src.startsWith(UrlPrefix.DATA) ||
+ img.dataset[DATA_ERROR_HANDLED_ATTR] === BOOL_TRUE_STRING
+ )
return;
img.dataset[DATA_ERROR_HANDLED_ATTR] = BOOL_TRUE_STRING;
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { AlertTriangle, TimerOff } from '@lucide/svelte';
+ import { ErrorDialogType } from '$lib/enums';
interface Props {
open: boolean;
- type: 'timeout' | 'server';
+ type: ErrorDialogType;
message: string;
contextInfo?: { n_prompt_tokens: number; n_ctx: number };
onOpenChange?: (open: boolean) => void;
let { open = $bindable(), type, message, contextInfo, onOpenChange }: Props = $props();
- const isTimeout = $derived(type === 'timeout');
+ const isTimeout = $derived(type === ErrorDialogType.TIMEOUT);
const title = $derived(isTimeout ? 'TCP Timeout' : 'Server Error');
const description = $derived(
isTimeout
<span class="font-medium">Prompt tokens:</span>
{contextInfo.n_prompt_tokens.toLocaleString()}
</p>
- <p><span class="font-medium">Context size:</span> {contextInfo.n_ctx.toLocaleString()}</p>
+ {#if contextInfo.n_ctx}
+ <p>
+ <span class="font-medium">Context size:</span>
+ {contextInfo.n_ctx.toLocaleString()}
+ </p>
+ {/if}
</div>
{/if}
</div>
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { ChatSettings } from '$lib/components/app';
+ import type { SettingsSectionTitle } from '$lib/constants/settings-sections';
interface Props {
onOpenChange?: (open: boolean) => void;
open?: boolean;
+ initialSection?: SettingsSectionTitle;
}
- let { onOpenChange, open = false }: Props = $props();
+ let { onOpenChange, open = false, initialSection }: Props = $props();
let chatSettingsRef: ChatSettings | undefined = $state();
<Dialog.Root {open} onOpenChange={handleClose}>
<Dialog.Content
- class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
- md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
- style="max-width: 48rem;"
+ class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] max-w-4xl! flex-col gap-0 rounded-none
+ p-0 md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
>
- <ChatSettings bind:this={chatSettingsRef} onSave={handleSave} />
+ <ChatSettings bind:this={chatSettingsRef} onSave={handleSave} {initialSection} />
</Dialog.Content>
</Dialog.Root>
--- /dev/null
+<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 allow-same-origin"
+ 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>
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import type { Component } from 'svelte';
+ import { KeyboardKey } from '$lib/enums';
interface Props {
open: boolean;
}: Props = $props();
function handleKeydown(event: KeyboardEvent) {
- if (event.key === 'Enter') {
+ if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
onConfirm();
}
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Table from '$lib/components/ui/table';
- import { BadgeModality, CopyToClipboardIcon } from '$lib/components/app';
+ import { BadgeModality, ActionIconCopyToClipboard } from '$lib/components/app';
import { serverStore } from '$lib/stores/server.svelte';
import { modelsStore, modelOptions, modelsLoading } from '$lib/stores/models.svelte';
import { formatFileSize, formatParameters, formatNumber } from '$lib/utils';
<Dialog.Header>
<Dialog.Title>Model Information</Dialog.Title>
+
<Dialog.Description>Current model details and capabilities</Dialog.Description>
</Dialog.Header>
{modelName}
</span>
- <CopyToClipboardIcon
+ <ActionIconCopyToClipboard
text={modelName || ''}
canCopy={!!modelName}
ariaLabel="Copy model name to clipboard"
{serverProps.model_path}
</span>
- <CopyToClipboardIcon
+ <ActionIconCopyToClipboard
text={serverProps.model_path}
ariaLabel="Copy model path to clipboard"
/>
</Table.Row>
<!-- Context Size -->
- <Table.Row>
- <Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell>
- <Table.Cell
- >{formatNumber(serverProps.default_generation_settings.n_ctx)} tokens</Table.Cell
- >
- </Table.Row>
+ {#if serverProps?.default_generation_settings?.n_ctx}
+ <Table.Row>
+ <Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell>
+
+ <Table.Cell
+ >{formatNumber(serverProps.default_generation_settings.n_ctx)} tokens</Table.Cell
+ >
+ </Table.Row>
+ {:else}
+ <Table.Row>
+ <Table.Cell class="h-10 align-middle font-medium text-red-500"
+ >Context Size</Table.Cell
+ >
+
+ <Table.Cell class="text-red-500">Not available</Table.Cell>
+ </Table.Row>
+ {/if}
<!-- Training Context -->
{#if modelMeta?.n_ctx_train}
<Table.Row>
<Table.Cell class="h-10 align-middle font-medium">Training Context</Table.Cell>
+
<Table.Cell>{formatNumber(modelMeta.n_ctx_train)} tokens</Table.Cell>
</Table.Row>
{/if}
{#if modelMeta?.size}
<Table.Row>
<Table.Cell class="h-10 align-middle font-medium">Model Size</Table.Cell>
+
<Table.Cell>{formatFileSize(modelMeta.size)}</Table.Cell>
</Table.Row>
{/if}
{#if modelMeta?.n_params}
<Table.Row>
<Table.Cell class="h-10 align-middle font-medium">Parameters</Table.Cell>
+
<Table.Cell>{formatParameters(modelMeta.n_params)}</Table.Cell>
</Table.Row>
{/if}
{#if modelMeta?.n_embd}
<Table.Row>
<Table.Cell class="align-middle font-medium">Embedding Size</Table.Cell>
+
<Table.Cell>{formatNumber(modelMeta.n_embd)}</Table.Cell>
</Table.Row>
{/if}
{#if modelMeta?.n_vocab}
<Table.Row>
<Table.Cell class="align-middle font-medium">Vocabulary Size</Table.Cell>
+
<Table.Cell>{formatNumber(modelMeta.n_vocab)} tokens</Table.Cell>
</Table.Row>
{/if}
<!-- Total Slots -->
<Table.Row>
<Table.Cell class="align-middle font-medium">Parallel Slots</Table.Cell>
+
<Table.Cell>{serverProps.total_slots}</Table.Cell>
</Table.Row>
{#if modalities.length > 0}
<Table.Row>
<Table.Cell class="align-middle font-medium">Modalities</Table.Cell>
+
<Table.Cell>
<div class="flex flex-wrap gap-1">
<BadgeModality {modalities} />
<!-- Build Info -->
<Table.Row>
<Table.Cell class="align-middle font-medium">Build Info</Table.Cell>
+
<Table.Cell class="align-middle font-mono text-xs"
>{serverProps.build_info}</Table.Cell
>
{#if serverProps.chat_template}
<Table.Row>
<Table.Cell class="align-middle font-medium">Chat Template</Table.Cell>
+
<Table.Cell class="py-10">
<div class="max-h-120 overflow-y-auto rounded-md bg-muted p-4">
<pre
--- /dev/null
+<script lang="ts">
+ import { Plus, Trash2 } from '@lucide/svelte';
+ import { Input } from '$lib/components/ui/input';
+ import { autoResizeTextarea } from '$lib/utils';
+ import type { KeyValuePair } from '$lib/types';
+
+ interface Props {
+ class?: string;
+ pairs: KeyValuePair[];
+ onPairsChange: (pairs: KeyValuePair[]) => void;
+ keyPlaceholder?: string;
+ valuePlaceholder?: string;
+ addButtonLabel?: string;
+ emptyMessage?: string;
+ sectionLabel?: string;
+ sectionLabelOptional?: boolean;
+ }
+
+ let {
+ class: className = '',
+ pairs,
+ onPairsChange,
+ keyPlaceholder = 'Key',
+ valuePlaceholder = 'Value',
+ addButtonLabel = 'Add',
+ emptyMessage = 'No items configured.',
+ sectionLabel,
+ sectionLabelOptional = true
+ }: Props = $props();
+
+ function addPair() {
+ onPairsChange([...pairs, { key: '', value: '' }]);
+ }
+
+ function removePair(index: number) {
+ onPairsChange(pairs.filter((_, i) => i !== index));
+ }
+
+ function updatePairKey(index: number, key: string) {
+ const newPairs = [...pairs];
+ newPairs[index] = { ...newPairs[index], key };
+ onPairsChange(newPairs);
+ }
+
+ function updatePairValue(index: number, value: string) {
+ const newPairs = [...pairs];
+ newPairs[index] = { ...newPairs[index], value };
+ onPairsChange(newPairs);
+ }
+</script>
+
+<div class={className}>
+ <div class="mb-2 flex items-center justify-between">
+ {#if sectionLabel}
+ <span class="text-xs font-medium">
+ {sectionLabel}
+ {#if sectionLabelOptional}
+ <span class="text-muted-foreground">(optional)</span>
+ {/if}
+ </span>
+ {/if}
+
+ <button
+ type="button"
+ class="inline-flex cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
+ onclick={addPair}
+ >
+ <Plus class="h-3 w-3" />
+ {addButtonLabel}
+ </button>
+ </div>
+ {#if pairs.length > 0}
+ <div class="space-y-3">
+ {#each pairs as pair, index (index)}
+ <div class="flex items-start gap-2">
+ <Input
+ type="text"
+ placeholder={keyPlaceholder}
+ value={pair.key}
+ oninput={(e) => updatePairKey(index, e.currentTarget.value)}
+ class="flex-1"
+ />
+
+ <textarea
+ use:autoResizeTextarea
+ placeholder={valuePlaceholder}
+ value={pair.value}
+ oninput={(e) => {
+ updatePairValue(index, e.currentTarget.value);
+ autoResizeTextarea(e.currentTarget);
+ }}
+ class="flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm leading-5 placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
+ rows="1"
+ ></textarea>
+
+ <button
+ type="button"
+ class="mt-1.5 shrink-0 cursor-pointer rounded-md p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
+ onclick={() => removePair(index)}
+ aria-label="Remove item"
+ >
+ <Trash2 class="h-3.5 w-3.5" />
+ </button>
+ </div>
+ {/each}
+ </div>
+ {:else}
+ <p class="text-xs text-muted-foreground">{emptyMessage}</p>
+ {/if}
+</div>
--- /dev/null
+<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 z-10 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>
--- /dev/null
+/**
+ *
+ * FORMS & INPUTS
+ *
+ * Form-related utility components.
+ *
+ */
+
+/**
+ * **SearchInput** - Search field with clear button
+ *
+ * Input field optimized for search with clear button and keyboard handling.
+ * Supports placeholder, autofocus, and change callbacks.
+ */
+export { default as SearchInput } from './SearchInput.svelte';
+
+/**
+ * **KeyValuePairs** - Editable key-value list
+ *
+ * Dynamic list of key-value pairs with add/remove functionality.
+ * Used for HTTP headers, metadata, and configuration.
+ *
+ * **Features:**
+ * - Add new pairs with button
+ * - Remove individual pairs
+ * - Customizable placeholders and labels
+ * - Empty state message
+ * - Auto-resize value textarea
+ */
+export { default as KeyValuePairs } from './KeyValuePairs.svelte';
-// Chat
+export * from './actions';
+export * from './badges';
+export * from './content';
+export * from './forms';
+export * from './misc';
+export * from './models';
+export * from './navigation';
+export * from './server';
+// Chat
export { default as ChatAttachmentPreview } from './chat/ChatAttachments/ChatAttachmentPreview.svelte';
export { default as ChatAttachmentThumbnailFile } from './chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte';
export { default as ChatAttachmentThumbnailImage } from './chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte';
export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttachmentsList.svelte';
export { default as ChatAttachmentsViewAll } from './chat/ChatAttachments/ChatAttachmentsViewAll.svelte';
-
export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte';
+export { default as ChatFormActionAttachmentsDropdown } from './chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte';
export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte';
export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte';
export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
-
export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
export { default as ChatMessageActions } from './chat/ChatMessages/ChatMessageActions.svelte';
+export { default as ChatMessageAssistant } from './chat/ChatMessages/ChatMessageAssistant.svelte';
export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
+export { default as ChatMessageEditForm } from './chat/ChatMessages/ChatMessageEditForm.svelte';
export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.svelte';
export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte';
export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
+export { default as ChatMessageUser } from './chat/ChatMessages/ChatMessageUser.svelte';
export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
-
export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
+export { default as ChatScreenDragOverlay } from './chat/ChatScreen/ChatScreenDragOverlay.svelte';
+export { default as ChatScreenForm } from './chat/ChatScreen/ChatScreenForm.svelte';
export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte';
-
export { default as ChatSettings } from './chat/ChatSettings/ChatSettings.svelte';
export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte';
export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
-
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
+export { default as ChatSidebarActions } from './chat/ChatSidebar/ChatSidebarActions.svelte';
export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
// Dialogs
-
export { default as DialogChatAttachmentPreview } from './dialogs/DialogChatAttachmentPreview.svelte';
export { default as DialogChatAttachmentsViewAll } from './dialogs/DialogChatAttachmentsViewAll.svelte';
export { default as DialogChatError } from './dialogs/DialogChatError.svelte';
export { default as DialogChatSettings } from './dialogs/DialogChatSettings.svelte';
+export { default as DialogCodePreview } from './dialogs/DialogCodePreview.svelte';
export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svelte';
export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte';
export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte';
export { default as DialogModelInformation } from './dialogs/DialogModelInformation.svelte';
export { default as DialogModelNotAvailable } from './dialogs/DialogModelNotAvailable.svelte';
-// Miscellanous
-
-export { default as ActionButton } from './misc/ActionButton.svelte';
-export { default as ActionDropdown } from './misc/ActionDropdown.svelte';
-export { default as BadgeChatStatistic } from './misc/BadgeChatStatistic.svelte';
-export { default as BadgeInfo } from './misc/BadgeInfo.svelte';
-export { default as ModelBadge } from './models/ModelBadge.svelte';
-export { default as BadgeModality } from './misc/BadgeModality.svelte';
-export { default as ConversationSelection } from './misc/ConversationSelection.svelte';
-export { default as CopyToClipboardIcon } from './misc/CopyToClipboardIcon.svelte';
-export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
-export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
-export { default as RemoveButton } from './misc/RemoveButton.svelte';
-export { default as SearchInput } from './misc/SearchInput.svelte';
-export { default as SyntaxHighlightedCode } from './misc/SyntaxHighlightedCode.svelte';
-export { default as ModelsSelector } from './models/ModelsSelector.svelte';
-
-// Server
-
-export { default as ServerStatus } from './server/ServerStatus.svelte';
-export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte';
-export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte';
+// Compatibility aliases
+export { default as ActionButton } from './actions/ActionIcon.svelte';
+export { default as ActionDropdown } from './navigation/DropdownMenuActions.svelte';
+export { default as CopyToClipboardIcon } from './actions/ActionIconCopyToClipboard.svelte';
+export { default as RemoveButton } from './actions/ActionIconRemove.svelte';
+++ /dev/null
-<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>
+++ /dev/null
-<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}
+++ /dev/null
-<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}
+++ /dev/null
-<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>
+++ /dev/null
-<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}
+++ /dev/null
-<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>
+++ /dev/null
-<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)}
-/>
+++ /dev/null
-<script lang="ts">
- import type { Snippet } from 'svelte';
- import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
- import { cn } from '$lib/components/ui/utils';
- import { SearchInput } from '$lib/components/app';
-
- interface Props {
- open?: boolean;
- onOpenChange?: (open: boolean) => void;
- placeholder?: string;
- searchValue?: string;
- onSearchChange?: (value: string) => void;
- onSearchKeyDown?: (event: KeyboardEvent) => void;
- align?: 'start' | 'center' | 'end';
- contentClass?: string;
- emptyMessage?: string;
- isEmpty?: boolean;
- disabled?: boolean;
- trigger: Snippet;
- children: Snippet;
- footer?: Snippet;
- }
-
- let {
- open = $bindable(false),
- onOpenChange,
- placeholder = 'Search...',
- searchValue = $bindable(''),
- onSearchChange,
- onSearchKeyDown,
- align = 'start',
- contentClass = 'w-72',
- emptyMessage = 'No items found',
- isEmpty = false,
- disabled = false,
- trigger,
- children,
- footer
- }: Props = $props();
-
- function handleOpenChange(newOpen: boolean) {
- open = newOpen;
-
- if (!newOpen) {
- searchValue = '';
- onSearchChange?.('');
- }
-
- onOpenChange?.(newOpen);
- }
-</script>
-
-<DropdownMenu.Root bind:open onOpenChange={handleOpenChange}>
- <DropdownMenu.Trigger
- {disabled}
- onclick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- }}
- >
- {@render trigger()}
- </DropdownMenu.Trigger>
-
- <DropdownMenu.Content {align} class={cn(contentClass, 'pt-0')}>
- <div class="sticky top-0 z-10 mb-2 bg-popover p-1 pt-2">
- <SearchInput
- {placeholder}
- bind:value={searchValue}
- onInput={onSearchChange}
- onKeyDown={onSearchKeyDown}
- />
- </div>
-
- <div class={cn('overflow-y-auto')}>
- {@render children()}
-
- {#if isEmpty}
- <div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
- {/if}
- </div>
-
- {#if footer}
- <DropdownMenu.Separator />
-
- {@render footer()}
- {/if}
- </DropdownMenu.Content>
-</DropdownMenu.Root>
+++ /dev/null
-<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;
- overflow-wrap: anywhere;
- word-break: break-all;
- }
-
- 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>
+++ /dev/null
-<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>
+++ /dev/null
-<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>
+++ /dev/null
-<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>
<script lang="ts">
import { Package } from '@lucide/svelte';
- import { BadgeInfo, CopyToClipboardIcon } from '$lib/components/app';
+ import { BadgeInfo, ActionIconCopyToClipboard } from '$lib/components/app';
import { modelsStore } from '$lib/stores/models.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
{model}
{#if showCopyIcon}
- <CopyToClipboardIcon text={model || ''} ariaLabel="Copy model name" />
+ <ActionIconCopyToClipboard text={model || ''} ariaLabel="Copy model name" />
{/if}
</BadgeInfo>
{/snippet}
<script lang="ts">
- import { onMount, tick } from 'svelte';
- import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/svelte';
+ import { onMount } from 'svelte';
+ import { ChevronDown, Loader2, Package, Power } from '@lucide/svelte';
+ import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
- import * as Popover from '$lib/components/ui/popover';
import { cn } from '$lib/components/ui/utils';
import {
modelsStore,
modelsUpdating,
selectedModelId,
routerModels,
- propsCacheVersion,
singleModelName
} from '$lib/stores/models.svelte';
- import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte';
- import { ServerModelStatus } from '$lib/enums';
+ import { KeyboardKey, ServerModelStatus } from '$lib/enums';
import { isRouterMode } from '$lib/stores/server.svelte';
- import { DialogModelInformation, SearchInput } from '$lib/components/app';
+ import {
+ DialogModelInformation,
+ DropdownMenuSearchable,
+ TruncatedText
+ } from '$lib/components/app';
import type { ModelOption } from '$lib/types/models';
interface Props {
forceForegroundText?: boolean;
/** When true, user's global selection takes priority over currentModel (for form selector) */
useGlobalSelection?: boolean;
- /**
- * When provided, only consider modalities from messages BEFORE this message.
- * Used for regeneration - allows selecting models that don't support modalities
- * used in later messages.
- */
+ /** Optional compatibility prop for context-aware selectors. */
upToMessageId?: string;
}
disabled = false,
forceForegroundText = false,
useGlobalSelection = false,
- upToMessageId
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ upToMessageId: _upToMessageId = undefined
}: Props = $props();
let options = $derived(modelOptions());
// Reactive router models state - needed for proper reactivity of status checks
let currentRouterModels = $derived(routerModels());
- let requiredModalities = $derived(
- upToMessageId ? conversationsStore.getModalitiesUpToMessage(upToMessageId) : usedModalities()
- );
-
function getModelStatus(modelId: string): ServerModelStatus | null {
const model = currentRouterModels.find((m) => m.id === modelId);
return (model?.status?.value as ServerModelStatus) ?? null;
}
- /**
- * Checks if a model supports all modalities used in the conversation.
- * Returns true if the model can be selected, false if it should be disabled.
- */
- function isModelCompatible(option: ModelOption): boolean {
- void propsCacheVersion();
-
- const modelModalities = modelsStore.getModelModalities(option.model);
-
- if (!modelModalities) {
- const status = getModelStatus(option.model);
-
- if (status === ServerModelStatus.LOADED) {
- if (requiredModalities.vision || requiredModalities.audio) return false;
- }
-
- return true;
- }
-
- if (requiredModalities.vision && !modelModalities.vision) return false;
- if (requiredModalities.audio && !modelModalities.audio) return false;
-
- return true;
- }
-
- /**
- * Gets missing modalities for a model.
- * Returns object with vision/audio booleans indicating what's missing.
- */
- function getMissingModalities(option: ModelOption): { vision: boolean; audio: boolean } | null {
- void propsCacheVersion();
-
- const modelModalities = modelsStore.getModelModalities(option.model);
-
- if (!modelModalities) {
- const status = getModelStatus(option.model);
-
- if (status === ServerModelStatus.LOADED) {
- const missing = {
- vision: requiredModalities.vision,
- audio: requiredModalities.audio
- };
-
- if (missing.vision || missing.audio) return missing;
- }
-
- return null;
- }
-
- const missing = {
- vision: requiredModalities.vision && !modelModalities.vision,
- audio: requiredModalities.audio && !modelModalities.audio
- };
-
- if (!missing.vision && !missing.audio) return null;
-
- return missing;
- }
-
let isHighlightedCurrentModelActive = $derived(
!isRouter || !currentModel
? false
});
let searchTerm = $state('');
- let searchInputRef = $state<HTMLInputElement | null>(null);
let highlightedIndex = $state<number>(-1);
let filteredOptions: ModelOption[] = $derived(
})()
);
- // Get indices of compatible options for keyboard navigation
- let compatibleIndices = $derived(
- filteredOptions
- .map((option, index) => (isModelCompatible(option) ? index : -1))
- .filter((i) => i !== -1)
- );
-
// Reset highlighted index when search term changes
$effect(() => {
void searchTerm;
});
});
- // Handle changes to the model selector pop-down or the model dialog, depending on if the server is in
+ // Handle changes to the model selector dropdown or the model dialog, depending on if the server is in
// router mode or not.
function handleOpenChange(open: boolean) {
if (loading || updating) return;
searchTerm = '';
highlightedIndex = -1;
- // Focus search input after popover opens
- tick().then(() => {
- requestAnimationFrame(() => searchInputRef?.focus());
- });
-
modelsStore.fetchRouterModels().then(() => {
modelsStore.fetchModalitiesForLoadedModels();
});
function handleSearchKeyDown(event: KeyboardEvent) {
if (event.isComposing) return;
- if (event.key === 'ArrowDown') {
+ if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
- if (compatibleIndices.length === 0) return;
+ if (filteredOptions.length === 0) return;
- const currentPos = compatibleIndices.indexOf(highlightedIndex);
- if (currentPos === -1 || currentPos === compatibleIndices.length - 1) {
- highlightedIndex = compatibleIndices[0];
+ if (highlightedIndex === -1 || highlightedIndex === filteredOptions.length - 1) {
+ highlightedIndex = 0;
} else {
- highlightedIndex = compatibleIndices[currentPos + 1];
+ highlightedIndex += 1;
}
- } else if (event.key === 'ArrowUp') {
+ } else if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
- if (compatibleIndices.length === 0) return;
+ if (filteredOptions.length === 0) return;
- const currentPos = compatibleIndices.indexOf(highlightedIndex);
- if (currentPos === -1 || currentPos === 0) {
- highlightedIndex = compatibleIndices[compatibleIndices.length - 1];
+ if (highlightedIndex === -1 || highlightedIndex === 0) {
+ highlightedIndex = filteredOptions.length - 1;
} else {
- highlightedIndex = compatibleIndices[currentPos - 1];
+ highlightedIndex -= 1;
}
- } else if (event.key === 'Enter') {
+ } else if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
const option = filteredOptions[highlightedIndex];
- if (isModelCompatible(option)) {
- handleSelect(option.id);
- }
- } else if (compatibleIndices.length > 0) {
- // No selection - highlight first compatible option
- highlightedIndex = compatibleIndices[0];
+ handleSelect(option.id);
+ } else if (filteredOptions.length > 0) {
+ // No selection - highlight first option
+ highlightedIndex = 0;
}
}
}
{@const selectedOption = getDisplayOption()}
{#if isRouter}
- <Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
- <Popover.Trigger
- class={cn(
- `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
- !isCurrentModelInCache()
- ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
- : forceForegroundText
- ? 'text-foreground'
- : isHighlightedCurrentModelActive
- ? 'text-foreground'
- : 'text-muted-foreground',
- isOpen ? 'text-foreground' : ''
- )}
- style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
+ <DropdownMenu.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
+ <DropdownMenu.Trigger
disabled={disabled || updating}
+ onclick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
>
- <Package class="h-3.5 w-3.5" />
-
- <span class="truncate font-medium">
- {selectedOption?.model || 'Select model'}
- </span>
-
- {#if updating}
- <Loader2 class="h-3 w-3.5 animate-spin" />
- {:else}
- <ChevronDown class="h-3 w-3.5" />
- {/if}
- </Popover.Trigger>
-
- <Popover.Content
- class="group/popover-content w-96 max-w-[calc(100vw-2rem)] p-0"
+ <button
+ type="button"
+ class={cn(
+ `inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
+ !isCurrentModelInCache()
+ ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
+ : forceForegroundText
+ ? 'text-foreground'
+ : isHighlightedCurrentModelActive
+ ? 'text-foreground'
+ : 'text-muted-foreground',
+ isOpen ? 'text-foreground' : ''
+ )}
+ style="max-width: min(calc(100cqw - 9rem), 20rem)"
+ disabled={disabled || updating}
+ >
+ <Package class="h-3.5 w-3.5" />
+
+ <TruncatedText
+ text={selectedOption?.model || 'Select model'}
+ class="min-w-0 font-medium"
+ />
+
+ {#if updating}
+ <Loader2 class="h-3 w-3.5 animate-spin" />
+ {:else}
+ <ChevronDown class="h-3 w-3.5" />
+ {/if}
+ </button>
+ </DropdownMenu.Trigger>
+
+ <DropdownMenu.Content
align="end"
- sideOffset={8}
- collisionPadding={16}
+ class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
>
- <div class="flex max-h-[50dvh] flex-col overflow-hidden">
- <div
- class="order-1 shrink-0 border-b p-4 group-data-[side=top]/popover-content:order-2 group-data-[side=top]/popover-content:border-t group-data-[side=top]/popover-content:border-b-0"
- >
- <SearchInput
- id="model-search"
- placeholder="Search models..."
- bind:value={searchTerm}
- bind:ref={searchInputRef}
- onClose={() => handleOpenChange(false)}
- onKeyDown={handleSearchKeyDown}
- />
- </div>
- <div
- class="models-list order-2 min-h-0 flex-1 overflow-y-auto group-data-[side=top]/popover-content:order-1"
- >
+ <DropdownMenuSearchable
+ bind:searchValue={searchTerm}
+ placeholder="Search models..."
+ onSearchKeyDown={handleSearchKeyDown}
+ emptyMessage="No models found."
+ isEmpty={filteredOptions.length === 0 && isCurrentModelInCache()}
+ >
+ <div class="models-list">
{#if !isCurrentModelInCache() && currentModel}
<!-- Show unavailable model as first option (disabled) -->
<button
type="button"
- class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-4 py-2 text-left text-sm text-red-400"
+ class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
role="option"
aria-selected="true"
aria-disabled="true"
disabled
>
- <span class="truncate">{selectedOption?.name || currentModel}</span>
+ <span
+ class="min-w-0 flex-1 truncate text-left sm:overflow-visible sm:text-clip sm:whitespace-nowrap"
+ >
+ {selectedOption?.name || currentModel}
+ </span>
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
<div class="my-1 h-px bg-border"></div>
{@const isLoaded = status === ServerModelStatus.LOADED}
{@const isLoading = status === ServerModelStatus.LOADING}
{@const isSelected = currentModel === option.model || activeId === option.id}
- {@const isCompatible = isModelCompatible(option)}
{@const isHighlighted = index === highlightedIndex}
- {@const missingModalities = getMissingModalities(option)}
<div
class={cn(
- 'group flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition focus:outline-none',
- isCompatible
- ? 'cursor-pointer hover:bg-muted focus:bg-muted'
- : 'cursor-not-allowed opacity-50',
+ 'group flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
+ 'cursor-pointer hover:bg-muted focus:bg-muted',
isSelected || isHighlighted
? 'bg-accent text-accent-foreground'
- : isCompatible
- ? 'hover:bg-accent hover:text-accent-foreground'
- : '',
+ : 'hover:bg-accent hover:text-accent-foreground',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
)}
role="option"
aria-selected={isSelected || isHighlighted}
- aria-disabled={!isCompatible}
- tabindex={isCompatible ? 0 : -1}
- onclick={() => isCompatible && handleSelect(option.id)}
+ tabindex="0"
+ onclick={() => handleSelect(option.id)}
onmouseenter={() => (highlightedIndex = index)}
onkeydown={(e) => {
- if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
+ if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(option.id);
}
}}
>
- <span class="min-w-0 flex-1 truncate">{option.model}</span>
-
- {#if missingModalities}
- <span class="flex shrink-0 items-center gap-1 text-muted-foreground/70">
- {#if missingModalities.vision}
- <Tooltip.Root>
- <Tooltip.Trigger>
- <EyeOff class="h-3.5 w-3.5" />
- </Tooltip.Trigger>
- <Tooltip.Content class="z-[9999]">
- <p>No vision support</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {/if}
- {#if missingModalities.audio}
- <Tooltip.Root>
- <Tooltip.Trigger>
- <MicOff class="h-3.5 w-3.5" />
- </Tooltip.Trigger>
- <Tooltip.Content class="z-[9999]">
- <p>No audio support</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {/if}
- </span>
- {/if}
-
- {#if isLoading}
- <Tooltip.Root>
- <Tooltip.Trigger>
- <Loader2 class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
- </Tooltip.Trigger>
- <Tooltip.Content class="z-[9999]">
- <p>Loading model...</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {:else if isLoaded}
- <Tooltip.Root>
- <Tooltip.Trigger>
- <button
- type="button"
- class="relative ml-2 flex h-4 w-4 shrink-0 items-center justify-center"
- onclick={(e) => {
- e.stopPropagation();
- modelsStore.unloadModel(option.model);
- }}
- >
- <span
- class="mr-2 h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
- ></span>
- <Power
- class="absolute mr-2 h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
- />
- </button>
- </Tooltip.Trigger>
- <Tooltip.Content class="z-[9999]">
- <p>Unload model</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {:else}
- <span class="mx-2 h-2 w-2 rounded-full bg-muted-foreground/50"></span>
- {/if}
+ <span
+ class="min-w-0 flex-1 truncate text-left sm:overflow-visible sm:pr-2 sm:text-clip sm:whitespace-nowrap"
+ >
+ {option.model}
+ </span>
+
+ <div class="flex w-6 shrink-0 justify-center">
+ {#if isLoading}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
+ </Tooltip.Trigger>
+ <Tooltip.Content class="z-[9999]">
+ <p>Loading model...</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {:else if isLoaded}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <button
+ type="button"
+ class="relative flex h-4 w-4 items-center justify-center"
+ onclick={(e) => {
+ e.stopPropagation();
+ modelsStore.unloadModel(option.model);
+ }}
+ >
+ <span
+ class="h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
+ ></span>
+ <Power
+ class="absolute h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
+ />
+ </button>
+ </Tooltip.Trigger>
+ <Tooltip.Content class="z-[9999]">
+ <p>Unload model</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {:else}
+ <span class="h-2 w-2 rounded-full bg-muted-foreground/50"></span>
+ {/if}
+ </div>
</div>
{/each}
</div>
- </div>
- </Popover.Content>
- </Popover.Root>
+ </DropdownMenuSearchable>
+ </DropdownMenu.Content>
+ </DropdownMenu.Root>
{:else}
<button
class={cn(
>
<Package class="h-3.5 w-3.5" />
- <span class="truncate font-medium">
- {selectedOption?.model}
- </span>
+ <TruncatedText text={selectedOption?.model || ''} class="min-w-0 font-medium" />
{#if updating}
<Loader2 class="h-3 w-3.5 animate-spin" />
--- /dev/null
+/**
+ *
+ * MODELS
+ *
+ * Components for model selection and display. Supports two server modes:
+ * - **Single model mode**: Server runs with one model, selector shows model info
+ * - **Router mode**: Server runs with multiple models, selector enables switching
+ *
+ * Integrates with modelsStore for model data and serverStore for mode detection.
+ *
+ */
+
+/**
+ * **ModelsSelector** - Model selection dropdown
+ *
+ * Dropdown for selecting AI models with status indicators,
+ * search, and model information display. Adapts UI based on server mode.
+ *
+ * **Architecture:**
+ * - Uses DropdownMenuSearchable for model list
+ * - Integrates with modelsStore for model options and selection
+ * - Detects router vs single mode from serverStore
+ * - Opens DialogModelInformation for model details
+ *
+ * **Features:**
+ * - Searchable model list with keyboard navigation
+ * - Model status indicators (loading/ready/error/updating)
+ * - Model capabilities badges (vision, tools, etc.)
+ * - Current/active model highlighting
+ * - Model information dialog on info button click
+ * - Router mode: shows all available models with status
+ * - Single mode: shows current model name only
+ * - Loading/updating skeleton states
+ * - Global selection support for form integration
+ *
+ * @example
+ * ```svelte
+ * <ModelsSelector
+ * currentModel={conversation.modelId}
+ * onModelChange={(id, name) => updateModel(id)}
+ * useGlobalSelection
+ * />
+ * ```
+ */
+export { default as ModelsSelector } from './ModelsSelector.svelte';
+
+/**
+ * **ModelBadge** - Model name display badge
+ *
+ * Compact badge showing current model name with package icon.
+ * Only visible in single model mode. Supports tooltip and copy functionality.
+ *
+ * **Architecture:**
+ * - Reads model name from modelsStore or prop
+ * - Checks server mode from serverStore
+ * - Uses BadgeInfo for consistent styling
+ *
+ * **Features:**
+ * - Optional copy to clipboard button
+ * - Optional tooltip with model details
+ * - Click handler for model info dialog
+ * - Only renders in model mode (not router)
+ *
+ * @example
+ * ```svelte
+ * <ModelBadge
+ * onclick={() => showModelInfo = true}
+ * showTooltip
+ * showCopyIcon
+ * />
+ * ```
+ */
+export { default as ModelBadge } from './ModelBadge.svelte';
import { serverStore, serverLoading } from '$lib/stores/server.svelte';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { fade, fly, scale } from 'svelte/transition';
- import { KeyboardKey } from '$lib/enums/keyboard';
+ import { KeyboardKey } from '$lib/enums';
interface Props {
class?: string;
-export interface BinaryDetectionOptions {
- prefixLength: number;
- suspiciousCharThresholdRatio: number;
- maxAbsoluteNullBytes: number;
-}
+import type { BinaryDetectionOptions } from '$lib/types';
export const DEFAULT_BINARY_DETECTION_OPTIONS: BinaryDetectionOptions = {
prefixLength: 1024 * 10, // Check the first 10KB of the string
--- /dev/null
+/**
+ * Cache configuration constants
+ */
+
+/**
+ * Default TTL (Time-To-Live) for cache entries in milliseconds.
+ */
+export const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
+
+/**
+ * Default maximum number of entries in a cache.
+ */
+export const DEFAULT_CACHE_MAX_ENTRIES = 100;
+
+/**
+ * TTL for model props cache in milliseconds.
+ */
+export const MODEL_PROPS_CACHE_TTL_MS = 10 * 60 * 1000;
+
+/**
+ * Maximum number of model props to cache.
+ */
+export const MODEL_PROPS_CACHE_MAX_ENTRIES = 50;
+
+/**
+ * Maximum number of inactive conversation states to keep in memory.
+ */
+export const MAX_INACTIVE_CONVERSATION_STATES = 10;
+
+/**
+ * Maximum age (in ms) for inactive conversation states before cleanup.
+ */
+export const INACTIVE_CONVERSATION_STATE_MAX_AGE_MS = 30 * 60 * 1000;
-export const INPUT_CLASSES = `
- bg-muted/70 dark:bg-muted/85
- border border-border/30 focus-within:border-border dark:border-border/20 dark:focus-within:border-border
- outline-none
- text-foreground
-`;
+export { INPUT_CLASSES } from './css-classes';
--- /dev/null
+/**
+ * Settings section titles constants for ChatSettings component.
+ */
+export const SETTINGS_SECTION_TITLES = {
+ GENERAL: 'General',
+ DISPLAY: 'Display',
+ SAMPLING: 'Sampling',
+ PENALTIES: 'Penalties',
+ IMPORT_EXPORT: 'Import/Export',
+ DEVELOPER: 'Developer'
+} as const;
+
+export type SettingsSectionTitle =
+ (typeof SETTINGS_SECTION_TITLES)[keyof typeof SETTINGS_SECTION_TITLES];
export { AttachmentType } from './attachment';
-export { ChatMessageStatsView } from './chat';
+export {
+ ChatMessageStatsView,
+ ReasoningFormat,
+ MessageRole,
+ MessageType,
+ ContentPartType,
+ ErrorDialogType
+} from './chat';
export {
FileTypeCategory,
export { ModelModality } from './model';
export { ServerRole, ServerModelStatus } from './server';
+
+export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings';
+
+export { KeyboardKey } from './keyboard';
+
+export { UrlPrefix } from './ui';
--- /dev/null
+/**
+ * URL prefixes for protocol detection.
+ */
+export enum UrlPrefix {
+ DATA = 'data:',
+ HTTP = 'http://',
+ HTTPS = 'https://',
+ WEBSOCKET = 'ws://',
+ WEBSOCKET_SECURE = 'wss://'
+}
import { modelsStore } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { toast } from 'svelte-sonner';
+import type { ModelModalities } from '$lib/types';
interface UseModelChangeValidationOptions {
/**
* Function to get required modalities for validation.
- * For ChatForm: () => usedModalities() - all messages
- * For ChatMessageAssistant: () => getModalitiesUpToMessage(messageId) - messages before
*/
getRequiredModalities: () => ModelModalities;
/**
* Optional callback to execute after successful validation.
- * For ChatForm: undefined - just select model
- * For ChatMessageAssistant: (modelName) => onRegenerate(modelName)
*/
onSuccess?: (modelName: string) => void;
/**
* Optional callback for rollback on validation failure.
- * For ChatForm: (previousId) => selectModelById(previousId)
- * For ChatMessageAssistant: undefined - no rollback needed
*/
onValidationFailure?: (previousModelId: string | null) => Promise<void>;
}
async function handleModelChange(modelId: string, modelName: string): Promise<boolean> {
try {
- // Store previous selection for potential rollback
if (onValidationFailure) {
previousSelectedModelId = modelsStore.selectedModelId;
}
- // Load model if not already loaded (router mode only)
let hasLoadedModel = false;
const isModelLoadedBefore = modelsStore.isModelLoaded(modelName);
}
}
- // Fetch model props to validate modalities
const props = await modelsStore.fetchModelProps(modelName);
if (props?.modalities) {
const requiredModalities = getRequiredModalities();
- // Check if model supports required modalities
const missingModalities: string[] = [];
if (requiredModalities.vision && !props.modalities.vision) {
missingModalities.push('vision');
`Model "${modelName}" doesn't support required modalities: ${missingModalities.join(', ')}. Please select a different model.`
);
- // Unload the model if we just loaded it
if (isRouter && hasLoadedModel) {
try {
await modelsStore.unloadModel(modelName);
}
}
- // Execute rollback callback if provided
if (onValidationFailure && previousSelectedModelId) {
await onValidationFailure(previousSelectedModelId);
}
}
}
- // Select the model (validation passed)
await modelsStore.selectModelById(modelId);
- // Execute success callback if provided
if (onSuccess) {
onSuccess(modelName);
}
console.error('Failed to change model:', error);
toast.error('Failed to validate model capabilities');
- // Execute rollback callback on error if provided
if (onValidationFailure && previousSelectedModelId) {
await onValidationFailure(previousSelectedModelId);
}
import { activeProcessingState } from '$lib/stores/chat.svelte';
import { config } from '$lib/stores/settings.svelte';
import { STATS_UNITS } from '$lib/constants/processing-info';
-import type { ApiProcessingState } from '$lib/types';
-
-interface LiveProcessingStats {
- tokensProcessed: number;
- totalTokens: number;
- timeMs: number;
- tokensPerSecond: number;
- etaSecs?: number;
-}
-
-interface LiveGenerationStats {
- tokensGenerated: number;
- timeMs: number;
- tokensPerSecond: number;
-}
+import type { ApiProcessingState, LiveProcessingStats, LiveGenerationStats } from '$lib/types';
export interface UseProcessingStateReturn {
readonly processingState: ApiProcessingState | null;
--- /dev/null
+import type { Root as HastRoot } from 'hast';
+import { visit } from 'unist-util-visit';
+import type { DatabaseMessageExtra, DatabaseMessageExtraImageFile } from '$lib/types/database';
+import { AttachmentType, UrlPrefix } from '$lib/enums';
+
+/**
+ * Rehype plugin to resolve attachment image sources.
+ * Converts attachment names to base64 data URLs.
+ */
+export function rehypeResolveAttachmentImages(options: { attachments?: DatabaseMessageExtra[] }) {
+ return (tree: HastRoot) => {
+ visit(tree, 'element', (node) => {
+ if (node.tagName === 'img' && node.properties?.src) {
+ const src = String(node.properties.src);
+
+ if (src.startsWith(UrlPrefix.DATA) || src.startsWith(UrlPrefix.HTTP)) {
+ return;
+ }
+
+ const attachment = options.attachments?.find(
+ (a): a is DatabaseMessageExtraImageFile =>
+ a.type === AttachmentType.IMAGE && a.name === src
+ );
+
+ if (attachment?.base64Url) {
+ node.properties.src = attachment.base64Url;
+ }
+ }
+ });
+ };
+}
const db = new LlamacppDatabase();
import { v4 as uuid } from 'uuid';
-import { MessageRole } from '$lib/enums/chat';
+import { MessageRole } from '$lib/enums';
export class DatabaseService {
/**
+++ /dev/null
-import Dexie, { type EntityTable } from 'dexie';
-import { findDescendantMessages } from '$lib/utils';
-
-class LlamacppDatabase extends Dexie {
- conversations!: EntityTable<DatabaseConversation, string>;
- messages!: EntityTable<DatabaseMessage, string>;
-
- constructor() {
- super('LlamacppWebui');
-
- this.version(1).stores({
- conversations: 'id, lastModified, currNode, name',
- messages: 'id, convId, type, role, timestamp, parent, children'
- });
- }
-}
-
-const db = new LlamacppDatabase();
-import { v4 as uuid } from 'uuid';
-
-/**
- * DatabaseService - Stateless IndexedDB communication layer
- *
- * **Terminology - Chat vs Conversation:**
- * - **Chat**: The active interaction space with the Chat Completions API (ephemeral, runtime).
- * - **Conversation**: The persistent database entity storing all messages and metadata.
- * This service handles raw database operations for conversations - the lowest layer
- * in the persistence stack.
- *
- * This service provides a stateless data access layer built on IndexedDB using Dexie ORM.
- * It handles all low-level storage operations for conversations and messages with support
- * for complex branching and message threading. All methods are static - no instance state.
- *
- * **Architecture & Relationships (bottom to top):**
- * - **DatabaseService** (this class): Stateless IndexedDB operations
- * - Lowest layer - direct Dexie/IndexedDB communication
- * - Pure CRUD operations without business logic
- * - Handles branching tree structure (parent-child relationships)
- * - Provides transaction safety for multi-table operations
- *
- * - **ConversationsService**: Stateless business logic layer
- * - Uses DatabaseService for all persistence operations
- * - Adds import/export, navigation, and higher-level operations
- *
- * - **conversationsStore**: Reactive state management for conversations
- * - Uses ConversationsService for database operations
- * - Manages conversation list, active conversation, and messages in memory
- *
- * - **chatStore**: Active AI interaction management
- * - Uses conversationsStore for conversation context
- * - Directly uses DatabaseService for message CRUD during streaming
- *
- * **Key Features:**
- * - **Conversation CRUD**: Create, read, update, delete conversations
- * - **Message CRUD**: Add, update, delete messages with branching support
- * - **Branch Operations**: Create branches, find descendants, cascade deletions
- * - **Transaction Safety**: Atomic operations for data consistency
- *
- * **Database Schema:**
- * - `conversations`: id, lastModified, currNode, name
- * - `messages`: id, convId, type, role, timestamp, parent, children
- *
- * **Branching Model:**
- * Messages form a tree structure where each message can have multiple children,
- * enabling conversation branching and alternative response paths. The conversation's
- * `currNode` tracks the currently active branch endpoint.
- */
-export class DatabaseService {
- // ─────────────────────────────────────────────────────────────────────────────
- // Conversations
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Creates a new conversation.
- *
- * @param name - Name of the conversation
- * @returns The created conversation
- */
- static async createConversation(name: string): Promise<DatabaseConversation> {
- const conversation: DatabaseConversation = {
- id: uuid(),
- name,
- lastModified: Date.now(),
- currNode: ''
- };
-
- await db.conversations.add(conversation);
- return conversation;
- }
-
- // ─────────────────────────────────────────────────────────────────────────────
- // Messages
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Creates a new message branch by adding a message and updating parent/child relationships.
- * Also updates the conversation's currNode to point to the new message.
- *
- * @param message - Message to add (without id)
- * @param parentId - Parent message ID to attach to
- * @returns The created message
- */
- static async createMessageBranch(
- message: Omit<DatabaseMessage, 'id'>,
- parentId: string | null
- ): Promise<DatabaseMessage> {
- return await db.transaction('rw', [db.conversations, db.messages], async () => {
- // Handle null parent (root message case)
- if (parentId !== null) {
- const parentMessage = await db.messages.get(parentId);
- if (!parentMessage) {
- throw new Error(`Parent message ${parentId} not found`);
- }
- }
-
- const newMessage: DatabaseMessage = {
- ...message,
- id: uuid(),
- parent: parentId,
- toolCalls: message.toolCalls ?? '',
- children: []
- };
-
- await db.messages.add(newMessage);
-
- // Update parent's children array if parent exists
- if (parentId !== null) {
- const parentMessage = await db.messages.get(parentId);
- if (parentMessage) {
- await db.messages.update(parentId, {
- children: [...parentMessage.children, newMessage.id]
- });
- }
- }
-
- await this.updateConversation(message.convId, {
- currNode: newMessage.id
- });
-
- return newMessage;
- });
- }
-
- /**
- * Creates a root message for a new conversation.
- * Root messages are not displayed but serve as the tree root for branching.
- *
- * @param convId - Conversation ID
- * @returns The created root message
- */
- static async createRootMessage(convId: string): Promise<string> {
- const rootMessage: DatabaseMessage = {
- id: uuid(),
- convId,
- type: 'root',
- timestamp: Date.now(),
- role: 'system',
- content: '',
- parent: null,
- thinking: '',
- toolCalls: '',
- children: []
- };
-
- await db.messages.add(rootMessage);
- return rootMessage.id;
- }
-
- /**
- * Creates a system prompt message for a conversation.
- *
- * @param convId - Conversation ID
- * @param systemPrompt - The system prompt content (must be non-empty)
- * @param parentId - Parent message ID (typically the root message)
- * @returns The created system message
- * @throws Error if systemPrompt is empty
- */
- static async createSystemMessage(
- convId: string,
- systemPrompt: string,
- parentId: string
- ): Promise<DatabaseMessage> {
- const trimmedPrompt = systemPrompt.trim();
- if (!trimmedPrompt) {
- throw new Error('Cannot create system message with empty content');
- }
-
- const systemMessage: DatabaseMessage = {
- id: uuid(),
- convId,
- type: 'system',
- timestamp: Date.now(),
- role: 'system',
- content: trimmedPrompt,
- parent: parentId,
- thinking: '',
- children: []
- };
-
- await db.messages.add(systemMessage);
-
- const parentMessage = await db.messages.get(parentId);
- if (parentMessage) {
- await db.messages.update(parentId, {
- children: [...parentMessage.children, systemMessage.id]
- });
- }
-
- return systemMessage;
- }
-
- /**
- * Deletes a conversation and all its messages.
- *
- * @param id - Conversation ID
- */
- static async deleteConversation(id: string): Promise<void> {
- await db.transaction('rw', [db.conversations, db.messages], async () => {
- await db.conversations.delete(id);
- await db.messages.where('convId').equals(id).delete();
- });
- }
-
- /**
- * Deletes a message and removes it from its parent's children array.
- *
- * @param messageId - ID of the message to delete
- */
- static async deleteMessage(messageId: string): Promise<void> {
- await db.transaction('rw', db.messages, async () => {
- const message = await db.messages.get(messageId);
- if (!message) return;
-
- // Remove this message from its parent's children array
- if (message.parent) {
- const parent = await db.messages.get(message.parent);
- if (parent) {
- parent.children = parent.children.filter((childId: string) => childId !== messageId);
- await db.messages.put(parent);
- }
- }
-
- // Delete the message
- await db.messages.delete(messageId);
- });
- }
-
- /**
- * Deletes a message and all its descendant messages (cascading deletion).
- * This removes the entire branch starting from the specified message.
- *
- * @param conversationId - ID of the conversation containing the message
- * @param messageId - ID of the root message to delete (along with all descendants)
- * @returns Array of all deleted message IDs
- */
- static async deleteMessageCascading(
- conversationId: string,
- messageId: string
- ): Promise<string[]> {
- return await db.transaction('rw', db.messages, async () => {
- // Get all messages in the conversation to find descendants
- const allMessages = await db.messages.where('convId').equals(conversationId).toArray();
-
- // Find all descendant messages
- const descendants = findDescendantMessages(allMessages, messageId);
- const allToDelete = [messageId, ...descendants];
-
- // Get the message to delete for parent cleanup
- const message = await db.messages.get(messageId);
- if (message && message.parent) {
- const parent = await db.messages.get(message.parent);
- if (parent) {
- parent.children = parent.children.filter((childId: string) => childId !== messageId);
- await db.messages.put(parent);
- }
- }
-
- // Delete all messages in the branch
- await db.messages.bulkDelete(allToDelete);
-
- return allToDelete;
- });
- }
-
- /**
- * Gets all conversations, sorted by last modified time (newest first).
- *
- * @returns Array of conversations
- */
- static async getAllConversations(): Promise<DatabaseConversation[]> {
- return await db.conversations.orderBy('lastModified').reverse().toArray();
- }
-
- /**
- * Gets a conversation by ID.
- *
- * @param id - Conversation ID
- * @returns The conversation if found, otherwise undefined
- */
- static async getConversation(id: string): Promise<DatabaseConversation | undefined> {
- return await db.conversations.get(id);
- }
-
- /**
- * Gets all messages in a conversation, sorted by timestamp (oldest first).
- *
- * @param convId - Conversation ID
- * @returns Array of messages in the conversation
- */
- static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
- return await db.messages.where('convId').equals(convId).sortBy('timestamp');
- }
-
- /**
- * Updates a conversation.
- *
- * @param id - Conversation ID
- * @param updates - Partial updates to apply
- * @returns Promise that resolves when the conversation is updated
- */
- static async updateConversation(
- id: string,
- updates: Partial<Omit<DatabaseConversation, 'id'>>
- ): Promise<void> {
- await db.conversations.update(id, {
- ...updates,
- lastModified: Date.now()
- });
- }
-
- // ─────────────────────────────────────────────────────────────────────────────
- // Navigation
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Updates the conversation's current node (active branch).
- * This determines which conversation path is currently being viewed.
- *
- * @param convId - Conversation ID
- * @param nodeId - Message ID to set as current node
- */
- static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {
- await this.updateConversation(convId, {
- currNode: nodeId
- });
- }
-
- /**
- * Updates a message.
- *
- * @param id - Message ID
- * @param updates - Partial updates to apply
- * @returns Promise that resolves when the message is updated
- */
- static async updateMessage(
- id: string,
- updates: Partial<Omit<DatabaseMessage, 'id'>>
- ): Promise<void> {
- await db.messages.update(id, updates);
- }
-
- // ─────────────────────────────────────────────────────────────────────────────
- // Import
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Imports multiple conversations and their messages.
- * Skips conversations that already exist.
- *
- * @param data - Array of { conv, messages } objects
- */
- static async importConversations(
- data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
- ): Promise<{ imported: number; skipped: number }> {
- let importedCount = 0;
- let skippedCount = 0;
-
- return await db.transaction('rw', [db.conversations, db.messages], async () => {
- for (const item of data) {
- const { conv, messages } = item;
-
- const existing = await db.conversations.get(conv.id);
- if (existing) {
- console.warn(`Conversation "${conv.name}" already exists, skipping...`);
- skippedCount++;
- continue;
- }
-
- await db.conversations.add(conv);
- for (const msg of messages) {
- await db.messages.put(msg);
- }
-
- importedCount++;
- }
-
- return { imported: importedCount, skipped: skippedCount };
- });
- }
-}
export { ChatService } from './chat';
-export { DatabaseService } from './database';
-export { ModelsService } from './models';
-export { PropsService } from './props';
-export { ParameterSyncService } from './parameter-sync';
+export { DatabaseService } from './database.service';
+export { ModelsService } from './models.service';
+export { PropsService } from './props.service';
+export { ParameterSyncService, SYNCABLE_PARAMETERS } from './parameter-sync.service';
import { ServerModelStatus } from '$lib/enums';
-import { apiFetch, apiPost } from '$lib/utils/api-fetch';
+import { apiFetch, apiPost } from '$lib/utils';
export class ModelsService {
/**
+++ /dev/null
-import { base } from '$app/paths';
-import { ServerModelStatus } from '$lib/enums';
-import { getJsonHeaders } from '$lib/utils';
-
-/**
- * ModelsService - Stateless service for model management API communication
- *
- * This service handles communication with model-related endpoints:
- * - `/v1/models` - OpenAI-compatible model list (MODEL + ROUTER mode)
- * - `/models/load`, `/models/unload` - Router-specific model management (ROUTER mode only)
- *
- * **Responsibilities:**
- * - List available models
- * - Load/unload models (ROUTER mode)
- * - Check model status (ROUTER mode)
- *
- * **Used by:**
- * - modelsStore: Primary consumer for model state management
- */
-export class ModelsService {
- // ─────────────────────────────────────────────────────────────────────────────
- // Listing
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Fetch list of models from OpenAI-compatible endpoint
- * Works in both MODEL and ROUTER modes
- */
- static async list(): Promise<ApiModelListResponse> {
- const response = await fetch(`${base}/v1/models`, {
- headers: getJsonHeaders()
- });
-
- if (!response.ok) {
- throw new Error(`Failed to fetch model list (status ${response.status})`);
- }
-
- return response.json() as Promise<ApiModelListResponse>;
- }
-
- /**
- * Fetch list of all models with detailed metadata (ROUTER mode)
- * Returns models with load status, paths, and other metadata
- */
- static async listRouter(): Promise<ApiRouterModelsListResponse> {
- const response = await fetch(`${base}/v1/models`, {
- headers: getJsonHeaders()
- });
-
- if (!response.ok) {
- throw new Error(`Failed to fetch router models list (status ${response.status})`);
- }
-
- return response.json() as Promise<ApiRouterModelsListResponse>;
- }
-
- // ─────────────────────────────────────────────────────────────────────────────
- // Load/Unload
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Load a model (ROUTER mode)
- * POST /models/load
- * @param modelId - Model identifier to load
- * @param extraArgs - Optional additional arguments to pass to the model instance
- */
- static async load(modelId: string, extraArgs?: string[]): Promise<ApiRouterModelsLoadResponse> {
- const payload: { model: string; extra_args?: string[] } = { model: modelId };
- if (extraArgs && extraArgs.length > 0) {
- payload.extra_args = extraArgs;
- }
-
- const response = await fetch(`${base}/models/load`, {
- method: 'POST',
- headers: getJsonHeaders(),
- body: JSON.stringify(payload)
- });
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.error || `Failed to load model (status ${response.status})`);
- }
-
- return response.json() as Promise<ApiRouterModelsLoadResponse>;
- }
-
- /**
- * Unload a model (ROUTER mode)
- * POST /models/unload
- * @param modelId - Model identifier to unload
- */
- static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
- const response = await fetch(`${base}/models/unload`, {
- method: 'POST',
- headers: getJsonHeaders(),
- body: JSON.stringify({ model: modelId })
- });
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.error || `Failed to unload model (status ${response.status})`);
- }
-
- return response.json() as Promise<ApiRouterModelsUnloadResponse>;
- }
-
- // ─────────────────────────────────────────────────────────────────────────────
- // Status
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Check if a model is loaded based on its metadata
- */
- static isModelLoaded(model: ApiModelDataEntry): boolean {
- return model.status.value === ServerModelStatus.LOADED;
- }
-
- /**
- * Check if a model is currently loading
- */
- static isModelLoading(model: ApiModelDataEntry): boolean {
- return model.status.value === ServerModelStatus.LOADING;
- }
-}
import { normalizeFloatingPoint } from '$lib/utils';
-import { SyncableParameterType, ParameterSource } from '$lib/enums/settings';
-
-type ParameterValue = string | number | boolean;
-type ParameterRecord = Record<string, ParameterValue>;
-
-interface ParameterInfo {
- value: string | number | boolean;
- source: ParameterSource;
- serverDefault?: string | number | boolean;
- userOverride?: string | number | boolean;
-}
-
-interface SyncableParameter {
- key: string;
- serverKey: string;
- type: SyncableParameterType;
- canSync: boolean;
-}
+import type { SyncableParameter, ParameterRecord, ParameterInfo, ParameterValue } from '$lib/types';
+import { SyncableParameterType, ParameterSource } from '$lib/enums';
/**
* Mapping of webui setting keys to server parameter keys.
+++ /dev/null
-import { describe, it, expect } from 'vitest';
-import { ParameterSyncService } from './parameter-sync';
-
-describe('ParameterSyncService', () => {
- describe('roundFloatingPoint', () => {
- it('should fix JavaScript floating-point precision issues', () => {
- // Test the specific values from the screenshot
- const mockServerParams = {
- top_p: 0.949999988079071,
- min_p: 0.009999999776482582,
- temperature: 0.800000011920929,
- top_k: 40,
- samplers: ['top_k', 'typ_p', 'top_p', 'min_p', 'temperature']
- };
-
- const result = ParameterSyncService.extractServerDefaults({
- ...mockServerParams,
- // Add other required fields to match the API type
- n_predict: 512,
- seed: -1,
- dynatemp_range: 0.0,
- dynatemp_exponent: 1.0,
- xtc_probability: 0.0,
- xtc_threshold: 0.1,
- typ_p: 1.0,
- repeat_last_n: 64,
- repeat_penalty: 1.0,
- presence_penalty: 0.0,
- frequency_penalty: 0.0,
- dry_multiplier: 0.0,
- dry_base: 1.75,
- dry_allowed_length: 2,
- dry_penalty_last_n: -1,
- mirostat: 0,
- mirostat_tau: 5.0,
- mirostat_eta: 0.1,
- stop: [],
- max_tokens: -1,
- n_keep: 0,
- n_discard: 0,
- ignore_eos: false,
- stream: true,
- logit_bias: [],
- n_probs: 0,
- min_keep: 0,
- grammar: '',
- grammar_lazy: false,
- grammar_triggers: [],
- preserved_tokens: [],
- chat_format: '',
- reasoning_format: '',
- reasoning_in_content: false,
- thinking_forced_open: false,
- 'speculative.n_max': 0,
- 'speculative.n_min': 0,
- 'speculative.p_min': 0.0,
- timings_per_token: false,
- post_sampling_probs: false,
- lora: [],
- top_n_sigma: 0.0,
- dry_sequence_breakers: []
- } as ApiLlamaCppServerProps['default_generation_settings']['params']);
-
- // Check that the problematic floating-point values are rounded correctly
- expect(result.top_p).toBe(0.95);
- expect(result.min_p).toBe(0.01);
- expect(result.temperature).toBe(0.8);
- expect(result.top_k).toBe(40); // Integer should remain unchanged
- expect(result.samplers).toBe('top_k;typ_p;top_p;min_p;temperature');
- });
-
- it('should preserve non-numeric values', () => {
- const mockServerParams = {
- samplers: ['top_k', 'temperature'],
- max_tokens: -1,
- temperature: 0.7
- };
-
- const result = ParameterSyncService.extractServerDefaults({
- ...mockServerParams,
- // Minimal required fields
- n_predict: 512,
- seed: -1,
- dynatemp_range: 0.0,
- dynatemp_exponent: 1.0,
- top_k: 40,
- top_p: 0.95,
- min_p: 0.05,
- xtc_probability: 0.0,
- xtc_threshold: 0.1,
- typ_p: 1.0,
- repeat_last_n: 64,
- repeat_penalty: 1.0,
- presence_penalty: 0.0,
- frequency_penalty: 0.0,
- dry_multiplier: 0.0,
- dry_base: 1.75,
- dry_allowed_length: 2,
- dry_penalty_last_n: -1,
- mirostat: 0,
- mirostat_tau: 5.0,
- mirostat_eta: 0.1,
- stop: [],
- n_keep: 0,
- n_discard: 0,
- ignore_eos: false,
- stream: true,
- logit_bias: [],
- n_probs: 0,
- min_keep: 0,
- grammar: '',
- grammar_lazy: false,
- grammar_triggers: [],
- preserved_tokens: [],
- chat_format: '',
- reasoning_format: '',
- reasoning_in_content: false,
- thinking_forced_open: false,
- 'speculative.n_max': 0,
- 'speculative.n_min': 0,
- 'speculative.p_min': 0.0,
- timings_per_token: false,
- post_sampling_probs: false,
- lora: [],
- top_n_sigma: 0.0,
- dry_sequence_breakers: []
- } as ApiLlamaCppServerProps['default_generation_settings']['params']);
-
- expect(result.samplers).toBe('top_k;temperature');
- expect(result.max_tokens).toBe(-1);
- expect(result.temperature).toBe(0.7);
- });
-
- it('should merge webui settings from props when provided', () => {
- const result = ParameterSyncService.extractServerDefaults(null, {
- pasteLongTextToFileLen: 0,
- pdfAsImage: true,
- renderUserContentAsMarkdown: false,
- theme: 'dark'
- });
-
- expect(result.pasteLongTextToFileLen).toBe(0);
- expect(result.pdfAsImage).toBe(true);
- expect(result.renderUserContentAsMarkdown).toBe(false);
- expect(result.theme).toBeUndefined();
- });
- });
-});
+++ /dev/null
-/**
- * ParameterSyncService - Handles synchronization between server defaults and user settings
- *
- * This service manages the complex logic of merging server-provided default parameters
- * with user-configured overrides, ensuring the UI reflects the actual server state
- * while preserving user customizations.
- *
- * **Key Responsibilities:**
- * - Extract syncable parameters from server props
- * - Merge server defaults with user overrides
- * - Track parameter sources (server, user, default)
- * - Provide sync utilities for settings store integration
- */
-
-import { normalizeFloatingPoint } from '$lib/utils';
-
-export type ParameterSource = 'default' | 'custom';
-export type ParameterValue = string | number | boolean;
-export type ParameterRecord = Record<string, ParameterValue>;
-
-export interface ParameterInfo {
- value: string | number | boolean;
- source: ParameterSource;
- serverDefault?: string | number | boolean;
- userOverride?: string | number | boolean;
-}
-
-export interface SyncableParameter {
- key: string;
- serverKey: string;
- type: 'number' | 'string' | 'boolean';
- canSync: boolean;
-}
-
-/**
- * Mapping of webui setting keys to server parameter keys
- * Only parameters that should be synced from server are included
- */
-export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
- { key: 'temperature', serverKey: 'temperature', type: 'number', canSync: true },
- { key: 'top_k', serverKey: 'top_k', type: 'number', canSync: true },
- { key: 'top_p', serverKey: 'top_p', type: 'number', canSync: true },
- { key: 'min_p', serverKey: 'min_p', type: 'number', canSync: true },
- { key: 'dynatemp_range', serverKey: 'dynatemp_range', type: 'number', canSync: true },
- { key: 'dynatemp_exponent', serverKey: 'dynatemp_exponent', type: 'number', canSync: true },
- { key: 'xtc_probability', serverKey: 'xtc_probability', type: 'number', canSync: true },
- { key: 'xtc_threshold', serverKey: 'xtc_threshold', type: 'number', canSync: true },
- { key: 'typ_p', serverKey: 'typ_p', type: 'number', canSync: true },
- { key: 'repeat_last_n', serverKey: 'repeat_last_n', type: 'number', canSync: true },
- { key: 'repeat_penalty', serverKey: 'repeat_penalty', type: 'number', canSync: true },
- { key: 'presence_penalty', serverKey: 'presence_penalty', type: 'number', canSync: true },
- { key: 'frequency_penalty', serverKey: 'frequency_penalty', type: 'number', canSync: true },
- { key: 'dry_multiplier', serverKey: 'dry_multiplier', type: 'number', canSync: true },
- { key: 'dry_base', serverKey: 'dry_base', type: 'number', canSync: true },
- { key: 'dry_allowed_length', serverKey: 'dry_allowed_length', type: 'number', canSync: true },
- { key: 'dry_penalty_last_n', serverKey: 'dry_penalty_last_n', type: 'number', canSync: true },
- { key: 'max_tokens', serverKey: 'max_tokens', type: 'number', canSync: true },
- { key: 'samplers', serverKey: 'samplers', type: 'string', canSync: true },
- {
- key: 'pasteLongTextToFileLen',
- serverKey: 'pasteLongTextToFileLen',
- type: 'number',
- canSync: true
- },
- { key: 'pdfAsImage', serverKey: 'pdfAsImage', type: 'boolean', canSync: true },
- {
- key: 'showThoughtInProgress',
- serverKey: 'showThoughtInProgress',
- type: 'boolean',
- canSync: true
- },
- { key: 'showToolCalls', serverKey: 'showToolCalls', type: 'boolean', canSync: true },
- { key: 'keepStatsVisible', serverKey: 'keepStatsVisible', type: 'boolean', canSync: true },
- { key: 'showMessageStats', serverKey: 'showMessageStats', type: 'boolean', canSync: true },
- {
- key: 'askForTitleConfirmation',
- serverKey: 'askForTitleConfirmation',
- type: 'boolean',
- canSync: true
- },
- { key: 'disableAutoScroll', serverKey: 'disableAutoScroll', type: 'boolean', canSync: true },
- {
- key: 'renderUserContentAsMarkdown',
- serverKey: 'renderUserContentAsMarkdown',
- type: 'boolean',
- canSync: true
- },
- { key: 'autoMicOnEmpty', serverKey: 'autoMicOnEmpty', type: 'boolean', canSync: true },
- {
- key: 'pyInterpreterEnabled',
- serverKey: 'pyInterpreterEnabled',
- type: 'boolean',
- canSync: true
- },
- {
- key: 'enableContinueGeneration',
- serverKey: 'enableContinueGeneration',
- type: 'boolean',
- canSync: true
- }
-];
-
-export class ParameterSyncService {
- // ─────────────────────────────────────────────────────────────────────────────
- // Extraction
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Round floating-point numbers to avoid JavaScript precision issues
- */
- private static roundFloatingPoint(value: ParameterValue): ParameterValue {
- return normalizeFloatingPoint(value) as ParameterValue;
- }
-
- /**
- * Extract server default parameters that can be synced
- */
- static extractServerDefaults(
- serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null,
- webuiSettings?: Record<string, string | number | boolean>
- ): ParameterRecord {
- const extracted: ParameterRecord = {};
-
- if (serverParams) {
- for (const param of SYNCABLE_PARAMETERS) {
- if (param.canSync && param.serverKey in serverParams) {
- const value = (serverParams as unknown as Record<string, ParameterValue>)[
- param.serverKey
- ];
- if (value !== undefined) {
- // Apply precision rounding to avoid JavaScript floating-point issues
- extracted[param.key] = this.roundFloatingPoint(value);
- }
- }
- }
-
- // Handle samplers array conversion to string
- if (serverParams.samplers && Array.isArray(serverParams.samplers)) {
- extracted.samplers = serverParams.samplers.join(';');
- }
- }
-
- if (webuiSettings) {
- for (const param of SYNCABLE_PARAMETERS) {
- if (param.canSync && param.serverKey in webuiSettings) {
- const value = webuiSettings[param.serverKey];
- if (value !== undefined) {
- extracted[param.key] = this.roundFloatingPoint(value);
- }
- }
- }
- }
-
- return extracted;
- }
-
- // ─────────────────────────────────────────────────────────────────────────────
- // Merging
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Merge server defaults with current user settings
- * Returns updated settings that respect user overrides while using server defaults
- */
- static mergeWithServerDefaults(
- currentSettings: ParameterRecord,
- serverDefaults: ParameterRecord,
- userOverrides: Set<string> = new Set()
- ): ParameterRecord {
- const merged = { ...currentSettings };
-
- for (const [key, serverValue] of Object.entries(serverDefaults)) {
- // Only update if user hasn't explicitly overridden this parameter
- if (!userOverrides.has(key)) {
- merged[key] = this.roundFloatingPoint(serverValue);
- }
- }
-
- return merged;
- }
-
- // ─────────────────────────────────────────────────────────────────────────────
- // Info
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Get parameter information including source and values
- */
- static getParameterInfo(
- key: string,
- currentValue: ParameterValue,
- propsDefaults: ParameterRecord,
- userOverrides: Set<string>
- ): ParameterInfo {
- const hasPropsDefault = propsDefaults[key] !== undefined;
- const isUserOverride = userOverrides.has(key);
-
- // Simple logic: either using default (from props) or custom (user override)
- const source: ParameterSource = isUserOverride ? 'custom' : 'default';
-
- return {
- value: currentValue,
- source,
- serverDefault: hasPropsDefault ? propsDefaults[key] : undefined, // Keep same field name for compatibility
- userOverride: isUserOverride ? currentValue : undefined
- };
- }
-
- /**
- * Check if a parameter can be synced from server
- */
- static canSyncParameter(key: string): boolean {
- return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync);
- }
-
- /**
- * Get all syncable parameter keys
- */
- static getSyncableParameterKeys(): string[] {
- return SYNCABLE_PARAMETERS.filter((param) => param.canSync).map((param) => param.key);
- }
-
- /**
- * Validate server parameter value
- */
- static validateServerParameter(key: string, value: ParameterValue): boolean {
- const param = SYNCABLE_PARAMETERS.find((p) => p.key === key);
- if (!param) return false;
-
- switch (param.type) {
- case 'number':
- return typeof value === 'number' && !isNaN(value);
- case 'string':
- return typeof value === 'string';
- case 'boolean':
- return typeof value === 'boolean';
- default:
- return false;
- }
- }
-
- // ─────────────────────────────────────────────────────────────────────────────
- // Diff
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Create a diff between current settings and server defaults
- */
- static createParameterDiff(
- currentSettings: ParameterRecord,
- serverDefaults: ParameterRecord
- ): Record<string, { current: ParameterValue; server: ParameterValue; differs: boolean }> {
- const diff: Record<
- string,
- { current: ParameterValue; server: ParameterValue; differs: boolean }
- > = {};
-
- for (const key of this.getSyncableParameterKeys()) {
- const currentValue = currentSettings[key];
- const serverValue = serverDefaults[key];
-
- if (serverValue !== undefined) {
- diff[key] = {
- current: currentValue,
- server: serverValue,
- differs: currentValue !== serverValue
- };
- }
- }
-
- return diff;
- }
-}
-import { apiFetchWithParams } from '$lib/utils/api-fetch';
+import { apiFetchWithParams } from '$lib/utils';
export class PropsService {
/**
+++ /dev/null
-import { getAuthHeaders } from '$lib/utils';
-
-/**
- * PropsService - Server properties management
- *
- * This service handles communication with the /props endpoint to retrieve
- * server configuration, model information, and capabilities.
- *
- * **Responsibilities:**
- * - Fetch server properties from /props endpoint
- * - Handle API authentication
- * - Parse and validate server response
- *
- * **Used by:**
- * - serverStore: Primary consumer for server state management
- */
-export class PropsService {
- // ─────────────────────────────────────────────────────────────────────────────
- // Fetching
- // ─────────────────────────────────────────────────────────────────────────────
-
- /**
- * Fetches server properties from the /props endpoint
- *
- * @param autoload - If false, prevents automatic model loading (default: false)
- * @returns {Promise<ApiLlamaCppServerProps>} Server properties
- * @throws {Error} If the request fails or returns invalid data
- */
- static async fetch(autoload = false): Promise<ApiLlamaCppServerProps> {
- const url = new URL('./props', window.location.href);
- if (!autoload) {
- url.searchParams.set('autoload', 'false');
- }
-
- const response = await fetch(url.toString(), {
- headers: getAuthHeaders()
- });
-
- if (!response.ok) {
- throw new Error(
- `Failed to fetch server properties: ${response.status} ${response.statusText}`
- );
- }
-
- const data = await response.json();
- return data as ApiLlamaCppServerProps;
- }
-
- /**
- * Fetches server properties for a specific model (ROUTER mode)
- *
- * @param modelId - The model ID to fetch properties for
- * @param autoload - If false, prevents automatic model loading (default: false)
- * @returns {Promise<ApiLlamaCppServerProps>} Server properties for the model
- * @throws {Error} If the request fails or returns invalid data
- */
- static async fetchForModel(modelId: string, autoload = false): Promise<ApiLlamaCppServerProps> {
- const url = new URL('./props', window.location.href);
- url.searchParams.set('model', modelId);
- if (!autoload) {
- url.searchParams.set('autoload', 'false');
- }
-
- const response = await fetch(url.toString(), {
- headers: getAuthHeaders()
- });
-
- if (!response.ok) {
- throw new Error(
- `Failed to fetch model properties: ${response.status} ${response.statusText}`
- );
- }
-
- const data = await response.json();
- return data as ApiLlamaCppServerProps;
- }
-}
clearUIState(): void {
this.isLoading = false;
this.currentResponse = '';
+ this.isStreamingActive = false;
}
// ─────────────────────────────────────────────────────────────────────────────
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
-import { DatabaseService } from '$lib/services/database';
+import { DatabaseService } from '$lib/services/database.service';
import { config } from '$lib/stores/settings.svelte';
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
import { AttachmentType } from '$lib/enums';
const leafNodeId =
this.activeConversation.currNode ||
- allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
+ allMessages.reduce((latest: DatabaseMessage, msg: DatabaseMessage) =>
+ msg.timestamp > latest.timestamp ? msg : latest
+ ).id;
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
if (!this.activeConversation) return;
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
- const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
+ const rootMessage = allMessages.find(
+ (m: DatabaseMessage) => m.type === 'root' && m.parent === null
+ );
const currentFirstUserMessage = this.activeMessages.find(
- (m) => m.role === 'user' && m.parent === rootMessage?.id
+ (m: DatabaseMessage) => m.role === 'user' && m.parent === rootMessage?.id
);
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
// Only show title dialog if we're navigating between different first user message siblings
if (rootMessage && this.activeMessages.length > 0) {
const newFirstUserMessage = this.activeMessages.find(
- (m) => m.role === 'user' && m.parent === rootMessage.id
+ (m: DatabaseMessage) => m.role === 'user' && m.parent === rootMessage.id
);
if (
}
const allData = await Promise.all(
- allConversations.map(async (conv) => {
+ allConversations.map(async (conv: DatabaseConversation) => {
const messages = await DatabaseService.getConversationMessages(conv.id);
return { conv, messages };
})
import { SvelteSet } from 'svelte/reactivity';
-import { ModelsService } from '$lib/services/models';
-import { PropsService } from '$lib/services/props';
+import { ModelsService } from '$lib/services/models.service';
+import { PropsService } from '$lib/services/props.service';
import { ServerModelStatus, ModelModality } from '$lib/enums';
import { serverStore } from '$lib/stores/server.svelte';
-import { PropsService } from '$lib/services/props';
+import { PropsService } from '$lib/services/props.service';
import { ServerRole } from '$lib/enums';
/**
import { browser } from '$app/environment';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
-import { ParameterSyncService } from '$lib/services/parameter-sync';
+import { ParameterSyncService } from '$lib/services/parameter-sync.service';
import { serverStore } from '$lib/stores/server.svelte';
import {
configToParameterRecord,
+import type { ErrorDialogType } from '$lib/enums';
+import type { DatabaseMessage, DatabaseMessageExtra } from './database';
+
export type ChatMessageType = 'root' | 'text' | 'think' | 'system';
export type ChatRole = 'user' | 'assistant' | 'system';
file: File;
preview?: string;
textContent?: string;
+ isLoading?: boolean;
+ loadError?: string;
}
export interface ChatAttachmentDisplayItem {
size?: number;
preview?: string;
isImage: boolean;
+ isLoading?: boolean;
+ loadError?: string;
uploadedFile?: ChatUploadedFile;
attachment?: DatabaseMessageExtra;
attachmentIndex?: number;
prompt_ms?: number;
prompt_n?: number;
}
+
+export interface ChatStreamCallbacks {
+ onChunk?: (chunk: string) => void;
+ onReasoningChunk?: (chunk: string) => void;
+ onToolCallChunk?: (chunk: string) => void;
+ onAttachments?: (extras: DatabaseMessageExtra[]) => void;
+ onModel?: (model: string) => void;
+ onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
+ onComplete?: (
+ content?: string,
+ reasoningContent?: string,
+ timings?: ChatMessageTimings,
+ toolCallContent?: string
+ ) => void;
+ onError?: (error: Error) => void;
+}
+
+export interface ErrorDialogState {
+ type: ErrorDialogType;
+ message: string;
+ contextInfo?: { n_prompt_tokens: number; n_ctx: number };
+}
+
+export interface LiveProcessingStats {
+ tokensProcessed: number;
+ totalTokens: number;
+ timeMs: number;
+ tokensPerSecond: number;
+ etaSecs?: number;
+}
+
+export interface LiveGenerationStats {
+ tokensGenerated: number;
+ timeMs: number;
+ tokensPerSecond: number;
+}
+
+export interface AttachmentDisplayItemsOptions {
+ uploadedFiles?: ChatUploadedFile[];
+ attachments?: DatabaseMessageExtra[];
+}
+
+export interface FileProcessingResult {
+ extras: DatabaseMessageExtra[];
+ emptyFiles: string[];
+}
--- /dev/null
+import type { AttachmentType } from '$lib/enums';
+
+/**
+ * Represents a key-value pair.
+ */
+export interface KeyValuePair {
+ key: string;
+ value: string;
+}
+
+/**
+ * Binary detection configuration options.
+ */
+export interface BinaryDetectionOptions {
+ prefixLength: number;
+ suspiciousCharThresholdRatio: number;
+ maxAbsoluteNullBytes: number;
+}
+
+/**
+ * Format for text attachments when copied to clipboard.
+ */
+export interface ClipboardTextAttachment {
+ type: typeof AttachmentType.TEXT;
+ name: string;
+ content: string;
+}
+
+/**
+ * Parsed result from clipboard content.
+ */
+export interface ParsedClipboardContent {
+ message: string;
+ textAttachments: ClipboardTextAttachment[];
+}
ChatAttachmentPreviewItem,
ChatMessageSiblingInfo,
ChatMessagePromptProgress,
- ChatMessageTimings
+ ChatMessageTimings,
+ ChatStreamCallbacks,
+ ErrorDialogState,
+ LiveProcessingStats,
+ LiveGenerationStats,
+ AttachmentDisplayItemsOptions,
+ FileProcessingResult
} from './chat';
// Database types
} from './database';
// Model types
-export type { ModelModalities, ModelOption } from './models';
+export type { ModelModalities, ModelOption, ModalityCapabilities } from './models';
// Settings types
export type {
SettingsConfigValue,
SettingsFieldConfig,
SettingsChatServiceOptions,
- SettingsConfigType
+ SettingsConfigType,
+ ParameterValue,
+ ParameterRecord,
+ ParameterInfo,
+ SyncableParameter
} from './settings';
+
+// Common types
+export type {
+ KeyValuePair,
+ BinaryDetectionOptions,
+ ClipboardTextAttachment,
+ ParsedClipboardContent
+} from './common';
import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
-import type { ChatMessageTimings } from './chat';
+import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
+import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums';
+import type { DatabaseMessageExtra } from './database';
export type SettingsConfigValue = string | number | boolean;
export interface SettingsFieldConfig {
key: string;
label: string;
- type: 'input' | 'textarea' | 'checkbox' | 'select';
+ type: SettingsFieldType;
isExperimental?: boolean;
help?: string;
options?: Array<{ value: string; label: string; icon?: typeof import('@lucide/svelte').Icon }>;
onChunk?: (chunk: string) => void;
onReasoningChunk?: (chunk: string) => void;
onToolCallChunk?: (chunk: string) => void;
+ onAttachments?: (extras: DatabaseMessageExtra[]) => void;
onModel?: (model: string) => void;
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
onComplete?: (
export type SettingsConfigType = typeof SETTING_CONFIG_DEFAULT & {
[key: string]: SettingsConfigValue;
};
+
+export type ParameterValue = string | number | boolean;
+export type ParameterRecord = Record<string, ParameterValue>;
+
+export interface ParameterInfo {
+ value: ParameterValue;
+ source: ParameterSource;
+ serverDefault?: ParameterValue;
+ userOverride?: ParameterValue;
+}
+
+export interface SyncableParameter {
+ key: string;
+ serverKey: string;
+ type: SyncableParameterType;
+ canSync: boolean;
+}
import { base } from '$app/paths';
import { getJsonHeaders, getAuthHeaders } from './api-headers';
+import { UrlPrefix } from '$lib/enums';
/**
* API Fetch Utilities
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
const headers = { ...baseHeaders, ...customHeaders };
- const url = path.startsWith('http://') || path.startsWith('https://') ? path : `${base}${path}`;
+ const url =
+ path.startsWith(UrlPrefix.HTTP) || path.startsWith(UrlPrefix.HTTPS) ? path : `${base}${path}`;
const response = await fetch(url, {
...fetchOptions,
* └── message 5 (assistant)
*/
-import { MessageRole } from '$lib/enums/chat';
+import { MessageRole } from '$lib/enums';
/**
* Filters messages to get the conversation path from root to a specific leaf node.
-const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
-const DEFAULT_CACHE_MAX_ENTRIES = 100;
+import { DEFAULT_CACHE_TTL_MS, DEFAULT_CACHE_MAX_ENTRIES } from '$lib/constants/cache';
/**
* TTL Cache - Time-To-Live cache implementation for memory optimization
import { getFileTypeCategory } from '$lib/utils';
import { FileTypeCategory } from '$lib/enums';
-import type { ModalityCapabilities } from '$lib/types/models';
+import type { ModalityCapabilities } from '$lib/types';
/**
* Check if a file type is supported by the given modalities
*/
import { DEFAULT_BINARY_DETECTION_OPTIONS } from '$lib/constants/binary-detection';
-import type { BinaryDetectionOptions } from '$lib/constants/binary-detection';
+import type { BinaryDetectionOptions } from '$lib/types';
import { FileExtensionText } from '$lib/enums';
/**
+++ /dev/null
-<script module lang="ts">
- import { defineMeta } from '@storybook/addon-svelte-csf';
- import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
- import { expect } from 'storybook/test';
- import jpgAsset from './fixtures/assets/1.jpg?url';
- import svgAsset from './fixtures/assets/hf-logo.svg?url';
- import pdfAsset from './fixtures/assets/example.pdf?raw';
-
- const { Story } = defineMeta({
- title: 'Components/ChatScreen/ChatForm',
- component: ChatForm,
- parameters: {
- layout: 'centered'
- }
- });
-
- let fileAttachments = $state([
- {
- id: '1',
- name: '1.jpg',
- type: 'image/jpeg',
- size: 44891,
- preview: jpgAsset,
- file: new File([''], '1.jpg', { type: 'image/jpeg' })
- },
- {
- id: '2',
- name: 'hf-logo.svg',
- type: 'image/svg+xml',
- size: 1234,
- preview: svgAsset,
- file: new File([''], 'hf-logo.svg', { type: 'image/svg+xml' })
- },
- {
- id: '3',
- name: 'example.pdf',
- type: 'application/pdf',
- size: 351048,
- file: new File([pdfAsset], 'example.pdf', { type: 'application/pdf' })
- }
- ]);
-</script>
-
-<Story
- name="Default"
- args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
- play={async ({ canvas, userEvent }) => {
- const textarea = await canvas.findByRole('textbox');
- const submitButton = await canvas.findByRole('button', { name: 'Send' });
-
- // Expect the input to be focused after the component is mounted
- await expect(textarea).toHaveFocus();
-
- // Expect the submit button to be disabled
- await expect(submitButton).toBeDisabled();
-
- const text = 'What is the meaning of life?';
-
- await userEvent.clear(textarea);
- await userEvent.type(textarea, text);
-
- await expect(textarea).toHaveValue(text);
-
- const fileInput = document.querySelector('input[type="file"]');
- await expect(fileInput).not.toHaveAttribute('accept');
- }}
-/>
-
-<Story name="Loading" args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', isLoading: true }} />
-
-<Story
- name="FileAttachments"
- args={{
- class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
- uploadedFiles: fileAttachments
- }}
- play={async ({ canvas }) => {
- const jpgAttachment = canvas.getByAltText('1.jpg');
- const svgAttachment = canvas.getByAltText('hf-logo.svg');
- const pdfFileExtension = canvas.getByText('PDF');
- const pdfAttachment = canvas.getByText('example.pdf');
- const pdfSize = canvas.getByText('342.82 KB');
-
- await expect(jpgAttachment).toBeInTheDocument();
- await expect(jpgAttachment).toHaveAttribute('src', jpgAsset);
-
- await expect(svgAttachment).toBeInTheDocument();
- await expect(svgAttachment).toHaveAttribute('src', svgAsset);
-
- await expect(pdfFileExtension).toBeInTheDocument();
- await expect(pdfAttachment).toBeInTheDocument();
- await expect(pdfSize).toBeInTheDocument();
- }}
-/>
--- /dev/null
+<script module lang="ts">
+ import { defineMeta } from '@storybook/addon-svelte-csf';
+ import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
+ import { expect } from 'storybook/test';
+ import jpgAsset from './fixtures/assets/1.jpg?url';
+ import svgAsset from './fixtures/assets/hf-logo.svg?url';
+ import pdfAsset from './fixtures/assets/example.pdf?raw';
+
+ const { Story } = defineMeta({
+ title: 'Components/ChatScreen/ChatScreenForm',
+ component: ChatScreenForm,
+ parameters: {
+ layout: 'centered'
+ }
+ });
+
+ let fileAttachments = $state([
+ {
+ id: '1',
+ name: '1.jpg',
+ type: 'image/jpeg',
+ size: 44891,
+ preview: jpgAsset,
+ file: new File([''], '1.jpg', { type: 'image/jpeg' })
+ },
+ {
+ id: '2',
+ name: 'hf-logo.svg',
+ type: 'image/svg+xml',
+ size: 1234,
+ preview: svgAsset,
+ file: new File([''], 'hf-logo.svg', { type: 'image/svg+xml' })
+ },
+ {
+ id: '3',
+ name: 'example.pdf',
+ type: 'application/pdf',
+ size: 351048,
+ file: new File([pdfAsset], 'example.pdf', { type: 'application/pdf' })
+ }
+ ]);
+</script>
+
+<Story
+ name="Default"
+ args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
+ play={async (context) => {
+ const { canvas, userEvent } = context;
+ const textarea = await canvas.findByRole('textbox');
+ const submitButton = await canvas.findByRole('button', { name: 'Send' });
+
+ // Expect the input to be focused after the component is mounted
+ await expect(textarea).toHaveFocus();
+
+ // Expect the submit button to be disabled
+ await expect(submitButton).toBeDisabled();
+
+ const text = 'What is the meaning of life?';
+
+ await userEvent.clear(textarea);
+ await userEvent.type(textarea, text);
+
+ await expect(textarea).toHaveValue(text);
+
+ const fileInput = document.querySelector('input[type="file"]');
+ await expect(fileInput).not.toHaveAttribute('accept');
+ }}
+/>
+
+<Story name="Loading" args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', isLoading: true }} />
+
+<Story
+ name="FileAttachments"
+ args={{
+ class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
+ uploadedFiles: fileAttachments
+ }}
+ play={async (context) => {
+ const { canvas } = context;
+ const jpgAttachment = canvas.getByAltText('1.jpg');
+ const svgAttachment = canvas.getByAltText('hf-logo.svg');
+ const pdfFileExtension = canvas.getByText('PDF');
+ const pdfAttachment = canvas.getByText('example.pdf');
+ const pdfSize = canvas.getByText('342.82 KB');
+
+ await expect(jpgAttachment).toBeInTheDocument();
+ await expect(jpgAttachment).toHaveAttribute('src', jpgAsset);
+
+ await expect(svgAttachment).toBeInTheDocument();
+ await expect(svgAttachment).toHaveAttribute('src', svgAsset);
+
+ await expect(pdfFileExtension).toBeInTheDocument();
+ await expect(pdfAttachment).toBeInTheDocument();
+ await expect(pdfSize).toBeInTheDocument();
+ }}
+/>
All links should have \`target="_blank"\` and \`rel="noopener noreferrer"\` attributes for security.`,
class: 'max-w-[56rem] w-[calc(100vw-2rem)]'
}}
- play={async ({ canvasElement }) => {
+ play={async (context) => {
+ const { canvasElement } = context;
// Wait for component to render
await new Promise((resolve) => setTimeout(resolve, 100));
// Find all links in the rendered content
- const links = canvasElement.querySelectorAll('a[href]');
+ const links = (canvasElement as HTMLElement).querySelectorAll(
+ 'a[href]'
+ ) as NodeListOf<HTMLAnchorElement>;
+ const linkList = Array.from(links) as HTMLAnchorElement[];
// Test that we have the expected number of links
expect(links.length).toBeGreaterThan(0);
// Test each link for proper attributes
- links.forEach((link) => {
+ links.forEach((link: HTMLAnchorElement) => {
const href = link.getAttribute('href');
// Test that external links have proper security attributes
});
// Test specific links exist
- const hugginFaceLink = Array.from(links).find(
+ const hugginFaceLink = linkList.find(
(link) => link.getAttribute('href') === 'https://huggingface.co'
);
expect(hugginFaceLink).toBeTruthy();
expect(hugginFaceLink?.textContent).toBe('Hugging Face Homepage');
- const githubLink = Array.from(links).find(
+ const githubLink = linkList.find(
(link) => link.getAttribute('href') === 'https://github.com/ggml-org/llama.cpp'
);
expect(githubLink).toBeTruthy();
expect(githubLink?.textContent).toBe('GitHub Repository');
- const openaiLink = Array.from(links).find(
- (link) => link.getAttribute('href') === 'https://openai.com'
- );
+ const openaiLink = linkList.find((link) => link.getAttribute('href') === 'https://openai.com');
expect(openaiLink).toBeTruthy();
expect(openaiLink?.textContent).toBe('OpenAI Website');
- const googleLink = Array.from(links).find(
+ const googleLink = linkList.find(
(link) => link.getAttribute('href') === 'https://www.google.com'
);
expect(googleLink).toBeTruthy();
expect(googleLink?.textContent).toBe('Google Search');
// Test inline links (auto-linked URLs)
- const exampleLink = Array.from(links).find(
+ const exampleLink = linkList.find(
(link) => link.getAttribute('href') === 'https://example.com'
);
expect(exampleLink).toBeTruthy();
- const pythonDocsLink = Array.from(links).find(
+ const pythonDocsLink = linkList.find(
(link) => link.getAttribute('href') === 'https://docs.python.org'
);
expect(pythonDocsLink).toBeTruthy();
import { sveltekit } from '@sveltejs/kit/vite';
import * as fflate from 'fflate';
import { readFileSync, writeFileSync, existsSync } from 'fs';
-import { resolve } from 'path';
-import { defineConfig } from 'vite';
+import { dirname, resolve } from 'path';
+import { fileURLToPath } from 'url';
+
+import { defineConfig, searchForWorkspaceRoot } from 'vite';
import devtoolsJson from 'vite-plugin-devtools-json';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
const GUIDE_FOR_FRONTEND = `
<!--
This is a single file build of the frontend.
proxy: {
'/v1': 'http://localhost:8080',
'/props': 'http://localhost:8080',
- '/models': 'http://localhost:8080'
+ '/models': 'http://localhost:8080',
+ '/cors-proxy': 'http://localhost:8080'
},
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin'
+ },
+ fs: {
+ allow: [searchForWorkspaceRoot(process.cwd()), resolve(__dirname, 'tests')]
}
}
});