Initial CodeCraft: web-based coding harness for AI models
Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash, web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare), permissions with modes and rules, Anthropic + OpenAI-compatible providers, MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics, compaction, commented TOML config editable via toml_edit. React PWA: chat with live streaming, permission/question cards, modes (build/plan/craft), settings for providers/models/prices/MCP/permissions, raw config editor, statistics dashboard, i18n (en/de). Docker setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f3a2fe3816
62 files changed
+21835
No files matched your search
@@ -0,0 +1,729 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { get, patch as apiPatch, post, put } from "../api";
|
||||
import { useFetch, useModels } from "../hooks";
|
||||
import { getLang, languages, setLang, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import { applyTheme, getTheme, type Theme } from "../theme";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
|
||||
type Json = any;
|
||||
interface ConfigResponse {
|
||||
config: Json;
|
||||
workspace: string;
|
||||
currency: string;
|
||||
}
|
||||
type Patch = { path: string[]; value: unknown };
|
||||
|
||||
const MASK = "••••••••";
|
||||
|
||||
async function savePatches(patches: Patch[]) {
|
||||
await apiPatch("/config", { patches });
|
||||
}
|
||||
|
||||
function useSaver(reload: () => void) {
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||||
const t = useT();
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
try {
|
||||
await fn();
|
||||
setNotice({ ok: true, text: t("settings.saved") });
|
||||
reload();
|
||||
} catch (e: any) {
|
||||
setNotice({ ok: false, text: e.message });
|
||||
}
|
||||
};
|
||||
const view = notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>;
|
||||
return { run, view };
|
||||
}
|
||||
|
||||
// --- general ----------------------------------------------------------------
|
||||
|
||||
function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
const t = useT();
|
||||
const s = cfg.config.server;
|
||||
const c = cfg.config.craft;
|
||||
const [form, setForm] = useState({
|
||||
default_model: s.default_model ?? "",
|
||||
small_model: s.small_model ?? "",
|
||||
currency: s.currency ?? "USD",
|
||||
auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
|
||||
clean_room: c.clean_room ?? true,
|
||||
chromium: c.chromium ?? "chromium",
|
||||
widths: (c.widths ?? []).join(", "),
|
||||
});
|
||||
const [theme, setThemeState] = useState<Theme>(getTheme());
|
||||
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
|
||||
const { run, view } = useSaver(reload);
|
||||
|
||||
const save = () =>
|
||||
run(() =>
|
||||
savePatches([
|
||||
{ path: ["server", "default_model"], value: form.default_model },
|
||||
{ path: ["server", "small_model"], value: form.small_model },
|
||||
{ path: ["server", "currency"], value: form.currency },
|
||||
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) },
|
||||
{ path: ["craft", "clean_room"], value: form.clean_room },
|
||||
{ path: ["craft", "chromium"], value: form.chromium },
|
||||
{ path: ["craft", "widths"], value: String(form.widths).split(/[\s,]+/).filter(Boolean).map(Number) },
|
||||
]),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel">
|
||||
<h3>{t("settings.interface")}</h3>
|
||||
<div className="form">
|
||||
<label>{t("settings.language")}</label>
|
||||
<select value={getLang()} onChange={(e) => setLang(e.target.value)}>
|
||||
{Object.entries(languages).map(([k, v]) => (
|
||||
<option key={k} value={k}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label>{t("settings.theme")}</label>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value as Theme;
|
||||
setThemeState(v);
|
||||
applyTheme(v);
|
||||
}}
|
||||
>
|
||||
<option value="system">{t("settings.themeSystem")}</option>
|
||||
<option value="dark">{t("settings.themeDark")}</option>
|
||||
<option value="light">{t("settings.themeLight")}</option>
|
||||
</select>
|
||||
<label>{t("settings.notifications")}</label>
|
||||
<div>
|
||||
{notif === "granted" ? (
|
||||
<span className="chip">{t("settings.notificationsOn")}</span>
|
||||
) : (
|
||||
<button className="small" onClick={() => Notification.requestPermission().then(setNotif)} disabled={notif === "denied"}>
|
||||
{t("settings.notificationsEnable")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="hint">{t("settings.notificationsHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>{t("settings.server")}</h3>
|
||||
<div className="form">
|
||||
<label>{t("settings.workspace")}</label>
|
||||
<code>{cfg.workspace}</code>
|
||||
<label>{t("settings.defaultModel")}</label>
|
||||
<ModelSelect value={form.default_model} onChange={(v) => setForm({ ...form, default_model: v })} />
|
||||
<label>{t("settings.smallModel")}</label>
|
||||
<ModelSelect value={form.small_model} onChange={(v) => setForm({ ...form, small_model: v })} />
|
||||
<div className="hint">{t("settings.smallModelHint")}</div>
|
||||
<label>{t("settings.currency")}</label>
|
||||
<input value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value.toUpperCase() })} maxLength={3} />
|
||||
<label>{t("settings.autoCompact")}</label>
|
||||
<input type="number" min={0} max={1} step={0.05} value={form.auto_compact_threshold} onChange={(e) => setForm({ ...form, auto_compact_threshold: e.target.value })} />
|
||||
<div className="hint">{t("settings.autoCompactHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>{t("settings.craft")}</h3>
|
||||
<div className="form">
|
||||
<label>{t("settings.cleanRoom")}</label>
|
||||
<input type="checkbox" checked={form.clean_room} onChange={(e) => setForm({ ...form, clean_room: e.target.checked })} style={{ justifySelf: "start" }} />
|
||||
<div className="hint">{t("settings.cleanRoomHint")}</div>
|
||||
<label>{t("settings.chromium")}</label>
|
||||
<input value={form.chromium} onChange={(e) => setForm({ ...form, chromium: e.target.value })} />
|
||||
<label>{t("settings.widths")}</label>
|
||||
<input value={form.widths} onChange={(e) => setForm({ ...form, widths: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
{view}
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- providers --------------------------------------------------------------
|
||||
|
||||
interface ModelRow {
|
||||
id: string;
|
||||
name?: string;
|
||||
context?: number;
|
||||
max_output?: number;
|
||||
reasoning?: boolean;
|
||||
vision?: boolean;
|
||||
effort?: string;
|
||||
input_price?: number;
|
||||
output_price?: number;
|
||||
cache_read_price?: number;
|
||||
cache_write_price?: number;
|
||||
}
|
||||
|
||||
const PRESETS: Record<string, { kind: string; base_url: string; api_key: string; auth_prefix?: string }> = {
|
||||
anthropic: { kind: "anthropic", base_url: "https://api.anthropic.com/v1", api_key: "${ANTHROPIC_API_KEY}" },
|
||||
gemini: { kind: "openai", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", api_key: "${GEMINI_API_KEY}" },
|
||||
"opencode-go": { kind: "openai", base_url: "https://opencode.ai/inference/go/openai/v1", api_key: "${OPENCODE_API_KEY}" },
|
||||
fal: { kind: "openai", base_url: "https://fal.run/openrouter/router/openai/v1", api_key: "${FAL_KEY}", auth_prefix: "Key " },
|
||||
openrouter: { kind: "openai", base_url: "https://openrouter.ai/api/v1", api_key: "${OPENROUTER_API_KEY}" },
|
||||
ollama: { kind: "openai", base_url: "http://host.docker.internal:11434/v1", api_key: "" },
|
||||
custom: { kind: "openai", base_url: "http://", api_key: "" },
|
||||
};
|
||||
|
||||
function normalizeModels(models: Json[]): ModelRow[] {
|
||||
return (models ?? []).map((m) => (typeof m === "string" ? { id: m } : { ...m }));
|
||||
}
|
||||
|
||||
function cleanModel(m: ModelRow) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(m)) {
|
||||
if (v === undefined || v === "" || (typeof v === "number" && Number.isNaN(v))) continue;
|
||||
if (v === false && (k === "reasoning" || k === "vision")) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: () => void }) {
|
||||
const t = useT();
|
||||
const [form, setForm] = useState({ ...p, api_key: p.api_key === MASK ? "" : p.api_key });
|
||||
const [models, setModels] = useState<ModelRow[]>(normalizeModels(p.models));
|
||||
const [open, setOpen] = useState(false);
|
||||
const discovered = useModels().find((x) => x.provider === name)?.models ?? [];
|
||||
const { run, view } = useSaver(reload);
|
||||
|
||||
const num = (v: string) => (v === "" ? undefined : Number(v));
|
||||
const setModel = (i: number, patch: Partial<ModelRow>) => setModels((ms) => ms.map((m, j) => (j === i ? { ...m, ...patch } : m)));
|
||||
|
||||
const save = () =>
|
||||
run(() => {
|
||||
const patches: Patch[] = [
|
||||
{ path: ["providers", name, "kind"], value: form.kind },
|
||||
{ path: ["providers", name, "base_url"], value: form.base_url },
|
||||
{ path: ["providers", name, "enabled"], value: !!form.enabled },
|
||||
{ path: ["providers", name, "models"], value: models.filter((m) => m.id.trim()).map(cleanModel) },
|
||||
{ path: ["providers", name, "auth_prefix"], value: form.auth_prefix ? form.auth_prefix : null },
|
||||
];
|
||||
// Empty key field = keep the stored (masked) key.
|
||||
if (form.api_key !== "" || p.api_key !== MASK) patches.push({ path: ["providers", name, "api_key"], value: form.api_key });
|
||||
return savePatches(patches);
|
||||
});
|
||||
|
||||
const remove = () => {
|
||||
if (!confirm(t("settings.deleteProviderConfirm", { name }))) return;
|
||||
void run(() => savePatches([{ path: ["providers", name], value: null }]));
|
||||
};
|
||||
|
||||
const addDiscovered = () => {
|
||||
const have = new Set(models.map((m) => m.id));
|
||||
setModels([...models, ...discovered.filter((d) => !have.has(d.id)).map((d) => ({ id: d.id, context: d.context }))]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
<input type="checkbox" checked={!!form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} title={t("settings.enabled")} />
|
||||
{name}
|
||||
<span className="chip">{form.kind}</span>
|
||||
<span className="chip">{t("settings.modelCount", { n: models.length || discovered.length })}</span>
|
||||
<span className="spacer" />
|
||||
<button className="small" onClick={() => setOpen(!open)}>
|
||||
{open ? t("common.collapse") : t("common.edit")}
|
||||
</button>
|
||||
</h3>
|
||||
{open && (
|
||||
<>
|
||||
<div className="form">
|
||||
<label>{t("settings.kind")}</label>
|
||||
<select value={form.kind} onChange={(e) => setForm({ ...form, kind: e.target.value })}>
|
||||
<option value="anthropic">Anthropic Messages API</option>
|
||||
<option value="openai">OpenAI-compatible</option>
|
||||
</select>
|
||||
<label>{t("settings.baseUrl")}</label>
|
||||
<input value={form.base_url} onChange={(e) => setForm({ ...form, base_url: e.target.value })} />
|
||||
<label>{t("settings.apiKey")}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.api_key}
|
||||
placeholder={p.api_key === MASK ? t("settings.apiKeyStored") : "${ENV_VAR}"}
|
||||
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||
/>
|
||||
<div className="hint">{t("settings.apiKeyHint")}</div>
|
||||
<label>{t("settings.authPrefix")}</label>
|
||||
<input value={form.auth_prefix ?? ""} placeholder={form.kind === "openai" ? "Bearer " : ""} onChange={(e) => setForm({ ...form, auth_prefix: e.target.value })} />
|
||||
</div>
|
||||
<h3 style={{ marginTop: 16 }}>
|
||||
{t("settings.models")}
|
||||
<span className="spacer" />
|
||||
{discovered.length > 0 && (
|
||||
<button className="small" onClick={addDiscovered}>
|
||||
{t("settings.addDiscovered", { n: discovered.length })}
|
||||
</button>
|
||||
)}
|
||||
<button className="small" onClick={() => setModels([...models, { id: "" }])}>
|
||||
+ {t("settings.addModel")}
|
||||
</button>
|
||||
</h3>
|
||||
<div className="hint" style={{ marginBottom: 6, fontSize: 12, color: "var(--muted)" }}>
|
||||
{t("settings.pricesHint")}
|
||||
</div>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>{t("settings.context")}</th>
|
||||
<th>{t("settings.maxOutput")}</th>
|
||||
<th title={t("settings.reasoning")}>🧠</th>
|
||||
<th title={t("settings.vision")}>👁</th>
|
||||
<th>{t("settings.effort")}</th>
|
||||
<th>{t("settings.priceIn")}</th>
|
||||
<th>{t("settings.priceOut")}</th>
|
||||
<th>{t("settings.priceCacheRead")}</th>
|
||||
<th>{t("settings.priceCacheWrite")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.map((m, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input value={m.id} onChange={(e) => setModel(i, { id: e.target.value })} style={{ minWidth: 160 }} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" value={m.context ?? ""} onChange={(e) => setModel(i, { context: num(e.target.value) })} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" value={m.max_output ?? ""} onChange={(e) => setModel(i, { max_output: num(e.target.value) })} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" checked={!!m.reasoning} onChange={(e) => setModel(i, { reasoning: e.target.checked })} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" checked={!!m.vision} onChange={(e) => setModel(i, { vision: e.target.checked })} />
|
||||
</td>
|
||||
<td>
|
||||
<select value={m.effort ?? ""} onChange={(e) => setModel(i, { effort: e.target.value || undefined })}>
|
||||
<option value="">–</option>
|
||||
{["low", "medium", "high", "xhigh", "max"].map((x) => (
|
||||
<option key={x}>{x}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
{(["input_price", "output_price", "cache_read_price", "cache_write_price"] as const).map((k) => (
|
||||
<td key={k}>
|
||||
<input type="number" step="0.01" value={m[k] ?? ""} onChange={(e) => setModel(i, { [k]: num(e.target.value) })} />
|
||||
</td>
|
||||
))}
|
||||
<td>
|
||||
<button className="small ghost danger" onClick={() => setModels(models.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{models.length === 0 && <div className="hint" style={{ color: "var(--muted)", fontSize: 12 }}>{t("settings.modelsEmptyHint")}</div>}
|
||||
{view}
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
<button className="danger" onClick={remove}>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
const t = useT();
|
||||
const [name, setName] = useState("");
|
||||
const [preset, setPreset] = useState("anthropic");
|
||||
const { run, view } = useSaver(reload);
|
||||
const providers = cfg.config.providers ?? {};
|
||||
|
||||
const add = () => {
|
||||
const n = name.trim() || preset;
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
|
||||
const p = PRESETS[preset];
|
||||
const value: Record<string, unknown> = { kind: p.kind, base_url: p.base_url, api_key: p.api_key, enabled: true, models: [] };
|
||||
if (p.auth_prefix) value.auth_prefix = p.auth_prefix;
|
||||
void run(() => savePatches([{ path: ["providers", n], value }]));
|
||||
setName("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<button onClick={() => post("/providers/reload").then(reload)}>{t("settings.reloadProviders")}</button>
|
||||
</div>
|
||||
{Object.entries(providers).map(([n, p]) => (
|
||||
<ProviderEditor key={n + JSON.stringify(p)} name={n} p={p} reload={reload} />
|
||||
))}
|
||||
<div className="panel">
|
||||
<h3>{t("settings.addProvider")}</h3>
|
||||
<div className="row">
|
||||
<select value={preset} onChange={(e) => setPreset(e.target.value)}>
|
||||
{Object.keys(PRESETS).map((k) => (
|
||||
<option key={k}>{k}</option>
|
||||
))}
|
||||
</select>
|
||||
<input placeholder={t("settings.providerName")} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<button className="primary" onClick={add}>
|
||||
+ {t("common.add")}
|
||||
</button>
|
||||
</div>
|
||||
{view}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- MCP --------------------------------------------------------------------
|
||||
|
||||
function linesToMap(text: string, sep: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of text.split("\n")) {
|
||||
const i = line.indexOf(sep);
|
||||
if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + sep.length).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mapToLines(m: Record<string, string> | undefined, sep: string) {
|
||||
return Object.entries(m ?? {})
|
||||
.map(([k, v]) => `${k}${sep}${v}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload: () => void; isNew?: boolean }) {
|
||||
const t = useT();
|
||||
const status = useStore((s) => s.mcp[name]);
|
||||
const [form, setForm] = useState({
|
||||
name,
|
||||
type: m.type ?? "stdio",
|
||||
command: m.command ?? "",
|
||||
args: (m.args ?? []).join("\n"),
|
||||
env: mapToLines(m.env, "="),
|
||||
url: m.url ?? "",
|
||||
headers: mapToLines(m.headers, ": "),
|
||||
enabled: m.enabled ?? true,
|
||||
});
|
||||
const [open, setOpen] = useState(!!isNew);
|
||||
const { run, view } = useSaver(reload);
|
||||
|
||||
const save = () => {
|
||||
const n = form.name.trim();
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
|
||||
const value: Record<string, unknown> = { type: form.type, enabled: form.enabled };
|
||||
if (form.type === "stdio") {
|
||||
value.command = form.command;
|
||||
value.args = form.args.split("\n").map((s: string) => s.trim()).filter(Boolean);
|
||||
value.env = linesToMap(form.env, "=");
|
||||
} else {
|
||||
value.url = form.url;
|
||||
value.headers = linesToMap(form.headers, ":");
|
||||
}
|
||||
return run(async () => {
|
||||
const patches: Patch[] = [];
|
||||
if (!isNew && n !== name) patches.push({ path: ["mcp", name], value: null });
|
||||
patches.push({ path: ["mcp", n], value });
|
||||
await savePatches(patches);
|
||||
});
|
||||
};
|
||||
|
||||
const st = status?.status;
|
||||
const stateLabel = st ? t(`mcp.state.${st.state}` as Key) : isNew ? "" : t("mcp.state.unknown");
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
{isNew ? t("settings.addMcp") : name}
|
||||
{!isNew && <span className={`chip ${st?.state === "failed" ? "warn" : ""}`}>{stateLabel}</span>}
|
||||
{st?.state === "connected" && <span className="chip">{t("mcp.tools", { n: st.tools ?? 0 })}</span>}
|
||||
<span className="spacer" />
|
||||
{!isNew && (
|
||||
<button className="small" onClick={() => setOpen(!open)}>
|
||||
{open ? t("common.collapse") : t("common.edit")}
|
||||
</button>
|
||||
)}
|
||||
</h3>
|
||||
{st?.state === "failed" && <div className="notice err">{st.error}</div>}
|
||||
{open && (
|
||||
<>
|
||||
<div className="form">
|
||||
<label>{t("settings.name")}</label>
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
<label>{t("settings.kind")}</label>
|
||||
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
|
||||
<option value="stdio">stdio</option>
|
||||
<option value="http">Streamable HTTP</option>
|
||||
</select>
|
||||
{form.type === "stdio" ? (
|
||||
<>
|
||||
<label>{t("mcp.command")}</label>
|
||||
<input value={form.command} onChange={(e) => setForm({ ...form, command: e.target.value })} placeholder="npx" />
|
||||
<label>{t("mcp.args")}</label>
|
||||
<textarea rows={3} value={form.args} onChange={(e) => setForm({ ...form, args: e.target.value })} className="mono" />
|
||||
<label>{t("mcp.env")}</label>
|
||||
<textarea rows={2} value={form.env} onChange={(e) => setForm({ ...form, env: e.target.value })} className="mono" placeholder="KEY=${SECRET}" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label>URL</label>
|
||||
<input value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} placeholder="https://…/mcp" />
|
||||
<label>{t("mcp.headers")}</label>
|
||||
<textarea rows={2} value={form.headers} onChange={(e) => setForm({ ...form, headers: e.target.value })} className="mono" placeholder="Authorization: Bearer ${TOKEN}" />
|
||||
</>
|
||||
)}
|
||||
<label>{t("settings.enabled")}</label>
|
||||
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} style={{ justifySelf: "start" }} />
|
||||
</div>
|
||||
{status?.tools?.length ? (
|
||||
<details style={{ marginTop: 10 }}>
|
||||
<summary style={{ cursor: "pointer" }}>{t("mcp.tools", { n: status.tools.length })}</summary>
|
||||
<ul style={{ fontSize: 12.5 }}>
|
||||
{status.tools.map((tool) => (
|
||||
<li key={tool.name}>
|
||||
<code>{tool.name}</code> – {tool.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
{view}
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
{!isNew && (
|
||||
<button className="danger" onClick={() => confirm(t("settings.deleteMcpConfirm", { name })) && run(() => savePatches([{ path: ["mcp", name], value: null }]))}>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Mcp({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
const t = useT();
|
||||
const servers = cfg.config.mcp ?? {};
|
||||
return (
|
||||
<>
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<button onClick={() => post("/mcp/reload")}>{t("mcp.reconnect")}</button>
|
||||
</div>
|
||||
{Object.keys(servers).length === 0 && <div className="notice">{t("mcp.none")}</div>}
|
||||
{Object.entries(servers).map(([n, m]) => (
|
||||
<McpEditor key={n + JSON.stringify(m)} name={n} m={m} reload={reload} />
|
||||
))}
|
||||
<McpEditor key={`new-${Object.keys(servers).length}`} name="" m={{}} reload={reload} isNew />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- permissions ------------------------------------------------------------
|
||||
|
||||
function RuleTable({ rules, setRules, choices, keyLabel }: { rules: [string, string][]; setRules: (r: [string, string][]) => void; choices: string[]; keyLabel: string }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{keyLabel}</th>
|
||||
<th>{t("permissions.rule")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map(([k, v], i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input className="mono" value={k} onChange={(e) => setRules(rules.map((r, j) => (j === i ? [e.target.value, r[1]] : r)))} />
|
||||
</td>
|
||||
<td>
|
||||
<select value={v} onChange={(e) => setRules(rules.map((r, j) => (j === i ? [r[0], e.target.value] : r)))}>
|
||||
{choices.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`permissions.${c}` as Key)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button className="small ghost danger" onClick={() => setRules(rules.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td colSpan={3}>
|
||||
<button className="small" onClick={() => setRules([...rules, ["", choices[0]]])}>
|
||||
+ {t("common.add")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
const t = useT();
|
||||
const { bash_rules = {}, ...tools } = cfg.config.permissions ?? {};
|
||||
const [toolRules, setToolRules] = useState<[string, string][]>(Object.entries(tools) as [string, string][]);
|
||||
const [bashRules, setBashRules] = useState<[string, string][]>(Object.entries(bash_rules) as [string, string][]);
|
||||
const { run, view } = useSaver(reload);
|
||||
|
||||
const save = () =>
|
||||
run(() => {
|
||||
const patches: Patch[] = [];
|
||||
for (const k of Object.keys(tools)) if (!toolRules.some(([n]) => n === k)) patches.push({ path: ["permissions", k], value: null });
|
||||
for (const [k, v] of toolRules) if (k.trim()) patches.push({ path: ["permissions", k.trim()], value: v });
|
||||
for (const k of Object.keys(bash_rules)) if (!bashRules.some(([n]) => n === k)) patches.push({ path: ["permissions", "bash_rules", k], value: null });
|
||||
for (const [k, v] of bashRules) if (k.trim()) patches.push({ path: ["permissions", "bash_rules", k.trim()], value: v });
|
||||
return savePatches(patches);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="notice">{t("permissions.intro")}</div>
|
||||
<div className="two-col">
|
||||
<div className="panel">
|
||||
<h3>{t("permissions.tools")}</h3>
|
||||
<RuleTable rules={toolRules} setRules={setToolRules} choices={["allow", "ask", "deny"]} keyLabel={t("permissions.tool")} />
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>{t("permissions.bash")}</h3>
|
||||
<div style={{ fontSize: 12, color: "var(--muted)", marginBottom: 6 }}>{t("permissions.bashHint")}</div>
|
||||
<RuleTable rules={bashRules} setRules={setBashRules} choices={["allow", "deny"]} keyLabel={t("permissions.pattern")} />
|
||||
</div>
|
||||
</div>
|
||||
{view}
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- agents (read-only overview) ----------------------------------------------
|
||||
|
||||
function Agents() {
|
||||
const t = useT();
|
||||
const agents = useFetch<Json[]>("/agents").data ?? [];
|
||||
const cmds = useFetch<{ commands: Json[]; skills: Json[] }>("/commands").data;
|
||||
return (
|
||||
<>
|
||||
<div className="notice">{t("agents.intro")}</div>
|
||||
<div className="panel">
|
||||
<h3>{t("agents.title")}</h3>
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
{agents.map((a) => (
|
||||
<tr key={a.name}>
|
||||
<td>
|
||||
<code>{a.name}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span className="chip">{a.mode}</span> {a.read_only && <span className="chip">{t("agents.readOnly")}</span>}
|
||||
</td>
|
||||
<td>{a.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- raw ----------------------------------------------------------------------
|
||||
|
||||
function Raw({ reload }: { reload: () => void }) {
|
||||
const t = useT();
|
||||
const [text, setText] = useState<string>();
|
||||
const { run, view } = useSaver(reload);
|
||||
useEffect(() => {
|
||||
get<{ text: string }>("/config/raw").then((r) => setText(r.text));
|
||||
}, []);
|
||||
if (text === undefined) return <div className="empty">{t("common.loading")}</div>;
|
||||
return (
|
||||
<>
|
||||
<div className="notice">{t("settings.rawHint")}</div>
|
||||
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
|
||||
{view}
|
||||
<div className="row" style={{ marginTop: 8 }}>
|
||||
<button className="primary" onClick={() => run(() => put("/config/raw", { text }))}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
<button onClick={() => get<{ text: string }>("/config/raw").then((r) => setText(r.text))}>{t("settings.revert")}</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- page -------------------------------------------------------------------
|
||||
|
||||
const TABS = ["general", "providers", "mcp", "permissions", "agents", "raw"] as const;
|
||||
|
||||
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
|
||||
const t = useT();
|
||||
const active = (TABS as readonly string[]).includes(tab ?? "") ? tab! : "general";
|
||||
const { data: cfg, error, reload } = useFetch<ConfigResponse>("/config");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
☰
|
||||
</button>
|
||||
<div className="grow">
|
||||
<h1>{t("nav.settings")}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page">
|
||||
<div className="page-inner">
|
||||
<div className="tabs" role="tablist">
|
||||
{TABS.map((x) => (
|
||||
<button key={x} role="tab" aria-selected={active === x} className={active === x ? "active" : ""} onClick={() => navigate(`/settings/${x}`)}>
|
||||
{t(`settings.tab.${x}` as Key)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
{!cfg && !error && <div className="empty">{t("common.loading")}</div>}
|
||||
{cfg && active === "general" && <General key={JSON.stringify(cfg.config.server)} cfg={cfg} reload={reload} />}
|
||||
{cfg && active === "providers" && <Providers cfg={cfg} reload={reload} />}
|
||||
{cfg && active === "mcp" && <Mcp cfg={cfg} reload={reload} />}
|
||||
{cfg && active === "permissions" && <Permissions key={JSON.stringify(cfg.config.permissions)} cfg={cfg} reload={reload} />}
|
||||
{active === "agents" && <Agents />}
|
||||
{active === "raw" && <Raw reload={reload} />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user