Add security hardening, Ghidra, recreate skill, checkpoints, shells, web search and themes
- Sandbox (bubblewrap) for non-admin shell/git, MCP per-user rights, hard budgets and quotas, login rate limiting, audit log and backups - Ghidra headless decompilation (ARM64 natives built from source), binary_info tool, clean room / deep workflow per session - Recreate skill and commands for analysing and re-building old software - File checkpoints with rewind/restore, background shells (bash_output, kill_shell) - web_search tool (DuckDuckGo, SearXNG, Brave, Tavily) - In-app dialogs and toasts instead of browser dialogs - 15 base themes (trilium-themes palettes plus Nord, VS Code, GitHub, Catppuccin, One, Dracula, Rose Pine, Everforest, Kanagawa) with light/dark/auto switcher and license notes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fab9add5d5
commit
5b569b6835
108 files changed
+6785
-279
No files matched your search
@@ -1,16 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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 { applyTheme, getTheme, THEMES, type Theme } from "../theme";
|
||||
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 { 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 {
|
||||
@@ -48,7 +51,9 @@ function useSaver(reload: () => void) {
|
||||
function InterfaceSettings() {
|
||||
const t = useT();
|
||||
const reasoning = useReasoningMode();
|
||||
const [theme, setThemeState] = useState<Theme>(getTheme());
|
||||
const theme = useSyncExternalStore(onThemeChange, getTheme);
|
||||
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
|
||||
const themeInfo = THEMES.find((th) => th.id === theme)!;
|
||||
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
|
||||
return (
|
||||
<div className="panel">
|
||||
@@ -63,20 +68,36 @@ function InterfaceSettings() {
|
||||
))}
|
||||
</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>
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<label>{t("settings.reasoningDisplay")}</label>
|
||||
<select value={reasoning} onChange={(e) => setReasoningMode(e.target.value as ReasoningMode)}>
|
||||
<option value="collapsed">{t("settings.reasoningCollapsed")}</option>
|
||||
@@ -114,6 +135,8 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
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(", "),
|
||||
@@ -134,6 +157,8 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
{ 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 },
|
||||
@@ -168,6 +193,18 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
<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>
|
||||
@@ -305,8 +342,8 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
|
||||
return savePatches(patches);
|
||||
});
|
||||
|
||||
const remove = () => {
|
||||
if (!confirm(t("settings.deleteProviderConfirm", { name }))) return;
|
||||
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 }]));
|
||||
};
|
||||
|
||||
@@ -469,7 +506,7 @@ function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void })
|
||||
|
||||
const add = () => {
|
||||
const n = name.trim() || preset;
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
|
||||
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;
|
||||
@@ -534,15 +571,18 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
|
||||
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 alert(t("settings.invalidName"));
|
||||
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);
|
||||
@@ -612,6 +652,9 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
|
||||
<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 }}>
|
||||
@@ -632,7 +675,7 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
{!isNew && (
|
||||
<button className="danger" onClick={() => confirm(t("settings.deleteMcpConfirm", { name })) && run(() => savePatches([{ path: ["mcp", name], value: null }]))}>
|
||||
<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>
|
||||
)}
|
||||
@@ -773,7 +816,7 @@ function Raw({ reload }: { reload: () => void }) {
|
||||
|
||||
// --- page -------------------------------------------------------------------
|
||||
|
||||
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "account", "raw"] as const;
|
||||
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "account", "raw"] as const;
|
||||
const USER_TABS = ["general", "account"] as const;
|
||||
|
||||
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
|
||||
@@ -808,6 +851,8 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
|
||||
{active === "account" && <Account />}
|
||||
{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} />}
|
||||
|
||||
Reference in new issue
Block a user