mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
Merge pull request #50100 from kshitijk4poor/salvage/model-visibility-cross-provider-47450
fix(desktop): preserve other providers' hide-all in model visibility dialog (salvage #47450)
This commit is contained in:
commit
8ca38d3121
3 changed files with 207 additions and 36 deletions
|
|
@ -14,10 +14,9 @@ import {
|
|||
$visibleModels,
|
||||
collapseModelFamilies,
|
||||
effectiveVisibleKeys,
|
||||
emptyProviderSentinelKey,
|
||||
isProviderSentinel,
|
||||
modelVisibilityKey,
|
||||
setVisibleModels
|
||||
setVisibleModels,
|
||||
toggleModelVisibility
|
||||
} from '@/store/model-visibility'
|
||||
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
|
|
@ -61,25 +60,7 @@ export function ModelVisibilityDialog({
|
|||
const visible = effectiveVisibleKeys(stored, providers)
|
||||
|
||||
const toggle = (provider: ModelOptionProvider, model: string) => {
|
||||
const next = new Set(effectiveVisibleKeys($visibleModels.get(), providers))
|
||||
const key = modelVisibilityKey(provider.slug, model)
|
||||
const sentinel = emptyProviderSentinelKey(provider.slug)
|
||||
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
|
||||
// Check if this was the last real model for this provider.
|
||||
const remainingForProvider = [...next].some(k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k))
|
||||
|
||||
if (!remainingForProvider) {
|
||||
next.add(sentinel)
|
||||
}
|
||||
} else {
|
||||
next.delete(sentinel)
|
||||
next.add(key)
|
||||
}
|
||||
|
||||
setVisibleModels(next)
|
||||
setVisibleModels(toggleModelVisibility($visibleModels.get(), providers, provider.slug, model))
|
||||
}
|
||||
|
||||
const q = search.trim().toLowerCase()
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ import type { ModelOptionProvider } from '@/types/hermes'
|
|||
|
||||
import {
|
||||
collapseModelFamilies,
|
||||
defaultVisibleKeys,
|
||||
effectiveVisibleKeys,
|
||||
emptyProviderSentinelKey,
|
||||
isProviderSentinel,
|
||||
modelVisibilityKey
|
||||
modelVisibilityKey,
|
||||
resolveVisibleKeys,
|
||||
toggleModelVisibility
|
||||
} from './model-visibility'
|
||||
|
||||
const provider = (slug: string, models: string[]): ModelOptionProvider => ({
|
||||
|
|
@ -96,4 +99,133 @@ describe('model visibility', () => {
|
|||
expect(isProviderSentinel('openai::')).toBe(true)
|
||||
expect(isProviderSentinel('openai::gpt-4o')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolveVisibleKeys preserves sentinels that effectiveVisibleKeys strips', () => {
|
||||
const stored = new Set([emptyProviderSentinelKey('nous')])
|
||||
const providers = [provider('nous', ['hermes-x', 'hermes-y']), provider('ollama', ['qwen3:latest'])]
|
||||
|
||||
const resolved = resolveVisibleKeys(stored, providers)
|
||||
expect(resolved.has(emptyProviderSentinelKey('nous'))).toBe(true)
|
||||
expect(resolved.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(false)
|
||||
// Un-customized providers still expand to their defaults.
|
||||
expect(resolved.has(modelVisibilityKey('ollama', 'qwen3:latest'))).toBe(true)
|
||||
|
||||
// Display variant drops the sentinel.
|
||||
expect(effectiveVisibleKeys(stored, providers).has(emptyProviderSentinelKey('nous'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleModelVisibility', () => {
|
||||
const providers = [provider('openai', ['gpt-a', 'gpt-b']), provider('nous', ['hermes-x', 'hermes-y'])]
|
||||
|
||||
// Drive the handler the way the dialog does: feed each result back in as the
|
||||
// next `stored`, so the persisted set is what the next toggle starts from.
|
||||
const apply = (stored: Set<string> | null, slug: string, model: string) =>
|
||||
toggleModelVisibility(stored, providers, slug, model)
|
||||
|
||||
it('records a hide-all sentinel when the last model of a provider is toggled off', () => {
|
||||
let stored: Set<string> | null = null
|
||||
stored = apply(stored, 'openai', 'gpt-a')
|
||||
stored = apply(stored, 'openai', 'gpt-b')
|
||||
|
||||
expect(stored.has(emptyProviderSentinelKey('openai'))).toBe(true)
|
||||
expect(effectiveVisibleKeys(stored, providers).has(modelVisibilityKey('openai', 'gpt-a'))).toBe(false)
|
||||
expect(effectiveVisibleKeys(stored, providers).has(modelVisibilityKey('openai', 'gpt-b'))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a hidden provider hidden when a different provider is toggled (regression for #43485)', () => {
|
||||
// Hide ALL of nous — its sentinel is now stored.
|
||||
let stored: Set<string> | null = null
|
||||
stored = apply(stored, 'nous', 'hermes-x')
|
||||
stored = apply(stored, 'nous', 'hermes-y')
|
||||
expect(stored.has(emptyProviderSentinelKey('nous'))).toBe(true)
|
||||
|
||||
// Toggle a model in another provider. nous must NOT snap back on.
|
||||
stored = apply(stored, 'openai', 'gpt-a')
|
||||
|
||||
expect(stored.has(emptyProviderSentinelKey('nous'))).toBe(true)
|
||||
const visible = effectiveVisibleKeys(stored, providers)
|
||||
expect(visible.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(false)
|
||||
expect(visible.has(modelVisibilityKey('nous', 'hermes-y'))).toBe(false)
|
||||
})
|
||||
|
||||
it('clears only the toggled provider sentinel when a model is re-enabled', () => {
|
||||
let stored: Set<string> | null = new Set([emptyProviderSentinelKey('openai'), emptyProviderSentinelKey('nous')])
|
||||
|
||||
stored = apply(stored, 'openai', 'gpt-a')
|
||||
|
||||
expect(stored.has(emptyProviderSentinelKey('openai'))).toBe(false)
|
||||
expect(stored.has(emptyProviderSentinelKey('nous'))).toBe(true)
|
||||
const visible = effectiveVisibleKeys(stored, providers)
|
||||
expect(visible.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true)
|
||||
expect(visible.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(false)
|
||||
})
|
||||
|
||||
it('re-enabling one model of a hidden-all provider restores ONLY that model, not the curated defaults', () => {
|
||||
// openai hidden-all, nous untouched.
|
||||
let stored: Set<string> | null = new Set([emptyProviderSentinelKey('openai')])
|
||||
|
||||
stored = apply(stored, 'openai', 'gpt-a')
|
||||
|
||||
const visible = effectiveVisibleKeys(stored, providers)
|
||||
expect(visible.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true)
|
||||
// gpt-b is NOT restored — "you hid everything, you get back only what you re-enable".
|
||||
expect(visible.has(modelVisibilityKey('openai', 'gpt-b'))).toBe(false)
|
||||
})
|
||||
|
||||
it('re-hiding the last re-enabled model re-adds the sentinel (full round-trip)', () => {
|
||||
let stored: Set<string> | null = new Set([emptyProviderSentinelKey('openai')])
|
||||
|
||||
// Re-enable gpt-a (clears sentinel, set = {gpt-a}), then toggle it back off.
|
||||
stored = apply(stored, 'openai', 'gpt-a')
|
||||
expect(stored.has(emptyProviderSentinelKey('openai'))).toBe(false)
|
||||
stored = apply(stored, 'openai', 'gpt-a')
|
||||
|
||||
expect(stored.has(emptyProviderSentinelKey('openai'))).toBe(true)
|
||||
expect(effectiveVisibleKeys(stored, providers).has(modelVisibilityKey('openai', 'gpt-a'))).toBe(false)
|
||||
})
|
||||
|
||||
it('toggling from an empty (non-null) stored set adds the model without expanding defaults', () => {
|
||||
// Empty-but-not-null = "everything hidden". resolveVisibleKeys short-circuits to {}.
|
||||
const stored = new Set<string>()
|
||||
|
||||
const next = apply(stored, 'openai', 'gpt-a')
|
||||
|
||||
expect(next.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true)
|
||||
// No curated defaults were expanded for any provider.
|
||||
expect(next.has(modelVisibilityKey('openai', 'gpt-b'))).toBe(false)
|
||||
expect(next.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(false)
|
||||
})
|
||||
|
||||
it('toggling off one default model from null stored keeps the rest of the curated defaults', () => {
|
||||
// null = "never customized": resolveVisibleKeys expands all defaults first.
|
||||
const next = apply(null, 'openai', 'gpt-a')
|
||||
|
||||
expect(next.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(false)
|
||||
expect(next.has(modelVisibilityKey('openai', 'gpt-b'))).toBe(true)
|
||||
expect(next.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(true)
|
||||
// Other models remain, so no sentinel.
|
||||
expect(next.has(emptyProviderSentinelKey('openai'))).toBe(false)
|
||||
})
|
||||
|
||||
it('tolerates a provider with zero models (defensive — dialog filters these out)', () => {
|
||||
const ps = [provider('empty', []), provider('openai', ['gpt-a'])]
|
||||
const next = toggleModelVisibility(new Set([modelVisibilityKey('openai', 'gpt-a')]), ps, 'empty', 'ghost')
|
||||
|
||||
// No crash; the phantom key is recorded but no defaults are invented.
|
||||
expect([...next].some(k => k.startsWith('empty::') && !isProviderSentinel(k))).toBe(true)
|
||||
expect(next.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveVisibleKeys', () => {
|
||||
const providers = [provider('openai', ['gpt-a', 'gpt-b']), provider('nous', ['hermes-x', 'hermes-y'])]
|
||||
|
||||
it('returns the curated defaults verbatim for null stored', () => {
|
||||
expect(resolveVisibleKeys(null, providers)).toEqual(defaultVisibleKeys(providers))
|
||||
})
|
||||
|
||||
it('returns an empty set for an empty (non-null) stored set', () => {
|
||||
expect([...resolveVisibleKeys(new Set(), providers)]).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -106,19 +106,29 @@ export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): S
|
|||
const keys = new Set<string>()
|
||||
|
||||
for (const provider of providers) {
|
||||
const families = collapseModelFamilies(provider.models ?? [])
|
||||
|
||||
for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
|
||||
keys.add(modelVisibilityKey(provider.slug, family.id))
|
||||
}
|
||||
expandProviderDefaults(provider, keys)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Resolve which keys are currently visible: the user's explicit set when
|
||||
* configured, otherwise the curated default for the given providers. */
|
||||
export function effectiveVisibleKeys(
|
||||
/** Add a provider's curated default model keys (top-N collapsed families) to
|
||||
* `target`. Shared by `defaultVisibleKeys` and `resolveVisibleKeys` so the
|
||||
* expansion rule lives in exactly one place. */
|
||||
function expandProviderDefaults(provider: ModelOptionProvider, target: Set<string>): void {
|
||||
const families = collapseModelFamilies(provider.models ?? [])
|
||||
|
||||
for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
|
||||
target.add(modelVisibilityKey(provider.slug, family.id))
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the canonical working set: the user's stored keys plus the curated
|
||||
* 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> {
|
||||
|
|
@ -134,22 +144,31 @@ export function effectiveVisibleKeys(
|
|||
|
||||
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) {
|
||||
continue
|
||||
}
|
||||
|
||||
const families = collapseModelFamilies(provider.models ?? [])
|
||||
|
||||
for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
|
||||
next.add(modelVisibilityKey(provider.slug, family.id))
|
||||
}
|
||||
expandProviderDefaults(provider, next)
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/** Resolve which keys are currently visible for DISPLAY: the resolved working
|
||||
* set with bookkeeping sentinels stripped (they are not real models). */
|
||||
export function effectiveVisibleKeys(
|
||||
stored: Set<string> | null,
|
||||
providers: readonly ModelOptionProvider[]
|
||||
): Set<string> {
|
||||
const next = resolveVisibleKeys(stored, providers)
|
||||
|
||||
// Strip sentinel keys — they are bookkeeping, not real visibility entries.
|
||||
for (const key of [...next]) {
|
||||
if (isProviderSentinel(key)) {
|
||||
|
|
@ -159,3 +178,42 @@ export function effectiveVisibleKeys(
|
|||
|
||||
return next
|
||||
}
|
||||
|
||||
/** Compute the next persisted visibility set when one model row is toggled.
|
||||
* Seeds from `resolveVisibleKeys` (NOT `effectiveVisibleKeys`) so other
|
||||
* providers' hide-all sentinels survive the persist. When the last visible
|
||||
* model of a provider is toggled off, a sentinel records the explicit
|
||||
* hide-all; re-enabling a model clears THAT provider's sentinel (only). */
|
||||
export function toggleModelVisibility(
|
||||
stored: Set<string> | null,
|
||||
providers: readonly ModelOptionProvider[],
|
||||
providerSlug: string,
|
||||
model: string
|
||||
): Set<string> {
|
||||
// `resolveVisibleKeys` always returns a fresh Set, so we can mutate it directly.
|
||||
const next = resolveVisibleKeys(stored, providers)
|
||||
const key = modelVisibilityKey(providerSlug, model)
|
||||
const sentinel = emptyProviderSentinelKey(providerSlug)
|
||||
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
|
||||
// Check if this was the last real model for this provider.
|
||||
const remainingForProvider = [...next].some(
|
||||
k => k.startsWith(`${providerSlug}::`) && !isProviderSentinel(k)
|
||||
)
|
||||
|
||||
if (!remainingForProvider) {
|
||||
next.add(sentinel)
|
||||
}
|
||||
} else {
|
||||
// Re-enabling promotes a previously hidden-all provider to an explicit
|
||||
// set of exactly the one re-enabled model — the curated defaults are NOT
|
||||
// restored. Intentional: "you hid everything, you get back only what you
|
||||
// re-enable." (Locked in by the sentinel-clear-on-re-enable test.)
|
||||
next.delete(sentinel)
|
||||
next.add(key)
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue