Files
codecraft/web/src/components/settings/AgentsEditor.tsx
T
Richard ZinkandClaude Opus 5.5 d152ad836d Split view, personal modes, budget warnings, server move script
- Up to three chats side by side (#/s/a+b+c), "Open beside" in the sidebar menu
- File manager tabs of a chat stored per user on the server (/sessions/{id}/tabs)
- Personal modes for every user (Settings → My modes, /account/modes)
- Budget meters (total, provider, images, opencode Go windows) with push warnings
  for admins at the warning share and 100 %, shown in the status overlay
- scripts/move-server.sh: move to another Docker host with bind mounts (Unraid);
  scripts/smoke-test.sh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:06:20 +01:00

283 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ?? "",
});
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 } : 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>{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>
</>
);
}