webui: Improvements for Models Selector UI (#20066)

This commit is contained in:
Aleksander Grygier
2026-03-05 08:52:22 +01:00
committed by GitHub
parent 92f7da00b4
commit 5e335ba113
54 changed files with 769 additions and 226 deletions
Binary file not shown.
@@ -8,6 +8,7 @@
tooltip: string; tooltip: string;
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon'; size?: 'default' | 'sm' | 'lg' | 'icon';
iconSize?: string;
class?: string; class?: string;
disabled?: boolean; disabled?: boolean;
onclick: () => void; onclick: () => void;
@@ -21,6 +22,7 @@
size = 'sm', size = 'sm',
class: className = '', class: className = '',
disabled = false, disabled = false,
iconSize = 'h-3 w-3',
onclick, onclick,
'aria-label': ariaLabel 'aria-label': ariaLabel
}: Props = $props(); }: Props = $props();
@@ -38,7 +40,7 @@
> >
{@const IconComponent = icon} {@const IconComponent = icon}
<IconComponent class="h-3 w-3" /> <IconComponent class={iconSize} />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ModelModality } from '$lib/enums'; import { ModelModality } from '$lib/enums';
import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants/icons'; import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants';
import { cn } from '$lib/components/ui/utils'; import { cn } from '$lib/components/ui/utils';
type DisplayableModality = ModelModality.VISION | ModelModality.AUDIO; type DisplayableModality = ModelModality.VISION | ModelModality.AUDIO;
@@ -5,9 +5,11 @@
ChatFormFileInputInvisible, ChatFormFileInputInvisible,
ChatFormTextarea ChatFormTextarea
} from '$lib/components/app'; } from '$lib/components/app';
import { INPUT_CLASSES } from '$lib/constants/css-classes'; import {
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config'; CLIPBOARD_CONTENT_QUOTE_PREFIX,
import { CLIPBOARD_CONTENT_QUOTE_PREFIX } from '$lib/constants/chat-form'; INPUT_CLASSES,
SETTING_CONFIG_DEFAULT
} from '$lib/constants';
import { KeyboardKey, MimeTypeText } from '$lib/enums'; import { KeyboardKey, MimeTypeText } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte'; import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
@@ -4,8 +4,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { FILE_TYPE_ICONS } from '$lib/constants/icons'; import { FILE_TYPE_ICONS, TOOLTIP_DELAY_DURATION } from '$lib/constants';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
interface Props { interface Props {
class?: string; class?: string;
@@ -11,7 +11,7 @@
import { getFileTypeCategory } from '$lib/utils'; import { getFileTypeCategory } from '$lib/utils';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte'; import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte'; import { isRouterMode, serverError } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte'; import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte'; import { activeMessages } from '$lib/stores/conversations.svelte';
@@ -45,6 +45,7 @@
let currentConfig = $derived(config()); let currentConfig = $derived(config());
let isRouter = $derived(isRouterMode()); let isRouter = $derived(isRouterMode());
let isOffline = $derived(!!serverError());
let conversationModel = $derived( let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[]) chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
@@ -55,7 +56,10 @@
$effect(() => { $effect(() => {
if (conversationModel && conversationModel !== previousConversationModel) { if (conversationModel && conversationModel !== previousConversationModel) {
previousConversationModel = conversationModel; previousConversationModel = conversationModel;
modelsStore.selectModelByName(conversationModel);
if (!isRouter || modelsStore.isModelLoaded(conversationModel)) {
modelsStore.selectModelByName(conversationModel);
}
} }
}); });
@@ -168,9 +172,9 @@
<div class="ml-auto flex items-center gap-1.5"> <div class="ml-auto flex items-center gap-1.5">
<ModelsSelector <ModelsSelector
{disabled}
bind:this={selectorModelRef} bind:this={selectorModelRef}
currentModel={conversationModel} currentModel={conversationModel}
disabled={disabled || isOffline}
forceForegroundText={true} forceForegroundText={true}
useGlobalSelection={true} useGlobalSelection={true}
/> />
@@ -5,7 +5,7 @@
import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte'; import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte'; import { conversationsStore } from '$lib/stores/conversations.svelte';
import { DatabaseService } from '$lib/services'; import { DatabaseService } from '$lib/services';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui'; import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
import { MessageRole } from '$lib/enums'; import { MessageRole } from '$lib/enums';
import { import {
ChatMessageAssistant, ChatMessageAssistant,
@@ -16,14 +16,13 @@
import { Check, X } from '@lucide/svelte'; import { Check, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import { INPUT_CLASSES } from '$lib/constants/css-classes'; import { INPUT_CLASSES, REASONING_TAGS } from '$lib/constants';
import { MessageRole, KeyboardKey } from '$lib/enums'; import { MessageRole, KeyboardKey } from '$lib/enums';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { isRouterMode } from '$lib/stores/server.svelte'; import { isRouterMode } from '$lib/stores/server.svelte';
import { modelsStore } from '$lib/stores/models.svelte'; import { modelsStore } from '$lib/stores/models.svelte';
import { ServerModelStatus } from '$lib/enums'; import { ServerModelStatus } from '$lib/enums';
import { REASONING_TAGS } from '$lib/constants/agentic';
interface Props { interface Props {
class?: string; class?: string;
@@ -4,7 +4,7 @@
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { ChatMessageStatsView } from '$lib/enums'; import { ChatMessageStatsView } from '$lib/enums';
import { formatPerformanceTime } from '$lib/utils'; import { formatPerformanceTime } from '$lib/utils';
import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants/formatters'; import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants';
interface Props { interface Props {
predictedTokens?: number; predictedTokens?: number;
@@ -4,7 +4,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { MarkdownContent } from '$lib/components/app'; import { MarkdownContent } from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts'; import { getMessageEditContext } from '$lib/contexts';
import { INPUT_CLASSES } from '$lib/constants/css-classes'; import { INPUT_CLASSES } from '$lib/constants';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { isIMEComposing } from '$lib/utils'; import { isIMEComposing } from '$lib/utils';
import ChatMessageActions from './ChatMessageActions.svelte'; import ChatMessageActions from './ChatMessageActions.svelte';
@@ -12,7 +12,7 @@
} from '$lib/components/app'; } from '$lib/components/app';
import * as Alert from '$lib/components/ui/alert'; import * as Alert from '$lib/components/ui/alert';
import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { INITIAL_SCROLL_DELAY } from '$lib/constants/auto-scroll'; import { INITIAL_SCROLL_DELAY } from '$lib/constants';
import { KeyboardKey } from '$lib/enums'; import { KeyboardKey } from '$lib/enums';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte'; import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import { import {
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { PROCESSING_INFO_TIMEOUT } from '$lib/constants/processing-info'; import { PROCESSING_INFO_TIMEOUT } from '$lib/constants';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte'; import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte'; import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte'; import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte';
@@ -18,15 +18,16 @@
import { config, settingsStore } from '$lib/stores/settings.svelte'; import { config, settingsStore } from '$lib/stores/settings.svelte';
import { import {
SETTINGS_SECTION_TITLES, SETTINGS_SECTION_TITLES,
type SettingsSectionTitle type SettingsSectionTitle,
} from '$lib/constants/settings-sections'; NUMERIC_FIELDS,
POSITIVE_INTEGER_FIELDS,
SETTINGS_COLOR_MODES_CONFIG,
SETTINGS_KEYS
} from '$lib/constants';
import { setMode } from 'mode-watcher'; import { setMode } from 'mode-watcher';
import { ColorMode } from '$lib/enums/ui'; import { ColorMode } from '$lib/enums/ui';
import { SettingsFieldType } from '$lib/enums/settings'; import { SettingsFieldType } from '$lib/enums/settings';
import type { Component } from 'svelte'; import type { Component } from 'svelte';
import { NUMERIC_FIELDS, POSITIVE_INTEGER_FIELDS } from '$lib/constants/settings-fields';
import { SETTINGS_COLOR_MODES_CONFIG } from '$lib/constants/settings-config';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
interface Props { interface Props {
onSave?: () => void; onSave?: () => void;
@@ -133,6 +134,11 @@
key: SETTINGS_KEYS.AUTO_SHOW_SIDEBAR_ON_NEW_CHAT, key: SETTINGS_KEYS.AUTO_SHOW_SIDEBAR_ON_NEW_CHAT,
label: 'Auto-show sidebar on new chat', label: 'Auto-show sidebar on new chat',
type: SettingsFieldType.CHECKBOX type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
label: 'Show raw model names',
type: SettingsFieldType.CHECKBOX
} }
] ]
}, },
@@ -5,8 +5,7 @@
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import { Textarea } from '$lib/components/ui/textarea'; import { Textarea } from '$lib/components/ui/textarea';
import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config'; import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO, SETTINGS_KEYS } from '$lib/constants';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
import { SettingsFieldType } from '$lib/enums/settings'; import { SettingsFieldType } from '$lib/enums/settings';
import { settingsStore } from '$lib/stores/settings.svelte'; import { settingsStore } from '$lib/stores/settings.svelte';
import { ChatSettingsParameterSourceIndicator } from '$lib/components/app'; import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
@@ -22,8 +22,9 @@
IMAGE_NOT_ERROR_BOUND_SELECTOR, IMAGE_NOT_ERROR_BOUND_SELECTOR,
DATA_ERROR_BOUND_ATTR, DATA_ERROR_BOUND_ATTR,
DATA_ERROR_HANDLED_ATTR, DATA_ERROR_HANDLED_ATTR,
BOOL_TRUE_STRING BOOL_TRUE_STRING,
} from '$lib/constants/markdown'; SETTINGS_KEYS
} from '$lib/constants';
import { UrlPrefix } from '$lib/enums'; import { UrlPrefix } from '$lib/enums';
import { FileTypeText } from '$lib/enums/files'; import { FileTypeText } from '$lib/enums/files';
import { import {
@@ -39,7 +40,6 @@
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte'; import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import type { DatabaseMessageExtra } from '$lib/types/database'; import type { DatabaseMessageExtra } from '$lib/types/database';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
interface Props { interface Props {
attachments?: DatabaseMessageExtra[]; attachments?: DatabaseMessageExtra[];
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import { ChatSettings } from '$lib/components/app'; import { ChatSettings } from '$lib/components/app';
import type { SettingsSectionTitle } from '$lib/constants/settings-sections'; import type { SettingsSectionTitle } from '$lib/constants';
interface Props { interface Props {
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Package } from '@lucide/svelte'; import { Package } from '@lucide/svelte';
import { BadgeInfo, ActionIconCopyToClipboard } from '$lib/components/app'; import { BadgeInfo, ActionIconCopyToClipboard } from '$lib/components/app';
import ModelId from './ModelId.svelte';
import { modelsStore } from '$lib/stores/models.svelte'; import { modelsStore } from '$lib/stores/models.svelte';
import { serverStore } from '$lib/stores/server.svelte'; import { serverStore } from '$lib/stores/server.svelte';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
@@ -23,6 +24,7 @@
let model = $derived(modelProp || modelsStore.singleModelName); let model = $derived(modelProp || modelsStore.singleModelName);
let isModelMode = $derived(serverStore.isModelMode); let isModelMode = $derived(serverStore.isModelMode);
let shouldShow = $derived(model && (modelProp !== undefined || isModelMode));
</script> </script>
{#snippet badgeContent()} {#snippet badgeContent()}
@@ -31,7 +33,9 @@
<Package class="h-3 w-3" /> <Package class="h-3 w-3" />
{/snippet} {/snippet}
{model} {#if model}
<ModelId modelId={model} />
{/if}
{#if showCopyIcon} {#if showCopyIcon}
<ActionIconCopyToClipboard text={model || ''} ariaLabel="Copy model name" /> <ActionIconCopyToClipboard text={model || ''} ariaLabel="Copy model name" />
@@ -39,7 +43,7 @@
</BadgeInfo> </BadgeInfo>
{/snippet} {/snippet}
{#if model && isModelMode} {#if shouldShow}
{#if showTooltip} {#if showTooltip}
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger> <Tooltip.Trigger>
@@ -0,0 +1,64 @@
<script lang="ts">
import { ModelsService } from '$lib/services/models.service';
import { config } from '$lib/stores/settings.svelte';
interface Props {
modelId: string;
showOrgName?: boolean;
showRaw?: boolean;
aliases?: string[];
tags?: string[];
class?: string;
}
let {
modelId,
showOrgName = false,
showRaw = undefined,
aliases,
tags,
class: className = ''
}: Props = $props();
const badgeClass =
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25';
const tagBadgeClass =
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground';
let parsed = $derived(ModelsService.parseModelId(modelId));
let resolvedShowRaw = $derived(showRaw ?? (config().showRawModelNames as boolean) ?? false);
</script>
{#if resolvedShowRaw}
<span class="min-w-0 truncate font-medium {className}">{modelId}</span>
{:else}
<span class="flex min-w-0 flex-wrap items-center gap-1 {className}">
<span class="min-w-0 truncate font-medium">
{#if showOrgName}{parsed.orgName}/{/if}{parsed.modelName ?? modelId}
</span>
{#if parsed.params}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</span>
{/if}
{#if parsed.quantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
{/if}
{#if aliases && aliases.length > 0}
{#each aliases as alias (alias)}
<span class={badgeClass}>{alias}</span>
{/each}
{/if}
{#if tags && tags.length > 0}
{#each tags as tag (tag)}
<span class={tagBadgeClass}>{tag}</span>
{/each}
{/if}
</span>
{/if}
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { ChevronDown, Loader2, Package, Power } from '@lucide/svelte'; import { SvelteMap } from 'svelte/reactivity';
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { cn } from '$lib/components/ui/utils'; import { cn } from '$lib/components/ui/utils';
@@ -10,35 +11,33 @@
modelsLoading, modelsLoading,
modelsUpdating, modelsUpdating,
selectedModelId, selectedModelId,
routerModels,
singleModelName singleModelName
} from '$lib/stores/models.svelte'; } from '$lib/stores/models.svelte';
import { KeyboardKey, ServerModelStatus } from '$lib/enums'; import { KeyboardKey } from '$lib/enums';
import { isRouterMode } from '$lib/stores/server.svelte'; import { isRouterMode } from '$lib/stores/server.svelte';
import { import {
DialogModelInformation, DialogModelInformation,
DropdownMenuSearchable, DropdownMenuSearchable,
TruncatedText ModelId,
ModelsSelectorOption
} from '$lib/components/app'; } from '$lib/components/app';
import type { ModelOption } from '$lib/types/models'; import type { ModelOption } from '$lib/types/models';
interface Props { interface Props {
class?: string; class?: string;
currentModel?: string | null; currentModel?: string | null;
/** Callback when model changes. Return false to keep menu open (e.g., for validation failures) */
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
disabled?: boolean; disabled?: boolean;
forceForegroundText?: boolean; forceForegroundText?: boolean;
/** When true, user's global selection takes priority over currentModel (for form selector) */ onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
useGlobalSelection?: boolean; useGlobalSelection?: boolean;
} }
let { let {
class: className = '', class: className = '',
currentModel = null, currentModel = null,
onModelChange,
disabled = false, disabled = false,
forceForegroundText = false, forceForegroundText = false,
onModelChange,
useGlobalSelection = false useGlobalSelection = false
}: Props = $props(); }: Props = $props();
@@ -55,46 +54,109 @@
let isRouter = $derived(isRouterMode()); let isRouter = $derived(isRouterMode());
let serverModel = $derived(singleModelName()); let serverModel = $derived(singleModelName());
// Reactive router models state - needed for proper reactivity of status checks let isHighlightedCurrentModelActive = $derived.by(() => {
let currentRouterModels = $derived(routerModels()); if (!isRouter || !currentModel) return false;
function getModelStatus(modelId: string): ServerModelStatus | null { const currentOption = options.find((option) => option.model === currentModel);
const model = currentRouterModels.find((m) => m.id === modelId);
return (model?.status?.value as ServerModelStatus) ?? null;
}
let isHighlightedCurrentModelActive = $derived( return currentOption ? currentOption.id === activeId : false;
!isRouter || !currentModel });
? false
: (() => {
const currentOption = options.find((option) => option.model === currentModel);
return currentOption ? currentOption.id === activeId : false; let isCurrentModelInCache = $derived.by(() => {
})()
);
let isCurrentModelInCache = $derived(() => {
if (!isRouter || !currentModel) return true; if (!isRouter || !currentModel) return true;
return options.some((option) => option.model === currentModel); return options.some((option) => option.model === currentModel);
}); });
let isLoadingModel = $state(false);
let searchTerm = $state(''); let searchTerm = $state('');
let highlightedIndex = $state<number>(-1); let highlightedIndex = $state<number>(-1);
let filteredOptions: ModelOption[] = $derived( let filteredOptions: ModelOption[] = $derived.by(() => {
(() => { const term = searchTerm.trim().toLowerCase();
const term = searchTerm.trim().toLowerCase(); if (!term) return options;
if (!term) return options;
return options.filter( return options.filter(
(option) => (option) =>
option.model.toLowerCase().includes(term) || option.name?.toLowerCase().includes(term) 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))
);
});
let groupedFilteredOptions = $derived.by(() => {
const favIds = modelsStore.favouriteModelIds;
const result: {
orgName: string | null;
isFavouritesGroup: boolean;
isLoadedGroup: boolean;
items: { option: ModelOption; flatIndex: number }[];
}[] = [];
// Loaded models group (top)
const loadedItems: { option: ModelOption; flatIndex: number }[] = [];
for (let i = 0; i < filteredOptions.length; i++) {
if (modelsStore.isModelLoaded(filteredOptions[i].model)) {
loadedItems.push({ option: filteredOptions[i], flatIndex: i });
}
}
if (loadedItems.length > 0) {
result.push({
orgName: null,
isFavouritesGroup: false,
isLoadedGroup: true,
items: loadedItems
});
}
// Favourites group
const loadedModelIds = new Set(loadedItems.map((item) => item.option.model));
const favItems: { option: ModelOption; flatIndex: number }[] = [];
for (let i = 0; i < filteredOptions.length; i++) {
if (favIds.has(filteredOptions[i].model) && !loadedModelIds.has(filteredOptions[i].model)) {
favItems.push({ option: filteredOptions[i], flatIndex: i });
}
}
if (favItems.length > 0) {
result.push({
orgName: null,
isFavouritesGroup: true,
isLoadedGroup: false,
items: favItems
});
}
// Org groups (excluding loaded and favourites)
const orgGroups = new SvelteMap<string, { option: ModelOption; flatIndex: number }[]>();
for (let i = 0; i < filteredOptions.length; i++) {
const option = filteredOptions[i];
if (loadedModelIds.has(option.model) || favIds.has(option.model)) continue;
const orgName = option.parsedId?.orgName ?? null;
const key = orgName ?? '';
if (!orgGroups.has(key)) orgGroups.set(key, []);
orgGroups.get(key)!.push({ option, flatIndex: i });
}
for (const [orgName, items] of orgGroups) {
result.push({
orgName: orgName || null,
isFavouritesGroup: false,
isLoadedGroup: false,
items
});
}
return result;
});
// Reset highlighted index when search term changes
$effect(() => { $effect(() => {
void searchTerm; void searchTerm;
highlightedIndex = -1; highlightedIndex = -1;
@@ -109,8 +171,6 @@
}); });
}); });
// Handle changes to the model selector dropdown or the model dialog, depending on if the server is in
// router mode or not.
function handleOpenChange(open: boolean) { function handleOpenChange(open: boolean) {
if (loading || updating) return; if (loading || updating) return;
@@ -142,6 +202,7 @@
if (event.key === KeyboardKey.ARROW_DOWN) { if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault(); event.preventDefault();
if (filteredOptions.length === 0) return; if (filteredOptions.length === 0) return;
if (highlightedIndex === -1 || highlightedIndex === filteredOptions.length - 1) { if (highlightedIndex === -1 || highlightedIndex === filteredOptions.length - 1) {
@@ -151,6 +212,7 @@
} }
} else if (event.key === KeyboardKey.ARROW_UP) { } else if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault(); event.preventDefault();
if (filteredOptions.length === 0) return; if (filteredOptions.length === 0) return;
if (highlightedIndex === -1 || highlightedIndex === 0) { if (highlightedIndex === -1 || highlightedIndex === 0) {
@@ -160,11 +222,12 @@
} }
} else if (event.key === KeyboardKey.ENTER) { } else if (event.key === KeyboardKey.ENTER) {
event.preventDefault(); event.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) { if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
const option = filteredOptions[highlightedIndex]; const option = filteredOptions[highlightedIndex];
handleSelect(option.id); handleSelect(option.id);
} else if (filteredOptions.length > 0) { } else if (filteredOptions.length > 0) {
// No selection - highlight first option
highlightedIndex = 0; highlightedIndex = 0;
} }
} }
@@ -177,31 +240,18 @@
let shouldCloseMenu = true; let shouldCloseMenu = true;
if (onModelChange) { if (onModelChange) {
// If callback provided, use it (for regenerate functionality)
const result = await onModelChange(option.id, option.model); const result = await onModelChange(option.id, option.model);
// If callback returns false, keep menu open (validation failed)
if (result === false) { if (result === false) {
shouldCloseMenu = false; shouldCloseMenu = false;
} }
} else { } else {
// Update global selection
await modelsStore.selectModelById(option.id); await modelsStore.selectModelById(option.id);
// Load the model if not already loaded (router mode)
if (isRouter && getModelStatus(option.model) !== ServerModelStatus.LOADED) {
try {
await modelsStore.loadModel(option.model);
} catch (error) {
console.error('Failed to load model:', error);
}
}
} }
if (shouldCloseMenu) { if (shouldCloseMenu) {
handleOpenChange(false); handleOpenChange(false);
// Focus the chat textarea after model selection
requestAnimationFrame(() => { requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>( const textarea = document.querySelector<HTMLTextAreaElement>(
'[data-slot="chat-form"] textarea' '[data-slot="chat-form"] textarea'
@@ -209,32 +259,39 @@
textarea?.focus(); textarea?.focus();
}); });
} }
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
isLoadingModel = true;
modelsStore
.loadModel(option.model)
.catch((error) => console.error('Failed to load model:', error))
.finally(() => (isLoadingModel = false));
}
} }
function getDisplayOption(): ModelOption | undefined { function getDisplayOption(): ModelOption | undefined {
if (!isRouter) { if (!isRouter) {
if (serverModel) { const displayModel = serverModel || currentModel;
if (displayModel) {
return { return {
id: 'current', id: serverModel ? 'current' : 'offline-current',
model: serverModel, model: displayModel,
name: serverModel.split('/').pop() || serverModel, name: displayModel.split('/').pop() || displayModel,
capabilities: [] // Empty array for single model mode capabilities: []
}; };
} }
return undefined; return undefined;
} }
// When useGlobalSelection is true (form selector), prioritize user selection
// Otherwise (message display), prioritize currentModel
if (useGlobalSelection && activeId) { if (useGlobalSelection && activeId) {
const selected = options.find((option) => option.id === activeId); const selected = options.find((option) => option.id === activeId);
if (selected) return selected; if (selected) return selected;
} }
// Show currentModel (from message payload or conversation)
if (currentModel) { if (currentModel) {
if (!isCurrentModelInCache()) { if (!isCurrentModelInCache) {
return { return {
id: 'not-in-cache', id: 'not-in-cache',
model: currentModel, model: currentModel,
@@ -246,12 +303,10 @@
return options.find((option) => option.model === currentModel); return options.find((option) => option.model === currentModel);
} }
// Fallback to user selection (for new chats before first message)
if (activeId) { if (activeId) {
return options.find((option) => option.id === activeId); return options.find((option) => option.id === activeId);
} }
// No selection - return undefined to show "Select model"
return undefined; return undefined;
} }
</script> </script>
@@ -260,10 +315,25 @@
{#if loading && options.length === 0 && isRouter} {#if loading && options.length === 0 && isRouter}
<div class="flex items-center gap-2 text-xs text-muted-foreground"> <div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" /> <Loader2 class="h-3.5 w-3.5 animate-spin" />
Loading models… Loading models…
</div> </div>
{:else if options.length === 0 && isRouter} {:else if options.length === 0 && isRouter}
<p class="text-xs text-muted-foreground">No models available.</p> {#if currentModel}
<span
class={cn(
'inline-flex items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs text-muted-foreground',
className
)}
style="max-width: min(calc(100cqw - 9rem), 20rem)"
>
<Package class="h-3.5 w-3.5" />
<ModelId modelId={currentModel} class="min-w-0" showOrgName />
</span>
{:else}
<p class="text-xs text-muted-foreground">No models available.</p>
{/if}
{:else} {:else}
{@const selectedOption = getDisplayOption()} {@const selectedOption = getDisplayOption()}
@@ -280,7 +350,7 @@
type="button" type="button"
class={cn( class={cn(
`inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`, `inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
!isCurrentModelInCache() !isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400' ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText : forceForegroundText
? 'text-foreground' ? 'text-foreground'
@@ -294,12 +364,21 @@
> >
<Package class="h-3.5 w-3.5" /> <Package class="h-3.5 w-3.5" />
<TruncatedText {#if selectedOption}
text={selectedOption?.model || 'Select model'} <Tooltip.Root>
class="min-w-0 font-medium" <Tooltip.Trigger class="min-w-0 overflow-hidden">
/> <ModelId modelId={selectedOption.model} class="min-w-0" showOrgName />
</Tooltip.Trigger>
{#if updating} <Tooltip.Content>
<p class="font-mono">{selectedOption.model}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="min-w-0 font-medium">Select model</span>
{/if}
{#if updating || isLoadingModel}
<Loader2 class="h-3 w-3.5 animate-spin" /> <Loader2 class="h-3 w-3.5 animate-spin" />
{:else} {:else}
<ChevronDown class="h-3 w-3.5" /> <ChevronDown class="h-3 w-3.5" />
@@ -316,10 +395,10 @@
placeholder="Search models..." placeholder="Search models..."
onSearchKeyDown={handleSearchKeyDown} onSearchKeyDown={handleSearchKeyDown}
emptyMessage="No models found." emptyMessage="No models found."
isEmpty={filteredOptions.length === 0 && isCurrentModelInCache()} isEmpty={filteredOptions.length === 0 && isCurrentModelInCache}
> >
<div class="models-list"> <div class="models-list">
{#if !isCurrentModelInCache() && currentModel} {#if !isCurrentModelInCache && currentModel}
<!-- Show unavailable model as first option (disabled) --> <!-- Show unavailable model as first option (disabled) -->
<button <button
type="button" type="button"
@@ -329,90 +408,54 @@
aria-disabled="true" aria-disabled="true"
disabled disabled
> >
<span <ModelId modelId={currentModel} class="flex-1" showOrgName />
class="min-w-0 flex-1 truncate text-left sm:overflow-visible sm:text-clip sm:whitespace-nowrap"
>
{selectedOption?.name || currentModel}
</span>
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span> <span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button> </button>
<div class="my-1 h-px bg-border"></div>
{/if} {/if}
{#if filteredOptions.length === 0} {#if filteredOptions.length === 0}
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p> <p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
{/if} {/if}
{#each filteredOptions as option, index (option.id)}
{@const status = getModelStatus(option.model)}
{@const isLoaded = status === ServerModelStatus.LOADED}
{@const isLoading = status === ServerModelStatus.LOADING}
{@const isSelected = currentModel === option.model || activeId === option.id}
{@const isHighlighted = index === highlightedIndex}
<div {#each groupedFilteredOptions as group (group.isLoadedGroup ? '__loaded__' : group.isFavouritesGroup ? '__favourites__' : group.orgName)}
class={cn( {#if group.isLoadedGroup}
'group flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none', <p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
'cursor-pointer hover:bg-muted focus:bg-muted', Loaded models
isSelected || isHighlighted </p>
? 'bg-accent text-accent-foreground' {:else if group.isFavouritesGroup}
: 'hover:bg-accent hover:text-accent-foreground', <p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground' Favourite models
)} </p>
role="option" {:else if group.orgName}
aria-selected={isSelected || isHighlighted} <p
tabindex="0" class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
onclick={() => handleSelect(option.id)}
onmouseenter={() => (highlightedIndex = index)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(option.id);
}
}}
>
<span
class="min-w-0 flex-1 truncate text-left sm:overflow-visible sm:pr-2 sm:text-clip sm:whitespace-nowrap"
> >
{option.model} {group.orgName}
</span> </p>
{/if}
<div class="flex w-6 shrink-0 justify-center"> {#each group.items as { option, flatIndex } (group.isLoadedGroup ? `loaded-${option.id}` : group.isFavouritesGroup ? `fav-${option.id}` : option.id)}
{#if isLoading} {@const isSelected = currentModel === option.model || activeId === option.id}
<Tooltip.Root> {@const isHighlighted = flatIndex === highlightedIndex}
<Tooltip.Trigger> {@const isFav = modelsStore.favouriteModelIds.has(option.model)}
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
</Tooltip.Trigger> <ModelsSelectorOption
<Tooltip.Content class="z-[9999]"> {option}
<p>Loading model...</p> {isSelected}
</Tooltip.Content> {isHighlighted}
</Tooltip.Root> {isFav}
{:else if isLoaded} showOrgName={group.isFavouritesGroup || group.isLoadedGroup}
<Tooltip.Root> onSelect={handleSelect}
<Tooltip.Trigger> onMouseEnter={() => (highlightedIndex = flatIndex)}
<button onKeyDown={(e) => {
type="button" if (e.key === KeyboardKey.ENTER || e.key === KeyboardKey.SPACE) {
class="relative flex h-4 w-4 items-center justify-center" e.preventDefault();
onclick={(e) => { handleSelect(option.id);
e.stopPropagation(); }
modelsStore.unloadModel(option.model); }}
}} />
> {/each}
<span
class="h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
></span>
<Power
class="absolute h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
/>
</button>
</Tooltip.Trigger>
<Tooltip.Content class="z-[9999]">
<p>Unload model</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="h-2 w-2 rounded-full bg-muted-foreground/50"></span>
{/if}
</div>
</div>
{/each} {/each}
</div> </div>
</DropdownMenuSearchable> </DropdownMenuSearchable>
@@ -422,7 +465,7 @@
<button <button
class={cn( class={cn(
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`, `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
!isCurrentModelInCache() !isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400' ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText : forceForegroundText
? 'text-foreground' ? 'text-foreground'
@@ -437,7 +480,17 @@
> >
<Package class="h-3.5 w-3.5" /> <Package class="h-3.5 w-3.5" />
<TruncatedText text={selectedOption?.model || ''} class="min-w-0 font-medium" /> {#if selectedOption}
<Tooltip.Root>
<Tooltip.Trigger class="min-w-0 overflow-hidden">
<ModelId modelId={selectedOption.model} class="min-w-0" showOrgName />
</Tooltip.Trigger>
<Tooltip.Content>
<p class="font-mono">{selectedOption.model}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if updating} {#if updating}
<Loader2 class="h-3 w-3.5 animate-spin" /> <Loader2 class="h-3 w-3.5 animate-spin" />
@@ -0,0 +1,143 @@
<script lang="ts">
import { CircleAlert, Heart, HeartOff, Loader2, Power, PowerOff, RotateCw } from '@lucide/svelte';
import { cn } from '$lib/components/ui/utils';
import { ActionIcon, ModelId } from '$lib/components/app';
import type { ModelOption } from '$lib/types/models';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore, routerModels } from '$lib/stores/models.svelte';
interface Props {
option: ModelOption;
isSelected: boolean;
isHighlighted: boolean;
isFav: boolean;
showOrgName?: boolean;
onSelect: (modelId: string) => void;
onMouseEnter: () => void;
onKeyDown: (e: KeyboardEvent) => void;
}
let {
option,
isSelected,
isHighlighted,
isFav,
showOrgName = false,
onSelect,
onMouseEnter,
onKeyDown
}: Props = $props();
let currentRouterModels = $derived(routerModels());
let serverStatus = $derived.by(() => {
const model = currentRouterModels.find((m) => m.id === option.model);
return (model?.status?.value as ServerModelStatus) ?? null;
});
let isOperationInProgress = $derived(modelsStore.isModelOperationInProgress(option.model));
let isFailed = $derived(serverStatus === ServerModelStatus.FAILED);
let isLoaded = $derived(serverStatus === ServerModelStatus.LOADED && !isOperationInProgress);
let isLoading = $derived(serverStatus === ServerModelStatus.LOADING || isOperationInProgress);
</script>
<div
class={cn(
'group flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
'cursor-pointer hover:bg-muted focus:bg-muted',
isSelected || isHighlighted
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
)}
role="option"
aria-selected={isSelected || isHighlighted}
tabindex="0"
onclick={() => onSelect(option.id)}
onmouseenter={onMouseEnter}
onkeydown={onKeyDown}
>
<ModelId
modelId={option.model}
{showOrgName}
aliases={option.aliases}
tags={option.tags}
class="flex-1"
/>
<div class="flex shrink-0 items-center gap-2.5">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pointer-events-none flex w-4 items-center justify-center pl-2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100"
onclick={(e) => e.stopPropagation()}
>
{#if isFav}
<ActionIcon
iconSize="h-2.5 w-2.5"
icon={HeartOff}
tooltip="Remove from favourites"
class="h-3 w-3 hover:text-foreground"
onclick={() => modelsStore.toggleFavourite(option.model)}
/>
{:else}
<ActionIcon
iconSize="h-2.5 w-2.5"
icon={Heart}
tooltip="Add to favourites"
class="h-3 w-3 hover:text-foreground"
onclick={() => modelsStore.toggleFavourite(option.model)}
/>
{/if}
</div>
{#if isLoading}
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
{:else if isFailed}
<div class="flex w-4 items-center justify-center">
<CircleAlert class="h-3.5 w-3.5 text-red-500 group-hover:hidden" />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="hidden group-hover:flex" onclick={(e) => e.stopPropagation()}>
<ActionIcon
iconSize="h-2.5 w-2.5"
icon={RotateCw}
tooltip="Retry loading model"
class="h-3 w-3 text-red-500 hover:text-foreground"
onclick={() => modelsStore.loadModel(option.model)}
/>
</div>
</div>
{:else if isLoaded}
<div class="flex w-4 items-center justify-center">
<span class="h-2 w-2 rounded-full bg-green-500 group-hover:hidden"></span>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="hidden group-hover:flex" onclick={(e) => e.stopPropagation()}>
<ActionIcon
iconSize="h-2.5 w-2.5"
icon={PowerOff}
tooltip="Unload model"
class="h-3 w-3 text-red-500 hover:text-red-600"
onclick={() => modelsStore.unloadModel(option.model)}
/>
</div>
</div>
{:else}
<div class="flex w-4 items-center justify-center">
<span class="h-2 w-2 rounded-full bg-muted-foreground/50 group-hover:hidden"></span>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="hidden group-hover:flex" onclick={(e) => e.stopPropagation()}>
<ActionIcon
iconSize="h-2.5 w-2.5"
icon={Power}
tooltip="Load model"
class="h-3 w-3"
onclick={() => modelsStore.loadModel(option.model)}
/>
</div>
</div>
{/if}
</div>
</div>
@@ -71,3 +71,5 @@ export { default as ModelsSelector } from './ModelsSelector.svelte';
* ``` * ```
*/ */
export { default as ModelBadge } from './ModelBadge.svelte'; export { default as ModelBadge } from './ModelBadge.svelte';
export { default as ModelId } from './ModelId.svelte';
export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
@@ -8,6 +8,8 @@
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent', default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
secondary: secondary:
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent', 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
tertiary:
'bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25 border-transparent',
destructive: destructive:
'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white', 'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground' outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils'; import { cn, type WithElementRef } from '$lib/components/ui/utils';
import { BOX_BORDER } from '$lib/constants/css-classes'; import { BOX_BORDER } from '$lib/constants';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -0,0 +1,5 @@
export const API_MODELS = {
LIST: '/v1/models',
LOAD: '/models/load',
UNLOAD: '/models/unload'
};
@@ -0,0 +1,33 @@
// Central constants export file
// All constants should be imported from '$lib/constants'
export * from './agentic';
export * from './api-endpoints';
export * from './attachment-labels';
export * from './auto-scroll';
export * from './binary-detection';
export * from './cache';
export * from './chat-form';
export * from './code-blocks';
export * from './code';
export * from './css-classes';
export * from './floating-ui-constraints';
export * from './formatters';
export * from './icons';
export * from './latex-protection';
export * from './literal-html';
export * from './localstorage-keys';
export * from './markdown';
export * from './max-bundle-size';
export * from './model-id';
export * from './precision';
export * from './processing-info';
export * from './settings-config';
export * from './settings-fields';
export * from './settings-keys';
export * from './settings-sections';
export * from './supported-file-types';
export * from './table-html-restorer';
export * from './tooltip-config';
export * from './ui';
export * from './viewport';
@@ -1,2 +1,3 @@
export const CONFIG_LOCALSTORAGE_KEY = 'LlamaCppWebui.config'; export const CONFIG_LOCALSTORAGE_KEY = 'LlamaCppWebui.config';
export const USER_OVERRIDES_LOCALSTORAGE_KEY = 'LlamaCppWebui.userOverrides'; export const USER_OVERRIDES_LOCALSTORAGE_KEY = 'LlamaCppWebui.userOverrides';
export const FAVOURITE_MODELS_LOCALSTORAGE_KEY = 'LlamaCppWebui.favouriteModels';
@@ -0,0 +1,28 @@
/** Sentinel value returned by `indexOf` when a substring is not found. */
export const MODEL_ID_NOT_FOUND = -1;
/** Separates `<org>` from `<model>` in a model ID, e.g. `org/ModelName`. */
export const MODEL_ID_ORG_SEPARATOR = '/';
/** Separates named segments within the model path, e.g. `ModelName-7B-GGUF`. */
export const MODEL_ID_SEGMENT_SEPARATOR = '-';
/** Separates the model path from the quantization tag, e.g. `model:Q4_K_M`. */
export const MODEL_ID_QUANTIZATION_SEPARATOR = ':';
/**
* Matches a trailing ALL-CAPS format segment, e.g. `GGUF`, `BF16`, `Q4_K_M`.
* Must be at least 2 uppercase letters, optionally followed by uppercase letters or digits.
*/
export const MODEL_FORMAT_SEGMENT_RE = /^[A-Z]{2,}[A-Z0-9]*$/;
/**
* Matches a parameter-count segment, e.g. `7B`, `1.5b`, `120M`.
*/
export const MODEL_PARAMS_RE = /^\d+(\.\d+)?[BbMmKkTt]$/;
/**
* Matches an activated-parameter-count segment, e.g. `A10B`, `A2.4b`.
* The leading `A` distinguishes it from a regular params segment.
*/
export const MODEL_ACTIVATED_PARAMS_RE = /^A\d+(\.\d+)?[BbMmKkTt]$/;
@@ -23,6 +23,7 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
autoShowSidebarOnNewChat: true, autoShowSidebarOnNewChat: true,
autoMicOnEmpty: false, autoMicOnEmpty: false,
fullHeightCodeBlocks: false, fullHeightCodeBlocks: false,
showRawModelNames: false,
// make sure these default values are in sync with `common.h` // make sure these default values are in sync with `common.h`
samplers: 'top_k;typ_p;top_p;min_p;temperature', samplers: 'top_k;typ_p;top_p;min_p;temperature',
backend_sampling: false, backend_sampling: false,
@@ -116,6 +117,8 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.', 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
fullHeightCodeBlocks: fullHeightCodeBlocks:
'Always display code blocks at their full natural height, overriding any height limits.', 'Always display code blocks at their full natural height, overriding any height limits.',
showRawModelNames:
'Display full raw model identifiers (e.g. "unsloth/Qwen3.5-27B-GGUF:BF16") instead of parsed names with badges.',
pyInterpreterEnabled: pyInterpreterEnabled:
'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.', 'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.',
enableContinueGeneration: enableContinueGeneration:
@@ -24,6 +24,7 @@ export const SETTINGS_KEYS = {
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop', ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
AUTO_SHOW_SIDEBAR_ON_NEW_CHAT: 'autoShowSidebarOnNewChat', AUTO_SHOW_SIDEBAR_ON_NEW_CHAT: 'autoShowSidebarOnNewChat',
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks', FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
// Sampling // Sampling
TEMPERATURE: 'temperature', TEMPERATURE: 'temperature',
DYNATEMP_RANGE: 'dynatemp_range', DYNATEMP_RANGE: 'dynatemp_range',
+2 -1
View File
@@ -11,5 +11,6 @@ export enum KeyboardKey {
D_UPPER = 'D', D_UPPER = 'D',
E_UPPER = 'E', E_UPPER = 'E',
K_LOWER = 'k', K_LOWER = 'k',
O_UPPER = 'O' O_UPPER = 'O',
SPACE = ' '
} }
@@ -1,4 +1,4 @@
import { DEFAULT_MOBILE_BREAKPOINT } from '$lib/constants/viewport'; import { DEFAULT_MOBILE_BREAKPOINT } from '$lib/constants';
import { MediaQuery } from 'svelte/reactivity'; import { MediaQuery } from 'svelte/reactivity';
export class IsMobile extends MediaQuery { export class IsMobile extends MediaQuery {
@@ -1,4 +1,4 @@
import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/constants/auto-scroll'; import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/constants';
export interface AutoScrollOptions { export interface AutoScrollOptions {
/** Whether auto-scroll is disabled globally (e.g., from settings) */ /** Whether auto-scroll is disabled globally (e.g., from settings) */
@@ -1,6 +1,6 @@
import { activeProcessingState } from '$lib/stores/chat.svelte'; import { activeProcessingState } from '$lib/stores/chat.svelte';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { STATS_UNITS } from '$lib/constants/processing-info'; import { STATS_UNITS } from '$lib/constants';
import type { ApiProcessingState, LiveProcessingStats, LiveGenerationStats } from '$lib/types'; import type { ApiProcessingState, LiveProcessingStats, LiveGenerationStats } from '$lib/types';
export interface UseProcessingStateReturn { export interface UseProcessingStateReturn {
@@ -22,7 +22,7 @@ import {
COPY_CODE_BTN_CLASS, COPY_CODE_BTN_CLASS,
PREVIEW_CODE_BTN_CLASS, PREVIEW_CODE_BTN_CLASS,
RELATIVE_CLASS RELATIVE_CLASS
} from '$lib/constants/code-blocks'; } from '$lib/constants';
declare global { declare global {
interface Window { interface Window {
@@ -1,7 +1,7 @@
import type { Plugin } from 'unified'; import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit'; import { visit } from 'unist-util-visit';
import type { Break, Content, Paragraph, PhrasingContent, Root, Text } from 'mdast'; import type { Break, Content, Paragraph, PhrasingContent, Root, Text } from 'mdast';
import { LINE_BREAK, NBSP, PHRASE_PARENTS, TAB_AS_SPACES } from '$lib/constants/literal-html'; import { LINE_BREAK, NBSP, PHRASE_PARENTS, TAB_AS_SPACES } from '$lib/constants';
/** /**
* remark plugin that rewrites raw HTML nodes into plain-text equivalents. * remark plugin that rewrites raw HTML nodes into plain-text equivalents.
@@ -68,7 +68,7 @@ import type { Plugin } from 'unified';
import type { Element, ElementContent, Root, Text } from 'hast'; import type { Element, ElementContent, Root, Text } from 'hast';
import { visit } from 'unist-util-visit'; import { visit } from 'unist-util-visit';
import { visitParents } from 'unist-util-visit-parents'; import { visitParents } from 'unist-util-visit-parents';
import { BR_PATTERN, LIST_PATTERN, LI_PATTERN } from '$lib/constants/table-html-restorer'; import { BR_PATTERN, LIST_PATTERN, LI_PATTERN } from '$lib/constants';
/** /**
* Expands text containing `<br>` tags into an array of text nodes and br elements. * Expands text containing `<br>` tags into an array of text nodes and br elements.
@@ -1,5 +1,5 @@
import { getJsonHeaders, formatAttachmentText, isAbortError } from '$lib/utils'; import { getJsonHeaders, formatAttachmentText, isAbortError } from '$lib/utils';
import { ATTACHMENT_LABEL_PDF_FILE } from '$lib/constants/attachment-labels'; import { AGENTIC_REGEX, ATTACHMENT_LABEL_PDF_FILE } from '$lib/constants';
import { import {
AttachmentType, AttachmentType,
ContentPartType, ContentPartType,
@@ -9,7 +9,6 @@ import {
} from '$lib/enums'; } from '$lib/enums';
import type { ApiChatMessageContentPart, ApiChatCompletionToolCall } from '$lib/types/api'; import type { ApiChatMessageContentPart, ApiChatCompletionToolCall } from '$lib/types/api';
import { modelsStore } from '$lib/stores/models.svelte'; import { modelsStore } from '$lib/stores/models.svelte';
import { AGENTIC_REGEX } from '$lib/constants/agentic';
export class ChatService { export class ChatService {
private static stripReasoningContent( private static stripReasoningContent(
@@ -1,5 +1,16 @@
import { ServerModelStatus } from '$lib/enums'; import { ServerModelStatus } from '$lib/enums';
import { apiFetch, apiPost } from '$lib/utils'; import { apiFetch, apiPost } from '$lib/utils';
import type { ParsedModelId } from '$lib/types/models';
import {
MODEL_FORMAT_SEGMENT_RE,
MODEL_PARAMS_RE,
MODEL_ACTIVATED_PARAMS_RE,
MODEL_ID_NOT_FOUND,
MODEL_ID_ORG_SEPARATOR,
MODEL_ID_SEGMENT_SEPARATOR,
MODEL_ID_QUANTIZATION_SEPARATOR,
API_MODELS
} from '$lib/constants';
export class ModelsService { export class ModelsService {
/** /**
@@ -17,7 +28,7 @@ export class ModelsService {
* @returns List of available models with basic metadata * @returns List of available models with basic metadata
*/ */
static async list(): Promise<ApiModelListResponse> { static async list(): Promise<ApiModelListResponse> {
return apiFetch<ApiModelListResponse>('/v1/models'); return apiFetch<ApiModelListResponse>(API_MODELS.LIST);
} }
/** /**
@@ -28,7 +39,7 @@ export class ModelsService {
* @returns List of models with detailed status and configuration info * @returns List of models with detailed status and configuration info
*/ */
static async listRouter(): Promise<ApiRouterModelsListResponse> { static async listRouter(): Promise<ApiRouterModelsListResponse> {
return apiFetch<ApiRouterModelsListResponse>('/v1/models'); return apiFetch<ApiRouterModelsListResponse>(API_MODELS.LIST);
} }
/** /**
@@ -54,7 +65,7 @@ export class ModelsService {
payload.extra_args = extraArgs; payload.extra_args = extraArgs;
} }
return apiPost<ApiRouterModelsLoadResponse>('/models/load', payload); return apiPost<ApiRouterModelsLoadResponse>(API_MODELS.LOAD, payload);
} }
/** /**
@@ -66,7 +77,7 @@ export class ModelsService {
* @returns Unload response from the server * @returns Unload response from the server
*/ */
static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> { static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
return apiPost<ApiRouterModelsUnloadResponse>('/models/unload', { model: modelId }); return apiPost<ApiRouterModelsUnloadResponse>(API_MODELS.UNLOAD, { model: modelId });
} }
/** /**
@@ -96,4 +107,89 @@ export class ModelsService {
static isModelLoading(model: ApiModelDataEntry): boolean { static isModelLoading(model: ApiModelDataEntry): boolean {
return model.status.value === ServerModelStatus.LOADING; return model.status.value === ServerModelStatus.LOADING;
} }
/**
*
*
* Parsing
*
*
*/
/**
* Parse a model ID string into its structured components.
*
* Handles the convention:
* `<org>/<ModelName>-<Parameters>(-<ActivatedParameters>)-<Format>:<QuantizationType>`
*
* @param modelId - Raw model identifier string
* @returns Structured {@link ParsedModelId} with all detected fields
*/
static parseModelId(modelId: string): ParsedModelId {
const result: ParsedModelId = {
raw: modelId,
orgName: null,
modelName: null,
params: null,
activatedParams: null,
format: null,
quantization: null,
tags: []
};
const colonIdx = modelId.indexOf(MODEL_ID_QUANTIZATION_SEPARATOR);
let modelPath: string;
if (colonIdx !== MODEL_ID_NOT_FOUND) {
result.quantization = modelId.slice(colonIdx + 1) || null;
modelPath = modelId.slice(0, colonIdx);
} else {
modelPath = modelId;
}
const slashIdx = modelPath.indexOf(MODEL_ID_ORG_SEPARATOR);
let modelStr: string;
if (slashIdx !== MODEL_ID_NOT_FOUND) {
result.orgName = modelPath.slice(0, slashIdx);
modelStr = modelPath.slice(slashIdx + 1);
} else {
modelStr = modelPath;
}
const segments = modelStr.split(MODEL_ID_SEGMENT_SEPARATOR);
if (segments.length > 0 && MODEL_FORMAT_SEGMENT_RE.test(segments[segments.length - 1])) {
result.format = segments.pop()!;
}
const paramsRe = MODEL_PARAMS_RE;
const activatedParamsRe = MODEL_ACTIVATED_PARAMS_RE;
let paramsIdx = MODEL_ID_NOT_FOUND;
let activatedParamsIdx = MODEL_ID_NOT_FOUND;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (paramsIdx === -1 && paramsRe.test(seg)) {
paramsIdx = i;
result.params = seg.toUpperCase();
} else if (activatedParamsRe.test(seg)) {
activatedParamsIdx = i;
result.activatedParams = seg.toUpperCase();
}
}
const pivotIdx = paramsIdx !== MODEL_ID_NOT_FOUND ? paramsIdx : segments.length;
result.modelName = segments.slice(0, pivotIdx).join(MODEL_ID_SEGMENT_SEPARATOR) || null;
if (paramsIdx !== MODEL_ID_NOT_FOUND) {
result.tags = segments
.slice(paramsIdx + 1)
.filter((_, relIdx) => paramsIdx + 1 + relIdx !== activatedParamsIdx);
}
return result;
}
} }
@@ -28,12 +28,12 @@ import {
findLeafNode, findLeafNode,
isAbortError isAbortError
} from '$lib/utils'; } from '$lib/utils';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
import { REASONING_TAGS } from '$lib/constants/agentic';
import { import {
MAX_INACTIVE_CONVERSATION_STATES, MAX_INACTIVE_CONVERSATION_STATES,
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
} from '$lib/constants/cache'; REASONING_TAGS,
SYSTEM_MESSAGE_PLACEHOLDER
} from '$lib/constants';
import type { import type {
ChatMessageTimings, ChatMessageTimings,
ChatMessagePromptProgress, ChatMessagePromptProgress,
@@ -1,9 +1,14 @@
import { SvelteSet } from 'svelte/reactivity'; import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { ServerModelStatus, ModelModality } from '$lib/enums'; import { ServerModelStatus, ModelModality } from '$lib/enums';
import { ModelsService, PropsService } from '$lib/services'; import { ModelsService, PropsService } from '$lib/services';
import { serverStore } from '$lib/stores/server.svelte'; import { serverStore } from '$lib/stores/server.svelte';
import { TTLCache } from '$lib/utils'; import { TTLCache } from '$lib/utils';
import { MODEL_PROPS_CACHE_TTL_MS, MODEL_PROPS_CACHE_MAX_ENTRIES } from '$lib/constants/cache'; import {
MODEL_PROPS_CACHE_TTL_MS,
MODEL_PROPS_CACHE_MAX_ENTRIES,
FAVOURITE_MODELS_LOCALSTORAGE_KEY
} from '$lib/constants';
/** /**
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes * modelsStore - Reactive store for model management in both MODEL and ROUTER modes
@@ -50,7 +55,9 @@ class ModelsStore {
selectedModelName = $state<string | null>(null); selectedModelName = $state<string | null>(null);
private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map()); private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
private modelLoadingStates = $state<Map<string, boolean>>(new Map()); private modelLoadingStates = new SvelteMap<string, boolean>();
favouriteModelIds = $state<Set<string>>(this.loadFavouritesFromStorage());
/** /**
* Model-specific props cache with TTL * Model-specific props cache with TTL
@@ -261,15 +268,19 @@ class ModelsStore {
const displayNameSource = const displayNameSource =
details?.name && details.name.trim().length > 0 ? details.name : item.id; details?.name && details.name.trim().length > 0 ? details.name : item.id;
const displayName = this.toDisplayName(displayNameSource); const displayName = this.toDisplayName(displayNameSource);
const modelId = details?.model || item.id;
return { return {
id: item.id, id: item.id,
name: displayName, name: displayName,
model: details?.model || item.id, model: modelId,
description: details?.description, description: details?.description,
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)), capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
details: details?.details, details: details?.details,
meta: item.meta ?? null meta: item.meta ?? null,
parsedId: ModelsService.parseModelId(modelId),
aliases: item.aliases ?? [],
tags: item.tags ?? []
} satisfies ModelOption; } satisfies ModelOption;
}); });
@@ -497,22 +508,21 @@ class ModelsStore {
/** Polling interval in ms for checking model status */ /** Polling interval in ms for checking model status */
private static readonly STATUS_POLL_INTERVAL = 500; private static readonly STATUS_POLL_INTERVAL = 500;
/** Maximum polling attempts before giving up */
private static readonly STATUS_POLL_MAX_ATTEMPTS = 60; // 30 seconds max
/** /**
* Poll for expected model status after load/unload operation. * Poll for expected model status after load/unload operation.
* Keeps polling until the model reaches the expected status or max attempts reached. * Keeps polling indefinitely until the model reaches the expected status or fails.
* *
* @param modelId - Model identifier to check * @param modelId - Model identifier to check
* @param expectedStatus - Expected status to wait for * @param expectedStatus - Expected status to wait for
* @returns Promise that resolves when expected status is reached * @throws Error if model reaches FAILED status
*/ */
private async pollForModelStatus( private async pollForModelStatus(
modelId: string, modelId: string,
expectedStatus: ServerModelStatus expectedStatus: ServerModelStatus
): Promise<void> { ): Promise<void> {
for (let attempt = 0; attempt < ModelsStore.STATUS_POLL_MAX_ATTEMPTS; attempt++) { let attempt = 0;
while (true) {
await this.fetchRouterModels(); await this.fetchRouterModels();
const currentStatus = this.getModelStatus(modelId); const currentStatus = this.getModelStatus(modelId);
@@ -520,12 +530,23 @@ class ModelsStore {
return; return;
} }
if (currentStatus === ServerModelStatus.FAILED) {
throw new Error(
`Model failed to ${expectedStatus === ServerModelStatus.LOADED ? 'load' : 'unload'}`
);
}
if (
expectedStatus === ServerModelStatus.LOADED &&
currentStatus === ServerModelStatus.UNLOADED &&
attempt > 2
) {
throw new Error('Model was unloaded unexpectedly during loading');
}
attempt++;
await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL)); await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL));
} }
console.warn(
`Model ${modelId} did not reach expected status ${expectedStatus} after ${ModelsStore.STATUS_POLL_MAX_ATTEMPTS} attempts`
);
} }
/** /**
@@ -547,8 +568,10 @@ class ModelsStore {
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED); await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
await this.updateModelModalities(modelId); await this.updateModelModalities(modelId);
toast.success(`Model loaded: ${this.toDisplayName(modelId)}`);
} catch (error) { } catch (error) {
this.error = error instanceof Error ? error.message : 'Failed to load model'; this.error = error instanceof Error ? error.message : 'Failed to load model';
toast.error(`Failed to load model: ${this.toDisplayName(modelId)}`);
throw error; throw error;
} finally { } finally {
this.modelLoadingStates.set(modelId, false); this.modelLoadingStates.set(modelId, false);
@@ -573,8 +596,10 @@ class ModelsStore {
await ModelsService.unload(modelId); await ModelsService.unload(modelId);
await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED); await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
toast.info(`Model unloaded: ${this.toDisplayName(modelId)}`);
} catch (error) { } catch (error) {
this.error = error instanceof Error ? error.message : 'Failed to unload model'; this.error = error instanceof Error ? error.message : 'Failed to unload model';
toast.error(`Failed to unload model: ${this.toDisplayName(modelId)}`);
throw error; throw error;
} finally { } finally {
this.modelLoadingStates.set(modelId, false); this.modelLoadingStates.set(modelId, false);
@@ -593,6 +618,48 @@ class ModelsStore {
await this.loadModel(modelId); await this.loadModel(modelId);
} }
/**
*
*
* Favourites
*
*
*/
isFavourite(modelId: string): boolean {
return this.favouriteModelIds.has(modelId);
}
toggleFavourite(modelId: string): void {
const next = new SvelteSet(this.favouriteModelIds);
if (next.has(modelId)) {
next.delete(modelId);
} else {
next.add(modelId);
}
this.favouriteModelIds = next;
try {
localStorage.setItem(FAVOURITE_MODELS_LOCALSTORAGE_KEY, JSON.stringify([...next]));
} catch {
toast.error('Failed to save favourite models to local storage');
}
}
private loadFavouritesFromStorage(): Set<string> {
try {
const raw = localStorage.getItem(FAVOURITE_MODELS_LOCALSTORAGE_KEY);
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
} catch {
toast.error('Failed to load favourite models from local storage');
return new Set();
}
}
/** /**
* *
* *
@@ -646,3 +713,4 @@ export const loadingModelIds = () => modelsStore.loadingModelIds;
export const propsCacheVersion = () => modelsStore.propsCacheVersion; export const propsCacheVersion = () => modelsStore.propsCacheVersion;
export const singleModelName = () => modelsStore.singleModelName; export const singleModelName = () => modelsStore.singleModelName;
export const selectedModelContextSize = () => modelsStore.selectedModelContextSize; export const selectedModelContextSize = () => modelsStore.selectedModelContextSize;
export const favouriteModelIds = () => modelsStore.favouriteModelIds;
@@ -32,7 +32,11 @@
*/ */
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config'; import {
CONFIG_LOCALSTORAGE_KEY,
SETTING_CONFIG_DEFAULT,
USER_OVERRIDES_LOCALSTORAGE_KEY
} from '$lib/constants';
import { ParameterSyncService } from '$lib/services/parameter-sync.service'; import { ParameterSyncService } from '$lib/services/parameter-sync.service';
import { serverStore } from '$lib/stores/server.svelte'; import { serverStore } from '$lib/stores/server.svelte';
import { import {
@@ -41,10 +45,6 @@ import {
getConfigValue, getConfigValue,
setConfigValue setConfigValue
} from '$lib/utils'; } from '$lib/utils';
import {
CONFIG_LOCALSTORAGE_KEY,
USER_OVERRIDES_LOCALSTORAGE_KEY
} from '$lib/constants/localstorage-keys';
class SettingsStore { class SettingsStore {
/** /**
+4
View File
@@ -81,6 +81,10 @@ export interface ApiModelDataEntry {
path: string; path: string;
/** Current status of the model */ /** Current status of the model */
status: ApiModelStatus; status: ApiModelStatus;
/** Alternative names that resolve to this model */
aliases?: string[];
/** Informational tags for this model */
tags?: string[];
/** Legacy meta field (may be present in older responses) */ /** Legacy meta field (may be present in older responses) */
meta?: Record<string, unknown> | null; meta?: Record<string, unknown> | null;
} }
+14
View File
@@ -14,6 +14,20 @@ export interface ModelOption {
modalities?: ModelModalities; modalities?: ModelModalities;
details?: ApiModelDetails['details']; details?: ApiModelDetails['details'];
meta?: ApiModelDataEntry['meta']; meta?: ApiModelDataEntry['meta'];
parsedId?: ParsedModelId;
aliases?: string[];
tags?: string[];
}
export interface ParsedModelId {
raw: string;
orgName: string | null;
modelName: string | null;
params: string | null;
activatedParams: string | null;
format: string | null;
quantization: string | null;
tags: string[];
} }
/** /**
+1 -1
View File
@@ -1,4 +1,4 @@
import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config'; import type { SETTING_CONFIG_DEFAULT } from '$lib/constants';
import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat'; import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
import type { DatabaseMessageExtra } from './database'; import type { DatabaseMessageExtra } from './database';
import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums'; import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums';
@@ -1,4 +1,4 @@
import { DEFAULT_CACHE_TTL_MS, DEFAULT_CACHE_MAX_ENTRIES } from '$lib/constants/cache'; import { DEFAULT_CACHE_TTL_MS, DEFAULT_CACHE_MAX_ENTRIES } from '$lib/constants';
/** /**
* TTL Cache - Time-To-Live cache implementation for memory optimization * TTL Cache - Time-To-Live cache implementation for memory optimization
+1 -1
View File
@@ -7,7 +7,7 @@ import {
LT_REGEX, LT_REGEX,
GT_REGEX, GT_REGEX,
FENCE_PATTERN FENCE_PATTERN
} from '$lib/constants/code'; } from '$lib/constants';
export interface IncompleteCodeBlock { export interface IncompleteCodeBlock {
language: string; language: string;
@@ -3,7 +3,7 @@ import {
IMAGE_FILE_TYPES, IMAGE_FILE_TYPES,
PDF_FILE_TYPES, PDF_FILE_TYPES,
TEXT_FILE_TYPES TEXT_FILE_TYPES
} from '$lib/constants/supported-file-types'; } from '$lib/constants';
import { import {
FileExtensionAudio, FileExtensionAudio,
FileExtensionImage, FileExtensionImage,
@@ -4,7 +4,7 @@ import {
SECONDS_PER_HOUR, SECONDS_PER_HOUR,
SHORT_DURATION_THRESHOLD, SHORT_DURATION_THRESHOLD,
MEDIUM_DURATION_THRESHOLD MEDIUM_DURATION_THRESHOLD
} from '$lib/constants/formatters'; } from '$lib/constants';
/** /**
* Formats file size in bytes to human readable format * Formats file size in bytes to human readable format
@@ -3,7 +3,7 @@ import {
LATEX_MATH_AND_CODE_PATTERN, LATEX_MATH_AND_CODE_PATTERN,
LATEX_LINEBREAK_REGEXP, LATEX_LINEBREAK_REGEXP,
MHCHEM_PATTERN_MAP MHCHEM_PATTERN_MAP
} from '$lib/constants/latex-protection'; } from '$lib/constants';
/** /**
* Replaces inline LaTeX expressions enclosed in `$...$` with placeholders, avoiding dollar signs * Replaces inline LaTeX expressions enclosed in `$...$` with placeholders, avoiding dollar signs
@@ -5,7 +5,7 @@
* and display, addressing JavaScript's floating-point precision issues. * and display, addressing JavaScript's floating-point precision issues.
*/ */
import { PRECISION_MULTIPLIER } from '$lib/constants/precision'; import { PRECISION_MULTIPLIER } from '$lib/constants';
/** /**
* Normalize floating-point numbers for consistent comparison * Normalize floating-point numbers for consistent comparison
@@ -3,7 +3,7 @@
* Handles text file detection, reading, and validation * Handles text file detection, reading, and validation
*/ */
import { DEFAULT_BINARY_DETECTION_OPTIONS } from '$lib/constants/binary-detection'; import { DEFAULT_BINARY_DETECTION_OPTIONS } from '$lib/constants';
import type { BinaryDetectionOptions } from '$lib/types'; import type { BinaryDetectionOptions } from '$lib/types';
import { FileExtensionText } from '$lib/enums'; import { FileExtensionText } from '$lib/enums';
+1 -1
View File
@@ -14,7 +14,7 @@
import { Toaster } from 'svelte-sonner'; import { Toaster } from 'svelte-sonner';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { modelsStore } from '$lib/stores/models.svelte'; import { modelsStore } from '$lib/stores/models.svelte';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config'; import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
import { KeyboardKey } from '$lib/enums'; import { KeyboardKey } from '$lib/enums';
import { IsMobile } from '$lib/hooks/is-mobile.svelte'; import { IsMobile } from '$lib/hooks/is-mobile.svelte';
@@ -3,6 +3,7 @@
import { chatStore } from '$lib/stores/chat.svelte'; import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte'; import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
import { modelsStore, modelOptions } from '$lib/stores/models.svelte'; import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { replaceState } from '$app/navigation'; import { replaceState } from '$app/navigation';
@@ -71,6 +72,14 @@
conversationsStore.clearActiveConversation(); conversationsStore.clearActiveConversation();
chatStore.clearUIState(); chatStore.clearUIState();
if (
isRouterMode() &&
modelsStore.selectedModelName &&
!modelsStore.isModelLoaded(modelsStore.selectedModelName)
) {
modelsStore.clearSelection();
}
// Handle URL params only if we have ?q= or ?model= or ?new_chat=true // Handle URL params only if we have ?q= or ?model= or ?new_chat=true
if (qParam !== null || modelParam !== null || newChatParam === 'true') { if (qParam !== null || modelParam !== null || newChatParam === 'true') {
await handleUrlParams(); await handleUrlParams();
@@ -99,10 +99,12 @@
(option) => option.model === lastMessageWithModel.model (option) => option.model === lastMessageWithModel.model
); );
if (matchingModel) { if (matchingModel && modelsStore.isModelLoaded(matchingModel.model)) {
try { try {
await modelsStore.selectModelById(matchingModel.id); await modelsStore.selectModelById(matchingModel.id);
console.log(`Automatically loaded model: ${lastMessageWithModel.model} from last message`); console.log(
`Automatically selected model: ${lastMessageWithModel.model} from last message`
);
} catch (error) { } catch (error) {
console.warn('Failed to automatically select model from last message:', error); console.warn('Failed to automatically select model from last message:', error);
} }