From 7e324cb2ab296e05fa2e8b587bf622adadb691c2 Mon Sep 17 00:00:00 2001 From: Federico Cucinotta Date: Sat, 6 Jun 2026 11:37:33 +0200 Subject: [PATCH 1/2] feat: manual model filter for providers and model dropdown - Add filter button in provider settings to show only manually-added models - Auto-fetched models hidden when user has pinned models - Model dropdown filters to pinned models when available - Add Model dialog upgrades existing models instead of rejecting duplicates - Backfill migration for existing users with customized models - One-time filter resets on refresh --- .../src/containers/DropdownModelProvider.tsx | 11 ++++ web-app/src/containers/dialogs/AddModel.tsx | 17 +++++-- web-app/src/containers/dialogs/EditModel.tsx | 7 ++- web-app/src/hooks/useModelProvider.ts | 28 +++++++++- .../settings/providers/$providerName.tsx | 51 +++++++++++++++++-- .../__tests__/$providerName.test.tsx | 1 + 6 files changed, 106 insertions(+), 9 deletions(-) diff --git a/web-app/src/containers/DropdownModelProvider.tsx b/web-app/src/containers/DropdownModelProvider.tsx index 660bb94255..3cdc6a7e39 100644 --- a/web-app/src/containers/DropdownModelProvider.tsx +++ b/web-app/src/containers/DropdownModelProvider.tsx @@ -264,10 +264,21 @@ const DropdownModelProvider = memo(function DropdownModelProvider({ providers.forEach((provider) => { if (!provider.active) return + // Check if this provider has any manually-added/pinned models. + // If so, only show those in the dropdown to keep the list manageable. + const hasManualModels = provider.models.some( + (m) => (m as any).manuallyAdded === true + ) + provider.models.forEach((modelItem) => { // Skip embedding models - they can't be used for chat if (modelItem.embedding) return + // If the provider has pinned models, hide auto-fetched ones + if (hasManualModels) { + if ((modelItem as any).manuallyAdded !== true) return + } + // Skip models that require API key but don't have one (except llamacpp) // For custom providers, allow if they have at least one model loaded const isPredefined = predefinedProviders.some((e) => diff --git a/web-app/src/containers/dialogs/AddModel.tsx b/web-app/src/containers/dialogs/AddModel.tsx index 6704e89e3b..1c5db6f3c9 100644 --- a/web-app/src/containers/dialogs/AddModel.tsx +++ b/web-app/src/containers/dialogs/AddModel.tsx @@ -40,10 +40,20 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => { if (!modelId.trim()) return // Don't submit if model ID is empty if (provider.models.some((e) => e.id === modelId)) { - toast.error(t('providers:addModel.modelExists'), { - description: t('providers:addModel.modelExistsDesc'), + // Model already exists — upgrade it to manually-added so it appears + // in the "manual only" filter. This handles the common case where a + // user wants to "pin" a model that was auto-fetched from a remote catalog. + const updatedModels = provider.models.map((m) => + m.id === modelId ? { ...m, manuallyAdded: true } : m + ) + updateProvider(provider.provider, { + ...provider, + models: updatedModels, }) - return // Don't submit if model ID already exists + toast.success('Model added to your collection') + setModelId('') + setOpen(false) + return } // Create the new model @@ -53,6 +63,7 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => { name: modelId, capabilities: getModelCapabilities(provider.provider, modelId), version: '1.0', + manuallyAdded: true, } // Update the provider with the new model diff --git a/web-app/src/containers/dialogs/EditModel.tsx b/web-app/src/containers/dialogs/EditModel.tsx index 642631ffb2..6e0b4b7bc3 100644 --- a/web-app/src/containers/dialogs/EditModel.tsx +++ b/web-app/src/containers/dialogs/EditModel.tsx @@ -119,7 +119,7 @@ export const DialogEditModel = ({ const capabilitiesChanged = JSON.stringify(capabilities) !== JSON.stringify(originalCapabilities) // Build the update object for the selected model - const modelUpdate: Partial & { _userConfiguredCapabilities?: boolean } = {} + const modelUpdate: Partial & { _userConfiguredCapabilities?: boolean; manuallyAdded?: boolean } = {} if (nameChanged) { modelUpdate.displayName = displayName @@ -132,6 +132,11 @@ export const DialogEditModel = ({ modelUpdate._userConfiguredCapabilities = true } + // Mark as manually added so it shows in the "manual only" filter + if (nameChanged || capabilitiesChanged) { + modelUpdate.manuallyAdded = true + } + // Update the model in the provider models array const updatedModels = provider.models.map((m: Model) => m.id === selectedModelId ? { ...m, ...modelUpdate } : m diff --git a/web-app/src/hooks/useModelProvider.ts b/web-app/src/hooks/useModelProvider.ts index a82131db3c..458064616b 100644 --- a/web-app/src/hooks/useModelProvider.ts +++ b/web-app/src/hooks/useModelProvider.ts @@ -819,9 +819,35 @@ export const useModelProvider = create()( }) } + if (version <= 17 && state?.providers) { + // Backfill manuallyAdded on models that were manually customized + // before the "manual filter" feature existed. This preserves the + // user's curated selection when upgrading. + state.providers.forEach((provider) => { + if (!provider.models) return + provider.models.forEach((model) => { + const m = model as Model & Record + if ( + (m as any).manuallyAdded === true || + (m as any).imported === true + ) { + return // already flagged + } + // Models that were added via the "Add Model" dialog, renamed, or + // had capabilities manually toggled should be considered manual. + if ( + m.displayName || + (m as any)._userConfiguredCapabilities === true + ) { + ;(m as any).manuallyAdded = true + } + }) + }) + } + return state }, - version: 17, + version: 18, } ) ) diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index 2f207ec68b..b8067f2554 100644 --- a/web-app/src/routes/settings/providers/$providerName.tsx +++ b/web-app/src/routes/settings/providers/$providerName.tsx @@ -29,6 +29,7 @@ import { IconInfoCircle, IconLoader, IconRefresh, + IconFilter, IconUpload, } from '@tabler/icons-react' import { useDefaultEmbeddingModel } from '@/hooks/useDefaultEmbeddingModel' @@ -73,6 +74,7 @@ function ProviderDetail() { ) const [loadingModels, setLoadingModels] = useState([]) const [refreshingModels, setRefreshingModels] = useState(false) + const [showManualOnly, setShowManualOnly] = useState(false) const [isCheckingBackendUpdate, setIsCheckingBackendUpdate] = useState(false) const [isInstallingBackend, setIsInstallingBackend] = useState(false) const [importingModel, setImportingModel] = useState(null) @@ -108,6 +110,22 @@ function ProviderDetail() { : allModels, [isLlamacpp, allModels] ) + // Filter to only show manually-added models when the filter is active. + // A model counts as "manual" if it was explicitly added, edited (has a + // custom display name or user-configured capabilities), or was imported. + const isManuallyAdded = (m: Model) => + (m as any).manuallyAdded === true || + (m as any).imported === true || + !!(m as any).displayName || + (m as any)._userConfiguredCapabilities === true + const displayedChatModels = useMemo( + () => (showManualOnly ? chatModels.filter(isManuallyAdded) : chatModels), + [chatModels, showManualOnly] + ) + const displayedEmbeddingModels = useMemo( + () => (showManualOnly ? embeddingModels.filter(isManuallyAdded) : embeddingModels), + [embeddingModels, showManualOnly] + ) const defaultEmbeddingModelId = useDefaultEmbeddingModel((s) => isLlamacpp ? s.getDefault('llamacpp') : undefined ) @@ -496,6 +514,7 @@ function ProviderDetail() { // This ensures all screens receive the event intermediately const handleRefreshModels = async () => { + setShowManualOnly(false) if (!provider || !provider.base_url || !providerHasRemoteApiKeys(provider)) { toast.error(t('providers:models'), { description: t('providers:refreshModelsError'), @@ -1188,6 +1207,18 @@ function ProviderDetail() { /> )} + )} @@ -1237,7 +1268,19 @@ function ProviderDetail() { > {provider?.models.length ? ( <> - {isLlamacpp && embeddingModels.length > 0 && chatModels.length > 0 && ( + {displayedChatModels.length === 0 && showManualOnly && ( +
+
+
+ No manually added models +
+
+

+ Use the "Add Model" button to add models manually, or click the refresh button to show all models. +

+
+ )} + {displayedChatModels.length > 0 && isLlamacpp && displayedEmbeddingModels.length > 0 && (
)} - {(isLlamacpp ? chatModels : allModels).map((model, modelIndex) => { + {displayedChatModels.map((model, modelIndex) => { const capabilities = model.capabilities || [] return ( )} - {isLlamacpp && provider && embeddingModels.length > 0 && ( + {isLlamacpp && provider && displayedEmbeddingModels.length > 0 && ( <>
- {embeddingModels.map((model, modelIndex) => { + {displayedEmbeddingModels.map((model, modelIndex) => { const isDefault = defaultEmbeddingModelId === model.id return ( ({ IconInfoCircle: () => , IconLoader: () => , IconRefresh: () => , + IconFilter: () => , IconUpload: () => , IconTrash: () => , IconCircle: () => , From 982408b812ea4559a290cbdc7d41a0924a21ab48 Mon Sep 17 00:00:00 2001 From: Federico Cucinotta Date: Sun, 7 Jun 2026 11:29:07 +0200 Subject: [PATCH 2/2] fix: address manual model filter PR review feedback --- .../src/containers/DropdownModelProvider.tsx | 9 +- ...ropdownModelProvider.manualFilter.test.tsx | 197 ++++++++++++++++++ web-app/src/containers/dialogs/AddModel.tsx | 2 +- web-app/src/containers/dialogs/EditModel.tsx | 2 +- .../hooks/__tests__/useModelProvider.test.ts | 51 +++++ web-app/src/hooks/useModelProvider.ts | 37 ++-- web-app/src/lib/__tests__/models.test.ts | 27 +++ web-app/src/lib/models.ts | 16 ++ web-app/src/locales/en/providers.json | 8 +- .../settings/providers/$providerName.tsx | 21 +- web-app/src/types/modelProviders.d.ts | 7 + 11 files changed, 336 insertions(+), 41 deletions(-) create mode 100644 web-app/src/containers/__tests__/DropdownModelProvider.manualFilter.test.tsx diff --git a/web-app/src/containers/DropdownModelProvider.tsx b/web-app/src/containers/DropdownModelProvider.tsx index 3cdc6a7e39..38f729f478 100644 --- a/web-app/src/containers/DropdownModelProvider.tsx +++ b/web-app/src/containers/DropdownModelProvider.tsx @@ -22,6 +22,7 @@ import { useTranslation } from '@/i18n/react-i18next-compat' import { useFavoriteModel } from '@/hooks/useFavoriteModel' import { predefinedProviders } from '@/constants/providers' import { providerHasRemoteApiKeys } from '@/lib/provider-api-keys' +import { isManuallyAdded } from '@/lib/models' import { useServiceHub } from '@/hooks/useServiceHub' import { getLastUsedModel } from '@/utils/getModelToStart' import { ChevronsUpDown } from 'lucide-react' @@ -266,18 +267,14 @@ const DropdownModelProvider = memo(function DropdownModelProvider({ // Check if this provider has any manually-added/pinned models. // If so, only show those in the dropdown to keep the list manageable. - const hasManualModels = provider.models.some( - (m) => (m as any).manuallyAdded === true - ) + const hasManualModels = provider.models.some(isManuallyAdded) provider.models.forEach((modelItem) => { // Skip embedding models - they can't be used for chat if (modelItem.embedding) return // If the provider has pinned models, hide auto-fetched ones - if (hasManualModels) { - if ((modelItem as any).manuallyAdded !== true) return - } + if (hasManualModels && !isManuallyAdded(modelItem)) return // Skip models that require API key but don't have one (except llamacpp) // For custom providers, allow if they have at least one model loaded diff --git a/web-app/src/containers/__tests__/DropdownModelProvider.manualFilter.test.tsx b/web-app/src/containers/__tests__/DropdownModelProvider.manualFilter.test.tsx new file mode 100644 index 0000000000..8230f24750 --- /dev/null +++ b/web-app/src/containers/__tests__/DropdownModelProvider.manualFilter.test.tsx @@ -0,0 +1,197 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, cleanup } from '@testing-library/react' +import '@testing-library/jest-dom' +import DropdownModelProvider from '../DropdownModelProvider' +import { useModelProvider } from '@/hooks/useModelProvider' + +// Minimal local types to avoid pulling ambient declarations into the test. +type Model = { + id: string + displayName?: string + capabilities?: string[] + manuallyAdded?: boolean + imported?: boolean + embedding?: boolean +} + +type ModelProvider = { + provider: string + active: boolean + models: Model[] + settings: unknown[] +} + +type MockHookReturn = { + providers: ModelProvider[] + selectedProvider: string + selectedModel: Model + getProviderByName: (name: string) => ModelProvider | undefined + selectModelProvider: () => void + getModelBy: (id: string) => Model | undefined + updateProvider: () => void +} + +vi.mock('@/hooks/useModelProvider', () => ({ + useModelProvider: vi.fn(), +})) + +vi.mock('@/hooks/useThreads', () => ({ + useThreads: vi.fn(() => ({ updateCurrentThreadModel: vi.fn() })), +})) + +vi.mock('@/hooks/useServiceHub', () => ({ + useServiceHub: vi.fn(() => ({ + models: () => ({ + checkMmprojExists: vi.fn(() => Promise.resolve(false)), + checkMmprojExistsAndUpdateOffloadMMprojSetting: vi.fn(() => + Promise.resolve() + ), + }), + })), +})) + +vi.mock('@/i18n/react-i18next-compat', () => ({ + useTranslation: vi.fn(() => ({ t: (key: string) => key })), +})) + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: vi.fn(() => vi.fn()), +})) + +vi.mock('@/hooks/useFavoriteModel', () => ({ + useFavoriteModel: vi.fn(() => ({ favoriteModels: [] })), +})) + +vi.mock('@/lib/platform/const', () => ({ + PlatformFeatures: { + WEB_AUTO_MODEL_SELECTION: false, + MODEL_PROVIDER_SETTINGS: true, + projects: true, + }, +})) + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, + PopoverTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + PopoverContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock('../ProvidersAvatar', () => ({ + default: ({ provider }: { provider: any }) => ( +
+ ), +})) + +vi.mock('../Capabilities', () => ({ + default: ({ capabilities }: { capabilities: string[] }) => ( +
{capabilities.join(',')}
+ ), +})) + +vi.mock('../ModelSetting', () => ({ + ModelSetting: () =>
, +})) + +vi.mock('../ModelSupportStatus', () => ({ + ModelSupportStatus: () =>
, +})) + +const mockHook = (providers: ModelProvider[], selectedModel: Model) => + vi.mocked(useModelProvider).mockReturnValue({ + providers, + selectedProvider: providers[0]?.provider, + selectedModel, + getProviderByName: vi.fn((name: string) => + providers.find((p) => p.provider === name) + ), + selectModelProvider: vi.fn(), + getModelBy: vi.fn((id: string) => + providers.flatMap((p) => p.models).find((m) => m.id === id) + ), + updateProvider: vi.fn(), + } as MockHookReturn) + +describe('DropdownModelProvider - manual model filter', () => { + beforeEach(() => vi.clearAllMocks()) + afterEach(() => cleanup()) + + it('hides auto-fetched models when the provider has pinned models', () => { + const provider: ModelProvider = { + provider: 'llamacpp', + active: true, + settings: [], + models: [ + { id: 'pinned.gguf', capabilities: ['completion'], manuallyAdded: true }, + { id: 'auto-1.gguf', capabilities: ['completion'] }, + { id: 'auto-2.gguf', capabilities: ['completion'] }, + ], + } + mockHook([provider], provider.models[0]) + + render() + + expect(screen.getAllByText('pinned.gguf').length).toBeGreaterThanOrEqual(1) + expect(screen.queryByText('auto-1.gguf')).not.toBeInTheDocument() + expect(screen.queryByText('auto-2.gguf')).not.toBeInTheDocument() + }) + + it('treats imported models as pinned and hides auto-fetched ones', () => { + const provider: ModelProvider = { + provider: 'llamacpp', + active: true, + settings: [], + models: [ + { id: 'local.gguf', capabilities: ['completion'], imported: true }, + { id: 'auto-1.gguf', capabilities: ['completion'] }, + ], + } + mockHook([provider], provider.models[0]) + + render() + + expect(screen.getAllByText('local.gguf').length).toBeGreaterThanOrEqual(1) + expect(screen.queryByText('auto-1.gguf')).not.toBeInTheDocument() + }) + + it('shows all models when the provider has no pinned models', () => { + const provider: ModelProvider = { + provider: 'llamacpp', + active: true, + settings: [], + models: [ + { id: 'auto-1.gguf', capabilities: ['completion'] }, + { id: 'auto-2.gguf', capabilities: ['completion'] }, + { id: 'auto-3.gguf', capabilities: ['completion'] }, + ], + } + mockHook([provider], provider.models[0]) + + render() + + expect(screen.getByText('auto-2.gguf')).toBeInTheDocument() + expect(screen.getByText('auto-3.gguf')).toBeInTheDocument() + }) + + it('does not treat a catalog displayName as a pin', () => { + // A displayName alone must NOT activate the filter — otherwise catalogs + // that ship displayName would wrongly hide every other model. + const provider: ModelProvider = { + provider: 'llamacpp', + active: true, + settings: [], + models: [ + { id: 'auto-1.gguf', displayName: 'Auto One', capabilities: ['completion'] }, + { id: 'auto-2.gguf', displayName: 'Auto Two', capabilities: ['completion'] }, + ], + } + mockHook([provider], provider.models[0]) + + render() + + expect(screen.getByText('Auto Two')).toBeInTheDocument() + }) +}) diff --git a/web-app/src/containers/dialogs/AddModel.tsx b/web-app/src/containers/dialogs/AddModel.tsx index 1c5db6f3c9..5e85b48577 100644 --- a/web-app/src/containers/dialogs/AddModel.tsx +++ b/web-app/src/containers/dialogs/AddModel.tsx @@ -50,7 +50,7 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => { ...provider, models: updatedModels, }) - toast.success('Model added to your collection') + toast.success(t('providers:addModel.modelPinned')) setModelId('') setOpen(false) return diff --git a/web-app/src/containers/dialogs/EditModel.tsx b/web-app/src/containers/dialogs/EditModel.tsx index 6e0b4b7bc3..33c6b2277e 100644 --- a/web-app/src/containers/dialogs/EditModel.tsx +++ b/web-app/src/containers/dialogs/EditModel.tsx @@ -119,7 +119,7 @@ export const DialogEditModel = ({ const capabilitiesChanged = JSON.stringify(capabilities) !== JSON.stringify(originalCapabilities) // Build the update object for the selected model - const modelUpdate: Partial & { _userConfiguredCapabilities?: boolean; manuallyAdded?: boolean } = {} + const modelUpdate: Partial = {} if (nameChanged) { modelUpdate.displayName = displayName diff --git a/web-app/src/hooks/__tests__/useModelProvider.test.ts b/web-app/src/hooks/__tests__/useModelProvider.test.ts index 35e74636f4..3e6f93c4ee 100644 --- a/web-app/src/hooks/__tests__/useModelProvider.test.ts +++ b/web-app/src/hooks/__tests__/useModelProvider.test.ts @@ -435,4 +435,55 @@ describe('useModelProvider migrations', () => { // Pre-set api_type must round-trip untouched. expect(customAnthropic.api_type).toBe('anthropic') }) + + it('backfills manuallyAdded on curated models (v17 → v18)', () => { + const persistApi = (useModelProvider as any).persist + const migrate = persistApi?.getOptions().migrate as + | ((state: unknown, version: number) => any) + | undefined + + expect(migrate).toBeDefined() + + const persistedState = { + providers: [ + { + provider: 'openrouter', + base_url: 'https://openrouter.ai/api/v1', + settings: [], + models: [ + // User toggled capabilities → curated. + { id: 'cap', name: 'cap', _userConfiguredCapabilities: true }, + // User renamed (displayName differs from name) → curated. + { id: 'renamed', name: 'renamed', displayName: 'My Model' }, + // Catalog echoes displayName === name → NOT curated. + { id: 'echo-name', name: 'echo-name', displayName: 'echo-name' }, + // Catalog echoes displayName === id → NOT curated. + { id: 'echo-id', name: 'Some Name', displayName: 'echo-id' }, + // Plain auto-fetched model → untouched. + { id: 'plain', name: 'plain' }, + // Already flagged → preserved. + { id: 'already', name: 'already', manuallyAdded: true }, + // Imported models are recognized at read time, not backfilled here. + { id: 'imported', name: 'imported', imported: true }, + ], + }, + ], + selectedProvider: 'openrouter', + selectedModel: null, + deletedModels: [], + } + + const migratedState = migrate!(persistedState, 17) + const byId = Object.fromEntries( + migratedState.providers[0].models.map((m: Model) => [m.id, m]) + ) + + expect(byId.cap.manuallyAdded).toBe(true) + expect(byId.renamed.manuallyAdded).toBe(true) + expect(byId['echo-name'].manuallyAdded).toBeUndefined() + expect(byId['echo-id'].manuallyAdded).toBeUndefined() + expect(byId.plain.manuallyAdded).toBeUndefined() + expect(byId.already.manuallyAdded).toBe(true) + expect(byId.imported.manuallyAdded).toBeUndefined() + }) }) diff --git a/web-app/src/hooks/useModelProvider.ts b/web-app/src/hooks/useModelProvider.ts index 458064616b..a7dc544a24 100644 --- a/web-app/src/hooks/useModelProvider.ts +++ b/web-app/src/hooks/useModelProvider.ts @@ -820,26 +820,25 @@ export const useModelProvider = create()( } if (version <= 17 && state?.providers) { - // Backfill manuallyAdded on models that were manually customized - // before the "manual filter" feature existed. This preserves the - // user's curated selection when upgrading. + // One-time backfill of `manuallyAdded` for users upgrading from + // before the manual-filter feature. Imported models are recognized + // at read time (see `isManuallyAdded`), so they need no backfill. + // + // Heuristic: a model is user-curated if it has user-configured + // capabilities, or a displayName that differs from its name/id + // (i.e. the user renamed it). We deliberately ignore a displayName + // that merely mirrors name/id, because remote catalogs sometimes + // populate it verbatim — counting those would wrongly hide + // auto-fetched models from the chat dropdown. state.providers.forEach((provider) => { - if (!provider.models) return - provider.models.forEach((model) => { - const m = model as Model & Record - if ( - (m as any).manuallyAdded === true || - (m as any).imported === true - ) { - return // already flagged - } - // Models that were added via the "Add Model" dialog, renamed, or - // had capabilities manually toggled should be considered manual. - if ( - m.displayName || - (m as any)._userConfiguredCapabilities === true - ) { - ;(m as any).manuallyAdded = true + provider.models?.forEach((model) => { + if (model.manuallyAdded === true) return + const userRenamed = + !!model.displayName && + model.displayName !== model.name && + model.displayName !== model.id + if (model._userConfiguredCapabilities === true || userRenamed) { + model.manuallyAdded = true } }) }) diff --git a/web-app/src/lib/__tests__/models.test.ts b/web-app/src/lib/__tests__/models.test.ts index d5047c5827..22196c4ae0 100644 --- a/web-app/src/lib/__tests__/models.test.ts +++ b/web-app/src/lib/__tests__/models.test.ts @@ -8,6 +8,7 @@ import { extractQuantLabel, getModelCapabilities, selectDefaultQuant, + isManuallyAdded, } from '../models' import { ModelCapabilities } from '@/types/models' @@ -382,3 +383,29 @@ describe('getModelCapabilities', () => { expect(capabilities).not.toContain(ModelCapabilities.VISION) }) }) + +describe('isManuallyAdded', () => { + it('returns true when the manuallyAdded flag is set', () => { + expect(isManuallyAdded({ id: 'gpt-4', manuallyAdded: true })).toBe(true) + }) + + it('returns true for imported local models', () => { + expect(isManuallyAdded({ id: 'local.gguf', imported: true })).toBe(true) + }) + + it('returns false for plain auto-fetched models', () => { + expect(isManuallyAdded({ id: 'gpt-4' })).toBe(false) + }) + + it('does NOT treat a catalog displayName as manual', () => { + // Read-time check relies solely on the persisted flags — displayName is a + // migration-only heuristic and must not leak into this predicate. + expect(isManuallyAdded({ id: 'gpt-4', displayName: 'GPT-4' })).toBe(false) + }) + + it('does NOT treat user-configured capabilities as manual at read time', () => { + expect( + isManuallyAdded({ id: 'gpt-4', _userConfiguredCapabilities: true }) + ).toBe(false) + }) +}) diff --git a/web-app/src/lib/models.ts b/web-app/src/lib/models.ts index 5097a730ff..703088b898 100644 --- a/web-app/src/lib/models.ts +++ b/web-app/src/lib/models.ts @@ -110,3 +110,19 @@ export const extractQuantLabel = (modelId?: string): string | null => { ) return match ? match[1].toUpperCase() : null } + +/** + * Single source of truth for whether a model was curated by the user. + * + * A model is "manually added" when it carries the explicit `manuallyAdded` + * flag (set when added/pinned via the Add Model dialog or edited) or when it + * was `imported` from a local file. Both are real persisted flags, so the + * settings "manual only" filter and the chat model dropdown stay consistent. + * + * The displayName / _userConfiguredCapabilities heuristics are intentionally + * NOT consulted here — they are only used once, by the v17→18 migration, to + * backfill `manuallyAdded` for users who customized models before this flag + * existed. + */ +export const isManuallyAdded = (model: Model): boolean => + model.manuallyAdded === true || model.imported === true diff --git a/web-app/src/locales/en/providers.json b/web-app/src/locales/en/providers.json index 90cf549675..499c4452d2 100644 --- a/web-app/src/locales/en/providers.json +++ b/web-app/src/locales/en/providers.json @@ -35,7 +35,13 @@ "exploreModels": "See model list from {{provider}}", "addModel": "Add Model", "modelExists": "Model already exists", - "modelExistsDesc": "Please choose a different model ID." + "modelExistsDesc": "Please choose a different model ID.", + "modelPinned": "Model added to your collection" + }, + "manualFilter": { + "tooltip": "Show manually added models only", + "emptyTitle": "No manually added models", + "emptyDescription": "Use the \"Add Model\" button to add models manually, or toggle the filter off to show all models." }, "deleteModel": { "title": "Delete Model: {{modelId}}", diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index b8067f2554..225066b386 100644 --- a/web-app/src/routes/settings/providers/$providerName.tsx +++ b/web-app/src/routes/settings/providers/$providerName.tsx @@ -40,6 +40,7 @@ import { useModelLoad } from '@/hooks/useModelLoad' import { useLlamacppDevices } from '@/hooks/useLlamacppDevices' import { useBackendUpdater } from '@/hooks/useBackendUpdater' import { basenameNoExt } from '@/lib/utils' +import { isManuallyAdded } from '@/lib/models' import { useAppState } from '@/hooks/useAppState' import { useShallow } from 'zustand/shallow' import { DialogAddModel } from '@/containers/dialogs/AddModel' @@ -110,14 +111,9 @@ function ProviderDetail() { : allModels, [isLlamacpp, allModels] ) - // Filter to only show manually-added models when the filter is active. - // A model counts as "manual" if it was explicitly added, edited (has a - // custom display name or user-configured capabilities), or was imported. - const isManuallyAdded = (m: Model) => - (m as any).manuallyAdded === true || - (m as any).imported === true || - !!(m as any).displayName || - (m as any)._userConfiguredCapabilities === true + // When the filter is active, show only models the user has curated. + // `isManuallyAdded` is the shared definition used by the chat dropdown too, + // so the two views stay consistent. const displayedChatModels = useMemo( () => (showManualOnly ? chatModels.filter(isManuallyAdded) : chatModels), [chatModels, showManualOnly] @@ -1210,9 +1206,8 @@ function ProviderDetail() {
)} diff --git a/web-app/src/types/modelProviders.d.ts b/web-app/src/types/modelProviders.d.ts index ef53270cc8..b339712511 100644 --- a/web-app/src/types/modelProviders.d.ts +++ b/web-app/src/types/modelProviders.d.ts @@ -42,6 +42,13 @@ type Model = { /** Whether this model was imported from a user-supplied local file * (path lives outside the provider's managed models directory). */ imported?: boolean + /** Whether the user explicitly curated this model — added/pinned it via the + * Add Model dialog or edited it. Drives the "manual only" filter and the + * chat dropdown. See `isManuallyAdded` in `@/lib/models`. */ + manuallyAdded?: boolean + /** Whether the user manually toggled this model's capabilities in the Edit + * Model dialog (vs. capabilities inferred from the provider catalog). */ + _userConfiguredCapabilities?: boolean } /**