- AgentConfig.reverse_tools (Settings -> Agents & commands) gives any mode binary_info and decompile; the clean-room check of the tools applies to craft only (ToolCtx.clean_room = clean room && craft). - decompile: optional processor (Ghidra language id) and base_address for raw images (BinaryLoader); separate cache per processor/address. - Skill "reverse-engineering": identify, convert HEX, processor table for common chips, decompile, explore, notes in analysis/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
300 lines
14 KiB
TypeScript
300 lines
14 KiB
TypeScript
import { Icon } from "../Icon";
|
||
import { useState } from "react";
|
||
import { del, patch } from "../../api";
|
||
import { useFetch } from "../../hooks";
|
||
import { useT } from "../../i18n";
|
||
import type { AgentInfo } from "../../types";
|
||
import { ModelSelect } from "../ModelSelect";
|
||
import { confirmDialog, promptDialog, toast } from "../../dialogs";
|
||
|
||
type Patch = { path: string[]; value: unknown };
|
||
|
||
export interface ToolInfo {
|
||
name: string;
|
||
read_only: boolean;
|
||
description: string;
|
||
}
|
||
|
||
/** Tool checkboxes: nothing checked = all tools. */
|
||
export function ToolPicker({ tools, value, onChange, readOnly }: { tools: ToolInfo[]; value: string[]; onChange: (v: string[]) => void; readOnly: boolean }) {
|
||
const t = useT();
|
||
const toggle = (n: string) => onChange(value.includes(n) ? value.filter((x) => x !== n) : [...value, n]);
|
||
// Names configured by hand (e.g. MCP patterns) stay selected even if not listed.
|
||
const extra = value.filter((v) => !tools.some((x) => x.name === v));
|
||
return (
|
||
<div className="tool-picker">
|
||
<div className="hint">{value.length ? t("agents.toolsSome", { n: value.length }) : t("agents.toolsAll")}</div>
|
||
<div className="tool-grid">
|
||
{tools.map((x) => (
|
||
<label key={x.name} className={`tool-item ${readOnly && !x.read_only ? "disabled" : ""}`} title={x.description}>
|
||
<input type="checkbox" checked={value.includes(x.name)} disabled={readOnly && !x.read_only} onChange={() => toggle(x.name)} />
|
||
<span className="mono">{x.name}</span>
|
||
</label>
|
||
))}
|
||
{extra.map((n) => (
|
||
<label key={n} className="tool-item">
|
||
<input type="checkbox" checked onChange={() => toggle(n)} />
|
||
<span className="mono">{n}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const WRITER_PROMPT = `You are a technical writer. Produce clear, well-structured text (Markdown) for
|
||
documentation, summaries, step-by-step guides and translations.
|
||
Return only the finished text.`;
|
||
|
||
function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: ToolInfo[]; onSaved: (ok: boolean, text: string) => void }) {
|
||
const t = useT();
|
||
const [open, setOpen] = useState(!!raw && !raw.prompt && !raw.description && raw.mode === "primary");
|
||
const [form, setForm] = useState({
|
||
model: a.model ?? "",
|
||
max_parallel: a.max_parallel ? String(a.max_parallel) : "",
|
||
description: raw?.description ?? "",
|
||
tools: (raw?.tools ?? []) as string[],
|
||
read_only: !!(raw?.read_only ?? a.read_only),
|
||
style: (raw?.style ?? a.style ?? "coding") as "coding" | "chat",
|
||
prompt: raw?.prompt ?? "",
|
||
mode: raw?.mode ?? a.mode,
|
||
refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
|
||
refine_model: raw?.refine?.model ?? "",
|
||
refine_criteria: raw?.refine?.criteria ?? "",
|
||
refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean,
|
||
reverse_tools: !!(raw?.reverse_tools ?? a.reverse),
|
||
});
|
||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
|
||
|
||
const save = async () => {
|
||
const base = ["agents", a.name];
|
||
const patches: Patch[] = [
|
||
{ path: [...base, "model"], value: form.model || null },
|
||
{ path: [...base, "max_parallel"], value: form.max_parallel ? Number(form.max_parallel) : null },
|
||
];
|
||
if (a.mode === "primary") {
|
||
const rounds = Math.max(0, Math.min(10, Number(form.refine_rounds) || 0));
|
||
patches.push({
|
||
path: [...base, "refine"],
|
||
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null, after_todos: form.refine_after_todos } : null,
|
||
});
|
||
}
|
||
if (a.name !== "craft") patches.push({ path: [...base, "reverse_tools"], value: form.reverse_tools || null });
|
||
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
||
if (!builtin) {
|
||
patches.push({ path: [...base, "mode"], value: form.mode });
|
||
patches.push({ path: [...base, "tools"], value: form.tools.length ? form.tools : null });
|
||
patches.push({ path: [...base, "read_only"], value: form.read_only || null });
|
||
patches.push({ path: [...base, "style"], value: form.style === "chat" ? "chat" : null });
|
||
patches.push({ path: [...base, "prompt"], value: form.prompt || null });
|
||
}
|
||
try {
|
||
await patch("/config", { patches });
|
||
onSaved(true, t("settings.saved"));
|
||
} catch (e: any) {
|
||
onSaved(false, e.message);
|
||
}
|
||
};
|
||
|
||
const removeFile = async () => {
|
||
if (!(await confirmDialog({ message: t("agents.deleteFile", { path: a.path ?? a.name }), danger: true }))) return;
|
||
try {
|
||
await del(`/agents/${encodeURIComponent(a.name)}`);
|
||
onSaved(true, t("settings.saved"));
|
||
} catch (e: any) {
|
||
onSaved(false, e.message);
|
||
}
|
||
};
|
||
|
||
const reset = async () => {
|
||
if (!(await confirmDialog({ message: t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }), danger: true }))) return;
|
||
try {
|
||
await patch("/config", { patches: [{ path: ["agents", a.name], value: null }] });
|
||
onSaved(true, t("settings.saved"));
|
||
} catch (e: any) {
|
||
onSaved(false, e.message);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="panel">
|
||
<h3>
|
||
<code>{a.name}</code>
|
||
<span className="chip">{a.mode === "primary" ? t("agents.modePrimary") : t("agents.modeSubagent")}</span>
|
||
<span className="chip">{t(`agents.source.${a.source}` as any)}</span>
|
||
{a.read_only && <span className="chip">{t("agents.readOnly")}</span>}
|
||
<span className="spacer" />
|
||
{!builtin && (a.source === "config" || a.source === "file") && (
|
||
<button className="small ghost danger" onClick={a.source === "file" ? removeFile : reset} title={t("common.delete")} aria-label={t("common.delete")}>
|
||
<Icon name="trash" />
|
||
</button>
|
||
)}
|
||
<button className="small" onClick={() => setOpen(!open)}>
|
||
{open ? t("common.collapse") : t("common.edit")}
|
||
</button>
|
||
</h3>
|
||
<div style={{ fontSize: 13, color: "var(--fg3)" }}>{a.description}</div>
|
||
<div className="row" style={{ marginTop: 8 }}>
|
||
<span style={{ fontSize: 12, color: "var(--muted)" }}>{t("agents.model")}:</span>
|
||
<span className="chip">{a.model || t("agents.sessionModel")}</span>
|
||
{a.max_parallel ? <span className="chip">{t("agents.maxParallelShort", { n: a.max_parallel })}</span> : null}
|
||
{a.refine?.rounds ? <span className="chip">{t("agents.refineShort", { n: a.refine.rounds })}</span> : null}
|
||
</div>
|
||
{open && (
|
||
<>
|
||
<div className="form" style={{ marginTop: 12 }}>
|
||
<label>{t("agents.model")}</label>
|
||
<ModelSelect value={form.model} onChange={(v) => setForm({ ...form, model: v })} emptyLabel={t("agents.sessionModel")} />
|
||
<div className="hint">{t("agents.modelHint")}</div>
|
||
{a.mode !== "primary" && (
|
||
<>
|
||
<label>{t("agents.maxParallel")}</label>
|
||
<input type="number" min={1} value={form.max_parallel} placeholder="∞" onChange={(e) => setForm({ ...form, max_parallel: e.target.value })} />
|
||
</>
|
||
)}
|
||
{a.mode === "primary" && (
|
||
<>
|
||
<label>{t("agents.refineRounds")}</label>
|
||
<input type="number" min={0} max={10} value={form.refine_rounds} placeholder={t("agents.refineOff")} onChange={(e) => setForm({ ...form, refine_rounds: e.target.value })} />
|
||
<label>{t("agents.refineModel")}</label>
|
||
<ModelSelect value={form.refine_model} onChange={(v) => setForm({ ...form, refine_model: v })} emptyLabel={t("agents.sessionModel")} />
|
||
<label>{t("agents.refineCriteria")}</label>
|
||
<textarea rows={3} value={form.refine_criteria} onChange={(e) => setForm({ ...form, refine_criteria: e.target.value })} />
|
||
<div className="hint">{t("agents.refineCriteriaHint")}</div>
|
||
<label className="check">
|
||
<input type="checkbox" checked={form.refine_after_todos} onChange={(e) => setForm({ ...form, refine_after_todos: e.target.checked })} />
|
||
{t("agents.refineAfterTodos")}
|
||
</label>
|
||
<div className="hint">{t("agents.refineAfterTodosHint")}</div>
|
||
</>
|
||
)}
|
||
{a.name !== "craft" && (
|
||
<>
|
||
<label className="check">
|
||
<input type="checkbox" checked={form.reverse_tools} onChange={(e) => setForm({ ...form, reverse_tools: e.target.checked })} />
|
||
{t("agents.reverseTools")}
|
||
</label>
|
||
<div className="hint">{t("agents.reverseToolsHint")}</div>
|
||
</>
|
||
)}
|
||
<label>{t("agents.description")}</label>
|
||
<input value={form.description} placeholder={a.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||
{a.mode !== "primary" && <div className="hint">{t("agents.descriptionHint")}</div>}
|
||
{!builtin && (
|
||
<>
|
||
<label>{t("agents.mode")}</label>
|
||
<select value={form.mode} onChange={(e) => setForm({ ...form, mode: e.target.value })}>
|
||
<option value="subagent">{t("agents.modeSubagent")}</option>
|
||
<option value="primary">{t("agents.modePrimary")}</option>
|
||
</select>
|
||
{form.mode === "primary" && (
|
||
<>
|
||
<label>{t("agents.style")}</label>
|
||
<select value={form.style} onChange={(e) => setForm({ ...form, style: e.target.value as "coding" | "chat" })}>
|
||
<option value="coding">{t("agents.styleCoding")}</option>
|
||
<option value="chat">{t("agents.styleChat")}</option>
|
||
</select>
|
||
<div className="hint">{t("agents.styleHint")}</div>
|
||
</>
|
||
)}
|
||
<label>{t("agents.readOnlyLabel")}</label>
|
||
<label className="row" style={{ gap: 6, alignItems: "center" }}>
|
||
<input type="checkbox" checked={form.read_only} onChange={(e) => setForm({ ...form, read_only: e.target.checked })} />
|
||
<span className="hint">{t("agents.readOnlyHint")}</span>
|
||
</label>
|
||
<label>{t("agents.tools")}</label>
|
||
<ToolPicker tools={tools} value={form.tools} onChange={(v) => setForm({ ...form, tools: v })} readOnly={form.read_only} />
|
||
<label>{t("agents.prompt")}</label>
|
||
<textarea rows={6} value={form.prompt} onChange={(e) => setForm({ ...form, prompt: e.target.value })} />
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="row" style={{ marginTop: 12 }}>
|
||
<button className="primary" onClick={save}>
|
||
{t("common.save")}
|
||
</button>
|
||
<span className="spacer" />
|
||
{a.source === "config" && builtin && (
|
||
<button className="danger" onClick={reset}>
|
||
{t("agents.reset")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<string, any>; reloadConfig: () => void }) {
|
||
const t = useT();
|
||
const { data: all, reload } = useFetch<AgentInfo[]>("/agents");
|
||
// Personal modes are managed under "My modes".
|
||
const agents = all?.filter((a) => a.source !== "personal");
|
||
const tools = useFetch<ToolInfo[]>("/agents/tools").data ?? [];
|
||
const cmds = useFetch<{ commands: any[]; skills: any[] }>("/commands").data;
|
||
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||
const [name, setName] = useState("");
|
||
|
||
const saved = (ok: boolean, text: string) => {
|
||
setNotice({ ok, text });
|
||
if (ok) {
|
||
reload();
|
||
reloadConfig();
|
||
}
|
||
};
|
||
|
||
const add = async (template: "writer" | "blank" | "mode") => {
|
||
const n = name.trim() || (template === "writer" ? "writer" : "");
|
||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
|
||
const value =
|
||
template === "writer"
|
||
? { mode: "subagent", description: "Writes documentation, summaries, guides and translations.", tools: ["read", "glob", "grep"], max_parallel: 1, prompt: WRITER_PROMPT }
|
||
: template === "mode"
|
||
? { mode: "primary" }
|
||
: { mode: "subagent", description: "", prompt: "" };
|
||
try {
|
||
await patch("/config", { patches: [{ path: ["agents", n], value }] });
|
||
setName("");
|
||
saved(true, t("settings.saved"));
|
||
} catch (e: any) {
|
||
saved(false, e.message);
|
||
}
|
||
};
|
||
|
||
const key = (a: AgentInfo) => a.name + JSON.stringify(rawAgents[a.name] ?? {}) + (a.model ?? "");
|
||
return (
|
||
<>
|
||
<div className="notice">{t("agents.intro")}</div>
|
||
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>}
|
||
<h3 style={{ fontSize: 14 }}>{t("agents.primaryTitle")}</h3>
|
||
{agents?.filter((a) => a.mode === "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} tools={tools} onSaved={saved} />)}
|
||
<h3 style={{ fontSize: 14 }}>{t("agents.subagentTitle")}</h3>
|
||
{agents?.filter((a) => a.mode !== "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} tools={tools} onSaved={saved} />)}
|
||
<div className="panel">
|
||
<h3>{t("agents.add")}</h3>
|
||
<div className="row">
|
||
<input placeholder={t("settings.name")} value={name} onChange={(e) => setName(e.target.value)} />
|
||
<button className="primary" onClick={() => add("mode")} disabled={!name.trim()}>
|
||
+ {t("agents.addMode")}
|
||
</button>
|
||
<button onClick={() => add("writer")}>+ {t("agents.addWriter")}</button>
|
||
<button onClick={() => add("blank")} disabled={!name.trim()}>
|
||
+ {t("agents.addBlank")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="two-col">
|
||
<div className="panel">
|
||
<h3>{t("agents.commands")}</h3>
|
||
{cmds?.commands.length ? cmds.commands.map((c) => <div key={c.name}><code>/{c.name}</code> – {c.description}</div>) : <div className="empty">–</div>}
|
||
</div>
|
||
<div className="panel">
|
||
<h3>{t("agents.skills")}</h3>
|
||
{cmds?.skills.length ? cmds.skills.map((s) => <div key={s.name}><code>{s.name}</code> – {s.description}</div>) : <div className="empty">–</div>}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|