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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 19:12:30 +01:00
1 parent fab9add5d5
commit 5b569b6835
108 files changed
+6785 -279

No files matched your search

+94 -25
View File
@@ -1,9 +1,8 @@
import { useEffect } from "react";
import { useT } from "../i18n";
import { useEffect, useState } from "react";
import { fmtCompact, useT } from "../i18n";
import { useStore, type Draft } from "../store";
import type { Part } from "../types";
type ToolResult = Extract<Part, { type: "tool_result" }>;
/** Animated three-dot pulse. */
export function Dots() {
@@ -20,43 +19,113 @@ export function Spinner({ small }: { small?: boolean }) {
return <span className={`spinner ${small ? "small" : ""}`} aria-hidden="true" />;
}
/** What the agent is doing right now, derived from the streaming message. */
const STARS = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
/** Rotating star like a terminal agent's spinner. */
function Star() {
const [i, setI] = useState(0);
useEffect(() => {
const h = setInterval(() => setI((x) => (x + 1) % STARS.length), 120);
return () => clearInterval(h);
}, []);
return <span className="star" aria-hidden="true">{STARS[i]}</span>;
}
function useNow(active: boolean) {
const [now, setNow] = useState(Date.now());
useEffect(() => {
if (!active) return;
const h = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(h);
}, [active]);
return now;
}
function fmtElapsed(ms: number) {
const s = Math.max(0, Math.floor(ms / 1000));
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
}
/** Generated tokens so far, estimated from the streamed characters. */
function generatedTokens(draft?: Draft) {
if (!draft) return 0;
let chars = 0;
for (const p of draft.parts) {
if (p.type === "text" || p.type === "reasoning") chars += p.text.length;
else if (p.type === "tool_call") chars += JSON.stringify(p.input ?? {}).length;
}
return Math.round(chars / 4);
}
/** What the agent is doing right now – playful text, elapsed time and tokens. */
export function ActivityLine({
sessionId,
draft,
results,
pendingTool,
waiting,
permissionPending,
}: {
sessionId: string;
draft?: Draft;
results: Map<string, ToolResult>;
/** A tool call of the last (finished) model step that has no result yet. */
pendingTool?: { name: string; input: any; since: number };
waiting?: string;
permissionPending: boolean;
}) {
const t = useT();
let label: string;
const step = useStore((s) => s.steps[sessionId]);
const now = useNow((!!step || !!pendingTool) && !permissionPending);
if (permissionPending) {
label = t("activity.waitingForYou");
} else if (waiting) {
return (
<div className="activity paused" role="status">
<span className="pause-icon" aria-hidden="true" />
<span>{t("activity.waitingForYou")}</span>
</div>
);
}
if (waiting) {
const [kind, ...rest] = waiting.split(":");
const what = rest.join(":");
label = kind === "model" ? t("chat.waitingModel", { model: what }) : t("chat.waitingSubagent", { agent: what === "*" ? t("chat.anySubagent") : what });
} else {
const last = draft?.parts[draft.parts.length - 1];
const runningTool = draft?.parts.filter((p) => p.type === "tool_call" && !results.has(p.id)).pop() as
| Extract<Part, { type: "tool_call" }>
| undefined;
if (runningTool) {
label = runningTool.name === "task" ? t("activity.subagent", { name: runningTool.input?.description ?? "" }) : t("activity.tool", { tool: runningTool.name });
} else if (last?.type === "text") {
label = t("activity.writing");
} else {
label = t("activity.thinking");
}
return (
<div className="activity" role="status">
<Dots />
<span>{kind === "model" ? t("chat.waitingModel", { model: what }) : t("chat.waitingSubagent", { agent: what === "*" ? t("chat.anySubagent") : what })}</span>
</div>
);
}
// A tool is running between two model steps.
if (!draft && pendingTool) {
const label = pendingTool.name === "task" ? t("activity.subagent", { name: pendingTool.input?.description ?? "" }) : t("activity.tool", { tool: pendingTool.name });
return (
<div className="activity" role="status" aria-live="polite">
<Star />
<span className="activity-text">{label}</span>
<span className="activity-meta">({fmtElapsed(now - pendingTool.since)})</span>
</div>
);
}
const live = draft && step && draft.id === step.messageId ? draft : undefined;
const generated = generatedTokens(live);
const phrases = t("activity.phrases").split("|");
// A stable starting phrase per step, changing every 4 seconds.
const seed = step ? [...step.messageId].reduce((a, c) => a + c.charCodeAt(0), 0) : 0;
const elapsed = step ? now - step.startedAt : 0;
const label = `${phrases[(seed + Math.floor(elapsed / 4000)) % phrases.length]}…`;
const prefill = step && generated === 0;
return (
<div className={`activity ${permissionPending ? "paused" : ""}`} role="status" aria-live="polite">
{permissionPending ? <span className="pause-icon" aria-hidden="true" /> : <Dots />}
<span>{label}</span>
<div className="activity" role="status" aria-live="polite">
<Star />
<span className="activity-text">{label}</span>
{step && (
<span className="activity-meta">
({fmtElapsed(elapsed)} · ↑ {step.estimated ? "~" : ""}
{fmtCompact(step.promptTokens)} {t("activity.prefill")} · ↓ {prefill ? t("activity.prefilling") : `${fmtCompact(generated)} ${t("activity.generated")}`})
</span>
)}
</div>
);
}
+30 -57
View File
@@ -12,8 +12,12 @@ import { ShareDialog } from "./ShareDialog";
import { QueueList } from "./QueueList";
import { BudgetChip } from "./Budget";
import { PermissionCard, QuestionCard } from "./Interaction";
import { Markdown } from "./Markdown";
import { ImageRefs, Markdown } from "./Markdown";
import { UsageOverlay } from "./UsageOverlay";
import { RewindDialog } from "./RewindDialog";
import { ShellsChip } from "./ShellsPanel";
import { TodoList, ToolCard } from "./ToolCard";
import { confirmDialog, promptDialog, toast } from "../dialogs";
type ToolResult = Extract<Part, { type: "tool_result" }>;
@@ -53,7 +57,7 @@ function Reasoning({ text, live }: { text: string; live: boolean }) {
);
}
function AssistantParts({ parts, results, running, live }: { parts: Part[]; results: Map<string, ToolResult>; running: boolean; live: boolean }) {
function AssistantParts({ parts, results, running, live, baseDir }: { parts: Part[]; results: Map<string, ToolResult>; running: boolean; live: boolean; baseDir: string }) {
return (
<>
{parts.map((p, i) => {
@@ -61,13 +65,14 @@ function AssistantParts({ parts, results, running, live }: { parts: Part[]; resu
case "text":
return (
<div key={i} className={live && i === parts.length - 1 ? "streaming" : undefined}>
<Markdown text={p.text} />
<Markdown text={p.text} baseDir={baseDir} />
{!live && <ImageRefs text={p.text} baseDir={baseDir} />}
</div>
);
case "reasoning":
return <Reasoning key={i} text={p.text} live={live && i === parts.length - 1} />;
case "tool_call":
return <ToolCard key={p.id} call={p} result={results.get(p.id)} running={running} />;
return <ToolCard key={p.id} call={p} result={results.get(p.id)} running={running} baseDir={baseDir} />;
default:
return null;
}
@@ -102,46 +107,6 @@ function BackgroundResult({ text }: { text: string }) {
);
}
function StatsPanel({ stats }: { stats: SessionStats }) {
const t = useT();
const a = stats.total;
return (
<div className="panel" style={{ margin: "8px 0" }}>
<table className="grid">
<thead>
<tr>
<th>{t("stats.model")}</th>
<th className="num">{t("stats.requests")}</th>
<th className="num">{t("stats.input")}</th>
<th className="num">{t("stats.cacheRead")}</th>
<th className="num">{t("stats.output")}</th>
<th className="num">{t("stats.ttft")}</th>
<th className="num">{t("stats.outputSpeed")}</th>
<th className="num">{t("stats.cost")}</th>
</tr>
</thead>
<tbody>
{[...stats.by_model, { ...a, model: t("stats.total") }].map((r) => (
<tr key={r.model}>
<td>{r.model}</td>
<td className="num">{fmtNumber(r.requests)}</td>
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
<td className="num">{fmtCompact(r.output_tokens)}</td>
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
<td className="num">{fmtNumber(r.output_tps, 1)} tok/s</td>
<td className="num">{fmtCost(r.cost, stats.currency)}</td>
</tr>
))}
</tbody>
</table>
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 6 }}>
{t("stats.prefillSpeed")}: {fmtNumber(a.prefill_tps)} tok/s · {t("stats.cacheHit")}: {fmtPercent(a.cache_hit_rate)}
</div>
</div>
);
}
export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () => void }) {
const t = useT();
const session = useStore((s) => s.sessionById[sessionId]);
@@ -195,7 +160,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
try {
await patch(`/sessions/${sessionId}`, p);
} catch (e: any) {
alert(e.message);
toast(e.message);
}
};
@@ -210,20 +175,21 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
setText("");
stick.current = true;
} catch (e: any) {
alert(e.message);
toast(e.message);
}
};
const rewind = async (m: Message) => {
if (!confirm(t("chat.rewindConfirm"))) return;
const txt = m.parts.map((p) => (p.type === "text" ? p.text : "")).join("");
await post(`/sessions/${sessionId}/rewind`, { message_id: m.id });
const [rewinding, setRewinding] = useState<Message>();
const rewind = (m: Message) => setRewinding(m);
const rewindDone = async () => {
const m = rewinding!;
setRewinding(undefined);
await loadSession(sessionId);
setText(txt);
setText(m.parts.map((p) => (p.type === "text" ? p.text : "")).join(""));
};
const remove = async () => {
if (!confirm(t("chat.deleteConfirm"))) return;
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
await del(`/sessions/${sessionId}`);
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
};
@@ -237,6 +203,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
const todos = Array.isArray(session.todos) ? session.todos : [];
const openTodos = todos.filter((x) => x.status !== "completed");
const lastIsAssistant = messages.length > 0 && messages[messages.length - 1].role === "assistant";
const lastMsg = messages[messages.length - 1];
const pendingCall = lastIsAssistant
? (lastMsg.parts.filter((p) => p.type === "tool_call" && !results.has(p.id)).pop() as Extract<Part, { type: "tool_call" }> | undefined)
: undefined;
const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined;
return (
<>
@@ -279,6 +250,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
{fmtCost(stats.total.cost, stats.currency)}
</button>
)}
<ShellsChip sessionId={sessionId} busy={busy} />
{isAdmin && <BudgetChip />}
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
@@ -289,7 +261,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
⇄
</button>
)}
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => alert(e.message))} disabled={busy} title={t("command.compact")}>
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
⇊
</button>
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
@@ -308,13 +280,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
}}
>
<div className="chat">
{showStats && stats && <StatsPanel stats={stats} />}
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
{messages.map((m) => {
if (m.role === "assistant") {
return (
<div key={m.id} className="msg">
<AssistantParts parts={m.parts} results={results} running={busy} live={false} />
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={session.cwd} />
</div>
);
}
@@ -367,11 +339,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
})}
{draft && (
<div className="msg">
<AssistantParts parts={draft.parts} results={results} running={busy} live />
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
</div>
)}
{(busy || waiting) && (
<ActivityLine draft={draft} results={results} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
)}
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
<div className="todos">
@@ -409,6 +381,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
</div>
</div>
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
{readOnly ? (
<div className="readonly-bar">{t("chat.readOnlyHint")}</div>
+13 -2
View File
@@ -3,6 +3,7 @@ import { useAgents, useFetch } from "../hooks";
import { useT, type Key } from "../i18n";
import type { ImageData, Session } from "../types";
import { ModelSelect } from "./ModelSelect";
import { confirmDialog, promptDialog, toast } from "../dialogs";
interface Suggestion {
name: string;
@@ -119,7 +120,7 @@ export function Composer({
const imgs = await Promise.all(all.filter((f) => f.type.startsWith("image/")).map(readImage));
setImages((cur) => [...cur, ...imgs]);
} catch (e: any) {
alert(e.message ?? String(e));
toast(e.message ?? String(e));
}
// Everything else is uploaded into the project folder.
for (const f of all.filter((f) => !f.type.startsWith("image/"))) {
@@ -135,7 +136,7 @@ export function Composer({
if (!res.ok) throw new Error(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
setFiles((cur) => [...cur, { name: f.name, path, size: f.size }]);
} catch (e: any) {
alert(e.message ?? String(e));
toast(e.message ?? String(e));
} finally {
setUploading((n) => n - 1);
}
@@ -238,6 +239,16 @@ export function Composer({
</button>
))}
</div>
{session.agent === "craft" && (
<div className="mode-tabs" role="tablist" aria-label={t("craft.mode")} title={t("craft.modeHint")}>
<button className={session.clean_room !== false ? "active" : ""} onClick={() => onUpdate({ clean_room: true })}>
{t("craft.cleanRoom")}
</button>
<button className={session.clean_room === false ? "active deep" : ""} onClick={() => onUpdate({ clean_room: false })}>
{t("craft.deep")}
</button>
</div>
)}
<select
value={session.permission_mode}
onChange={(e) => onUpdate({ permission_mode: e.target.value as Session["permission_mode"] })}
+141
View File
@@ -0,0 +1,141 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { get } from "../api";
import { useT } from "../i18n";
import { Spinner } from "./Activity";
const ModelViewer = lazy(() => import("./ModelViewer").then((m) => ({ default: m.ModelViewer })));
export type PreviewKind = "image" | "pdf" | "doc" | "audio" | "module" | "model";
const KINDS: [RegExp, PreviewKind][] = [
[/\.(png|jpe?g|gif|webp|bmp|svg)$/i, "image"],
[/\.pdf$/i, "pdf"],
[/\.(docx|pptx|xlsx|xlsm|xls|ods)$/i, "doc"],
[/\.(wav|mp3|ogg|oga|flac|m4a)$/i, "audio"],
[/\.(xm|mod|s3m|it)$/i, "module"],
[/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i, "model"],
];
export function previewKind(path: string): PreviewKind | undefined {
return KINDS.find(([re]) => re.test(path))?.[1];
}
const q = (p: string) => `path=${encodeURIComponent(p)}`;
function PdfPreview({ path }: { path: string }) {
const t = useT();
const [pages, setPages] = useState<number>();
const [page, setPage] = useState(1);
useEffect(() => {
setPage(1);
get<{ pages: number }>(`/files/preview/pdf-info?${q(path)}`).then((r) => setPages(r.pages)).catch(() => setPages(1));
}, [path]);
return (
<div className="pdf-preview">
<div className="row pdf-nav">
<button className="small" disabled={page <= 1} onClick={() => setPage(page - 1)}>
‹
</button>
<span>{t("preview.page", { n: page, total: pages ?? "…" })}</span>
<button className="small" disabled={!pages || page >= pages} onClick={() => setPage(page + 1)}>
›
</button>
<span className="spacer" />
<a className="button small" href={`/api/files/raw?${q(path)}`} target="_blank" rel="noreferrer">
{t("preview.openBrowser")}
</a>
</div>
<div className="pane-scroll image-preview">
<img key={page} src={`/api/files/preview/pdf-page?${q(path)}&page=${page}`} alt={`page ${page}`} />
</div>
</div>
);
}
interface DocData {
kind: "text" | "table";
text?: string;
sheets?: { name: string; rows: string[][]; total_rows: number }[];
}
function DocPreview({ path }: { path: string }) {
const t = useT();
const [data, setData] = useState<DocData>();
const [error, setError] = useState<string>();
const [sheet, setSheet] = useState(0);
useEffect(() => {
setData(undefined);
setSheet(0);
get<DocData>(`/files/preview/doc?${q(path)}`).then(setData).catch((e) => setError(e.message));
}, [path]);
if (error) return <div className="notice err">{error}</div>;
if (!data) return <div className="empty"><Spinner /></div>;
if (data.kind === "text") return <pre className="pane-scroll doc-text">{data.text}</pre>;
const s = data.sheets?.[sheet];
return (
<div className="pdf-preview">
<div className="mode-tabs sheet-tabs">
{data.sheets?.map((x, i) => (
<button key={x.name} className={i === sheet ? "active" : ""} onClick={() => setSheet(i)}>
{x.name}
</button>
))}
</div>
<div className="pane-scroll">
{s && (
<table className="grid sheet">
<tbody>
{s.rows.map((r, i) => (
<tr key={i}>
<th className="num">{i + 1}</th>
{r.map((c, j) => (
<td key={j}>{c}</td>
))}
</tr>
))}
</tbody>
</table>
)}
{s && s.total_rows > s.rows.length && <div className="empty">{t("preview.moreRows", { n: s.total_rows - s.rows.length })}</div>}
</div>
</div>
);
}
export function FilePreview({ path, kind }: { path: string; kind: PreviewKind }) {
const t = useT();
const raw = `/api/files/raw?${q(path)}`;
switch (kind) {
case "image":
return (
<div className="pane-scroll image-preview">
<img src={raw} alt="" />
</div>
);
case "pdf":
return <PdfPreview path={path} />;
case "doc":
return <DocPreview path={path} />;
case "audio":
return (
<div className="audio-preview">
<audio controls src={raw} preload="metadata" />
</div>
);
case "module":
return (
<div className="audio-preview">
<div className="muted">{t("preview.moduleHint")}</div>
<audio controls src={`/api/files/preview/module?${q(path)}`} preload="none" />
</div>
);
case "model": {
const ext = path.split(".").pop()!.toLowerCase();
return (
<Suspense fallback={<div className="empty"><Spinner /></div>}>
<ModelViewer url={raw} ext={ext} />
</Suspense>
);
}
}
}
+39 -24
View File
@@ -6,6 +6,8 @@ import { Spinner } from "./Activity";
import { Diff } from "./Diff";
import { Markdown } from "./Markdown";
import { GitPanel, type GitStatus } from "./GitPanel";
import { FilePreview, previewKind, type PreviewKind } from "./FilePreview";
import { confirmDialog, promptDialog, toast } from "../dialogs";
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
@@ -31,12 +33,13 @@ interface OpenFile {
mtime?: number;
binary: boolean;
tooLarge?: boolean;
preview?: PreviewKind;
}
/** What the right pane shows. */
type Pane = { kind: "file"; file: OpenFile } | { kind: "diff"; title: string; diff: string } | { kind: "none" };
const IMAGE = /\.(png|jpe?g|gif|webp)$/i;
const IMAGE = /\.(png|jpe?g|gif|webp|bmp|svg)$/i;
const rawUrl = (p: string, download = false) => `/api/files/raw?path=${encodeURIComponent(p)}${download ? "&download=true" : ""}`;
const baseName = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/";
@@ -44,6 +47,9 @@ const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/";
function icon(e: Entry) {
if (e.is_dir) return "📁";
if (IMAGE.test(e.name)) return "🖼";
if (/\.(wav|mp3|ogg|flac|m4a|xm|mod|s3m|it)$/i.test(e.name)) return "🎵";
if (/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i.test(e.name)) return "🧊";
if (/\.(pdf|docx|pptx|xlsx|xls|ods)$/i.test(e.name)) return "📑";
if (/\.(md|txt)$/i.test(e.name)) return "📝";
return "📄";
}
@@ -112,17 +118,24 @@ export function Files({ onMenu }: { onMenu: () => void }) {
const dirty = pane.kind === "file" && pane.file.content !== pane.file.saved;
const confirmDiscard = () => !dirty || confirm(t("files.discardChanges"));
const confirmDiscard = async () => !dirty || (await confirmDialog({ message: t("files.discardChanges"), danger: true, confirmLabel: t("files.discard") }));
const openFile = async (path: string) => {
if (!confirmDiscard()) return;
if (!(await confirmDiscard())) return;
const kind = previewKind(path);
// Previewable files (except SVG, which can also be edited) are not loaded as text.
if (kind && !/\.svg$/i.test(path)) {
setPane({ kind: "file", file: { path, content: "", saved: "", binary: true, preview: kind } });
setMobileEditor(true);
return;
}
try {
const r = await get<{ content?: string; binary: boolean; mtime?: number; too_large?: boolean }>(`/files/read?path=${encodeURIComponent(path)}`);
setPane({ kind: "file", file: { path, content: r.content ?? "", saved: r.content ?? "", mtime: r.mtime, binary: r.binary, tooLarge: r.too_large } });
setPreview(/\.md$/i.test(path));
setPreview(/\.(md|svg)$/i.test(path));
setMobileEditor(true);
} catch (e: any) {
alert(e.message);
toast(e.message);
}
};
@@ -135,8 +148,8 @@ export function Files({ onMenu }: { onMenu: () => void }) {
setPane({ kind: "file", file: { ...f, saved: f.content, mtime: r.mtime } });
void refreshGit();
} catch (e: any) {
if (e.status === 409 && confirm(t("files.conflict"))) return save(true);
alert(e.message);
if (e.status === 409 && (await confirmDialog({ message: t("files.conflict"), danger: true, confirmLabel: t("files.overwrite") }))) return save(true);
toast(e.message);
}
},
[pane, refreshGit, t],
@@ -148,12 +161,12 @@ export function Files({ onMenu }: { onMenu: () => void }) {
if (listing) await load(listing.path);
void refreshGit();
} catch (e: any) {
alert(e.message);
toast(e.message);
}
};
const newEntry = (isDir: boolean) => {
const name = prompt(isDir ? t("files.newFolderName") : t("files.newFileName"));
const newEntry = async (isDir: boolean) => {
const name = await promptDialog({ message: isDir ? t("files.newFolderName") : t("files.newFileName") });
if (!name || !listing) return;
const path = `${listing.path}/${name}`;
void act(async () => {
@@ -162,18 +175,18 @@ export function Files({ onMenu }: { onMenu: () => void }) {
});
};
const rename = (e: Entry) => {
const name = prompt(t("files.renameTo"), e.name);
const rename = async (e: Entry) => {
const name = await promptDialog({ message: t("files.renameTo"), defaultValue: e.name });
if (name && name !== e.name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}` }));
};
const duplicate = (e: Entry) => {
const name = prompt(t("files.copyTo"), `${e.name.replace(/(\.[^.]*)?$/, " copy$1")}`);
const duplicate = async (e: Entry) => {
const name = await promptDialog({ message: t("files.copyTo"), defaultValue: `${e.name.replace(/(\.[^.]*)?$/, " copy$1")}` });
if (name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}`, copy: true }));
};
const remove = (e: Entry) => {
if (confirm(t(e.is_dir ? "files.deleteFolderConfirm" : "files.deleteConfirm", { name: e.name }))) {
const remove = async (e: Entry) => {
if (await confirmDialog({ message: t(e.is_dir ? "files.deleteFolderConfirm" : "files.deleteConfirm", { name: e.name }), danger: true, confirmLabel: t("common.delete") })) {
void act(() => del(`/files/delete?path=${encodeURIComponent(e.path)}`));
if (pane.kind === "file" && pane.file.path.startsWith(e.path)) setPane({ kind: "none" });
}
@@ -183,7 +196,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
if (!listing) return;
for (const f of [...files]) {
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(`${listing.path}/${f.name}`)}`, { method: "PUT", body: f, credentials: "same-origin" });
if (!res.ok) alert(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
if (!res.ok) toast(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
}
await load(listing.path);
void refreshGit();
@@ -220,7 +233,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
>
<div className="files-toolbar">
{roots.length > 1 && (
<select value={root?.path ?? ""} onChange={(e) => confirmDiscard() && load(e.target.value)} aria-label={t("images.folder")}>
<select value={root?.path ?? ""} onChange={(e) => { const v = e.target.value; void confirmDiscard().then((ok): unknown => ok && load(v)); }} aria-label={t("images.folder")}>
{roots.map((r) => (
<option key={r.path} value={r.path}>
{r.name}
@@ -335,14 +348,14 @@ export function Files({ onMenu }: { onMenu: () => void }) {
return (
<div className="editor-pane">
<div className="editor-bar">
<button className="ghost small mobile-only" onClick={() => confirmDiscard() && setMobileEditor(false)}>
<button className="ghost small mobile-only" onClick={() => void confirmDiscard().then((ok): unknown => ok && setMobileEditor(false))}>
←
</button>
<b className="grow" title={f.path}>
{baseName(f.path)}
{dirty && <span className="dirty"> ●</span>}
</b>
{isMd && (
{(isMd || isImage) && !f.preview && (
<button className="small" onClick={() => setPreview(!preview)}>
{preview ? t("files.edit") : t("files.preview")}
</button>
@@ -356,7 +369,9 @@ export function Files({ onMenu }: { onMenu: () => void }) {
</button>
)}
</div>
{isImage ? (
{f.preview ? (
<FilePreview path={f.path} kind={f.preview} />
) : isImage && preview ? (
<div className="pane-scroll image-preview">
<img src={rawUrl(f.path)} alt={baseName(f.path)} />
</div>
@@ -422,10 +437,10 @@ export function Files({ onMenu }: { onMenu: () => void }) {
</button>
<button
className="small"
onClick={() => {
const url = prompt(t("git.cloneUrl"));
onClick={async () => {
const url = await promptDialog({ message: t("git.cloneUrl"), placeholder: "https://…/repo.git" });
if (!url) return;
const name = prompt(t("git.cloneFolder"), baseName(url).replace(/\.git$/, ""));
const name = await promptDialog({ message: t("git.cloneFolder"), defaultValue: baseName(url).replace(/\.git$/, "") });
if (name) void act(() => post("/git", { path: `${listing.path}/${name}`, action: "clone", url }));
}}
>
+5 -4
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { post } from "../api";
import { fmtRelative, useT } from "../i18n";
import { Spinner } from "./Activity";
import { confirmDialog, promptDialog, toast } from "../dialogs";
export interface GitFile {
path: string;
@@ -90,7 +91,7 @@ export function GitPanel({
const r = await call<{ diff: string }>("diff", { files: [abs(f)], staged: isStaged });
showDiff(`${f.path}${isStaged ? ` (${t("git.staged")})` : ""}`, r.diff);
} catch (e: any) {
alert(e.message);
toast(e.message);
}
};
@@ -112,7 +113,7 @@ export function GitPanel({
) : (
<>
{f.index !== "?" && (
<button className="small ghost danger" title={t("git.discard")} onClick={() => confirm(t("git.discardConfirm", { file: f.path })) && run("discard", () => call("discard", { files: [abs(f)] }))}>
<button className="small ghost danger" title={t("git.discard")} onClick={() => void confirmDialog({ message: t("git.discardConfirm", { file: f.path }), danger: true, confirmLabel: t("git.discard") }).then((ok): unknown => ok && run("discard", () => call("discard", { files: [abs(f)] })))}>
↺
</button>
)}
@@ -143,8 +144,8 @@ export function GitPanel({
<button
className="small"
title={t("git.newBranch")}
onClick={() => {
const name = prompt(t("git.newBranchName"));
onClick={async () => {
const name = await promptDialog({ message: t("git.newBranchName"), placeholder: "feature/…" });
if (name) void run("checkout", () => call("checkout", { branch: name, create: true }), true);
}}
>
+2 -1
View File
@@ -3,6 +3,7 @@ import { del, post } from "../api";
import { useFetch } from "../hooks";
import { fmtCost, fmtDate, useT, type Key } from "../i18n";
import { Spinner } from "./Activity";
import { confirmDialog, promptDialog, toast } from "../dialogs";
interface ImageConfig {
enabled: boolean;
@@ -148,7 +149,7 @@ export function Images({ onMenu }: { onMenu: () => void }) {
<button className="small" title={t("images.copyPath")} onClick={() => navigator.clipboard?.writeText(img.path)}>
⧉
</button>
<button className="small danger" title={t("common.delete")} onClick={() => confirm(t("images.deleteConfirm")) && del(`/images/${img.id}`).then(reload)}>
<button className="small danger" title={t("common.delete")} onClick={() => void confirmDialog({ message: t("images.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }).then((ok): unknown => ok && del(`/images/${img.id}`).then(reload))}>
🗑
</button>
</div>
+100 -3
View File
@@ -1,18 +1,115 @@
import { memo } from "react";
import { memo, useState, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
export const Markdown = memo(function Markdown({ text }: { text: string }) {
export const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|svg)$/i;
/** URL for a file in the project (absolute or relative to baseDir). */
export function projectFileUrl(src: string, baseDir?: string): string | undefined {
if (/^(https?:|data:|blob:)/i.test(src)) return src;
if (!baseDir && !src.startsWith("/")) return undefined;
const parts = (src.startsWith("/") ? src : `${baseDir}/${src}`).split("/");
const out: string[] = [];
for (const p of parts) {
if (p === "..") out.pop();
else if (p && p !== ".") out.push(p);
}
return `/api/files/raw?path=${encodeURIComponent("/" + out.join("/"))}`;
}
function textOf(node: ReactNode): string {
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(textOf).join("");
if (node && typeof node === "object" && "props" in node) return textOf((node as any).props.children);
return "";
}
/** SVG code shown as an image (img context: scripts never run), code on demand. */
export function SvgBlock({ code, children }: { code: string; children?: ReactNode }) {
const [showCode, setShowCode] = useState(false);
const url = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(code)))}`;
return (
<div className="svg-block">
<img src={url} alt="SVG" />
<button className="small ghost" onClick={() => setShowCode(!showCode)}>
{showCode ? "▾ SVG" : "▸ SVG"}
</button>
{showCode && children}
</div>
);
}
export const Markdown = memo(function Markdown({ text, baseDir }: { text: string; baseDir?: string }) {
return (
<div className="markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
components={{ a: (props) => <a {...props} target="_blank" rel="noreferrer" /> }}
components={{
a: (props) => <a {...props} target="_blank" rel="noreferrer" />,
img: ({ src, alt }) => {
const url = typeof src === "string" ? projectFileUrl(src, baseDir) : undefined;
if (!url) return <span>{alt}</span>;
// Non-image files (3D models, documents) become links.
if (typeof src === "string" && !/^(data:|https?:)/i.test(src) && !IMAGE_EXT.test(src)) {
return (
<a href={url} target="_blank" rel="noreferrer">
📎 {alt || src}
</a>
);
}
return <img className="inline-image" src={url} alt={alt ?? ""} loading="lazy" />;
},
pre: ({ children }) => {
const code = textOf(children);
const child: any = Array.isArray(children) ? children[0] : children;
const cls = String(child?.props?.className ?? "");
if (/language-svg/.test(cls) || (/language-(xml|html)/.test(cls) && code.trim().startsWith("<svg"))) {
return (
<SvgBlock code={code}>
<pre>{children}</pre>
</SvgBlock>
);
}
return <pre>{children}</pre>;
},
}}
>
{text}
</ReactMarkdown>
</div>
);
});
/** Image files mentioned in a text (paths in backticks or plain). */
export function imagePathsIn(text: string): string[] {
const found = new Set<string>();
const re = /(?:^|[\s`(\["'])((?:\.{0,2}\/)?[\w\-./]+\.(?:png|jpe?g|gif|webp|bmp|svg))(?=$|[\s`)\]"',.:;!?])/gim;
for (const m of text.matchAll(re)) {
// Skip URLs and markdown images (rendered anyway).
const idx = m.index ?? 0;
const before = text.slice(Math.max(0, idx - 2), idx + 1);
if (m[1].includes("://") || before.includes("](") || text.slice(Math.max(0, idx - 10), idx + 1).includes("http")) continue;
found.add(m[1]);
}
return [...found].slice(0, 8);
}
/** Thumbnails for image files mentioned in a message; missing files hide themselves. */
export function ImageRefs({ text, baseDir }: { text: string; baseDir?: string }) {
const paths = imagePathsIn(text.replace(/!\[[^\]]*\]\([^)]*\)/g, ""));
if (paths.length === 0 || !baseDir) return null;
return (
<div className="image-refs">
{paths.map((p) => {
const url = projectFileUrl(p, baseDir)!;
return (
<a key={p} href={url} target="_blank" rel="noreferrer" title={p}>
<img src={url} alt={p} loading="lazy" onError={(e) => ((e.currentTarget.parentElement as HTMLElement).style.display = "none")} />
</a>
);
})}
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
// 3D preview for GLB/GLTF, STL, OBJ and STEP/IGES (via OpenCascade WASM).
// Loaded on demand only when such a file is opened.
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
import occtScriptUrl from "occt-import-js/dist/occt-import-js.js?url";
import occtWasmUrl from "occt-import-js/dist/occt-import-js.wasm?url";
let occtPromise: Promise<any> | undefined;
/** Load the OpenCascade importer once (script tag + wasm URL). */
function loadOcct(): Promise<any> {
occtPromise ??= new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = occtScriptUrl;
s.onload = () => (window as any).occtimportjs({ locateFile: () => occtWasmUrl }).then(resolve, reject);
s.onerror = () => reject(new Error("could not load the STEP importer"));
document.head.appendChild(s);
});
return occtPromise;
}
function cssColor(name: string, fallback: string) {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
async function loadObject(url: string, ext: string): Promise<THREE.Object3D> {
const material = new THREE.MeshStandardMaterial({ color: cssColor("--blue", "#83a598"), metalness: 0.1, roughness: 0.6, side: THREE.DoubleSide });
switch (ext) {
case "glb":
case "gltf":
return (await new GLTFLoader().loadAsync(url)).scene;
case "stl": {
const geo = await new STLLoader().loadAsync(url);
geo.computeVertexNormals();
return new THREE.Mesh(geo, material);
}
case "obj": {
const obj = await new OBJLoader().loadAsync(url);
obj.traverse((c) => c instanceof THREE.Mesh && (c.material = material));
return obj;
}
default: {
// STEP / IGES: tessellate with OpenCascade in the browser.
const [occt, buf] = await Promise.all([loadOcct(), fetch(url, { credentials: "same-origin" }).then((r) => r.arrayBuffer())]);
const data = new Uint8Array(buf);
const res = ext === "igs" || ext === "iges" ? occt.ReadIgesFile(data, null) : occt.ReadStepFile(data, null);
if (!res.success) throw new Error("the file could not be read");
const group = new THREE.Group();
for (const m of res.meshes) {
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(m.attributes.position.array, 3));
if (m.attributes.normal) geo.setAttribute("normal", new THREE.Float32BufferAttribute(m.attributes.normal.array, 3));
else geo.computeVertexNormals();
if (m.index) geo.setIndex(new THREE.Uint32BufferAttribute(m.index.array, 1));
const mat = m.color ? material.clone() : material;
if (m.color) (mat as THREE.MeshStandardMaterial).color = new THREE.Color(m.color[0], m.color[1], m.color[2]);
group.add(new THREE.Mesh(geo, mat));
}
return group;
}
}
}
export function ModelViewer({ url, ext }: { url: string; ext: string }) {
const host = useRef<HTMLDivElement>(null);
const [state, setState] = useState<{ loading: boolean; error?: string; info?: string }>({ loading: true });
useEffect(() => {
const el = host.current!;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
el.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(cssColor("--bg0", "#282828"));
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.6));
const sun = new THREE.DirectionalLight(0xffffff, 1.8);
sun.position.set(3, 5, 4);
scene.add(sun);
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1e6);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
const resize = () => {
const w = el.clientWidth || 300;
const h = el.clientHeight || 300;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
};
const ro = new ResizeObserver(resize);
ro.observe(el);
resize();
let alive = true;
let frame = 0;
const loop = () => {
if (!alive) return;
controls.update();
renderer.render(scene, camera);
frame = requestAnimationFrame(loop);
};
loop();
loadObject(url, ext)
.then((obj) => {
if (!alive) return;
scene.add(obj);
// Frame the model.
const box = new THREE.Box3().setFromObject(obj);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const radius = Math.max(size.length() / 2, 1e-3);
camera.position.copy(center).add(new THREE.Vector3(radius * 1.4, radius * 1.0, radius * 1.8));
camera.near = radius / 100;
camera.far = radius * 100;
camera.updateProjectionMatrix();
controls.target.copy(center);
let tris = 0;
obj.traverse((c) => {
if (c instanceof THREE.Mesh) tris += (c.geometry.index ? c.geometry.index.count : c.geometry.attributes.position.count) / 3;
});
setState({ loading: false, info: `${Math.round(tris).toLocaleString()} triangles · ${size.x.toFixed(2)} × ${size.y.toFixed(2)} × ${size.z.toFixed(2)}` });
})
.catch((e) => alive && setState({ loading: false, error: String(e?.message ?? e) }));
return () => {
alive = false;
cancelAnimationFrame(frame);
ro.disconnect();
controls.dispose();
scene.traverse((c) => {
if (c instanceof THREE.Mesh) {
c.geometry.dispose();
(Array.isArray(c.material) ? c.material : [c.material]).forEach((m) => m.dispose());
}
});
renderer.dispose();
el.removeChild(renderer.domElement);
};
}, [url, ext]);
return (
<div className="model-viewer">
<div ref={host} className="model-canvas" />
<div className="model-info">{state.loading ? "…" : state.error ? `⚠ ${state.error}` : state.info}</div>
</div>
);
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { del, post } from "../api";
import { useT } from "../i18n";
import { useStore } from "../store";
import type { QueueItem } from "../types";
import { confirmDialog, promptDialog, toast } from "../dialogs";
const EMPTY: QueueItem[] = [];
@@ -15,7 +16,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
<div className="queue-head">
<span>{busy ? t("queue.pending", { n: items.length }) : t("queue.stopped", { n: items.length })}</span>
{!busy && (
<button className="small primary" onClick={() => post(`/sessions/${sessionId}/queue/flush`).catch((e) => alert(e.message))}>
<button className="small primary" onClick={() => post(`/sessions/${sessionId}/queue/flush`).catch((e) => toast(e.message))}>
{t("queue.sendNow")}
</button>
)}
@@ -35,7 +36,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
className="small ghost"
aria-label={t("queue.remove")}
title={t("queue.remove")}
onClick={() => del(`/sessions/${sessionId}/queue/${it.id}`).catch((e) => alert(e.message))}
onClick={() => del(`/sessions/${sessionId}/queue/${it.id}`).catch((e) => toast(e.message))}
>
×
</button>
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import { get, post } from "../api";
import { useT } from "../i18n";
import { confirmDialog, promptDialog, toast } from "../dialogs";
/** Rewind to a message, optionally restoring the files the agent changed since. */
export function RewindDialog({ sessionId, messageId, onDone, onClose }: { sessionId: string; messageId: string; onDone: () => void; onClose: () => void }) {
const t = useT();
const [files, setFiles] = useState<{ path: string; created: boolean }[]>();
const [restore, setRestore] = useState(true);
const [busy, setBusy] = useState(false);
useEffect(() => {
get<{ path: string; created: boolean }[]>(`/sessions/${sessionId}/changes?since_message=${messageId}`).then(setFiles).catch(() => setFiles([]));
}, [sessionId, messageId]);
const go = async () => {
setBusy(true);
try {
await post(`/sessions/${sessionId}/rewind`, { message_id: messageId, restore_files: restore && !!files?.length });
onDone();
} catch (e: any) {
toast(e.message);
setBusy(false);
}
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" role="dialog" aria-label={t("chat.rewind")} onClick={(e) => e.stopPropagation()}>
<h3>{t("chat.rewind")}</h3>
<p style={{ fontSize: 13, color: "var(--fg3)" }}>{t("rewind.intro")}</p>
{files === undefined ? (
<div className="empty">…</div>
) : files.length === 0 ? (
<p style={{ fontSize: 13 }}>{t("rewind.noFiles")}</p>
) : (
<>
<label className="row" style={{ marginBottom: 6 }}>
<input type="checkbox" checked={restore} onChange={(e) => setRestore(e.target.checked)} /> {t("rewind.restore", { n: files.length })}
</label>
<ul className="rewind-files">
{files.map((f) => (
<li key={f.path}>
<code>{f.path}</code> {f.created && <span className="chip">{t("rewind.willDelete")}</span>}
</li>
))}
</ul>
<p style={{ fontSize: 12, color: "var(--muted)" }}>{t("rewind.bashNote")}</p>
</>
)}
<div className="row" style={{ justifyContent: "flex-end", marginTop: 10 }}>
<button onClick={onClose}>{t("common.cancel")}</button>
<button className="primary" disabled={busy || files === undefined} onClick={go}>
{t("rewind.confirm")}
</button>
</div>
</div>
</div>
);
}
+67 -22
View File
@@ -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} />}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import { get, post } from "../api";
import { toast } from "../dialogs";
import { fmtRelative, useT } from "../i18n";
import { Spinner } from "./Activity";
interface ShellInfo {
id: string;
command: string;
started_at: number;
running: boolean;
exit_code: number | null;
output: string;
}
/** Header chip with the session's background shells; opens a live output popup. */
export function ShellsChip({ sessionId, busy }: { sessionId: string; busy: boolean }) {
const t = useT();
const [shells, setShells] = useState<ShellInfo[]>([]);
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string>();
useEffect(() => {
let alive = true;
const load = () => get<ShellInfo[]>(`/sessions/${sessionId}/shells`).then((s) => alive && setShells(s)).catch(() => undefined);
void load();
const anyRunning = shells.some((s) => s.running);
if (!open && !busy && !anyRunning) return () => void (alive = false);
const h = setInterval(load, open ? 1500 : 5000);
return () => {
alive = false;
clearInterval(h);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, open, busy, shells.some((s) => s.running)]);
const running = shells.filter((s) => s.running).length;
if (shells.length === 0) return null;
const current = shells.find((s) => s.id === selected) ?? shells[0];
return (
<>
<button className={`chip ${running ? "warn" : ""}`} style={{ border: "none" }} onClick={() => setOpen(true)} title={t("shells.title")}>
{running ? <Spinner small /> : "▣"} {t("shells.chip", { n: running, total: shells.length })}
</button>
{open && (
<div className="modal-backdrop" onClick={() => setOpen(false)}>
<div className="modal wide" role="dialog" aria-label={t("shells.title")} onClick={(e) => e.stopPropagation()}>
<div className="row" style={{ marginBottom: 8 }}>
<h3 style={{ margin: 0 }}>{t("shells.title")}</h3>
<span className="spacer" />
<button className="small ghost" onClick={() => setOpen(false)} aria-label={t("common.close")}>
✕
</button>
</div>
<div className="mode-tabs" style={{ flexWrap: "wrap", marginBottom: 8 }}>
{shells.map((s) => (
<button key={s.id} className={s.id === current.id ? "active" : ""} onClick={() => setSelected(s.id)}>
{s.running ? "● " : "○ "}
{s.command.slice(0, 40)}
</button>
))}
</div>
<div className="row" style={{ fontSize: 12.5, color: "var(--muted)", marginBottom: 6 }}>
<code>{current.command}</code>
<span>· {fmtRelative(current.started_at)}</span>
<span>· {current.running ? t("shells.running") : t("shells.exited", { code: current.exit_code ?? "?" })}</span>
<span className="spacer" />
{current.running && (
<button className="small danger" onClick={() => post(`/sessions/${sessionId}/shells/${current.id}/kill`).catch(toast)}>
■ {t("shells.stop")}
</button>
)}
</div>
<pre className="shell-output">{current.output || t("shells.noOutput")}</pre>
</div>
</div>
)}
</>
);
}
+16 -1
View File
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
import { useMemo, useSyncExternalStore } from "react";
import { post } from "../api";
import { fmtRelative, useT, type Key } from "../i18n";
import { navigate } from "../router";
@@ -88,6 +89,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
{me?.display_name || me?.username}
{me?.role === "admin" && <span className="chip" style={{ marginLeft: 6 }}>admin</span>}
</span>
<ThemeModeButton />
<button className="small ghost" onClick={() => post("/auth/logout").then(() => location.reload())}>
{t("nav.logout")}
</button>
@@ -109,3 +111,16 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
</aside>
);
}
/** Quick light/dark/auto switcher. */
function ThemeModeButton() {
const t = useT();
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
const icon = mode === "auto" ? "◐" : mode === "light" ? "☀" : "☾";
const label = t(mode === "auto" ? "settings.themeAuto" : mode === "light" ? "settings.themeLight" : "settings.themeDark");
return (
<button className="small ghost theme-toggle" onClick={cycleThemeMode} title={`${t("settings.themeToggle")} – ${label}`} aria-label={t("settings.themeToggle")}>
{icon}
</button>
);
}
+22 -1
View File
@@ -4,6 +4,7 @@ import { useStore } from "../store";
import type { Part, Todo } from "../types";
import { Spinner } from "./Activity";
import { Diff } from "./Diff";
import { IMAGE_EXT, projectFileUrl } from "./Markdown";
type ToolCall = Extract<Part, { type: "tool_call" }>;
type ToolResult = Extract<Part, { type: "tool_result" }>;
@@ -57,7 +58,7 @@ export function TodoList({ todos }: { todos: Todo[] }) {
);
}
export function ToolCard({ call, result, running }: { call: ToolCall; result?: ToolResult; running: boolean }) {
export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; result?: ToolResult; running: boolean; baseDir?: string }) {
const t = useT();
const progress = useStore((s) => s.progress[call.id]);
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
@@ -77,6 +78,26 @@ export function ToolCard({ call, result, running }: { call: ToolCall; result?: T
);
}
// Image files written by the agent are shown as images (SVG too, rendered via <img>).
const writtenImage =
(call.name === "write" || call.name === "edit") && typeof call.input?.path === "string" && IMAGE_EXT.test(call.input.path) && result && !result.is_error
? projectFileUrl(call.input.path, baseDir)
: undefined;
if (writtenImage) {
return (
<div className="tool image-result">
<div className="tool-head">
<span className="tool-name">{call.name}</span>
<span className="tool-title">{call.input.path}</span>
<span className="status ok">✓</span>
</div>
<a href={writtenImage} target="_blank" rel="noreferrer">
<img src={`${writtenImage}&v=${result!.call_id}`} alt={call.input.path} />
</a>
</div>
);
}
// Short diffs are shown right away, like in a terminal agent.
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
const openByDefault =
+199
View File
@@ -0,0 +1,199 @@
import { useEffect } from "react";
import { useFetch, useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, useT } from "../i18n";
import { useStore } from "../store";
import type { Aggregate, Session } from "../types";
interface SessionStats {
total: Aggregate;
by_model: (Aggregate & { model: string })[];
by_agent: (Aggregate & { key: string })[];
first: number | null;
last: number | null;
currency: string;
}
interface BudgetRow {
provider: string;
budget: number | null;
spent: number;
share: number | null;
warning: boolean;
}
function Row({ label, value, hint }: { label: string; value: React.ReactNode; hint?: string }) {
return (
<div className="kv" title={hint}>
<span>{label}</span>
<b>{value}</b>
</div>
);
}
const perMillion = (a: Aggregate) => {
const tokens = a.input_tokens + a.cache_read_tokens + a.cache_write_tokens + a.output_tokens;
return tokens > 0 ? (a.cost / tokens) * 1_000_000 : 0;
};
/** Everything about the models used in a session: configuration, usage, speed, cost. */
export function UsageOverlay({ session, onClose }: { session: Session; onClose: () => void }) {
const t = useT();
const { data } = useFetch<SessionStats>(`/sessions/${session.id}/stats`);
const models = useModels();
const isAdmin = useStore((s) => s.me?.role === "admin");
const budget = useFetch<{ providers: BudgetRow[]; currency: string }>(isAdmin ? "/budget" : null).data;
useEffect(() => {
const h = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
const cur = data?.currency ?? "USD";
const info = (spec: string) => {
const [p, ...rest] = spec.split("/");
return models.find((x) => x.provider === p)?.models.find((m) => m.id === rest.join("/"));
};
const u = session.last_usage;
const ctxTokens = u ? u.input_tokens + u.cache_read_tokens + u.cache_write_tokens + u.output_tokens : 0;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal wide" role="dialog" aria-label={t("usage.title")} onClick={(e) => e.stopPropagation()}>
<div className="row" style={{ marginBottom: 8 }}>
<h3 style={{ margin: 0 }}>{t("usage.title")}</h3>
<span className="spacer" />
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
✕
</button>
</div>
{!data ? (
<div className="empty">{t("common.loading")}</div>
) : (
<>
<div className="kpis">
<div className="kpi">
<div className="k-label">{t("stats.cost")}</div>
<div className="k-value">{fmtCost(data.total.cost, cur)}</div>
<div className="k-sub">{t("stats.perRequest", { v: fmtCost(data.total.requests ? data.total.cost / data.total.requests : 0, cur) })}</div>
</div>
<div className="kpi">
<div className="k-label">{t("stats.requests")}</div>
<div className="k-value">{fmtNumber(data.total.requests)}</div>
{data.total.errors > 0 && <div className="k-sub">{t("stats.errors", { n: data.total.errors })}</div>}
</div>
<div className="kpi">
<div className="k-label">{t("stats.tokens")}</div>
<div className="k-value">
{fmtCompact(data.total.input_tokens + data.total.cache_read_tokens + data.total.cache_write_tokens)}↑ {fmtCompact(data.total.output_tokens)}↓
</div>
<div className="k-sub">
{t("stats.cacheHit")}: {fmtPercent(data.total.cache_hit_rate)}
</div>
</div>
<div className="kpi">
<div className="k-label">{t("usage.period")}</div>
<div className="k-value" style={{ fontSize: 15 }}>
{data.first ? fmtDate(data.first) : "–"}
</div>
<div className="k-sub">{data.last ? `→ ${fmtDate(data.last)}` : ""}</div>
</div>
</div>
{data.by_model.map((m) => {
const mi = info(m.model);
const provider = m.model.split("/")[0];
const b = budget?.providers.find((x) => x.provider === provider);
const isCurrent = m.model === session.model;
return (
<div className="panel" key={m.model}>
<h3>
{mi?.name ?? m.model}
{isCurrent && <span className="chip">{t("usage.current")}</span>}
<span className="chip">{provider}</span>
</h3>
<div className="kv-grid">
<div>
<div className="kv-title">{t("usage.model")}</div>
<Row label="ID" value={<code>{m.model}</code>} />
<Row label={t("settings.context")} value={mi?.context ? `${fmtCompact(mi.context)} tok` : "–"} />
<Row label={t("settings.maxOutput")} value={mi?.max_output ? `${fmtCompact(mi.max_output)} tok` : "–"} />
<Row label={t("settings.reasoning")} value={mi?.reasoning ? `✓${mi.effort ? ` (${mi.effort})` : ""}` : "–"} />
<Row label={t("settings.vision")} value={mi?.vision ? "✓" : "–"} />
{isCurrent && mi?.context ? (
<Row label={t("usage.contextNow")} value={`${fmtCompact(ctxTokens)} / ${fmtCompact(mi.context)} (${fmtPercent(ctxTokens / mi.context)})`} />
) : null}
</div>
<div>
<div className="kv-title">{t("usage.prices")}</div>
<Row label={t("settings.priceIn")} value={mi?.input_price != null ? fmtCost(mi.input_price, cur) : "–"} />
<Row label={t("settings.priceOut")} value={mi?.output_price != null ? fmtCost(mi.output_price, cur) : "–"} />
<Row label={t("settings.priceCacheRead")} value={mi?.cache_read_price != null ? fmtCost(mi.cache_read_price, cur) : "–"} />
<Row label={t("settings.priceCacheWrite")} value={mi?.cache_write_price != null ? fmtCost(mi.cache_write_price, cur) : "–"} />
<Row label={t("stats.perMillion")} value={fmtCost(perMillion(m), cur)} hint={t("stats.perMillionHint")} />
</div>
<div>
<div className="kv-title">{t("usage.usage")}</div>
<Row label={t("stats.requests")} value={fmtNumber(m.requests)} />
<Row label={t("stats.input")} value={fmtNumber(m.input_tokens)} />
<Row label={t("stats.cacheRead")} value={fmtNumber(m.cache_read_tokens)} />
<Row label={t("usage.cacheWrite")} value={fmtNumber(m.cache_write_tokens)} />
<Row label={t("stats.output")} value={fmtNumber(m.output_tokens)} />
<Row label={t("stats.cacheHit")} value={fmtPercent(m.cache_hit_rate)} />
</div>
<div>
<div className="kv-title">{t("usage.speed")}</div>
<Row label={t("stats.ttft")} value={`${fmtNumber(m.avg_ttft_ms)} ms`} />
<Row label={t("stats.prefillSpeed")} value={`${fmtNumber(m.prefill_tps)} tok/s`} />
<Row label={t("stats.outputSpeed")} value={`${fmtNumber(m.output_tps, 1)} tok/s`} />
<Row label={t("usage.totalTime")} value={`${fmtNumber(m.duration_ms / 1000, 1)} s`} />
<div className="kv-title" style={{ marginTop: 8 }}>{t("stats.cost")}</div>
<Row label={t("usage.inSession")} value={fmtCost(m.cost, cur)} />
{b && (
<Row
label={t("usage.providerMonth")}
value={
<span style={{ color: b.warning ? "var(--accent2)" : undefined }}>
{fmtCost(b.spent, budget!.currency)}
{b.budget ? ` / ${fmtCost(b.budget, budget!.currency)}` : ""}
</span>
}
/>
)}
</div>
</div>
</div>
);
})}
{data.by_agent.length > 1 && (
<div className="panel">
<h3>{t("usage.byAgent")}</h3>
<table className="grid">
<thead>
<tr>
<th>{t("stats.agent")}</th>
<th className="num">{t("stats.requests")}</th>
<th className="num">{t("stats.tokens")}</th>
<th className="num">{t("stats.cost")}</th>
</tr>
</thead>
<tbody>
{data.by_agent.map((a) => (
<tr key={a.key}>
<td>{a.key}</td>
<td className="num">{fmtNumber(a.requests)}</td>
<td className="num">{fmtCompact(a.input_tokens + a.cache_read_tokens + a.cache_write_tokens + a.output_tokens)}</td>
<td className="num">{fmtCost(a.cost, cur)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
);
}
+3 -2
View File
@@ -4,6 +4,7 @@ import { useFetch } from "../../hooks";
import { useT } from "../../i18n";
import type { AgentInfo } from "../../types";
import { ModelSelect } from "../ModelSelect";
import { confirmDialog, promptDialog, toast } from "../../dialogs";
type Patch = { path: string[]; value: unknown };
@@ -45,7 +46,7 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
};
const reset = async () => {
if (!confirm(t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }))) return;
if (!(await confirmDialog({ message: t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }), danger: true }))) return;
try {
await patch("/config", { patches: [{ path: ["agents", a.name], value: null }] });
onSaved(true, t("settings.saved"));
@@ -135,7 +136,7 @@ export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<st
const add = async (template: "writer" | "blank") => {
const n = name.trim() || (template === "writer" ? "writer" : "");
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 =
template === "writer"
? { mode: "subagent", description: "Writes documentation, summaries, guides and translations.", tools: ["read", "glob", "grep"], max_parallel: 1, prompt: WRITER_PROMPT }
+73
View File
@@ -0,0 +1,73 @@
import { useState } from "react";
import { useFetch } from "../../hooks";
import { fmtDate, useT } from "../../i18n";
interface Entry {
ts: number;
user: string;
action: string;
detail: string;
ip: string;
}
/** Admin: audit log and backup download. */
export function Audit() {
const t = useT();
const [filter, setFilter] = useState("");
const { data, reload } = useFetch<Entry[]>(`/audit?limit=500${filter ? `&action=${encodeURIComponent(filter)}` : ""}`);
return (
<>
<div className="panel">
<h3>{t("audit.backup")}</h3>
<div style={{ fontSize: 13, color: "var(--fg3)", marginBottom: 8 }}>{t("audit.backupHint")}</div>
<a className="button primary" href="/api/backup">
{t("audit.download")}
</a>
</div>
<div className="panel">
<h3>
{t("audit.title")}
<span className="spacer" />
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="">{t("audit.all")}</option>
{["login", "permission", "config", "user", "folder", "session", "git", "backup"].map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
<button className="small" onClick={reload}>
↻
</button>
</h3>
<div style={{ overflowX: "auto" }}>
<table className="grid">
<thead>
<tr>
<th>{t("audit.time")}</th>
<th>{t("stats.user")}</th>
<th>{t("audit.action")}</th>
<th>{t("audit.detail")}</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{data?.map((e, i) => (
<tr key={i} className={e.action.includes("failed") || e.action.includes("blocked") || e.action.includes("reject") ? "audit-warn" : ""}>
<td style={{ whiteSpace: "nowrap" }}>{fmtDate(e.ts)}</td>
<td>{e.user || "–"}</td>
<td>
<code>{e.action}</code>
</td>
<td style={{ wordBreak: "break-word" }}>{e.detail}</td>
<td>{e.ip}</td>
</tr>
))}
</tbody>
</table>
{data?.length === 0 && <div className="empty">–</div>}
</div>
</div>
</>
);
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { del, post, put } from "../../api";
import { useFetch } from "../../hooks";
import { useT } from "../../i18n";
import type { User } from "../../types";
import { confirmDialog, promptDialog, toast } from "../../dialogs";
interface Folder {
id: string;
@@ -44,7 +45,7 @@ export function Folders() {
<h3>
📁 <code>{f.path.slice(ws.length) || "/"}</code>
<span className="spacer" />
<button className="small danger" onClick={() => confirm(t("folders.removeConfirm")) && run(() => del(`/folders/${f.id}`))}>
<button className="small danger" onClick={() => void confirmDialog({ message: t("folders.removeConfirm"), danger: true, confirmLabel: t("folders.remove") }).then((ok): unknown => ok && run(() => del(`/folders/${f.id}`)))}>
{t("folders.remove")}
</button>
</h3>
+42
View File
@@ -0,0 +1,42 @@
import { useEffect } from "react";
import { post } from "../../api";
import { useFetch } from "../../hooks";
import { useT } from "../../i18n";
import { Spinner } from "../Activity";
import { confirmDialog, promptDialog, toast } from "../../dialogs";
interface ToolsStatus {
ghidra: { installed: boolean; version?: string | null; running: boolean; log: string };
}
/** Admin: optional tools installed into the data volume. */
export function Tools() {
const t = useT();
const { data, reload } = useFetch<ToolsStatus>("/tools");
// Poll while an installation runs.
useEffect(() => {
if (!data?.ghidra.running) return;
const h = setInterval(reload, 3000);
return () => clearInterval(h);
}, [data?.ghidra.running, reload]);
const g = data?.ghidra;
return (
<div className="panel">
<h3>
Ghidra
{g?.installed && <span className="chip">{g.version ?? t("tools.installed")}</span>}
</h3>
<div style={{ fontSize: 13, color: "var(--fg3)", marginBottom: 10 }}>{t("tools.ghidraHint")}</div>
{g?.running ? (
<div className="row">
<Spinner /> {t("tools.installing")}
</div>
) : (
<button className="primary" onClick={() => post("/tools/ghidra/install").then(reload).catch((e) => toast(e.message))}>
{g?.installed ? t("tools.reinstall") : t("tools.install")}
</button>
)}
{g?.log && <pre className="git-output" style={{ marginTop: 10 }}>{g.log}</pre>}
</div>
);
}
+18 -3
View File
@@ -4,6 +4,7 @@ import { useFetch } from "../../hooks";
import { fmtDate, useT } from "../../i18n";
import { useStore } from "../../store";
import type { User } from "../../types";
import { confirmDialog, promptDialog, toast } from "../../dialogs";
export function Users() {
const t = useT();
@@ -22,8 +23,8 @@ export function Users() {
}
};
const resetPassword = (u: User) => {
const pw = prompt(t("users.newPasswordFor", { name: u.username }));
const resetPassword = async (u: User) => {
const pw = await promptDialog({ message: t("users.newPasswordFor", { name: u.username }), password: true });
if (pw) void run(() => patch(`/users/${u.id}`, { password: pw }));
};
@@ -39,6 +40,7 @@ export function Users() {
<th>{t("users.displayName")}</th>
<th>{t("users.role")}</th>
<th>{t("users.login")}</th>
<th>{t("users.quota")}</th>
<th>{t("users.created")}</th>
<th />
</tr>
@@ -60,6 +62,19 @@ export function Users() {
<td>
{u.has_password && <span className="chip">{t("users.password")}</span>} {u.oidc && <span className="chip">SSO</span>}
</td>
<td>
<button
className="small ghost"
title={t("users.quotaHint")}
onClick={async () => {
const v = await promptDialog({ message: t("users.quotaPrompt", { name: u.username }), defaultValue: u.monthly_quota != null ? String(u.monthly_quota) : "" });
if (v === null) return;
void run(() => patch(`/users/${u.id}`, { monthly_quota: v.trim() === "" ? -1 : Number(v.replace(",", ".")) }));
}}
>
{u.monthly_quota != null ? u.monthly_quota : "∞"}
</button>
</td>
<td>{fmtDate(u.created_at, false)}</td>
<td>
<div className="row" style={{ flexWrap: "nowrap" }}>
@@ -70,7 +85,7 @@ export function Users() {
{u.disabled ? t("users.enable") : t("users.disable")}
</button>
{u.id !== me?.id && (
<button className="small danger" onClick={() => confirm(t("users.deleteConfirm", { name: u.username })) && run(() => del(`/users/${u.id}`))}>
<button className="small danger" onClick={() => void confirmDialog({ message: t("users.deleteConfirm", { name: u.username }), danger: true, confirmLabel: t("common.delete") }).then((ok): unknown => ok && run(() => del(`/users/${u.id}`)))}>
{t("common.delete")}
</button>
)}