Users, file manager, image generation, budgets and agent workflow

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 16:54:08 +01:00
1 parent f3a2fe3816
commit 49262c612c
70 files changed
+8495 -714

No files matched your search

+179 -87
View File
@@ -4,8 +4,13 @@ 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 { applyTheme, getTheme, THEMES, type Theme } from "../theme";
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
import { ModelSelect } from "./ModelSelect";
import { Account } from "./settings/Account";
import { AgentsEditor } from "./settings/AgentsEditor";
import { Folders } from "./settings/Folders";
import { Users } from "./settings/Users";
type Json = any;
interface ConfigResponse {
@@ -39,6 +44,63 @@ function useSaver(reload: () => void) {
// --- general ----------------------------------------------------------------
/** Per-device settings (language, theme, notifications) – available to everyone. */
function InterfaceSettings() {
const t = useT();
const reasoning = useReasoningMode();
const [theme, setThemeState] = useState<Theme>(getTheme());
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
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);
}}
>
{THEMES.map((th) => (
<option key={th.id} value={th.id}>
{th.id === "system" ? t("settings.themeSystem") : th.name}
</option>
))}
</select>
<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>
<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>
);
}
function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
const t = useT();
const s = cfg.config.server;
@@ -48,12 +110,18 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
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 ?? "",
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 [theme, setThemeState] = useState<Theme>(getTheme());
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
const { run, view } = useSaver(reload);
const save = () =>
@@ -63,51 +131,23 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
{ 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: ["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 (
<>
<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>
<InterfaceSettings />
<div className="panel">
<h3>{t("settings.server")}</h3>
<div className="form">
@@ -123,6 +163,14 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
<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.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">
@@ -137,6 +185,28 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
<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")}
@@ -159,6 +229,8 @@ interface ModelRow {
output_price?: number;
cache_read_price?: number;
cache_write_price?: number;
max_concurrency?: number;
options?: Record<string, unknown>;
}
const PRESETS: Record<string, { kind: string; base_url: string; api_key: string; auth_prefix?: string }> = {
@@ -187,7 +259,27 @@ function cleanModel(m: ModelRow) {
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 [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 ?? [];
@@ -204,9 +296,12 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
{ 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);
});
@@ -252,10 +347,27 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
<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 })}
@@ -268,6 +380,7 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
<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>
@@ -282,6 +395,7 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
<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 />
</tr>
</thead>
@@ -316,6 +430,9 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
<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>
<button className="small ghost danger" onClick={() => setModels(models.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
×
@@ -416,6 +533,7 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
url: m.url ?? "",
headers: mapToLines(m.headers, ": "),
enabled: m.enabled ?? true,
load: m.load ?? "auto",
});
const [open, setOpen] = useState(!!isNew);
const { run, view } = useSaver(reload);
@@ -424,6 +542,7 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
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.load !== "auto") value.load = form.load;
if (form.type === "stdio") {
value.command = form.command;
value.args = form.args.split("\n").map((s: string) => s.trim()).filter(Boolean);
@@ -486,6 +605,13 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
)}
<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>
</div>
{status?.tools?.length ? (
<details style={{ marginTop: 10 }}>
@@ -620,47 +746,6 @@ function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void
);
}
// --- 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 }) {
@@ -688,12 +773,15 @@ function Raw({ reload }: { reload: () => void }) {
// --- page -------------------------------------------------------------------
const TABS = ["general", "providers", "mcp", "permissions", "agents", "raw"] as const;
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "account", "raw"] as const;
const USER_TABS = ["general", "account"] 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");
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 (
<>
@@ -715,12 +803,16 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
))}
</div>
{error && <div className="notice err">{error}</div>}
{!cfg && !error && <div className="empty">{t("common.loading")}</div>}
{isAdmin && !cfg && !error && <div className="empty">{t("common.loading")}</div>}
{!isAdmin && active === "general" && <InterfaceSettings />}
{active === "account" && <Account />}
{cfg && active === "users" && <Users />}
{cfg && active === "folders" && <Folders />}
{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 />}
{cfg && active === "agents" && <AgentsEditor rawAgents={cfg.config.agents ?? {}} reloadConfig={reload} />}
{active === "raw" && <Raw reload={reload} />}
</div>
</div>