diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte index a4baa40bf9..434db0cf4f 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte @@ -1,6 +1,14 @@ -{#if hasError} +{#if hasError || isLoadingModel}
{#if isLoadingModel} diff --git a/tools/ui/src/lib/constants/icons.constants.ts b/tools/ui/src/lib/constants/icons.constants.ts index 71741c6957..38de4e0cdc 100644 --- a/tools/ui/src/lib/constants/icons.constants.ts +++ b/tools/ui/src/lib/constants/icons.constants.ts @@ -31,9 +31,6 @@ export const DEFAULT_FILE_ICON = FileIcon; /** The mark for a model, wherever one is listed. */ export const MODEL_ICON = ModelIcon; -/** Alias kept for the selector until it adopts MODEL_ICON. */ -export const MODEL_SELECTOR_ICON = ModelIcon; - export const MODALITY_ICONS = { [ModelModality.AUDIO]: AudioIcon, [ModelModality.VIDEO]: VideoIcon, diff --git a/tools/ui/src/lib/constants/settings-keys.constants.ts b/tools/ui/src/lib/constants/settings-keys.constants.ts index 7727370bde..574c906de2 100644 --- a/tools/ui/src/lib/constants/settings-keys.constants.ts +++ b/tools/ui/src/lib/constants/settings-keys.constants.ts @@ -41,6 +41,7 @@ export const SETTINGS_KEYS = { MCP_SERVERS: 'mcpServers', MENTION_SEARCH_MAX_DEPTH: 'mentionSearchMaxDepth', MIN_P: 'min_p', + // Display PASTE_LONG_TEXT_TO_FILE_LEN: 'pasteLongTextToFileLen', PDF_AS_IMAGE: 'pdfAsImage', // Performance @@ -58,7 +59,8 @@ export const SETTINGS_KEYS = { SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions', // Display SHOW_MESSAGE_STATS: 'showMessageStats', - SHOW_MODEL_ORG_NAME_IN_TRIGGER: 'showModelOrgNameInTrigger', + SHOW_MODEL_CAPABILITIES_IN_SELECTOR: 'showModelCapabilitiesInSelector', + SHOW_MODEL_ORG_NAME: 'showModelOrgName', SHOW_MODEL_QUANTIZATION: 'showModelQuantization', SHOW_MODEL_TAGS: 'showModelTags', SHOW_RAW_MODEL_NAMES: 'showRawModelNames', diff --git a/tools/ui/src/lib/constants/settings.constants.ts b/tools/ui/src/lib/constants/settings.constants.ts index 27d20c9243..2d5cb4afec 100644 --- a/tools/ui/src/lib/constants/settings.constants.ts +++ b/tools/ui/src/lib/constants/settings.constants.ts @@ -28,6 +28,7 @@ import type { /** Settings sections — slug is the routing identity, title is the display label. */ export const SETTINGS_SECTIONS = { AGENTIC: { slug: 'agentic', title: 'Agentic' }, + BACKENDS: { slug: 'backends', title: 'Providers' }, DEVELOPER: { slug: 'developer', title: 'Developer' }, DISPLAY: { slug: 'display', title: 'Display' }, GENERAL: { slug: 'general', title: 'General' }, @@ -38,6 +39,7 @@ export const SETTINGS_SECTIONS = { export const SETTINGS_SECTION_SLUGS = { AGENTIC: SETTINGS_SECTIONS.AGENTIC.slug, + BACKENDS: SETTINGS_SECTIONS.BACKENDS.slug, DEVELOPER: SETTINGS_SECTIONS.DEVELOPER.slug, DISPLAY: SETTINGS_SECTIONS.DISPLAY.slug, GENERAL: SETTINGS_SECTIONS.GENERAL.slug, @@ -48,6 +50,7 @@ export const SETTINGS_SECTION_SLUGS = { export const SETTINGS_SECTION_TITLES = { AGENTIC: SETTINGS_SECTIONS.AGENTIC.title, + BACKENDS: SETTINGS_SECTIONS.BACKENDS.title, DEVELOPER: SETTINGS_SECTIONS.DEVELOPER.title, DISPLAY: SETTINGS_SECTIONS.DISPLAY.title, GENERAL: SETTINGS_SECTIONS.GENERAL.title, @@ -291,9 +294,16 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [ }, { defaultValue: false, - help: 'Display the organization name in the model selector trigger button.', - key: SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER, - label: 'Show organization name in model selector trigger', + help: 'Show the modality and capability icons (vision, audio, reasoning, tools) on the models selector rows.', + key: SETTINGS_KEYS.SHOW_MODEL_CAPABILITIES_IN_SELECTOR, + label: 'Show model modalities & capabilities in models selector', + type: SettingsFieldType.CHECKBOX + }, + { + defaultValue: true, + help: 'Display the organization name alongside model names in the model selector, in the trigger and in the model rows.', + key: SETTINGS_KEYS.SHOW_MODEL_ORG_NAME, + label: 'Show organization name in model selector', type: SettingsFieldType.CHECKBOX }, { @@ -303,19 +313,19 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [ label: 'Show build version information', type: SettingsFieldType.CHECKBOX }, - { - defaultValue: true, - help: 'Group models into families (e.g. "Qwen") in the model picker and the models manager. Turn it off to list every model on its own.', - key: SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY, - label: 'Group models by family', - type: SettingsFieldType.CHECKBOX - }, { defaultValue: false, help: 'Display the full file system path inside file and folder @-mention badges instead of just the file or folder name.', key: SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS, label: 'Show full path in mentions', type: SettingsFieldType.CHECKBOX + }, + { + defaultValue: true, + help: 'Group models into families (e.g. "Qwen") in the model picker and the models manager. Turn it off to list every model on its own.', + key: SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY, + label: 'Group models by family', + type: SettingsFieldType.CHECKBOX } ], slug: SETTINGS_SECTION_SLUGS.DISPLAY, diff --git a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts index 7d2770a261..38c5f3b42b 100644 --- a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts +++ b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts @@ -1,14 +1,35 @@ -import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils'; -import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants'; -import { modelsStore, serverStore } from '$lib/stores'; +import type { ModelItem } from '$lib/components/app/navigation/utils'; +import { + filterModelOptions, + groupFavoriteOptions, + groupModelOptions, + groupProviderOptions +} from '$lib/components/app/navigation/utils'; +import { + CHAT_INPUT_FOCUS_SELECTOR, + LOCAL_BACKEND_ID, + REMOTE_PROVIDER_MODEL_LIMIT +} from '$lib/constants'; +import { backendsModelsStore, backendsStore, modelsStore, serverStore, uiStore } from '$lib/stores'; import type { ModelOption } from '$lib/types/models'; +import { getBackend } from '$lib/utils/api-base'; +import { getBackendCapabilities } from '$lib/utils/backend'; +import { rawModelId } from '$lib/utils/model-option-id'; import { onMount } from 'svelte'; +import { SvelteSet } from 'svelte/reactivity'; + +/** Groups of the favorites tab, which lists favorites only. */ +const EMPTY_GROUPS = { available: [], loaded: [], providers: [] }; export interface UseModelsSelectorOptions { currentModel: () => string | null; useGlobalSelection?: () => boolean; onModelChange?: () => - | ((modelId: string, modelName: string) => Promise | boolean | void) + | (( + modelId: string, + modelName: string, + backendId?: string + ) => Promise | boolean | void) | undefined; onOpenChange?: (open: boolean) => void; } @@ -18,19 +39,24 @@ export interface UseModelsSelectorReturn { readonly loading: boolean; readonly updating: boolean; readonly activeId: string | null; + readonly emptyMessage: string; + readonly isMultiModel: boolean; readonly isRouter: boolean; readonly serverModel: string | null; readonly isHighlightedCurrentModelActive: boolean; readonly isCurrentModelInCache: boolean; + readonly favoriteItems: ModelItem[]; + readonly loadedItems: ModelItem[]; readonly filteredOptions: ModelOption[]; + readonly isEmpty: boolean; + readonly isProviderView: boolean; readonly groupedFilteredOptions: ReturnType; readonly isLoadingModel: boolean; readonly searchTerm: string; - readonly showModelDialog: boolean; - readonly infoModelId: string | null; + closeProvider(): void; + openProvider(backendId: string): void; setSearchTerm(value: string): void; - setShowModelDialog(value: boolean): void; - handleInfoClick(modelName: string): void; + showBackendModels(backendId: string): Promise; handleSelect(modelId: string): Promise; handleOpenChange(open: boolean): void; isFavorite(model: string): boolean; @@ -45,16 +71,36 @@ export interface UseModelsSelectorReturn { * duplicating store derivations, selection handling, and model loading. */ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSelectorReturn { - const options = $derived( + /** + * Current view: the favorites of every backend, the local server's models, or + * the remote backends'. Favorites are the default while there is at least one. + */ + /** Remote backend drilled into from its section; null while browsing. */ + let providerViewId = $state(null); + + const isProviderView = $derived(providerViewId !== null); + const isLocalOption = (option: ModelOption) => option.backendId === LOCAL_BACKEND_ID; + // every enabled backend's models are one list: favorites, then the local + // server, then one section per remote provider + const allOptions = $derived( modelsStore.models.filter((option) => { const modelProps = modelsStore.props.getModelProps(option.model); return modelProps?.ui !== false; }) ); + const options = $derived( + providerViewId ? allOptions.filter((option) => option.backendId === providerViewId) : allOptions + ); const loading = $derived(modelsStore.loading); const updating = $derived(modelsStore.updating); const activeId = $derived(modelsStore.selectedModelId); + // Router mode and external backends both expose a selectable model list; only + // a lone llama.cpp server without a router has nothing to choose from. + const isMultiModel = $derived( + serverStore.isRouterMode || + backendsStore.enabled.some((backend) => backend.id !== LOCAL_BACKEND_ID) + ); const isRouter = $derived(serverStore.isRouterMode); const serverModel = $derived(modelsStore.singleModelName); const currentModel = $derived(opts.currentModel()); @@ -62,32 +108,95 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele const isHighlightedCurrentModelActive = $derived.by(() => { if (!isRouter || !currentModel) return false; - const currentOption = options.find((option) => option.model === currentModel); + const currentOption = allOptions.find((option) => option.model === currentModel); return currentOption ? currentOption.id === activeId : false; }); const isCurrentModelInCache = $derived.by(() => { if (!isRouter || !currentModel) return true; - return options.some((option) => option.model === currentModel); + return allOptions.some((option) => option.model === currentModel); }); let isLoadingModel = $state(false); let searchTerm = $state(''); - let showModelDialog = $state(false); - let infoModelId = $state(null); + const visibleOptions = $derived(allOptions.filter((option) => !modelsStore.isHidden(option.id))); const filteredOptions = $derived(filterModelOptions(options, searchTerm)); - const groupedFilteredOptions = $derived( - groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (m) => - modelsStore.isModelLoaded(m) + // favorites span every backend, so they come from the full option list + const favoriteItems = $derived( + groupFavoriteOptions( + filterModelOptions(visibleOptions, searchTerm), + modelsStore.favoriteModelIds ) ); + const remoteProviders = $derived( + backendsStore.enabled + .filter((backend) => backend.id !== LOCAL_BACKEND_ID) + .map((backend) => { + const state = backendsModelsStore.get(backend.id); - function handleInfoClick(modelName: string) { - infoModelId = modelName; - showModelDialog = true; - } + return { + backendId: backend.id, + catalog: state.models.length, + error: state.error, + loading: state.loading, + name: backend.name + }; + }) + ); + // loaded models lead the list, from any llama-compat backend + const isLoadedLlamaCompat = (option: ModelOption) => + modelsStore.isModelLoaded(option.model) && + getBackendCapabilities(getBackend(option.backendId)).loadUnload; + const loadedItems = $derived.by(() => { + if (isProviderView) return []; + + return filterModelOptions(visibleOptions, searchTerm) + .map((option, flatIndex) => ({ flatIndex, option })) + .filter(({ option }) => isLoadedLlamaCompat(option)); + }); + const loadedIds = $derived(new SvelteSet(loadedItems.map((item) => item.option.id))); + // loaded models and favorites are listed once, at the top: the sections skip both + const sectionOptions = $derived( + filteredOptions.filter( + (option) => !modelsStore.favoriteModelIds.has(option.model) && !loadedIds.has(option.id) + ) + ); + const providerSections = $derived( + groupProviderOptions( + sectionOptions, + remoteProviders, + // a drill-in or a search reaches every model, the sections stay short + providerViewId || searchTerm ? Infinity : REMOTE_PROVIDER_MODEL_LIMIT, + modelsStore.recentModelIds + ) + ); + const groupedFilteredOptions = $derived.by(() => { + if (isProviderView) { + const sections = providerSections.filter((section) => section.backendId === providerViewId); + + return { ...EMPTY_GROUPS, providers: sections }; + } + + const local = groupModelOptions(sectionOptions.filter(isLocalOption), (m) => + modelsStore.isModelLoaded(m) + ); + + return { ...local, providers: providerSections }; + }); + const isEmpty = $derived( + filteredOptions.length === 0 && favoriteItems.length === 0 && loadedItems.length === 0 + ); + const emptyMessage = $derived(searchTerm ? 'No models found.' : 'No models yet.'); + + // a row action can hand over to the models manager, which takes the focus; + // the selector closes so it does not sit open behind the dialog + $effect(() => { + if (!uiStore.manageModelsOpen) return; + + opts.onOpenChange?.(false); + }); onMount(() => { modelsStore.fetch().catch((error) => { @@ -98,36 +207,61 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele function handleOpenChange(open: boolean) { if (loading || updating) return; - if (isRouter) { - searchTerm = ''; + // a single-model llama.cpp server with no other backend has no list to show, + // so the trigger opens the manager, which holds the one model's configuration + if (!isMultiModel) { + if (open) uiStore.openModelsManager(); - if (open) { - modelsStore.fetchRouterModels().then(() => { - modelsStore.props.fetchModalitiesForLoadedModels(); - }); - } - - opts.onOpenChange?.(open); - } else { - showModelDialog = open; + return; } + + searchTerm = ''; + providerViewId = null; + + if (open && isRouter) { + modelsStore.props.fetchModalitiesForLoadedModels(); + } + + opts.onOpenChange?.(open); + } + + /** + * Switch the rendered view. Views are display only: the backend that serves + * requests follows the selected model, not the view. + */ + /** Drill into one remote backend's full model list. */ + function openProvider(backendId: string) { + providerViewId = backendId; + searchTerm = ''; + } + + function closeProvider() { + providerViewId = null; + searchTerm = ''; + } + + /** Refresh a backend's models, e.g. right after it was added. */ + async function showBackendModels(backendId: string): Promise { + await backendsModelsStore.ensureLoaded(backendId); } async function handleSelect(modelId: string) { - const option = options.find((opt) => opt.id === modelId); + // favorites live above the tabs and may belong to another backend, so the + // lookup spans every enabled backend + const option = allOptions.find((opt) => opt.id === modelId); if (!option) return; let shouldCloseMenu = true; if (onModelChange) { - const result = await onModelChange(option.id, option.model); + const result = await onModelChange(rawModelId(option.id), option.model, option.backendId); if (result === false) { shouldCloseMenu = false; } } else { - await modelsStore.selectModelById(option.id); + await modelsStore.selectModelById(option.id, { recordRecent: true }); } if (shouldCloseMenu) { @@ -140,7 +274,10 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele }); } - if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) { + // only the built-in server loads on request, and only in router mode + const canLoadHere = option.backendId === LOCAL_BACKEND_ID && isRouter; + + if (!onModelChange && canLoadHere && !modelsStore.isModelLoaded(option.model)) { isLoadingModel = true; modelsStore.status @@ -152,6 +289,18 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele function getDisplayOption(): ModelOption | undefined { if (!isRouter) { + // External backend: the selection is backend-scoped, so it wins over + // the conversation's model, which may belong to another backend. + if (!serverStore.capabilities.props) { + const selected = activeId ? allOptions.find((option) => option.id === activeId) : undefined; + + if (selected) return selected; + + return currentModel + ? allOptions.find((option) => option.model === currentModel) + : undefined; + } + const displayModel = serverModel || currentModel; if (displayModel) { @@ -176,11 +325,11 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele }; } - return options.find((option) => option.model === currentModel); + return allOptions.find((option) => option.model === currentModel); } if (activeId) { - return options.find((option) => option.id === activeId); + return allOptions.find((option) => option.id === activeId); } return undefined; @@ -191,6 +340,15 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele return activeId; }, + closeProvider, + + get emptyMessage() { + return emptyMessage; + }, + + get favoriteItems() { + return favoriteItems; + }, get filteredOptions() { return filteredOptions; }, @@ -201,20 +359,18 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele return groupedFilteredOptions; }, - handleInfoClick, - handleOpenChange, handleSelect, - get infoModelId() { - return infoModelId; - }, - get isCurrentModelInCache() { return isCurrentModelInCache; }, + get isEmpty() { + return isEmpty; + }, + isFavorite(model: string) { return modelsStore.favoriteModelIds.has(model); }, @@ -227,14 +383,28 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele return isLoadingModel; }, + get isMultiModel() { + return isMultiModel; + }, + + get isProviderView() { + return isProviderView; + }, + get isRouter() { return isRouter; }, + get loadedItems() { + return loadedItems; + }, + get loading() { return loading; }, + openProvider, + get options() { return options; }, @@ -251,13 +421,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele searchTerm = value; }, - setShowModelDialog(value: boolean) { - showModelDialog = value; - }, - - get showModelDialog() { - return showModelDialog; - }, + showBackendModels, get updating() { return updating; diff --git a/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts b/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts index 3c52d7ab27..ab3012d9c6 100644 --- a/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts +++ b/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts @@ -20,8 +20,8 @@ export interface UseReasoningMenuReturn { /** * Shared reactive state and helpers for the reasoning effort menu. * - * Used by both the desktop dropdown (`ChatFormActionAddReasoningSubmenu`) - * and the mobile sheet (`ChatFormActionAddSheet`) to avoid duplicating the + * Used by the models dropdown footer (`ModelsSelectorReasoningPanel`) and the + * mobile sheet (`ChatFormActionAddSheet`) to avoid duplicating the * thinking-support derivation and the effort selection logic. */ export function useReasoningMenu(): UseReasoningMenuReturn { diff --git a/tools/ui/src/lib/stores/settings/index.svelte.ts b/tools/ui/src/lib/stores/settings/index.svelte.ts index a583a1423f..fb98b03dcc 100644 --- a/tools/ui/src/lib/stores/settings/index.svelte.ts +++ b/tools/ui/src/lib/stores/settings/index.svelte.ts @@ -212,7 +212,7 @@ class SettingsStore { /** * Initialize the settings store by loading from localStorage. - * Called by initStores() after migrations have run. + * Called by hydrateStores() after migrations have run. */ initialize() { if (!browser) return; @@ -220,6 +220,7 @@ class SettingsStore { try { this.loadConfig(); this.migrateLegacyTheme(); + this.migrateModelOrgNameKey(); // Apply the persisted theme from config on initial load setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode); this.isInitialized = true; @@ -475,6 +476,30 @@ class SettingsStore { } } + /** + * Move the trigger-only org name key onto the selector wide one. The setting + * grew from the trigger to every model id, so an explicit choice has to + * survive the rename. + */ + private migrateModelOrgNameKey() { + if (!browser) return; + + const legacyKey = 'showModelOrgNameInTrigger'; + const { config: saved } = SettingsService.loadConfig(); + + if (!(legacyKey in saved)) return; + + this.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME] = Boolean(saved[legacyKey]); + + if (this.userOverrides.delete(legacyKey)) { + this.userOverrides.add(SETTINGS_KEYS.SHOW_MODEL_ORG_NAME); + } + + delete (this.config as Record)[legacyKey]; + + this.saveConfig(); + } + /** * Save the current configuration to localStorage via the persistence service. */ diff --git a/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte b/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte index 826e0d4aa1..c56bfd7678 100644 --- a/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte +++ b/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte @@ -2,25 +2,8 @@ import { defineMeta } from '@storybook/addon-svelte-csf'; import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte'; import { ATTACHMENT_TOOLTIP_TEXT } from '$lib/constants'; - import { ServerRole } from '$lib/enums'; - import { serverStore } from '$lib/stores'; - import type { ApiLlamaCppServerProps } from '$lib/types'; import { expect, screen, waitFor } from 'storybook/test'; - /** - * The add menu mounts the reasoning submenu only outside router mode, and the - * dev server proxies /props to whichever server happens to be running, so pin - * the mode this story asserts instead of inheriting it from the environment. - */ - function pinSingleModelMode(): void { - serverStore.props = { - ...(serverStore.props ?? {}), - role: ServerRole.MODEL - } as ApiLlamaCppServerProps; - - serverStore.role = ServerRole.MODEL; - } - const { Story } = defineMeta({ component: ChatScreenForm, parameters: { @@ -55,8 +38,6 @@ args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }} name="AddDropdownFocusesFirstEnabled" play={async ({ canvas, userEvent }) => { - pinSingleModelMode(); - const trigger = await canvas.findByRole('button', { name: ATTACHMENT_TOOLTIP_TEXT }); trigger.focus(); @@ -64,7 +45,7 @@ await screen.findByRole('menu'); await waitFor(() => { - expect(document.activeElement).toHaveTextContent('Reasoning'); + expect(document.activeElement).toHaveTextContent('Add files'); }); }} />