Files
codecraft/web/src/components/Settings.tsx
T
Richard ZinkandClaude Opus 5.5 873447ed17 Provider status: tell rejected API keys apart from unreachable hosts
A 404/405 on the model list counts as reachable (endpoint without a list).

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

991 lines
44 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 { useEffect, useState, useSyncExternalStore } 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 { canPrompt, isInstalled, onInstallChange, platformHint, promptInstall } from "../install";
import { currentSubscription, disablePush, enablePush, pushSupported, syncPush, testPush } from "../push";
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES, type Theme, type ThemeMode } from "../theme";
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
import { ModelSelect } from "./ModelSelect";
import { Account } from "./settings/Account";
import { Templates } from "./settings/Templates";
import { AgentsEditor } from "./settings/AgentsEditor";
import { Folders } from "./settings/Folders";
import { Users } from "./settings/Users";
import { Audit } from "./settings/Audit";
import { Tools } from "./settings/Tools";
import { confirmDialog, promptDialog, toast } from "../dialogs";
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 ----------------------------------------------------------------
/** Per-device settings (language, theme, notifications) – available to everyone. */
function InterfaceSettings() {
const t = useT();
const reasoning = useReasoningMode();
const theme = useSyncExternalStore(onThemeChange, getTheme);
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
const themeInfo = THEMES.find((th) => th.id === theme)!;
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>
<div className="stack">
<select
value={theme}
onChange={(e) => {
const v = e.target.value as Theme;
applyTheme(v, mode);
}}
>
{THEMES.map((th) => (
<option key={th.id} value={th.id}>
{th.name}
</option>
))}
</select>
<div className="hint">{t("settings.themeSource", { source: themeInfo.source, license: themeInfo.license })}</div>
</div>
<label>{t("settings.themeMode")}</label>
<div className="segmented">
{THEME_MODES.map((m) => (
<button
key={m}
className={`small ${m === mode ? "active" : "ghost"}`}
onClick={() => {
applyTheme(theme, m);
}}
>
{t(m === "auto" ? "settings.themeAuto" : m === "light" ? "settings.themeLight" : "settings.themeDark")}
</button>
))}
</div>
<label>{t("settings.reasoningDisplay")}</label>
<select value={reasoning} onChange={(e) => setReasoningMode(e.target.value as ReasoningMode)}>
<option value="collapsed">{t("settings.reasoningCollapsed")}</option>
<option value="hidden">{t("settings.reasoningHidden")}</option>
<option value="live">{t("settings.reasoningLive")}</option>
<option value="expanded">{t("settings.reasoningExpanded")}</option>
</select>
<div className="hint">{t("settings.reasoningHint")}</div>
<label>{t("settings.notifications")}</label>
<PushControl />
<label>{t("install.label")}</label>
<InstallControl />
<label>{t("settings.version")}</label>
<VersionInfo />
<div className="hint">{t("settings.notificationsHint")}</div>
</div>
</div>
);
}
function InstallControl() {
const t = useT();
const prompt = useSyncExternalStore(onInstallChange, canPrompt);
if (isInstalled()) return <span className="chip">{t("install.installed")}</span>;
if (prompt)
return (
<div>
<button className="small primary" onClick={() => void promptInstall()}>
<Icon name="download" /> {t("install.button")}
</button>
</div>
);
return <div className="hint">{t(`install.hint.${platformHint()}` as Key)}</div>;
}
function VersionInfo() {
const info = useFetch<{ version: string; commit?: string }>("/info").data;
return <div className="hint mono">{info ? `CodeCraft ${info.version}${info.commit ? ` (${info.commit})` : ""}` : "…"}</div>;
}
/** Web Push on this device: enable/disable and a test notification. */
function PushControl() {
const t = useT();
const [state, setState] = useState<"loading" | "on" | "off" | "unsupported" | "denied">("loading");
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!pushSupported()) return setState("unsupported");
if (Notification.permission === "denied") return setState("denied");
currentSubscription()
.then(async (s) => {
setState(s ? "on" : "off");
// A subscription the server lost (or never got) is registered again.
if (s) await syncPush().catch(() => undefined);
})
.catch(() => setState("off"));
}, []);
const run = (f: () => Promise<unknown>) => async () => {
setBusy(true);
try {
await f();
} catch (e: any) {
toast(e.message ?? String(e));
} finally {
setBusy(false);
}
};
if (state === "loading") return <div />;
if (state === "unsupported") return <div className="hint">{t("settings.pushUnsupported")}</div>;
if (state === "denied") return <div className="hint">{t("settings.pushDenied")}</div>;
return (
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
{state === "on" ? (
<>
<span className="chip">{t("settings.notificationsOn")}</span>
<button
className="small"
disabled={busy}
onClick={run(async () => {
const r = await testPush();
if (r.sent > 0) toast(t("settings.pushTestSent"), "success");
else toast(r.errors?.length ? `${t("settings.pushTestFailed")}: ${r.errors.join("; ")}` : t("settings.pushTestNone"));
})}
>
{t("settings.pushTest")}
</button>
<button className="small ghost" disabled={busy} onClick={run(async () => (await disablePush(), setState("off")))}>
{t("settings.pushDisable")}
</button>
</>
) : (
<button className="small" disabled={busy} onClick={run(async () => (await enablePush(), setState("on")))}>
{t("settings.notificationsEnable")}
</button>
)}
</div>
);
}
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,
max_parallel_subagents: s.max_parallel_subagents ?? 4,
monthly_budget: s.monthly_budget ?? 0,
budget_warning: s.budget_warning ?? 0.8,
images_budget: cfg.config.images?.monthly_budget ?? "",
budget_action: s.budget_action ?? "warn",
sandbox_mode: cfg.config.sandbox?.mode ?? "auto",
clean_room: c.clean_room ?? true,
chromium: c.chromium ?? "chromium",
widths: (c.widths ?? []).join(", "),
images_enabled: cfg.config.images?.enabled ?? false,
images_key: cfg.config.images?.api_key === MASK ? "" : (cfg.config.images?.api_key ?? "${FAL_KEY}"),
images_model: cfg.config.images?.default_model ?? "fal-ai/flux/schnell",
images_dir: cfg.config.images?.output_dir ?? "generated",
});
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: ["server", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) },
{ path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 },
{ path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 },
{ path: ["server", "budget_action"], value: form.budget_action },
{ path: ["sandbox", "mode"], value: form.sandbox_mode },
{ path: ["images", "monthly_budget"], value: form.images_budget === "" ? null : Number(form.images_budget) },
{ 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) },
{ path: ["images", "enabled"], value: form.images_enabled },
{ path: ["images", "default_model"], value: form.images_model },
{ path: ["images", "output_dir"], value: form.images_dir },
...(form.images_key ? [{ path: ["images", "api_key"], value: form.images_key }] : []),
]),
);
return (
<>
<InterfaceSettings />
<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>
<label>{t("settings.monthlyBudgetTotal")}</label>
<input type="number" min={0} value={form.monthly_budget} onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
<div className="hint">{t("settings.monthlyBudgetHint")}</div>
<label>{t("settings.budgetWarning")}</label>
<input type="number" min={0} max={1} step={0.05} value={form.budget_warning} onChange={(e) => setForm({ ...form, budget_warning: e.target.value })} />
<label>{t("settings.budgetAction")}</label>
<select value={form.budget_action} onChange={(e) => setForm({ ...form, budget_action: e.target.value })}>
<option value="warn">{t("settings.budgetWarn")}</option>
<option value="block">{t("settings.budgetBlock")}</option>
</select>
<label>{t("settings.sandbox")}</label>
<select value={form.sandbox_mode} onChange={(e) => setForm({ ...form, sandbox_mode: e.target.value })}>
<option value="auto">{t("settings.sandboxAuto")}</option>
<option value="always">{t("settings.sandboxAlways")}</option>
<option value="off">{t("settings.sandboxOff")}</option>
</select>
<div className="hint">{t("settings.sandboxHint")}</div>
<label>{t("settings.maxParallelSubagents")}</label>
<input type="number" min={0} value={form.max_parallel_subagents} onChange={(e) => setForm({ ...form, max_parallel_subagents: e.target.value })} />
<div className="hint">{t("settings.maxParallelSubagentsHint")}</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>
<div className="panel">
<h3>{t("settings.images")}</h3>
<div className="form">
<label>{t("settings.imagesEnabled")}</label>
<input type="checkbox" checked={form.images_enabled} onChange={(e) => setForm({ ...form, images_enabled: e.target.checked })} style={{ justifySelf: "start" }} />
<label>{t("settings.imagesKey")}</label>
<input type="password" value={form.images_key} placeholder={cfg.config.images?.api_key === MASK ? t("settings.apiKeyStored") : "${FAL_KEY}"} onChange={(e) => setForm({ ...form, images_key: e.target.value })} />
<label>{t("settings.imagesDefault")}</label>
<select value={form.images_model} onChange={(e) => setForm({ ...form, images_model: e.target.value })}>
{(cfg.config.images?.models ?? []).map((m: any) => (
<option key={m.id} value={m.id}>
{m.name ?? m.id}
</option>
))}
</select>
<label>{t("settings.monthlyBudget")}</label>
<input type="number" min={0} value={form.images_budget} placeholder="–" onChange={(e) => setForm({ ...form, images_budget: e.target.value })} />
<label>{t("settings.imagesDir")}</label>
<input value={form.images_dir} onChange={(e) => setForm({ ...form, images_dir: e.target.value })} />
<div className="hint">{t("settings.imagesHint")}</div>
</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;
max_concurrency?: number;
fallback?: string;
options?: Record<string, unknown>;
}
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;
}
type ProviderStatus = { up: boolean; state?: "ok" | "auth" | "down"; error?: string; latency_ms: number; checked_at: number };
function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; reload: () => void; status?: ProviderStatus }) {
const t = useT();
const [form, setForm] = useState({ ...p, api_key: p.api_key === MASK ? "" : p.api_key, admin_key: "" });
const [priceNote, setPriceNote] = useState<string>();
const fetchPrices = async () => {
try {
const r = await get<{ models: ModelRow[] }>(`/providers/${name}/prices`);
const byId = new Map(r.models.map((m) => [m.id, m]));
let n = 0;
setModels((ms) =>
ms.map((m) => {
const f = byId.get(m.id);
if (!f) return m;
n++;
return { ...m, input_price: f.input_price ?? m.input_price, output_price: f.output_price ?? m.output_price, cache_read_price: f.cache_read_price ?? m.cache_read_price, cache_write_price: f.cache_write_price ?? m.cache_write_price, context: m.context ?? f.context };
}),
);
setPriceNote(t("settings.pricesFetched", { n }));
} catch (e: any) {
setPriceNote(e.message);
}
};
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 },
{ path: ["providers", name, "max_concurrency"], value: form.max_concurrency ? Number(form.max_concurrency) : null },
{ path: ["providers", name, "monthly_budget"], value: form.monthly_budget ? Number(form.monthly_budget) : 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 });
if (form.admin_key) patches.push({ path: ["providers", name, "admin_key"], value: form.admin_key });
return savePatches(patches);
});
const remove = async () => {
if (!(await confirmDialog({ message: t("settings.deleteProviderConfirm", { name }), danger: true, confirmLabel: t("common.delete") }))) 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>
{status && (
<span
className={"chip " + (status.up ? "ok" : "warn")}
title={(status.error ?? "") + " · " + new Date(status.checked_at).toLocaleTimeString()}
>
<Icon name={status.up ? "check" : "warning"} size={12} />
{status.up ? t("settings.providerUp", { ms: status.latency_ms }) : status.state === "auth" ? t("settings.providerAuth") : t("settings.providerDown")}
</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 })} />
<label>{t("settings.monthlyBudget")}</label>
<input type="number" min={0} step="1" value={form.monthly_budget ?? ""} placeholder="–" onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
{form.kind === "anthropic" && (
<>
<label>{t("settings.adminKey")}</label>
<input type="password" value={form.admin_key} placeholder={p.admin_key === MASK ? t("settings.apiKeyStored") : "${ANTHROPIC_ADMIN_KEY}"} onChange={(e) => setForm({ ...form, admin_key: e.target.value })} />
<div className="hint">{t("settings.adminKeyHint")}</div>
</>
)}
<label>{t("settings.maxConcurrency")}</label>
<input type="number" min={1} value={form.max_concurrency ?? ""} placeholder="∞" onChange={(e) => setForm({ ...form, max_concurrency: e.target.value })} />
<div className="hint">{t("settings.maxConcurrencyHint")}</div>
</div>
<h3 style={{ marginTop: 16 }}>
{t("settings.models")}
<span className="spacer" />
{form.base_url?.includes("openrouter.ai") && (
<button className="small" onClick={fetchPrices}>
{t("settings.fetchPrices")}
</button>
)}
{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>
{priceNote && <div className="notice">{priceNote}</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")}><Icon name="brain" /></th>
<th title={t("settings.vision")}><Icon name="eye" /></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 title={t("settings.maxConcurrencyHint")}>{t("settings.slots")}</th>
<th title={t("settings.fallbackHint")}>{t("settings.fallback")}</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>
<input type="number" min={1} value={m.max_concurrency ?? ""} placeholder="∞" onChange={(e) => setModel(i, { max_concurrency: num(e.target.value) })} />
</td>
<td>
<ModelSelect value={m.fallback ?? ""} onChange={(v) => setModel(i, { fallback: v || undefined })} emptyLabel="–" />
</td>
<td>
<button className="small ghost danger" onClick={() => setModels(models.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" />
</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 [status, setStatus] = useState<Record<string, ProviderStatus>>({});
const [checking, setChecking] = useState(false);
useEffect(() => {
get<Record<string, ProviderStatus>>("/providers/status").then(setStatus).catch(() => {});
}, []);
const recheck = () => {
setChecking(true);
post<Record<string, ProviderStatus>>("/providers/status")
.then(setStatus)
.catch(() => {})
.finally(() => setChecking(false));
};
const add = () => {
const n = name.trim() || preset;
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(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>
<button onClick={recheck} disabled={checking}>
{checking ? t("settings.providerChecking") : t("settings.providerCheck")}
</button>
</div>
{Object.entries(providers).map(([n, p]) => (
<ProviderEditor key={n + JSON.stringify(p)} name={n} p={p} reload={reload} status={status[n]} />
))}
<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,
load: m.load ?? "auto",
users: (m.users ?? []).join(", "),
});
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 toast(t("settings.invalidName"));
const value: Record<string, unknown> = { type: form.type, enabled: form.enabled };
if (form.load !== "auto") value.load = form.load;
const users = form.users.split(/[\s,]+/).filter(Boolean);
if (users.length) value.users = users;
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" }} />
<label>{t("mcp.load")}</label>
<select value={form.load} onChange={(e) => setForm({ ...form, load: e.target.value })}>
<option value="auto">{t("mcp.loadAuto")}</option>
<option value="always">{t("mcp.loadAlways")}</option>
<option value="on_demand">{t("mcp.loadOnDemand")}</option>
</select>
<div className="hint">{t("mcp.loadHint")}</div>
<label>{t("mcp.users")}</label>
<input value={form.users} onChange={(e) => setForm({ ...form, users: e.target.value })} placeholder={t("mcp.usersPlaceholder")} />
<div className="hint">{t("mcp.usersHint")}</div>
</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={() => void confirmDialog({ message: t("settings.deleteMcpConfirm", { name }), danger: true, confirmLabel: t("common.delete") }).then((ok): unknown => ok && 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")}>
<Icon name="close" />
</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>
</>
);
}
// --- 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 ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "templates", "account", "raw"] as const;
const USER_TABS = ["general", "templates", "account"] as const;
const TAB_ICONS: Record<string, string> = {
general: "gear",
providers: "brain",
agents: "robot",
mcp: "plug",
permissions: "shield",
users: "users",
folders: "folders",
tools: "toolbox",
audit: "scroll",
templates: "template",
account: "user",
raw: "file-text",
};
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
const t = useT();
const isAdmin = useStore((s) => s.me?.role === "admin");
const TABS: readonly string[] = isAdmin ? ADMIN_TABS : USER_TABS;
const active = TABS.includes(tab ?? "") ? tab! : "general";
const { data: cfg, error, reload } = useFetch<ConfigResponse>(isAdmin ? "/config" : null);
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="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}`)}>
<Icon name={TAB_ICONS[x]} className="nav-icon" />
{t(`settings.tab.${x}` as Key)}
</button>
))}
</div>
{error && <div className="notice err">{error}</div>}
{isAdmin && !cfg && !error && <div className="empty">{t("common.loading")}</div>}
{!isAdmin && active === "general" && <InterfaceSettings />}
{active === "account" && <Account />}
{active === "templates" && <Templates />}
{cfg && active === "users" && <Users />}
{cfg && active === "folders" && <Folders />}
{cfg && active === "audit" && <Audit />}
{cfg && active === "tools" && <Tools />}
{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} />}
{cfg && active === "agents" && <AgentsEditor rawAgents={cfg.config.agents ?? {}} reloadConfig={reload} />}
{active === "raw" && <Raw reload={reload} />}
</div>
</div>
</>
);
}