mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-07 16:37:57 +02:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8fe4840e9 | ||
|
|
c81f79e714 | ||
|
|
dd02ae6a6b | ||
|
|
67adbb18ab | ||
|
|
0f901faeda | ||
|
|
af2b52a145 | ||
|
|
7d0640a44d | ||
|
|
ec2a303f3c | ||
|
|
d79ec3643e | ||
|
|
4c2da7e7c7 | ||
|
|
55df66839f | ||
|
|
632209f16a | ||
|
|
d3df967e22 | ||
|
|
a5906b7200 | ||
|
|
c5ee8d0659 | ||
|
|
46726f077b | ||
|
|
9a4662afb1 | ||
|
|
623999274d | ||
|
|
1778c97cff | ||
|
|
4efe1b9d2a | ||
|
|
1859ad41c0 | ||
|
|
de40f3e81b |
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { FileDown } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-1000000 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-border bg-background p-12 shadow-lg"
|
||||
>
|
||||
<FileDown class="mb-4 h-12 w-12 text-muted-foreground" />
|
||||
|
||||
<p class="text-lg font-medium text-foreground">Import conversations or settings</p>
|
||||
|
||||
<p class="text-sm text-muted-foreground">Drop your files here to import</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Eye, Mic, Video } from '@lucide/svelte';
|
||||
import { Image, Mic, Video } from '@lucide/svelte';
|
||||
import { ModelModality } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
@@ -19,7 +19,7 @@
|
||||
]}
|
||||
>
|
||||
{#if modality === ModelModality.VISION}
|
||||
<Eye class="h-3 w-3" />
|
||||
<Image class="h-3 w-3" />
|
||||
|
||||
Vision (Image)
|
||||
{:else if modality === ModelModality.VIDEO}
|
||||
|
||||
+2
-7
@@ -2,7 +2,6 @@
|
||||
import { File, FolderOpen, MessageSquare, Plus, Zap } from '@lucide/svelte';
|
||||
import {
|
||||
ChatFormActionAddMcpServersSubmenu,
|
||||
ChatFormActionAddReasoningSubmenu,
|
||||
ChatFormActionAddToolsSubmenu
|
||||
} from '$lib/components/app';
|
||||
import { buttonVariants } from '$lib/components/ui/button';
|
||||
@@ -93,10 +92,6 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChatFormActionAddReasoningSubmenu />
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<File class={ICON_CLASS_DEFAULT} />
|
||||
@@ -156,11 +151,11 @@
|
||||
|
||||
<ChatFormActionAddToolsSubmenu />
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
|
||||
|
||||
{#if chatFormActions.hasMcpPromptsSupport}
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={chatFormActions.onMcpPromptClick}
|
||||
|
||||
+59
-61
@@ -8,69 +8,67 @@
|
||||
const reasoning = useReasoningMenu();
|
||||
</script>
|
||||
|
||||
{#if reasoning.modelSupportsThinking}
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
{#if reasoning.thinkingEnabled}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||
{:else if reasoning.isOff}
|
||||
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||
{:else if reasoning.isOff}
|
||||
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
<span
|
||||
class="text-sm inline-flex gap-2 {!reasoning.thinkingEnabled
|
||||
? 'text-muted-foreground'
|
||||
: ''}"
|
||||
>
|
||||
Reasoning
|
||||
|
||||
<span class="capitalize text-muted-foreground">
|
||||
{reasoning.currentEffort}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent
|
||||
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
|
||||
<span
|
||||
class="text-sm inline-flex gap-2 {!reasoning.isReasoningActive
|
||||
? 'text-muted-foreground'
|
||||
: ''}"
|
||||
>
|
||||
{#each reasoning.levels as level (level.value)}
|
||||
{@const tokenLabel = reasoning.tokenLabel(level)}
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
|
||||
level
|
||||
)
|
||||
? 'bg-accent'
|
||||
: ''}"
|
||||
onclick={() => reasoning.select(level)}
|
||||
>
|
||||
{#if reasoning.isSelected(level)}
|
||||
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
|
||||
{:else}
|
||||
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
|
||||
{/if}
|
||||
Reasoning
|
||||
|
||||
<span class="flex-1">{level.label}</span>
|
||||
<span class="capitalize text-muted-foreground">
|
||||
{reasoning.currentEffort}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
{#if tokenLabel}
|
||||
<span class="text-[11px] text-muted-foreground opacity-60">
|
||||
{tokenLabel}
|
||||
</span>
|
||||
{/if}
|
||||
<DropdownMenu.SubContent
|
||||
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
|
||||
>
|
||||
{#each reasoning.levels as level (level.value)}
|
||||
{@const tokenLabel = reasoning.tokenLabel(level)}
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
|
||||
level
|
||||
)
|
||||
? 'bg-accent'
|
||||
: ''}"
|
||||
onclick={() => reasoning.select(level)}
|
||||
>
|
||||
{#if reasoning.isSelected(level)}
|
||||
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
|
||||
{:else}
|
||||
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
{#if level.hasInfo}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="left">
|
||||
<p>Maximum reasoning effort with extended context usage</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
{/if}
|
||||
<span class="flex-1">{level.label}</span>
|
||||
|
||||
{#if tokenLabel}
|
||||
<span class="text-[11px] text-muted-foreground opacity-60">
|
||||
{tokenLabel}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if level.hasInfo}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="left">
|
||||
<p>Maximum reasoning effort with extended context usage</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
+3
-1
@@ -100,7 +100,7 @@
|
||||
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
{/if}
|
||||
|
||||
{#if reasoning.thinkingEnabled}
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||
{:else if reasoning.isOff}
|
||||
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
@@ -194,6 +194,8 @@
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
|
||||
<div class="h-px bg-border"></div>
|
||||
|
||||
<Collapsible.Root open={mcpExpanded} onOpenChange={(open) => (mcpExpanded = open)}>
|
||||
<Collapsible.Trigger class={sheetItemClass}>
|
||||
{#if mcpExpanded}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { MessageSquarePlus } from '@lucide/svelte';
|
||||
import ChatMessages from '$lib/components/app/chat/ChatMessages/ChatMessages.svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
conversationName?: string;
|
||||
messages?: DatabaseMessage[];
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
conversationName = '',
|
||||
messages = [],
|
||||
onCancel,
|
||||
onConfirm,
|
||||
open = $bindable()
|
||||
}: Props = $props();
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (!newOpen) {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
|
||||
<AlertDialog.Content class="sm:max-w-3xl">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title class="flex items-center gap-2">
|
||||
<MessageSquarePlus class="h-5 w-5" />
|
||||
|
||||
Import conversation?
|
||||
</AlertDialog.Title>
|
||||
|
||||
<AlertDialog.Description>
|
||||
Preview of
|
||||
<span class="font-medium">"{conversationName || 'Untitled conversation'}"</span>. Confirm to
|
||||
import it into your library.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto rounded-md border">
|
||||
<ChatMessages {messages} />
|
||||
</div>
|
||||
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel onclick={onCancel}>Cancel</AlertDialog.Cancel>
|
||||
|
||||
<AlertDialog.Action onclick={onConfirm}>Import</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { MessageSquarePlus } from '@lucide/svelte';
|
||||
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
||||
import { UI_DATA_ATTRS } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
conversations: DatabaseConversation[];
|
||||
messageCountMap?: Map<string, number>;
|
||||
onOpen: (conversation: DatabaseConversation) => void;
|
||||
onClose: () => void;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
conversations,
|
||||
messageCountMap = new Map(),
|
||||
onClose,
|
||||
onOpen,
|
||||
open = $bindable()
|
||||
}: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (!newOpen) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
let filteredConversations = $derived(
|
||||
conversations.filter((conv) => {
|
||||
const name = conv.name || 'Untitled conversation';
|
||||
|
||||
return name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="z-1000000" />
|
||||
|
||||
<Dialog.Content class="z-1000001 max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<MessageSquarePlus class="h-5 w-5" />
|
||||
|
||||
Imported Conversations
|
||||
</Dialog.Title>
|
||||
|
||||
<Dialog.Description>
|
||||
{conversations.length} conversation{conversations.length === 1 ? '' : 's'} imported. Select
|
||||
one to open it.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<SearchInput bind:value={searchQuery} placeholder="Search conversations..." />
|
||||
|
||||
<div class="overflow-hidden rounded-md border">
|
||||
<ScrollArea class="h-100">
|
||||
<table class="w-full">
|
||||
<thead class="sticky top-0 z-10 bg-muted">
|
||||
<tr class="border-b">
|
||||
<th class="p-3 text-left text-sm font-medium">Conversation Name</th>
|
||||
|
||||
<th class="w-32 p-3 text-left text-sm font-medium">Messages</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{#if filteredConversations.length === 0}
|
||||
<tr>
|
||||
<td colspan="2" class="p-8 text-center text-sm text-muted-foreground">
|
||||
No conversations found matching "{searchQuery}"
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each filteredConversations as conv (conv.id)}
|
||||
<tr
|
||||
class="cursor-pointer border-b transition-colors hover:bg-muted/50"
|
||||
{...{ [UI_DATA_ATTRS.CONVERSATION_ROW]: conv.id }}
|
||||
onclick={() => onOpen(conv)}
|
||||
>
|
||||
<td class="p-3 text-sm">
|
||||
<div class="max-w-68 truncate" title={conv.name || 'Untitled conversation'}>
|
||||
{conv.name || 'Untitled conversation'}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="p-3 text-sm text-muted-foreground">
|
||||
{messageCountMap.get(conv.id) ?? 0}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight, Settings as SettingsIcon } from '@lucide/svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
|
||||
export interface SettingsDiffEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
from: SettingsConfigValue;
|
||||
to: SettingsConfigValue;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
diff?: SettingsDiffEntry[];
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { diff = [], onCancel, onConfirm, open = $bindable() }: Props = $props();
|
||||
|
||||
function formatValue(value: SettingsConfigValue): string {
|
||||
if (value === undefined) return '(unset)';
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value || '(empty)';
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (!newOpen) {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
|
||||
<AlertDialog.Content class="sm:max-w-2xl">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title class="flex items-center gap-2">
|
||||
<SettingsIcon class="h-5 w-5" />
|
||||
|
||||
Import settings?
|
||||
</AlertDialog.Title>
|
||||
|
||||
<AlertDialog.Description>
|
||||
Review the settings that would change before importing.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto rounded-md border">
|
||||
{#if diff.length === 0}
|
||||
<p class="p-4 text-sm text-muted-foreground">No settings would change.</p>
|
||||
{:else}
|
||||
<div class="divide-y">
|
||||
{#each diff as entry (entry.key)}
|
||||
<div class="flex items-center gap-3 p-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">{entry.label}</p>
|
||||
|
||||
<p class="truncate text-xs text-muted-foreground">{entry.key}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2 text-sm">
|
||||
<span class="line-through text-muted-foreground">{formatValue(entry.from)}</span>
|
||||
|
||||
<ArrowRight class="h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<span class="font-medium">{formatValue(entry.to)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel onclick={onCancel}>Cancel</AlertDialog.Cancel>
|
||||
|
||||
<AlertDialog.Action onclick={onConfirm}>Import</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -375,6 +375,65 @@ export { default as DialogModelNotAvailable } from './DialogModelNotAvailable.sv
|
||||
*/
|
||||
export { default as DialogConversationSelection } from './DialogConversationSelection.svelte';
|
||||
|
||||
/**
|
||||
* **DialogSettingsImportPreview** - Review the settings diff before importing
|
||||
*
|
||||
* Alert dialog shown when a settings file is imported via drag-and-drop.
|
||||
* Lists each setting that would change (old value -> new value) so the user
|
||||
* can review before confirming the import.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogSettingsImportPreview
|
||||
* bind:open={showSettingsPreview}
|
||||
* diff={settingsDiff}
|
||||
* onConfirm={handleConfirm}
|
||||
* onCancel={() => (showSettingsPreview = false)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogSettingsImportPreview } from './DialogSettingsImportPreview.svelte';
|
||||
|
||||
/**
|
||||
* **DialogImportConversationPreview** - Preview a single conversation before importing
|
||||
*
|
||||
* Alert dialog shown when a single conversation is imported via drag-and-drop.
|
||||
* Renders the conversation's messages (via ChatMessages) so the user can review
|
||||
* it before confirming the import.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogImportConversationPreview
|
||||
* bind:open={showPreview}
|
||||
* conversationName={conv?.name}
|
||||
* messages={conv?.messages}
|
||||
* onConfirm={handleConfirm}
|
||||
* onCancel={() => (showPreview = false)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogImportConversationPreview } from './DialogImportConversationPreview.svelte';
|
||||
|
||||
/**
|
||||
* **DialogImportConversationsResult** - Pick one imported conversation to open
|
||||
*
|
||||
* Dialog shown after multiple conversations are imported via drag-and-drop.
|
||||
* Lists the imported conversations in a table (with search) and lets the user
|
||||
* click one to open it.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogImportConversationsResult
|
||||
* bind:open={showOpenBulk}
|
||||
* conversations={imported}
|
||||
* messageCountMap={countMap}
|
||||
* onOpen={handleOpen}
|
||||
* onClose={() => (showOpenBulk = false)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogImportConversationsResult } from './DialogImportConversationsResult.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* MODEL INFORMATION DIALOGS
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './actions';
|
||||
export * from './badges';
|
||||
export { default as DropImportOverlay } from './DropImportOverlay.svelte';
|
||||
export * from './chat';
|
||||
export * from './content';
|
||||
export * from './dialogs';
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Image, Lightbulb, Mic, Video } from '@lucide/svelte';
|
||||
import { TruncatedText } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { settingsStore } from '$lib/stores';
|
||||
import type { ModelModalities } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
modelId: string;
|
||||
hideOrgName?: boolean;
|
||||
showRaw?: boolean;
|
||||
showRawTooltip?: boolean;
|
||||
hideQuantization?: boolean;
|
||||
hideTags?: boolean;
|
||||
aliases?: string[];
|
||||
tags?: string[];
|
||||
modalities?: ModelModalities;
|
||||
supportsThinking?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -20,8 +26,11 @@
|
||||
hideOrgName = false,
|
||||
hideQuantization,
|
||||
hideTags,
|
||||
modalities,
|
||||
modelId,
|
||||
showRaw = undefined,
|
||||
showRawTooltip = false,
|
||||
supportsThinking = false,
|
||||
tags,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
@@ -42,6 +51,9 @@
|
||||
|
||||
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
|
||||
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
|
||||
let hasModalityIcons = $derived(
|
||||
supportsThinking || modalities?.vision || modalities?.video || modalities?.audio
|
||||
);
|
||||
|
||||
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
|
||||
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
|
||||
@@ -50,37 +62,103 @@
|
||||
{#if resolvedShowRaw}
|
||||
<TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} {...rest} />
|
||||
{:else}
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-1 {className}" {...rest}>
|
||||
{#snippet nameAndBadges()}
|
||||
<span class="min-w-0 truncate font-medium">
|
||||
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
|
||||
</span>
|
||||
|
||||
{#if parsed.params}
|
||||
<span class={badgeClass}>
|
||||
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if parsed.quantization && !resolvedHideQuantization}
|
||||
<span class={badgeClass}>
|
||||
{parsed.quantization}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if primaryAlias}
|
||||
{#if primaryAlias !== parsed.modelName}
|
||||
<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{#if parsed.params}
|
||||
<span class={badgeClass}>
|
||||
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
|
||||
</span>
|
||||
{/if}
|
||||
{:else if uniqueAliases.length > 1}
|
||||
{#each uniqueAliases as alias (alias)}
|
||||
<span class={badgeClass}>{alias}</span>
|
||||
{/each}
|
||||
|
||||
{#if parsed.quantization && !resolvedHideQuantization}
|
||||
<span class={badgeClass}>
|
||||
{parsed.quantization}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if primaryAlias}
|
||||
{#if primaryAlias !== parsed.modelName}
|
||||
<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
|
||||
{/if}
|
||||
{:else if uniqueAliases.length > 1}
|
||||
{#each uniqueAliases as alias (alias)}
|
||||
<span class={badgeClass}>{alias}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if uniqueTags.length > 0 && !resolvedHideTags}
|
||||
{#each uniqueTags as tag (tag)}
|
||||
<span class={tagBadgeClass}>{tag}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
<span class="flex min-w-0 items-center gap-1.5 {className}" {...rest}>
|
||||
{#if showRawTooltip}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
|
||||
{@render nameAndBadges()}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{modelId}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
{@render nameAndBadges()}
|
||||
{/if}
|
||||
|
||||
{#if uniqueTags.length > 0 && !resolvedHideTags}
|
||||
{#each uniqueTags as tag (tag)}
|
||||
<span class={tagBadgeClass}>{tag}</span>
|
||||
{/each}
|
||||
{#if hasModalityIcons}
|
||||
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
|
||||
{#if supportsThinking}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Lightbulb class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Reasoning</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if modalities?.vision}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Image class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Vision</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if modalities?.video}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Video class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Video</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if modalities?.audio}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Mic class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Audio</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
||||
import type { ModelItem } from './utils';
|
||||
import { ChevronDown, Loader2 } from '@lucide/svelte';
|
||||
import { ChevronDown, Lightbulb, Loader2 } from '@lucide/svelte';
|
||||
import {
|
||||
ChatFormActionAddReasoningSubmenu,
|
||||
DialogModelInformation,
|
||||
DropdownMenuSearchable,
|
||||
ModelId,
|
||||
ModelsSelectorList,
|
||||
ModelsSelectorOption
|
||||
ModelsSelectorOption,
|
||||
ModelsSelectorSettingsSubmenu
|
||||
} from '$lib/components/app';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { MODEL_SELECTOR_ICON } from '$lib/constants';
|
||||
import {
|
||||
MODEL_SELECTOR_ICON,
|
||||
SETTINGS_CHAT_SECTIONS,
|
||||
SETTINGS_KEYS,
|
||||
SETTINGS_SECTION_SLUGS
|
||||
} from '$lib/constants';
|
||||
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
|
||||
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
|
||||
import { modelsStore, settingsStore } from '$lib/stores';
|
||||
import { modelLoadFraction } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
@@ -37,6 +45,9 @@
|
||||
|
||||
let isOpen = $state(false);
|
||||
let highlightedId = $state<string | null>(null);
|
||||
// The model submenu opens together with the menu so the list and its search
|
||||
// box are immediately available, as before the submenu was introduced
|
||||
let modelSubOpen = $state(false);
|
||||
|
||||
const ms = useModelsSelector({
|
||||
currentModel: () => currentModel,
|
||||
@@ -44,24 +55,52 @@
|
||||
onOpenChange: (open) => {
|
||||
isOpen = open;
|
||||
highlightedId = null;
|
||||
|
||||
if (open) {
|
||||
// Defer submenu open so the Sub component is mounted first;
|
||||
// setting bind:open synchronously can be lost if the Sub hasn't
|
||||
// rendered yet.
|
||||
queueMicrotask(() => {
|
||||
if (isOpen) modelSubOpen = true;
|
||||
});
|
||||
} else {
|
||||
modelSubOpen = false;
|
||||
}
|
||||
},
|
||||
useGlobalSelection: () => useGlobalSelection
|
||||
});
|
||||
|
||||
const reasoning = useReasoningMenu();
|
||||
|
||||
const samplingSection = $derived(
|
||||
SETTINGS_CHAT_SECTIONS.find((s) => s.slug === SETTINGS_SECTION_SLUGS.SAMPLING)
|
||||
);
|
||||
const penaltiesSection = $derived(
|
||||
SETTINGS_CHAT_SECTIONS.find((s) => s.slug === SETTINGS_SECTION_SLUGS.PENALTIES)
|
||||
);
|
||||
|
||||
// Sampling/penalties need a loaded model to show server defaults, so
|
||||
// disable the submenus until one is loaded.
|
||||
const hasModelLoaded = $derived(modelsStore.loadedModelIds.length > 0);
|
||||
|
||||
const showOrgNameInTrigger = $derived(
|
||||
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
void ms.searchTerm;
|
||||
highlightedId = null;
|
||||
});
|
||||
|
||||
// Focus the dropdown's search box without scrolling the page. bits-ui
|
||||
// Focus the model submenu's search box without scrolling the page. bits-ui
|
||||
// auto-focuses the opened content by default, which can yank the page
|
||||
// scroll; we prevent that on the Content and refocus the search here.
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!isOpen || !modelSubOpen) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const search = document.querySelector<HTMLElement>(
|
||||
'[data-slot="dropdown-menu-content"] input'
|
||||
'[data-slot="dropdown-menu-sub-content"] input'
|
||||
);
|
||||
|
||||
search?.focus({ preventScroll: true });
|
||||
@@ -188,7 +227,7 @@
|
||||
<DropdownMenu.Trigger
|
||||
{...props}
|
||||
class={[
|
||||
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
@@ -203,16 +242,22 @@
|
||||
>
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
modelId={selectedOption.model}
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideOrgName={false}
|
||||
hideQuantization
|
||||
/>
|
||||
{:else}
|
||||
<span class="min-w-0 font-medium">Select model</span>
|
||||
{/if}
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
modelId={selectedOption.model}
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideQuantization
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
/>
|
||||
{:else}
|
||||
<span class="min-w-0 font-medium">Select model</span>
|
||||
{/if}
|
||||
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if ms.updating || ms.isLoadingModel}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
@@ -236,73 +281,102 @@
|
||||
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
|
||||
class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuSearchable
|
||||
searchValue={ms.searchTerm}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
placeholder="Search models..."
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
emptyMessage="No models found."
|
||||
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
|
||||
>
|
||||
<div class="models-list">
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<!-- Show unavailable model as first option (disabled) -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
||||
role="option"
|
||||
aria-selected="true"
|
||||
aria-disabled="true"
|
||||
disabled
|
||||
>
|
||||
<ModelId modelId={currentModel} class="flex-1" hideQuantization />
|
||||
<DropdownMenu.Sub bind:open={modelSubOpen}>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<MODEL_SELECTOR_ICON class="h-4 w-4" />
|
||||
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if ms.filteredOptions.length === 0}
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{option}
|
||||
{isSelected}
|
||||
{isHighlighted}
|
||||
{isFav}
|
||||
{hideOrgName}
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
void handleModelKeyAction(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
modelId={selectedOption.model}
|
||||
class="min-w-0 flex-1 overflow-hidden"
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideQuantization
|
||||
/>
|
||||
{/snippet}
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
|
||||
{/if}
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<ModelsSelectorList
|
||||
groups={ms.groupedFilteredOptions}
|
||||
{currentModel}
|
||||
activeId={ms.activeId}
|
||||
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
renderOption={modelOption}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuSearchable>
|
||||
<DropdownMenu.SubContent class="w-100 max-w-[calc(100vw-2rem)] pt-0">
|
||||
<DropdownMenuSearchable
|
||||
searchValue={ms.searchTerm}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
placeholder="Search models..."
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
emptyMessage="No models found."
|
||||
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
|
||||
>
|
||||
<div class="models-list">
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<!-- Show unavailable model as first option (disabled) -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
||||
role="option"
|
||||
aria-selected="true"
|
||||
aria-disabled="true"
|
||||
disabled
|
||||
>
|
||||
<ModelId modelId={currentModel} class="flex-1" hideQuantization />
|
||||
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if ms.filteredOptions.length === 0}
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{option}
|
||||
{isSelected}
|
||||
{isHighlighted}
|
||||
{isFav}
|
||||
{hideOrgName}
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
void handleModelKeyAction(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<ModelsSelectorList
|
||||
groups={ms.groupedFilteredOptions}
|
||||
{currentModel}
|
||||
activeId={ms.activeId}
|
||||
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
renderOption={modelOption}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuSearchable>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<ChatFormActionAddReasoningSubmenu />
|
||||
|
||||
{#if samplingSection}
|
||||
<ModelsSelectorSettingsSubmenu section={samplingSection} disabled={!hasModelLoaded} />
|
||||
{/if}
|
||||
|
||||
{#if penaltiesSection}
|
||||
<ModelsSelectorSettingsSubmenu section={penaltiesSection} disabled={!hasModelLoaded} />
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
@@ -333,11 +407,15 @@
|
||||
<ModelId
|
||||
modelId={selectedOption.model}
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideOrgName={false}
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideQuantization
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
|
||||
{/if}
|
||||
|
||||
{#if ms.updating}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{/if}
|
||||
|
||||
@@ -58,15 +58,19 @@
|
||||
let loadProgress = $derived(isLoading ? modelsStore.status.getLoadProgress(option.model) : null);
|
||||
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
|
||||
let loadTitle = $derived(modelLoadProgressText(loadProgress));
|
||||
let modalities = $derived(option.modalities);
|
||||
let supportsThinking = $derived(modelsStore.props.checkModelSupportsThinking(option.model));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
|
||||
'cursor-pointer',
|
||||
isSelected && 'bg-accent/50 text-accent-foreground',
|
||||
isSelected && !isHighlighted && 'bg-accent/50',
|
||||
isHighlighted && 'bg-accent',
|
||||
!isSelected && !isHighlighted && 'hover:bg-muted',
|
||||
(isSelected || isHighlighted) && 'text-accent-foreground',
|
||||
'hover:bg-accent',
|
||||
'focus:bg-accent',
|
||||
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
|
||||
]}
|
||||
role="option"
|
||||
@@ -82,6 +86,9 @@
|
||||
{hideOrgName}
|
||||
aliases={option.aliases}
|
||||
tags={option.tags}
|
||||
{modalities}
|
||||
{supportsThinking}
|
||||
showRawTooltip
|
||||
class="flex-1"
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { SettingsFieldType } from '$lib/enums/settings.enums';
|
||||
import { modelsStore, serverStore, settingsStore } from '$lib/stores';
|
||||
import type { SettingsSection } from '$lib/types';
|
||||
import { normalizeFloatingPoint } from '$lib/utils/precision';
|
||||
|
||||
interface Props {
|
||||
section: SettingsSection;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { disabled = false, section }: Props = $props();
|
||||
|
||||
let currentModelParams = $derived.by(() => {
|
||||
void modelsStore.props.cacheVersion;
|
||||
|
||||
if (serverStore.isRouterMode) {
|
||||
const currentModelName = modelsStore.selectedModelName;
|
||||
|
||||
if (currentModelName) {
|
||||
const currentModelProps = modelsStore.props.getModelProps(currentModelName);
|
||||
|
||||
return (currentModelProps?.default_generation_settings?.params ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
return (serverStore.defaultParams ?? {}) as Record<string, unknown>;
|
||||
});
|
||||
|
||||
function currentValue(key: string): string {
|
||||
const value = settingsStore.config[key];
|
||||
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
function handleInput(key: string, value: string) {
|
||||
settingsStore.updateConfig(key, value);
|
||||
}
|
||||
|
||||
function placeholder(key: string): string {
|
||||
const serverDefault = currentModelParams[key];
|
||||
|
||||
return serverDefault != null ? `Default: ${normalizeFloatingPoint(serverDefault)}` : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2" {disabled}>
|
||||
<section.icon class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<span class="text-sm text-muted-foreground">{section.title}</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent
|
||||
class="max-h-96 w-72 overflow-y-auto bg-popover p-2 text-popover-foreground shadow-md outline-none"
|
||||
>
|
||||
{#each section.fields ?? [] as field (field.key)}
|
||||
{#if field.type === SettingsFieldType.INPUT}
|
||||
<label class="mb-2 flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">{field.label}</span>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
placeholder={placeholder(field.key)}
|
||||
oninput={(event) => handleInput(field.key, event.currentTarget.value)}
|
||||
class="h-8"
|
||||
/>
|
||||
</label>
|
||||
{:else if field.type === SettingsFieldType.CHECKBOX}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-1 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<Checkbox
|
||||
checked={Boolean(settingsStore.config[field.key])}
|
||||
onCheckedChange={(checked) => settingsStore.updateConfig(field.key, Boolean(checked))}
|
||||
/>
|
||||
|
||||
<span>{field.label}</span>
|
||||
</label>
|
||||
{/if}
|
||||
{/each}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
@@ -65,6 +65,8 @@ export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
|
||||
*/
|
||||
export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
|
||||
|
||||
export { default as ModelsSelectorSettingsSubmenu } from './ModelsSelectorSettingsSubmenu.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsSelectorSheet** - Mobile model selection sheet
|
||||
*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ModelModality } from '$lib/enums';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
@@ -17,6 +18,23 @@ export interface GroupedModelOptions {
|
||||
available: OrgGroup[];
|
||||
}
|
||||
|
||||
function matchesModality(option: ModelOption, term: string): boolean {
|
||||
const modalities = option.modalities;
|
||||
|
||||
if (!modalities) return false;
|
||||
|
||||
switch (term) {
|
||||
case ModelModality.VISION.toLowerCase():
|
||||
return modalities.vision;
|
||||
case ModelModality.AUDIO.toLowerCase():
|
||||
return modalities.audio;
|
||||
case ModelModality.VIDEO.toLowerCase():
|
||||
return modalities.video;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
|
||||
@@ -27,7 +45,8 @@ export function filterModelOptions(options: ModelOption[], searchTerm: string):
|
||||
option.model.toLowerCase().includes(term) ||
|
||||
option.name?.toLowerCase().includes(term) ||
|
||||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
|
||||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term))
|
||||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) ||
|
||||
matchesModality(option, term)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
NUMERIC_FIELDS,
|
||||
POSITIVE_INTEGER_FIELDS,
|
||||
SETTINGS_CHAT_SECTIONS,
|
||||
SETTINGS_SECTION_TITLES
|
||||
SETTINGS_SECTION_SLUGS
|
||||
} from '$lib/constants';
|
||||
import { ColorMode } from '$lib/enums/ui.enums';
|
||||
import { RouterService } from '$lib/services/router.service';
|
||||
@@ -148,9 +148,9 @@
|
||||
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
|
||||
</div>
|
||||
|
||||
{#if currentSection.title === SETTINGS_SECTION_TITLES.TOOLS}
|
||||
{#if currentSection.slug === SETTINGS_SECTION_SLUGS.TOOLS}
|
||||
<SettingsChatToolsTab />
|
||||
{:else if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT}
|
||||
{:else if currentSection.slug === SETTINGS_SECTION_SLUGS.IMPORT_EXPORT}
|
||||
<SettingsChatImportExportTab />
|
||||
{:else if currentSection.fields}
|
||||
<div class="space-y-6">
|
||||
@@ -161,7 +161,7 @@
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
|
||||
{#if currentSection.title === SETTINGS_SECTION_TITLES.GENERAL}
|
||||
{#if currentSection.slug === SETTINGS_SECTION_SLUGS.GENERAL}
|
||||
<div class="flex justify-end">
|
||||
<Button variant="outline" onclick={() => window.location.reload()}>
|
||||
<RefreshCw class="h-3 w-3" />
|
||||
|
||||
+46
-6
@@ -4,12 +4,16 @@
|
||||
import {
|
||||
DialogConfirmation,
|
||||
DialogConversationSelection,
|
||||
DialogExportSettings
|
||||
DialogExportSettings,
|
||||
DialogSettingsImportPreview
|
||||
} from '$lib/components/app';
|
||||
import type { SettingsDiffEntry } from '$lib/components/app/dialogs/DialogSettingsImportPreview.svelte';
|
||||
import SettingsGroup from '$lib/components/app/settings/SettingsGroup.svelte';
|
||||
import { ConversationSelectionMode, FileExtensionText, HtmlInputType } from '$lib/enums';
|
||||
import { computeSettingsDiff } from '$lib/hooks/use-drop-import.svelte';
|
||||
import { ConversationTransferService } from '$lib/services';
|
||||
import { conversationsStore, settingsStore } from '$lib/stores';
|
||||
import type { SettingsConfigType, SettingsExportType } from '$lib/types';
|
||||
import { createMessageCountMap } from '$lib/utils';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -35,6 +39,9 @@
|
||||
let showSettingsImportSummary = $state(false);
|
||||
let showSettingsExportDialog = $state(false);
|
||||
let includeSensitiveData = $state(false);
|
||||
let showSettingsImportPreview = $state(false);
|
||||
let pendingSettingsImport = $state<SettingsExportType | null>(null);
|
||||
let settingsImportDiff = $state<SettingsDiffEntry[]>([]);
|
||||
|
||||
function handleSettingsExport() {
|
||||
showSettingsExportDialog = true;
|
||||
@@ -92,11 +99,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
settingsStore.importSettings(data);
|
||||
|
||||
showSettingsImportSummary = true;
|
||||
showSettingsExportSummary = false;
|
||||
toast.success('Settings imported successfully');
|
||||
pendingSettingsImport = data as SettingsExportType;
|
||||
settingsImportDiff = computeSettingsDiff(
|
||||
$state.snapshot(settingsStore.config) as SettingsConfigType,
|
||||
data.config
|
||||
);
|
||||
showSettingsImportPreview = true;
|
||||
} catch (err) {
|
||||
console.error('Failed to import settings:', err);
|
||||
toast.error('Failed to import settings');
|
||||
@@ -110,6 +118,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleSettingsImportConfirm() {
|
||||
showSettingsImportPreview = false;
|
||||
|
||||
if (!pendingSettingsImport) return;
|
||||
|
||||
try {
|
||||
settingsStore.importSettings(pendingSettingsImport);
|
||||
|
||||
pendingSettingsImport = null;
|
||||
settingsImportDiff = [];
|
||||
showSettingsImportSummary = true;
|
||||
showSettingsExportSummary = false;
|
||||
toast.success('Settings imported successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to import settings:', err);
|
||||
toast.error('Failed to import settings');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSettingsImportCancel() {
|
||||
showSettingsImportPreview = false;
|
||||
pendingSettingsImport = null;
|
||||
settingsImportDiff = [];
|
||||
}
|
||||
|
||||
async function handleExportClick() {
|
||||
try {
|
||||
const allConversations = conversationsStore.conversations;
|
||||
@@ -321,6 +354,13 @@
|
||||
onCancel={handleSettingsExportCancel}
|
||||
/>
|
||||
|
||||
<DialogSettingsImportPreview
|
||||
bind:open={showSettingsImportPreview}
|
||||
diff={settingsImportDiff}
|
||||
onConfirm={handleSettingsImportConfirm}
|
||||
onCancel={handleSettingsImportCancel}
|
||||
/>
|
||||
|
||||
<DialogConversationSelection
|
||||
conversations={availableConversations}
|
||||
{messageCountMap}
|
||||
|
||||
@@ -48,7 +48,7 @@ export * from './pwa.constants';
|
||||
export * from './routes.constants';
|
||||
export * from './sandbox.constants';
|
||||
export * from './settings-keys.constants';
|
||||
export * from './settings-registry.constants';
|
||||
export * from './settings.constants';
|
||||
export * from './special-characters.constants';
|
||||
export * from './stream.constants';
|
||||
export * from './supported-file-types.constants';
|
||||
|
||||
@@ -10,18 +10,6 @@ export const URL_PARAMS = {
|
||||
QUERY: 'q'
|
||||
} as const;
|
||||
|
||||
/** Settings section slugs — used for routes and navigation. */
|
||||
export const SETTINGS_SECTION_SLUGS = {
|
||||
AGENTIC: 'agentic',
|
||||
DEVELOPER: 'developer',
|
||||
DISPLAY: 'display',
|
||||
GENERAL: 'general',
|
||||
IMPORT_EXPORT: 'import-export',
|
||||
PENALTIES: 'penalties',
|
||||
SAMPLING: 'sampling',
|
||||
TOOLS: 'tools'
|
||||
} as const;
|
||||
|
||||
export const ROUTES = {
|
||||
/** Chat base — for dynamic chat URLs use RouterService. */
|
||||
CHAT: '#/chat',
|
||||
@@ -33,6 +21,8 @@ export const ROUTES = {
|
||||
SEARCH: '#/search',
|
||||
/** Settings base — for dynamic settings URLs use RouterService. */
|
||||
SETTINGS: '#/settings',
|
||||
/** Exit destination for the settings view (fallback when no referrer). */
|
||||
SETTINGS_EXIT: '#/',
|
||||
/** Root — start of the app. */
|
||||
START: '#/'
|
||||
} as const;
|
||||
|
||||
@@ -53,6 +53,7 @@ export const SETTINGS_KEYS = {
|
||||
SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
|
||||
// Display
|
||||
SHOW_MESSAGE_STATS: 'showMessageStats',
|
||||
SHOW_MODEL_ORG_NAME_IN_TRIGGER: 'showModelOrgNameInTrigger',
|
||||
SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
|
||||
SHOW_MODEL_TAGS: 'showModelTags',
|
||||
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
|
||||
|
||||
+419
-409
@@ -1,11 +1,9 @@
|
||||
import { CLI_FLAGS } from './cli-flags.constants';
|
||||
import { DEFAULT_MCP_CONFIG } from './mcp.constants';
|
||||
import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes.constants';
|
||||
import { SETTINGS_KEYS } from './settings-keys.constants';
|
||||
import { TITLE_GENERATION } from './title-generation.constants';
|
||||
import { FILE_GLOB_SEARCH_PICKERS } from './working-directory.constants';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Code,
|
||||
Database,
|
||||
Funnel,
|
||||
@@ -13,8 +11,9 @@ import {
|
||||
Monitor,
|
||||
Moon,
|
||||
PencilRuler,
|
||||
Sliders,
|
||||
Sun
|
||||
SlidersVertical,
|
||||
Sun,
|
||||
TriangleAlert
|
||||
} from '@lucide/svelte';
|
||||
import { SyncableParameterType } from '$lib/enums';
|
||||
import { SettingsFieldType } from '$lib/enums/settings.enums';
|
||||
@@ -22,174 +21,191 @@ import { ColorMode } from '$lib/enums/ui.enums';
|
||||
import type {
|
||||
SettingsConfigValue,
|
||||
SettingsEntry,
|
||||
SettingsFieldConfig,
|
||||
SettingsSection,
|
||||
SettingsSectionEntry,
|
||||
SettingsSectionTitle,
|
||||
SyncableParameter
|
||||
SettingsSectionEntry
|
||||
} from '$lib/types';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
/** Settings sections — slug is the routing identity, title is the display label. */
|
||||
export const SETTINGS_SECTIONS = {
|
||||
AGENTIC: { slug: 'agentic', title: 'Agentic' },
|
||||
DEVELOPER: { slug: 'developer', title: 'Developer' },
|
||||
DISPLAY: { slug: 'display', title: 'Display' },
|
||||
GENERAL: { slug: 'general', title: 'General' },
|
||||
IMPORT_EXPORT: { slug: 'import-export', title: 'Import/Export' },
|
||||
PENALTIES: { slug: 'penalties', title: 'Penalties' },
|
||||
SAMPLING: { slug: 'sampling', title: 'Sampling' },
|
||||
TOOLS: { slug: 'tools', title: 'Tools' }
|
||||
} as const;
|
||||
|
||||
export const SETTINGS_SECTION_SLUGS = {
|
||||
AGENTIC: SETTINGS_SECTIONS.AGENTIC.slug,
|
||||
DEVELOPER: SETTINGS_SECTIONS.DEVELOPER.slug,
|
||||
DISPLAY: SETTINGS_SECTIONS.DISPLAY.slug,
|
||||
GENERAL: SETTINGS_SECTIONS.GENERAL.slug,
|
||||
IMPORT_EXPORT: SETTINGS_SECTIONS.IMPORT_EXPORT.slug,
|
||||
PENALTIES: SETTINGS_SECTIONS.PENALTIES.slug,
|
||||
SAMPLING: SETTINGS_SECTIONS.SAMPLING.slug,
|
||||
TOOLS: SETTINGS_SECTIONS.TOOLS.slug
|
||||
} as const;
|
||||
|
||||
export const SETTINGS_SECTION_TITLES = {
|
||||
AGENTIC: 'Agentic',
|
||||
DEVELOPER: 'Developer',
|
||||
DISPLAY: 'Display',
|
||||
GENERAL: 'General',
|
||||
IMPORT_EXPORT: 'Import/Export',
|
||||
PENALTIES: 'Penalties',
|
||||
SAMPLING: 'Sampling',
|
||||
TOOLS: 'Tools'
|
||||
AGENTIC: SETTINGS_SECTIONS.AGENTIC.title,
|
||||
DEVELOPER: SETTINGS_SECTIONS.DEVELOPER.title,
|
||||
DISPLAY: SETTINGS_SECTIONS.DISPLAY.title,
|
||||
GENERAL: SETTINGS_SECTIONS.GENERAL.title,
|
||||
IMPORT_EXPORT: SETTINGS_SECTIONS.IMPORT_EXPORT.title,
|
||||
PENALTIES: SETTINGS_SECTIONS.PENALTIES.title,
|
||||
SAMPLING: SETTINGS_SECTIONS.SAMPLING.title,
|
||||
TOOLS: SETTINGS_SECTIONS.TOOLS.title
|
||||
} as const;
|
||||
|
||||
const STANDALONE_SECTIONS: { title: SettingsSectionTitle; slug: string; icon: Component }[] = [
|
||||
{ icon: PencilRuler, slug: SETTINGS_SECTION_SLUGS.TOOLS, title: SETTINGS_SECTION_TITLES.TOOLS },
|
||||
export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
|
||||
// General
|
||||
{
|
||||
icon: Database,
|
||||
slug: SETTINGS_SECTION_SLUGS.IMPORT_EXPORT,
|
||||
title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT
|
||||
}
|
||||
];
|
||||
const COLOR_MODE_OPTIONS: Array<{ value: string; label: string; icon: Component }> = [
|
||||
{ icon: Monitor, label: 'System', value: ColorMode.SYSTEM },
|
||||
{ icon: Sun, label: 'Light', value: ColorMode.LIGHT },
|
||||
{ icon: Moon, label: 'Dark', value: ColorMode.DARK }
|
||||
];
|
||||
// Shared options for the title-generation radio group. Both paired registry entries
|
||||
// (USE_FIRST_LINE, USE_LLM) reference this list so labels stay in lockstep.
|
||||
const TITLE_GENERATION_RADIO_OPTIONS: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
key: string;
|
||||
isExperimental?: boolean;
|
||||
}> = [
|
||||
{
|
||||
key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
|
||||
label: 'Use first non-empty line for the conversation title',
|
||||
value: 'firstLine'
|
||||
},
|
||||
{
|
||||
isExperimental: true,
|
||||
key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
|
||||
label: 'Generate title with LLM',
|
||||
value: 'llm'
|
||||
}
|
||||
];
|
||||
// Common shape for the conversation title radio entry.
|
||||
const TITLE_GENERATION_BASE = {
|
||||
radioOptions: TITLE_GENERATION_RADIO_OPTIONS,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.RADIO
|
||||
} as const;
|
||||
const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
[SETTINGS_SECTION_SLUGS.AGENTIC]: {
|
||||
icon: ListRestart,
|
||||
icon: SlidersVertical,
|
||||
settings: [
|
||||
{
|
||||
defaultValue: 10,
|
||||
help: 'Maximum number of tool execution cycles before stopping (prevents infinite loops).',
|
||||
isPositiveInteger: true,
|
||||
key: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
|
||||
label: 'Agentic turns',
|
||||
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
||||
help: 'Timeout for individual MCP tool calls.',
|
||||
isPositiveInteger: true,
|
||||
key: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS,
|
||||
label: 'MCP request timeout (seconds)',
|
||||
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: FILE_GLOB_SEARCH_PICKERS.DEFAULT_SEARCH_DEPTH,
|
||||
help: 'How many directory levels below the working directory the @-mention file search descends. Larger values surface deeply nested files but take longer on large trees.',
|
||||
isPositiveInteger: true,
|
||||
key: SETTINGS_KEYS.MENTION_SEARCH_MAX_DEPTH,
|
||||
label: 'Mention search depth',
|
||||
max: FILE_GLOB_SEARCH_PICKERS.MAX_SEARCH_DEPTH,
|
||||
min: 1,
|
||||
placeholder: `${FILE_GLOB_SEARCH_PICKERS.DEFAULT_SEARCH_DEPTH}`,
|
||||
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
type: SettingsFieldType.INPUT
|
||||
}
|
||||
],
|
||||
slug: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
title: SETTINGS_SECTION_TITLES.AGENTIC
|
||||
},
|
||||
[SETTINGS_SECTION_SLUGS.DEVELOPER]: {
|
||||
icon: Code,
|
||||
settings: [
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'After each response, re-submit the conversation to pre-fill the server KV cache. Makes the next turn faster since the prompt is already encoded while you read the response.',
|
||||
key: SETTINGS_KEYS.PRE_ENCODE_CONVERSATION,
|
||||
label: 'Pre-fill KV cache after response',
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Send reasoning_format=none so the server returns thinking tokens inline instead of extracting them into a separate field.',
|
||||
key: SETTINGS_KEYS.DISABLE_REASONING_PARSING,
|
||||
label: 'Disable reasoning content parsing',
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Strip thinking from previous messages before sending. When off, thinking is sent back via the reasoning_content field so the model sees its own chain-of-thought across turns.',
|
||||
key: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT,
|
||||
label: 'Exclude reasoning from context',
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Show toggle button to display messages as plain text instead of Markdown-formatted content',
|
||||
key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
|
||||
label: 'Enable raw output toggle',
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Expose a run_javascript tool to the model. Code runs in a Web Worker inside a sandboxed iframe with an opaque origin, isolated from the WebUI and its API, with a hard timeout.',
|
||||
key: SETTINGS_KEYS.JS_SANDBOX_ENABLED,
|
||||
label: 'JavaScript sandbox tool',
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
dependsOn: SETTINGS_KEYS.JS_SANDBOX_ENABLED,
|
||||
help: 'Pre-load nerdamer in the sandbox for symbolic computation: simplify, diff, integrate, solve, and more. Requires "JavaScript sandbox tool" to be enabled.',
|
||||
key: SETTINGS_KEYS.SYMBOLIC_MATH_ENABLED,
|
||||
label: 'Symbolic math (nerdamer)',
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
defaultValue: ColorMode.SYSTEM,
|
||||
help: 'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.',
|
||||
key: SETTINGS_KEYS.THEME,
|
||||
label: 'Theme',
|
||||
options: [
|
||||
{ icon: Monitor, label: 'System', value: ColorMode.SYSTEM },
|
||||
{ icon: Sun, label: 'Light', value: ColorMode.LIGHT },
|
||||
{ icon: Moon, label: 'Dark', value: ColorMode.DARK }
|
||||
],
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.SELECT
|
||||
},
|
||||
{
|
||||
defaultValue: '',
|
||||
help: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
|
||||
key: SETTINGS_KEYS.CUSTOM_JSON,
|
||||
label: 'Custom JSON',
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
type: SettingsFieldType.TEXTAREA
|
||||
help: `Set the API Key if you are using <code> ${CLI_FLAGS.API_KEY} </code> option for the server.`,
|
||||
isPrivate: true,
|
||||
key: SETTINGS_KEYS.API_KEY,
|
||||
label: 'API Key',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: '',
|
||||
help: 'CSS injected into the page at runtime. Set it here, or ship it server side via the --ui-config customCss field.',
|
||||
key: SETTINGS_KEYS.CUSTOM_CSS,
|
||||
label: 'Custom CSS',
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
help: 'The starting message that defines how model should behave.',
|
||||
key: SETTINGS_KEYS.SYSTEM_MESSAGE,
|
||||
label: 'System Message',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.TEXTAREA
|
||||
},
|
||||
{
|
||||
defaultValue: true,
|
||||
help: 'Display the system message at the top of each conversation.',
|
||||
key: SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE,
|
||||
label: 'Show system message',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
standaloneField: false,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: 2500,
|
||||
help: 'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.',
|
||||
key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
|
||||
label: 'Paste long text to file length',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: true,
|
||||
help: 'Use Enter to send messages and Shift + Enter for new lines. When disabled, use Ctrl/Cmd + Enter.',
|
||||
key: SETTINGS_KEYS.SEND_ON_ENTER,
|
||||
label: 'Send message on Enter',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: true,
|
||||
help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
|
||||
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
|
||||
label: 'Show microphone on empty input',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Enable "Continue" button for assistant messages, including reasoning models.',
|
||||
isExperimental: true,
|
||||
key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
|
||||
label: 'Enable "Continue" button',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: true,
|
||||
help: 'Choose how conversation titles are generated. The first non-empty line uses a fast deterministic rule; the LLM option uses a model-generated title from the first message exchange.',
|
||||
key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
|
||||
label: 'Conversation title',
|
||||
radioOptions: [
|
||||
{
|
||||
key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
|
||||
label: 'Use first non-empty line for the conversation title',
|
||||
value: 'firstLine'
|
||||
},
|
||||
{
|
||||
isExperimental: true,
|
||||
key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
|
||||
label: 'Generate title with LLM',
|
||||
value: 'llm'
|
||||
}
|
||||
],
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.RADIO
|
||||
},
|
||||
{
|
||||
defaultValue: TITLE_GENERATION.DEFAULT_PROMPT,
|
||||
dependsOn: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
|
||||
help: 'Optional template for the title generation prompt. Use {{USER}} for the user message and {{ASSISTANT}} for the assistant message.',
|
||||
key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
|
||||
label: 'LLM title generation prompt',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.TEXTAREA
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Counterpart of the conversation title radio; stored and synced without a dedicated UI field.',
|
||||
key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
|
||||
label: 'Generate title with LLM',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
standaloneField: false,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
|
||||
key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
|
||||
label: 'Copy text attachments as plain text',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.',
|
||||
key: SETTINGS_KEYS.PDF_AS_IMAGE,
|
||||
label: 'Parse PDF as image',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: 0,
|
||||
help: 'Images larger than this will be resized before sending to server. Set to 0 to disable.',
|
||||
key: SETTINGS_KEYS.MAX_IMAGE_RESOLUTION,
|
||||
label: 'Maximum image resolution (megapixels)',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.INPUT
|
||||
}
|
||||
],
|
||||
slug: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
title: SETTINGS_SECTION_TITLES.DEVELOPER
|
||||
slug: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
title: SETTINGS_SECTION_TITLES.GENERAL
|
||||
},
|
||||
[SETTINGS_SECTION_SLUGS.DISPLAY]: {
|
||||
// Display
|
||||
{
|
||||
icon: Monitor,
|
||||
settings: [
|
||||
{
|
||||
@@ -289,6 +305,14 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Display the organization name in the model selector trigger button.',
|
||||
key: SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER,
|
||||
label: 'Show organization name in model selector trigger',
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Display the current build version in the bottom-right corner of the interface.',
|
||||
@@ -309,214 +333,70 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
slug: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||
title: SETTINGS_SECTION_TITLES.DISPLAY
|
||||
},
|
||||
[SETTINGS_SECTION_SLUGS.GENERAL]: {
|
||||
icon: Sliders,
|
||||
// MCP Servers (non-UI config object)
|
||||
{
|
||||
icon: PencilRuler,
|
||||
settings: [
|
||||
{
|
||||
defaultValue: ColorMode.SYSTEM,
|
||||
help: 'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.',
|
||||
key: SETTINGS_KEYS.THEME,
|
||||
label: 'Theme',
|
||||
options: COLOR_MODE_OPTIONS,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.SELECT
|
||||
},
|
||||
{
|
||||
defaultValue: '',
|
||||
help: `Set the API Key if you are using <code> ${CLI_FLAGS.API_KEY} </code> option for the server.`,
|
||||
isPrivate: true,
|
||||
key: SETTINGS_KEYS.API_KEY,
|
||||
label: 'API Key',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: '',
|
||||
help: 'The starting message that defines how model should behave.',
|
||||
key: SETTINGS_KEYS.SYSTEM_MESSAGE,
|
||||
label: 'System Message',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.TEXTAREA
|
||||
},
|
||||
{
|
||||
defaultValue: 2500,
|
||||
help: 'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.',
|
||||
key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
|
||||
label: 'Paste long text to file length',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: true,
|
||||
help: 'Use Enter to send messages and Shift + Enter for new lines. When disabled, use Ctrl/Cmd + Enter.',
|
||||
key: SETTINGS_KEYS.SEND_ON_ENTER,
|
||||
label: 'Send message on Enter',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
|
||||
isExperimental: true,
|
||||
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
|
||||
label: 'Show microphone on empty input',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Enable "Continue" button for assistant messages, including reasoning models.',
|
||||
isExperimental: true,
|
||||
key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
|
||||
label: 'Enable "Continue" button',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
...TITLE_GENERATION_BASE,
|
||||
defaultValue: true,
|
||||
help: 'Choose how conversation titles are generated. The first non-empty line uses a fast deterministic rule; the LLM option uses a model-generated title from the first message exchange.',
|
||||
key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
|
||||
label: 'Conversation title'
|
||||
},
|
||||
{
|
||||
defaultValue: TITLE_GENERATION.DEFAULT_PROMPT,
|
||||
dependsOn: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
|
||||
help: 'Optional template for the title generation prompt. Use {{USER}} for the user message and {{ASSISTANT}} for the assistant message.',
|
||||
key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
|
||||
label: 'LLM title generation prompt',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.TEXTAREA
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
|
||||
key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
|
||||
label: 'Copy text attachments as plain text',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.',
|
||||
key: SETTINGS_KEYS.PDF_AS_IMAGE,
|
||||
label: 'Parse PDF as image',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: 0,
|
||||
help: 'Images larger than this will be resized before sending to server. Set to 0 to disable.',
|
||||
key: SETTINGS_KEYS.MAX_IMAGE_RESOLUTION,
|
||||
label: 'Maximum image resolution (megapixels)',
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
defaultValue: '[]',
|
||||
help: 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.',
|
||||
key: SETTINGS_KEYS.MCP_SERVERS,
|
||||
label: 'MCP servers',
|
||||
section: SETTINGS_SECTION_SLUGS.TOOLS,
|
||||
standaloneField: false,
|
||||
type: SettingsFieldType.INPUT
|
||||
}
|
||||
],
|
||||
slug: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
title: SETTINGS_SECTION_TITLES.GENERAL
|
||||
slug: SETTINGS_SECTION_SLUGS.TOOLS,
|
||||
title: SETTINGS_SECTION_TITLES.TOOLS
|
||||
},
|
||||
[SETTINGS_SECTION_SLUGS.PENALTIES]: {
|
||||
icon: AlertTriangle,
|
||||
// Tools
|
||||
{
|
||||
icon: ListRestart,
|
||||
settings: [
|
||||
{
|
||||
defaultValue: undefined,
|
||||
help: 'Last n tokens to consider for penalizing repetition',
|
||||
key: SETTINGS_KEYS.REPEAT_LAST_N,
|
||||
label: 'Repeat last N',
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
paramType: SyncableParameterType.NUMBER,
|
||||
serverKey: SETTINGS_KEYS.REPEAT_LAST_N
|
||||
},
|
||||
defaultValue: 10,
|
||||
help: 'Maximum number of tool execution cycles before stopping (prevents infinite loops).',
|
||||
isPositiveInteger: true,
|
||||
key: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
|
||||
label: 'Agentic turns',
|
||||
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: undefined,
|
||||
help: 'Controls the repetition of token sequences in the generated text',
|
||||
key: SETTINGS_KEYS.REPEAT_PENALTY,
|
||||
label: 'Repeat penalty',
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
paramType: SyncableParameterType.NUMBER,
|
||||
serverKey: SETTINGS_KEYS.REPEAT_PENALTY
|
||||
},
|
||||
defaultValue: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
||||
help: 'Timeout for individual MCP tool calls.',
|
||||
isPositiveInteger: true,
|
||||
key: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS,
|
||||
label: 'MCP request timeout (seconds)',
|
||||
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: undefined,
|
||||
help: 'Limits tokens based on whether they appear in the output or not.',
|
||||
key: SETTINGS_KEYS.PRESENCE_PENALTY,
|
||||
label: 'Presence penalty',
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
paramType: SyncableParameterType.NUMBER,
|
||||
serverKey: SETTINGS_KEYS.PRESENCE_PENALTY
|
||||
},
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: undefined,
|
||||
help: 'Limits tokens based on how often they appear in the output.',
|
||||
key: SETTINGS_KEYS.FREQUENCY_PENALTY,
|
||||
label: 'Frequency penalty',
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
paramType: SyncableParameterType.NUMBER,
|
||||
serverKey: SETTINGS_KEYS.FREQUENCY_PENALTY
|
||||
},
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: undefined,
|
||||
help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling multiplier.',
|
||||
key: SETTINGS_KEYS.DRY_MULTIPLIER,
|
||||
label: 'DRY multiplier',
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
paramType: SyncableParameterType.NUMBER,
|
||||
serverKey: SETTINGS_KEYS.DRY_MULTIPLIER
|
||||
},
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: undefined,
|
||||
help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling base value.',
|
||||
key: SETTINGS_KEYS.DRY_BASE,
|
||||
label: 'DRY base',
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: { paramType: SyncableParameterType.NUMBER, serverKey: SETTINGS_KEYS.DRY_BASE },
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: undefined,
|
||||
help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the allowed length for DRY sampling.',
|
||||
key: SETTINGS_KEYS.DRY_ALLOWED_LENGTH,
|
||||
label: 'DRY allowed length',
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
paramType: SyncableParameterType.NUMBER,
|
||||
serverKey: SETTINGS_KEYS.DRY_ALLOWED_LENGTH
|
||||
},
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: undefined,
|
||||
help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets DRY penalty for the last n tokens.',
|
||||
key: SETTINGS_KEYS.DRY_PENALTY_LAST_N,
|
||||
label: 'DRY penalty last N',
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
paramType: SyncableParameterType.NUMBER,
|
||||
serverKey: SETTINGS_KEYS.DRY_PENALTY_LAST_N
|
||||
},
|
||||
defaultValue: FILE_GLOB_SEARCH_PICKERS.DEFAULT_SEARCH_DEPTH,
|
||||
help: 'How many directory levels below the working directory the @-mention file search descends. Larger values surface deeply nested files but take longer on large trees.',
|
||||
isPositiveInteger: true,
|
||||
key: SETTINGS_KEYS.MENTION_SEARCH_MAX_DEPTH,
|
||||
label: 'Mention search depth',
|
||||
max: FILE_GLOB_SEARCH_PICKERS.MAX_SEARCH_DEPTH,
|
||||
min: 1,
|
||||
placeholder: `${FILE_GLOB_SEARCH_PICKERS.DEFAULT_SEARCH_DEPTH}`,
|
||||
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
type: SettingsFieldType.INPUT
|
||||
}
|
||||
],
|
||||
slug: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
title: SETTINGS_SECTION_TITLES.PENALTIES
|
||||
slug: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
title: SETTINGS_SECTION_TITLES.AGENTIC
|
||||
},
|
||||
[SETTINGS_SECTION_SLUGS.SAMPLING]: {
|
||||
// Import/Export
|
||||
{
|
||||
icon: Database,
|
||||
settings: [],
|
||||
slug: SETTINGS_SECTION_SLUGS.IMPORT_EXPORT,
|
||||
title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT
|
||||
},
|
||||
// Sampling
|
||||
{
|
||||
icon: Funnel,
|
||||
settings: [
|
||||
{
|
||||
@@ -647,48 +527,189 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
],
|
||||
slug: SETTINGS_SECTION_SLUGS.SAMPLING,
|
||||
title: SETTINGS_SECTION_TITLES.SAMPLING
|
||||
}
|
||||
} as const;
|
||||
const NON_UI_SETTINGS: SettingsEntry[] = [
|
||||
{
|
||||
defaultValue: true,
|
||||
help: 'Display the system message at the top of each conversation.',
|
||||
key: SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE,
|
||||
label: 'Show system message',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
// Penalties
|
||||
{
|
||||
defaultValue: '[]',
|
||||
help: 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.',
|
||||
key: SETTINGS_KEYS.MCP_SERVERS,
|
||||
label: 'MCP servers',
|
||||
type: SettingsFieldType.INPUT
|
||||
icon: TriangleAlert,
|
||||
settings: [
|
||||
{
|
||||
defaultValue: undefined,
|
||||
help: 'Last n tokens to consider for penalizing repetition',
|
||||
key: SETTINGS_KEYS.REPEAT_LAST_N,
|
||||
label: 'Repeat last N',
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
paramType: SyncableParameterType.NUMBER,
|
||||
serverKey: SETTINGS_KEYS.REPEAT_LAST_N
|
||||
},
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: undefined,
|
||||
help: 'Controls the repetition of token sequences in the generated text',
|
||||
key: SETTINGS_KEYS.REPEAT_PENALTY,
|
||||
label: 'Repeat penalty',
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
paramType: SyncableParameterType.NUMBER,
|
||||
serverKey: SETTINGS_KEYS.REPEAT_PENALTY
|
||||
},
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: undefined,
|
||||
help: 'Limits tokens based on whether they appear in the output or not.',
|
||||
key: SETTINGS_KEYS.PRESENCE_PENALTY,
|
||||
label: 'Presence penalty',
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
paramType: SyncableParameterType.NUMBER,
|
||||
serverKey: SETTINGS_KEYS.PRESENCE_PENALTY
|
||||
},
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: undefined,
|
||||
help: 'Limits tokens based on how often they appear in the output.',
|
||||
key: SETTINGS_KEYS.FREQUENCY_PENALTY,
|
||||
label: 'Frequency penalty',
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
paramType: SyncableParameterType.NUMBER,
|
||||
serverKey: SETTINGS_KEYS.FREQUENCY_PENALTY
|
||||
},
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: undefined,
|
||||
help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling multiplier.',
|
||||
key: SETTINGS_KEYS.DRY_MULTIPLIER,
|
||||
label: 'DRY multiplier',
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
paramType: SyncableParameterType.NUMBER,
|
||||
serverKey: SETTINGS_KEYS.DRY_MULTIPLIER
|
||||
},
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: undefined,
|
||||
help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling base value.',
|
||||
key: SETTINGS_KEYS.DRY_BASE,
|
||||
label: 'DRY base',
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: { paramType: SyncableParameterType.NUMBER, serverKey: SETTINGS_KEYS.DRY_BASE },
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: undefined,
|
||||
help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the allowed length for DRY sampling.',
|
||||
key: SETTINGS_KEYS.DRY_ALLOWED_LENGTH,
|
||||
label: 'DRY allowed length',
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
paramType: SyncableParameterType.NUMBER,
|
||||
serverKey: SETTINGS_KEYS.DRY_ALLOWED_LENGTH
|
||||
},
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
defaultValue: undefined,
|
||||
help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets DRY penalty for the last n tokens.',
|
||||
key: SETTINGS_KEYS.DRY_PENALTY_LAST_N,
|
||||
label: 'DRY penalty last N',
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
paramType: SyncableParameterType.NUMBER,
|
||||
serverKey: SETTINGS_KEYS.DRY_PENALTY_LAST_N
|
||||
},
|
||||
type: SettingsFieldType.INPUT
|
||||
}
|
||||
],
|
||||
slug: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
title: SETTINGS_SECTION_TITLES.PENALTIES
|
||||
},
|
||||
// Developer
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Counterpart of the conversation title radio; stored and synced without a dedicated UI field.',
|
||||
key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
|
||||
label: 'Generate title with LLM',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
icon: Code,
|
||||
settings: [
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'After each response, re-submit the conversation to pre-fill the server KV cache. Makes the next turn faster since the prompt is already encoded while you read the response.',
|
||||
key: SETTINGS_KEYS.PRE_ENCODE_CONVERSATION,
|
||||
label: 'Pre-fill KV cache after response',
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Send reasoning_format=none so the server returns thinking tokens inline instead of extracting them into a separate field.',
|
||||
key: SETTINGS_KEYS.DISABLE_REASONING_PARSING,
|
||||
label: 'Disable reasoning content parsing',
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Strip thinking from previous messages before sending. When off, thinking is sent back via the reasoning_content field so the model sees its own chain-of-thought across turns.',
|
||||
key: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT,
|
||||
label: 'Exclude reasoning from context',
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Show toggle button to display messages as plain text instead of Markdown-formatted content',
|
||||
key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
|
||||
label: 'Enable raw output toggle',
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Expose a run_javascript tool to the model. Code runs in a Web Worker inside a sandboxed iframe with an opaque origin, isolated from the WebUI and its API, with a hard timeout.',
|
||||
key: SETTINGS_KEYS.JS_SANDBOX_ENABLED,
|
||||
label: 'JavaScript sandbox tool',
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
dependsOn: SETTINGS_KEYS.JS_SANDBOX_ENABLED,
|
||||
help: 'Pre-load nerdamer in the sandbox for symbolic computation: simplify, diff, integrate, solve, and more. Requires "JavaScript sandbox tool" to be enabled.',
|
||||
key: SETTINGS_KEYS.SYMBOLIC_MATH_ENABLED,
|
||||
label: 'Symbolic math (nerdamer)',
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: '',
|
||||
help: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
|
||||
key: SETTINGS_KEYS.CUSTOM_JSON,
|
||||
label: 'Custom JSON',
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
type: SettingsFieldType.TEXTAREA
|
||||
},
|
||||
{
|
||||
defaultValue: '',
|
||||
help: 'CSS injected into the page at runtime. Set it here, or ship it server side via the --ui-config customCss field.',
|
||||
key: SETTINGS_KEYS.CUSTOM_CSS,
|
||||
label: 'Custom CSS',
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
type: SettingsFieldType.TEXTAREA
|
||||
}
|
||||
],
|
||||
slug: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
title: SETTINGS_SECTION_TITLES.DEVELOPER
|
||||
}
|
||||
// {
|
||||
// key: SETTINGS_KEYS.PY_INTERPRETER_ENABLED,
|
||||
// label: 'Python interpreter enabled',
|
||||
// help: 'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.',
|
||||
// defaultValue: false,
|
||||
// type: SettingsFieldType.CHECKBOX,
|
||||
// isExperimental: true,
|
||||
//
|
||||
// }
|
||||
];
|
||||
|
||||
function getAllSettings(): SettingsEntry[] {
|
||||
const result: SettingsEntry[] = [];
|
||||
|
||||
for (const section of Object.values(SETTINGS_REGISTRY)) {
|
||||
for (const section of SETTINGS_REGISTRY) {
|
||||
result.push(...section.settings);
|
||||
}
|
||||
result.push(...NON_UI_SETTINGS);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -703,52 +724,41 @@ export const SETTING_CONFIG_INFO: Record<string, string> = Object.fromEntries(
|
||||
getAllSettings().map((s) => [s.key, s.help])
|
||||
) as Record<string, string>;
|
||||
|
||||
/** Theme select options. */
|
||||
export const SETTINGS_COLOR_MODES_CONFIG = COLOR_MODE_OPTIONS;
|
||||
|
||||
/** Sidebar sections + field configs (as consumed by UI). */
|
||||
export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
|
||||
...Object.values(SETTINGS_REGISTRY).map((section) => ({
|
||||
fields: section.settings.map((s) => ({
|
||||
dependsOn: s.dependsOn,
|
||||
help: s.help,
|
||||
isExperimental: s.isExperimental,
|
||||
isPositiveInteger: s.isPositiveInteger,
|
||||
isPrivate: s.isPrivate,
|
||||
key: s.key,
|
||||
label: s.label,
|
||||
max: s.max,
|
||||
min: s.min,
|
||||
options: s.options,
|
||||
placeholder: s.placeholder,
|
||||
radioOptions: s.radioOptions,
|
||||
type: s.type
|
||||
})),
|
||||
function toSettingsSection(section: SettingsSectionEntry): SettingsSection {
|
||||
return {
|
||||
fields: section.settings
|
||||
.filter((s) => s.standaloneField !== false)
|
||||
.map((s) => ({
|
||||
dependsOn: s.dependsOn,
|
||||
help: s.help,
|
||||
isExperimental: s.isExperimental,
|
||||
isPositiveInteger: s.isPositiveInteger,
|
||||
isPrivate: s.isPrivate,
|
||||
key: s.key,
|
||||
label: s.label,
|
||||
max: s.max,
|
||||
min: s.min,
|
||||
options: s.options as SettingsFieldConfig['options'],
|
||||
placeholder: s.placeholder,
|
||||
radioOptions: s.radioOptions,
|
||||
type: s.type
|
||||
})),
|
||||
icon: section.icon,
|
||||
slug: section.slug,
|
||||
title: section.title
|
||||
})),
|
||||
...STANDALONE_SECTIONS
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
/** Sidebar sections in custom display order (the registry array order). */
|
||||
export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = SETTINGS_REGISTRY.map(toSettingsSection);
|
||||
|
||||
/** INPUT-type settings whose value is a number. */
|
||||
export const NUMERIC_FIELDS = getAllSettings()
|
||||
.filter((s) => s.type === SettingsFieldType.INPUT && typeof s.defaultValue !== 'string')
|
||||
.map((s) => s.key) as readonly string[];
|
||||
|
||||
/** Numeric fields clamped to ≥ 1 and rounded. */
|
||||
/** Numeric fields clamped to >= 1 and rounded. */
|
||||
export const POSITIVE_INTEGER_FIELDS = getAllSettings()
|
||||
.filter((s) => s.isPositiveInteger)
|
||||
.map((s) => s.key) as readonly string[];
|
||||
|
||||
/** Derived for the parameter sync service. */
|
||||
export const SYNCABLE_PARAMETERS: SyncableParameter[] = getAllSettings()
|
||||
.filter((s) => s.sync !== undefined)
|
||||
.map((s) => ({
|
||||
canSync: true,
|
||||
key: s.key,
|
||||
serverKey: s.sync!.serverKey,
|
||||
type: s.sync!.paramType
|
||||
}));
|
||||
|
||||
export const SETTINGS_FALLBACK_EXIT_ROUTE = ROUTES.START;
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { chatStore } from '$lib/stores';
|
||||
import { isImportFileByExtension } from '$lib/utils/import-file.utils';
|
||||
|
||||
interface UseChatScreenDragAndDropOptions {
|
||||
/** Called when the user drops files and no message is being edited. */
|
||||
@@ -49,6 +50,20 @@ export function useChatScreenDragAndDrop(options: UseChatScreenDragAndDropOption
|
||||
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
|
||||
// Defer import files (conversation/settings exports) to the global
|
||||
// drag-and-drop import handler instead of attaching them to a message.
|
||||
if (files.some(isImportFileByExtension)) {
|
||||
console.log(
|
||||
'[chat-drop] deferring import files:',
|
||||
files.map((f) => f.name)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop bubbling so the global import handler ignores this attachment.
|
||||
event.stopPropagation();
|
||||
|
||||
if (chatStore.isEditing()) {
|
||||
const handler = chatStore.getAddFilesHandler();
|
||||
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Global drag-and-drop import state machine.
|
||||
*
|
||||
* Tracks pointer enter/leave nesting so the overlay stays visible while the
|
||||
* cursor traverses child elements, then routes dropped files to the right
|
||||
* importer: settings files (JSON with a `config` key) are restored directly,
|
||||
* while conversation files (JSONL/ZIP/JSON) go through the existing
|
||||
* selection dialog before asking whether to open the result.
|
||||
*/
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import type { SettingsDiffEntry } from '$lib/components/app/dialogs/DialogSettingsImportPreview.svelte';
|
||||
import { ZIP_MAGIC } from '$lib/constants';
|
||||
import { SETTINGS_REGISTRY } from '$lib/constants/settings.constants';
|
||||
import { ConversationTransferService, RouterService } from '$lib/services';
|
||||
import { conversationsStore, settingsStore } from '$lib/stores';
|
||||
import type { SettingsConfigType, SettingsExportType } from '$lib/types';
|
||||
import { createMessageCountMap } from '$lib/utils';
|
||||
import { strFromU8 } from 'fflate';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
type FileKind = 'settings' | 'conversations';
|
||||
|
||||
/**
|
||||
* Detects whether a dropped file holds settings or conversations.
|
||||
* Settings files are JSON objects carrying a `config` key; everything else
|
||||
* (ZIP archives, JSONL sessions, legacy JSON) is treated as conversations.
|
||||
*/
|
||||
async function classifyFile(file: File): Promise<FileKind> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
|
||||
if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) {
|
||||
return 'conversations';
|
||||
}
|
||||
|
||||
const text = strFromU8(bytes);
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
if (parsed && typeof parsed === 'object' && 'config' in parsed) {
|
||||
return 'settings';
|
||||
}
|
||||
} catch {
|
||||
// Not a JSON object, so not a settings file.
|
||||
}
|
||||
|
||||
return 'conversations';
|
||||
}
|
||||
|
||||
export function computeSettingsDiff(
|
||||
current: SettingsConfigType,
|
||||
imported: SettingsConfigType
|
||||
): SettingsDiffEntry[] {
|
||||
const labels = new SvelteMap<string, string>();
|
||||
|
||||
for (const section of SETTINGS_REGISTRY) {
|
||||
for (const setting of section.settings) {
|
||||
labels.set(setting.key, setting.label);
|
||||
}
|
||||
}
|
||||
|
||||
const keys = new SvelteSet([...Object.keys(current), ...Object.keys(imported)]);
|
||||
const diff: SettingsDiffEntry[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const from = current[key];
|
||||
const to = imported[key];
|
||||
|
||||
if (from !== to) {
|
||||
diff.push({ from, key, label: labels.get(key) ?? key, to });
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
export function useDropImport() {
|
||||
let dragCounter = $state(0);
|
||||
let isDragOver = $state(false);
|
||||
|
||||
// All dialog state lives in one reactive object so it stays reactive when
|
||||
// exposed through the hook and bound from the layout.
|
||||
const ui = $state({
|
||||
availableConversations: [] as DatabaseConversation[],
|
||||
bulkMessageCountMap: new SvelteMap() as SvelteMap<string, number>,
|
||||
fullImportData: [] as ExportedConversation[],
|
||||
importedConversations: [] as DatabaseConversation[],
|
||||
previewData: null as ExportedConversation | null,
|
||||
selectionMessageCountMap: new SvelteMap() as SvelteMap<string, number>,
|
||||
settingsData: null as SettingsExportType | null,
|
||||
settingsDiff: [] as SettingsDiffEntry[],
|
||||
// Bulk result dialog: pick one of the imported conversations to open.
|
||||
showOpenBulk: false,
|
||||
// Single conversation preview dialog (confirm before importing).
|
||||
showPreview: false,
|
||||
// Selection dialog (the existing import flow) for multiple conversations.
|
||||
showSelection: false,
|
||||
// Settings import preview dialog (review diff before applying).
|
||||
showSettingsPreview: false
|
||||
});
|
||||
|
||||
function handleDragEnter(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
dragCounter++;
|
||||
|
||||
if (event.dataTransfer?.types.includes('Files')) {
|
||||
isDragOver = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
dragCounter--;
|
||||
|
||||
if (dragCounter === 0) {
|
||||
isDragOver = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
async function handleDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
isDragOver = false;
|
||||
dragCounter = 0;
|
||||
|
||||
if (!event.dataTransfer?.files) return;
|
||||
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
|
||||
await processFiles(files);
|
||||
}
|
||||
|
||||
async function processFiles(files: File[]) {
|
||||
const allConversations: ExportedConversation[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const kind = await classifyFile(file);
|
||||
|
||||
if (kind === 'settings') {
|
||||
try {
|
||||
const data = JSON.parse(await file.text());
|
||||
|
||||
if (data?.config) {
|
||||
ui.settingsData = data as SettingsExportType;
|
||||
ui.settingsDiff = computeSettingsDiff(
|
||||
$state.snapshot(settingsStore.config) as SettingsConfigType,
|
||||
data.config
|
||||
);
|
||||
ui.showSettingsPreview = true;
|
||||
} else {
|
||||
toast.error(`Invalid settings file: ${file.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to import settings:', err);
|
||||
toast.error(`Failed to import settings from ${file.name}`);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const parsed = await ConversationTransferService.parseImportFile(file);
|
||||
|
||||
allConversations.push(...parsed);
|
||||
} catch (err) {
|
||||
console.error('Failed to parse file:', err);
|
||||
toast.error(`Failed to parse ${file.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allConversations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (allConversations.length === 1) {
|
||||
ui.previewData = allConversations[0];
|
||||
ui.showPreview = true;
|
||||
} else {
|
||||
ui.fullImportData = allConversations;
|
||||
ui.availableConversations = allConversations.map((item) => item.conv);
|
||||
ui.selectionMessageCountMap = new SvelteMap(createMessageCountMap(allConversations));
|
||||
ui.showSelection = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSettingsImport() {
|
||||
const data = ui.settingsData;
|
||||
|
||||
if (!data) return;
|
||||
|
||||
try {
|
||||
settingsStore.importSettings(data);
|
||||
ui.showSettingsPreview = false;
|
||||
toast.success('Settings imported successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to import settings:', err);
|
||||
toast.error('Failed to import settings');
|
||||
}
|
||||
}
|
||||
|
||||
function cancelSettingsImport() {
|
||||
ui.showSettingsPreview = false;
|
||||
}
|
||||
|
||||
async function confirmImportSingle() {
|
||||
const data = ui.previewData;
|
||||
|
||||
if (!data) return;
|
||||
|
||||
try {
|
||||
await conversationsStore.importConversationsData([data]);
|
||||
|
||||
ui.showPreview = false;
|
||||
goto(RouterService.chat(data.conv.id));
|
||||
} catch (err) {
|
||||
console.error('Failed to import conversation:', err);
|
||||
toast.error('Failed to import conversation');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectionConfirm(selectedConversations: DatabaseConversation[]) {
|
||||
try {
|
||||
const selectedIds = new SvelteSet(selectedConversations.map((c) => c.id));
|
||||
const selectedData = ($state.snapshot(ui.fullImportData) as ExportedConversation[]).filter(
|
||||
(item) => selectedIds.has(item.conv.id)
|
||||
);
|
||||
|
||||
await conversationsStore.importConversationsData(selectedData);
|
||||
|
||||
ui.importedConversations = selectedConversations;
|
||||
ui.bulkMessageCountMap = new SvelteMap(createMessageCountMap(selectedData));
|
||||
ui.showSelection = false;
|
||||
ui.showOpenBulk = true;
|
||||
} catch (err) {
|
||||
console.error('Import failed:', err);
|
||||
toast.error('Failed to import conversations');
|
||||
}
|
||||
}
|
||||
|
||||
function openConversation(conversation: DatabaseConversation) {
|
||||
ui.showOpenBulk = false;
|
||||
goto(RouterService.chat(conversation.id));
|
||||
}
|
||||
|
||||
function cancelPreview() {
|
||||
ui.showPreview = false;
|
||||
}
|
||||
|
||||
function cancelBulk() {
|
||||
ui.showOpenBulk = false;
|
||||
}
|
||||
|
||||
function cancelSelection() {
|
||||
ui.showSelection = false;
|
||||
}
|
||||
|
||||
return {
|
||||
cancelBulk,
|
||||
cancelPreview,
|
||||
cancelSelection,
|
||||
cancelSettingsImport,
|
||||
confirmImportSingle,
|
||||
confirmSettingsImport,
|
||||
dragHandlers: {
|
||||
dragenter: handleDragEnter,
|
||||
dragleave: handleDragLeave,
|
||||
dragover: handleDragOver,
|
||||
drop: handleDrop
|
||||
},
|
||||
handleSelectionConfirm,
|
||||
get isDragOver() {
|
||||
return isDragOver;
|
||||
},
|
||||
openConversation,
|
||||
ui
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { getConversationModel } from '$lib/utils';
|
||||
export interface UseReasoningMenuReturn {
|
||||
readonly modelSupportsThinking: boolean;
|
||||
readonly thinkingEnabled: boolean;
|
||||
readonly isReasoningActive: boolean;
|
||||
readonly isOff: boolean;
|
||||
readonly currentEffort: ReasoningEffort;
|
||||
readonly levels: ReasoningEffortLevel[];
|
||||
@@ -59,6 +60,12 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
|
||||
const thinkingEnabled = $derived(
|
||||
currentEffort !== ReasoningEffort.OFF && currentEffort !== ReasoningEffort.DEFAULT
|
||||
);
|
||||
// Thinking is effectively on (lightbulb lit) either when an explicit effort
|
||||
// is selected, or when the effort is left at "Default" and the model
|
||||
// supports thinking.
|
||||
const isReasoningActive = $derived(
|
||||
thinkingEnabled || (currentEffort === ReasoningEffort.DEFAULT && modelSupportsThinking)
|
||||
);
|
||||
|
||||
return {
|
||||
get currentEffort() {
|
||||
@@ -67,6 +74,9 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
|
||||
get isOff() {
|
||||
return currentEffort === ReasoningEffort.OFF;
|
||||
},
|
||||
get isReasoningActive() {
|
||||
return isReasoningActive;
|
||||
},
|
||||
isSelected(level: ReasoningEffortLevel): boolean {
|
||||
return currentEffort === level.value;
|
||||
},
|
||||
|
||||
@@ -340,3 +340,13 @@ export { RouterService } from './router.service';
|
||||
* @see migration.service.ts — full implementation (non-destructive)
|
||||
*/
|
||||
export { MigrationService } from './migration.service';
|
||||
|
||||
/**
|
||||
* **SettingsService** - localStorage persistence layer for settings
|
||||
*
|
||||
* Stateless read/write of the settings config and user-override keys. Business
|
||||
* logic (default merging, mobile defaults, theme migration) stays in the store.
|
||||
*
|
||||
* @see settingsStore in stores/settings/index.svelte.ts - reactive state + business logic
|
||||
*/
|
||||
export { SettingsService } from './settings.service';
|
||||
|
||||
@@ -187,8 +187,17 @@ export class ModelsService {
|
||||
|
||||
// 6. Model name = segments before params; tags = remaining segments after params
|
||||
const pivotIdx = paramsIdx !== MODEL_ID.NOT_FOUND ? paramsIdx : segments.length;
|
||||
const modelSegments = segments.slice(0, pivotIdx);
|
||||
|
||||
result.modelName = segments.slice(0, pivotIdx).join(MODEL_ID.SEGMENT_SEPARATOR) || null;
|
||||
// strip trailing container-format segments (e.g. GGUF) from the model name
|
||||
while (
|
||||
modelSegments.length > 0 &&
|
||||
MODEL_ID.IGNORED_SEGMENTS.has(modelSegments[modelSegments.length - 1].toUpperCase())
|
||||
) {
|
||||
modelSegments.pop();
|
||||
}
|
||||
|
||||
result.modelName = modelSegments.join(MODEL_ID.SEGMENT_SEPARATOR) || null;
|
||||
|
||||
if (paramsIdx !== MODEL_ID.NOT_FOUND) {
|
||||
result.tags = segments.slice(paramsIdx + 1).filter((_, relIdx) => {
|
||||
|
||||
@@ -6,11 +6,23 @@
|
||||
* No reactive state; consumed by settingsStore.
|
||||
*/
|
||||
|
||||
import { SETTINGS_KEYS, SYNCABLE_PARAMETERS } from '$lib/constants';
|
||||
import { SETTINGS_KEYS, SETTINGS_REGISTRY } from '$lib/constants';
|
||||
import { ParameterSource, SyncableParameterType } from '$lib/enums';
|
||||
import type { ParameterInfo, ParameterRecord, ParameterValue } from '$lib/types';
|
||||
import type { ParameterInfo, ParameterRecord, ParameterValue, SyncableParameter } from '$lib/types';
|
||||
import { normalizeFloatingPoint } from '$lib/utils';
|
||||
|
||||
/** Mapping of UI setting keys to server parameter keys, derived from the registry. */
|
||||
export const SYNCABLE_PARAMETERS: SyncableParameter[] = SETTINGS_REGISTRY.flatMap(
|
||||
(section) => section.settings
|
||||
)
|
||||
.filter((s) => s.sync !== undefined)
|
||||
.map((s) => ({
|
||||
canSync: true,
|
||||
key: s.key,
|
||||
serverKey: s.sync!.serverKey,
|
||||
type: s.sync!.paramType
|
||||
}));
|
||||
|
||||
export class ParameterSyncService {
|
||||
/**
|
||||
* Check if a parameter can be synced from server.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { CONFIG_LOCALSTORAGE_KEY, USER_OVERRIDES_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
|
||||
/**
|
||||
* SettingsService - localStorage persistence layer for settings
|
||||
*
|
||||
* Stateless read/write of the settings config and user-override keys. Business
|
||||
* logic (default merging, mobile defaults, theme migration) stays in the store.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **settingsStore**: Primary consumer - loads config on init and persists on change
|
||||
*
|
||||
* @see settingsStore in stores/settings/index.svelte.ts - reactive state + business logic
|
||||
*/
|
||||
export class SettingsService {
|
||||
/**
|
||||
* Read the raw config and user overrides from localStorage.
|
||||
* @returns Parsed values, or empty defaults when nothing is stored or parsing fails.
|
||||
*/
|
||||
static loadConfig(): {
|
||||
config: Record<string, unknown>;
|
||||
userOverrides: string[];
|
||||
isFirstVisit: boolean;
|
||||
} {
|
||||
if (!browser) {
|
||||
return { config: {}, isFirstVisit: false, userOverrides: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
|
||||
const isFirstVisit = storedConfigRaw === null;
|
||||
const config = JSON.parse(storedConfigRaw || '{}') as Record<string, unknown>;
|
||||
const userOverrides = JSON.parse(
|
||||
localStorage.getItem(USER_OVERRIDES_LOCALSTORAGE_KEY) || '[]'
|
||||
) as string[];
|
||||
|
||||
return { config, isFirstVisit, userOverrides };
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse config from localStorage, using defaults:', error);
|
||||
|
||||
return { config: {}, isFirstVisit: false, userOverrides: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate the legacy un-namespaced "theme" localStorage key.
|
||||
* Returns the legacy theme value (and removes the key) when present, else null.
|
||||
*/
|
||||
static migrateLegacyTheme(): string | null {
|
||||
if (!browser) return null;
|
||||
|
||||
const legacyTheme = localStorage.getItem('theme');
|
||||
|
||||
if (legacyTheme) {
|
||||
localStorage.removeItem('theme');
|
||||
|
||||
return legacyTheme;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the config and user overrides to localStorage.
|
||||
*/
|
||||
static saveConfig(config: Record<string, unknown>, userOverrides: string[]): void {
|
||||
if (!browser) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
|
||||
localStorage.setItem(USER_OVERRIDES_LOCALSTORAGE_KEY, JSON.stringify(userOverrides));
|
||||
} catch (error) {
|
||||
console.error('Failed to save config to localStorage:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,10 @@
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
CONFIG_LOCALSTORAGE_KEY,
|
||||
SETTING_CONFIG_DEFAULT,
|
||||
SETTINGS_KEYS,
|
||||
USER_OVERRIDES_LOCALSTORAGE_KEY
|
||||
} from '$lib/constants';
|
||||
import { SETTING_CONFIG_DEFAULT, SETTINGS_KEYS } from '$lib/constants';
|
||||
import { ColorMode } from '$lib/enums';
|
||||
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
|
||||
import { SettingsService } from '$lib/services/settings.service';
|
||||
import { deviceStore } from '$lib/stores/device.svelte';
|
||||
// direct imports between stores, not via the barrel, to avoid circular deps
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
@@ -428,45 +424,37 @@ class SettingsStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from localStorage
|
||||
* Returns default values for missing keys to prevent breaking changes
|
||||
* Load configuration from localStorage via the persistence service.
|
||||
* Returns default values for missing keys to prevent breaking changes.
|
||||
*/
|
||||
private loadConfig() {
|
||||
if (!browser) return;
|
||||
|
||||
try {
|
||||
const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
|
||||
const {
|
||||
config: savedVal,
|
||||
isFirstVisit,
|
||||
userOverrides: savedOverrides
|
||||
} = SettingsService.loadConfig();
|
||||
|
||||
// First visit: no stored config yet. Server ui_settings apply once in
|
||||
// this state, then the user's config diverges freely.
|
||||
this.isFirstVisit = storedConfigRaw === null;
|
||||
// First visit: no stored config yet. Server ui_settings apply once in
|
||||
// this state, then the user's config diverges freely.
|
||||
this.isFirstVisit = isFirstVisit;
|
||||
|
||||
const savedVal = JSON.parse(storedConfigRaw || '{}');
|
||||
// Merge with defaults to prevent breaking changes
|
||||
this.config = {
|
||||
...SETTING_CONFIG_DEFAULT,
|
||||
...savedVal
|
||||
};
|
||||
|
||||
// Merge with defaults to prevent breaking changes
|
||||
this.config = {
|
||||
...SETTING_CONFIG_DEFAULT,
|
||||
...savedVal
|
||||
};
|
||||
|
||||
// Default sendOnEnter to false on mobile when the user has no saved preference
|
||||
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
|
||||
if (deviceStore.isMobile) {
|
||||
this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false;
|
||||
}
|
||||
// Default sendOnEnter to false on mobile when the user has no saved preference
|
||||
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
|
||||
if (deviceStore.isMobile) {
|
||||
this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false;
|
||||
}
|
||||
|
||||
// Load user overrides
|
||||
const savedOverrides = JSON.parse(
|
||||
localStorage.getItem(USER_OVERRIDES_LOCALSTORAGE_KEY) || '[]'
|
||||
);
|
||||
|
||||
this.userOverrides = new Set(savedOverrides);
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse config from localStorage, using defaults:', error);
|
||||
this.config = { ...SETTING_CONFIG_DEFAULT };
|
||||
this.userOverrides = new Set();
|
||||
}
|
||||
|
||||
// Load user overrides
|
||||
this.userOverrides = new Set(savedOverrides);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -478,32 +466,22 @@ class SettingsStore {
|
||||
private migrateLegacyTheme() {
|
||||
if (!browser) return;
|
||||
|
||||
const legacyTheme = localStorage.getItem('theme');
|
||||
const legacyTheme = SettingsService.migrateLegacyTheme();
|
||||
|
||||
if (legacyTheme) {
|
||||
this.config[SETTINGS_KEYS.THEME] = legacyTheme;
|
||||
localStorage.removeItem('theme');
|
||||
this.saveConfig();
|
||||
setMode(legacyTheme as ColorMode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current configuration to localStorage
|
||||
* Save the current configuration to localStorage via the persistence service.
|
||||
*/
|
||||
private saveConfig() {
|
||||
if (!browser) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(this.config));
|
||||
|
||||
localStorage.setItem(
|
||||
USER_OVERRIDES_LOCALSTORAGE_KEY,
|
||||
JSON.stringify(Array.from(this.userOverrides))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to save config to localStorage:', error);
|
||||
}
|
||||
SettingsService.saveConfig(this.config, Array.from(this.userOverrides));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
* there after a fallback exit. Standalone reactive value, no host.
|
||||
*/
|
||||
|
||||
import { SETTINGS_FALLBACK_EXIT_ROUTE } from '$lib/constants';
|
||||
import { ROUTES } from '$lib/constants';
|
||||
|
||||
let _url = $state<string>(SETTINGS_FALLBACK_EXIT_ROUTE);
|
||||
let _url = $state<string>(ROUTES.SETTINGS_EXIT);
|
||||
|
||||
export const settingsReferrer = {
|
||||
get url() {
|
||||
|
||||
Vendored
+3
@@ -31,7 +31,10 @@ export interface SettingsEntry {
|
||||
radioOptions?: Array<{ value: string; label: string; key: string; isExperimental?: boolean }>;
|
||||
isExperimental?: boolean;
|
||||
isPositiveInteger?: boolean;
|
||||
/** When true, the field is rendered as a password input (e.g. API key). */
|
||||
isPrivate?: boolean;
|
||||
/** When false, the setting is stored/synced but has no standalone field; it is rendered by a sibling control or a dedicated page. */
|
||||
standaloneField?: boolean;
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Helpers for deciding whether a dropped file is an import file
|
||||
* (conversation/settings export) rather than a plain attachment.
|
||||
*
|
||||
* The chat screen attaches arbitrary files to messages, while the global
|
||||
* drag-and-drop handler imports conversations and settings. Exported
|
||||
* conversations are `.jsonl` (single) or `.zip` (archive), and settings are
|
||||
* `.json`, so the extension is a reliable way to route a drop to the importer.
|
||||
*/
|
||||
|
||||
const IMPORT_FILE_EXTENSIONS = ['.zip', '.jsonl', '.json'];
|
||||
|
||||
export function isImportFileByExtension(file: File): boolean {
|
||||
const name = file.name.toLowerCase();
|
||||
|
||||
return IMPORT_FILE_EXTENSIONS.some((ext) => name.endsWith(ext));
|
||||
}
|
||||
@@ -4,7 +4,14 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { SidebarNavigation } from '$lib/components/app';
|
||||
import {
|
||||
DialogConversationSelection,
|
||||
DialogImportConversationPreview,
|
||||
DialogImportConversationsResult,
|
||||
DialogSettingsImportPreview,
|
||||
DropImportOverlay,
|
||||
SidebarNavigation
|
||||
} from '$lib/components/app';
|
||||
import { PwaMetaTags, PwaRefreshAlert } from '$lib/components/pwa';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import {
|
||||
@@ -15,6 +22,7 @@
|
||||
SETTINGS_KEYS,
|
||||
TOOLTIP_DELAY_DURATION
|
||||
} from '$lib/constants';
|
||||
import { useDropImport } from '$lib/hooks/use-drop-import.svelte';
|
||||
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
|
||||
import { usePwa } from '$lib/hooks/use-pwa.svelte';
|
||||
import { RouterService } from '$lib/services/router.service';
|
||||
@@ -100,6 +108,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Global drag-and-drop import (works on any route)
|
||||
const dropImport = useDropImport();
|
||||
|
||||
// Global keyboard shortcuts
|
||||
const { handleKeydown } = useKeyboardShortcuts({
|
||||
editActiveConversation: () => chatSidebar?.editActiveConversation?.(),
|
||||
@@ -272,7 +283,13 @@
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} bind:innerHeight bind:innerWidth />
|
||||
<svelte:document onvisibilitychange={handleVisibilityChange} />
|
||||
<svelte:document
|
||||
onvisibilitychange={handleVisibilityChange}
|
||||
ondragenter={dropImport.dragHandlers.dragenter}
|
||||
ondragleave={dropImport.dragHandlers.dragleave}
|
||||
ondragover={dropImport.dragHandlers.dragover}
|
||||
ondrop={dropImport.dragHandlers.drop}
|
||||
/>
|
||||
|
||||
<Tooltip.Provider delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<div class="flex flex-col md:flex-row">
|
||||
@@ -294,6 +311,42 @@
|
||||
<ModeWatcher />
|
||||
|
||||
<Toaster richColors />
|
||||
|
||||
{#if dropImport.isDragOver}
|
||||
<DropImportOverlay />
|
||||
{/if}
|
||||
|
||||
<DialogConversationSelection
|
||||
bind:open={dropImport.ui.showSelection}
|
||||
conversations={dropImport.ui.availableConversations}
|
||||
messageCountMap={dropImport.ui.selectionMessageCountMap}
|
||||
mode="import"
|
||||
onConfirm={dropImport.handleSelectionConfirm}
|
||||
onCancel={dropImport.cancelSelection}
|
||||
/>
|
||||
|
||||
<DialogSettingsImportPreview
|
||||
bind:open={dropImport.ui.showSettingsPreview}
|
||||
diff={dropImport.ui.settingsDiff}
|
||||
onConfirm={dropImport.confirmSettingsImport}
|
||||
onCancel={dropImport.cancelSettingsImport}
|
||||
/>
|
||||
|
||||
<DialogImportConversationPreview
|
||||
bind:open={dropImport.ui.showPreview}
|
||||
conversationName={dropImport.ui.previewData?.conv.name}
|
||||
messages={dropImport.ui.previewData?.messages}
|
||||
onConfirm={dropImport.confirmImportSingle}
|
||||
onCancel={dropImport.cancelPreview}
|
||||
/>
|
||||
|
||||
<DialogImportConversationsResult
|
||||
bind:open={dropImport.ui.showOpenBulk}
|
||||
conversations={dropImport.ui.importedConversations}
|
||||
messageCountMap={dropImport.ui.bulkMessageCountMap}
|
||||
onOpen={dropImport.openConversation}
|
||||
onClose={dropImport.cancelBulk}
|
||||
/>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<!-- PWA update prompt + version -->
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
import { SETTINGS_FALLBACK_EXIT_ROUTE } from '$lib/constants';
|
||||
import { ROUTES } from '$lib/constants';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
if (browser && window.history.length > 1 && !prevIsSettings) {
|
||||
history.back();
|
||||
} else {
|
||||
goto(SETTINGS_FALLBACK_EXIT_ROUTE);
|
||||
goto(ROUTES.SETTINGS_EXIT);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -97,6 +97,38 @@ describe('parseModelId', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('strips trailing container format segments from model names', () => {
|
||||
expect(parseModelId('unsloth/DeepSeek-V4-Flash-0731-GGUF:Q2_K_XL')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'DeepSeek-V4-Flash-0731',
|
||||
orgName: 'unsloth',
|
||||
params: null,
|
||||
quantization: 'Q2_K_XL',
|
||||
raw: 'unsloth/DeepSeek-V4-Flash-0731-GGUF:Q2_K_XL',
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('unsloth/Laguna-S-2.1-GGUF:Q4_K_XL')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Laguna-S-2.1',
|
||||
orgName: 'unsloth',
|
||||
params: null,
|
||||
quantization: 'Q4_K_XL',
|
||||
raw: 'unsloth/Laguna-S-2.1-GGUF:Q4_K_XL',
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('org/Model-Name-GGUF')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Model-Name',
|
||||
orgName: 'org',
|
||||
params: null,
|
||||
quantization: null,
|
||||
raw: 'org/Model-Name-GGUF',
|
||||
tags: []
|
||||
});
|
||||
});
|
||||
|
||||
it('handles real-world examples correctly', () => {
|
||||
expect(parseModelId('meta-llama/Llama-3.1-8B')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
|
||||
Reference in New Issue
Block a user