A 404/405 on the model list counts as reachable (endpoint without a list). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
991 lines
44 KiB
TypeScript
991 lines
44 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|