mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-29 17:37:39 -05:00
ui : rework the selector around its providers
The selector keeps working when a provider is down: the trigger shows the provider's mark and the org avatar, the banner only appears when no backend is enabled, and the model ids read through their settings. The model mark becomes MODEL_ICON everywhere. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
+29
-6
@@ -1,6 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
|
||||
import { conversationsStore, deviceStore, modelsStore, serverStore } from '$lib/stores';
|
||||
import { LOCAL_BACKEND_ID } from '$lib/constants';
|
||||
import {
|
||||
backendsModelsStore,
|
||||
backendsStore,
|
||||
conversationsStore,
|
||||
deviceStore,
|
||||
modelsStore,
|
||||
serverStore
|
||||
} from '$lib/stores';
|
||||
import { getConversationModel } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
@@ -28,7 +36,14 @@
|
||||
}: Props = $props();
|
||||
|
||||
let isRouter = $derived(serverStore.isRouterMode);
|
||||
let isOffline = $derived(!!serverStore.error);
|
||||
// the provider this selector is pointed at, which is the one its colours report
|
||||
let selectorError = $derived.by(() => {
|
||||
const backendId = backendsStore.active.id;
|
||||
|
||||
return backendId === LOCAL_BACKEND_ID
|
||||
? Boolean(serverStore.error)
|
||||
: backendsModelsStore.get(backendId).error !== null;
|
||||
});
|
||||
|
||||
let conversationModel = $derived(
|
||||
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
|
||||
@@ -52,10 +67,16 @@
|
||||
|
||||
$effect(() => {
|
||||
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
|
||||
if (modelsStore.models.some((m) => m.model === conversationModel)) {
|
||||
const option = modelsStore.models.find((m) => m.model === conversationModel);
|
||||
|
||||
// only sync models served by the active backend; a model from another
|
||||
// backend must not yank the active tab (and trigger a full backend
|
||||
// switch) just because the conversation used it. sends resolve their
|
||||
// backend explicitly via ensureModelBackend
|
||||
if (option && option.backendId === backendsStore.active.id) {
|
||||
modelsStore.selectedModelName = conversationModel;
|
||||
modelsStore.selectModelByName(conversationModel);
|
||||
} else {
|
||||
} else if (!option) {
|
||||
modelsStore.selectedModelName = null;
|
||||
modelsStore.clearSelection();
|
||||
}
|
||||
@@ -155,7 +176,8 @@
|
||||
<ModelsSelectorSheet
|
||||
bind:this={selectorModelRef}
|
||||
currentModel={selectorModel}
|
||||
disabled={disabled || isOffline}
|
||||
{disabled}
|
||||
error={selectorError}
|
||||
{forceForegroundText}
|
||||
{useGlobalSelection}
|
||||
/>
|
||||
@@ -163,7 +185,8 @@
|
||||
<ModelsSelectorDropdown
|
||||
bind:this={selectorModelRef}
|
||||
currentModel={selectorModel}
|
||||
disabled={disabled || isOffline}
|
||||
{disabled}
|
||||
error={selectorError}
|
||||
{forceForegroundText}
|
||||
{useGlobalSelection}
|
||||
/>
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
ChatFormPickerListItem,
|
||||
ChatFormPickerPopover
|
||||
} from '$lib/components/app/chat';
|
||||
import { MODEL_SELECTOR_ICON } from '$lib/constants';
|
||||
import { MODEL_ICON } from '$lib/constants';
|
||||
import { ChatFormCommandAction } from '$lib/enums';
|
||||
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
|
||||
import type { ChatFormCommand } from '$lib/types';
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
const commandIcon: Record<ChatFormCommandAction, typeof Sparkles> = {
|
||||
[ChatFormCommandAction.CWD]: FolderOpen,
|
||||
[ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON,
|
||||
[ChatFormCommandAction.MODEL]: MODEL_ICON,
|
||||
[ChatFormCommandAction.PROMPT]: Sparkles
|
||||
};
|
||||
|
||||
|
||||
@@ -2,13 +2,20 @@
|
||||
import { AlertTriangle, Loader2, RefreshCw } from '@lucide/svelte';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { serverStore } from '$lib/stores';
|
||||
import { useBackendAvailability } from '$lib/hooks/use-backend-availability.svelte';
|
||||
import { backendsStore, serverStore } from '$lib/stores';
|
||||
|
||||
const availability = useBackendAvailability();
|
||||
|
||||
let hasError = $derived(!!serverStore.error);
|
||||
let isLoadingModel = $derived(serverStore.status === 503);
|
||||
// A single provider leaves nowhere to switch to, so the banner is the only
|
||||
// place the failure can be reported. With another provider available the
|
||||
// selector carries it instead, and stays usable.
|
||||
let isOnlyProvider = $derived(backendsStore.enabled.length <= 1);
|
||||
let hasError = $derived(availability.isOffline && isOnlyProvider);
|
||||
</script>
|
||||
|
||||
{#if hasError}
|
||||
{#if hasError || isLoadingModel}
|
||||
<div class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1">
|
||||
<Alert.Root variant={isLoadingModel ? 'default' : 'destructive'}>
|
||||
{#if isLoadingModel}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> | boolean | void)
|
||||
| ((
|
||||
modelId: string,
|
||||
modelName: string,
|
||||
backendId?: string
|
||||
) => Promise<boolean> | 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<typeof groupModelOptions>;
|
||||
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<void>;
|
||||
handleSelect(modelId: string): Promise<void>;
|
||||
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<string | null>(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<string | null>(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<void> {
|
||||
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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown>)[legacyKey];
|
||||
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current configuration to localStorage via the persistence service.
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user