Files
codecraft/web/src/components/settings/AgentsEditor.tsx
T
Richard ZinkandClaude Opus 5.5 49262c612c Users, file manager, image generation, budgets and agent workflow
- User management: local accounts (Argon2) and OpenID Connect, admin/user
  roles, per-user folders plus admin-shared folders, read-only session
  sharing, event filtering per user
- File manager with CodeMirror editor and git client (status, diff,
  stage, commit, branches, fetch/pull/push, log, clone/init)
- Image generation via fal.ai: generate_image tool and image page
- Budgets: monthly limits per provider, provider balances/costs
  (OpenRouter, Anthropic admin cost report, fal.ai), price discovery
- Subagents on other models, background tasks, concurrency limits per
  host/model/subagent
- MCP tools loaded on demand (mcp_search/mcp_call), read-only hints
- Permission mode "auto", safe read-only commands always allowed
- Persistent message queue with attachments, deletable
- Live reasoning incl. <think> tags, activity animations, CSS themes,
  mobile layout, statistics per user, upload size handling
- Docker: named volume for state, localhost-bound port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:54:08 +01:00

184 lines
8.2 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 { useState } from "react";
import { patch } from "../../api";
import { useFetch } from "../../hooks";
import { useT } from "../../i18n";
import type { AgentInfo } from "../../types";
import { ModelSelect } from "../ModelSelect";
type Patch = { path: string[]; value: unknown };
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, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: boolean, text: string) => void }) {
const t = useT();
const [open, setOpen] = useState(false);
const [form, setForm] = useState({
model: a.model ?? "",
max_parallel: a.max_parallel ? String(a.max_parallel) : "",
description: raw?.description ?? "",
tools: (raw?.tools ?? []).join(", "),
prompt: raw?.prompt ?? "",
mode: raw?.mode ?? a.mode,
});
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "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 (!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.trim() ? form.tools.split(/[\s,]+/).filter(Boolean) : 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 reset = async () => {
if (!confirm(t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }))) 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" />
<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}
</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 })} />
</>
)}
<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>
<label>{t("agents.tools")}</label>
<input value={form.tools} placeholder={t("agents.toolsAll")} onChange={(e) => setForm({ ...form, tools: e.target.value })} className="mono" />
<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" && (
<button className="danger" onClick={reset}>
{builtin ? t("agents.reset") : t("common.delete")}
</button>
)}
</div>
</>
)}
</div>
);
}
export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<string, any>; reloadConfig: () => void }) {
const t = useT();
const { data: agents, reload } = useFetch<AgentInfo[]>("/agents");
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") => {
const n = name.trim() || (template === "writer" ? "writer" : "");
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(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 }
: { 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]} 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]} 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 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>
</>
);
}