fix(desktop): models added by a plugin or catalog update no longer start hidden in the picker
The Edit Models store persisted only an allowlist of visible provider::model keys. Once a provider had any stored key it was skipped by the default expansion, so a model that appeared later (plugin update shipping a new route, catalog refresh, new release) was absent from the list and rendered switched off. The store could not tell "hidden on purpose" from "never seen". Persist a `known` snapshot (hermes.desktop.known-models) beside the allowlist, recorded whenever the user persists a choice. A curated provider now admits models absent from the snapshot through the same curated default rule (featured list / top-N); a provider the user hid outright stays hidden, new models included. Stores written before the snapshot existed adopt the catalog as judged the first time it loads, so existing hide choices are honoured verbatim and only later arrivals count as new.
This commit is contained in:
@@ -39,6 +39,7 @@ import {
|
||||
effectiveVisibleKeys,
|
||||
type ModelFamily,
|
||||
modelVisibilityKey,
|
||||
seedKnownModels,
|
||||
setModelVisibilityOpen
|
||||
} from '@/store/model-visibility'
|
||||
import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse'
|
||||
@@ -268,6 +269,8 @@ export function ModelCatalogMenu({
|
||||
// Resolve visibility HERE, against the catalog we actually fetched: an empty
|
||||
// provider list would otherwise resolve to an empty key set that reads as
|
||||
// "user hid everything" and blanks the menu on first open.
|
||||
useEffect(() => seedKnownModels(pickerProviders), [pickerProviders])
|
||||
|
||||
const shownKeys = useMemo(
|
||||
() => effectiveVisibleKeys(visibleModels, pickerProviders),
|
||||
[visibleModels, pickerProviders]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ModelOptionProvider, ModelOptionsResult } from '@hermes/shared'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
collapseModelFamilies,
|
||||
effectiveVisibleKeys,
|
||||
modelVisibilityKey,
|
||||
seedKnownModels,
|
||||
setProviderVisibility,
|
||||
setVisibleModels,
|
||||
toggleModelVisibility
|
||||
@@ -63,14 +64,16 @@ export function ModelVisibilityDialog({
|
||||
[modelOptions.data]
|
||||
)
|
||||
|
||||
useEffect(() => seedKnownModels(providers), [providers])
|
||||
|
||||
const visible = effectiveVisibleKeys(stored, providers)
|
||||
|
||||
const toggle = (provider: ModelOptionProvider, model: string) => {
|
||||
setVisibleModels(toggleModelVisibility($visibleModels.get(), providers, provider.slug, model))
|
||||
setVisibleModels(toggleModelVisibility($visibleModels.get(), providers, provider.slug, model), providers)
|
||||
}
|
||||
|
||||
const setProviderVisible = (provider: ModelOptionProvider, next: boolean) => {
|
||||
setVisibleModels(setProviderVisibility($visibleModels.get(), providers, provider.slug, next))
|
||||
setVisibleModels(setProviderVisibility($visibleModels.get(), providers, provider.slug, next), providers)
|
||||
}
|
||||
|
||||
const q = normalize(search)
|
||||
|
||||
@@ -33,15 +33,41 @@ describe('model visibility', () => {
|
||||
expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not re-add models from a provider that already has stored choices', () => {
|
||||
it('does not re-add models the user already judged for a curated provider', () => {
|
||||
const stored = new Set([modelVisibilityKey('local-ollama', 'qwen3:latest')])
|
||||
|
||||
const visible = effectiveVisibleKeys(stored, [provider('local-ollama', ['qwen3:latest', 'llama3.2:latest'])])
|
||||
const known = new Set([
|
||||
modelVisibilityKey('local-ollama', 'qwen3:latest'),
|
||||
modelVisibilityKey('local-ollama', 'llama3.2:latest')
|
||||
])
|
||||
|
||||
const visible = effectiveVisibleKeys(stored, [provider('local-ollama', ['qwen3:latest', 'llama3.2:latest'])], known)
|
||||
|
||||
expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true)
|
||||
expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(false)
|
||||
})
|
||||
|
||||
it('shows a model that appeared after the user curated its provider, unless the provider is hidden', () => {
|
||||
// User curated claude-sub (kept sonnet, hid haiku) and hid all of nous; then a plugin update adds opus.
|
||||
const stored = new Set([modelVisibilityKey('claude-sub', 'sonnet'), emptyProviderSentinelKey('nous')])
|
||||
|
||||
const known = new Set([
|
||||
modelVisibilityKey('claude-sub', 'sonnet'),
|
||||
modelVisibilityKey('claude-sub', 'haiku'),
|
||||
modelVisibilityKey('nous', 'hermes-4')
|
||||
])
|
||||
|
||||
const providers = [provider('claude-sub', ['sonnet', 'haiku', 'opus']), provider('nous', ['hermes-4', 'hermes-5'])]
|
||||
const visible = effectiveVisibleKeys(stored, providers, known)
|
||||
|
||||
expect(visible.has(modelVisibilityKey('claude-sub', 'opus'))).toBe(true)
|
||||
expect(visible.has(modelVisibilityKey('claude-sub', 'haiku'))).toBe(false)
|
||||
expect(visible.has(modelVisibilityKey('nous', 'hermes-5'))).toBe(false)
|
||||
|
||||
// No snapshot yet (pre-upgrade store): nothing counts as new, hide choices stay verbatim.
|
||||
expect(effectiveVisibleKeys(stored, providers, null).has(modelVisibilityKey('claude-sub', 'opus'))).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves hidden-provider sentinel without re-adding defaults', () => {
|
||||
// User explicitly hid all models for "nous" — sentinel marks this choice.
|
||||
const stored = new Set([emptyProviderSentinelKey('nous')])
|
||||
|
||||
@@ -5,6 +5,12 @@ import { persistString, storedString } from '@/lib/storage'
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.visible-models'
|
||||
|
||||
/** Every `provider::model` key the user has had a chance to judge — snapshotted
|
||||
* each time the visible set is persisted. A model absent from here appeared
|
||||
* AFTER the user last curated (plugin update, catalog refresh, new release), so
|
||||
* it falls through to the curated default rule instead of defaulting to hidden. */
|
||||
const KNOWN_STORAGE_KEY = 'hermes.desktop.known-models'
|
||||
|
||||
/** Models shown per provider in the status-bar dropdown before the user has
|
||||
* customized the list. Backend `models` are already relevance-ordered. */
|
||||
export const DEFAULT_VISIBLE_PER_PROVIDER = 50
|
||||
@@ -68,8 +74,8 @@ export function collapseModelFamilies(models: readonly string[]): ModelFamily[]
|
||||
return families
|
||||
}
|
||||
|
||||
function loadVisible(): Set<string> | null {
|
||||
const raw = storedString(STORAGE_KEY)
|
||||
function loadKeySet(storageKey: string): Set<string> | null {
|
||||
const raw = storedString(storageKey)
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
@@ -86,13 +92,56 @@ function loadVisible(): Set<string> | null {
|
||||
|
||||
/** Explicit set of visible `provider::model` keys, or null when the user
|
||||
* hasn't customized — in which case the curated default applies. */
|
||||
export const $visibleModels = atom<Set<string> | null>(loadVisible())
|
||||
export const $visibleModels = atom<Set<string> | null>(loadKeySet(STORAGE_KEY))
|
||||
|
||||
/** Keys the user has seen, or null when nothing has been recorded yet (a fresh
|
||||
* install, or a store written before the snapshot existed). */
|
||||
export const $knownModels = atom<Set<string> | null>(loadKeySet(KNOWN_STORAGE_KEY))
|
||||
|
||||
export const $modelVisibilityOpen = atom(false)
|
||||
|
||||
export function setVisibleModels(keys: Set<string>): void {
|
||||
/** Every collapsed-family key across `providers`. */
|
||||
function allFamilyKeys(providers: readonly ModelOptionProvider[]): Set<string> {
|
||||
const keys = new Set<string>()
|
||||
|
||||
for (const provider of providers) {
|
||||
for (const family of collapseModelFamilies(provider.models ?? [])) {
|
||||
keys.add(modelVisibilityKey(provider.slug, family.id))
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Persist the visible set and, when the current catalog is supplied, mark every
|
||||
* model in it as judged so only models that appear later count as new. */
|
||||
export function setVisibleModels(keys: Set<string>, providers: readonly ModelOptionProvider[] = []): void {
|
||||
$visibleModels.set(new Set(keys))
|
||||
persistString(STORAGE_KEY, JSON.stringify([...keys]))
|
||||
|
||||
if (providers.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
persistKnownModels(new Set([...($knownModels.get() ?? []), ...allFamilyKeys(providers)]))
|
||||
}
|
||||
|
||||
function persistKnownModels(known: Set<string>): void {
|
||||
$knownModels.set(known)
|
||||
persistString(KNOWN_STORAGE_KEY, JSON.stringify([...known]))
|
||||
}
|
||||
|
||||
/** One-time adoption for a visible set persisted before the known snapshot
|
||||
* existed: everything in the catalog at that moment counts as judged (the
|
||||
* user's hide choices are honoured verbatim); only models that appear later are
|
||||
* new. Never a running union — that would mark a newcomer judged on the very
|
||||
* render that first shows it. Call when the catalog has loaded. */
|
||||
export function seedKnownModels(providers: readonly ModelOptionProvider[]): void {
|
||||
if ($knownModels.get() !== null || $visibleModels.get() === null || providers.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
persistKnownModels(allFamilyKeys(providers))
|
||||
}
|
||||
|
||||
export function setModelVisibilityOpen(open: boolean): void {
|
||||
@@ -117,7 +166,11 @@ export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): S
|
||||
* falls back to the top-N collapsed families when a provider ships no featured
|
||||
* list. Shared by `defaultVisibleKeys` and `resolveVisibleKeys` so the
|
||||
* expansion rule lives in exactly one place. */
|
||||
function expandProviderDefaults(provider: ModelOptionProvider, target: Set<string>): void {
|
||||
function expandProviderDefaults(
|
||||
provider: ModelOptionProvider,
|
||||
target: Set<string>,
|
||||
admit: (key: string) => boolean = () => true
|
||||
): void {
|
||||
const families = collapseModelFamilies(provider.models ?? [])
|
||||
|
||||
const featured = provider.featured_models ?? []
|
||||
@@ -127,7 +180,11 @@ function expandProviderDefaults(provider: ModelOptionProvider, target: Set<strin
|
||||
: families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)
|
||||
|
||||
for (const family of defaults) {
|
||||
target.add(modelVisibilityKey(provider.slug, family.id))
|
||||
const key = modelVisibilityKey(provider.slug, family.id)
|
||||
|
||||
if (admit(key)) {
|
||||
target.add(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +192,18 @@ function expandProviderDefaults(provider: ModelOptionProvider, target: Set<strin
|
||||
* default expansion for any provider they haven't customized. Hide-all
|
||||
* sentinels are PRESERVED here — this is the set the toggle handler mutates and
|
||||
* persists, so dropping a sentinel would silently re-enable a provider the user
|
||||
* emptied. Use `effectiveVisibleKeys` for display (sentinels stripped). */
|
||||
export function resolveVisibleKeys(stored: Set<string> | null, providers: readonly ModelOptionProvider[]): Set<string> {
|
||||
* emptied. Use `effectiveVisibleKeys` for display (sentinels stripped).
|
||||
*
|
||||
* A provider the user has curated still admits models absent from `known`
|
||||
* (they arrived after the last curation) through the same default rule, so a
|
||||
* plugin or catalog update never lands a model silently switched off. A
|
||||
* provider the user hid outright (sentinel) stays hidden, new models included.
|
||||
* With no snapshot yet (`known` null) nothing counts as new. */
|
||||
export function resolveVisibleKeys(
|
||||
stored: Set<string> | null,
|
||||
providers: readonly ModelOptionProvider[],
|
||||
known: Set<string> | null = $knownModels.get()
|
||||
): Set<string> {
|
||||
if (!stored) {
|
||||
return defaultVisibleKeys(providers)
|
||||
}
|
||||
@@ -150,15 +217,17 @@ export function resolveVisibleKeys(stored: Set<string> | null, providers: readon
|
||||
for (const provider of providers) {
|
||||
const providerPrefix = `${provider.slug}::`
|
||||
|
||||
const hasStoredProvider = [...stored].some(key => key.startsWith(providerPrefix) && !isProviderSentinel(key))
|
||||
|
||||
const hasSentinel = stored.has(emptyProviderSentinelKey(provider.slug))
|
||||
|
||||
if (hasStoredProvider || hasSentinel) {
|
||||
if (stored.has(emptyProviderSentinelKey(provider.slug))) {
|
||||
continue
|
||||
}
|
||||
|
||||
expandProviderDefaults(provider, next)
|
||||
const hasStoredProvider = [...stored].some(key => key.startsWith(providerPrefix) && !isProviderSentinel(key))
|
||||
|
||||
if (!hasStoredProvider) {
|
||||
expandProviderDefaults(provider, next)
|
||||
} else if (known) {
|
||||
expandProviderDefaults(provider, next, key => !known.has(key))
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
@@ -168,9 +237,10 @@ export function resolveVisibleKeys(stored: Set<string> | null, providers: readon
|
||||
* set with bookkeeping sentinels stripped (they are not real models). */
|
||||
export function effectiveVisibleKeys(
|
||||
stored: Set<string> | null,
|
||||
providers: readonly ModelOptionProvider[]
|
||||
providers: readonly ModelOptionProvider[],
|
||||
known: Set<string> | null = $knownModels.get()
|
||||
): Set<string> {
|
||||
const next = resolveVisibleKeys(stored, providers)
|
||||
const next = resolveVisibleKeys(stored, providers, known)
|
||||
|
||||
// Strip sentinel keys — they are bookkeeping, not real visibility entries.
|
||||
for (const key of [...next]) {
|
||||
@@ -191,10 +261,11 @@ export function toggleModelVisibility(
|
||||
stored: Set<string> | null,
|
||||
providers: readonly ModelOptionProvider[],
|
||||
providerSlug: string,
|
||||
model: string
|
||||
model: string,
|
||||
known: Set<string> | null = $knownModels.get()
|
||||
): Set<string> {
|
||||
// `resolveVisibleKeys` always returns a fresh Set, so we can mutate it directly.
|
||||
const next = resolveVisibleKeys(stored, providers)
|
||||
const next = resolveVisibleKeys(stored, providers, known)
|
||||
const key = modelVisibilityKey(providerSlug, model)
|
||||
const sentinel = emptyProviderSentinelKey(providerSlug)
|
||||
|
||||
@@ -229,9 +300,10 @@ export function setProviderVisibility(
|
||||
stored: Set<string> | null,
|
||||
providers: readonly ModelOptionProvider[],
|
||||
providerSlug: string,
|
||||
visible: boolean
|
||||
visible: boolean,
|
||||
known: Set<string> | null = $knownModels.get()
|
||||
): Set<string> {
|
||||
const next = resolveVisibleKeys(stored, providers)
|
||||
const next = resolveVisibleKeys(stored, providers, known)
|
||||
const sentinel = emptyProviderSentinelKey(providerSlug)
|
||||
const provider = providers.find(p => p.slug === providerSlug)
|
||||
const families = collapseModelFamilies(provider?.models ?? [])
|
||||
|
||||
Reference in New Issue
Block a user