Users, file manager, image generation, budgets and agent workflow
- User management: local accounts (Argon2) and OpenID Connect, admin/user roles, per-user folders plus admin-shared folders, read-only session sharing, event filtering per user - File manager with CodeMirror editor and git client (status, diff, stage, commit, branches, fetch/pull/push, log, clone/init) - Image generation via fal.ai: generate_image tool and image page - Budgets: monthly limits per provider, provider balances/costs (OpenRouter, Anthropic admin cost report, fal.ai), price discovery - Subagents on other models, background tasks, concurrency limits per host/model/subagent - MCP tools loaded on demand (mcp_search/mcp_call), read-only hints - Permission mode "auto", safe read-only commands always allowed - Persistent message queue with attachments, deletable - Live reasoning incl. <think> tags, activity animations, CSS themes, mobile layout, statistics per user, upload size handling - Docker: named volume for state, localhost-bound port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3a2fe3816
commit
49262c612c
70 files changed
+8495
-714
No files matched your search
@@ -0,0 +1,80 @@
|
||||
import { useEffect } from "react";
|
||||
import { 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() {
|
||||
return (
|
||||
<span className="dots" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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. */
|
||||
export function ActivityLine({
|
||||
draft,
|
||||
results,
|
||||
waiting,
|
||||
permissionPending,
|
||||
}: {
|
||||
draft?: Draft;
|
||||
results: Map<string, ToolResult>;
|
||||
waiting?: string;
|
||||
permissionPending: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
let label: string;
|
||||
if (permissionPending) {
|
||||
label = t("activity.waitingForYou");
|
||||
} else 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 ${permissionPending ? "paused" : ""}`} role="status" aria-live="polite">
|
||||
{permissionPending ? <span className="pause-icon" aria-hidden="true" /> : <Dots />}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Thin animated bar shown under the top bar while a session works. */
|
||||
export function WorkBar({ active }: { active: boolean }) {
|
||||
return <div className={`workbar ${active ? "on" : ""}`} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
/** Prefix the browser tab title while agents work or wait for input. */
|
||||
export function useTitleActivity() {
|
||||
const t = useT();
|
||||
const busyCount = useStore((s) => s.sessions.filter((x) => s.busy[x.id]).length);
|
||||
const waiting = useStore((s) => s.permissions.length + s.questions.length);
|
||||
useEffect(() => {
|
||||
const base = "CodeCraft";
|
||||
if (waiting > 0) document.title = `● ${t("activity.titleInput")} – ${base}`;
|
||||
else if (busyCount > 0) document.title = `⏳ ${t("activity.titleBusy", { n: busyCount })} – ${base}`;
|
||||
else document.title = base;
|
||||
}, [busyCount, waiting, t]);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useFetch } from "../hooks";
|
||||
import { fmtCost, fmtPercent, useT } from "../i18n";
|
||||
|
||||
interface BudgetRow {
|
||||
provider: string;
|
||||
budget: number | null;
|
||||
spent: number;
|
||||
remaining: number | null;
|
||||
share: number | null;
|
||||
warning: boolean;
|
||||
exceeded: boolean;
|
||||
images?: boolean;
|
||||
remote?: { currency?: string; remaining?: number | null; limit?: number | null; used?: number | null; spent_month?: number; scope?: string; error?: string };
|
||||
}
|
||||
|
||||
interface BudgetData {
|
||||
currency: string;
|
||||
total: BudgetRow;
|
||||
providers: BudgetRow[];
|
||||
}
|
||||
|
||||
function Bar({ share, warning, exceeded }: { share: number; warning: boolean; exceeded: boolean }) {
|
||||
const color = exceeded ? "var(--red)" : warning ? "var(--accent2)" : "var(--green)";
|
||||
return (
|
||||
<div className="budget-bar" role="meter" aria-valuenow={Math.round(share * 100)} aria-valuemin={0} aria-valuemax={100}>
|
||||
<div style={{ width: `${Math.min(share, 1) * 100}%`, background: color }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Remote({ r, t }: { r: BudgetRow["remote"]; t: ReturnType<typeof useT> }) {
|
||||
if (!r) return <span className="muted">–</span>;
|
||||
if (r.error) return <span className="muted" title={r.error}>⚠ {t("budget.remoteError")}</span>;
|
||||
const cur = r.currency ?? "USD";
|
||||
if (r.spent_month !== undefined) return <span title={t("budget.orgWide")}>{t("budget.providerSpent", { v: fmtCost(r.spent_month, cur) })}</span>;
|
||||
if (r.remaining !== null && r.remaining !== undefined) return <span>{t("budget.balance", { v: fmtCost(r.remaining, cur) })}</span>;
|
||||
if (r.used !== null && r.used !== undefined) return <span>{t("budget.providerSpent", { v: fmtCost(r.used, cur) })}</span>;
|
||||
return <span className="muted">–</span>;
|
||||
}
|
||||
|
||||
/** Monthly budgets per provider (admins). */
|
||||
export function BudgetPanel() {
|
||||
const t = useT();
|
||||
const { data, error } = useFetch<BudgetData>("/budget");
|
||||
if (error || !data) return null;
|
||||
const rows = [data.total, ...data.providers];
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>{t("budget.title")}</h3>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("stats.provider")}</th>
|
||||
<th className="num">{t("budget.spent")}</th>
|
||||
<th className="num">{t("budget.budget")}</th>
|
||||
<th style={{ minWidth: 140 }} />
|
||||
<th className="num">{t("budget.remaining")}</th>
|
||||
<th>{t("budget.provider")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.provider}>
|
||||
<td>
|
||||
{r.provider === "total" ? <b>{t("stats.total")}</b> : r.provider}
|
||||
{r.images && <span className="chip" style={{ marginLeft: 6 }}>{t("nav.images")}</span>}
|
||||
</td>
|
||||
<td className="num">{fmtCost(r.spent, data.currency)}</td>
|
||||
<td className="num">{r.budget ? fmtCost(r.budget, data.currency) : "–"}</td>
|
||||
<td>{r.share !== null && <Bar share={r.share} warning={r.warning} exceeded={r.exceeded} />}</td>
|
||||
<td className="num" style={{ color: r.exceeded ? "var(--red)" : r.warning ? "var(--accent2)" : undefined }}>
|
||||
{r.remaining !== null ? fmtCost(r.remaining, data.currency) : "–"}
|
||||
{r.share !== null && <span className="muted"> ({fmtPercent(r.share)})</span>}
|
||||
</td>
|
||||
<td>{r.provider === "total" ? null : <Remote r={r.remote} t={t} />}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 6 }}>{t("budget.hint")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small header chip when the overall budget is nearly used up. */
|
||||
export function BudgetChip() {
|
||||
const t = useT();
|
||||
const { data } = useFetch<BudgetData>("/budget");
|
||||
const worst = data ? [data.total, ...data.providers].filter((r) => r.warning).sort((a, b) => (b.share ?? 0) - (a.share ?? 0))[0] : undefined;
|
||||
if (!worst) return null;
|
||||
return (
|
||||
<span className="chip warn hide-mobile" title={t("budget.chipHint")}>
|
||||
{t("budget.chip", { name: worst.provider === "total" ? t("stats.total") : worst.provider, p: fmtPercent(worst.share ?? 0) })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -190,7 +190,9 @@ export function Heatmap({
|
||||
const [tip, setTip] = useState<Tip>();
|
||||
const left = 40;
|
||||
const top = 18;
|
||||
const cell = Math.max(Math.floor((width - left) / 24), 8);
|
||||
// Cells stretch with the width but stay at most 26 px tall.
|
||||
const cellW = Math.max(Math.floor((width - left) / 24), 8);
|
||||
const cell = Math.min(cellW, 26);
|
||||
const max = Math.max(...cells, 0);
|
||||
const order = Array.from({ length: 7 }, (_, i) => (firstDay + i) % 7);
|
||||
const bin = (v: number) => (v <= 0 || max === 0 ? 0 : Math.min(6, 1 + Math.floor((v / max) * 5.999)));
|
||||
@@ -200,7 +202,7 @@ export function Heatmap({
|
||||
<svg height={top + cell * 7 + 4} role="img" aria-label={t("stats.heatmap")}>
|
||||
<g className="axis">
|
||||
{[0, 3, 6, 9, 12, 15, 18, 21].map((h) => (
|
||||
<text key={h} x={left + h * cell + cell / 2} y={12} textAnchor="middle">
|
||||
<text key={h} x={left + h * cellW + cellW / 2} y={12} textAnchor="middle">
|
||||
{h}
|
||||
</text>
|
||||
))}
|
||||
@@ -216,9 +218,9 @@ export function Heatmap({
|
||||
return (
|
||||
<rect
|
||||
key={`${d}-${h}`}
|
||||
x={left + h * cell + 1}
|
||||
x={left + h * cellW + 1}
|
||||
y={top + row * cell + 1}
|
||||
width={cell - 2}
|
||||
width={cellW - 2}
|
||||
height={cell - 2}
|
||||
rx={3}
|
||||
fill={SEQ[bin(v)]}
|
||||
|
||||
+112
-20
@@ -5,7 +5,12 @@ import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { dismissError, loadSession, useStore } from "../store";
|
||||
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
|
||||
import { ActivityLine, Dots, WorkBar } from "./Activity";
|
||||
import { useReasoningMode } from "../prefs";
|
||||
import { Composer } from "./Composer";
|
||||
import { ShareDialog } from "./ShareDialog";
|
||||
import { QueueList } from "./QueueList";
|
||||
import { BudgetChip } from "./Budget";
|
||||
import { PermissionCard, QuestionCard } from "./Interaction";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { TodoList, ToolCard } from "./ToolCard";
|
||||
@@ -22,12 +27,29 @@ interface SessionStats {
|
||||
|
||||
function Reasoning({ text, live }: { text: string; live: boolean }) {
|
||||
const t = useT();
|
||||
if (!text.trim()) return null;
|
||||
const mode = useReasoningMode();
|
||||
const [toggled, setToggled] = useState<boolean | null>(null);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const open = toggled ?? (mode === "expanded" || (mode === "live" && live));
|
||||
// Follow the stream while the model is thinking.
|
||||
useLayoutEffect(() => {
|
||||
if (live && bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
||||
}, [text, live]);
|
||||
if (mode === "hidden" || !text.trim()) return null;
|
||||
const words = text.trim().split(/\s+/).length;
|
||||
return (
|
||||
<details className="reasoning" open={live}>
|
||||
<summary>{t("chat.reasoning")}</summary>
|
||||
<div className="body">{text}</div>
|
||||
</details>
|
||||
<div className={`reasoning ${live ? "live" : ""}`}>
|
||||
<button className="reasoning-head" onClick={() => setToggled(!open)} aria-expanded={open}>
|
||||
{live ? <Dots /> : <span aria-hidden="true">💭</span>}
|
||||
<span>{live ? t("chat.reasoningLive") : t("chat.reasoningDone", { n: words })}</span>
|
||||
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div ref={bodyRef} className={`body ${live ? "streaming-text" : ""}`}>
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +59,11 @@ function AssistantParts({ parts, results, running, live }: { parts: Part[]; resu
|
||||
{parts.map((p, i) => {
|
||||
switch (p.type) {
|
||||
case "text":
|
||||
return <Markdown key={i} text={p.text} />;
|
||||
return (
|
||||
<div key={i} className={live && i === parts.length - 1 ? "streaming" : undefined}>
|
||||
<Markdown text={p.text} />
|
||||
</div>
|
||||
);
|
||||
case "reasoning":
|
||||
return <Reasoning key={i} text={p.text} live={live && i === parts.length - 1} />;
|
||||
case "tool_call":
|
||||
@@ -50,6 +76,32 @@ function AssistantParts({ parts, results, running, live }: { parts: Part[]; resu
|
||||
);
|
||||
}
|
||||
|
||||
const BG_RE = /^<background-task-result agent="([^"]*)" session="([^"]*)" description="([^"]*)">\n?([\s\S]*?)\n?<\/background-task-result>$/;
|
||||
|
||||
/** Result of a background subagent, delivered as a message. */
|
||||
function BackgroundResult({ text }: { text: string }) {
|
||||
const t = useT();
|
||||
const m = text.match(BG_RE);
|
||||
if (!m) return null;
|
||||
const [, agent, session, description, body] = m;
|
||||
return (
|
||||
<details className="tool" style={{ borderColor: "var(--aqua)" }}>
|
||||
<summary>
|
||||
<span className="tool-name">{agent}</span>
|
||||
<span className="tool-title">
|
||||
{t("chat.backgroundDone")}: {description}
|
||||
</span>
|
||||
<a href={`#/s/${session}`} onClick={(e) => e.stopPropagation()}>
|
||||
{t("tool.openSubagent")}
|
||||
</a>
|
||||
</summary>
|
||||
<div className="tool-body">
|
||||
<Markdown text={body} />
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsPanel({ stats }: { stats: SessionStats }) {
|
||||
const t = useT();
|
||||
const a = stats.total;
|
||||
@@ -99,6 +151,10 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
const errors = useStore((s) => s.errors[sessionId]);
|
||||
const allPermissions = useStore((s) => s.permissions);
|
||||
const allQuestions = useStore((s) => s.questions);
|
||||
const waiting = useStore((s) => s.waiting[sessionId]);
|
||||
const readOnly = useStore((s) => !!s.readOnly[sessionId]);
|
||||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const models = useModels();
|
||||
const [loadError, setLoadError] = useState<string>();
|
||||
const [text, setText] = useState("");
|
||||
@@ -210,24 +266,39 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
<div className="path">{session.cwd}</div>
|
||||
</div>
|
||||
{ctxShare !== undefined && (
|
||||
<span className={`chip ${ctxShare > 0.7 ? "warn" : ""}`} title={t("chat.contextHint", { tokens: fmtNumber(ctxTokens) })}>
|
||||
<span className={`chip hide-mobile ${ctxShare > 0.7 ? "warn" : ""}`} title={t("chat.contextHint", { tokens: fmtNumber(ctxTokens) })}>
|
||||
{t("chat.context")} {fmtPercent(ctxShare)}
|
||||
</span>
|
||||
)}
|
||||
{stats && (
|
||||
<button className="chip" style={{ border: "none" }} onClick={() => setShowStats((x) => !x)} title={t("chat.statsHint")}>
|
||||
{fmtCompact(stats.total.input_tokens + stats.total.cache_read_tokens + stats.total.cache_write_tokens)}↑{" "}
|
||||
{fmtCompact(stats.total.output_tokens)}↓ · {fmtCost(stats.total.cost, stats.currency)}
|
||||
<span className="hide-mobile">
|
||||
{fmtCompact(stats.total.input_tokens + stats.total.cache_read_tokens + stats.total.cache_write_tokens)}↑{" "}
|
||||
{fmtCompact(stats.total.output_tokens)}↓ ·{" "}
|
||||
</span>
|
||||
{fmtCost(stats.total.cost, stats.currency)}
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && <BudgetChip />}
|
||||
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
|
||||
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => alert(e.message))} disabled={busy} title={t("command.compact")}>
|
||||
⇊
|
||||
</button>
|
||||
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
|
||||
🗑
|
||||
</button>
|
||||
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
|
||||
{!readOnly && (
|
||||
<>
|
||||
{!session.parent_id && (
|
||||
<button className="small ghost" onClick={() => setSharing(true)} title={t("share.title")}>
|
||||
⇄
|
||||
</button>
|
||||
)}
|
||||
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => alert(e.message))} disabled={busy} title={t("command.compact")}>
|
||||
⇊
|
||||
</button>
|
||||
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
|
||||
🗑
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
|
||||
<div
|
||||
className="chat-scroll"
|
||||
ref={scrollRef}
|
||||
@@ -256,13 +327,26 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
</details>
|
||||
);
|
||||
}
|
||||
const texts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
||||
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
||||
const background = allTexts.filter((p) => BG_RE.test(p.text));
|
||||
const texts = allTexts.filter((p) => !BG_RE.test(p.text));
|
||||
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
||||
if (texts.length === 0 && imgs.length === 0) return null;
|
||||
if (texts.length === 0 && imgs.length === 0) {
|
||||
return background.length ? (
|
||||
<div key={m.id} className="msg">
|
||||
{background.map((p, i) => (
|
||||
<BackgroundResult key={i} text={p.text} />
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<div key={m.id} className="msg">
|
||||
{background.map((p, i) => (
|
||||
<BackgroundResult key={i} text={p.text} />
|
||||
))}
|
||||
<div className="msg-user">
|
||||
{!busy && (
|
||||
{!busy && !readOnly && (
|
||||
<div className="msg-actions">
|
||||
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
|
||||
✎
|
||||
@@ -286,8 +370,10 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
<AssistantParts parts={draft.parts} results={results} running={busy} live />
|
||||
</div>
|
||||
)}
|
||||
{busy && !draft && <div className="msg-meta">{t("chat.thinking")}</div>}
|
||||
{openTodos.length > 0 && (
|
||||
{(busy || waiting) && (
|
||||
<ActivityLine draft={draft} results={results} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
|
||||
)}
|
||||
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
|
||||
<div className="todos">
|
||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
|
||||
<TodoList todos={todos} />
|
||||
@@ -322,6 +408,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
||||
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
|
||||
{readOnly ? (
|
||||
<div className="readonly-bar">{t("chat.readOnlyHint")}</div>
|
||||
) : (
|
||||
<Composer
|
||||
session={session}
|
||||
busy={busy}
|
||||
@@ -331,6 +422,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
onAbort={() => post(`/sessions/${sessionId}/abort`)}
|
||||
onUpdate={update}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { basicSetup } from "codemirror";
|
||||
import { Compartment, EditorState } from "@codemirror/state";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { HighlightStyle, LanguageDescription, syntaxHighlighting } from "@codemirror/language";
|
||||
import { languages } from "@codemirror/language-data";
|
||||
import { indentWithTab } from "@codemirror/commands";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
// Colours come from the theme variables, so every CSS theme applies.
|
||||
const theme = EditorView.theme({
|
||||
"&": { height: "100%", backgroundColor: "var(--bg0)", color: "var(--fg1)", fontSize: "13px" },
|
||||
".cm-content": { fontFamily: "var(--mono)", caretColor: "var(--accent)" },
|
||||
".cm-gutters": { backgroundColor: "var(--bg1)", color: "var(--muted)", border: "none", borderRight: "1px solid var(--bg2)" },
|
||||
".cm-activeLine": { backgroundColor: "color-mix(in srgb, var(--bg2) 55%, transparent)" },
|
||||
".cm-activeLineGutter": { backgroundColor: "var(--bg2)" },
|
||||
"&.cm-focused .cm-cursor": { borderLeftColor: "var(--accent)" },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": { backgroundColor: "color-mix(in srgb, var(--blue) 35%, transparent) !important" },
|
||||
".cm-searchMatch": { backgroundColor: "color-mix(in srgb, var(--accent) 35%, transparent)" },
|
||||
".cm-panels": { backgroundColor: "var(--bg1)", color: "var(--fg1)" },
|
||||
".cm-tooltip": { backgroundColor: "var(--bg1)", border: "1px solid var(--bg3)" },
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
});
|
||||
|
||||
const highlight = HighlightStyle.define([
|
||||
{ tag: [tags.keyword, tags.controlKeyword, tags.operatorKeyword, tags.modifier], color: "var(--syntax-keyword)" },
|
||||
{ tag: [tags.string, tags.special(tags.string), tags.regexp], color: "var(--syntax-string)" },
|
||||
{ tag: [tags.number, tags.bool, tags.null, tags.atom], color: "var(--syntax-number)" },
|
||||
{ tag: [tags.comment, tags.lineComment, tags.blockComment], color: "var(--syntax-comment)", fontStyle: "italic" },
|
||||
{ tag: [tags.function(tags.variableName), tags.function(tags.propertyName), tags.heading], color: "var(--syntax-function)" },
|
||||
{ tag: [tags.typeName, tags.className, tags.tagName, tags.namespace], color: "var(--syntax-type)" },
|
||||
{ tag: [tags.variableName, tags.propertyName, tags.attributeName], color: "var(--syntax-variable)" },
|
||||
{ tag: [tags.meta, tags.processingInstruction, tags.annotation, tags.link], color: "var(--syntax-meta)" },
|
||||
{ tag: tags.strong, fontWeight: "bold" },
|
||||
{ tag: tags.emphasis, fontStyle: "italic" },
|
||||
]);
|
||||
|
||||
export function CodeEditor({
|
||||
value,
|
||||
filename,
|
||||
readOnly,
|
||||
onChange,
|
||||
onSave,
|
||||
}: {
|
||||
value: string;
|
||||
filename: string;
|
||||
readOnly?: boolean;
|
||||
onChange: (v: string) => void;
|
||||
onSave?: () => void;
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const view = useRef<EditorView | null>(null);
|
||||
const lang = useRef(new Compartment());
|
||||
const ro = useRef(new Compartment());
|
||||
const cb = useRef({ onChange, onSave });
|
||||
cb.current = { onChange, onSave };
|
||||
|
||||
// Create the editor once per file.
|
||||
useEffect(() => {
|
||||
const v = new EditorView({
|
||||
parent: host.current!,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
keymap.of([indentWithTab, { key: "Mod-s", preventDefault: true, run: () => (cb.current.onSave?.(), true) }]),
|
||||
theme,
|
||||
syntaxHighlighting(highlight),
|
||||
lang.current.of([]),
|
||||
ro.current.of(EditorState.readOnly.of(!!readOnly)),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((u) => u.docChanged && cb.current.onChange(u.state.doc.toString())),
|
||||
],
|
||||
}),
|
||||
});
|
||||
view.current = v;
|
||||
const desc = LanguageDescription.matchFilename(languages, filename);
|
||||
desc?.load().then((support) => v.dispatch({ effects: lang.current.reconfigure(support) }));
|
||||
return () => v.destroy();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filename]);
|
||||
|
||||
// External content changes (reload) replace the document.
|
||||
useEffect(() => {
|
||||
const v = view.current;
|
||||
if (v && v.state.doc.toString() !== value) {
|
||||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
view.current?.dispatch({ effects: ro.current.reconfigure(EditorState.readOnly.of(!!readOnly)) });
|
||||
}, [readOnly]);
|
||||
|
||||
return <div className="code-editor" ref={host} />;
|
||||
}
|
||||
@@ -14,16 +14,21 @@ const BUILTIN: { name: string; key: Key }[] = [
|
||||
{ name: "new", key: "command.new" },
|
||||
];
|
||||
|
||||
function readImage(file: File): Promise<ImageData> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
const url = String(r.result);
|
||||
resolve({ media_type: file.type || "image/png", data: url.slice(url.indexOf(",") + 1) });
|
||||
};
|
||||
r.onerror = reject;
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
const MAX_SIDE = 2000;
|
||||
|
||||
/** Read an image and downscale it: models don't use more detail, and uploads stay small. */
|
||||
async function readImage(file: File): Promise<ImageData> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const scale = Math.min(1, MAX_SIDE / Math.max(bitmap.width, bitmap.height));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.round(bitmap.width * scale);
|
||||
canvas.height = Math.round(bitmap.height * scale);
|
||||
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
bitmap.close();
|
||||
// PNG keeps screenshots crisp; photos become JPEG.
|
||||
const type = file.type === "image/png" && file.size < 1_500_000 ? "image/png" : "image/jpeg";
|
||||
const url = canvas.toDataURL(type, 0.85);
|
||||
return { media_type: type, data: url.slice(url.indexOf(",") + 1) };
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
@@ -97,8 +102,12 @@ export function Composer({
|
||||
};
|
||||
|
||||
const addFiles = async (files: FileList | File[]) => {
|
||||
const imgs = await Promise.all([...files].filter((f) => f.type.startsWith("image/")).map(readImage));
|
||||
setImages((cur) => [...cur, ...imgs]);
|
||||
try {
|
||||
const imgs = await Promise.all([...files].filter((f) => f.type.startsWith("image/")).map(readImage));
|
||||
setImages((cur) => [...cur, ...imgs]);
|
||||
} catch (e: any) {
|
||||
alert(e.message ?? String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -163,6 +172,7 @@ export function Composer({
|
||||
aria-label={t("composer.placeholder")}
|
||||
/>
|
||||
<div className="composer-bar">
|
||||
<div className="composer-options">
|
||||
<div className="mode-tabs" role="tablist" aria-label={t("composer.mode")}>
|
||||
{agents.map((a) => (
|
||||
<button
|
||||
@@ -182,6 +192,7 @@ export function Composer({
|
||||
onChange={(e) => onUpdate({ permission_mode: e.target.value as Session["permission_mode"] })}
|
||||
title={t("composer.permissionMode")}
|
||||
>
|
||||
<option value="auto">{t("permissionMode.auto")}</option>
|
||||
<option value="default">{t("permissionMode.default")}</option>
|
||||
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
|
||||
<option value="bypass">{t("permissionMode.bypass")}</option>
|
||||
@@ -191,7 +202,8 @@ export function Composer({
|
||||
📎
|
||||
<input type="file" accept="image/*" multiple hidden onChange={(e) => e.target.files && addFiles(e.target.files)} />
|
||||
</label>
|
||||
<span className="grow" />
|
||||
</div>
|
||||
<div className="composer-actions">
|
||||
{busy && (
|
||||
<button className="danger" onClick={onAbort} title="Esc">
|
||||
■ {t("composer.stop")}
|
||||
@@ -200,6 +212,7 @@ export function Composer({
|
||||
<button className="primary" onClick={send} disabled={!text.trim() && images.length === 0}>
|
||||
{busy ? t("composer.queue") : t("composer.send")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export function Diff({ diff }: { diff: string }) {
|
||||
const lines = diff.split("\n");
|
||||
const lines = diff.replace(/\n+$/, "").split("\n");
|
||||
return (
|
||||
<div className="diff">
|
||||
{lines.map((l, i) => {
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { del, get, post, put } from "../api";
|
||||
import { useFetch } from "../hooks";
|
||||
import { fmtCompact, fmtDate, useT } from "../i18n";
|
||||
import { Spinner } from "./Activity";
|
||||
import { Diff } from "./Diff";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { GitPanel, type GitStatus } from "./GitPanel";
|
||||
|
||||
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
|
||||
|
||||
interface Entry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
size: number;
|
||||
mtime?: number;
|
||||
}
|
||||
|
||||
interface Listing {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
entries: Entry[];
|
||||
repo: string | null;
|
||||
}
|
||||
|
||||
interface OpenFile {
|
||||
path: string;
|
||||
content: string;
|
||||
saved: string;
|
||||
mtime?: number;
|
||||
binary: boolean;
|
||||
tooLarge?: boolean;
|
||||
}
|
||||
|
||||
/** 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 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(/\/[^/]+\/?$/, "") || "/";
|
||||
|
||||
function icon(e: Entry) {
|
||||
if (e.is_dir) return "📁";
|
||||
if (IMAGE.test(e.name)) return "🖼";
|
||||
if (/\.(md|txt)$/i.test(e.name)) return "📝";
|
||||
return "📄";
|
||||
}
|
||||
|
||||
export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
const t = useT();
|
||||
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
||||
const [dir, setDir] = useState<string>(() => localStorage.getItem("codecraft.filesDir") ?? "");
|
||||
const [listing, setListing] = useState<Listing>();
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [pane, setPane] = useState<Pane>({ kind: "none" });
|
||||
const [tab, setTab] = useState<"files" | "git">("files");
|
||||
const [git, setGit] = useState<GitStatus>();
|
||||
const [search, setSearch] = useState("");
|
||||
const [hits, setHits] = useState<{ path: string; is_dir: boolean }[]>();
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [mobileEditor, setMobileEditor] = useState(false);
|
||||
const uploadRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(
|
||||
async (path: string) => {
|
||||
try {
|
||||
const l = await get<Listing>(`/files/list?path=${encodeURIComponent(path)}&hidden=${hidden}`);
|
||||
setListing(l);
|
||||
setDir(l.path);
|
||||
localStorage.setItem("codecraft.filesDir", l.path);
|
||||
setError(undefined);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
if (roots[0] && path !== roots[0].path) void load(roots[0].path);
|
||||
}
|
||||
},
|
||||
[hidden, roots],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (roots.length) void load(dir || roots[0].path);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [roots.length, hidden]);
|
||||
|
||||
const refreshGit = useCallback(async () => {
|
||||
if (!listing?.repo) return setGit(undefined);
|
||||
try {
|
||||
setGit(await post<GitStatus>("/git", { path: listing.repo, action: "status" }));
|
||||
} catch {
|
||||
setGit(undefined);
|
||||
}
|
||||
}, [listing?.repo]);
|
||||
useEffect(() => void refreshGit(), [refreshGit]);
|
||||
|
||||
// Git state per file name in the current directory.
|
||||
const gitMarks = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
if (!git || !listing) return m;
|
||||
for (const f of git.files) {
|
||||
const abs = `${git.repo}/${f.path}`;
|
||||
const code = f.index === "?" ? "U" : (f.index.trim() || f.worktree.trim());
|
||||
// Mark the file itself and every parent folder inside the listing.
|
||||
if (dirName(abs) === listing.path) m.set(abs, code);
|
||||
for (const e of listing.entries) if (e.is_dir && abs.startsWith(e.path + "/")) m.set(e.path, "•");
|
||||
}
|
||||
return m;
|
||||
}, [git, listing]);
|
||||
|
||||
const dirty = pane.kind === "file" && pane.file.content !== pane.file.saved;
|
||||
|
||||
const confirmDiscard = () => !dirty || confirm(t("files.discardChanges"));
|
||||
|
||||
const openFile = async (path: string) => {
|
||||
if (!confirmDiscard()) 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));
|
||||
setMobileEditor(true);
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const save = useCallback(
|
||||
async (force = false) => {
|
||||
if (pane.kind !== "file") return;
|
||||
const f = pane.file;
|
||||
try {
|
||||
const r = await put<{ mtime: number }>("/files/write", { path: f.path, content: f.content, expected_mtime: f.mtime, force });
|
||||
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);
|
||||
}
|
||||
},
|
||||
[pane, refreshGit, t],
|
||||
);
|
||||
|
||||
const act = async (fn: () => Promise<unknown>) => {
|
||||
try {
|
||||
await fn();
|
||||
if (listing) await load(listing.path);
|
||||
void refreshGit();
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const newEntry = (isDir: boolean) => {
|
||||
const name = prompt(isDir ? t("files.newFolderName") : t("files.newFileName"));
|
||||
if (!name || !listing) return;
|
||||
const path = `${listing.path}/${name}`;
|
||||
void act(async () => {
|
||||
await post("/files/create", { path, dir: isDir });
|
||||
if (!isDir) await openFile(path);
|
||||
});
|
||||
};
|
||||
|
||||
const rename = (e: Entry) => {
|
||||
const name = prompt(t("files.renameTo"), 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")}`);
|
||||
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 }))) {
|
||||
void act(() => del(`/files/delete?path=${encodeURIComponent(e.path)}`));
|
||||
if (pane.kind === "file" && pane.file.path.startsWith(e.path)) setPane({ kind: "none" });
|
||||
}
|
||||
};
|
||||
|
||||
const upload = async (files: FileList | File[]) => {
|
||||
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" : ""}`);
|
||||
}
|
||||
await load(listing.path);
|
||||
void refreshGit();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!search.trim() || !listing) return setHits(undefined);
|
||||
const h = setTimeout(() => {
|
||||
get<{ path: string; is_dir: boolean }[]>(`/files/search?path=${encodeURIComponent(listing.path)}&q=${encodeURIComponent(search)}`).then(setHits).catch(() => setHits([]));
|
||||
}, 250);
|
||||
return () => clearTimeout(h);
|
||||
}, [search, listing]);
|
||||
|
||||
// Warn before leaving with unsaved changes.
|
||||
useEffect(() => {
|
||||
const h = (e: BeforeUnloadEvent) => dirty && e.preventDefault();
|
||||
window.addEventListener("beforeunload", h);
|
||||
return () => window.removeEventListener("beforeunload", h);
|
||||
}, [dirty]);
|
||||
|
||||
const root = roots.find((r) => listing?.path.startsWith(r.path));
|
||||
const crumbs = listing && root ? [root.path, ...listing.path.slice(root.path.length).split("/").filter(Boolean)] : [];
|
||||
|
||||
const list = (
|
||||
<div
|
||||
className={`files-list ${dragOver ? "drag" : ""}`}
|
||||
onDragOver={(e) => (e.preventDefault(), setDragOver(true))}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
void upload(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<div className="files-toolbar">
|
||||
{roots.length > 1 && (
|
||||
<select value={root?.path ?? ""} onChange={(e) => confirmDiscard() && load(e.target.value)} aria-label={t("images.folder")}>
|
||||
{roots.map((r) => (
|
||||
<option key={r.path} value={r.path}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<input className="files-search" placeholder={t("files.search")} value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<button className="small" title={t("files.newFile")} onClick={() => newEntry(false)}>
|
||||
+ 📄
|
||||
</button>
|
||||
<button className="small" title={t("files.newFolder")} onClick={() => newEntry(true)}>
|
||||
+ 📁
|
||||
</button>
|
||||
<button className="small" title={t("files.upload")} onClick={() => uploadRef.current?.click()}>
|
||||
⤒
|
||||
</button>
|
||||
<input ref={uploadRef} type="file" multiple hidden onChange={(e) => e.target.files && upload(e.target.files)} />
|
||||
<button className={`small ${hidden ? "active" : ""}`} title={t("files.showHidden")} onClick={() => setHidden(!hidden)}>
|
||||
.*
|
||||
</button>
|
||||
</div>
|
||||
<div className="crumbs">
|
||||
{crumbs.map((c, i) => {
|
||||
const path = i === 0 ? c : `${crumbs[0]}/${crumbs.slice(1, i + 1).join("/")}`;
|
||||
return (
|
||||
<span key={path}>
|
||||
{i > 0 && " / "}
|
||||
<a href="#" onClick={(e) => (e.preventDefault(), load(path))}>
|
||||
{i === 0 ? root?.name : c}
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<div className="entries" role="list">
|
||||
{hits ? (
|
||||
hits.length === 0 ? (
|
||||
<div className="empty">{t("files.noHits")}</div>
|
||||
) : (
|
||||
hits.map((h) => (
|
||||
<button key={h.path} className="entry" onClick={() => (h.is_dir ? load(h.path) : openFile(h.path))}>
|
||||
<span>{h.is_dir ? "📁" : "📄"}</span>
|
||||
<span className="name">{h.path.slice((listing?.path.length ?? 0) + 1)}</span>
|
||||
</button>
|
||||
))
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{listing?.parent && (
|
||||
<button className="entry" onClick={() => load(listing.parent!)}>
|
||||
<span>↰</span>
|
||||
<span className="name">..</span>
|
||||
</button>
|
||||
)}
|
||||
{listing?.entries.map((e) => {
|
||||
const mark = gitMarks.get(e.path);
|
||||
const active = pane.kind === "file" && pane.file.path === e.path;
|
||||
return (
|
||||
<div key={e.path} className={`entry ${active ? "active" : ""}`} role="listitem">
|
||||
<button className="entry-main" onClick={() => (e.is_dir ? load(e.path) : openFile(e.path))}>
|
||||
<span>{icon(e)}</span>
|
||||
<span className="name">{e.name}</span>
|
||||
{mark && <span className={`git-mark m-${mark === "•" ? "dot" : mark}`}>{mark}</span>}
|
||||
{!e.is_dir && <span className="size">{fmtCompact(e.size)}B</span>}
|
||||
</button>
|
||||
<details className="entry-menu">
|
||||
<summary aria-label={t("files.actions")}>⋯</summary>
|
||||
<div className="menu">
|
||||
<button onClick={() => rename(e)}>{t("files.rename")}</button>
|
||||
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
|
||||
{!e.is_dir && (
|
||||
<a className="button" href={rawUrl(e.path, true)}>
|
||||
{t("files.download")}
|
||||
</a>
|
||||
)}
|
||||
<button className="danger" onClick={() => remove(e)}>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{listing?.entries.length === 0 && <div className="empty">{t("files.emptyFolder")}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="drop-hint">{t("files.dropHint")}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const right = (() => {
|
||||
if (pane.kind === "diff") {
|
||||
return (
|
||||
<div className="editor-pane">
|
||||
<div className="editor-bar">
|
||||
<button className="ghost small mobile-only" onClick={() => setMobileEditor(false)}>
|
||||
←
|
||||
</button>
|
||||
<b className="grow">{pane.title}</b>
|
||||
</div>
|
||||
<div className="pane-scroll">{pane.diff ? <Diff diff={pane.diff} /> : <div className="empty">{t("git.noDiff")}</div>}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (pane.kind === "none") return <div className="editor-pane empty">{t("files.selectFile")}</div>;
|
||||
const f = pane.file;
|
||||
const isImage = IMAGE.test(f.path);
|
||||
const isMd = /\.md$/i.test(f.path);
|
||||
return (
|
||||
<div className="editor-pane">
|
||||
<div className="editor-bar">
|
||||
<button className="ghost small mobile-only" onClick={() => confirmDiscard() && setMobileEditor(false)}>
|
||||
←
|
||||
</button>
|
||||
<b className="grow" title={f.path}>
|
||||
{baseName(f.path)}
|
||||
{dirty && <span className="dirty"> ●</span>}
|
||||
</b>
|
||||
{isMd && (
|
||||
<button className="small" onClick={() => setPreview(!preview)}>
|
||||
{preview ? t("files.edit") : t("files.preview")}
|
||||
</button>
|
||||
)}
|
||||
<a className="button small" href={rawUrl(f.path, true)}>
|
||||
⤓
|
||||
</a>
|
||||
{!f.binary && (
|
||||
<button className="small primary" disabled={!dirty} onClick={() => save()}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isImage ? (
|
||||
<div className="pane-scroll image-preview">
|
||||
<img src={rawUrl(f.path)} alt={baseName(f.path)} />
|
||||
</div>
|
||||
) : f.binary ? (
|
||||
<div className="empty">{f.tooLarge ? t("files.tooLarge") : t("files.binary")}</div>
|
||||
) : isMd && preview ? (
|
||||
<div className="pane-scroll" style={{ padding: 16 }}>
|
||||
<Markdown text={f.content} />
|
||||
</div>
|
||||
) : (
|
||||
<Suspense fallback={<div className="empty"><Spinner /></div>}>
|
||||
<CodeEditor
|
||||
key={f.path}
|
||||
value={f.content}
|
||||
filename={baseName(f.path)}
|
||||
onChange={(v) => setPane((p) => (p.kind === "file" ? { kind: "file", file: { ...p.file, content: v } } : p))}
|
||||
onSave={() => void save()}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
☰
|
||||
</button>
|
||||
<div className="grow">
|
||||
<h1>{t("nav.files")}</h1>
|
||||
<div className="path">{listing?.path}</div>
|
||||
</div>
|
||||
{listing?.repo && (
|
||||
<div className="mode-tabs" role="tablist">
|
||||
<button className={tab === "files" ? "active" : ""} onClick={() => setTab("files")}>
|
||||
{t("files.tabFiles")}
|
||||
</button>
|
||||
<button className={tab === "git" ? "active" : ""} onClick={() => setTab("git")}>
|
||||
Git{git && git.files.length > 0 ? ` (${git.files.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`files-layout ${mobileEditor ? "show-editor" : ""}`}>
|
||||
<div className="files-side">
|
||||
{tab === "git" && listing?.repo ? (
|
||||
<GitPanel
|
||||
repo={listing.repo}
|
||||
status={git}
|
||||
refresh={refreshGit}
|
||||
showDiff={(title, diff) => (setPane({ kind: "diff", title, diff }), setMobileEditor(true))}
|
||||
openFile={openFile}
|
||||
/>
|
||||
) : (
|
||||
list
|
||||
)}
|
||||
{!listing?.repo && listing && (
|
||||
<div className="git-init">
|
||||
<button className="small" onClick={() => act(() => post("/git", { path: listing.path, action: "init" }))}>
|
||||
{t("git.init")}
|
||||
</button>
|
||||
<button
|
||||
className="small"
|
||||
onClick={() => {
|
||||
const url = prompt(t("git.cloneUrl"));
|
||||
if (!url) return;
|
||||
const name = prompt(t("git.cloneFolder"), baseName(url).replace(/\.git$/, ""));
|
||||
if (name) void act(() => post("/git", { path: `${listing.path}/${name}`, action: "clone", url }));
|
||||
}}
|
||||
>
|
||||
{t("git.clone")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="files-main">{right}</div>
|
||||
</div>
|
||||
{pane.kind === "file" && pane.file.mtime && <div className="files-status">{fmtDate(pane.file.mtime)}</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { post } from "../api";
|
||||
import { fmtRelative, useT } from "../i18n";
|
||||
import { Spinner } from "./Activity";
|
||||
|
||||
export interface GitFile {
|
||||
path: string;
|
||||
index: string;
|
||||
worktree: string;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export interface GitStatus {
|
||||
repo: string;
|
||||
branch: string | null;
|
||||
upstream: string | null;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
files: GitFile[];
|
||||
}
|
||||
|
||||
interface Commit {
|
||||
hash: string;
|
||||
author: string;
|
||||
time: number;
|
||||
subject: string;
|
||||
refs: string;
|
||||
}
|
||||
|
||||
const LABEL: Record<string, string> = { M: "M", A: "A", D: "D", R: "R", C: "C", U: "!", "?": "U" };
|
||||
|
||||
export function GitPanel({
|
||||
repo,
|
||||
status,
|
||||
refresh,
|
||||
showDiff,
|
||||
openFile,
|
||||
}: {
|
||||
repo: string;
|
||||
status?: GitStatus;
|
||||
refresh: () => Promise<void>;
|
||||
showDiff: (title: string, diff: string) => void;
|
||||
openFile: (path: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [message, setMessage] = useState("");
|
||||
const [amend, setAmend] = useState(false);
|
||||
const [busy, setBusy] = useState<string>();
|
||||
const [output, setOutput] = useState<{ ok: boolean; text: string }>();
|
||||
const [branches, setBranches] = useState<{ name: string; current: boolean }[]>([]);
|
||||
const [log, setLog] = useState<Commit[]>([]);
|
||||
const [view, setView] = useState<"changes" | "history">("changes");
|
||||
|
||||
const call = async <T,>(action: string, extra: Record<string, unknown> = {}): Promise<T> => post<T>("/git", { path: repo, action, ...extra });
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>, showOutput = false) => {
|
||||
setBusy(label);
|
||||
setOutput(undefined);
|
||||
try {
|
||||
const r: any = await fn();
|
||||
if (showOutput) setOutput({ ok: true, text: (r?.output ?? "").trim() || t("git.done") });
|
||||
await refresh();
|
||||
void loadMeta();
|
||||
} catch (e: any) {
|
||||
setOutput({ ok: false, text: e.message });
|
||||
} finally {
|
||||
setBusy(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMeta = async () => {
|
||||
try {
|
||||
const [b, l] = await Promise.all([call<{ branches: any[] }>("branches"), call<{ commits: Commit[] }>("log", { limit: 60 })]);
|
||||
setBranches(b.branches.filter((x: any) => !String(x.name).startsWith("origin/HEAD")));
|
||||
setLog(l.commits);
|
||||
} catch {
|
||||
/* empty repository */
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => void loadMeta(), [repo]);
|
||||
|
||||
if (!status) return <div className="empty"><Spinner /></div>;
|
||||
const abs = (f: GitFile) => `${status.repo}/${f.path}`;
|
||||
const staged = status.files.filter((f) => f.index !== " " && f.index !== "?");
|
||||
const unstaged = status.files.filter((f) => f.worktree !== " " || f.index === "?");
|
||||
|
||||
const diff = async (f: GitFile, isStaged: boolean) => {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const fileRow = (f: GitFile, isStaged: boolean) => {
|
||||
const code = isStaged ? f.index : f.index === "?" ? "?" : f.worktree;
|
||||
return (
|
||||
<div key={`${isStaged}-${f.path}`} className="git-file">
|
||||
<span className={`git-mark m-${LABEL[code] ?? code}`}>{LABEL[code] ?? code}</span>
|
||||
<button className="ghost git-name" onClick={() => diff(f, isStaged)} title={f.path}>
|
||||
{f.path}
|
||||
</button>
|
||||
<button className="small ghost" title={t("git.openFile")} onClick={() => openFile(abs(f))}>
|
||||
↗
|
||||
</button>
|
||||
{isStaged ? (
|
||||
<button className="small" title={t("git.unstage")} onClick={() => run("unstage", () => call("unstage", { files: [abs(f)] }))}>
|
||||
−
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
<button className="small" title={t("git.stage")} onClick={() => run("stage", () => call("stage", { files: [abs(f)] }))}>
|
||||
+
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="git-panel">
|
||||
<div className="git-branch">
|
||||
<select
|
||||
value={branches.find((b) => b.current)?.name ?? status.branch ?? ""}
|
||||
onChange={(e) => run("checkout", () => call("checkout", { branch: e.target.value.replace(/^origin\//, "") }), true)}
|
||||
aria-label={t("git.branch")}
|
||||
>
|
||||
{!branches.length && <option>{status.branch}</option>}
|
||||
{branches.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="small"
|
||||
title={t("git.newBranch")}
|
||||
onClick={() => {
|
||||
const name = prompt(t("git.newBranchName"));
|
||||
if (name) void run("checkout", () => call("checkout", { branch: name, create: true }), true);
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
{status.upstream && (
|
||||
<span className="chip" title={status.upstream}>
|
||||
↑{status.ahead} ↓{status.behind}
|
||||
</span>
|
||||
)}
|
||||
<span className="spacer" />
|
||||
{(["fetch", "pull", "push"] as const).map((a) => (
|
||||
<button key={a} className="small" disabled={!!busy} onClick={() => run(a, () => call(a), true)}>
|
||||
{busy === a ? <Spinner small /> : t(`git.${a}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{output && <pre className={`git-output ${output.ok ? "" : "err"}`}>{output.text}</pre>}
|
||||
|
||||
<div className="mode-tabs" style={{ margin: "8px 0" }}>
|
||||
<button className={view === "changes" ? "active" : ""} onClick={() => setView("changes")}>
|
||||
{t("git.changes")} ({status.files.length})
|
||||
</button>
|
||||
<button className={view === "history" ? "active" : ""} onClick={() => setView("history")}>
|
||||
{t("git.history")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{view === "changes" ? (
|
||||
<>
|
||||
<div className="git-section">
|
||||
<div className="git-head">
|
||||
{t("git.staged")} ({staged.length})
|
||||
{staged.length > 0 && (
|
||||
<button className="small ghost" onClick={() => run("unstage", () => call("unstage", { files: staged.map(abs) }))}>
|
||||
{t("git.unstageAll")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{staged.map((f) => fileRow(f, true))}
|
||||
</div>
|
||||
<div className="git-section">
|
||||
<div className="git-head">
|
||||
{t("git.unstaged")} ({unstaged.length})
|
||||
{unstaged.length > 0 && (
|
||||
<button className="small ghost" onClick={() => run("stage", () => call("stage", { files: unstaged.map(abs) }))}>
|
||||
{t("git.stageAll")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{unstaged.map((f) => fileRow(f, false))}
|
||||
{status.files.length === 0 && <div className="empty" style={{ padding: 12 }}>{t("git.clean")}</div>}
|
||||
</div>
|
||||
<div className="git-commit">
|
||||
<textarea rows={3} placeholder={t("git.message")} value={message} onChange={(e) => setMessage(e.target.value)} />
|
||||
<div className="row">
|
||||
<label style={{ fontSize: 12.5 }}>
|
||||
<input type="checkbox" checked={amend} onChange={(e) => setAmend(e.target.checked)} /> {t("git.amend")}
|
||||
</label>
|
||||
<span className="spacer" />
|
||||
<button
|
||||
className="primary"
|
||||
disabled={!!busy || (!amend && (!message.trim() || staged.length === 0))}
|
||||
onClick={() =>
|
||||
run(
|
||||
"commit",
|
||||
async () => {
|
||||
const r = await call("commit", { message, amend });
|
||||
setMessage("");
|
||||
setAmend(false);
|
||||
return r;
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
>
|
||||
{busy === "commit" ? <Spinner small /> : t("git.commit")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="git-log">
|
||||
{log.length === 0 && <div className="empty">{t("git.noCommits")}</div>}
|
||||
{log.map((c) => (
|
||||
<button
|
||||
key={c.hash}
|
||||
className="git-commit-row"
|
||||
onClick={async () => {
|
||||
const r = await call<{ diff: string }>("show", { rev: c.hash });
|
||||
showDiff(`${c.hash.slice(0, 8)} ${c.subject}`, r.diff);
|
||||
}}
|
||||
>
|
||||
<span className="subject">{c.subject}</span>
|
||||
<span className="meta">
|
||||
{c.hash.slice(0, 7)} · {c.author} · {fmtRelative(c.time)}
|
||||
{c.refs && <span className="chip" style={{ marginLeft: 6 }}>{c.refs}</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { del, post } from "../api";
|
||||
import { useFetch } from "../hooks";
|
||||
import { fmtCost, fmtDate, useT, type Key } from "../i18n";
|
||||
import { Spinner } from "./Activity";
|
||||
|
||||
interface ImageConfig {
|
||||
enabled: boolean;
|
||||
default_model: string;
|
||||
output_dir: string;
|
||||
sizes: string[];
|
||||
models: { id: string; name: string; price?: number | null }[];
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface ImageRecord {
|
||||
id: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
path: string;
|
||||
width: number;
|
||||
height: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
const fileUrl = (id: string) => `/api/images/${id}/file`;
|
||||
|
||||
export function Images({ onMenu }: { onMenu: () => void }) {
|
||||
const t = useT();
|
||||
const { data: cfg } = useFetch<ImageConfig>("/images/config");
|
||||
const { data: images, reload } = useFetch<ImageRecord[]>("/images");
|
||||
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
||||
const [form, setForm] = useState({ prompt: "", model: "", size: "landscape_4_3", count: 1, root: "", folder: "" });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [viewer, setViewer] = useState<ImageRecord>();
|
||||
|
||||
useEffect(() => {
|
||||
if (cfg && !form.model) setForm((f) => ({ ...f, model: cfg.default_model, folder: cfg.output_dir }));
|
||||
}, [cfg, form.model]);
|
||||
useEffect(() => {
|
||||
if (roots.length && !form.root) setForm((f) => ({ ...f, root: roots[0].path }));
|
||||
}, [roots, form.root]);
|
||||
|
||||
const generate = async () => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const directory = form.root ? `${form.root}/${form.folder.replace(/^\/+/, "")}` : undefined;
|
||||
await post("/images", { prompt: form.prompt, model: form.model, size: form.size, count: form.count, directory });
|
||||
reload();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const price = cfg?.models.find((m) => m.id === form.model)?.price;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
☰
|
||||
</button>
|
||||
<div className="grow">
|
||||
<h1>{t("nav.images")}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page">
|
||||
<div className="page-inner">
|
||||
{cfg && !cfg.enabled && <div className="notice">{t("images.disabled")}</div>}
|
||||
<div className="panel">
|
||||
<textarea
|
||||
style={{ width: "100%", minHeight: 90 }}
|
||||
placeholder={t("images.promptPlaceholder")}
|
||||
value={form.prompt}
|
||||
onChange={(e) => setForm({ ...form, prompt: e.target.value })}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.ctrlKey || e.metaKey) && form.prompt.trim() && generate()}
|
||||
/>
|
||||
<div className="row" style={{ marginTop: 8 }}>
|
||||
<select value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} aria-label={t("images.model")}>
|
||||
{cfg?.models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={form.size} onChange={(e) => setForm({ ...form, size: e.target.value })} aria-label={t("images.size")}>
|
||||
{cfg?.sizes.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`images.size.${s}` as Key)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={form.count} onChange={(e) => setForm({ ...form, count: Number(e.target.value) })} aria-label={t("images.count")}>
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{t("images.countN", { n })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{roots.length > 1 && (
|
||||
<select value={form.root} onChange={(e) => setForm({ ...form, root: e.target.value })} aria-label={t("images.folder")}>
|
||||
{roots.map((r) => (
|
||||
<option key={r.path} value={r.path}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<input value={form.folder} onChange={(e) => setForm({ ...form, folder: e.target.value })} title={t("images.folder")} style={{ width: 140 }} />
|
||||
<span className="spacer" />
|
||||
{price ? <span className="chip">≈ {fmtCost(price * form.count, cfg!.currency)}</span> : null}
|
||||
<button className="primary" disabled={busy || !form.prompt.trim() || !cfg?.enabled} onClick={generate}>
|
||||
{busy ? <Spinner small /> : null} {busy ? t("images.generating") : t("images.generate")}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="notice err" style={{ marginTop: 8 }}>{error}</div>}
|
||||
</div>
|
||||
|
||||
{images?.length === 0 && <div className="empty">{t("images.empty")}</div>}
|
||||
<div className="gallery">
|
||||
{busy &&
|
||||
Array.from({ length: form.count }, (_, i) => (
|
||||
<div key={`p${i}`} className="gallery-item placeholder">
|
||||
<Spinner />
|
||||
</div>
|
||||
))}
|
||||
{images?.map((img) => (
|
||||
<figure key={img.id} className="gallery-item">
|
||||
<img src={fileUrl(img.id)} alt={img.prompt} loading="lazy" onClick={() => setViewer(img)} />
|
||||
<figcaption>
|
||||
<div className="prompt" title={img.prompt}>
|
||||
{img.prompt}
|
||||
</div>
|
||||
<div className="meta">
|
||||
{img.model.split("/").slice(1).join("/")} · {fmtDate(img.created_at)}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 4 }}>
|
||||
<a className="button small" href={fileUrl(img.id)} download={img.path.split("/").pop()}>
|
||||
⤓
|
||||
</a>
|
||||
<button className="small" title={t("images.reuse")} onClick={() => setForm({ ...form, prompt: img.prompt, model: img.model })}>
|
||||
↺
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{viewer && (
|
||||
<div className="modal-backdrop" onClick={() => setViewer(undefined)}>
|
||||
<figure className="viewer" onClick={(e) => e.stopPropagation()}>
|
||||
<img src={fileUrl(viewer.id)} alt={viewer.prompt} />
|
||||
<figcaption>
|
||||
{viewer.prompt}
|
||||
<div className="meta">
|
||||
{viewer.width}×{viewer.height} · {viewer.path}
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import { get, setToken } from "../api";
|
||||
import { post } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import type { Info } from "../types";
|
||||
|
||||
export function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
/** Sign-in, or creation of the first admin when no users exist yet. */
|
||||
export function Login({ info, onSuccess }: { info: Info; onSuccess: () => void }) {
|
||||
const t = useT();
|
||||
const [token, setValue] = useState("");
|
||||
const [error, setError] = useState(false);
|
||||
const setup = info.setup_required;
|
||||
const [form, setForm] = useState({ username: "", password: "", password2: "", code: "" });
|
||||
const [error, setError] = useState<string>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setToken(token.trim());
|
||||
setError(undefined);
|
||||
if (setup && form.password !== form.password2) return setError(t("login.mismatch"));
|
||||
setBusy(true);
|
||||
try {
|
||||
await get("/sessions");
|
||||
if (setup) await post("/auth/setup", { code: form.code, username: form.username, password: form.password });
|
||||
else await post("/auth/login", { username: form.username, password: form.password });
|
||||
onSuccess();
|
||||
} catch {
|
||||
setError(true);
|
||||
} catch (err: any) {
|
||||
setError(err.status === 401 ? t(setup ? "login.invalidCode" : "login.invalid") : err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => setForm({ ...form, [k]: e.target.value });
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<form onSubmit={submit}>
|
||||
<img src="/icon.svg" alt="" />
|
||||
<h2 style={{ margin: 0 }}>CodeCraft</h2>
|
||||
<div style={{ color: "var(--fg3)" }}>{t("login.prompt")}</div>
|
||||
<input type="password" autoFocus value={token} onChange={(e) => setValue(e.target.value)} placeholder={t("login.token")} />
|
||||
{error && <div style={{ color: "var(--red)" }}>{t("login.invalid")}</div>}
|
||||
<button className="primary" type="submit" disabled={!token.trim()}>
|
||||
{t("login.submit")}
|
||||
<div style={{ color: "var(--fg3)" }}>{setup ? t("login.setupPrompt") : t("login.prompt")}</div>
|
||||
{setup && <input autoFocus placeholder={t("login.setupCode")} value={form.code} onChange={set("code")} autoComplete="one-time-code" />}
|
||||
<input autoFocus={!setup} placeholder={t("login.username")} value={form.username} onChange={set("username")} autoComplete="username" autoCapitalize="none" />
|
||||
<input type="password" placeholder={t("login.password")} value={form.password} onChange={set("password")} autoComplete={setup ? "new-password" : "current-password"} />
|
||||
{setup && <input type="password" placeholder={t("login.passwordRepeat")} value={form.password2} onChange={set("password2")} autoComplete="new-password" />}
|
||||
{error && <div style={{ color: "var(--red)" }}>{error}</div>}
|
||||
<button className="primary" type="submit" disabled={busy || !form.username || !form.password}>
|
||||
{setup ? t("login.createAdmin") : t("login.submit")}
|
||||
</button>
|
||||
{!setup && info.oidc && (
|
||||
<>
|
||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("login.or")}</div>
|
||||
<a className="button" href="/api/auth/oidc/start">
|
||||
{info.oidc.label}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useModels, modelOptions } from "../hooks";
|
||||
|
||||
/** Model picker; keeps unknown current values selectable. */
|
||||
export function ModelSelect({ value, onChange, title }: { value: string; onChange: (v: string) => void; title?: string }) {
|
||||
export function ModelSelect({ value, onChange, title, emptyLabel }: { value: string; onChange: (v: string) => void; title?: string; emptyLabel?: string }) {
|
||||
const options = modelOptions(useModels());
|
||||
const known = options.some((o) => o.value === value);
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} title={title}>
|
||||
{emptyLabel !== undefined && <option value="">{emptyLabel}</option>}
|
||||
{!known && value && <option value={value}>{value}</option>}
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ModelSelect } from "./ModelSelect";
|
||||
interface DirListing {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
root: string;
|
||||
workspace: string;
|
||||
dirs: { name: string; path: string; git: boolean }[];
|
||||
}
|
||||
@@ -16,6 +17,7 @@ const LAST_DIR = "codecraft.lastDir";
|
||||
export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreated: (id: string) => void }) {
|
||||
const t = useT();
|
||||
const [path, setPath] = useState<string>(localStorage.getItem(LAST_DIR) ?? "");
|
||||
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
||||
const { data: listing, error: dirError } = useFetch<DirListing>(`/fs/dirs${path ? `?path=${encodeURIComponent(path)}` : ""}`);
|
||||
const config = useFetch<{ default_model?: string }>("/config").data;
|
||||
const [model, setModel] = useState("");
|
||||
@@ -50,6 +52,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
};
|
||||
|
||||
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : "";
|
||||
const currentRoot = listing?.root;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -65,6 +68,15 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
<div className="page-inner" style={{ maxWidth: 760 }}>
|
||||
<div className="panel">
|
||||
<h3>{t("new.directory")}</h3>
|
||||
{roots.length > 1 && (
|
||||
<div className="mode-tabs" style={{ marginBottom: 8, flexWrap: "wrap" }}>
|
||||
{roots.map((r) => (
|
||||
<button key={r.path} className={currentRoot === r.path ? "active" : ""} onClick={() => setPath(r.path)}>
|
||||
{r.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="row" style={{ marginBottom: 8 }}>
|
||||
<code className="grow" style={{ flex: 1 }}>
|
||||
{rel}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { del, post } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import { useStore } from "../store";
|
||||
import type { QueueItem } from "../types";
|
||||
|
||||
const EMPTY: QueueItem[] = [];
|
||||
|
||||
/** Messages waiting for the agent; deletable until delivered. */
|
||||
export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolean }) {
|
||||
const t = useT();
|
||||
const items = useStore((s) => s.queue[sessionId] ?? EMPTY);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div className="queue">
|
||||
<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))}>
|
||||
{t("queue.sendNow")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{items.map((it) => {
|
||||
const text = it.parts.map((p) => (p.type === "text" ? p.text : "")).join(" ").trim();
|
||||
const images = it.parts.filter((p) => p.type === "image") as Extract<QueueItem["parts"][number], { type: "image" }>[];
|
||||
return (
|
||||
<div key={it.id} className="queue-item">
|
||||
{it.source === "background" && <span className="chip">{t("tool.background")}</span>}
|
||||
<span className="queue-text">{it.source === "background" ? t("queue.backgroundResult") : text || "—"}</span>
|
||||
{images.map((p, i) => (
|
||||
<img key={i} src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
|
||||
))}
|
||||
{it.source !== "background" && (
|
||||
<button
|
||||
className="small ghost"
|
||||
aria-label={t("queue.remove")}
|
||||
title={t("queue.remove")}
|
||||
onClick={() => del(`/sessions/${sessionId}/queue/${it.id}`).catch((e) => alert(e.message))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+179
-87
@@ -4,8 +4,13 @@ import { useFetch, useModels } from "../hooks";
|
||||
import { getLang, languages, setLang, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import { applyTheme, getTheme, type Theme } from "../theme";
|
||||
import { applyTheme, getTheme, THEMES, type Theme } from "../theme";
|
||||
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
import { Account } from "./settings/Account";
|
||||
import { AgentsEditor } from "./settings/AgentsEditor";
|
||||
import { Folders } from "./settings/Folders";
|
||||
import { Users } from "./settings/Users";
|
||||
|
||||
type Json = any;
|
||||
interface ConfigResponse {
|
||||
@@ -39,6 +44,63 @@ function useSaver(reload: () => void) {
|
||||
|
||||
// --- general ----------------------------------------------------------------
|
||||
|
||||
/** Per-device settings (language, theme, notifications) – available to everyone. */
|
||||
function InterfaceSettings() {
|
||||
const t = useT();
|
||||
const reasoning = useReasoningMode();
|
||||
const [theme, setThemeState] = useState<Theme>(getTheme());
|
||||
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>{t("settings.interface")}</h3>
|
||||
<div className="form">
|
||||
<label>{t("settings.language")}</label>
|
||||
<select value={getLang()} onChange={(e) => setLang(e.target.value)}>
|
||||
{Object.entries(languages).map(([k, v]) => (
|
||||
<option key={k} value={k}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label>{t("settings.theme")}</label>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value as Theme;
|
||||
setThemeState(v);
|
||||
applyTheme(v);
|
||||
}}
|
||||
>
|
||||
{THEMES.map((th) => (
|
||||
<option key={th.id} value={th.id}>
|
||||
{th.id === "system" ? t("settings.themeSystem") : th.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label>{t("settings.reasoningDisplay")}</label>
|
||||
<select value={reasoning} onChange={(e) => setReasoningMode(e.target.value as ReasoningMode)}>
|
||||
<option value="collapsed">{t("settings.reasoningCollapsed")}</option>
|
||||
<option value="hidden">{t("settings.reasoningHidden")}</option>
|
||||
<option value="live">{t("settings.reasoningLive")}</option>
|
||||
<option value="expanded">{t("settings.reasoningExpanded")}</option>
|
||||
</select>
|
||||
<div className="hint">{t("settings.reasoningHint")}</div>
|
||||
<label>{t("settings.notifications")}</label>
|
||||
<div>
|
||||
{notif === "granted" ? (
|
||||
<span className="chip">{t("settings.notificationsOn")}</span>
|
||||
) : (
|
||||
<button className="small" onClick={() => Notification.requestPermission().then(setNotif)} disabled={notif === "denied"}>
|
||||
{t("settings.notificationsEnable")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="hint">{t("settings.notificationsHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
const t = useT();
|
||||
const s = cfg.config.server;
|
||||
@@ -48,12 +110,18 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
small_model: s.small_model ?? "",
|
||||
currency: s.currency ?? "USD",
|
||||
auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
|
||||
max_parallel_subagents: s.max_parallel_subagents ?? 4,
|
||||
monthly_budget: s.monthly_budget ?? 0,
|
||||
budget_warning: s.budget_warning ?? 0.8,
|
||||
images_budget: cfg.config.images?.monthly_budget ?? "",
|
||||
clean_room: c.clean_room ?? true,
|
||||
chromium: c.chromium ?? "chromium",
|
||||
widths: (c.widths ?? []).join(", "),
|
||||
images_enabled: cfg.config.images?.enabled ?? false,
|
||||
images_key: cfg.config.images?.api_key === MASK ? "" : (cfg.config.images?.api_key ?? "${FAL_KEY}"),
|
||||
images_model: cfg.config.images?.default_model ?? "fal-ai/flux/schnell",
|
||||
images_dir: cfg.config.images?.output_dir ?? "generated",
|
||||
});
|
||||
const [theme, setThemeState] = useState<Theme>(getTheme());
|
||||
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
|
||||
const { run, view } = useSaver(reload);
|
||||
|
||||
const save = () =>
|
||||
@@ -63,51 +131,23 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
{ path: ["server", "small_model"], value: form.small_model },
|
||||
{ path: ["server", "currency"], value: form.currency },
|
||||
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) },
|
||||
{ path: ["server", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) },
|
||||
{ path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 },
|
||||
{ path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 },
|
||||
{ path: ["images", "monthly_budget"], value: form.images_budget === "" ? null : Number(form.images_budget) },
|
||||
{ path: ["craft", "clean_room"], value: form.clean_room },
|
||||
{ path: ["craft", "chromium"], value: form.chromium },
|
||||
{ path: ["craft", "widths"], value: String(form.widths).split(/[\s,]+/).filter(Boolean).map(Number) },
|
||||
{ path: ["images", "enabled"], value: form.images_enabled },
|
||||
{ path: ["images", "default_model"], value: form.images_model },
|
||||
{ path: ["images", "output_dir"], value: form.images_dir },
|
||||
...(form.images_key ? [{ path: ["images", "api_key"], value: form.images_key }] : []),
|
||||
]),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel">
|
||||
<h3>{t("settings.interface")}</h3>
|
||||
<div className="form">
|
||||
<label>{t("settings.language")}</label>
|
||||
<select value={getLang()} onChange={(e) => setLang(e.target.value)}>
|
||||
{Object.entries(languages).map(([k, v]) => (
|
||||
<option key={k} value={k}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label>{t("settings.theme")}</label>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value as Theme;
|
||||
setThemeState(v);
|
||||
applyTheme(v);
|
||||
}}
|
||||
>
|
||||
<option value="system">{t("settings.themeSystem")}</option>
|
||||
<option value="dark">{t("settings.themeDark")}</option>
|
||||
<option value="light">{t("settings.themeLight")}</option>
|
||||
</select>
|
||||
<label>{t("settings.notifications")}</label>
|
||||
<div>
|
||||
{notif === "granted" ? (
|
||||
<span className="chip">{t("settings.notificationsOn")}</span>
|
||||
) : (
|
||||
<button className="small" onClick={() => Notification.requestPermission().then(setNotif)} disabled={notif === "denied"}>
|
||||
{t("settings.notificationsEnable")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="hint">{t("settings.notificationsHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<InterfaceSettings />
|
||||
<div className="panel">
|
||||
<h3>{t("settings.server")}</h3>
|
||||
<div className="form">
|
||||
@@ -123,6 +163,14 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
<label>{t("settings.autoCompact")}</label>
|
||||
<input type="number" min={0} max={1} step={0.05} value={form.auto_compact_threshold} onChange={(e) => setForm({ ...form, auto_compact_threshold: e.target.value })} />
|
||||
<div className="hint">{t("settings.autoCompactHint")}</div>
|
||||
<label>{t("settings.monthlyBudgetTotal")}</label>
|
||||
<input type="number" min={0} value={form.monthly_budget} onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
|
||||
<div className="hint">{t("settings.monthlyBudgetHint")}</div>
|
||||
<label>{t("settings.budgetWarning")}</label>
|
||||
<input type="number" min={0} max={1} step={0.05} value={form.budget_warning} onChange={(e) => setForm({ ...form, budget_warning: e.target.value })} />
|
||||
<label>{t("settings.maxParallelSubagents")}</label>
|
||||
<input type="number" min={0} value={form.max_parallel_subagents} onChange={(e) => setForm({ ...form, max_parallel_subagents: e.target.value })} />
|
||||
<div className="hint">{t("settings.maxParallelSubagentsHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel">
|
||||
@@ -137,6 +185,28 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
<input value={form.widths} onChange={(e) => setForm({ ...form, widths: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>{t("settings.images")}</h3>
|
||||
<div className="form">
|
||||
<label>{t("settings.imagesEnabled")}</label>
|
||||
<input type="checkbox" checked={form.images_enabled} onChange={(e) => setForm({ ...form, images_enabled: e.target.checked })} style={{ justifySelf: "start" }} />
|
||||
<label>{t("settings.imagesKey")}</label>
|
||||
<input type="password" value={form.images_key} placeholder={cfg.config.images?.api_key === MASK ? t("settings.apiKeyStored") : "${FAL_KEY}"} onChange={(e) => setForm({ ...form, images_key: e.target.value })} />
|
||||
<label>{t("settings.imagesDefault")}</label>
|
||||
<select value={form.images_model} onChange={(e) => setForm({ ...form, images_model: e.target.value })}>
|
||||
{(cfg.config.images?.models ?? []).map((m: any) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name ?? m.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label>{t("settings.monthlyBudget")}</label>
|
||||
<input type="number" min={0} value={form.images_budget} placeholder="–" onChange={(e) => setForm({ ...form, images_budget: e.target.value })} />
|
||||
<label>{t("settings.imagesDir")}</label>
|
||||
<input value={form.images_dir} onChange={(e) => setForm({ ...form, images_dir: e.target.value })} />
|
||||
<div className="hint">{t("settings.imagesHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
{view}
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
@@ -159,6 +229,8 @@ interface ModelRow {
|
||||
output_price?: number;
|
||||
cache_read_price?: number;
|
||||
cache_write_price?: number;
|
||||
max_concurrency?: number;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const PRESETS: Record<string, { kind: string; base_url: string; api_key: string; auth_prefix?: string }> = {
|
||||
@@ -187,7 +259,27 @@ function cleanModel(m: ModelRow) {
|
||||
|
||||
function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: () => void }) {
|
||||
const t = useT();
|
||||
const [form, setForm] = useState({ ...p, api_key: p.api_key === MASK ? "" : p.api_key });
|
||||
const [form, setForm] = useState({ ...p, api_key: p.api_key === MASK ? "" : p.api_key, admin_key: "" });
|
||||
const [priceNote, setPriceNote] = useState<string>();
|
||||
|
||||
const fetchPrices = async () => {
|
||||
try {
|
||||
const r = await get<{ models: ModelRow[] }>(`/providers/${name}/prices`);
|
||||
const byId = new Map(r.models.map((m) => [m.id, m]));
|
||||
let n = 0;
|
||||
setModels((ms) =>
|
||||
ms.map((m) => {
|
||||
const f = byId.get(m.id);
|
||||
if (!f) return m;
|
||||
n++;
|
||||
return { ...m, input_price: f.input_price ?? m.input_price, output_price: f.output_price ?? m.output_price, cache_read_price: f.cache_read_price ?? m.cache_read_price, cache_write_price: f.cache_write_price ?? m.cache_write_price, context: m.context ?? f.context };
|
||||
}),
|
||||
);
|
||||
setPriceNote(t("settings.pricesFetched", { n }));
|
||||
} catch (e: any) {
|
||||
setPriceNote(e.message);
|
||||
}
|
||||
};
|
||||
const [models, setModels] = useState<ModelRow[]>(normalizeModels(p.models));
|
||||
const [open, setOpen] = useState(false);
|
||||
const discovered = useModels().find((x) => x.provider === name)?.models ?? [];
|
||||
@@ -204,9 +296,12 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
|
||||
{ path: ["providers", name, "enabled"], value: !!form.enabled },
|
||||
{ path: ["providers", name, "models"], value: models.filter((m) => m.id.trim()).map(cleanModel) },
|
||||
{ path: ["providers", name, "auth_prefix"], value: form.auth_prefix ? form.auth_prefix : null },
|
||||
{ path: ["providers", name, "max_concurrency"], value: form.max_concurrency ? Number(form.max_concurrency) : null },
|
||||
{ path: ["providers", name, "monthly_budget"], value: form.monthly_budget ? Number(form.monthly_budget) : null },
|
||||
];
|
||||
// Empty key field = keep the stored (masked) key.
|
||||
if (form.api_key !== "" || p.api_key !== MASK) patches.push({ path: ["providers", name, "api_key"], value: form.api_key });
|
||||
if (form.admin_key) patches.push({ path: ["providers", name, "admin_key"], value: form.admin_key });
|
||||
return savePatches(patches);
|
||||
});
|
||||
|
||||
@@ -252,10 +347,27 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
|
||||
<div className="hint">{t("settings.apiKeyHint")}</div>
|
||||
<label>{t("settings.authPrefix")}</label>
|
||||
<input value={form.auth_prefix ?? ""} placeholder={form.kind === "openai" ? "Bearer " : ""} onChange={(e) => setForm({ ...form, auth_prefix: e.target.value })} />
|
||||
<label>{t("settings.monthlyBudget")}</label>
|
||||
<input type="number" min={0} step="1" value={form.monthly_budget ?? ""} placeholder="–" onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
|
||||
{form.kind === "anthropic" && (
|
||||
<>
|
||||
<label>{t("settings.adminKey")}</label>
|
||||
<input type="password" value={form.admin_key} placeholder={p.admin_key === MASK ? t("settings.apiKeyStored") : "${ANTHROPIC_ADMIN_KEY}"} onChange={(e) => setForm({ ...form, admin_key: e.target.value })} />
|
||||
<div className="hint">{t("settings.adminKeyHint")}</div>
|
||||
</>
|
||||
)}
|
||||
<label>{t("settings.maxConcurrency")}</label>
|
||||
<input type="number" min={1} value={form.max_concurrency ?? ""} placeholder="∞" onChange={(e) => setForm({ ...form, max_concurrency: e.target.value })} />
|
||||
<div className="hint">{t("settings.maxConcurrencyHint")}</div>
|
||||
</div>
|
||||
<h3 style={{ marginTop: 16 }}>
|
||||
{t("settings.models")}
|
||||
<span className="spacer" />
|
||||
{form.base_url?.includes("openrouter.ai") && (
|
||||
<button className="small" onClick={fetchPrices}>
|
||||
{t("settings.fetchPrices")}
|
||||
</button>
|
||||
)}
|
||||
{discovered.length > 0 && (
|
||||
<button className="small" onClick={addDiscovered}>
|
||||
{t("settings.addDiscovered", { n: discovered.length })}
|
||||
@@ -268,6 +380,7 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
|
||||
<div className="hint" style={{ marginBottom: 6, fontSize: 12, color: "var(--muted)" }}>
|
||||
{t("settings.pricesHint")}
|
||||
</div>
|
||||
{priceNote && <div className="notice">{priceNote}</div>}
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
@@ -282,6 +395,7 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
|
||||
<th>{t("settings.priceOut")}</th>
|
||||
<th>{t("settings.priceCacheRead")}</th>
|
||||
<th>{t("settings.priceCacheWrite")}</th>
|
||||
<th title={t("settings.maxConcurrencyHint")}>{t("settings.slots")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -316,6 +430,9 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
|
||||
<input type="number" step="0.01" value={m[k] ?? ""} onChange={(e) => setModel(i, { [k]: num(e.target.value) })} />
|
||||
</td>
|
||||
))}
|
||||
<td>
|
||||
<input type="number" min={1} value={m.max_concurrency ?? ""} placeholder="∞" onChange={(e) => setModel(i, { max_concurrency: num(e.target.value) })} />
|
||||
</td>
|
||||
<td>
|
||||
<button className="small ghost danger" onClick={() => setModels(models.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||
×
|
||||
@@ -416,6 +533,7 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
|
||||
url: m.url ?? "",
|
||||
headers: mapToLines(m.headers, ": "),
|
||||
enabled: m.enabled ?? true,
|
||||
load: m.load ?? "auto",
|
||||
});
|
||||
const [open, setOpen] = useState(!!isNew);
|
||||
const { run, view } = useSaver(reload);
|
||||
@@ -424,6 +542,7 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
|
||||
const n = form.name.trim();
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
|
||||
const value: Record<string, unknown> = { type: form.type, enabled: form.enabled };
|
||||
if (form.load !== "auto") value.load = form.load;
|
||||
if (form.type === "stdio") {
|
||||
value.command = form.command;
|
||||
value.args = form.args.split("\n").map((s: string) => s.trim()).filter(Boolean);
|
||||
@@ -486,6 +605,13 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
|
||||
)}
|
||||
<label>{t("settings.enabled")}</label>
|
||||
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} style={{ justifySelf: "start" }} />
|
||||
<label>{t("mcp.load")}</label>
|
||||
<select value={form.load} onChange={(e) => setForm({ ...form, load: e.target.value })}>
|
||||
<option value="auto">{t("mcp.loadAuto")}</option>
|
||||
<option value="always">{t("mcp.loadAlways")}</option>
|
||||
<option value="on_demand">{t("mcp.loadOnDemand")}</option>
|
||||
</select>
|
||||
<div className="hint">{t("mcp.loadHint")}</div>
|
||||
</div>
|
||||
{status?.tools?.length ? (
|
||||
<details style={{ marginTop: 10 }}>
|
||||
@@ -620,47 +746,6 @@ function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void
|
||||
);
|
||||
}
|
||||
|
||||
// --- agents (read-only overview) ----------------------------------------------
|
||||
|
||||
function Agents() {
|
||||
const t = useT();
|
||||
const agents = useFetch<Json[]>("/agents").data ?? [];
|
||||
const cmds = useFetch<{ commands: Json[]; skills: Json[] }>("/commands").data;
|
||||
return (
|
||||
<>
|
||||
<div className="notice">{t("agents.intro")}</div>
|
||||
<div className="panel">
|
||||
<h3>{t("agents.title")}</h3>
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
{agents.map((a) => (
|
||||
<tr key={a.name}>
|
||||
<td>
|
||||
<code>{a.name}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span className="chip">{a.mode}</span> {a.read_only && <span className="chip">{t("agents.readOnly")}</span>}
|
||||
</td>
|
||||
<td>{a.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="two-col">
|
||||
<div className="panel">
|
||||
<h3>{t("agents.commands")}</h3>
|
||||
{cmds?.commands.length ? cmds.commands.map((c) => <div key={c.name}><code>/{c.name}</code> – {c.description}</div>) : <div className="empty">–</div>}
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>{t("agents.skills")}</h3>
|
||||
{cmds?.skills.length ? cmds.skills.map((s) => <div key={s.name}><code>{s.name}</code> – {s.description}</div>) : <div className="empty">–</div>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- raw ----------------------------------------------------------------------
|
||||
|
||||
function Raw({ reload }: { reload: () => void }) {
|
||||
@@ -688,12 +773,15 @@ function Raw({ reload }: { reload: () => void }) {
|
||||
|
||||
// --- page -------------------------------------------------------------------
|
||||
|
||||
const TABS = ["general", "providers", "mcp", "permissions", "agents", "raw"] as const;
|
||||
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "account", "raw"] as const;
|
||||
const USER_TABS = ["general", "account"] as const;
|
||||
|
||||
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
|
||||
const t = useT();
|
||||
const active = (TABS as readonly string[]).includes(tab ?? "") ? tab! : "general";
|
||||
const { data: cfg, error, reload } = useFetch<ConfigResponse>("/config");
|
||||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||
const TABS: readonly string[] = isAdmin ? ADMIN_TABS : USER_TABS;
|
||||
const active = TABS.includes(tab ?? "") ? tab! : "general";
|
||||
const { data: cfg, error, reload } = useFetch<ConfigResponse>(isAdmin ? "/config" : null);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -715,12 +803,16 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
|
||||
))}
|
||||
</div>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
{!cfg && !error && <div className="empty">{t("common.loading")}</div>}
|
||||
{isAdmin && !cfg && !error && <div className="empty">{t("common.loading")}</div>}
|
||||
{!isAdmin && active === "general" && <InterfaceSettings />}
|
||||
{active === "account" && <Account />}
|
||||
{cfg && active === "users" && <Users />}
|
||||
{cfg && active === "folders" && <Folders />}
|
||||
{cfg && active === "general" && <General key={JSON.stringify(cfg.config.server)} cfg={cfg} reload={reload} />}
|
||||
{cfg && active === "providers" && <Providers cfg={cfg} reload={reload} />}
|
||||
{cfg && active === "mcp" && <Mcp cfg={cfg} reload={reload} />}
|
||||
{cfg && active === "permissions" && <Permissions key={JSON.stringify(cfg.config.permissions)} cfg={cfg} reload={reload} />}
|
||||
{active === "agents" && <Agents />}
|
||||
{cfg && active === "agents" && <AgentsEditor rawAgents={cfg.config.agents ?? {}} reloadConfig={reload} />}
|
||||
{active === "raw" && <Raw reload={reload} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { get, put } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import { useStore } from "../store";
|
||||
|
||||
interface DirUser {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
/** Choose users a session is shared with (read-only for them). */
|
||||
export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) {
|
||||
const t = useT();
|
||||
const me = useStore((s) => s.me);
|
||||
const owner = useStore((s) => s.sessionById[sessionId]?.owner_id);
|
||||
const [users, setUsers] = useState<DirUser[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([get<DirUser[]>("/users/directory"), get<string[]>(`/sessions/${sessionId}/shares`)])
|
||||
.then(([u, s]) => {
|
||||
setUsers(u.filter((x) => x.id !== owner && x.id !== me?.id));
|
||||
setSelected(new Set(s));
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
}, [sessionId, owner, me?.id]);
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await put(`/sessions/${sessionId}/shares`, { users: [...selected] });
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" role="dialog" aria-label={t("share.title")} onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t("share.title")}</h3>
|
||||
<div style={{ fontSize: 13, color: "var(--muted)", marginBottom: 8 }}>{t("share.hint")}</div>
|
||||
<div className="check-list">
|
||||
{users.length === 0 && <div className="empty">{t("share.noUsers")}</div>}
|
||||
{users.map((u) => (
|
||||
<label key={u.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(u.id)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(selected);
|
||||
if (e.target.checked) next.add(u.id);
|
||||
else next.delete(u.id);
|
||||
setSelected(next);
|
||||
}}
|
||||
/>
|
||||
{u.display_name || u.username} <span style={{ color: "var(--muted)" }}>@{u.username}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
||||
<button onClick={onClose}>{t("common.cancel")}</button>
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { post } from "../api";
|
||||
import { fmtRelative, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
@@ -24,6 +25,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
const connected = useStore((s) => s.connected);
|
||||
const permissions = useStore((s) => s.permissions);
|
||||
const questions = useStore((s) => s.questions);
|
||||
const me = useStore((s) => s.me);
|
||||
|
||||
const attention = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
@@ -69,10 +71,11 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
onClick={() => navigate(`/s/${s.id}`)}
|
||||
>
|
||||
<div className="title">
|
||||
{attention.has(s.id) ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy[s.id] && <i className="busy-dot" />}
|
||||
{attention.has(s.id) ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy[s.id] && <i className="busy-ring" title={t("activity.working")} />}
|
||||
<span>{s.title || t("session.untitled")}</span>
|
||||
</div>
|
||||
<div className="sub">
|
||||
{s.shared && <span title={t("sidebar.shared")}>⇄ </span>}
|
||||
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
|
||||
</div>
|
||||
</button>
|
||||
@@ -80,7 +83,22 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-user">
|
||||
<span title={me?.username}>
|
||||
{me?.display_name || me?.username}
|
||||
{me?.role === "admin" && <span className="chip" style={{ marginLeft: 6 }}>admin</span>}
|
||||
</span>
|
||||
<button className="small ghost" onClick={() => post("/auth/logout").then(() => location.reload())}>
|
||||
{t("nav.logout")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-foot">
|
||||
<button className={`ghost ${section === "files" ? "active" : ""}`} onClick={() => navigate("/files")}>
|
||||
{t("nav.files")}
|
||||
</button>
|
||||
<button className={`ghost ${section === "images" ? "active" : ""}`} onClick={() => navigate("/images")}>
|
||||
{t("nav.images")}
|
||||
</button>
|
||||
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
|
||||
{t("nav.stats")}
|
||||
</button>
|
||||
|
||||
@@ -2,8 +2,11 @@ import { useMemo, useState } from "react";
|
||||
import { useFetch } from "../hooks";
|
||||
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, weekdayNames, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import type { User } from "../types";
|
||||
import type { Aggregate } from "../types";
|
||||
import { Heatmap, Legend, SERIES, StackedBars, type Series } from "./Charts";
|
||||
import { BudgetPanel } from "./Budget";
|
||||
|
||||
type Row<K extends string> = Aggregate & Record<K, string>;
|
||||
|
||||
@@ -14,6 +17,7 @@ interface StatsData {
|
||||
by_model: Row<"model">[];
|
||||
by_provider: Row<"provider">[];
|
||||
by_agent: Row<"agent">[];
|
||||
by_user: Row<"user">[];
|
||||
heatmap: (Aggregate & { slot: number })[];
|
||||
top_sessions: (Aggregate & { session_id: string; title: string })[];
|
||||
currency: string;
|
||||
@@ -55,6 +59,12 @@ function Kpi({ label, value, sub }: { label: string; value: string; sub?: string
|
||||
);
|
||||
}
|
||||
|
||||
/** Effective cost per 1M tokens (input incl. cache + output). */
|
||||
function perMillion(r: Aggregate, currency: string) {
|
||||
const tokens = r.input_tokens + r.cache_read_tokens + r.cache_write_tokens + r.output_tokens;
|
||||
return tokens > 0 && r.cost > 0 ? fmtCost((r.cost / tokens) * 1_000_000, currency) : "–";
|
||||
}
|
||||
|
||||
function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate & Record<string, any>)[]; keyName: string; label: string; currency: string; link?: (r: any) => void }) {
|
||||
const t = useT();
|
||||
return (
|
||||
@@ -71,6 +81,7 @@ function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate &
|
||||
<th className="num">{t("stats.outputSpeed")}</th>
|
||||
<th className="num">{t("stats.prefillSpeed")}</th>
|
||||
<th className="num">{t("stats.cost")}</th>
|
||||
<th className="num" title={t("stats.perMillionHint")}>{t("stats.perMillion")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -85,6 +96,7 @@ function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate &
|
||||
<td className="num">{fmtNumber(r.output_tps, 1)}</td>
|
||||
<td className="num">{fmtNumber(r.prefill_tps)}</td>
|
||||
<td className="num">{fmtCost(r.cost, currency)}</td>
|
||||
<td className="num">{perMillion(r, currency)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -98,12 +110,15 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
const [range, setRange] = useState("30");
|
||||
const [heat, setHeat] = useState<HeatMetric>("requests");
|
||||
const [tableView, setTableView] = useState(false);
|
||||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||
const users = useFetch<User[]>(isAdmin ? "/users" : null).data ?? [];
|
||||
const [userFilter, setUserFilter] = useState("");
|
||||
const days = RANGES.find((r) => r.key === range)!.days;
|
||||
const tz = -new Date().getTimezoneOffset();
|
||||
// Stable per range: rounded to the minute so the URL doesn't change on every render.
|
||||
const now = Math.floor(Date.now() / 60000) * 60000 + 60000;
|
||||
const from = days ? now - days * 86400000 : 0;
|
||||
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${now}&tz=${tz}`);
|
||||
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${now}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`);
|
||||
|
||||
const view = useMemo(() => {
|
||||
if (!data) return undefined;
|
||||
@@ -165,6 +180,16 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<select value={userFilter} onChange={(e) => setUserFilter(e.target.value)} aria-label={t("stats.user")}>
|
||||
<option value="">{t("stats.allUsers")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button className="small" onClick={() => setTableView(!tableView)}>
|
||||
{tableView ? t("stats.chartView") : t("stats.tableView")}
|
||||
</button>
|
||||
@@ -173,6 +198,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
<div className="page-inner">
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
{!data && !error && <div className="empty">{t("common.loading")}</div>}
|
||||
{isAdmin && <BudgetPanel />}
|
||||
{data && tot && view && (
|
||||
<>
|
||||
<div className="kpis">
|
||||
@@ -183,7 +209,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
<Kpi label={t("stats.cacheHit")} value={fmtPercent(tot.cache_hit_rate)} />
|
||||
<Kpi label={t("stats.ttft")} value={`${fmtNumber(tot.avg_ttft_ms)} ms`} sub={t("stats.ttftHint")} />
|
||||
<Kpi label={t("stats.outputSpeed")} value={`${fmtNumber(tot.output_tps, 1)} tok/s`} />
|
||||
<Kpi label={t("stats.prefillSpeed")} value={`${fmtNumber(tot.prefill_tps)} tok/s`} />
|
||||
<Kpi label={t("stats.prefillSpeed")} value={`${fmtCompact(tot.prefill_tps)} tok/s`} />
|
||||
</div>
|
||||
|
||||
{tot.requests === 0 ? (
|
||||
@@ -236,6 +262,12 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && !userFilter && data.by_user.length > 1 && (
|
||||
<div className="panel">
|
||||
<h3>{t("stats.byUser")}</h3>
|
||||
<AggTable rows={data.by_user} keyName="user" label={t("stats.user")} currency={cur} />
|
||||
</div>
|
||||
)}
|
||||
<div className="panel">
|
||||
<h3>{t("stats.topSessions")}</h3>
|
||||
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => navigate(`/s/${r.session_id}`)} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import type { Part, Todo } from "../types";
|
||||
import { Spinner } from "./Activity";
|
||||
import { Diff } from "./Diff";
|
||||
|
||||
type ToolCall = Extract<Part, { type: "tool_call" }>;
|
||||
@@ -63,23 +64,34 @@ export function ToolCard({ call, result, running }: { call: ToolCall; result?: T
|
||||
const statusText = result ? (result.is_error ? "✗" : "✓") : running ? "…" : "–";
|
||||
const meta = result?.meta;
|
||||
|
||||
// The todo list is shown in its own panel; keep the card compact.
|
||||
// The live task list has its own panel; updates are a single line here.
|
||||
if (call.name === "todo_write" && !result?.is_error) {
|
||||
const todos: Todo[] = call.input?.todos ?? [];
|
||||
const done = todos.filter((x) => x.status === "completed").length;
|
||||
const current = todos.find((x) => x.status === "in_progress");
|
||||
return (
|
||||
<div className="todos">
|
||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
|
||||
<TodoList todos={call.input?.todos ?? []} />
|
||||
<div className="todo-line">
|
||||
☑ {t("tool.todosUpdated", { done, total: todos.length })}
|
||||
{current && <span> · ▶ {current.content}</span>}
|
||||
</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 =
|
||||
(status === "err" && !!result) ||
|
||||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image"));
|
||||
|
||||
return (
|
||||
<details className="tool" open={status === "err" && !!result}>
|
||||
<details className={`tool ${status === "run" ? "running" : ""}`} open={openByDefault}>
|
||||
<summary>
|
||||
<span className="tool-name">{call.name}</span>
|
||||
<span className="tool-title">{toolTitle(call.name, call.input)}</span>
|
||||
{meta?.similarity !== undefined && <span className="chip">{meta.similarity} %</span>}
|
||||
<span className={`status ${status}`}>{statusText}</span>
|
||||
{call.name === "task" && meta?.model && <span className="chip">{meta.model}</span>}
|
||||
{call.name === "task" && call.input?.background && <span className="chip">{t("tool.background")}</span>}
|
||||
{status === "run" ? <Spinner small /> : <span className={`status ${status}`}>{statusText}</span>}
|
||||
</summary>
|
||||
<div className="tool-body">
|
||||
{call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>}
|
||||
@@ -107,9 +119,17 @@ export function ToolCard({ call, result, running }: { call: ToolCall; result?: T
|
||||
<pre className="mono">{result.content}</pre>
|
||||
</>
|
||||
)}
|
||||
{result?.images?.map((img, i) => (
|
||||
<img key={i} src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
))}
|
||||
{Array.isArray(meta?.image_ids) ? (
|
||||
<div className="gallery small">
|
||||
{meta.image_ids.map((id: string) => (
|
||||
<a key={id} href={`/api/images/${id}/file`} target="_blank" rel="noreferrer" className="gallery-item">
|
||||
<img src={`/api/images/${id}/file`} alt="" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
result?.images?.map((img, i) => <img key={i} src={`data:${img.media_type};base64,${img.data}`} alt="" />)
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from "react";
|
||||
import { post } from "../../api";
|
||||
import { useT } from "../../i18n";
|
||||
import { useStore } from "../../store";
|
||||
|
||||
export function Account() {
|
||||
const t = useT();
|
||||
const me = useStore((s) => s.me);
|
||||
const [form, setForm] = useState({ current: "", next: "", repeat: "" });
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||||
|
||||
const save = async () => {
|
||||
if (form.next !== form.repeat) return setNotice({ ok: false, text: t("login.mismatch") });
|
||||
try {
|
||||
await post("/auth/password", { current: form.current || undefined, new: form.next });
|
||||
setForm({ current: "", next: "", repeat: "" });
|
||||
setNotice({ ok: true, text: t("account.changed") });
|
||||
} catch (e: any) {
|
||||
setNotice({ ok: false, text: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
{t("account.title")}: {me?.username}
|
||||
</h3>
|
||||
<div className="form">
|
||||
{me?.has_password && (
|
||||
<>
|
||||
<label>{t("account.current")}</label>
|
||||
<input type="password" value={form.current} onChange={(e) => setForm({ ...form, current: e.target.value })} autoComplete="current-password" />
|
||||
</>
|
||||
)}
|
||||
<label>{t("account.new")}</label>
|
||||
<input type="password" value={form.next} onChange={(e) => setForm({ ...form, next: e.target.value })} autoComplete="new-password" />
|
||||
<label>{t("login.passwordRepeat")}</label>
|
||||
<input type="password" value={form.repeat} onChange={(e) => setForm({ ...form, repeat: e.target.value })} autoComplete="new-password" />
|
||||
</div>
|
||||
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`} style={{ marginTop: 10 }}>{notice.text}</div>}
|
||||
<button className="primary" style={{ marginTop: 12 }} disabled={!form.next} onClick={save}>
|
||||
{t("account.change")}
|
||||
</button>
|
||||
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState } from "react";
|
||||
import { patch } from "../../api";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { useT } from "../../i18n";
|
||||
import type { AgentInfo } from "../../types";
|
||||
import { ModelSelect } from "../ModelSelect";
|
||||
|
||||
type Patch = { path: string[]; value: unknown };
|
||||
|
||||
const WRITER_PROMPT = `You are a technical writer. Produce clear, well-structured text (Markdown) for
|
||||
documentation, summaries, step-by-step guides and translations.
|
||||
Return only the finished text.`;
|
||||
|
||||
function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: boolean, text: string) => void }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
model: a.model ?? "",
|
||||
max_parallel: a.max_parallel ? String(a.max_parallel) : "",
|
||||
description: raw?.description ?? "",
|
||||
tools: (raw?.tools ?? []).join(", "),
|
||||
prompt: raw?.prompt ?? "",
|
||||
mode: raw?.mode ?? a.mode,
|
||||
});
|
||||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "general", "explore"].includes(a.name));
|
||||
|
||||
const save = async () => {
|
||||
const base = ["agents", a.name];
|
||||
const patches: Patch[] = [
|
||||
{ path: [...base, "model"], value: form.model || null },
|
||||
{ path: [...base, "max_parallel"], value: form.max_parallel ? Number(form.max_parallel) : null },
|
||||
];
|
||||
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
||||
if (!builtin) {
|
||||
patches.push({ path: [...base, "mode"], value: form.mode });
|
||||
patches.push({ path: [...base, "tools"], value: form.tools.trim() ? form.tools.split(/[\s,]+/).filter(Boolean) : null });
|
||||
patches.push({ path: [...base, "prompt"], value: form.prompt || null });
|
||||
}
|
||||
try {
|
||||
await patch("/config", { patches });
|
||||
onSaved(true, t("settings.saved"));
|
||||
} catch (e: any) {
|
||||
onSaved(false, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = async () => {
|
||||
if (!confirm(t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }))) return;
|
||||
try {
|
||||
await patch("/config", { patches: [{ path: ["agents", a.name], value: null }] });
|
||||
onSaved(true, t("settings.saved"));
|
||||
} catch (e: any) {
|
||||
onSaved(false, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
<code>{a.name}</code>
|
||||
<span className="chip">{a.mode === "primary" ? t("agents.modePrimary") : t("agents.modeSubagent")}</span>
|
||||
<span className="chip">{t(`agents.source.${a.source}` as any)}</span>
|
||||
{a.read_only && <span className="chip">{t("agents.readOnly")}</span>}
|
||||
<span className="spacer" />
|
||||
<button className="small" onClick={() => setOpen(!open)}>
|
||||
{open ? t("common.collapse") : t("common.edit")}
|
||||
</button>
|
||||
</h3>
|
||||
<div style={{ fontSize: 13, color: "var(--fg3)" }}>{a.description}</div>
|
||||
<div className="row" style={{ marginTop: 8 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--muted)" }}>{t("agents.model")}:</span>
|
||||
<span className="chip">{a.model || t("agents.sessionModel")}</span>
|
||||
{a.max_parallel ? <span className="chip">{t("agents.maxParallelShort", { n: a.max_parallel })}</span> : null}
|
||||
</div>
|
||||
{open && (
|
||||
<>
|
||||
<div className="form" style={{ marginTop: 12 }}>
|
||||
<label>{t("agents.model")}</label>
|
||||
<ModelSelect value={form.model} onChange={(v) => setForm({ ...form, model: v })} emptyLabel={t("agents.sessionModel")} />
|
||||
<div className="hint">{t("agents.modelHint")}</div>
|
||||
{a.mode !== "primary" && (
|
||||
<>
|
||||
<label>{t("agents.maxParallel")}</label>
|
||||
<input type="number" min={1} value={form.max_parallel} placeholder="∞" onChange={(e) => setForm({ ...form, max_parallel: e.target.value })} />
|
||||
</>
|
||||
)}
|
||||
<label>{t("agents.description")}</label>
|
||||
<input value={form.description} placeholder={a.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
{a.mode !== "primary" && <div className="hint">{t("agents.descriptionHint")}</div>}
|
||||
{!builtin && (
|
||||
<>
|
||||
<label>{t("agents.mode")}</label>
|
||||
<select value={form.mode} onChange={(e) => setForm({ ...form, mode: e.target.value })}>
|
||||
<option value="subagent">{t("agents.modeSubagent")}</option>
|
||||
<option value="primary">{t("agents.modePrimary")}</option>
|
||||
</select>
|
||||
<label>{t("agents.tools")}</label>
|
||||
<input value={form.tools} placeholder={t("agents.toolsAll")} onChange={(e) => setForm({ ...form, tools: e.target.value })} className="mono" />
|
||||
<label>{t("agents.prompt")}</label>
|
||||
<textarea rows={6} value={form.prompt} onChange={(e) => setForm({ ...form, prompt: e.target.value })} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
{a.source === "config" && (
|
||||
<button className="danger" onClick={reset}>
|
||||
{builtin ? t("agents.reset") : t("common.delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<string, any>; reloadConfig: () => void }) {
|
||||
const t = useT();
|
||||
const { data: agents, reload } = useFetch<AgentInfo[]>("/agents");
|
||||
const cmds = useFetch<{ commands: any[]; skills: any[] }>("/commands").data;
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const saved = (ok: boolean, text: string) => {
|
||||
setNotice({ ok, text });
|
||||
if (ok) {
|
||||
reload();
|
||||
reloadConfig();
|
||||
}
|
||||
};
|
||||
|
||||
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"));
|
||||
const value =
|
||||
template === "writer"
|
||||
? { mode: "subagent", description: "Writes documentation, summaries, guides and translations.", tools: ["read", "glob", "grep"], max_parallel: 1, prompt: WRITER_PROMPT }
|
||||
: { mode: "subagent", description: "", prompt: "" };
|
||||
try {
|
||||
await patch("/config", { patches: [{ path: ["agents", n], value }] });
|
||||
setName("");
|
||||
saved(true, t("settings.saved"));
|
||||
} catch (e: any) {
|
||||
saved(false, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const key = (a: AgentInfo) => a.name + JSON.stringify(rawAgents[a.name] ?? {}) + (a.model ?? "");
|
||||
return (
|
||||
<>
|
||||
<div className="notice">{t("agents.intro")}</div>
|
||||
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>}
|
||||
<h3 style={{ fontSize: 14 }}>{t("agents.primaryTitle")}</h3>
|
||||
{agents?.filter((a) => a.mode === "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} onSaved={saved} />)}
|
||||
<h3 style={{ fontSize: 14 }}>{t("agents.subagentTitle")}</h3>
|
||||
{agents?.filter((a) => a.mode !== "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} onSaved={saved} />)}
|
||||
<div className="panel">
|
||||
<h3>{t("agents.add")}</h3>
|
||||
<div className="row">
|
||||
<input placeholder={t("settings.name")} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<button onClick={() => add("writer")}>+ {t("agents.addWriter")}</button>
|
||||
<button onClick={() => add("blank")} disabled={!name.trim()}>
|
||||
+ {t("agents.addBlank")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="two-col">
|
||||
<div className="panel">
|
||||
<h3>{t("agents.commands")}</h3>
|
||||
{cmds?.commands.length ? cmds.commands.map((c) => <div key={c.name}><code>/{c.name}</code> – {c.description}</div>) : <div className="empty">–</div>}
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>{t("agents.skills")}</h3>
|
||||
{cmds?.skills.length ? cmds.skills.map((s) => <div key={s.name}><code>{s.name}</code> – {s.description}</div>) : <div className="empty">–</div>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { del, post, put } from "../../api";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { useT } from "../../i18n";
|
||||
import type { User } from "../../types";
|
||||
|
||||
interface Folder {
|
||||
id: string;
|
||||
path: string;
|
||||
users: string[];
|
||||
}
|
||||
|
||||
/** Admin: folders inside the workspace shared with selected users. */
|
||||
export function Folders() {
|
||||
const t = useT();
|
||||
const { data, reload } = useFetch<{ workspace: string; folders: Folder[] }>("/folders");
|
||||
const users = (useFetch<User[]>("/users").data ?? []).filter((u) => u.role !== "admin");
|
||||
const [path, setPath] = useState("");
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
try {
|
||||
await fn();
|
||||
setNotice({ ok: true, text: t("settings.saved") });
|
||||
reload();
|
||||
} catch (e: any) {
|
||||
setNotice({ ok: false, text: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (f: Folder, userId: string) => {
|
||||
const next = f.users.includes(userId) ? f.users.filter((u) => u !== userId) : [...f.users, userId];
|
||||
void run(() => put(`/folders/${f.id}/access`, { users: next }));
|
||||
};
|
||||
|
||||
const ws = data?.workspace ?? "";
|
||||
return (
|
||||
<>
|
||||
<div className="notice">{t("folders.intro")}</div>
|
||||
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>}
|
||||
{data?.folders.length === 0 && <div className="empty">{t("folders.none")}</div>}
|
||||
{data?.folders.map((f) => (
|
||||
<div className="panel" key={f.id}>
|
||||
<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}`))}>
|
||||
{t("folders.remove")}
|
||||
</button>
|
||||
</h3>
|
||||
<div className="check-list">
|
||||
{users.length === 0 && <div style={{ color: "var(--muted)" }}>{t("folders.noUsers")}</div>}
|
||||
{users.map((u) => (
|
||||
<label key={u.id}>
|
||||
<input type="checkbox" checked={f.users.includes(u.id)} onChange={() => toggle(f, u.id)} />
|
||||
{u.display_name || u.username} <span style={{ color: "var(--muted)" }}>@{u.username}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="panel">
|
||||
<h3>{t("folders.add")}</h3>
|
||||
<div className="row">
|
||||
<code>{ws}/</code>
|
||||
<input value={path} onChange={(e) => setPath(e.target.value)} placeholder="team/project" style={{ flex: 1, minWidth: 160 }} />
|
||||
<button className="primary" disabled={!path.trim()} onClick={() => run(async () => (await post("/folders", { path }), setPath("")))}>
|
||||
+ {t("common.add")}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 6 }}>{t("folders.addHint")}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from "react";
|
||||
import { del, patch, post } from "../../api";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { fmtDate, useT } from "../../i18n";
|
||||
import { useStore } from "../../store";
|
||||
import type { User } from "../../types";
|
||||
|
||||
export function Users() {
|
||||
const t = useT();
|
||||
const me = useStore((s) => s.me);
|
||||
const { data: users, reload } = useFetch<User[]>("/users");
|
||||
const [form, setForm] = useState({ username: "", display_name: "", password: "", role: "user" });
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, ok = t("settings.saved")) => {
|
||||
try {
|
||||
await fn();
|
||||
setNotice({ ok: true, text: ok });
|
||||
reload();
|
||||
} catch (e: any) {
|
||||
setNotice({ ok: false, text: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const resetPassword = (u: User) => {
|
||||
const pw = prompt(t("users.newPasswordFor", { name: u.username }));
|
||||
if (pw) void run(() => patch(`/users/${u.id}`, { password: pw }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="notice">{t("users.intro")}</div>
|
||||
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>}
|
||||
<div className="panel" style={{ overflowX: "auto" }}>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("users.username")}</th>
|
||||
<th>{t("users.displayName")}</th>
|
||||
<th>{t("users.role")}</th>
|
||||
<th>{t("users.login")}</th>
|
||||
<th>{t("users.created")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users?.map((u) => (
|
||||
<tr key={u.id} style={{ opacity: u.disabled ? 0.5 : 1 }}>
|
||||
<td>
|
||||
<code>{u.username}</code>
|
||||
{u.id === me?.id && <span className="chip" style={{ marginLeft: 6 }}>{t("users.you")}</span>}
|
||||
</td>
|
||||
<td>{u.display_name}</td>
|
||||
<td>
|
||||
<select value={u.role} onChange={(e) => run(() => patch(`/users/${u.id}`, { role: e.target.value }))}>
|
||||
<option value="user">{t("users.roleUser")}</option>
|
||||
<option value="admin">{t("users.roleAdmin")}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{u.has_password && <span className="chip">{t("users.password")}</span>} {u.oidc && <span className="chip">SSO</span>}
|
||||
</td>
|
||||
<td>{fmtDate(u.created_at, false)}</td>
|
||||
<td>
|
||||
<div className="row" style={{ flexWrap: "nowrap" }}>
|
||||
<button className="small" onClick={() => resetPassword(u)}>
|
||||
{t("users.setPassword")}
|
||||
</button>
|
||||
<button className="small" onClick={() => run(() => patch(`/users/${u.id}`, { disabled: !u.disabled }))}>
|
||||
{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}`))}>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>{t("users.add")}</h3>
|
||||
<div className="form">
|
||||
<label>{t("users.username")}</label>
|
||||
<input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} autoCapitalize="none" />
|
||||
<div className="hint">{t("users.usernameHint")}</div>
|
||||
<label>{t("users.displayName")}</label>
|
||||
<input value={form.display_name} onChange={(e) => setForm({ ...form, display_name: e.target.value })} />
|
||||
<label>{t("users.password")}</label>
|
||||
<input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} autoComplete="new-password" />
|
||||
<div className="hint">{t("users.passwordHint")}</div>
|
||||
<label>{t("users.role")}</label>
|
||||
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||||
<option value="user">{t("users.roleUser")}</option>
|
||||
<option value="admin">{t("users.roleAdmin")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
className="primary"
|
||||
style={{ marginTop: 12 }}
|
||||
disabled={!form.username.trim()}
|
||||
onClick={() =>
|
||||
run(async () => {
|
||||
await post("/users", { ...form, password: form.password || undefined });
|
||||
setForm({ username: "", display_name: "", password: "", role: "user" });
|
||||
})
|
||||
}
|
||||
>
|
||||
+ {t("common.add")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user