swarm-ui: link an agent's forge profile and config repo from its overview
The agent card's overview gets a forge row with two links, built from the forge entry of the swarm's service links.
This commit is contained in:
parent
412c80d4ff
commit
e8e6150137
6 changed files with 129 additions and 2 deletions
|
|
@ -73,8 +73,10 @@ and config PR.
|
|||
An opened card has four sections, each folded except **overview**, and each
|
||||
reads nothing until you open it:
|
||||
|
||||
- **overview**: the agent's turn state, context and cost, its hive, and its open
|
||||
config PR.
|
||||
- **overview**: the agent's turn state, context and cost, its hive, links to its
|
||||
profile and its config repo on the swarm's forge (built from the forge entry
|
||||
of `GET /api/links`; without one the row is left out), and its
|
||||
open config PR.
|
||||
- **settings**: inference, instructions, resource caps, permissions and allowed
|
||||
domains, each with its own editor (described below).
|
||||
- **accounts**: the agent's linked accounts, and the menu that links another.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@
|
|||
.ui-agent-detail-fields dd {
|
||||
margin: 0;
|
||||
}
|
||||
.ui-agent-detail-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4em 1em;
|
||||
}
|
||||
.ui-agent-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import { AllowedDomains } from "./AllowedDomains.js";
|
|||
import { AgentStateRow } from "./AgentStateRow.js";
|
||||
import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
|
||||
import { canTurnIntoCustom, FRESHNESS, type AgentRow } from "../AgentTypes.js";
|
||||
import { agentForgeLinks } from "./forge-links.js";
|
||||
import { useForgeBase } from "./useForgeBase.js";
|
||||
import { LinkAccountMenu } from "./LinkAccountMenu.js";
|
||||
import { LinkedAccounts } from "./LinkedAccounts.js";
|
||||
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
|
||||
|
|
@ -98,6 +100,8 @@ function OverviewRows({
|
|||
row: AgentRow;
|
||||
actions: AgentActions;
|
||||
}) {
|
||||
const forge = useForgeBase();
|
||||
const forgeLinks = forge ? agentForgeLinks(forge, row.name) : null;
|
||||
return (
|
||||
<>
|
||||
<dt>state</dt>
|
||||
|
|
@ -106,6 +110,19 @@ function OverviewRows({
|
|||
</dd>
|
||||
<dt>hive</dt>
|
||||
<dd>{row.hive ?? "—"}</dd>
|
||||
{forgeLinks ? (
|
||||
<>
|
||||
<dt>forge</dt>
|
||||
<dd class="ui-agent-detail-links">
|
||||
<a href={forgeLinks.profile} target="_blank" rel="noreferrer">
|
||||
profile
|
||||
</a>
|
||||
<a href={forgeLinks.configRepo} target="_blank" rel="noreferrer">
|
||||
config repo
|
||||
</a>
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
<dt>config PR</dt>
|
||||
<dd>
|
||||
{row.config_pr ? (
|
||||
|
|
|
|||
22
frontend/packages/swarm-ui/src/agent/detail/forge-links.ts
Normal file
22
frontend/packages/swarm-ui/src/agent/detail/forge-links.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// Where an agent lives on the swarm's forge, without a DOM: its profile and its
|
||||
// config repo. The agent's forge login is its name, and its config repo is
|
||||
// `agent-configs/<name>`. The forge's address is the swarm's "Forge" entry in
|
||||
// `GET /api/links`, the same list the header's links menu shows.
|
||||
import type { MenuLink } from "@hive/shared/links-menu.js";
|
||||
|
||||
export type AgentForgeLinks = { profile: string; configRepo: string };
|
||||
|
||||
/** The forge's base URL from the swarm's service links, or null without one. */
|
||||
export function forgeBase(links: MenuLink[]): string | null {
|
||||
const forge = links.find((l) => l.label.trim().toLowerCase() === "forge");
|
||||
return forge ? forge.url.replace(/\/+$/, "") : null;
|
||||
}
|
||||
|
||||
/** The agent's profile and config repo on the forge at `base`. */
|
||||
export function agentForgeLinks(base: string, agent: string): AgentForgeLinks {
|
||||
const name = encodeURIComponent(agent);
|
||||
return {
|
||||
profile: `${base}/${name}`,
|
||||
configRepo: `${base}/agent-configs/${name}`,
|
||||
};
|
||||
}
|
||||
40
frontend/packages/swarm-ui/src/agent/detail/useForgeBase.ts
Normal file
40
frontend/packages/swarm-ui/src/agent/detail/useForgeBase.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// The swarm forge's base URL, from `GET /api/links`. Fetched once and shared by
|
||||
// every agent card, so opening many cards sends one request. A failed fetch
|
||||
// is not kept, so the next card retries; the links just stay hidden meanwhile.
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import type { MenuLink } from "@hive/shared/links-menu.js";
|
||||
import { forgeBase } from "./forge-links.js";
|
||||
|
||||
let pending: Promise<string | null> | null = null;
|
||||
|
||||
function loadForgeBase(): Promise<string | null> {
|
||||
if (!pending) {
|
||||
pending = fetch("/api/links")
|
||||
.then((r) =>
|
||||
r.ok
|
||||
? (r.json() as Promise<MenuLink[]>)
|
||||
: Promise.reject(new Error(`http ${r.status}`)),
|
||||
)
|
||||
.then(forgeBase)
|
||||
.catch(() => {
|
||||
pending = null;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** The forge's base URL, or null until known (or when the swarm lists none). */
|
||||
export function useForgeBase(): string | null {
|
||||
const [base, setBase] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void loadForgeBase().then((b) => {
|
||||
if (live) setBase(b);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
return base;
|
||||
}
|
||||
41
frontend/packages/swarm-ui/test/forge-links.test.mjs
Normal file
41
frontend/packages/swarm-ui/test/forge-links.test.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// Where an agent lives on the swarm's forge: its profile and config repo, from
|
||||
// the forge entry of the swarm's service links. Run by `npm test` with Node's
|
||||
// type stripping.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { agentForgeLinks, forgeBase } from "../src/agent/detail/forge-links.ts";
|
||||
|
||||
const link = (label, url) => ({ label, icon: "x", url });
|
||||
|
||||
test("the forge base is the forge entry's URL, any case, without a trailing slash", () => {
|
||||
assert.equal(
|
||||
forgeBase([
|
||||
link("Grafana", "https://grafana.example/"),
|
||||
link("Forge", "https://forge.example/"),
|
||||
]),
|
||||
"https://forge.example",
|
||||
);
|
||||
assert.equal(
|
||||
forgeBase([link("forge", "https://forge.example")]),
|
||||
"https://forge.example",
|
||||
);
|
||||
});
|
||||
|
||||
test("no forge entry gives no base", () => {
|
||||
assert.equal(forgeBase([]), null);
|
||||
assert.equal(forgeBase([link("Grafana", "https://grafana.example/")]), null);
|
||||
});
|
||||
|
||||
test("an agent's profile and config repo sit under the forge base", () => {
|
||||
assert.deepEqual(agentForgeLinks("https://forge.example", "iris"), {
|
||||
profile: "https://forge.example/iris",
|
||||
configRepo: "https://forge.example/agent-configs/iris",
|
||||
});
|
||||
});
|
||||
|
||||
test("the agent name is encoded", () => {
|
||||
assert.equal(
|
||||
agentForgeLinks("https://forge.example", "a b").profile,
|
||||
"https://forge.example/a%20b",
|
||||
);
|
||||
});
|
||||
Loading…
Reference in a new issue