diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index c47683df..12044a1e 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -273,33 +273,37 @@ window.marked = marked; menu.append(logoutBtn); // ─── model quick-picker ──────────────────────────────────────── - // Three one-click shortcuts for the most common model aliases. + // One-click shortcuts for each model in `availableModels` (seeded + // from `state.available_models` / `HIVE_AVAILABLE_MODELS` nix option). // The active model is highlighted via the `active` class; see // `renderModelChip` which updates `modelPickerBtns` live. const modelSep = el('div', { class: 'overflow-sep', 'aria-hidden': 'true' }); const modelLabel = el('div', { class: 'overflow-section-label' }, 'model'); menu.append(modelSep, modelLabel); - const MODEL_ALIASES = [ - { name: 'haiku', label: 'haiku (fast)' }, - { name: 'sonnet', label: 'sonnet (balanced)' }, - { name: 'opus', label: 'opus (powerful)' }, - ]; + // Well-known aliases get a parenthetical description; unknown aliases + // (operator-declared custom models) show just the name. + const MODEL_DESCRIPTIONS = { + haiku: 'haiku (fast)', + sonnet: 'sonnet (balanced)', + opus: 'opus (powerful)', + }; modelPickerBtns = []; - for (const m of MODEL_ALIASES) { + for (const name of availableModels) { + const label = MODEL_DESCRIPTIONS[name] ?? name; const btn = el('button', { type: 'button', class: 'overflow-item overflow-item-model', role: 'menuitem', - title: `/model ${m.name}`, - 'data-model': m.name, + title: `/model ${name}`, + 'data-model': name, }, el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '⊞'), - m.label, + label, ); btn.addEventListener('click', () => { - if (currentModel === m.name) { closeOverflowMenu(); return; } + if (currentModel === name) { closeOverflowMenu(); return; } closeOverflowMenu(); - postModel(m.name); + postModel(name); }); modelPickerBtns.push(btn); menu.append(btn); @@ -471,6 +475,10 @@ window.marked = marked; // menu so renderModelChip can update their `active` state without // rebuilding the whole menu. let modelPickerBtns = []; + // Ordered list of model short-names available on this hive. Seeded from + // `state.available_models` (injected by the nix option); falls back to + // the built-in default until the first /api/state cold-load completes. + let availableModels = ['haiku', 'sonnet', 'opus']; const SLASH_COMMANDS = [ { name: '/help', desc: 'list slash commands' }, @@ -1113,6 +1121,12 @@ window.marked = marked; const resp = await fetch('api/state'); if (!resp.ok) throw new Error('http ' + resp.status); const s = await resp.json(); + // Seed available_models before populateOverflowMenu (called from + // setHeader on the first load) so the picker uses the operator-declared + // list rather than the JS fallback default. + if (Array.isArray(s.available_models) && s.available_models.length > 0) { + availableModels = s.available_models; + } if (!headerSet) { setHeader(s.label, s.qualified_label, s.dashboard_port, s.hive_name, s.swarm_name); headerSet = true; } currentLabel = s.label; // Render server-supplied navigation links — see diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 5c1f2ab8..6d3e62b0 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -436,6 +436,14 @@ struct StateSnapshot { /// Human name of the swarm (e.g. `"constellat1on"`). Sourced from /// `HYPERHIVE_SWARM_NAME`; `None` when unset. swarm_name: Option, + /// Ordered list of model short-names the operator has declared as + /// available on this hive. Sourced from `HIVE_AVAILABLE_MODELS` + /// (comma-separated, set by `services.hyperhive.availableModels`). + /// Falls back to `["haiku", "sonnet", "opus"]` when the env var is + /// absent or empty. The frontend model quick-picker renders one button + /// per entry in this list, so operators can add new models or drop + /// ones they don't want without touching the frontend code. + available_models: Vec, } /// One navigation link in the agent page header row. The same JSON @@ -561,6 +569,7 @@ async fn api_state(State(state): State) -> axum::Json { .filter(|s| !s.is_empty()), hive_name: crate::identity::hive_name(), swarm_name: crate::identity::swarm_name(), + available_models: available_models(), }) } @@ -1076,3 +1085,25 @@ fn error_response(message: &str) -> Response { // be noise. (StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response() } + +/// Read `HIVE_AVAILABLE_MODELS` (comma-separated short names injected by +/// `services.hyperhive.availableModels`) and return the parsed list. +/// Falls back to `["haiku", "sonnet", "opus"]` when the env var is absent +/// or resolves to an empty list after trimming. +fn available_models() -> Vec { + const DEFAULT: &[&str] = &["haiku", "sonnet", "opus"]; + let raw = match std::env::var("HIVE_AVAILABLE_MODELS") { + Ok(v) if !v.trim().is_empty() => v, + _ => return DEFAULT.iter().map(|s| s.to_string()).collect(), + }; + let models: Vec = raw + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if models.is_empty() { + DEFAULT.iter().map(|s| s.to_string()).collect() + } else { + models + } +}