pages/agents held the roster page, the terminal page and every component under them. It keeps the two pages; the components move to src/agent, by concern: roster (card, wanted menu, actions), detail (panel body, dialog, inference, linked accounts, link menu) and terminal (preview, input, subagents, stream hooks, favicon), with the shared row type and icon at the top. Moves and import rewrites only.
301 lines
9.1 KiB
TypeScript
301 lines
9.1 KiB
TypeScript
// <AgentInference> — one agent's inference settings on the detail panel: the
|
|
// model and effort it runs on now, with where each came from, and an editor
|
|
// for the value the swarm sets.
|
|
//
|
|
// Reads `GET /api/agents/{name}/inference`, which answers
|
|
// `{ configured, effective }`: `configured` is the swarm's stored value (null
|
|
// when none), `effective` what the agent reported running on (null until its
|
|
// first report reaches the controller). The editor PUTs the whole value and
|
|
// the clear action DELETEs it; a cleared field puts the agent back on its own
|
|
// value, which is why an empty field is left out of the body rather than sent.
|
|
//
|
|
// Its state belongs to one agent: callers key it by agent name, so switching
|
|
// agents remounts it instead of showing the previous agent's settings.
|
|
import { useEffect, useState } from "preact/hooks";
|
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
|
import { Badge } from "@hive/shared/badge.js";
|
|
import { Dialog } from "../../ui/dialog/Dialog.js";
|
|
import { Panel } from "../../ui/panel/Panel.js";
|
|
import { SelectField } from "../../ui/select-field/SelectField.js";
|
|
import { TextField } from "../../ui/text-field/TextField.js";
|
|
import { Button } from "../../ui/button/Button.js";
|
|
import "./AgentInference.css";
|
|
|
|
type Source = "swarm" | "agent_nix" | "local_file" | "default";
|
|
|
|
// Mirrors the swarm's `InferenceSettings`; every field is optional.
|
|
interface Settings {
|
|
model?: string;
|
|
effort?: string;
|
|
subagent_models?: string[];
|
|
}
|
|
|
|
// Mirrors `EffectiveInference`.
|
|
interface Effective {
|
|
model: string;
|
|
effort: string;
|
|
model_source: Source;
|
|
effort_source: Source;
|
|
}
|
|
|
|
interface View {
|
|
configured: Settings | null;
|
|
effective: Effective | null;
|
|
}
|
|
|
|
// The values the swarm accepts for `effort`.
|
|
const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
const EFFORT_OPTIONS = [
|
|
{ value: "", label: "the agent's own" },
|
|
...EFFORTS.map((e) => ({ value: e, label: e })),
|
|
];
|
|
|
|
const SOURCE_LABEL: Record<Source, string> = {
|
|
swarm: "swarm",
|
|
agent_nix: "agent.nix",
|
|
local_file: "its own pick",
|
|
default: "default",
|
|
};
|
|
|
|
// The same checks the swarm makes on a model name, so an obviously bad one
|
|
// is refused before the round trip; the swarm stays the real gate.
|
|
function modelNameProblem(name: string): string | null {
|
|
if (/\s/.test(name)) return "a model name has no whitespace";
|
|
if (name.startsWith("-")) return "a model name does not start with -";
|
|
return null;
|
|
}
|
|
|
|
// Subagent models are typed as one list, split on commas and whitespace.
|
|
function parseModels(text: string): string[] {
|
|
return text.split(/[\s,]+/).filter((m) => m !== "");
|
|
}
|
|
|
|
function inferenceUrl(agent: string): string {
|
|
return `/api/agents/${encodeURIComponent(agent)}/inference`;
|
|
}
|
|
|
|
export function AgentInference({ agent }: { agent: string }) {
|
|
const [view, setView] = useState<View | null>(null);
|
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
|
// Bumped after a save or a clear, so the settings are fetched again.
|
|
const [reload, setReload] = useState(0);
|
|
const [editing, setEditing] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
const r = await fetch(inferenceUrl(agent));
|
|
if (!r.ok) {
|
|
if (!cancelled) setError(await readApiError(r));
|
|
return;
|
|
}
|
|
const body = (await r.json()) as Partial<View>;
|
|
if (cancelled) return;
|
|
setView({
|
|
configured: body.configured ?? null,
|
|
effective: body.effective ?? null,
|
|
});
|
|
setError(null);
|
|
})().catch((e: unknown) => {
|
|
if (!cancelled) setError({ detail: String(e) });
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [agent, reload]);
|
|
|
|
if (error) {
|
|
return (
|
|
<Badge
|
|
tone="negative"
|
|
value="inference unavailable"
|
|
title={error.detail ?? "reading the inference settings failed"}
|
|
/>
|
|
);
|
|
}
|
|
if (view === null) return <span class="ui-agent-inference-muted">…</span>;
|
|
|
|
const { configured, effective } = view;
|
|
const swarmModels = configured?.subagent_models ?? [];
|
|
return (
|
|
<div class="ui-agent-inference">
|
|
<ul class="ui-agent-inference-list">
|
|
<li>
|
|
{effective ? (
|
|
<>
|
|
<Badge label="model" value={effective.model} />
|
|
<span class="ui-agent-inference-source">
|
|
{SOURCE_LABEL[effective.model_source]}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<Badge
|
|
label="model"
|
|
value={configured?.model ?? "its own"}
|
|
title="the agent has not reported what it runs on yet"
|
|
/>
|
|
)}
|
|
</li>
|
|
<li>
|
|
{effective ? (
|
|
<>
|
|
<Badge label="effort" value={effective.effort} />
|
|
<span class="ui-agent-inference-source">
|
|
{SOURCE_LABEL[effective.effort_source]}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<Badge
|
|
label="effort"
|
|
value={configured?.effort ?? "its own"}
|
|
title="the agent has not reported what it runs on yet"
|
|
/>
|
|
)}
|
|
</li>
|
|
<li>
|
|
<Badge
|
|
label="subagent models"
|
|
value={swarmModels.length > 0 ? swarmModels.join(", ") : "its own"}
|
|
/>
|
|
</li>
|
|
</ul>
|
|
<Badge
|
|
variant="quiet"
|
|
value="edit"
|
|
title={`set the swarm's model, effort and subagent models for ${agent}`}
|
|
onClick={() => setEditing(true)}
|
|
/>
|
|
<Dialog
|
|
open={editing}
|
|
onClose={() => setEditing(false)}
|
|
label="edit inference settings"
|
|
plain
|
|
>
|
|
{/* Remounted per open, so a cancelled edit leaves nothing behind. */}
|
|
{editing ? (
|
|
<InferenceForm
|
|
agent={agent}
|
|
configured={configured}
|
|
onClose={() => setEditing(false)}
|
|
onSaved={() => {
|
|
setEditing(false);
|
|
setReload((n) => n + 1);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InferenceForm({
|
|
agent,
|
|
configured,
|
|
onClose,
|
|
onSaved,
|
|
}: {
|
|
agent: string;
|
|
configured: Settings | null;
|
|
onClose: () => void;
|
|
onSaved: () => void;
|
|
}) {
|
|
const [model, setModel] = useState(configured?.model ?? "");
|
|
const [effort, setEffort] = useState(configured?.effort ?? "");
|
|
const [subagents, setSubagents] = useState(
|
|
(configured?.subagent_models ?? []).join(", "),
|
|
);
|
|
const [busy, setBusy] = useState(false);
|
|
const [problem, setProblem] = useState<ProblemDetails | null>(null);
|
|
|
|
const subagentList = parseModels(subagents);
|
|
const modelProblem =
|
|
modelNameProblem(model.trim()) ??
|
|
subagentList.map(modelNameProblem).find((p) => p !== null) ??
|
|
null;
|
|
const empty =
|
|
model.trim() === "" && effort === "" && subagentList.length === 0;
|
|
|
|
async function send(init: RequestInit) {
|
|
setBusy(true);
|
|
try {
|
|
const r = await fetch(inferenceUrl(agent), init);
|
|
if (!r.ok) {
|
|
setProblem(await readApiError(r));
|
|
return;
|
|
}
|
|
onSaved();
|
|
} catch (e: unknown) {
|
|
setProblem({ detail: String(e) });
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function save(e: Event) {
|
|
e.preventDefault();
|
|
const body: Settings = {};
|
|
if (model.trim() !== "") body.model = model.trim();
|
|
if (effort !== "") body.effort = effort;
|
|
if (subagentList.length > 0) body.subagent_models = subagentList;
|
|
void send({
|
|
method: "PUT",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
return (
|
|
<Panel title={`inference — ${agent}`} icon="🧠" onClose={onClose}>
|
|
<p>
|
|
Sets what the swarm makes {agent} run on. A field left empty stays the
|
|
agent's own value. The agent picks a change up within seconds; a turn
|
|
already running finishes on what it started with.
|
|
</p>
|
|
<form class="ui-agent-inference-form" onSubmit={save}>
|
|
<TextField
|
|
id="inference-model"
|
|
label="model"
|
|
value={model}
|
|
placeholder="e.g. opus"
|
|
onInput={setModel}
|
|
/>
|
|
<SelectField
|
|
id="inference-effort"
|
|
label="effort"
|
|
value={effort}
|
|
options={EFFORT_OPTIONS}
|
|
onChange={setEffort}
|
|
/>
|
|
<TextField
|
|
id="inference-subagent-models"
|
|
label="subagent models"
|
|
value={subagents}
|
|
placeholder="e.g. haiku, sonnet"
|
|
onInput={setSubagents}
|
|
/>
|
|
{modelProblem ? (
|
|
<p class="ui-agent-inference-problem">{modelProblem}</p>
|
|
) : null}
|
|
{problem ? (
|
|
<ApiErrorPanel context="saving failed" problem={problem} />
|
|
) : null}
|
|
<div class="ui-agent-inference-actions">
|
|
<Button
|
|
variant="primary"
|
|
type="submit"
|
|
disabled={busy || empty || modelProblem !== null}
|
|
>
|
|
{busy ? "saving…" : "save"}
|
|
</Button>
|
|
<Button
|
|
disabled={busy || configured === null}
|
|
onClick={() => void send({ method: "DELETE" })}
|
|
>
|
|
clear the swarm's setting
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Panel>
|
|
);
|
|
}
|