]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui: Improvements for Models Selector UI (#20066)
authorAleksander Grygier <redacted>
Thu, 5 Mar 2026 07:52:22 +0000 (08:52 +0100)
committerGitHub <redacted>
Thu, 5 Mar 2026 07:52:22 +0000 (08:52 +0100)
54 files changed:
tools/server/public/index.html.gz
tools/server/webui/src/lib/components/app/actions/ActionIcon.svelte
tools/server/webui/src/lib/components/app/badges/BadgeModality.svelte
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte
tools/server/webui/src/lib/components/app/content/MarkdownContent.svelte
tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte
tools/server/webui/src/lib/components/app/models/ModelBadge.svelte
tools/server/webui/src/lib/components/app/models/ModelId.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte
tools/server/webui/src/lib/components/app/models/ModelsSelectorOption.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/models/index.ts
tools/server/webui/src/lib/components/ui/badge/badge.svelte
tools/server/webui/src/lib/components/ui/card/card.svelte
tools/server/webui/src/lib/constants/api-endpoints.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/index.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/localstorage-keys.ts
tools/server/webui/src/lib/constants/model-id.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/settings-config.ts
tools/server/webui/src/lib/constants/settings-keys.ts
tools/server/webui/src/lib/enums/keyboard.ts
tools/server/webui/src/lib/hooks/is-mobile.svelte.ts
tools/server/webui/src/lib/hooks/use-auto-scroll.svelte.ts
tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts
tools/server/webui/src/lib/markdown/enhance-code-blocks.ts
tools/server/webui/src/lib/markdown/literal-html.ts
tools/server/webui/src/lib/markdown/table-html-restorer.ts
tools/server/webui/src/lib/services/chat.service.ts
tools/server/webui/src/lib/services/models.service.ts
tools/server/webui/src/lib/stores/chat.svelte.ts
tools/server/webui/src/lib/stores/models.svelte.ts
tools/server/webui/src/lib/stores/settings.svelte.ts
tools/server/webui/src/lib/types/api.d.ts
tools/server/webui/src/lib/types/models.d.ts
tools/server/webui/src/lib/types/settings.d.ts
tools/server/webui/src/lib/utils/cache-ttl.ts
tools/server/webui/src/lib/utils/code.ts
tools/server/webui/src/lib/utils/file-type.ts
tools/server/webui/src/lib/utils/formatters.ts
tools/server/webui/src/lib/utils/latex-protection.ts
tools/server/webui/src/lib/utils/precision.ts
tools/server/webui/src/lib/utils/text-files.ts
tools/server/webui/src/routes/+layout.svelte
tools/server/webui/src/routes/+page.svelte
tools/server/webui/src/routes/chat/[id]/+page.svelte

index a5465fcd1328926223b97ce70814d89ec5f21cd5..77362ce66de8c9ccb1e661b4dbd3f710ad3a942a 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index 4494ea880b9d56ce4b89a95bf6af8f1d888f5f2b..c676e224a7221a63432070517aa3c5026866750d 100644 (file)
@@ -8,6 +8,7 @@
                tooltip: string;
                variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
                size?: 'default' | 'sm' | 'lg' | 'icon';
+               iconSize?: string;
                class?: string;
                disabled?: boolean;
                onclick: () => void;
@@ -21,6 +22,7 @@
                size = 'sm',
                class: className = '',
                disabled = false,
+               iconSize = 'h-3 w-3',
                onclick,
                'aria-label': ariaLabel
        }: Props = $props();
@@ -38,7 +40,7 @@
                >
                        {@const IconComponent = icon}
 
-                       <IconComponent class="h-3 w-3" />
+                       <IconComponent class={iconSize} />
                </Button>
        </Tooltip.Trigger>
 
index a0d5e863c2a210e33e683c8f507bc8321355fbad..15936691a6aa0bfcc37494eb88b53256579a6b62 100644 (file)
@@ -1,6 +1,6 @@
 <script lang="ts">
        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';
 
        type DisplayableModality = ModelModality.VISION | ModelModality.AUDIO;
index 3551b0b3d60678e048efface3ca1f644fee5b02d..37888d92e537199c7cbbea1112f354e3985b116e 100644 (file)
@@ -5,9 +5,11 @@
                ChatFormFileInputInvisible,
                ChatFormTextarea
        } from '$lib/components/app';
-       import { INPUT_CLASSES } from '$lib/constants/css-classes';
-       import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
-       import { CLIPBOARD_CONTENT_QUOTE_PREFIX } from '$lib/constants/chat-form';
+       import {
+               CLIPBOARD_CONTENT_QUOTE_PREFIX,
+               INPUT_CLASSES,
+               SETTING_CONFIG_DEFAULT
+       } from '$lib/constants';
        import { KeyboardKey, MimeTypeText } from '$lib/enums';
        import { config } from '$lib/stores/settings.svelte';
        import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
index b1cff67dcb532a3a7e4376cb467af095e5d88af7..86dca844829b852eef22f2a99a937b52ac99b46a 100644 (file)
@@ -4,8 +4,7 @@
        import { Button } from '$lib/components/ui/button';
        import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
        import * as Tooltip from '$lib/components/ui/tooltip';
