Merge pull request #37738 from NousResearch/bb/statusbar-model-menu

feat(desktop): inline model picker in the status bar
This commit is contained in:
brooklyn!
2026-06-02 20:00:39 -05:00
committed by GitHub
18 changed files with 1247 additions and 83 deletions
+108
View File
@@ -0,0 +1,108 @@
import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
import type { ModelOptionProvider } from '@/types/hermes'
const STORAGE_KEY = 'hermes.desktop.visible-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 = 5
/** Stable key for a provider/model pair (`::` avoids colliding with model ids
* that contain a single colon, e.g. `model:tag`). */
export const modelVisibilityKey = (provider: string, model: string): string => `${provider}::${model}`
/** A model and its optional `…-fast` sibling, collapsed into one logical row.
* `id` is the canonical (base) model; `fastId` is the fast variant if present. */
export interface ModelFamily {
fastId: string | null
id: string
}
/** Collapse a provider's model list so a base model and its `…-fast` variant
* become a single family (one row, one toggle). Order is preserved by the
* base model's position. A `…-fast` model with no base stands on its own. */
export function collapseModelFamilies(models: readonly string[]): ModelFamily[] {
const present = new Set(models)
const families: ModelFamily[] = []
const consumed = new Set<string>()
for (const model of models) {
if (consumed.has(model)) {
continue
}
if (/-fast$/i.test(model) && present.has(model.replace(/-fast$/i, ''))) {
// Represented by its base entry — the base attaches it as `fastId`.
continue
}
const fastId = `${model}-fast`
const hasFast = present.has(fastId)
families.push({ fastId: hasFast ? fastId : null, id: model })
consumed.add(model)
if (hasFast) {
consumed.add(fastId)
}
}
return families
}
function loadVisible(): Set<string> | null {
const raw = storedString(STORAGE_KEY)
if (!raw) {
return null
}
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? new Set(parsed.filter((x): x is string => typeof x === 'string')) : null
} catch {
return 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 $modelVisibilityOpen = atom(false)
export function setVisibleModels(keys: Set<string>): void {
$visibleModels.set(new Set(keys))
persistString(STORAGE_KEY, JSON.stringify([...keys]))
}
export function setModelVisibilityOpen(open: boolean): void {
$modelVisibilityOpen.set(open)
}
/** The default-visible key set: the curated top-N per provider. Used both as
* the dropdown fallback and to seed the Edit Models dialog. */
export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): Set<string> {
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))
}
}
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(
stored: Set<string> | null,
providers: readonly ModelOptionProvider[]
): Set<string> {
return stored ?? defaultVisibleKeys(providers)
}