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:
parent
f8beca4e6a
commit
53224d877e
9 changed files with 240 additions and 16 deletions
|
|
@ -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.
|
||||
|
|
|
|||
41
frontend/packages/swarm-ui/src/pages/ArchetypePicker.css
Normal file
41
frontend/packages/swarm-ui/src/pages/ArchetypePicker.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
76
frontend/packages/swarm-ui/src/pages/ArchetypePicker.tsx
Normal file
76
frontend/packages/swarm-ui/src/pages/ArchetypePicker.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// <ArchetypePicker> — 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 (
|
||||
<div class={"ui-agent-icon" + (src ? "" : " ui-agent-icon-none")}>
|
||||
<img alt="" src={src ?? "/favicon.svg"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<FormField label="archetype" labelId={`${id}-label`}>
|
||||
<div
|
||||
class="ui-archetype-picker"
|
||||
role="radiogroup"
|
||||
aria-labelledby={`${id}-label`}
|
||||
>
|
||||
{tiles.map((t) => {
|
||||
const on = t.key === value;
|
||||
return (
|
||||
<label
|
||||
key={t.key}
|
||||
class={"ui-archetype-tile" + (on ? " ui-archetype-tile-on" : "")}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="ui-archetype-radio"
|
||||
name={id}
|
||||
checked={on}
|
||||
disabled={disabled}
|
||||
onChange={() => onChange(t.key)}
|
||||
/>
|
||||
<Icon svg={t.svg} />
|
||||
<span class="ui-archetype-name">{t.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 `<img>` 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)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
<SelectField
|
||||
<ArchetypePicker
|
||||
id="agent-archetype"
|
||||
label="archetype"
|
||||
archetypes={archetypes.archetypes}
|
||||
value={archetype}
|
||||
onChange={pickArchetype}
|
||||
disabled={!catalogue}
|
||||
options={[
|
||||
{ value: "", label: "none" },
|
||||
...archetypes.archetypes.map((a) => ({
|
||||
value: archetypeKey(a),
|
||||
label: a.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
{picked && (
|
||||
<p class="create-agent-archetype-description">
|
||||
|
|
|
|||
|
|
@ -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('<svg viewBox="0 0 1 1"><script>x()</script></svg>');
|
||||
assert.ok(src.startsWith("data:image/svg+xml;charset=utf-8,%3Csvg"), src);
|
||||
assert.ok(!src.includes("<"), "the markup is percent-encoded");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/<name>.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/<name>.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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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`/`<source>`.
|
||||
#[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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// Open the archetype bucket for writing, creating it if nothing has yet.
|
||||
|
|
|
|||
Loading…
Reference in a new issue