-       import { FILE_TYPE_ICONS } from '$lib/constants/icons';
-       import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
+       import { FILE_TYPE_ICONS, TOOLTIP_DELAY_DURATION } from '$lib/constants';
 
        interface Props {
                class?: string;
index 54b11c86249c0d682deb40c66787a9bab62bc4a3..c94fe267d53b2b6b6ff64527c29dc865247d75db 100644 (file)
@@ -11,7 +11,7 @@
        import { getFileTypeCategory } from '$lib/utils';
        import { config } from '$lib/stores/settings.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 { activeMessages } from '$lib/stores/conversations.svelte';
 
@@ -45,6 +45,7 @@
 
        let currentConfig = $derived(config());
        let isRouter = $derived(isRouterMode());
+       let isOffline = $derived(!!serverError());
 
        let conversationModel = $derived(
                chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
        $effect(() => {
                if (conversationModel && conversationModel !== previousConversationModel) {
                        previousConversationModel = conversationModel;
-                       modelsStore.selectModelByName(conversationModel);
+
+                       if (!isRouter || modelsStore.isModelLoaded(conversationModel)) {
+                               modelsStore.selectModelByName(conversationModel);
+                       }
                }
        });
 
 
        <div class="ml-auto flex items-center gap-1.5">
                <ModelsSelector
-                       {disabled}
                        bind:this={selectorModelRef}
                        currentModel={conversationModel}
+                       disabled={disabled || isOffline}
                        forceForegroundText={true}
                        useGlobalSelection={true}
                />
index ebf7f433d1b46683694abe05e7db205454cedc8a..ba8990f01296f66e6e71cafad71434feaa509cc9 100644 (file)
@@ -5,7 +5,7 @@
        import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
        import { conversationsStore } from '$lib/stores/conversations.svelte';
        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 {
                ChatMessageAssistant,
index 263f90ec8046c208671a379b5e0b2f6c0a2a8d4d..5eb16f53a884b463b9f6739f7f68773c03ce98e4 100644 (file)
        import { Check, X } from '@lucide/svelte';
        import { Button } from '$lib/components/ui/button';
        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 Label from '$lib/components/ui/label/label.svelte';
        import { config } from '$lib/stores/settings.svelte';
        import { isRouterMode } from '$lib/stores/server.svelte';
        import { modelsStore } from '$lib/stores/models.svelte';
        import { ServerModelStatus } from '$lib/enums';
-       import { REASONING_TAGS } from '$lib/constants/agentic';
 
        interface Props {
                class?: string;
index 77951e9d2a35267c30f86a34d9c0ab065b2cc813..d74ecd782a878cef6b9a1730fbc8380468f59cef 100644 (file)
@@ -4,7 +4,7 @@
        import * as Tooltip from '$lib/components/ui/tooltip';
        import { ChatMessageStatsView } from '$lib/enums';
        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 {
                predictedTokens?: number;
index aec2d90c0286135a0cfbdb1a4d0bea4f0424fd63..9dadf3231b60e762ecfb7883f719f61bb6c70962 100644 (file)
@@ -4,7 +4,7 @@
        import { Button } from '$lib/components/ui/button';
        import { MarkdownContent } from '$lib/components/app';
        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 { isIMEComposing } from '$lib/utils';
        import ChatMessageActions from './ChatMessageActions.svelte';
index ceecf03e54dca59606c870cabe05ac1b9ed65d60..4efe2b212b4973dfc673c9e56c3f22107ea4025a 100644 (file)
@@ -12,7 +12,7 @@
        } from '$lib/components/app';
        import * as Alert from '$lib/components/ui/alert';
        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 { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
        import {
index cc7b22cfd8d2910c30f2ca6fcafa7be516be09e7..8c88480cef5ca227cee9f11b38d77fb5ba944286 100644 (file)
@@ -1,6 +1,6 @@
 <script lang="ts">
        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 { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
        import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte';
index 2130658dda50f31a12dbea5b14f5f621d010eb1c..ce2925b020e187290e0241266948c867a25713dd 100644 (file)
        import { config, settingsStore } from '$lib/stores/settings.svelte';
        import {
                SETTINGS_SECTION_TITLES,
-               type SettingsSectionTitle
-       } from '$lib/constants/settings-sections';
+               type SettingsSectionTitle,
+               NUMERIC_FIELDS,
+               POSITIVE_INTEGER_FIELDS,
+               SETTINGS_COLOR_MODES_CONFIG,
+               SETTINGS_KEYS
+       } from '$lib/constants';
        import { setMode } from 'mode-watcher';
        import { ColorMode } from '$lib/enums/ui';
        import { SettingsFieldType } from '$lib/enums/settings';
        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 {
                onSave?: () => void;
                                        key: SETTINGS_KEYS.AUTO_SHOW_SIDEBAR_ON_NEW_CHAT,
                                        label: 'Auto-show sidebar on new chat',
                                        type: SettingsFieldType.CHECKBOX
+                               },
+                               {
+                                       key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
+                                       label: 'Show raw model names',
+                                       type: SettingsFieldType.CHECKBOX
                                }
                        ]
                },
index 07749944118779d218dbf14b281ca8f158f6044a..b9015c196c1068dbb9e8de9e75f1e3257d44e604 100644 (file)
@@ -5,8 +5,7 @@
        import Label from '$lib/components/ui/label/label.svelte';
        import * as Select from '$lib/components/ui/select';
        import { Textarea } from '$lib/components/ui/textarea';
-       import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config';
-       import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
+       import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO, SETTINGS_KEYS } from '$lib/constants';
        import { SettingsFieldType } from '$lib/enums/settings';
        import { settingsStore } from '$lib/stores/settings.svelte';
        import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
index a0944e18a07ff285a9e2ee6d33ee7c4024c6022e..e4155afc5f46dda0489eeb4888b50dc1fd9a1656 100644 (file)
@@ -22,8 +22,9 @@
                IMAGE_NOT_ERROR_BOUND_SELECTOR,
                DATA_ERROR_BOUND_ATTR,
                DATA_ERROR_HANDLED_ATTR,
-               BOOL_TRUE_STRING
-       } from '$lib/constants/markdown';
+               BOOL_TRUE_STRING,
+               SETTINGS_KEYS
+       } from '$lib/constants';
        import { UrlPrefix } from '$lib/enums';
        import { FileTypeText } from '$lib/enums/files';
        import {
@@ -39,7 +40,6 @@
        import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
        import type { DatabaseMessageExtra } from '$lib/types/database';
        import { config } from '$lib/stores/settings.svelte';
-       import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
 
        interface Props {
                attachments?: DatabaseMessageExtra[];
index 7b1e598ce7237bf16ebdc8e9b3ee824ac07fc390..793af9a40f2fee0072566e48ca46a03dd6099587 100644 (file)
@@ -1,7 +1,7 @@
 <script lang="ts">
        import * as Dialog from '$lib/components/ui/dialog';
        import { ChatSettings } from '$lib/components/app';
-       import type { SettingsSectionTitle } from '$lib/constants/settings-sections';
+       import type { SettingsSectionTitle } from '$lib/constants';
 
        interface Props {
                onOpenChange?: (open: boolean) => void;
index f98ba7d78d7569fe8a4a3ed7a95ebe9ea9e2d1e2..cc1d1848e4b03e56091794fbe2c64b474c71e913 100644 (file)
@@ -1,6 +1,7 @@
 <script lang="ts">
        import { Package } from '@lucide/svelte';
        import { BadgeInfo, ActionIconCopyToClipboard } from '$lib/components/app';
+       import ModelId from './ModelId.svelte';
        import { modelsStore } from '$lib/stores/models.svelte';
        import { serverStore } from '$lib/stores/server.svelte';
        import * as Tooltip from '$lib/components/ui/tooltip';
@@ -23,6 +24,7 @@
 
        let model = $derived(modelProp || modelsStore.singleModelName);
        let isModelMode = $derived(serverStore.isModelMode);
+       let shouldShow = $derived(model && (modelProp !== undefined || isModelMode));
 </script>
 
 {#snippet badgeContent()}
@@ -31,7 +33,9 @@
                        <Package class="h-3 w-3" />
                {/snippet}
 
-               {model}
+               {#if model}
+                       <ModelId modelId={model} />
+               {/if}
 
                {#if showCopyIcon}
                        <ActionIconCopyToClipboard text={model || ''} ariaLabel="Copy model name" />
@@ -39,7 +43,7 @@
        </BadgeInfo>
 {/snippet}
 
-{#if model && isModelMode}
+{#if shouldShow}
        {#if showTooltip}
                <Tooltip.Root>
                        <Tooltip.Trigger>
diff --git a/tools/server/webui/src/lib/components/app/models/ModelId.svelte b/tools/server/webui/src/lib/components/app/models/ModelId.svelte
new file mode 100644 (file)
index 0000000..817e882
--- /dev/null
@@ -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}
index ebffae12120bc8e896388e4672ef56e85c955c59..a40501e2ccebc67fcc88052ecf97831ddec3b0a4 100644 (file)
@@ -1,6 +1,7 @@
 <script lang="ts">
        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 Tooltip from '$lib/components/ui/tooltip';
        import { cn } from '$lib/components/ui/utils';
                modelsLoading,
                modelsUpdating,
                selectedModelId,
-               routerModels,
                singleModelName
        } from '$lib/stores/models.svelte';
-       import { KeyboardKey, ServerModelStatus } from '$lib/enums';
+       import { KeyboardKey } from '$lib/enums';
        import { isRouterMode } from '$lib/stores/server.svelte';
        import {
                DialogModelInformation,
                DropdownMenuSearchable,
-               TruncatedText
+               ModelId,
+               ModelsSelectorOption
        } from '$lib/components/app';
        import type { ModelOption } from '$lib/types/models';
 
        interface Props {
                class?: string;
                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;
                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;
        }
 
        let {
                class: className = '',
                currentModel = null,
-               onModelChange,
                disabled = false,
                forceForegroundText = false,
+               onModelChange,
                useGlobalSelection = false
        }: Props = $props();
 
        let isRouter = $derived(isRouterMode());
        let serverModel = $derived(singleModelName());
 
-       // Reactive router models state - needed for proper reactivity of status checks
-       let currentRouterModels = $derived(routerModels());
+       let isHighlightedCurrentModelActive = $derived.by(() => {
+               if (!isRouter || !currentModel) return false;
 
-       function getModelStatus(modelId: string): ServerModelStatus | null {
-               const model = currentRouterModels.find((m) => m.id === modelId);
-               return (model?.status?.value as ServerModelStatus) ?? null;
-       }
-
-       let isHighlightedCurrentModelActive = $derived(
-               !isRouter || !currentModel
-                       ? false
-                       : (() => {
-                                       const currentOption = options.find((option) => option.model === currentModel);
+               const currentOption = options.find((option) => option.model === currentModel);
 
-                                       return currentOption ? currentOption.id === activeId : false;
-                               })()
-       );
+               return currentOption ? currentOption.id === activeId : false;
+       });
 
-       let isCurrentModelInCache = $derived(() => {
+       let isCurrentModelInCache = $derived.by(() => {
                if (!isRouter || !currentModel) return true;
 
                return options.some((option) => option.model === currentModel);
        });
 
+       let isLoadingModel = $state(false);
+
        let searchTerm = $state('');
        let highlightedIndex = $state<number>(-1);
 
-       let filteredOptions: ModelOption[] = $derived(
-               (() => {
-                       const term = searchTerm.trim().toLowerCase();
-                       if (!term) return options;
+       let filteredOptions: ModelOption[] = $derived.by(() => {
+               const term = searchTerm.trim().toLowerCase();
+               if (!term) return options;
+
+               return options.filter(
+                       (option) =>
+                               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))
+               );
+       });
 
-                       return options.filter(
-                               (option) =>
-                                       option.model.toLowerCase().includes(term) || option.name?.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(() => {
                void searchTerm;
                highlightedIndex = -1;
                });
        });
 
-       // Handle changes to the model selector dropdown or the model dialog, depending on if the server is in
-       // router mode or not.
        function handleOpenChange(open: boolean) {
                if (loading || updating) return;
 
 
                if (event.key === KeyboardKey.ARROW_DOWN) {
                        event.preventDefault();
+
                        if (filteredOptions.length === 0) return;
 
                        if (highlightedIndex === -1 || highlightedIndex === filteredOptions.length - 1) {
                        }
                } else if (event.key === KeyboardKey.ARROW_UP) {
                        event.preventDefault();
+
                        if (filteredOptions.length === 0) return;
 
                        if (highlightedIndex === -1 || highlightedIndex === 0) {
                        }
                } else if (event.key === KeyboardKey.ENTER) {
                        event.preventDefault();
+
                        if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
                                const option = filteredOptions[highlightedIndex];
+
                                handleSelect(option.id);
                        } else if (filteredOptions.length > 0) {
-                               // No selection - highlight first option
                                highlightedIndex = 0;
                        }
                }
                let shouldCloseMenu = true;
 
                if (onModelChange) {
-                       // If callback provided, use it (for regenerate functionality)
                        const result = await onModelChange(option.id, option.model);
 
-                       // If callback returns false, keep menu open (validation failed)
                        if (result === false) {
                                shouldCloseMenu = false;
                        }
                } else {
-                       // Update global selection
                        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) {
                        handleOpenChange(false);
 
-                       // Focus the chat textarea after model selection
                        requestAnimationFrame(() => {
                                const textarea = document.querySelector<HTMLTextAreaElement>(
                                        '[data-slot="chat-form"] textarea'
                                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 {
                if (!isRouter) {
-                       if (serverModel) {
+                       const displayModel = serverModel || currentModel;
+                       if (displayModel) {
                                return {
-                                       id: 'current',
-                                       model: serverModel,
-                                       name: serverModel.split('/').pop() || serverModel,
-                                       capabilities: [] // Empty array for single model mode
+                                       id: serverModel ? 'current' : 'offline-current',
+                                       model: displayModel,
+                                       name: displayModel.split('/').pop() || displayModel,
+                                       capabilities: []
                                };
                        }
 
                        return undefined;
                }
 
-               // When useGlobalSelection is true (form selector), prioritize user selection
-               // Otherwise (message display), prioritize currentModel
                if (useGlobalSelection && activeId) {
                        const selected = options.find((option) => option.id === activeId);
+
                        if (selected) return selected;
                }
 
-               // Show currentModel (from message payload or conversation)
                if (currentModel) {
-                       if (!isCurrentModelInCache()) {
+                       if (!isCurrentModelInCache) {
                                return {
                                        id: 'not-in-cache',
                                        model: currentModel,
                        return options.find((option) => option.model === currentModel);
                }
 
-               // Fallback to user selection (for new chats before first message)
                if (activeId) {
                        return options.find((option) => option.id === activeId);
                }
 
-               // No selection - return undefined to show "Select model"
                return undefined;
        }
 </script>
        {#if loading && options.length === 0 && isRouter}
                <div class="flex items-center gap-2 text-xs text-muted-foreground">
                        <Loader2 class="h-3.5 w-3.5 animate-spin" />
+
                        Loading models…
                </div>
        {: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}
                {@const selectedOption = getDisplayOption()}
 
                                                type="button"
                                                class={cn(
                                                        `inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
-                                                       !isCurrentModelInCache()
+                                                       !isCurrentModelInCache
                                                                ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
                                                                : forceForegroundText
                                                                        ? 'text-foreground'
                                        >
                                                <Package class="h-3.5 w-3.5" />
 
-                                               <TruncatedText
-                                                       text={selectedOption?.model || 'Select 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>
 
-                                               {#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" />
                                                {:else}
                                                        <ChevronDown class="h-3 w-3.5" />
                                                placeholder="Search models..."
                                                onSearchKeyDown={handleSearchKeyDown}
                                                emptyMessage="No models found."
-                                               isEmpty={filteredOptions.length === 0 && isCurrentModelInCache()}
+                                               isEmpty={filteredOptions.length === 0 && isCurrentModelInCache}
                                        >
                                                <div class="models-list">
-                                                       {#if !isCurrentModelInCache() && currentModel}
+                                                       {#if !isCurrentModelInCache && currentModel}
                                                                <!-- Show unavailable model as first option (disabled) -->
                                                                <button
                                                                        type="button"
                                                                        aria-disabled="true"
                                                                        disabled
                                                                >
-                                                                       <span
-                                                                               class="min-w-0 flex-1 truncate text-left sm:overflow-visible sm:text-clip sm:whitespace-nowrap"
-                                                                       >
-                                                                               {selectedOption?.name || currentModel}
-                                                                       </span>
+                                                                       <ModelId modelId={currentModel} class="flex-1" showOrgName />
+
                                                                        <span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
                                                                </button>
-                                                               <div class="my-1 h-px bg-border"></div>
                                                        {/if}
+
                                                        {#if filteredOptions.length === 0}
                                                                <p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
                                                        {/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
-                                                                       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={() => 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"
+
+                                                       {#each groupedFilteredOptions as group (group.isLoadedGroup ? '__loaded__' : group.isFavouritesGroup ? '__favourites__' : group.orgName)}
+                                                               {#if group.isLoadedGroup}
+                                                                       <p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
+                                                                               Loaded models
+                                                                       </p>
+                                                               {:else if group.isFavouritesGroup}
+                                                                       <p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
+                                                                               Favourite models
+                                                                       </p>
+                                                               {:else if group.orgName}
+                                                                       <p
+                                                                               class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
                                                                        >
-                                                                               {option.model}
-                                                                       </span>
-
-                                                                       <div class="flex w-6 shrink-0 justify-center">
-                                                                               {#if isLoading}
-                                                                                       <Tooltip.Root>
-                                                                                               <Tooltip.Trigger>
-                                                                                                       <Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
-                                                                                               </Tooltip.Trigger>
-                                                                                               <Tooltip.Content class="z-[9999]">
-                                                                                                       <p>Loading model...</p>
-                                                                                               </Tooltip.Content>
-                                                                                       </Tooltip.Root>
-                                                                               {:else if isLoaded}
-                                                                                       <Tooltip.Root>
-                                                                                               <Tooltip.Trigger>
-                                                                                                       <button
-                                                                                                               type="button"
-                                                                                                               class="relative flex h-4 w-4 items-center justify-center"
-                                                                                                               onclick={(e) => {
-                                                                                                                       e.stopPropagation();
-                                                                                                                       modelsStore.unloadModel(option.model);
-                                                                                                               }}
-                                                                                                       >
-                                                                                                               <span
-                                                                                                                       class="h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
-                                                                                                               ></span>
-                                                                                                               <Power
-                                                                                                                       class="absolute h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
-                                                                                                               />
-                                                                                                       </button>
-                                                                                               </Tooltip.Trigger>
-                                                                                               <Tooltip.Content class="z-[9999]">
-                                                                                                       <p>Unload model</p>
-                                                                                               </Tooltip.Content>
-                                                                                       </Tooltip.Root>
-                                                                               {:else}
-                                                                                       <span class="h-2 w-2 rounded-full bg-muted-foreground/50"></span>
-                                                                               {/if}
-                                                                       </div>
-                                                               </div>
+                                                                               {group.orgName}
+                                                                       </p>
+                                                               {/if}
+
+                                                               {#each group.items as { option, flatIndex } (group.isLoadedGroup ? `loaded-${option.id}` : group.isFavouritesGroup ? `fav-${option.id}` : option.id)}
+                                                                       {@const isSelected = currentModel === option.model || activeId === option.id}
+                                                                       {@const isHighlighted = flatIndex === highlightedIndex}
+                                                                       {@const isFav = modelsStore.favouriteModelIds.has(option.model)}
+
+                                                                       <ModelsSelectorOption
+                                                                               {option}
+                                                                               {isSelected}
+                                                                               {isHighlighted}
+                                                                               {isFav}
+                                                                               showOrgName={group.isFavouritesGroup || group.isLoadedGroup}
+                                                                               onSelect={handleSelect}
+                                                                               onMouseEnter={() => (highlightedIndex = flatIndex)}
+                                                                               onKeyDown={(e) => {
+                                                                                       if (e.key === KeyboardKey.ENTER || e.key === KeyboardKey.SPACE) {
+                                                                                               e.preventDefault();
+                                                                                               handleSelect(option.id);
+                                                                                       }
+                                                                               }}
+                                                                       />
+                                                               {/each}
                                                        {/each}
                                                </div>
                                        </DropdownMenuSearchable>
                        <button
                                class={cn(
                                        `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
-                                       !isCurrentModelInCache()
+                                       !isCurrentModelInCache
                                                ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
                                                : forceForegroundText
                                                        ? 'text-foreground'
                        >
                                <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}
                                        <Loader2 class="h-3 w-3.5 animate-spin" />
diff --git a/tools/server/webui/src/lib/components/app/models/ModelsSelectorOption.svelte b/tools/server/webui/src/lib/components/app/models/ModelsSelectorOption.svelte
new file mode 100644 (file)
index 0000000..d4239fb
--- /dev/null
@@ -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>
index bb3710d30a70d3f764aa136d4b5e1b1a12048834..aadc86cdad2a36a3bc2f5becdddf7dc00faeb2e8 100644 (file)
@@ -71,3 +71,5 @@ export { default as ModelsSelector } from './ModelsSelector.svelte';
  * ```
  */
 export { default as ModelBadge } from './ModelBadge.svelte';
+export { default as ModelId } from './ModelId.svelte';
+export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
index c3e6ac0720e6ee02f3928cc8e57bc3dc6121646f..9fbf0b80a592b56bfabf90dc566797200612b8d7 100644 (file)
@@ -8,6 +8,8 @@
                                default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
                                secondary:
                                        '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:
                                        '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'
index b9dcd2de6f4cdb9d4aecc0ed1f83c96802dcba52..d0a57d0c9704da401a68810c11473160b116ac53 100644 (file)
@@ -1,7 +1,7 @@
 <script lang="ts">
        import type { HTMLAttributes } from 'svelte/elements';
        import { cn, type WithElementRef } from '$lib/components/ui/utils';
-       import { BOX_BORDER } from '$lib/constants/css-classes';
+       import { BOX_BORDER } from '$lib/constants';
 
        let {
                ref = $bindable(null),
diff --git a/tools/server/webui/src/lib/constants/api-endpoints.ts b/tools/server/webui/src/lib/constants/api-endpoints.ts
new file mode 100644 (file)
index 0000000..f044241
--- /dev/null
@@ -0,0 +1,5 @@
+export const API_MODELS = {
+       LIST: '/v1/models',
+       LOAD: '/models/load',
+       UNLOAD: '/models/unload'
+};
diff --git a/tools/server/webui/src/lib/constants/index.ts b/tools/server/webui/src/lib/constants/index.ts
new file mode 100644 (file)
index 0000000..d80dc8f
--- /dev/null
@@ -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';
index 919b6ea06d346b89486020b6859b47eb70027579..6b9a9e0e2f8ff12d875f3f0e4743f0763c617a86 100644 (file)
@@ -1,2 +1,3 @@
 export const CONFIG_LOCALSTORAGE_KEY = 'LlamaCppWebui.config';
 export const USER_OVERRIDES_LOCALSTORAGE_KEY = 'LlamaCppWebui.userOverrides';
+export const FAVOURITE_MODELS_LOCALSTORAGE_KEY = 'LlamaCppWebui.favouriteModels';
diff --git a/tools/server/webui/src/lib/constants/model-id.ts b/tools/server/webui/src/lib/constants/model-id.ts
new file mode 100644 (file)
index 0000000..eb6662a
--- /dev/null
@@ -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]$/;
index 00dac3d6e9a6ed12b4b52c32b7ae2d312a4f3df3..9f3f06527e734f61173fa6fb900950ac01ede262 100644 (file)
@@ -23,6 +23,7 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
        autoShowSidebarOnNewChat: true,
        autoMicOnEmpty: false,
        fullHeightCodeBlocks: false,
+       showRawModelNames: false,
        // make sure these default values are in sync with `common.h`
        samplers: 'top_k;typ_p;top_p;min_p;temperature',
        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.',
        fullHeightCodeBlocks:
                '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:
                'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.',
        enableContinueGeneration:
index 38de41ffee37dee5309e099ac8e44b6c58a05f8f..0f531cb1e02f7ba15a02a6818cb701805e0112bd 100644 (file)
@@ -24,6 +24,7 @@ export const SETTINGS_KEYS = {
        ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
        AUTO_SHOW_SIDEBAR_ON_NEW_CHAT: 'autoShowSidebarOnNewChat',
        FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
+       SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
        // Sampling
        TEMPERATURE: 'temperature',
        DYNATEMP_RANGE: 'dynatemp_range',
index b8f6d5f7a28e2b5069c4e50497bb7070106371e8..bea68d790b5871fa3ee43419837ff281320164c5 100644 (file)
@@ -11,5 +11,6 @@ export enum KeyboardKey {
        D_UPPER = 'D',
        E_UPPER = 'E',
        K_LOWER = 'k',
-       O_UPPER = 'O'
+       O_UPPER = 'O',
+       SPACE = ' '
 }
index 22c74f4a6f07d440ffda670e3f52f08141ea2c8a..6454fc5b58a12ad53a0e56bb9e9ad7f7407bd472 100644 (file)
@@ -1,4 +1,4 @@
-import { DEFAULT_MOBILE_BREAKPOINT } from '$lib/constants/viewport';
+import { DEFAULT_MOBILE_BREAKPOINT } from '$lib/constants';
 import { MediaQuery } from 'svelte/reactivity';
 
 export class IsMobile extends MediaQuery {
index bbaa5d1362b56931c970f09f81f4fb1b598785cf..afd0aad8301de15dfa97c56e0d164f1f699b00a3 100644 (file)
@@ -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 {
        /** Whether auto-scroll is disabled globally (e.g., from settings) */
index 1205d9b97384c40da9da80ec34c8e544295dc1a5..f280319723f6faa7dfe1c9a8660f0845a0a25960 100644 (file)
@@ -1,6 +1,6 @@
 import { activeProcessingState } from '$lib/stores/chat.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';
 
 export interface UseProcessingStateReturn {
index 168de974037d68e3e7fbdb6bf2ba1c0d403d2c23..7c3b7f167f04808620daac64cd7943c3782ee335 100644 (file)
@@ -22,7 +22,7 @@ import {
        COPY_CODE_BTN_CLASS,
        PREVIEW_CODE_BTN_CLASS,
        RELATIVE_CLASS
-} from '$lib/constants/code-blocks';
+} from '$lib/constants';
 
 declare global {
        interface Window {
index d4ace01afe4fce51e92d3633157771d7d91024ca..c974d8b1893ed5bfa44568e3791c0ef9a73177b2 100644 (file)
@@ -1,7 +1,7 @@
 import type { Plugin } from 'unified';
 import { visit } from 'unist-util-visit';
 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.
index 918aa468116c14ceeeb591dfae2d1f94b7cbdafe..bc5d034653f20586a61878991242e833fb4258d4 100644 (file)
@@ -68,7 +68,7 @@ import type { Plugin } from 'unified';
 import type { Element, ElementContent, Root, Text } from 'hast';
 import { visit } from 'unist-util-visit';
 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.
index 71844946c571d4eac26ee58342d840eb6e60174f..ebddfe2e02e614335a10f42cf677692a9c6760dd 100644 (file)
@@ -1,5 +1,5 @@
 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 {
        AttachmentType,
        ContentPartType,
@@ -9,7 +9,6 @@ import {
 } from '$lib/enums';
 import type { ApiChatMessageContentPart, ApiChatCompletionToolCall } from '$lib/types/api';
 import { modelsStore } from '$lib/stores/models.svelte';
-import { AGENTIC_REGEX } from '$lib/constants/agentic';
 
 export class ChatService {
        private static stripReasoningContent(
index 347f171846588c2942f88669867801320ff504df..de90c48cf00f72e0a35bd6364ba18069908ce518 100644 (file)
@@ -1,5 +1,16 @@
 import { ServerModelStatus } from '$lib/enums';
 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 {
        /**
@@ -17,7 +28,7 @@ export class ModelsService {
         * @returns List of available models with basic metadata
         */
        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
         */
        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;
                }
 
-               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
         */
        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 {
                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;
+       }
 }
index 66d6eaf0d30ad8acfa3dfd2cdbb171ff0557e727..cad82b8472ba617b597acd5de54e66d1bb208d0b 100644 (file)
@@ -28,12 +28,12 @@ import {
        findLeafNode,
        isAbortError
 } from '$lib/utils';
-import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
-import { REASONING_TAGS } from '$lib/constants/agentic';
 import {
        MAX_INACTIVE_CONVERSATION_STATES,
-       INACTIVE_CONVERSATION_STATE_MAX_AGE_MS
-} from '$lib/constants/cache';
+       INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
+       REASONING_TAGS,
+       SYSTEM_MESSAGE_PLACEHOLDER
+} from '$lib/constants';
 import type {
        ChatMessageTimings,
        ChatMessagePromptProgress,
index c4cc3d38606282c76a1f22dfb75db76a53934ffb..a6d7d6572ff2bc061fcd17d26b186232813ce9af 100644 (file)
@@ -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 { ModelsService, PropsService } from '$lib/services';
 import { serverStore } from '$lib/stores/server.svelte';
 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
@@ -50,7 +55,9 @@ class ModelsStore {
        selectedModelName = $state<string | null>(null);
 
        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
@@ -261,15 +268,19 @@ class ModelsStore {
                                const displayNameSource =
                                        details?.name && details.name.trim().length > 0 ? details.name : item.id;
                                const displayName = this.toDisplayName(displayNameSource);
+                               const modelId = details?.model || item.id;
 
                                return {
                                        id: item.id,
                                        name: displayName,
-                                       model: details?.model || item.id,
+                                       model: modelId,
                                        description: details?.description,
                                        capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
                                        details: details?.details,
-                                       meta: item.meta ?? null
+                                       meta: item.meta ?? null,
+                                       parsedId: ModelsService.parseModelId(modelId),
+                                       aliases: item.aliases ?? [],
+                                       tags: item.tags ?? []
                                } satisfies ModelOption;
                        });
 
@@ -497,22 +508,21 @@ class ModelsStore {
 
        /** Polling interval in ms for checking model status */
        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.
-        * 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 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(
                modelId: string,
                expectedStatus: ServerModelStatus
        ): Promise<void> {
-               for (let attempt = 0; attempt < ModelsStore.STATUS_POLL_MAX_ATTEMPTS; attempt++) {
+               let attempt = 0;
+               while (true) {
                        await this.fetchRouterModels();
 
                        const currentStatus = this.getModelStatus(modelId);
@@ -520,12 +530,23 @@ class ModelsStore {
                                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));
                }
-
-               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.updateModelModalities(modelId);
+                       toast.success(`Model loaded: ${this.toDisplayName(modelId)}`);
                } catch (error) {
                        this.error = error instanceof Error ? error.message : 'Failed to load model';
+                       toast.error(`Failed to load model: ${this.toDisplayName(modelId)}`);
                        throw error;
                } finally {
                        this.modelLoadingStates.set(modelId, false);
@@ -573,8 +596,10 @@ class ModelsStore {
                        await ModelsService.unload(modelId);
 
                        await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
+                       toast.info(`Model unloaded: ${this.toDisplayName(modelId)}`);
                } catch (error) {
                        this.error = error instanceof Error ? error.message : 'Failed to unload model';
+                       toast.error(`Failed to unload model: ${this.toDisplayName(modelId)}`);
                        throw error;
                } finally {
                        this.modelLoadingStates.set(modelId, false);
@@ -593,6 +618,48 @@ class ModelsStore {
                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 singleModelName = () => modelsStore.singleModelName;
 export const selectedModelContextSize = () => modelsStore.selectedModelContextSize;
+export const favouriteModelIds = () => modelsStore.favouriteModelIds;
index 68431f4e32ebfee090a0532608606e0bcceccb07..8ab817c071aedea95d72ceaad09c79476a8392a8 100644 (file)
  */
 
 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 { serverStore } from '$lib/stores/server.svelte';
 import {
@@ -41,10 +45,6 @@ import {
        getConfigValue,
        setConfigValue
 } from '$lib/utils';
-import {
-       CONFIG_LOCALSTORAGE_KEY,
-       USER_OVERRIDES_LOCALSTORAGE_KEY
-} from '$lib/constants/localstorage-keys';
 
 class SettingsStore {
        /**
index 307e3b71d92b972e03b8d0cb5586ffaed86b79f1..c908258427bfc59029d0b71a3934faf6bbc675f7 100644 (file)
@@ -81,6 +81,10 @@ export interface ApiModelDataEntry {
        path: string;
        /** Current status of the model */
        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) */
        meta?: Record<string, unknown> | null;
 }
index 505867a1f0d23d0e170b9ab287b6e0b8f608d9a8..dc8e86485ceb32d78ae8cdf3569b7c31c056b87e 100644 (file)
@@ -14,6 +14,20 @@ export interface ModelOption {
        modalities?: ModelModalities;
        details?: ApiModelDetails['details'];
        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[];
 }
 
 /**
index 303462b2ccb6c91823a3572fcd940e5fd4a471ec..f9f5a7824ffad334de153bf785a57c5b74e70405 100644 (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 { DatabaseMessageExtra } from './database';
 import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums';
index 9a69501d0f3a34b8ac07da220f793ebcbc5f7333..4e414dd5457b56ed9ee16ab1fa55bdce49239931 100644 (file)
@@ -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
index 67efc6b27e2b388a8a1033661ad8f76888267ad7..d83bc31af3f5c5537dd879eaa3e845c355d97e97 100644 (file)
@@ -7,7 +7,7 @@ import {
        LT_REGEX,
        GT_REGEX,
        FENCE_PATTERN
-} from '$lib/constants/code';
+} from '$lib/constants';
 
 export interface IncompleteCodeBlock {
        language: string;
index 9a9996d171c6ae20807d2c1826aeb90cf9f7507d..4c670600ce5cb76796c1922c3858d5e66213e528 100644 (file)
@@ -3,7 +3,7 @@ import {
        IMAGE_FILE_TYPES,
        PDF_FILE_TYPES,
        TEXT_FILE_TYPES
-} from '$lib/constants/supported-file-types';
+} from '$lib/constants';
 import {
        FileExtensionAudio,
        FileExtensionImage,
index 37a8a3358cf98b362dc13c90d2018d5877387258..24a2c1c94c1899f11bf45ad0c864664861c1a6f5 100644 (file)
@@ -4,7 +4,7 @@ import {
        SECONDS_PER_HOUR,
        SHORT_DURATION_THRESHOLD,
        MEDIUM_DURATION_THRESHOLD
-} from '$lib/constants/formatters';
+} from '$lib/constants';
 
 /**
  * Formats file size in bytes to human readable format
index cafa2d4761fd65aaa5932d7b2597377899bc1837..839306978f332d32ea637e283893909ffe13cd4e 100644 (file)
@@ -3,7 +3,7 @@ import {
        LATEX_MATH_AND_CODE_PATTERN,
        LATEX_LINEBREAK_REGEXP,
        MHCHEM_PATTERN_MAP
-} from '$lib/constants/latex-protection';
+} from '$lib/constants';
 
 /**
  * Replaces inline LaTeX expressions enclosed in `$...$` with placeholders, avoiding dollar signs
index 6da200cf0b7ed178334c065df1568be25231e6eb..500281dc9a247b17aa5b552afa24f7caed01ce7a 100644 (file)
@@ -5,7 +5,7 @@
  * 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
index b7fdd4038cb4f7d50e159b7910aada075537de56..3f7a55ebc26da161e72e8b627ef84e371ea803bf 100644 (file)
@@ -3,7 +3,7 @@
  * 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 { FileExtensionText } from '$lib/enums';
 
index 705066119dcfb1d7038a19cc328c96c0acc6f8c3..4e9bf399000a902ac9dc2562563e9b29f30ebc4c 100644 (file)
@@ -14,7 +14,7 @@
        import { Toaster } from 'svelte-sonner';
        import { goto } from '$app/navigation';
        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 { IsMobile } from '$lib/hooks/is-mobile.svelte';
 
index 32a7c2e6e426f0b91818131534f8fc8f0f6a6450..3f51e3ab3619a249563dac54e5afe0919789a485 100644 (file)
@@ -3,6 +3,7 @@
        import { chatStore } from '$lib/stores/chat.svelte';
        import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
        import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
+       import { isRouterMode } from '$lib/stores/server.svelte';
        import { onMount } from 'svelte';
        import { page } from '$app/state';
        import { replaceState } from '$app/navigation';
                conversationsStore.clearActiveConversation();
                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
                if (qParam !== null || modelParam !== null || newChatParam === 'true') {
                        await handleUrlParams();
index b897ef5bcd47e5dd6b1ccbd2bc0a2f2d0bc3bda1..c0b2e0c5e7a2678d35a5254074dde811a726d41c 100644 (file)
                        (option) => option.model === lastMessageWithModel.model
                );
 
-               if (matchingModel) {
+               if (matchingModel && modelsStore.isModelLoaded(matchingModel.model)) {
                        try {
                                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) {
                                console.warn('Failed to automatically select model from last message:', error);
                        }