webui: Architecture and UI improvements (#19596)
This commit is contained in:
Binary file not shown.
@@ -1,17 +1,24 @@
|
|||||||
import type { StorybookConfig } from '@storybook/sveltekit';
|
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 = {
|
const config: StorybookConfig = {
|
||||||
stories: ['../tests/stories/**/*.mdx', '../tests/stories/**/*.stories.@(js|ts|svelte)'],
|
stories: ['../tests/stories/**/*.mdx', '../tests/stories/**/*.stories.@(js|ts|svelte)'],
|
||||||
addons: [
|
addons: [
|
||||||
'@storybook/addon-svelte-csf',
|
'@storybook/addon-svelte-csf',
|
||||||
'@chromatic-com/storybook',
|
'@chromatic-com/storybook',
|
||||||
'@storybook/addon-docs',
|
'@storybook/addon-vitest',
|
||||||
'@storybook/addon-a11y',
|
'@storybook/addon-a11y',
|
||||||
'@storybook/addon-vitest'
|
'@storybook/addon-docs'
|
||||||
],
|
],
|
||||||
framework: {
|
framework: '@storybook/sveltekit',
|
||||||
name: '@storybook/sveltekit',
|
viteFinal: async (config) => {
|
||||||
options: {}
|
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;
|
export default config;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const preview: Preview = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
backgrounds: {
|
backgrounds: {
|
||||||
disable: true
|
disabled: true
|
||||||
},
|
},
|
||||||
|
|
||||||
a11y: {
|
a11y: {
|
||||||
|
|||||||
@@ -49,14 +49,20 @@ sequenceDiagram
|
|||||||
settingsStore->>serverStore: defaultParams
|
settingsStore->>serverStore: defaultParams
|
||||||
serverStore-->>settingsStore: {temperature, top_p, top_k, ...}
|
serverStore-->>settingsStore: {temperature, top_p, top_k, ...}
|
||||||
|
|
||||||
settingsStore->>ParamSvc: extractServerDefaults(defaultParams)
|
loop each SYNCABLE_PARAMETER
|
||||||
ParamSvc-->>settingsStore: Record<string, value>
|
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)
|
alt serverStore.props has webuiSettings
|
||||||
Note right of ParamSvc: For each syncable parameter:<br/>- If NOT in userOverrides → use server default<br/>- If in userOverrides → keep user value
|
settingsStore->>settingsStore: Apply webuiSettings from server
|
||||||
ParamSvc-->>settingsStore: mergedConfig
|
Note right of settingsStore: Server-provided UI settings<br/>(e.g. showRawOutputSwitch)
|
||||||
|
end
|
||||||
|
|
||||||
settingsStore->>settingsStore: config = mergedConfig
|
|
||||||
settingsStore->>settingsStore: saveConfig()
|
settingsStore->>settingsStore: saveConfig()
|
||||||
deactivate settingsStore
|
deactivate settingsStore
|
||||||
|
|
||||||
@@ -67,11 +73,18 @@ sequenceDiagram
|
|||||||
UI->>settingsStore: updateConfig(key, value)
|
UI->>settingsStore: updateConfig(key, value)
|
||||||
activate settingsStore
|
activate settingsStore
|
||||||
settingsStore->>settingsStore: config[key] = value
|
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->>settingsStore: saveConfig()
|
||||||
settingsStore->>LS: set("llama-config", config)
|
settingsStore->>LS: set(CONFIG_LOCALSTORAGE_KEY, config)
|
||||||
settingsStore->>LS: set("llama-userOverrides", [...userOverrides])
|
settingsStore->>LS: set(USER_OVERRIDES_LOCALSTORAGE_KEY, [...userOverrides])
|
||||||
deactivate settingsStore
|
deactivate settingsStore
|
||||||
|
|
||||||
UI->>settingsStore: updateMultipleConfig({key1: val1, key2: val2})
|
UI->>settingsStore: updateMultipleConfig({key1: val1, key2: val2})
|
||||||
@@ -88,10 +101,9 @@ sequenceDiagram
|
|||||||
|
|
||||||
UI->>settingsStore: resetConfig()
|
UI->>settingsStore: resetConfig()
|
||||||
activate settingsStore
|
activate settingsStore
|
||||||
settingsStore->>settingsStore: config = SETTING_CONFIG_DEFAULT
|
settingsStore->>settingsStore: config = {...SETTING_CONFIG_DEFAULT}
|
||||||
settingsStore->>settingsStore: userOverrides.clear()
|
settingsStore->>settingsStore: userOverrides.clear()
|
||||||
settingsStore->>settingsStore: syncWithServerDefaults()
|
Note right of settingsStore: All params reset to defaults<br/>Next syncWithServerDefaults will adopt server values
|
||||||
Note right of settingsStore: Apply server defaults for syncable params
|
|
||||||
settingsStore->>settingsStore: saveConfig()
|
settingsStore->>settingsStore: saveConfig()
|
||||||
deactivate settingsStore
|
deactivate settingsStore
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Eye } from '@lucide/svelte';
|
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';
|
import { FileTypeText } from '$lib/enums';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -57,13 +57,13 @@
|
|||||||
let currentConfig = $derived(config());
|
let currentConfig = $derived(config());
|
||||||
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
|
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
|
||||||
let isRecording = $state(false);
|
let isRecording = $state(false);
|
||||||
let message = $state(initialMessage);
|
let message = $derived(initialMessage);
|
||||||
let pasteLongTextToFileLength = $derived.by(() => {
|
let pasteLongTextToFileLength = $derived.by(() => {
|
||||||
const n = Number(currentConfig.pasteLongTextToFileLen);
|
const n = Number(currentConfig.pasteLongTextToFileLen);
|
||||||
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
|
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
|
||||||
});
|
});
|
||||||
let previousIsLoading = $state(isLoading);
|
let previousIsLoading = $derived(isLoading);
|
||||||
let previousInitialMessage = $state(initialMessage);
|
let previousInitialMessage = $derived(initialMessage);
|
||||||
let recordingSupported = $state(false);
|
let recordingSupported = $state(false);
|
||||||
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
|
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
|
||||||
|
|
||||||
@@ -289,7 +289,7 @@
|
|||||||
|
|
||||||
<form
|
<form
|
||||||
onsubmit={handleSubmit}
|
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'
|
? 'cursor-not-allowed opacity-60'
|
||||||
: ''} {className}"
|
: ''} {className}"
|
||||||
data-slot="chat-form"
|
data-slot="chat-form"
|
||||||
@@ -304,10 +304,11 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<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}
|
onpaste={handlePaste}
|
||||||
>
|
>
|
||||||
<ChatFormTextarea
|
<ChatFormTextarea
|
||||||
|
class="px-5 py-1.5 md:pt-0"
|
||||||
bind:this={textareaRef}
|
bind:this={textareaRef}
|
||||||
bind:value={message}
|
bind:value={message}
|
||||||
onKeydown={handleKeydown}
|
onKeydown={handleKeydown}
|
||||||
@@ -315,6 +316,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatFormActions
|
<ChatFormActions
|
||||||
|
class="px-3"
|
||||||
bind:this={chatFormActionsRef}
|
bind:this={chatFormActionsRef}
|
||||||
canSend={message.trim().length > 0 || uploadedFiles.length > 0}
|
canSend={message.trim().length > 0 || uploadedFiles.length > 0}
|
||||||
hasText={message.trim().length > 0}
|
hasText={message.trim().length > 0}
|
||||||
|
|||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
<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>
|
||||||
+27
-20
@@ -2,7 +2,7 @@
|
|||||||
import { Square } from '@lucide/svelte';
|
import { Square } from '@lucide/svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import {
|
import {
|
||||||
ChatFormActionFileAttachments,
|
ChatFormActionAttachmentsDropdown,
|
||||||
ChatFormActionRecord,
|
ChatFormActionRecord,
|
||||||
ChatFormActionSubmit,
|
ChatFormActionSubmit,
|
||||||
ModelsSelector
|
ModelsSelector
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
|
|
||||||
const { handleModelChange } = useModelChangeValidation({
|
const { handleModelChange } = useModelChangeValidation({
|
||||||
getRequiredModalities: () => usedModalities(),
|
getRequiredModalities: () => usedModalities(),
|
||||||
onValidationFailure: async (previousModelId) => {
|
onValidationFailure: async (previousModelId: string | null) => {
|
||||||
if (previousModelId) {
|
if (previousModelId) {
|
||||||
await modelsStore.selectModelById(previousModelId);
|
await modelsStore.selectModelById(previousModelId);
|
||||||
}
|
}
|
||||||
@@ -166,32 +166,39 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
|
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
|
||||||
<ChatFormActionFileAttachments
|
<div class="mr-auto flex items-center gap-2">
|
||||||
class="mr-auto"
|
<ChatFormActionAttachmentsDropdown
|
||||||
{disabled}
|
{disabled}
|
||||||
{hasAudioModality}
|
{hasAudioModality}
|
||||||
{hasVisionModality}
|
{hasVisionModality}
|
||||||
{onFileUpload}
|
{onFileUpload}
|
||||||
{onSystemPromptClick}
|
{onSystemPromptClick}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ModelsSelector
|
<div class="ml-auto flex items-center gap-1.5">
|
||||||
{disabled}
|
<ModelsSelector
|
||||||
bind:this={selectorModelRef}
|
{disabled}
|
||||||
currentModel={conversationModel}
|
bind:this={selectorModelRef}
|
||||||
forceForegroundText={true}
|
currentModel={conversationModel}
|
||||||
useGlobalSelection={true}
|
forceForegroundText={true}
|
||||||
onModelChange={handleModelChange}
|
useGlobalSelection={true}
|
||||||
/>
|
onModelChange={handleModelChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
onclick={onStop}
|
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>
|
<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>
|
</Button>
|
||||||
{:else if shouldShowRecordButton}
|
{:else if shouldShowRecordButton}
|
||||||
<ChatFormActionRecord {disabled} {hasAudioModality} {isLoading} {isRecording} {onMicClick} />
|
<ChatFormActionRecord {disabled} {hasAudioModality} {isLoading} {isRecording} {onMicClick} />
|
||||||
|
|||||||
@@ -62,8 +62,8 @@
|
|||||||
assistantMessages: number;
|
assistantMessages: number;
|
||||||
messageTypes: string[];
|
messageTypes: string[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
let editedContent = $state(message.content);
|
let editedContent = $derived(message.content);
|
||||||
let editedExtras = $state<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
|
let editedExtras = $derived<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
|
||||||
let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
|
let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
|
||||||
let isEditing = $state(false);
|
let isEditing = $state(false);
|
||||||
let showDeleteDialog = $state(false);
|
let showDeleteDialog = $state(false);
|
||||||
|
|||||||
+1
-1
@@ -105,7 +105,7 @@
|
|||||||
|
|
||||||
const { handleModelChange } = useModelChangeValidation({
|
const { handleModelChange } = useModelChangeValidation({
|
||||||
getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
|
getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
|
||||||
onSuccess: (modelName) => onRegenerate(modelName)
|
onSuccess: (modelName: string) => onRegenerate(modelName)
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleCopyModel() {
|
function handleCopyModel() {
|
||||||
|
|||||||
+1
-1
@@ -133,7 +133,7 @@
|
|||||||
|
|
||||||
const { handleModelChange } = useModelChangeValidation({
|
const { handleModelChange } = useModelChangeValidation({
|
||||||
getRequiredModalities,
|
getRequiredModalities,
|
||||||
onValidationFailure: async (previousModelId) => {
|
onValidationFailure: async (previousModelId: string | null) => {
|
||||||
if (previousModelId) {
|
if (previousModelId) {
|
||||||
await modelsStore.selectModelById(previousModelId);
|
await modelsStore.selectModelById(previousModelId);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
initialView = ChatMessageStatsView.GENERATION
|
initialView = ChatMessageStatsView.GENERATION
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let activeView: ChatMessageStatsView = $state(initialView);
|
let activeView: ChatMessageStatsView = $derived(initialView);
|
||||||
let hasAutoSwitchedToGeneration = $state(false);
|
let hasAutoSwitchedToGeneration = $state(false);
|
||||||
|
|
||||||
// In live mode: auto-switch to GENERATION tab when prompt processing completes
|
// In live mode: auto-switch to GENERATION tab when prompt processing completes
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
||||||
import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
|
import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
|
||||||
import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
|
import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
|
||||||
|
import { ErrorDialogType } from '$lib/enums';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fade, fly, slide } from 'svelte/transition';
|
import { fade, fly, slide } from 'svelte/transition';
|
||||||
import { Trash2, AlertTriangle, RefreshCw } from '@lucide/svelte';
|
import { Trash2, AlertTriangle, RefreshCw } from '@lucide/svelte';
|
||||||
@@ -616,7 +617,7 @@
|
|||||||
contextInfo={activeErrorDialog?.contextInfo}
|
contextInfo={activeErrorDialog?.contextInfo}
|
||||||
onOpenChange={handleErrorDialogOpenChange}
|
onOpenChange={handleErrorDialogOpenChange}
|
||||||
open={Boolean(activeErrorDialog)}
|
open={Boolean(activeErrorDialog)}
|
||||||
type={activeErrorDialog?.type ?? 'server'}
|
type={(activeErrorDialog?.type as ErrorDialogType) ?? ErrorDialogType.SERVER}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<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>
|
||||||
@@ -18,19 +18,24 @@
|
|||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
||||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
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 { setMode } from 'mode-watcher';
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
|
initialSection?: SettingsSectionTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onSave }: Props = $props();
|
let { onSave, initialSection }: Props = $props();
|
||||||
|
|
||||||
const settingSections: Array<{
|
const settingSections: Array<{
|
||||||
fields: SettingsFieldConfig[];
|
fields: SettingsFieldConfig[];
|
||||||
icon: Component;
|
icon: Component;
|
||||||
title: string;
|
title: SettingsSectionTitle;
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
title: 'General',
|
title: 'General',
|
||||||
@@ -285,7 +290,9 @@
|
|||||||
// }
|
// }
|
||||||
];
|
];
|
||||||
|
|
||||||
let activeSection = $state('General');
|
let activeSection = $derived<SettingsSectionTitle>(
|
||||||
|
initialSection ?? SETTINGS_SECTION_TITLES.GENERAL
|
||||||
|
);
|
||||||
let currentSection = $derived(
|
let currentSection = $derived(
|
||||||
settingSections.find((section) => section.title === activeSection) || settingSections[0]
|
settingSections.find((section) => section.title === activeSection) || settingSections[0]
|
||||||
);
|
);
|
||||||
@@ -295,6 +302,16 @@
|
|||||||
let canScrollRight = $state(false);
|
let canScrollRight = $state(false);
|
||||||
let scrollContainer: HTMLDivElement | undefined = $state();
|
let scrollContainer: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!initialSection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingSections.some((section) => section.title === initialSection)) {
|
||||||
|
activeSection = initialSection;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function handleThemeChange(newTheme: string) {
|
function handleThemeChange(newTheme: string) {
|
||||||
localConfig.theme = newTheme;
|
localConfig.theme = newTheme;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -142,7 +142,7 @@
|
|||||||
{
|
{
|
||||||
icon: Download,
|
icon: Download,
|
||||||
label: 'Export',
|
label: 'Export',
|
||||||
onclick: (e) => {
|
onclick: (e: Event) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
conversationsStore.downloadConversation(conversation.id);
|
conversationsStore.downloadConversation(conversation.id);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
|
import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
|
||||||
import { rehypeEnhanceLinks } from '$lib/markdown/enhance-links';
|
import { rehypeEnhanceLinks } from '$lib/markdown/enhance-links';
|
||||||
import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks';
|
import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks';
|
||||||
|
import { rehypeResolveAttachmentImages } from '$lib/markdown/resolve-attachment-images';
|
||||||
import { remarkLiteralHtml } from '$lib/markdown/literal-html';
|
import { remarkLiteralHtml } from '$lib/markdown/literal-html';
|
||||||
import { copyCodeToClipboard, preprocessLaTeX, getImageErrorFallbackHtml } from '$lib/utils';
|
import { copyCodeToClipboard, preprocessLaTeX, getImageErrorFallbackHtml } from '$lib/utils';
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
DATA_ERROR_HANDLED_ATTR,
|
DATA_ERROR_HANDLED_ATTR,
|
||||||
BOOL_TRUE_STRING
|
BOOL_TRUE_STRING
|
||||||
} from '$lib/constants/markdown';
|
} from '$lib/constants/markdown';
|
||||||
|
import { UrlPrefix } from '$lib/enums';
|
||||||
import { FileTypeText } from '$lib/enums/files';
|
import { FileTypeText } from '$lib/enums/files';
|
||||||
import {
|
import {
|
||||||
highlightCode,
|
highlightCode,
|
||||||
@@ -33,8 +35,7 @@
|
|||||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||||
import { mode } from 'mode-watcher';
|
import { mode } from 'mode-watcher';
|
||||||
import ActionIconsCodeBlock from '$lib/components/app/actions/ActionIconsCodeBlock.svelte';
|
import { ActionIconsCodeBlock, DialogCodePreview } from '$lib/components/app';
|
||||||
import DialogCodePreview from '$lib/components/app/misc/CodePreviewDialog.svelte';
|
|
||||||
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
||||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||||
|
|
||||||
@@ -100,6 +101,7 @@
|
|||||||
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
|
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
|
||||||
.use(rehypeEnhanceLinks) // Add target="_blank" to links
|
.use(rehypeEnhanceLinks) // Add target="_blank" to links
|
||||||
.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
|
.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
|
||||||
|
.use(rehypeResolveAttachmentImages, { attachments })
|
||||||
.use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
|
.use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -500,7 +502,10 @@
|
|||||||
if (!img || !img.src) return;
|
if (!img || !img.src) return;
|
||||||
|
|
||||||
// Don't handle data URLs or already-handled images
|
// 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;
|
return;
|
||||||
img.dataset[DATA_ERROR_HANDLED_ATTR] = BOOL_TRUE_STRING;
|
img.dataset[DATA_ERROR_HANDLED_ATTR] = BOOL_TRUE_STRING;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
import { AlertTriangle, TimerOff } from '@lucide/svelte';
|
import { AlertTriangle, TimerOff } from '@lucide/svelte';
|
||||||
|
import { ErrorDialogType } from '$lib/enums';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
type: 'timeout' | 'server';
|
type: ErrorDialogType;
|
||||||
message: string;
|
message: string;
|
||||||
contextInfo?: { n_prompt_tokens: number; n_ctx: number };
|
contextInfo?: { n_prompt_tokens: number; n_ctx: number };
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
|
|
||||||
let { open = $bindable(), type, message, contextInfo, onOpenChange }: Props = $props();
|
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 title = $derived(isTimeout ? 'TCP Timeout' : 'Server Error');
|
||||||
const description = $derived(
|
const description = $derived(
|
||||||
isTimeout
|
isTimeout
|
||||||
@@ -58,7 +59,12 @@
|
|||||||
<span class="font-medium">Prompt tokens:</span>
|
<span class="font-medium">Prompt tokens:</span>
|
||||||
{contextInfo.n_prompt_tokens.toLocaleString()}
|
{contextInfo.n_prompt_tokens.toLocaleString()}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
import { ChatSettings } from '$lib/components/app';
|
import { ChatSettings } from '$lib/components/app';
|
||||||
|
import type { SettingsSectionTitle } from '$lib/constants/settings-sections';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
|
initialSection?: SettingsSectionTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onOpenChange, open = false }: Props = $props();
|
let { onOpenChange, open = false, initialSection }: Props = $props();
|
||||||
|
|
||||||
let chatSettingsRef: ChatSettings | undefined = $state();
|
let chatSettingsRef: ChatSettings | undefined = $state();
|
||||||
|
|
||||||
@@ -28,10 +30,9 @@
|
|||||||
|
|
||||||
<Dialog.Root {open} onOpenChange={handleClose}>
|
<Dialog.Root {open} onOpenChange={handleClose}>
|
||||||
<Dialog.Content
|
<Dialog.Content
|
||||||
class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
|
class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] max-w-4xl! flex-col gap-0 rounded-none
|
||||||
md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
|
p-0 md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
|
||||||
style="max-width: 48rem;"
|
|
||||||
>
|
>
|
||||||
<ChatSettings bind:this={chatSettingsRef} onSave={handleSave} />
|
<ChatSettings bind:this={chatSettingsRef} onSave={handleSave} {initialSection} />
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@
|
|||||||
<iframe
|
<iframe
|
||||||
bind:this={iframeRef}
|
bind:this={iframeRef}
|
||||||
title="Preview {language}"
|
title="Preview {language}"
|
||||||
sandbox="allow-scripts"
|
sandbox="allow-scripts allow-same-origin"
|
||||||
class="code-preview-iframe"
|
class="code-preview-iframe"
|
||||||
></iframe>
|
></iframe>
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
|
import { KeyboardKey } from '$lib/enums';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === KeyboardKey.ENTER) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onConfirm();
|
onConfirm();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
import * as Table from '$lib/components/ui/table';
|
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 { serverStore } from '$lib/stores/server.svelte';
|
||||||
import { modelsStore, modelOptions, modelsLoading } from '$lib/stores/models.svelte';
|
import { modelsStore, modelOptions, modelsLoading } from '$lib/stores/models.svelte';
|
||||||
import { formatFileSize, formatParameters, formatNumber } from '$lib/utils';
|
import { formatFileSize, formatParameters, formatNumber } from '$lib/utils';
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
|
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>Model Information</Dialog.Title>
|
<Dialog.Title>Model Information</Dialog.Title>
|
||||||
|
|
||||||
<Dialog.Description>Current model details and capabilities</Dialog.Description>
|
<Dialog.Description>Current model details and capabilities</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@
|
|||||||
{modelName}
|
{modelName}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<CopyToClipboardIcon
|
<ActionIconCopyToClipboard
|
||||||
text={modelName || ''}
|
text={modelName || ''}
|
||||||
canCopy={!!modelName}
|
canCopy={!!modelName}
|
||||||
ariaLabel="Copy model name to clipboard"
|
ariaLabel="Copy model name to clipboard"
|
||||||
@@ -97,7 +98,7 @@
|
|||||||
{serverProps.model_path}
|
{serverProps.model_path}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<CopyToClipboardIcon
|
<ActionIconCopyToClipboard
|
||||||
text={serverProps.model_path}
|
text={serverProps.model_path}
|
||||||
ariaLabel="Copy model path to clipboard"
|
ariaLabel="Copy model path to clipboard"
|
||||||
/>
|
/>
|
||||||
@@ -105,17 +106,29 @@
|
|||||||
</Table.Row>
|
</Table.Row>
|
||||||
|
|
||||||
<!-- Context Size -->
|
<!-- Context Size -->
|
||||||
<Table.Row>
|
{#if serverProps?.default_generation_settings?.n_ctx}
|
||||||
<Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell>
|
<Table.Row>
|
||||||
<Table.Cell
|
<Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell>
|
||||||
>{formatNumber(serverProps.default_generation_settings.n_ctx)} tokens</Table.Cell
|
|
||||||
>
|
<Table.Cell
|
||||||
</Table.Row>
|
>{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 -->
|
<!-- Training Context -->
|
||||||
{#if modelMeta?.n_ctx_train}
|
{#if modelMeta?.n_ctx_train}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="h-10 align-middle font-medium">Training Context</Table.Cell>
|
<Table.Cell class="h-10 align-middle font-medium">Training Context</Table.Cell>
|
||||||
|
|
||||||
<Table.Cell>{formatNumber(modelMeta.n_ctx_train)} tokens</Table.Cell>
|
<Table.Cell>{formatNumber(modelMeta.n_ctx_train)} tokens</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -124,6 +137,7 @@
|
|||||||
{#if modelMeta?.size}
|
{#if modelMeta?.size}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="h-10 align-middle font-medium">Model Size</Table.Cell>
|
<Table.Cell class="h-10 align-middle font-medium">Model Size</Table.Cell>
|
||||||
|
|
||||||
<Table.Cell>{formatFileSize(modelMeta.size)}</Table.Cell>
|
<Table.Cell>{formatFileSize(modelMeta.size)}</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -132,6 +146,7 @@
|
|||||||
{#if modelMeta?.n_params}
|
{#if modelMeta?.n_params}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="h-10 align-middle font-medium">Parameters</Table.Cell>
|
<Table.Cell class="h-10 align-middle font-medium">Parameters</Table.Cell>
|
||||||
|
|
||||||
<Table.Cell>{formatParameters(modelMeta.n_params)}</Table.Cell>
|
<Table.Cell>{formatParameters(modelMeta.n_params)}</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -140,6 +155,7 @@
|
|||||||
{#if modelMeta?.n_embd}
|
{#if modelMeta?.n_embd}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="align-middle font-medium">Embedding Size</Table.Cell>
|
<Table.Cell class="align-middle font-medium">Embedding Size</Table.Cell>
|
||||||
|
|
||||||
<Table.Cell>{formatNumber(modelMeta.n_embd)}</Table.Cell>
|
<Table.Cell>{formatNumber(modelMeta.n_embd)}</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -148,6 +164,7 @@
|
|||||||
{#if modelMeta?.n_vocab}
|
{#if modelMeta?.n_vocab}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="align-middle font-medium">Vocabulary Size</Table.Cell>
|
<Table.Cell class="align-middle font-medium">Vocabulary Size</Table.Cell>
|
||||||
|
|
||||||
<Table.Cell>{formatNumber(modelMeta.n_vocab)} tokens</Table.Cell>
|
<Table.Cell>{formatNumber(modelMeta.n_vocab)} tokens</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -163,6 +180,7 @@
|
|||||||
<!-- Total Slots -->
|
<!-- Total Slots -->
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="align-middle font-medium">Parallel Slots</Table.Cell>
|
<Table.Cell class="align-middle font-medium">Parallel Slots</Table.Cell>
|
||||||
|
|
||||||
<Table.Cell>{serverProps.total_slots}</Table.Cell>
|
<Table.Cell>{serverProps.total_slots}</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
|
|
||||||
@@ -170,6 +188,7 @@
|
|||||||
{#if modalities.length > 0}
|
{#if modalities.length > 0}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="align-middle font-medium">Modalities</Table.Cell>
|
<Table.Cell class="align-middle font-medium">Modalities</Table.Cell>
|
||||||
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
<BadgeModality {modalities} />
|
<BadgeModality {modalities} />
|
||||||
@@ -181,6 +200,7 @@
|
|||||||
<!-- Build Info -->
|
<!-- Build Info -->
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="align-middle font-medium">Build Info</Table.Cell>
|
<Table.Cell class="align-middle font-medium">Build Info</Table.Cell>
|
||||||
|
|
||||||
<Table.Cell class="align-middle font-mono text-xs"
|
<Table.Cell class="align-middle font-mono text-xs"
|
||||||
>{serverProps.build_info}</Table.Cell
|
>{serverProps.build_info}</Table.Cell
|
||||||
>
|
>
|
||||||
@@ -190,6 +210,7 @@
|
|||||||
{#if serverProps.chat_template}
|
{#if serverProps.chat_template}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="align-middle font-medium">Chat Template</Table.Cell>
|
<Table.Cell class="align-middle font-medium">Chat Template</Table.Cell>
|
||||||
|
|
||||||
<Table.Cell class="py-10">
|
<Table.Cell class="py-10">
|
||||||
<div class="max-h-120 overflow-y-auto rounded-md bg-muted p-4">
|
<div class="max-h-120 overflow-y-auto rounded-md bg-muted p-4">
|
||||||
<pre
|
<pre
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<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>
|
||||||
+1
-1
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
<div class="relative {className}">
|
<div class="relative {className}">
|
||||||
<Search
|
<Search
|
||||||
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
|
class="absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
*
|
||||||
|
* 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';
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
// 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 ChatAttachmentPreview } from './chat/ChatAttachments/ChatAttachmentPreview.svelte';
|
||||||
export { default as ChatAttachmentThumbnailFile } from './chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte';
|
export { default as ChatAttachmentThumbnailFile } from './chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte';
|
||||||
export { default as ChatAttachmentThumbnailImage } from './chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte';
|
export { default as ChatAttachmentThumbnailImage } from './chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte';
|
||||||
export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttachmentsList.svelte';
|
export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttachmentsList.svelte';
|
||||||
export { default as ChatAttachmentsViewAll } from './chat/ChatAttachments/ChatAttachmentsViewAll.svelte';
|
export { default as ChatAttachmentsViewAll } from './chat/ChatAttachments/ChatAttachmentsViewAll.svelte';
|
||||||
|
|
||||||
export { default as ChatForm } from './chat/ChatForm/ChatForm.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 ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte';
|
||||||
export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
|
export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
|
||||||
export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte';
|
export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte';
|
||||||
@@ -14,36 +22,38 @@ export { default as ChatFormActionSubmit } from './chat/ChatForm/ChatFormActions
|
|||||||
export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
|
export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
|
||||||
export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
|
export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
|
||||||
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
|
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
|
||||||
|
|
||||||
export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
|
export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
|
||||||
export { default as ChatMessageActions } from './chat/ChatMessages/ChatMessageActions.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 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 ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.svelte';
|
||||||
export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte';
|
export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte';
|
||||||
export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.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 ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
|
||||||
export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
|
export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
|
||||||
|
|
||||||
export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.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 ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
|
||||||
export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte';
|
export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte';
|
||||||
|
|
||||||
export { default as ChatSettings } from './chat/ChatSettings/ChatSettings.svelte';
|
export { default as ChatSettings } from './chat/ChatSettings/ChatSettings.svelte';
|
||||||
export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
|
export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
|
||||||
export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
|
export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
|
||||||
export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte';
|
export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte';
|
||||||
export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
|
export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
|
||||||
|
|
||||||
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.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 ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
|
||||||
export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
|
export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
|
||||||
|
|
||||||
// Dialogs
|
// Dialogs
|
||||||
|
|
||||||
export { default as DialogChatAttachmentPreview } from './dialogs/DialogChatAttachmentPreview.svelte';
|
export { default as DialogChatAttachmentPreview } from './dialogs/DialogChatAttachmentPreview.svelte';
|
||||||
export { default as DialogChatAttachmentsViewAll } from './dialogs/DialogChatAttachmentsViewAll.svelte';
|
export { default as DialogChatAttachmentsViewAll } from './dialogs/DialogChatAttachmentsViewAll.svelte';
|
||||||
export { default as DialogChatError } from './dialogs/DialogChatError.svelte';
|
export { default as DialogChatError } from './dialogs/DialogChatError.svelte';
|
||||||
export { default as DialogChatSettings } from './dialogs/DialogChatSettings.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 DialogConfirmation } from './dialogs/DialogConfirmation.svelte';
|
||||||
export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte';
|
export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte';
|
||||||
export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte';
|
export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte';
|
||||||
@@ -51,25 +61,8 @@ export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.
|
|||||||
export { default as DialogModelInformation } from './dialogs/DialogModelInformation.svelte';
|
export { default as DialogModelInformation } from './dialogs/DialogModelInformation.svelte';
|
||||||
export { default as DialogModelNotAvailable } from './dialogs/DialogModelNotAvailable.svelte';
|
export { default as DialogModelNotAvailable } from './dialogs/DialogModelNotAvailable.svelte';
|
||||||
|
|
||||||
// Miscellanous
|
// Compatibility aliases
|
||||||
|
export { default as ActionButton } from './actions/ActionIcon.svelte';
|
||||||
export { default as ActionButton } from './misc/ActionButton.svelte';
|
export { default as ActionDropdown } from './navigation/DropdownMenuActions.svelte';
|
||||||
export { default as ActionDropdown } from './misc/ActionDropdown.svelte';
|
export { default as CopyToClipboardIcon } from './actions/ActionIconCopyToClipboard.svelte';
|
||||||
export { default as BadgeChatStatistic } from './misc/BadgeChatStatistic.svelte';
|
export { default as RemoveButton } from './actions/ActionIconRemove.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';
|
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
<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}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<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}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<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}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<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)}
|
|
||||||
/>
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,872 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Package } from '@lucide/svelte';
|
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 { modelsStore } from '$lib/stores/models.svelte';
|
||||||
import { serverStore } from '$lib/stores/server.svelte';
|
import { serverStore } from '$lib/stores/server.svelte';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
{model}
|
{model}
|
||||||
|
|
||||||
{#if showCopyIcon}
|
{#if showCopyIcon}
|
||||||
<CopyToClipboardIcon text={model || ''} ariaLabel="Copy model name" />
|
<ActionIconCopyToClipboard text={model || ''} ariaLabel="Copy model name" />
|
||||||
{/if}
|
{/if}
|
||||||
</BadgeInfo>
|
</BadgeInfo>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/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 Tooltip from '$lib/components/ui/tooltip';
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
|
||||||
import { cn } from '$lib/components/ui/utils';
|
import { cn } from '$lib/components/ui/utils';
|
||||||
import {
|
import {
|
||||||
modelsStore,
|
modelsStore,
|
||||||
@@ -11,13 +11,15 @@
|
|||||||
modelsUpdating,
|
modelsUpdating,
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
routerModels,
|
routerModels,
|
||||||
propsCacheVersion,
|
|
||||||
singleModelName
|
singleModelName
|
||||||
} from '$lib/stores/models.svelte';
|
} from '$lib/stores/models.svelte';
|
||||||
import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte';
|
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
|
||||||
import { ServerModelStatus } from '$lib/enums';
|
|
||||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
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';
|
import type { ModelOption } from '$lib/types/models';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -29,11 +31,7 @@
|
|||||||
forceForegroundText?: boolean;
|
forceForegroundText?: boolean;
|
||||||
/** When true, user's global selection takes priority over currentModel (for form selector) */
|
/** When true, user's global selection takes priority over currentModel (for form selector) */
|
||||||
useGlobalSelection?: boolean;
|
useGlobalSelection?: boolean;
|
||||||
/**
|
/** Optional compatibility prop for context-aware selectors. */
|
||||||
* When provided, only consider modalities from messages BEFORE this message.
|
|
||||||
* Used for regeneration - allows selecting models that don't support modalities
|
|
||||||
* used in later messages.
|
|
||||||
*/
|
|
||||||
upToMessageId?: string;
|
upToMessageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +42,8 @@
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
forceForegroundText = false,
|
forceForegroundText = false,
|
||||||
useGlobalSelection = false,
|
useGlobalSelection = false,
|
||||||
upToMessageId
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
upToMessageId: _upToMessageId = undefined
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let options = $derived(modelOptions());
|
let options = $derived(modelOptions());
|
||||||
@@ -57,74 +56,11 @@
|
|||||||
// Reactive router models state - needed for proper reactivity of status checks
|
// Reactive router models state - needed for proper reactivity of status checks
|
||||||
let currentRouterModels = $derived(routerModels());
|
let currentRouterModels = $derived(routerModels());
|
||||||
|
|
||||||
let requiredModalities = $derived(
|
|
||||||
upToMessageId ? conversationsStore.getModalitiesUpToMessage(upToMessageId) : usedModalities()
|
|
||||||
);
|
|
||||||
|
|
||||||
function getModelStatus(modelId: string): ServerModelStatus | null {
|
function getModelStatus(modelId: string): ServerModelStatus | null {
|
||||||
const model = currentRouterModels.find((m) => m.id === modelId);
|
const model = currentRouterModels.find((m) => m.id === modelId);
|
||||||
return (model?.status?.value as ServerModelStatus) ?? null;
|
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(
|
let isHighlightedCurrentModelActive = $derived(
|
||||||
!isRouter || !currentModel
|
!isRouter || !currentModel
|
||||||
? false
|
? false
|
||||||
@@ -142,7 +78,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
let searchInputRef = $state<HTMLInputElement | null>(null);
|
|
||||||
let highlightedIndex = $state<number>(-1);
|
let highlightedIndex = $state<number>(-1);
|
||||||
|
|
||||||
let filteredOptions: ModelOption[] = $derived(
|
let filteredOptions: ModelOption[] = $derived(
|
||||||
@@ -157,13 +92,6 @@
|
|||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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
|
// Reset highlighted index when search term changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void searchTerm;
|
void searchTerm;
|
||||||
@@ -179,7 +107,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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.
|
// router mode or not.
|
||||||
function handleOpenChange(open: boolean) {
|
function handleOpenChange(open: boolean) {
|
||||||
if (loading || updating) return;
|
if (loading || updating) return;
|
||||||
@@ -190,11 +118,6 @@
|
|||||||
searchTerm = '';
|
searchTerm = '';
|
||||||
highlightedIndex = -1;
|
highlightedIndex = -1;
|
||||||
|
|
||||||
// Focus search input after popover opens
|
|
||||||
tick().then(() => {
|
|
||||||
requestAnimationFrame(() => searchInputRef?.focus());
|
|
||||||
});
|
|
||||||
|
|
||||||
modelsStore.fetchRouterModels().then(() => {
|
modelsStore.fetchRouterModels().then(() => {
|
||||||
modelsStore.fetchModalitiesForLoadedModels();
|
modelsStore.fetchModalitiesForLoadedModels();
|
||||||
});
|
});
|
||||||
@@ -215,36 +138,32 @@
|
|||||||
function handleSearchKeyDown(event: KeyboardEvent) {
|
function handleSearchKeyDown(event: KeyboardEvent) {
|
||||||
if (event.isComposing) return;
|
if (event.isComposing) return;
|
||||||
|
|
||||||
if (event.key === 'ArrowDown') {
|
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (compatibleIndices.length === 0) return;
|
if (filteredOptions.length === 0) return;
|
||||||
|
|
||||||
const currentPos = compatibleIndices.indexOf(highlightedIndex);
|
if (highlightedIndex === -1 || highlightedIndex === filteredOptions.length - 1) {
|
||||||
if (currentPos === -1 || currentPos === compatibleIndices.length - 1) {
|
highlightedIndex = 0;
|
||||||
highlightedIndex = compatibleIndices[0];
|
|
||||||
} else {
|
} else {
|
||||||
highlightedIndex = compatibleIndices[currentPos + 1];
|
highlightedIndex += 1;
|
||||||
}
|
}
|
||||||
} else if (event.key === 'ArrowUp') {
|
} else if (event.key === KeyboardKey.ARROW_UP) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (compatibleIndices.length === 0) return;
|
if (filteredOptions.length === 0) return;
|
||||||
|
|
||||||
const currentPos = compatibleIndices.indexOf(highlightedIndex);
|
if (highlightedIndex === -1 || highlightedIndex === 0) {
|
||||||
if (currentPos === -1 || currentPos === 0) {
|
highlightedIndex = filteredOptions.length - 1;
|
||||||
highlightedIndex = compatibleIndices[compatibleIndices.length - 1];
|
|
||||||
} else {
|
} else {
|
||||||
highlightedIndex = compatibleIndices[currentPos - 1];
|
highlightedIndex -= 1;
|
||||||
}
|
}
|
||||||
} else if (event.key === 'Enter') {
|
} else if (event.key === KeyboardKey.ENTER) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
|
if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
|
||||||
const option = filteredOptions[highlightedIndex];
|
const option = filteredOptions[highlightedIndex];
|
||||||
if (isModelCompatible(option)) {
|
handleSelect(option.id);
|
||||||
handleSelect(option.id);
|
} else if (filteredOptions.length > 0) {
|
||||||
}
|
// No selection - highlight first option
|
||||||
} else if (compatibleIndices.length > 0) {
|
highlightedIndex = 0;
|
||||||
// No selection - highlight first compatible option
|
|
||||||
highlightedIndex = compatibleIndices[0];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,68 +266,72 @@
|
|||||||
{@const selectedOption = getDisplayOption()}
|
{@const selectedOption = getDisplayOption()}
|
||||||
|
|
||||||
{#if isRouter}
|
{#if isRouter}
|
||||||
<Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
|
<DropdownMenu.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
<Popover.Trigger
|
<DropdownMenu.Trigger
|
||||||
class={cn(
|
|
||||||
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
|
|
||||||
!isCurrentModelInCache()
|
|
||||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
|
||||||
: forceForegroundText
|
|
||||||
? 'text-foreground'
|
|
||||||
: isHighlightedCurrentModelActive
|
|
||||||
? 'text-foreground'
|
|
||||||
: 'text-muted-foreground',
|
|
||||||
isOpen ? 'text-foreground' : ''
|
|
||||||
)}
|
|
||||||
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
|
||||||
disabled={disabled || updating}
|
disabled={disabled || updating}
|
||||||
|
onclick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Package class="h-3.5 w-3.5" />
|
<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" />
|
||||||
|
|
||||||
<span class="truncate font-medium">
|
<TruncatedText
|
||||||
{selectedOption?.model || 'Select model'}
|
text={selectedOption?.model || 'Select model'}
|
||||||
</span>
|
class="min-w-0 font-medium"
|
||||||
|
/>
|
||||||
|
|
||||||
{#if updating}
|
{#if updating}
|
||||||
<Loader2 class="h-3 w-3.5 animate-spin" />
|
<Loader2 class="h-3 w-3.5 animate-spin" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronDown class="h-3 w-3.5" />
|
<ChevronDown class="h-3 w-3.5" />
|
||||||
{/if}
|
{/if}
|
||||||
</Popover.Trigger>
|
</button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
<Popover.Content
|
<DropdownMenu.Content
|
||||||
class="group/popover-content w-96 max-w-[calc(100vw-2rem)] p-0"
|
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={8}
|
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
|
||||||
collisionPadding={16}
|
|
||||||
>
|
>
|
||||||
<div class="flex max-h-[50dvh] flex-col overflow-hidden">
|
<DropdownMenuSearchable
|
||||||
<div
|
bind:searchValue={searchTerm}
|
||||||
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"
|
placeholder="Search models..."
|
||||||
>
|
onSearchKeyDown={handleSearchKeyDown}
|
||||||
<SearchInput
|
emptyMessage="No models found."
|
||||||
id="model-search"
|
isEmpty={filteredOptions.length === 0 && isCurrentModelInCache()}
|
||||||
placeholder="Search models..."
|
>
|
||||||
bind:value={searchTerm}
|
<div class="models-list">
|
||||||
bind:ref={searchInputRef}
|
|
||||||
onClose={() => handleOpenChange(false)}
|
|
||||||
onKeyDown={handleSearchKeyDown}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="models-list order-2 min-h-0 flex-1 overflow-y-auto group-data-[side=top]/popover-content:order-1"
|
|
||||||
>
|
|
||||||
{#if !isCurrentModelInCache() && currentModel}
|
{#if !isCurrentModelInCache() && currentModel}
|
||||||
<!-- Show unavailable model as first option (disabled) -->
|
<!-- Show unavailable model as first option (disabled) -->
|
||||||
<button
|
<button
|
||||||
type="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"
|
role="option"
|
||||||
aria-selected="true"
|
aria-selected="true"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
disabled
|
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>
|
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="my-1 h-px bg-border"></div>
|
<div class="my-1 h-px bg-border"></div>
|
||||||
@@ -421,104 +344,78 @@
|
|||||||
{@const isLoaded = status === ServerModelStatus.LOADED}
|
{@const isLoaded = status === ServerModelStatus.LOADED}
|
||||||
{@const isLoading = status === ServerModelStatus.LOADING}
|
{@const isLoading = status === ServerModelStatus.LOADING}
|
||||||
{@const isSelected = currentModel === option.model || activeId === option.id}
|
{@const isSelected = currentModel === option.model || activeId === option.id}
|
||||||
{@const isCompatible = isModelCompatible(option)}
|
|
||||||
{@const isHighlighted = index === highlightedIndex}
|
{@const isHighlighted = index === highlightedIndex}
|
||||||
{@const missingModalities = getMissingModalities(option)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
'group flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition focus:outline-none',
|
'group flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
|
||||||
isCompatible
|
'cursor-pointer hover:bg-muted focus:bg-muted',
|
||||||
? 'cursor-pointer hover:bg-muted focus:bg-muted'
|
|
||||||
: 'cursor-not-allowed opacity-50',
|
|
||||||
isSelected || isHighlighted
|
isSelected || isHighlighted
|
||||||
? 'bg-accent text-accent-foreground'
|
? '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'
|
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={isSelected || isHighlighted}
|
aria-selected={isSelected || isHighlighted}
|
||||||
aria-disabled={!isCompatible}
|
tabindex="0"
|
||||||
tabindex={isCompatible ? 0 : -1}
|
onclick={() => handleSelect(option.id)}
|
||||||
onclick={() => isCompatible && handleSelect(option.id)}
|
|
||||||
onmouseenter={() => (highlightedIndex = index)}
|
onmouseenter={() => (highlightedIndex = index)}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSelect(option.id);
|
handleSelect(option.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="min-w-0 flex-1 truncate">{option.model}</span>
|
<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>
|
||||||
|
|
||||||
{#if missingModalities}
|
<div class="flex w-6 shrink-0 justify-center">
|
||||||
<span class="flex shrink-0 items-center gap-1 text-muted-foreground/70">
|
{#if isLoading}
|
||||||
{#if missingModalities.vision}
|
<Tooltip.Root>
|
||||||
<Tooltip.Root>
|
<Tooltip.Trigger>
|
||||||
<Tooltip.Trigger>
|
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
<EyeOff class="h-3.5 w-3.5" />
|
</Tooltip.Trigger>
|
||||||
</Tooltip.Trigger>
|
<Tooltip.Content class="z-[9999]">
|
||||||
<Tooltip.Content class="z-[9999]">
|
<p>Loading model...</p>
|
||||||
<p>No vision support</p>
|
</Tooltip.Content>
|
||||||
</Tooltip.Content>
|
</Tooltip.Root>
|
||||||
</Tooltip.Root>
|
{:else if isLoaded}
|
||||||
{/if}
|
<Tooltip.Root>
|
||||||
{#if missingModalities.audio}
|
<Tooltip.Trigger>
|
||||||
<Tooltip.Root>
|
<button
|
||||||
<Tooltip.Trigger>
|
type="button"
|
||||||
<MicOff class="h-3.5 w-3.5" />
|
class="relative flex h-4 w-4 items-center justify-center"
|
||||||
</Tooltip.Trigger>
|
onclick={(e) => {
|
||||||
<Tooltip.Content class="z-[9999]">
|
e.stopPropagation();
|
||||||
<p>No audio support</p>
|
modelsStore.unloadModel(option.model);
|
||||||
</Tooltip.Content>
|
}}
|
||||||
</Tooltip.Root>
|
>
|
||||||
{/if}
|
<span
|
||||||
</span>
|
class="h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
|
||||||
{/if}
|
></span>
|
||||||
|
<Power
|
||||||
{#if isLoading}
|
class="absolute h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
|
||||||
<Tooltip.Root>
|
/>
|
||||||
<Tooltip.Trigger>
|
</button>
|
||||||
<Loader2 class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
</Tooltip.Trigger>
|
||||||
</Tooltip.Trigger>
|
<Tooltip.Content class="z-[9999]">
|
||||||
<Tooltip.Content class="z-[9999]">
|
<p>Unload model</p>
|
||||||
<p>Loading model...</p>
|
</Tooltip.Content>
|
||||||
</Tooltip.Content>
|
</Tooltip.Root>
|
||||||
</Tooltip.Root>
|
{:else}
|
||||||
{:else if isLoaded}
|
<span class="h-2 w-2 rounded-full bg-muted-foreground/50"></span>
|
||||||
<Tooltip.Root>
|
{/if}
|
||||||
<Tooltip.Trigger>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="relative ml-2 flex h-4 w-4 shrink-0 items-center justify-center"
|
|
||||||
onclick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
modelsStore.unloadModel(option.model);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="mr-2 h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
|
|
||||||
></span>
|
|
||||||
<Power
|
|
||||||
class="absolute mr-2 h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Content class="z-[9999]">
|
|
||||||
<p>Unload model</p>
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
{:else}
|
|
||||||
<span class="mx-2 h-2 w-2 rounded-full bg-muted-foreground/50"></span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DropdownMenuSearchable>
|
||||||
</Popover.Content>
|
</DropdownMenu.Content>
|
||||||
</Popover.Root>
|
</DropdownMenu.Root>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
class={cn(
|
class={cn(
|
||||||
@@ -538,9 +435,7 @@
|
|||||||
>
|
>
|
||||||
<Package class="h-3.5 w-3.5" />
|
<Package class="h-3.5 w-3.5" />
|
||||||
|
|
||||||
<span class="truncate font-medium">
|
<TruncatedText text={selectedOption?.model || ''} class="min-w-0 font-medium" />
|
||||||
{selectedOption?.model}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{#if updating}
|
{#if updating}
|
||||||
<Loader2 class="h-3 w-3.5 animate-spin" />
|
<Loader2 class="h-3 w-3.5 animate-spin" />
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
*
|
||||||
|
* 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';
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
import { serverStore, serverLoading } from '$lib/stores/server.svelte';
|
import { serverStore, serverLoading } from '$lib/stores/server.svelte';
|
||||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||||
import { fade, fly, scale } from 'svelte/transition';
|
import { fade, fly, scale } from 'svelte/transition';
|
||||||
import { KeyboardKey } from '$lib/enums/keyboard';
|
import { KeyboardKey } from '$lib/enums';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
export interface BinaryDetectionOptions {
|
import type { BinaryDetectionOptions } from '$lib/types';
|
||||||
prefixLength: number;
|
|
||||||
suspiciousCharThresholdRatio: number;
|
|
||||||
maxAbsoluteNullBytes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_BINARY_DETECTION_OPTIONS: BinaryDetectionOptions = {
|
export const DEFAULT_BINARY_DETECTION_OPTIONS: BinaryDetectionOptions = {
|
||||||
prefixLength: 1024 * 10, // Check the first 10KB of the string
|
prefixLength: 1024 * 10, // Check the first 10KB of the string
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
@@ -1,6 +1 @@
|
|||||||
export const INPUT_CLASSES = `
|
export { INPUT_CLASSES } from './css-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
|
|
||||||
`;
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* 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];
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
export { AttachmentType } from './attachment';
|
export { AttachmentType } from './attachment';
|
||||||
|
|
||||||
export { ChatMessageStatsView } from './chat';
|
export {
|
||||||
|
ChatMessageStatsView,
|
||||||
|
ReasoningFormat,
|
||||||
|
MessageRole,
|
||||||
|
MessageType,
|
||||||
|
ContentPartType,
|
||||||
|
ErrorDialogType
|
||||||
|
} from './chat';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
FileTypeCategory,
|
FileTypeCategory,
|
||||||
@@ -21,3 +28,9 @@ export {
|
|||||||
export { ModelModality } from './model';
|
export { ModelModality } from './model';
|
||||||
|
|
||||||
export { ServerRole, ServerModelStatus } from './server';
|
export { ServerRole, ServerModelStatus } from './server';
|
||||||
|
|
||||||
|
export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings';
|
||||||
|
|
||||||
|
export { KeyboardKey } from './keyboard';
|
||||||
|
|
||||||
|
export { UrlPrefix } from './ui';
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* URL prefixes for protocol detection.
|
||||||
|
*/
|
||||||
|
export enum UrlPrefix {
|
||||||
|
DATA = 'data:',
|
||||||
|
HTTP = 'http://',
|
||||||
|
HTTPS = 'https://',
|
||||||
|
WEBSOCKET = 'ws://',
|
||||||
|
WEBSOCKET_SECURE = 'wss://'
|
||||||
|
}
|
||||||
@@ -1,26 +1,21 @@
|
|||||||
import { modelsStore } from '$lib/stores/models.svelte';
|
import { modelsStore } from '$lib/stores/models.svelte';
|
||||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import type { ModelModalities } from '$lib/types';
|
||||||
|
|
||||||
interface UseModelChangeValidationOptions {
|
interface UseModelChangeValidationOptions {
|
||||||
/**
|
/**
|
||||||
* Function to get required modalities for validation.
|
* Function to get required modalities for validation.
|
||||||
* For ChatForm: () => usedModalities() - all messages
|
|
||||||
* For ChatMessageAssistant: () => getModalitiesUpToMessage(messageId) - messages before
|
|
||||||
*/
|
*/
|
||||||
getRequiredModalities: () => ModelModalities;
|
getRequiredModalities: () => ModelModalities;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional callback to execute after successful validation.
|
* Optional callback to execute after successful validation.
|
||||||
* For ChatForm: undefined - just select model
|
|
||||||
* For ChatMessageAssistant: (modelName) => onRegenerate(modelName)
|
|
||||||
*/
|
*/
|
||||||
onSuccess?: (modelName: string) => void;
|
onSuccess?: (modelName: string) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional callback for rollback on validation failure.
|
* Optional callback for rollback on validation failure.
|
||||||
* For ChatForm: (previousId) => selectModelById(previousId)
|
|
||||||
* For ChatMessageAssistant: undefined - no rollback needed
|
|
||||||
*/
|
*/
|
||||||
onValidationFailure?: (previousModelId: string | null) => Promise<void>;
|
onValidationFailure?: (previousModelId: string | null) => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -33,12 +28,10 @@ export function useModelChangeValidation(options: UseModelChangeValidationOption
|
|||||||
|
|
||||||
async function handleModelChange(modelId: string, modelName: string): Promise<boolean> {
|
async function handleModelChange(modelId: string, modelName: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Store previous selection for potential rollback
|
|
||||||
if (onValidationFailure) {
|
if (onValidationFailure) {
|
||||||
previousSelectedModelId = modelsStore.selectedModelId;
|
previousSelectedModelId = modelsStore.selectedModelId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load model if not already loaded (router mode only)
|
|
||||||
let hasLoadedModel = false;
|
let hasLoadedModel = false;
|
||||||
const isModelLoadedBefore = modelsStore.isModelLoaded(modelName);
|
const isModelLoadedBefore = modelsStore.isModelLoaded(modelName);
|
||||||
|
|
||||||
@@ -52,13 +45,11 @@ export function useModelChangeValidation(options: UseModelChangeValidationOption
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch model props to validate modalities
|
|
||||||
const props = await modelsStore.fetchModelProps(modelName);
|
const props = await modelsStore.fetchModelProps(modelName);
|
||||||
|
|
||||||
if (props?.modalities) {
|
if (props?.modalities) {
|
||||||
const requiredModalities = getRequiredModalities();
|
const requiredModalities = getRequiredModalities();
|
||||||
|
|
||||||
// Check if model supports required modalities
|
|
||||||
const missingModalities: string[] = [];
|
const missingModalities: string[] = [];
|
||||||
if (requiredModalities.vision && !props.modalities.vision) {
|
if (requiredModalities.vision && !props.modalities.vision) {
|
||||||
missingModalities.push('vision');
|
missingModalities.push('vision');
|
||||||
@@ -72,7 +63,6 @@ export function useModelChangeValidation(options: UseModelChangeValidationOption
|
|||||||
`Model "${modelName}" doesn't support required modalities: ${missingModalities.join(', ')}. Please select a different model.`
|
`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) {
|
if (isRouter && hasLoadedModel) {
|
||||||
try {
|
try {
|
||||||
await modelsStore.unloadModel(modelName);
|
await modelsStore.unloadModel(modelName);
|
||||||
@@ -81,7 +71,6 @@ export function useModelChangeValidation(options: UseModelChangeValidationOption
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute rollback callback if provided
|
|
||||||
if (onValidationFailure && previousSelectedModelId) {
|
if (onValidationFailure && previousSelectedModelId) {
|
||||||
await onValidationFailure(previousSelectedModelId);
|
await onValidationFailure(previousSelectedModelId);
|
||||||
}
|
}
|
||||||
@@ -90,10 +79,8 @@ export function useModelChangeValidation(options: UseModelChangeValidationOption
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select the model (validation passed)
|
|
||||||
await modelsStore.selectModelById(modelId);
|
await modelsStore.selectModelById(modelId);
|
||||||
|
|
||||||
// Execute success callback if provided
|
|
||||||
if (onSuccess) {
|
if (onSuccess) {
|
||||||
onSuccess(modelName);
|
onSuccess(modelName);
|
||||||
}
|
}
|
||||||
@@ -103,7 +90,6 @@ export function useModelChangeValidation(options: UseModelChangeValidationOption
|
|||||||
console.error('Failed to change model:', error);
|
console.error('Failed to change model:', error);
|
||||||
toast.error('Failed to validate model capabilities');
|
toast.error('Failed to validate model capabilities');
|
||||||
|
|
||||||
// Execute rollback callback on error if provided
|
|
||||||
if (onValidationFailure && previousSelectedModelId) {
|
if (onValidationFailure && previousSelectedModelId) {
|
||||||
await onValidationFailure(previousSelectedModelId);
|
await onValidationFailure(previousSelectedModelId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,7 @@
|
|||||||
import { activeProcessingState } from '$lib/stores/chat.svelte';
|
import { activeProcessingState } from '$lib/stores/chat.svelte';
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
import { STATS_UNITS } from '$lib/constants/processing-info';
|
import { STATS_UNITS } from '$lib/constants/processing-info';
|
||||||
import type { ApiProcessingState } from '$lib/types';
|
import type { ApiProcessingState, LiveProcessingStats, LiveGenerationStats } from '$lib/types';
|
||||||
|
|
||||||
interface LiveProcessingStats {
|
|
||||||
tokensProcessed: number;
|
|
||||||
totalTokens: number;
|
|
||||||
timeMs: number;
|
|
||||||
tokensPerSecond: number;
|
|
||||||
etaSecs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LiveGenerationStats {
|
|
||||||
tokensGenerated: number;
|
|
||||||
timeMs: number;
|
|
||||||
tokensPerSecond: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseProcessingStateReturn {
|
export interface UseProcessingStateReturn {
|
||||||
readonly processingState: ApiProcessingState | null;
|
readonly processingState: ApiProcessingState | null;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ class LlamacppDatabase extends Dexie {
|
|||||||
|
|
||||||
const db = new LlamacppDatabase();
|
const db = new LlamacppDatabase();
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { MessageRole } from '$lib/enums/chat';
|
import { MessageRole } from '$lib/enums';
|
||||||
|
|
||||||
export class DatabaseService {
|
export class DatabaseService {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,400 +0,0 @@
|
|||||||
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 };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export { ChatService } from './chat';
|
export { ChatService } from './chat';
|
||||||
export { DatabaseService } from './database';
|
export { DatabaseService } from './database.service';
|
||||||
export { ModelsService } from './models';
|
export { ModelsService } from './models.service';
|
||||||
export { PropsService } from './props';
|
export { PropsService } from './props.service';
|
||||||
export { ParameterSyncService } from './parameter-sync';
|
export { ParameterSyncService, SYNCABLE_PARAMETERS } from './parameter-sync.service';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ServerModelStatus } from '$lib/enums';
|
import { ServerModelStatus } from '$lib/enums';
|
||||||
import { apiFetch, apiPost } from '$lib/utils/api-fetch';
|
import { apiFetch, apiPost } from '$lib/utils';
|
||||||
|
|
||||||
export class ModelsService {
|
export class ModelsService {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,6 @@
|
|||||||
import { normalizeFloatingPoint } from '$lib/utils';
|
import { normalizeFloatingPoint } from '$lib/utils';
|
||||||
import { SyncableParameterType, ParameterSource } from '$lib/enums/settings';
|
import type { SyncableParameter, ParameterRecord, ParameterInfo, ParameterValue } from '$lib/types';
|
||||||
|
import { SyncableParameterType, ParameterSource } from '$lib/enums';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mapping of webui setting keys to server parameter keys.
|
* Mapping of webui setting keys to server parameter keys.
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiFetchWithParams } from '$lib/utils/api-fetch';
|
import { apiFetchWithParams } from '$lib/utils';
|
||||||
|
|
||||||
export class PropsService {
|
export class PropsService {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -137,6 +137,7 @@ class ChatStore {
|
|||||||
clearUIState(): void {
|
clearUIState(): void {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.currentResponse = '';
|
this.currentResponse = '';
|
||||||
|
this.isStreamingActive = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { toast } from 'svelte-sonner';
|
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 { config } from '$lib/stores/settings.svelte';
|
||||||
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
|
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
|
||||||
import { AttachmentType } from '$lib/enums';
|
import { AttachmentType } from '$lib/enums';
|
||||||
@@ -241,7 +241,9 @@ class ConversationsStore {
|
|||||||
|
|
||||||
const leafNodeId =
|
const leafNodeId =
|
||||||
this.activeConversation.currNode ||
|
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[];
|
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
|
||||||
|
|
||||||
@@ -341,9 +343,11 @@ class ConversationsStore {
|
|||||||
if (!this.activeConversation) return;
|
if (!this.activeConversation) return;
|
||||||
|
|
||||||
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
|
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(
|
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);
|
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
|
||||||
@@ -355,7 +359,7 @@ class ConversationsStore {
|
|||||||
// Only show title dialog if we're navigating between different first user message siblings
|
// Only show title dialog if we're navigating between different first user message siblings
|
||||||
if (rootMessage && this.activeMessages.length > 0) {
|
if (rootMessage && this.activeMessages.length > 0) {
|
||||||
const newFirstUserMessage = this.activeMessages.find(
|
const newFirstUserMessage = this.activeMessages.find(
|
||||||
(m) => m.role === 'user' && m.parent === rootMessage.id
|
(m: DatabaseMessage) => m.role === 'user' && m.parent === rootMessage.id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -452,7 +456,7 @@ class ConversationsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allData = await Promise.all(
|
const allData = await Promise.all(
|
||||||
allConversations.map(async (conv) => {
|
allConversations.map(async (conv: DatabaseConversation) => {
|
||||||
const messages = await DatabaseService.getConversationMessages(conv.id);
|
const messages = await DatabaseService.getConversationMessages(conv.id);
|
||||||
return { conv, messages };
|
return { conv, messages };
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import { ModelsService } from '$lib/services/models';
|
import { ModelsService } from '$lib/services/models.service';
|
||||||
import { PropsService } from '$lib/services/props';
|
import { PropsService } from '$lib/services/props.service';
|
||||||
import { ServerModelStatus, ModelModality } from '$lib/enums';
|
import { ServerModelStatus, ModelModality } from '$lib/enums';
|
||||||
import { serverStore } from '$lib/stores/server.svelte';
|
import { serverStore } from '$lib/stores/server.svelte';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PropsService } from '$lib/services/props';
|
import { PropsService } from '$lib/services/props.service';
|
||||||
import { ServerRole } from '$lib/enums';
|
import { ServerRole } from '$lib/enums';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
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 { serverStore } from '$lib/stores/server.svelte';
|
||||||
import {
|
import {
|
||||||
configToParameterRecord,
|
configToParameterRecord,
|
||||||
|
|||||||
+53
@@ -1,3 +1,6 @@
|
|||||||
|
import type { ErrorDialogType } from '$lib/enums';
|
||||||
|
import type { DatabaseMessage, DatabaseMessageExtra } from './database';
|
||||||
|
|
||||||
export type ChatMessageType = 'root' | 'text' | 'think' | 'system';
|
export type ChatMessageType = 'root' | 'text' | 'think' | 'system';
|
||||||
export type ChatRole = 'user' | 'assistant' | 'system';
|
export type ChatRole = 'user' | 'assistant' | 'system';
|
||||||
|
|
||||||
@@ -9,6 +12,8 @@ export interface ChatUploadedFile {
|
|||||||
file: File;
|
file: File;
|
||||||
preview?: string;
|
preview?: string;
|
||||||
textContent?: string;
|
textContent?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
loadError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatAttachmentDisplayItem {
|
export interface ChatAttachmentDisplayItem {
|
||||||
@@ -17,6 +22,8 @@ export interface ChatAttachmentDisplayItem {
|
|||||||
size?: number;
|
size?: number;
|
||||||
preview?: string;
|
preview?: string;
|
||||||
isImage: boolean;
|
isImage: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
loadError?: string;
|
||||||
uploadedFile?: ChatUploadedFile;
|
uploadedFile?: ChatUploadedFile;
|
||||||
attachment?: DatabaseMessageExtra;
|
attachment?: DatabaseMessageExtra;
|
||||||
attachmentIndex?: number;
|
attachmentIndex?: number;
|
||||||
@@ -53,3 +60,49 @@ export interface ChatMessageTimings {
|
|||||||
prompt_ms?: number;
|
prompt_ms?: number;
|
||||||
prompt_n?: 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[];
|
||||||
|
}
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
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[];
|
||||||
|
}
|
||||||
@@ -41,7 +41,13 @@ export type {
|
|||||||
ChatAttachmentPreviewItem,
|
ChatAttachmentPreviewItem,
|
||||||
ChatMessageSiblingInfo,
|
ChatMessageSiblingInfo,
|
||||||
ChatMessagePromptProgress,
|
ChatMessagePromptProgress,
|
||||||
ChatMessageTimings
|
ChatMessageTimings,
|
||||||
|
ChatStreamCallbacks,
|
||||||
|
ErrorDialogState,
|
||||||
|
LiveProcessingStats,
|
||||||
|
LiveGenerationStats,
|
||||||
|
AttachmentDisplayItemsOptions,
|
||||||
|
FileProcessingResult
|
||||||
} from './chat';
|
} from './chat';
|
||||||
|
|
||||||
// Database types
|
// Database types
|
||||||
@@ -59,12 +65,24 @@ export type {
|
|||||||
} from './database';
|
} from './database';
|
||||||
|
|
||||||
// Model types
|
// Model types
|
||||||
export type { ModelModalities, ModelOption } from './models';
|
export type { ModelModalities, ModelOption, ModalityCapabilities } from './models';
|
||||||
|
|
||||||
// Settings types
|
// Settings types
|
||||||
export type {
|
export type {
|
||||||
SettingsConfigValue,
|
SettingsConfigValue,
|
||||||
SettingsFieldConfig,
|
SettingsFieldConfig,
|
||||||
SettingsChatServiceOptions,
|
SettingsChatServiceOptions,
|
||||||
SettingsConfigType
|
SettingsConfigType,
|
||||||
|
ParameterValue,
|
||||||
|
ParameterRecord,
|
||||||
|
ParameterInfo,
|
||||||
|
SyncableParameter
|
||||||
} from './settings';
|
} from './settings';
|
||||||
|
|
||||||
|
// Common types
|
||||||
|
export type {
|
||||||
|
KeyValuePair,
|
||||||
|
BinaryDetectionOptions,
|
||||||
|
ClipboardTextAttachment,
|
||||||
|
ParsedClipboardContent
|
||||||
|
} from './common';
|
||||||
|
|||||||
+22
-2
@@ -1,12 +1,14 @@
|
|||||||
import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
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 type SettingsConfigValue = string | number | boolean;
|
||||||
|
|
||||||
export interface SettingsFieldConfig {
|
export interface SettingsFieldConfig {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: 'input' | 'textarea' | 'checkbox' | 'select';
|
type: SettingsFieldType;
|
||||||
isExperimental?: boolean;
|
isExperimental?: boolean;
|
||||||
help?: string;
|
help?: string;
|
||||||
options?: Array<{ value: string; label: string; icon?: typeof import('@lucide/svelte').Icon }>;
|
options?: Array<{ value: string; label: string; icon?: typeof import('@lucide/svelte').Icon }>;
|
||||||
@@ -51,6 +53,7 @@ export interface SettingsChatServiceOptions {
|
|||||||
onChunk?: (chunk: string) => void;
|
onChunk?: (chunk: string) => void;
|
||||||
onReasoningChunk?: (chunk: string) => void;
|
onReasoningChunk?: (chunk: string) => void;
|
||||||
onToolCallChunk?: (chunk: string) => void;
|
onToolCallChunk?: (chunk: string) => void;
|
||||||
|
onAttachments?: (extras: DatabaseMessageExtra[]) => void;
|
||||||
onModel?: (model: string) => void;
|
onModel?: (model: string) => void;
|
||||||
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
|
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
|
||||||
onComplete?: (
|
onComplete?: (
|
||||||
@@ -65,3 +68,20 @@ export interface SettingsChatServiceOptions {
|
|||||||
export type SettingsConfigType = typeof SETTING_CONFIG_DEFAULT & {
|
export type SettingsConfigType = typeof SETTING_CONFIG_DEFAULT & {
|
||||||
[key: string]: SettingsConfigValue;
|
[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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { getJsonHeaders, getAuthHeaders } from './api-headers';
|
import { getJsonHeaders, getAuthHeaders } from './api-headers';
|
||||||
|
import { UrlPrefix } from '$lib/enums';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Fetch Utilities
|
* API Fetch Utilities
|
||||||
@@ -48,7 +49,8 @@ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}):
|
|||||||
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
|
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
|
||||||
const headers = { ...baseHeaders, ...customHeaders };
|
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, {
|
const response = await fetch(url, {
|
||||||
...fetchOptions,
|
...fetchOptions,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
* └── message 5 (assistant)
|
* └── 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.
|
* Filters messages to get the conversation path from root to a specific leaf node.
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
import { DEFAULT_CACHE_TTL_MS, DEFAULT_CACHE_MAX_ENTRIES } from '$lib/constants/cache';
|
||||||
const DEFAULT_CACHE_MAX_ENTRIES = 100;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TTL Cache - Time-To-Live cache implementation for memory optimization
|
* TTL Cache - Time-To-Live cache implementation for memory optimization
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { getFileTypeCategory } from '$lib/utils';
|
import { getFileTypeCategory } from '$lib/utils';
|
||||||
import { FileTypeCategory } from '$lib/enums';
|
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
|
* Check if a file type is supported by the given modalities
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { DEFAULT_BINARY_DETECTION_OPTIONS } from '$lib/constants/binary-detection';
|
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';
|
import { FileExtensionText } from '$lib/enums';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+7
-5
@@ -1,14 +1,14 @@
|
|||||||
<script module lang="ts">
|
<script module lang="ts">
|
||||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
|
import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
|
||||||
import { expect } from 'storybook/test';
|
import { expect } from 'storybook/test';
|
||||||
import jpgAsset from './fixtures/assets/1.jpg?url';
|
import jpgAsset from './fixtures/assets/1.jpg?url';
|
||||||
import svgAsset from './fixtures/assets/hf-logo.svg?url';
|
import svgAsset from './fixtures/assets/hf-logo.svg?url';
|
||||||
import pdfAsset from './fixtures/assets/example.pdf?raw';
|
import pdfAsset from './fixtures/assets/example.pdf?raw';
|
||||||
|
|
||||||
const { Story } = defineMeta({
|
const { Story } = defineMeta({
|
||||||
title: 'Components/ChatScreen/ChatForm',
|
title: 'Components/ChatScreen/ChatScreenForm',
|
||||||
component: ChatForm,
|
component: ChatScreenForm,
|
||||||
parameters: {
|
parameters: {
|
||||||
layout: 'centered'
|
layout: 'centered'
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,8 @@
|
|||||||
<Story
|
<Story
|
||||||
name="Default"
|
name="Default"
|
||||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||||
play={async ({ canvas, userEvent }) => {
|
play={async (context) => {
|
||||||
|
const { canvas, userEvent } = context;
|
||||||
const textarea = await canvas.findByRole('textbox');
|
const textarea = await canvas.findByRole('textbox');
|
||||||
const submitButton = await canvas.findByRole('button', { name: 'Send' });
|
const submitButton = await canvas.findByRole('button', { name: 'Send' });
|
||||||
|
|
||||||
@@ -74,7 +75,8 @@
|
|||||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||||
uploadedFiles: fileAttachments
|
uploadedFiles: fileAttachments
|
||||||
}}
|
}}
|
||||||
play={async ({ canvas }) => {
|
play={async (context) => {
|
||||||
|
const { canvas } = context;
|
||||||
const jpgAttachment = canvas.getByAltText('1.jpg');
|
const jpgAttachment = canvas.getByAltText('1.jpg');
|
||||||
const svgAttachment = canvas.getByAltText('hf-logo.svg');
|
const svgAttachment = canvas.getByAltText('hf-logo.svg');
|
||||||
const pdfFileExtension = canvas.getByText('PDF');
|
const pdfFileExtension = canvas.getByText('PDF');
|
||||||
@@ -68,18 +68,22 @@ You can also test inline links like https://example.com or https://docs.python.o
|
|||||||
All links should have \`target="_blank"\` and \`rel="noopener noreferrer"\` attributes for security.`,
|
All links should have \`target="_blank"\` and \`rel="noopener noreferrer"\` attributes for security.`,
|
||||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]'
|
class: 'max-w-[56rem] w-[calc(100vw-2rem)]'
|
||||||
}}
|
}}
|
||||||
play={async ({ canvasElement }) => {
|
play={async (context) => {
|
||||||
|
const { canvasElement } = context;
|
||||||
// Wait for component to render
|
// Wait for component to render
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
// Find all links in the rendered content
|
// 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
|
// Test that we have the expected number of links
|
||||||
expect(links.length).toBeGreaterThan(0);
|
expect(links.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Test each link for proper attributes
|
// Test each link for proper attributes
|
||||||
links.forEach((link) => {
|
links.forEach((link: HTMLAnchorElement) => {
|
||||||
const href = link.getAttribute('href');
|
const href = link.getAttribute('href');
|
||||||
|
|
||||||
// Test that external links have proper security attributes
|
// Test that external links have proper security attributes
|
||||||
@@ -90,37 +94,35 @@ All links should have \`target="_blank"\` and \`rel="noopener noreferrer"\` attr
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Test specific links exist
|
// Test specific links exist
|
||||||
const hugginFaceLink = Array.from(links).find(
|
const hugginFaceLink = linkList.find(
|
||||||
(link) => link.getAttribute('href') === 'https://huggingface.co'
|
(link) => link.getAttribute('href') === 'https://huggingface.co'
|
||||||
);
|
);
|
||||||
expect(hugginFaceLink).toBeTruthy();
|
expect(hugginFaceLink).toBeTruthy();
|
||||||
expect(hugginFaceLink?.textContent).toBe('Hugging Face Homepage');
|
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'
|
(link) => link.getAttribute('href') === 'https://github.com/ggml-org/llama.cpp'
|
||||||
);
|
);
|
||||||
expect(githubLink).toBeTruthy();
|
expect(githubLink).toBeTruthy();
|
||||||
expect(githubLink?.textContent).toBe('GitHub Repository');
|
expect(githubLink?.textContent).toBe('GitHub Repository');
|
||||||
|
|
||||||
const openaiLink = Array.from(links).find(
|
const openaiLink = linkList.find((link) => link.getAttribute('href') === 'https://openai.com');
|
||||||
(link) => link.getAttribute('href') === 'https://openai.com'
|
|
||||||
);
|
|
||||||
expect(openaiLink).toBeTruthy();
|
expect(openaiLink).toBeTruthy();
|
||||||
expect(openaiLink?.textContent).toBe('OpenAI Website');
|
expect(openaiLink?.textContent).toBe('OpenAI Website');
|
||||||
|
|
||||||
const googleLink = Array.from(links).find(
|
const googleLink = linkList.find(
|
||||||
(link) => link.getAttribute('href') === 'https://www.google.com'
|
(link) => link.getAttribute('href') === 'https://www.google.com'
|
||||||
);
|
);
|
||||||
expect(googleLink).toBeTruthy();
|
expect(googleLink).toBeTruthy();
|
||||||
expect(googleLink?.textContent).toBe('Google Search');
|
expect(googleLink?.textContent).toBe('Google Search');
|
||||||
|
|
||||||
// Test inline links (auto-linked URLs)
|
// Test inline links (auto-linked URLs)
|
||||||
const exampleLink = Array.from(links).find(
|
const exampleLink = linkList.find(
|
||||||
(link) => link.getAttribute('href') === 'https://example.com'
|
(link) => link.getAttribute('href') === 'https://example.com'
|
||||||
);
|
);
|
||||||
expect(exampleLink).toBeTruthy();
|
expect(exampleLink).toBeTruthy();
|
||||||
|
|
||||||
const pythonDocsLink = Array.from(links).find(
|
const pythonDocsLink = linkList.find(
|
||||||
(link) => link.getAttribute('href') === 'https://docs.python.org'
|
(link) => link.getAttribute('href') === 'https://docs.python.org'
|
||||||
);
|
);
|
||||||
expect(pythonDocsLink).toBeTruthy();
|
expect(pythonDocsLink).toBeTruthy();
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import tailwindcss from '@tailwindcss/vite';
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import * as fflate from 'fflate';
|
import * as fflate from 'fflate';
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
import { resolve } from 'path';
|
import { dirname, resolve } from 'path';
|
||||||
import { defineConfig } from 'vite';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
import { defineConfig, searchForWorkspaceRoot } from 'vite';
|
||||||
import devtoolsJson from 'vite-plugin-devtools-json';
|
import devtoolsJson from 'vite-plugin-devtools-json';
|
||||||
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const GUIDE_FOR_FRONTEND = `
|
const GUIDE_FOR_FRONTEND = `
|
||||||
<!--
|
<!--
|
||||||
This is a single file build of the frontend.
|
This is a single file build of the frontend.
|
||||||
@@ -156,11 +160,15 @@ export default defineConfig({
|
|||||||
proxy: {
|
proxy: {
|
||||||
'/v1': 'http://localhost:8080',
|
'/v1': 'http://localhost:8080',
|
||||||
'/props': 'http://localhost:8080',
|
'/props': 'http://localhost:8080',
|
||||||
'/models': 'http://localhost:8080'
|
'/models': 'http://localhost:8080',
|
||||||
|
'/cors-proxy': 'http://localhost:8080'
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||||
'Cross-Origin-Opener-Policy': 'same-origin'
|
'Cross-Origin-Opener-Policy': 'same-origin'
|
||||||
|
},
|
||||||
|
fs: {
|
||||||
|
allow: [searchForWorkspaceRoot(process.cwd()), resolve(__dirname, 'tests')]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user