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
5 changed files with 240 additions and 16 deletions
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");
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue