From 1f54a071950db40475a47738bae1ba11cd3ecaf1 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 13:27:51 +0200 Subject: [PATCH 1/2] feat(agent): read available models from HIVE_AVAILABLE_MODELS env var web_ui.rs: add available_models() helper that reads HIVE_AVAILABLE_MODELS (comma-separated, injected by services.hyperhive.availableModels nix option). Falls back to ["haiku", "sonnet", "opus"] when absent or empty. Field added to StateSnapshot so the frontend receives the list on cold-load. app.js: replace hardcoded MODEL_ALIASES array with availableModels module var. Seeded from state.available_models before setHeader/populateOverflowMenu on first /api/state load. Well-known aliases (haiku/sonnet/opus) still get their descriptive labels; operator-declared custom model names show the name itself. Implements the frontend + web_ui.rs side of issue #1359 (nix option shipped separately in PR #1360 by atlas). --- frontend/packages/agent/src/app.js | 38 ++++++++++++++++++++---------- hive-ag3nt/src/web_ui.rs | 31 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 12 deletions(-) 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 + } +} From bf25d71fc9d34d16dd6bcb4bb066e2ae82f4a434 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 13:46:30 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(agent):=20update=20model=20quick-picke?= =?UTF-8?q?r=20description=20=E2=80=94=20list=20is=20now=20configurable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/web-ui/agent.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index c8271f67..bc2413fa 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -73,14 +73,20 @@ through. Three flex columns: preserved). All destructive actions require one extra click to acknowledge — rare ops shouldn't live in the primary state strip. Below a separator, a **model quick-picker** section labelled - `model` renders three one-click shortcuts: `haiku (fast)`, - `sonnet (balanced)`, `opus (powerful)`. Clicking a button POSTs - `/api/model` with the alias (same path as the `/model ` - slash command). The button for the currently-active model is - highlighted via the `active` class; `renderModelChip` keeps the - picker state in sync with live `model_changed` events so it stays - accurate when the model is changed from another session. Clicking - the already-active model closes the menu without an extra POST. + `model` renders one button per model in the operator-configured + list. The list is driven by `state.available_models` (sourced from + the `HIVE_AVAILABLE_MODELS` env var, injected by the + `services.hyperhive.availableModels` NixOS option; defaults to + `["haiku", "sonnet", "opus"]` when unset). Well-known aliases get + a parenthetical description (`haiku (fast)`, `sonnet (balanced)`, + `opus (powerful)`); operator-declared custom names show as-is. + Clicking a button POSTs `/api/model` with the alias (same path as + the `/model ` slash command). The button for the + currently-active model is highlighted via the `active` class; + `renderModelChip` keeps the picker state in sync with live + `model_changed` events so it stays accurate when the model is + changed from another session. Clicking the already-active model + closes the menu without an extra POST. The popover's display rules are scoped to `:not([hidden])` so the `[hidden]` HTML attribute's UA `display: none` isn't overridden by the author CSS's `display: flex` — the popover stays hidden until