Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
iris 2026-11-11 09:08:03 +01:00 • committed by mara
commit 53224d877e

View file

@ -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<InferenceSettings>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
icon: Option<String>,
}
/// `meta` as stored, or why a create would refuse it.
@ -76,6 +78,12 @@ fn check(name: &str, meta: serde_json::Value) -> Result<ArchetypeMeta, String> {
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<ArchetypeMeta, String> {
.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 `<img>` 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("<svg") && !icon.trim_start().starts_with("<?xml") {
return Err("is not SVG text".to_owned());
}
Ok(())
}
/// The record to store for one evaluation: every archetype that passes
/// [`check`], and every one that does not with its reason.
fn record(source: String, archetypes: serde_json::Map<String, serde_json::Value>) -> 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<String>,
/// The archetype's icon as SVG text, for the create form to show.
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<String>,
}
/// 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 = "<svg xmlns=\"http://www.w3.org/2000/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!("<svg>{}</svg>", "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::<Vec<_>>(),
["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": "<svg/>" },
"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"], "<svg/>");
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!({