From 53224d877e73a3d79a3d6318a47127dfc319ac54 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 11 Nov 2026 09:08:03 +0100 Subject: [PATCH] swarm ui: the create form picks an archetype from tiles with their icons An archetype's meta gets an optional icon, the SVG text (at most 16 KiB), which the controller checks, stores and serves on GET /api/archetypes. The built-in archetypes read theirs from nix/archetypes/icons. The create form shows each archetype as a tile with its icon and name, laid out like the broadcast screen's agent tiles, in place of the dropdown, and draws the SVG through an img data URL. --- docs/2-handbook/swarm/archetypes.md | 5 +- .../swarm-ui/src/pages/ArchetypePicker.css | 41 ++++++++++ .../swarm-ui/src/pages/ArchetypePicker.tsx | 76 ++++++++++++++++++ .../src/pages/CreateAgentArchetype.ts | 11 +++ .../swarm-ui/src/pages/CreateAgentForm.tsx | 12 +-- .../test/create-agent-archetype.test.mjs | 9 +++ nix/archetypes/default.nix | 14 ++-- swarm-controller/src/archetypes.rs | 80 ++++++++++++++++++- swarm-queue-client/src/archetypes.rs | 8 ++ 9 files changed, 240 insertions(+), 16 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/pages/ArchetypePicker.css create mode 100644 frontend/packages/swarm-ui/src/pages/ArchetypePicker.tsx diff --git a/docs/2-handbook/swarm/archetypes.md b/docs/2-handbook/swarm/archetypes.md index 7071ebe1b..8a31c273a 100644 --- a/docs/2-handbook/swarm/archetypes.md +++ b/docs/2-handbook/swarm/archetypes.md @@ -54,7 +54,10 @@ hyperhiveArchetypes.reviewer.meta.prompt = builtins.readFile ./prompts/reviewer. evaluates `meta` alone. - Every archetype needs a `description`. `permissions`, `limits` and `inference` are optional and take the shapes `POST /api/agents` takes. - `prompt` is optional text that prefills `AGENTS.md`, [below](#the-prompt). The swarm ignores + `prompt` is optional text that prefills `AGENTS.md`, [below](#the-prompt). + `icon` is optional SVG text, at most 16 KiB, that the create form shows on the + archetype's tile; `builtins.readFile ./icon.svg` gives it. The swarm refuses an + `icon` that isn't SVG text or is over the limit, like any bad `meta`, and ignores other keys in `meta`. - `module` is a NixOS module for the agent's container. The meta flake imports it into each agent created from the archetype. diff --git a/frontend/packages/swarm-ui/src/pages/ArchetypePicker.css b/frontend/packages/swarm-ui/src/pages/ArchetypePicker.css new file mode 100644 index 000000000..7803732e8 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/ArchetypePicker.css @@ -0,0 +1,41 @@ +/* The archetype tiles, laid out and styled like the broadcast screen's agent + tiles (`.ui-broadcast-grid`, `.ui-broadcast-agent`). */ +.ui-archetype-picker { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8em, 1fr)); + gap: 0.6em; +} +.ui-archetype-tile { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35em; + padding: 0.7em 0.5em 0.6em; + border: 1px solid var(--border); + border-radius: 0.5em; + background: var(--bg); + cursor: pointer; +} +.ui-archetype-tile:hover { + border-color: var(--muted); +} +.ui-archetype-tile-on { + border-color: var(--purple); + background: var(--bg-elev); +} +.ui-archetype-tile:focus-within { + outline: 2px solid var(--purple); + outline-offset: 2px; +} +.ui-archetype-radio { + position: absolute; + top: 0.5em; + left: 0.5em; + margin: 0; +} +.ui-archetype-name { + max-width: 100%; + overflow-wrap: anywhere; + text-align: center; +} diff --git a/frontend/packages/swarm-ui/src/pages/ArchetypePicker.tsx b/frontend/packages/swarm-ui/src/pages/ArchetypePicker.tsx new file mode 100644 index 000000000..517b8791d --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/ArchetypePicker.tsx @@ -0,0 +1,76 @@ +// — the create form's archetype choice, drawn like the +// broadcast screen's agent picker: one tile per choice with its icon and name, +// where the broadcast screen has one per agent. One choice at a time, so each +// tile is a radio button. The first tile is "none", an agent with no archetype. +import { + archetypeKey, + iconSrc, + type Archetype, +} from "./CreateAgentArchetype.js"; +import { FormField } from "../ui/form-field/FormField.js"; +import "./ArchetypePicker.css"; + +function Icon({ svg }: { svg?: string }) { + const src = iconSrc(svg); + // The dimmed hyperhive mark, as an agent with no icon shows, when there is + // none (or none yet: a custom agent). + return ( +
+ +
+ ); +} + +export function ArchetypePicker({ + id, + archetypes, + value, + onChange, + disabled, +}: { + id: string; + archetypes: Archetype[]; + /** The picked tile's `archetypeKey`, `""` for none. */ + value: string; + onChange: (key: string) => void; + disabled?: boolean; +}) { + const tiles: { key: string; name: string; svg?: string }[] = [ + { key: "", name: "none" }, + ...archetypes.map((a) => ({ + key: archetypeKey(a), + name: a.name, + svg: a.icon, + })), + ]; + return ( + +
+ {tiles.map((t) => { + const on = t.key === value; + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts b/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts index b5dcf90b1..f5418d02e 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts @@ -16,6 +16,8 @@ export interface Archetype { max_subagents?: number; }; prompt?: string; + /** The archetype's icon as SVG text. */ + icon?: string; } // Mirrors swarm-controller's `Archetypes`. @@ -72,3 +74,12 @@ export function requestArchetype( ): { source: string; name: string } | undefined { return archetype && { source: archetype.source, name: archetype.name }; } + +/** + * An `` source for an archetype's SVG text, or null when it has none. A + * data URL, so the browser draws it as an image and a script in it never runs. + */ +export function iconSrc(svg: string | undefined): string | null { + if (svg === undefined || svg.trim() === "") return null; + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx index 9ea8dd550..5639027a9 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx @@ -63,6 +63,7 @@ import { requestArchetype, type ArchetypeList, } from "./CreateAgentArchetype.js"; +import { ArchetypePicker } from "./ArchetypePicker.js"; import { agentsMdAfterPick, agentsMdBody, @@ -377,19 +378,12 @@ export function CreateAgentForm({ /> {archetypes && archetypes.archetypes.length > 0 && ( <> - ({ - value: archetypeKey(a), - label: a.name, - })), - ]} /> {picked && (

diff --git a/frontend/packages/swarm-ui/test/create-agent-archetype.test.mjs b/frontend/packages/swarm-ui/test/create-agent-archetype.test.mjs index e6d3d0ca3..0284db724 100644 --- a/frontend/packages/swarm-ui/test/create-agent-archetype.test.mjs +++ b/frontend/packages/swarm-ui/test/create-agent-archetype.test.mjs @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { archetypeKey, + iconSrc, prefill, requestArchetype, } from "../src/pages/CreateAgentArchetype.ts"; @@ -83,3 +84,11 @@ test("a picked archetype is sent by source and name, none is left out", () => { }); assert.equal(requestArchetype(undefined), undefined); }); + +test("an icon is a data URL of the SVG text, and none without text", () => { + assert.equal(iconSrc(undefined), null); + assert.equal(iconSrc(" \n"), null); + const src = iconSrc(''); + assert.ok(src.startsWith("data:image/svg+xml;charset=utf-8,%3Csvg"), src); + assert.ok(!src.includes("<"), "the markup is percent-encoded"); +}); diff --git a/nix/archetypes/default.nix b/nix/archetypes/default.nix index f48d6b2f1..6a6b03443 100644 --- a/nix/archetypes/default.nix +++ b/nix/archetypes/default.nix @@ -3,11 +3,12 @@ # `meta` is plain data (no derivations, no `pkgs`), because the swarm # builder evaluates `meta` alone and swarm-controller stores it as JSON. # `meta` fields: `description` (required), `permissions`, `limits`, -# `inference`, in the shapes `POST /api/agents` takes them, and `prompt`, the -# text the create form prefills the new agent's AGENTS.md with (the built-ins -# read theirs from ./prompts/.md); see docs/2-handbook/swarm/archetypes.md. -# Every archetype sets `services.hyperhive.agent.icon` (with `mkDefault`) to an -# SVG in `icons/`, so its agents show one until their config sets another. +# `inference`, in the shapes `POST /api/agents` takes them, `icon`, the SVG text +# the create form shows beside the archetype, and `prompt`, the text the create +# form prefills the new agent's AGENTS.md with (the built-ins read theirs from +# ./prompts/.md); see docs/2-handbook/swarm/archetypes.md. Every archetype +# sets `services.hyperhive.agent.icon` (with `mkDefault`) to an SVG in `icons/`, +# so its agents show one until their config sets another. { reviewer = { module = @@ -16,6 +17,7 @@ services.hyperhive.agent.icon = lib.mkDefault ./icons/reviewer.svg; }; meta = { + icon = builtins.readFile ./icons/reviewer.svg; description = "Reviews pull requests and replies. It can run hive-forge and git (execution), and holds messaging and inbox; it has no get_agent_meta (meta)."; permissions = [ "messaging" @@ -37,6 +39,7 @@ ]; }; meta = { + icon = builtins.readFile ./icons/android-dev.svg; description = "Builds Android apps: adb and fastboot (android-tools), Gradle and a JDK are installed. It holds the default permissions."; prompt = builtins.readFile ./prompts/android-dev.md; }; @@ -49,6 +52,7 @@ services.hyperhive.agent.rea.enable = true; }; meta = { + icon = builtins.readFile ./icons/reverse-engineer.svg; description = "Reverse-engineers binaries and applications: REA and Ghidra 12.1 are installed with their MCP tools allowed. It holds the default permissions."; prompt = builtins.readFile ./prompts/reverse-engineer.md; }; diff --git a/swarm-controller/src/archetypes.rs b/swarm-controller/src/archetypes.rs index fa253d7ec..81d8e9c64 100644 --- a/swarm-controller/src/archetypes.rs +++ b/swarm-controller/src/archetypes.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; use swarm_controller_api::inference::InferenceSettings; use swarm_controller_api::limits::Caps; use swarm_queue_client::archetypes::{ - ArchetypeMeta, BUILTIN_SOURCE, MAX_PROMPT_BYTES, SourceRecord, + ArchetypeMeta, BUILTIN_SOURCE, MAX_ICON_BYTES, MAX_PROMPT_BYTES, SourceRecord, }; use swarm_wire::builder::{ ARCHETYPES_REPLY_DEADLINE, ARCHETYPES_SUBJECT, ArchetypesReply, EvalArchetypes, @@ -59,6 +59,8 @@ struct DeclaredMeta { inference: Option, #[serde(default)] prompt: Option, + #[serde(default)] + icon: Option, } /// `meta` as stored, or why a create would refuse it. @@ -76,6 +78,12 @@ fn check(name: &str, meta: serde_json::Value) -> Result { prompt.len() )); } + let icon = declared + .icon + .filter(|i| !i.trim().is_empty()) + .map(|i| check_icon(&i).map(|()| i)) + .transpose() + .map_err(|e| format!("icon: {e}"))?; let permissions = match declared.permissions { Some(perms) => { agent_permissions::unique(&perms)?; @@ -103,9 +111,26 @@ fn check(name: &str, meta: serde_json::Value) -> Result { .transpose() .map_err(|e| format!("inference: {e}"))?, prompt: declared.prompt.filter(|p| !p.trim().is_empty()), + icon, }) } +/// Whether `icon` can be shown as an image: SVG text within [`MAX_ICON_BYTES`]. +/// The create form loads it through an `` as a data URL, so a script in it +/// never runs. +fn check_icon(icon: &str) -> Result<(), String> { + if icon.len() > MAX_ICON_BYTES { + return Err(format!( + "is {} bytes; the limit is {MAX_ICON_BYTES}", + icon.len() + )); + } + if !icon.trim_start().starts_with(") -> SourceRecord { @@ -243,6 +268,9 @@ pub(crate) struct ArchetypeView { /// The text the create form prefills the agent's `AGENTS.md` with. #[serde(skip_serializing_if = "Option::is_none")] prompt: Option, + /// The archetype's icon as SVG text, for the create form to show. + #[serde(skip_serializing_if = "Option::is_none")] + icon: Option, } /// One archetype a source declared and this daemon refused. @@ -270,6 +298,7 @@ fn view(key: &str, record: SourceRecord) -> Archetypes { limits: meta.limits, inference: meta.inference, prompt: meta.prompt, + icon: meta.icon, }); } for (name, reason) in record.refused { @@ -413,6 +442,55 @@ mod tests { assert!(by_name("plain").get("prompt").is_none()); } + /// An icon is stored as declared when it is SVG text within the cap; one + /// that is not SVG, is over the cap or is not text refuses the archetype + /// by name, and a blank one is no icon. + #[test] + fn the_icon_is_stored_and_checked() { + let svg = ""; + let r = evaluated(json!({ + "plain": { "description": "x" }, + "with": { "description": "x", "icon": svg }, + "blank": { "description": "x", "icon": " \n" }, + "not-svg": { "description": "x", "icon": "hello" }, + "over-cap": { "description": "x", "icon": format!("{}", "a".repeat(MAX_ICON_BYTES)) }, + "not-text": { "description": "x", "icon": 3 } + })); + assert_eq!(r.archetypes["plain"].icon, None); + assert_eq!(r.archetypes["with"].icon.as_deref(), Some(svg)); + assert_eq!(r.archetypes["blank"].icon, None); + assert_eq!( + r.refused.keys().map(String::as_str).collect::>(), + ["not-svg", "not-text", "over-cap"] + ); + assert!( + r.refused["not-svg"].starts_with("icon:"), + "{}", + r.refused["not-svg"] + ); + } + + /// `GET /api/archetypes` carries the icon, and leaves it out when there is + /// none. + #[test] + fn the_view_carries_the_icon() { + let r = evaluated(json!({ + "with": { "description": "x", "icon": "" }, + "plain": { "description": "x" } + })); + let wire = serde_json::to_value(view(BUILTIN_SOURCE, r)).unwrap(); + let by_name = |name: &str| { + wire["archetypes"] + .as_array() + .unwrap() + .iter() + .find(|a| a["name"] == name) + .unwrap() + .clone() + }; + assert_eq!(by_name("with")["icon"], ""); + assert!(by_name("plain").get("icon").is_none()); + } #[test] fn a_bad_archetype_is_refused_by_name_and_the_rest_stay() { let r = evaluated(json!({ diff --git a/swarm-queue-client/src/archetypes.rs b/swarm-queue-client/src/archetypes.rs index 4082644fc..6c55c57d2 100644 --- a/swarm-queue-client/src/archetypes.rs +++ b/swarm-queue-client/src/archetypes.rs @@ -25,6 +25,10 @@ pub const BUILTIN_SOURCE: &str = "hyperhive"; /// agent's initial `AGENTS.md`, which the prompt prefills. pub const MAX_PROMPT_BYTES: usize = crate::agents_md_initial::MAX_BYTES; +/// The most bytes an archetype's `meta.icon` may hold. The swarm stores the SVG +/// text in the record and the create form shows it beside the archetype. +pub const MAX_ICON_BYTES: usize = 16 * 1024; + /// One source's archetypes: the value under `BUCKET`/``. #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct SourceRecord { @@ -54,6 +58,10 @@ pub struct ArchetypeMeta { /// at most [`MAX_PROMPT_BYTES`]. Absent prefills nothing. #[serde(default, skip_serializing_if = "Option::is_none")] pub prompt: Option, + /// The archetype's icon as SVG text, at most [`MAX_ICON_BYTES`]. Absent shows + /// none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, } /// Open the archetype bucket for writing, creating it if nothing has yet.