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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"] })}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user