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
+36
-19
@@ -1,39 +1,48 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ApiError, connectEvents, get, getToken } from "./api";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { connectEvents, get, setUnauthorizedHandler } from "./api";
|
||||
import { useT } from "./i18n";
|
||||
import { navigate, useRoute } from "./router";
|
||||
import { applyEvent, loadSessions, resync, setState } from "./store";
|
||||
import type { Info } from "./types";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { Login } from "./components/Login";
|
||||
import { ChatView } from "./components/ChatView";
|
||||
import { NewSession } from "./components/NewSession";
|
||||
import { Settings } from "./components/Settings";
|
||||
import { Stats } from "./components/Stats";
|
||||
import { Images } from "./components/Images";
|
||||
import { Spinner, useTitleActivity } from "./components/Activity";
|
||||
|
||||
type AuthState = "checking" | "login" | "ok";
|
||||
// The file manager (editor, git) is loaded on demand to keep the chat small.
|
||||
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
|
||||
|
||||
export function App() {
|
||||
const t = useT();
|
||||
const route = useRoute();
|
||||
const [auth, setAuth] = useState<AuthState>("checking");
|
||||
const [info, setInfo] = useState<Info>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
useTitleActivity();
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const i = await get<Info>("/info");
|
||||
setState(() => ({ me: i.user }));
|
||||
if (i.user) await loadSessions();
|
||||
setInfo(i);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Check whether a token is needed and whether ours works.
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const info = await get<{ auth_required: boolean }>("/info");
|
||||
if (info.auth_required && !getToken()) return setAuth("login");
|
||||
await loadSessions();
|
||||
setAuth("ok");
|
||||
} catch (e) {
|
||||
setAuth(e instanceof ApiError && e.status === 401 ? "login" : "ok");
|
||||
}
|
||||
})();
|
||||
setUnauthorizedHandler(() => setInfo((i) => (i ? { ...i, user: null } : i)));
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
const signedIn = !!info?.user;
|
||||
useEffect(() => {
|
||||
if (auth !== "ok") return;
|
||||
if (!signedIn) return;
|
||||
return connectEvents({
|
||||
onEvent: applyEvent,
|
||||
onOpen: () => {
|
||||
@@ -42,19 +51,27 @@ export function App() {
|
||||
},
|
||||
onClose: () => setState(() => ({ connected: false })),
|
||||
});
|
||||
}, [auth]);
|
||||
}, [signedIn]);
|
||||
|
||||
// Close the mobile menu on navigation.
|
||||
useEffect(() => setMenuOpen(false), [route.join("/")]);
|
||||
|
||||
if (auth === "checking") return <div className="empty">{t("common.loading")}</div>;
|
||||
if (auth === "login") return <Login onSuccess={() => setAuth("ok")} />;
|
||||
if (error) return <div className="empty">{error}</div>;
|
||||
if (!info) return <div className="empty">{t("common.loading")}</div>;
|
||||
if (!info.user) return <Login info={info} onSuccess={refresh} />;
|
||||
|
||||
const [section, arg] = route;
|
||||
let content;
|
||||
if (section === "s" && arg) content = <ChatView key={arg} sessionId={arg} onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "files")
|
||||
content = (
|
||||
<Suspense fallback={<div className="empty"><Spinner /></div>}>
|
||||
<Files onMenu={() => setMenuOpen(true)} />
|
||||
</Suspense>
|
||||
);
|
||||
else content = <NewSession onMenu={() => setMenuOpen(true)} onCreated={(id) => navigate(`/s/${id}`)} />;
|
||||
|
||||
return (
|
||||
|
||||
+18
-15
@@ -1,14 +1,6 @@
|
||||
import type { ServerEvent } from "./types";
|
||||
|
||||
const TOKEN_KEY = "codecraft.token";
|
||||
|
||||
export function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? "";
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
// Authentication uses an HttpOnly session cookie set by /api/auth/login.
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
@@ -21,20 +13,32 @@ export class ApiError extends Error {
|
||||
|
||||
export async function api<T = any>(path: string, init: RequestInit & { json?: unknown } = {}): Promise<T> {
|
||||
const headers: Record<string, string> = { ...(init.headers as Record<string, string>) };
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
let body = init.body;
|
||||
if (init.json !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(init.json);
|
||||
}
|
||||
const res = await fetch(`/api${path}`, { ...init, headers, body });
|
||||
const res = await fetch(`/api${path}`, { ...init, headers, body, credentials: "same-origin" });
|
||||
if (res.status === 401 && !path.startsWith("/auth/") && path !== "/info") onUnauthorized?.();
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
let data: any = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
// Proxies answer with HTML error pages (e.g. 413 from nginx).
|
||||
const hint = res.status === 413 ? "request too large – the reverse proxy rejected the upload (client_max_body_size)" : res.statusText || "unexpected response";
|
||||
throw new ApiError(res.status, `HTTP ${res.status}: ${hint}`);
|
||||
}
|
||||
if (!res.ok) throw new ApiError(res.status, data?.error ?? res.statusText);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/** Called when the session expired (set by the app to show the login). */
|
||||
let onUnauthorized: (() => void) | undefined;
|
||||
export function setUnauthorizedHandler(fn: () => void) {
|
||||
onUnauthorized = fn;
|
||||
}
|
||||
|
||||
export const get = <T = any>(path: string) => api<T>(path);
|
||||
export const post = <T = any>(path: string, json?: unknown) => api<T>(path, { method: "POST", json: json ?? {} });
|
||||
export const patch = <T = any>(path: string, json: unknown) => api<T>(path, { method: "PATCH", json });
|
||||
@@ -55,8 +59,7 @@ export function connectEvents(handlers: {
|
||||
|
||||
const open = () => {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const token = encodeURIComponent(getToken());
|
||||
ws = new WebSocket(`${proto}://${location.host}/api/ws?token=${token}`);
|
||||
ws = new WebSocket(`${proto}://${location.host}/api/ws`);
|
||||
ws.onopen = () => {
|
||||
retry = 0;
|
||||
handlers.onOpen();
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+223
-8
@@ -14,8 +14,116 @@ export const de: Messages = {
|
||||
"nav.settings": "Einstellungen",
|
||||
"nav.stats": "Statistik",
|
||||
|
||||
"login.prompt": "Gib das auf dem Server konfigurierte Zugriffstoken ein.",
|
||||
"login.token": "Zugriffstoken",
|
||||
"nav.images": "Bilder",
|
||||
"images.disabled": "Bildgenerierung ist nicht eingerichtet: Ein Admin aktiviert sie unter Einstellungen → Allgemein (fal.ai-Key in FAL_KEY).",
|
||||
"images.promptPlaceholder": "Beschreibe das Bild: Motiv, Stil, Komposition, Farben, Licht… (Strg+Enter)",
|
||||
"images.model": "Modell",
|
||||
"images.size": "Format",
|
||||
"images.count": "Anzahl",
|
||||
"images.countN": "{n}×",
|
||||
"images.folder": "Ordner",
|
||||
"images.generate": "Erzeugen",
|
||||
"images.generating": "Erzeuge…",
|
||||
"images.empty": "Noch keine Bilder.",
|
||||
"images.reuse": "Prompt wiederverwenden",
|
||||
"images.copyPath": "Dateipfad kopieren",
|
||||
"images.deleteConfirm": "Dieses Bild löschen (inklusive Datei)?",
|
||||
"images.size.square_hd": "Quadrat HD",
|
||||
"images.size.square": "Quadrat",
|
||||
"images.size.landscape_4_3": "Quer 4:3",
|
||||
"images.size.landscape_16_9": "Quer 16:9",
|
||||
"images.size.portrait_4_3": "Hoch 3:4",
|
||||
"images.size.portrait_16_9": "Hoch 9:16",
|
||||
"settings.images": "Bildgenerierung (fal.ai)",
|
||||
"settings.imagesEnabled": "Aktiv",
|
||||
"settings.imagesKey": "API-Key",
|
||||
"settings.imagesDefault": "Standardmodell",
|
||||
"settings.imagesDir": "Ordner im Projekt",
|
||||
"settings.imagesHint": "Modelle und Preise pro Bild werden in der Config-Datei bearbeitet ([images]).",
|
||||
"budget.title": "Budgets in diesem Monat",
|
||||
"budget.spent": "Verbraucht",
|
||||
"budget.budget": "Budget",
|
||||
"budget.remaining": "Verbleibend",
|
||||
"budget.provider": "Laut Anbieter",
|
||||
"budget.balance": "Guthaben {v}",
|
||||
"budget.providerSpent": "Abgerechnet {v}",
|
||||
"budget.orgWide": "Organisationsweiter Kostenbericht des Anbieters",
|
||||
"budget.remoteError": "nicht verfügbar",
|
||||
"budget.hint": "Verbraucht = von CodeCraft mit deinen Preisen erfasste Kosten. Werte des Anbieters kommen aus deren APIs (OpenRouter, Anthropic mit Admin-Key, fal.ai mit Admin-Key).",
|
||||
"budget.chip": "Budget {name}: {p}",
|
||||
"budget.chipHint": "Ein Monatsbudget ist fast aufgebraucht – siehe Statistik.",
|
||||
"settings.monthlyBudget": "Monatsbudget",
|
||||
"settings.monthlyBudgetTotal": "Monatsbudget (gesamt)",
|
||||
"settings.monthlyBudgetHint": "In der eingestellten Währung; 0 = keins. Warnungen erscheinen im Chat-Kopf.",
|
||||
"settings.budgetWarning": "Warnen ab",
|
||||
"settings.adminKey": "Admin-Key (optional)",
|
||||
"settings.adminKeyHint": "Anthropic Admin-API-Key (sk-ant-admin01-…), um die echten, organisationsweiten Monatskosten anzuzeigen.",
|
||||
"settings.fetchPrices": "Preise abrufen",
|
||||
"settings.pricesFetched": "Preise für {n} Modelle aktualisiert – zum Übernehmen speichern.",
|
||||
"stats.perMillion": "pro 1 Mio. Tok.",
|
||||
"stats.perMillionHint": "Effektive Kosten pro Million Tokens (Input inkl. Cache + Output)",
|
||||
"queue.pending": "{n} Nachricht(en) in der Warteschlange – werden beim nächsten Schritt übergeben",
|
||||
"queue.stopped": "{n} Nachricht(en) in der Warteschlange",
|
||||
"queue.sendNow": "Jetzt senden",
|
||||
"queue.remove": "Aus der Warteschlange entfernen",
|
||||
"queue.backgroundResult": "Ergebnis einer Hintergrundaufgabe",
|
||||
"permissionMode.auto": "Automatisch (fragt einmal, Sicheres läuft)",
|
||||
"nav.files": "Dateien",
|
||||
"files.search": "Dateien suchen…",
|
||||
"files.newFile": "Neue Datei",
|
||||
"files.newFolder": "Neuer Ordner",
|
||||
"files.upload": "Hochladen",
|
||||
"files.showHidden": "Versteckte Dateien zeigen",
|
||||
"files.newFileName": "Dateiname:",
|
||||
"files.newFolderName": "Ordnername:",
|
||||
"files.renameTo": "Neuer Name:",
|
||||
"files.copyTo": "Name der Kopie:",
|
||||
"files.rename": "Umbenennen",
|
||||
"files.duplicate": "Duplizieren",
|
||||
"files.download": "Herunterladen",
|
||||
"files.actions": "Aktionen",
|
||||
"files.deleteConfirm": "„{name}“ löschen?",
|
||||
"files.deleteFolderConfirm": "Den Ordner „{name}“ mit allem Inhalt löschen?",
|
||||
"files.noHits": "Keine Treffer",
|
||||
"files.emptyFolder": "Leerer Ordner",
|
||||
"files.dropHint": "Dateien hierher ziehen zum Hochladen",
|
||||
"files.selectFile": "Datei auswählen",
|
||||
"files.edit": "Bearbeiten",
|
||||
"files.preview": "Vorschau",
|
||||
"files.binary": "Binärdatei – zum Öffnen herunterladen.",
|
||||
"files.tooLarge": "Datei zu groß für den Editor – bitte herunterladen.",
|
||||
"files.discardChanges": "Ungespeicherte Änderungen verwerfen?",
|
||||
"files.conflict": "Die Datei wurde seit dem Öffnen geändert (z. B. von einem Agenten). Trotzdem überschreiben?",
|
||||
"files.tabFiles": "Dateien",
|
||||
"git.init": "Git-Repository anlegen",
|
||||
"git.clone": "Repository klonen",
|
||||
"git.cloneUrl": "Repository-URL (https://… oder git@…):",
|
||||
"git.cloneFolder": "Ordnername:",
|
||||
"git.branch": "Branch",
|
||||
"git.newBranch": "Neuer Branch",
|
||||
"git.newBranchName": "Name des neuen Branches:",
|
||||
"git.fetch": "Fetch",
|
||||
"git.pull": "Pull",
|
||||
"git.push": "Push",
|
||||
"git.done": "Erledigt.",
|
||||
"git.changes": "Änderungen",
|
||||
"git.history": "Verlauf",
|
||||
"git.staged": "Vorgemerkt",
|
||||
"git.unstaged": "Änderungen",
|
||||
"git.stage": "Vormerken",
|
||||
"git.unstage": "Nicht mehr vormerken",
|
||||
"git.stageAll": "Alle vormerken",
|
||||
"git.unstageAll": "Alle zurücknehmen",
|
||||
"git.discard": "Änderungen verwerfen",
|
||||
"git.discardConfirm": "Alle Änderungen an {file} verwerfen?",
|
||||
"git.openFile": "Datei öffnen",
|
||||
"git.clean": "Keine Änderungen.",
|
||||
"git.message": "Commit-Nachricht",
|
||||
"git.amend": "Letzten Commit ändern",
|
||||
"git.commit": "Commit",
|
||||
"git.noCommits": "Noch keine Commits.",
|
||||
"git.noDiff": "Keine Unterschiede.",
|
||||
"login.prompt": "Melde dich an, um fortzufahren.",
|
||||
"login.invalid": "Ungültiges Token.",
|
||||
"login.submit": "Anmelden",
|
||||
|
||||
@@ -40,9 +148,23 @@ export const de: Messages = {
|
||||
"new.firstPrompt": "Was soll erledigt werden? (optional)",
|
||||
"new.start": "Starten in {dir}",
|
||||
|
||||
"activity.thinking": "Denkt nach…",
|
||||
"activity.writing": "Schreibt…",
|
||||
"activity.tool": "Führt {tool} aus…",
|
||||
"activity.subagent": "Subagent arbeitet: {name}",
|
||||
"activity.waitingForYou": "Pausiert – wartet auf deine Antwort",
|
||||
"activity.working": "Arbeitet",
|
||||
"activity.titleBusy": "{n} aktiv",
|
||||
"activity.titleInput": "Eingabe nötig",
|
||||
"chat.reasoningLive": "Denkt nach…",
|
||||
"chat.reasoningDone": "Gedacht · {n} Wörter",
|
||||
"settings.reasoningDisplay": "Reasoning des Modells",
|
||||
"settings.reasoningCollapsed": "Eingeklappt (per Klick öffnen)",
|
||||
"settings.reasoningExpanded": "Immer aufgeklappt",
|
||||
"settings.reasoningHidden": "Aus",
|
||||
"settings.reasoningHint": "Die Überlegungen des Modells (sofern Modell und Provider sie liefern).",
|
||||
"settings.reasoningLive": "Beim Denken offen, danach einklappen",
|
||||
"chat.empty": "Schreib eine Nachricht, um zu starten.",
|
||||
"chat.reasoning": "Überlegungen",
|
||||
"chat.thinking": "Arbeitet…",
|
||||
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
|
||||
"chat.context": "Kontext",
|
||||
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
|
||||
@@ -66,7 +188,7 @@ export const de: Messages = {
|
||||
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
|
||||
"command.new": "Neue Session starten",
|
||||
|
||||
"permissionMode.default": "Vor Änderungen fragen",
|
||||
"permissionMode.default": "Fragen (außer Lesebefehle)",
|
||||
"permissionMode.accept_edits": "Änderungen automatisch annehmen",
|
||||
"permissionMode.bypass": "Alles erlauben",
|
||||
|
||||
@@ -75,6 +197,7 @@ export const de: Messages = {
|
||||
|
||||
"tool.input": "Eingabe",
|
||||
"tool.output": "Ausgabe",
|
||||
"tool.todosUpdated": "Aufgaben aktualisiert ({done}/{total} erledigt)",
|
||||
"tool.todos": "Aufgaben",
|
||||
"tool.openSubagent": "Subagent-Session öffnen",
|
||||
|
||||
@@ -107,8 +230,6 @@ export const de: Messages = {
|
||||
"settings.language": "Sprache",
|
||||
"settings.theme": "Design",
|
||||
"settings.themeSystem": "System",
|
||||
"settings.themeDark": "Dunkel",
|
||||
"settings.themeLight": "Hell",
|
||||
"settings.notifications": "Benachrichtigungen",
|
||||
"settings.notificationsEnable": "Aktivieren",
|
||||
"settings.notificationsOn": "Aktiv",
|
||||
@@ -159,6 +280,11 @@ export const de: Messages = {
|
||||
"settings.rawHint": "Die komplette Konfigurationsdatei inklusive Kommentaren. Sie wird vor dem Speichern geprüft.",
|
||||
"settings.revert": "Zurücksetzen",
|
||||
|
||||
"mcp.load": "Tools für das Modell",
|
||||
"mcp.loadAuto": "Automatisch (bei vielen Tools bei Bedarf)",
|
||||
"mcp.loadAlways": "Immer alle Tools mitsenden",
|
||||
"mcp.loadOnDemand": "Bei Bedarf (mcp_search / mcp_call)",
|
||||
"mcp.loadHint": "Jedes direkt mitgesendete Tool kostet bei jeder Anfrage Kontext. Große Server besser bei Bedarf laden.",
|
||||
"mcp.reconnect": "Alle neu verbinden",
|
||||
"mcp.none": "Keine MCP-Server konfiguriert.",
|
||||
"mcp.tools": "{n} Tools",
|
||||
@@ -183,7 +309,6 @@ export const de: Messages = {
|
||||
"permissions.ask": "Fragen",
|
||||
"permissions.deny": "Verbieten",
|
||||
|
||||
"agents.intro": "Modi und Subagenten. Eigene in der Config-Datei ([agents.*]) oder als Markdown in .codecraft/agents bzw. .claude/agents anlegen.",
|
||||
"agents.title": "Agenten",
|
||||
"agents.readOnly": "nur lesen",
|
||||
"agents.commands": "Slash-Befehle",
|
||||
@@ -225,4 +350,94 @@ export const de: Messages = {
|
||||
"stats.total": "Gesamt",
|
||||
"stats.other": "Sonstige",
|
||||
"stats.noData": "Keine Anfragen in diesem Zeitraum.",
|
||||
|
||||
"common.cancel": "Abbrechen",
|
||||
"nav.logout": "Abmelden",
|
||||
"login.username": "Benutzername",
|
||||
"login.password": "Passwort",
|
||||
"login.passwordRepeat": "Passwort wiederholen",
|
||||
"login.setupPrompt": "Noch keine Benutzer. Gib den Setup-Code aus dem Server-Log ein und lege den ersten Admin an.",
|
||||
"login.setupCode": "Setup-Code",
|
||||
"login.createAdmin": "Admin anlegen",
|
||||
"login.invalidCode": "Ungültiger Setup-Code.",
|
||||
"login.mismatch": "Die Passwörter stimmen nicht überein.",
|
||||
"login.or": "oder",
|
||||
"sidebar.shared": "Mit dir geteilt",
|
||||
"chat.readOnly": "nur lesen",
|
||||
"chat.readOnlyHint": "Diese Session wurde mit dir geteilt – du kannst mitlesen, aber nicht schreiben.",
|
||||
"chat.backgroundDone": "Hintergrundaufgabe fertig",
|
||||
"chat.waitingModel": "Warte auf freien Slot bei {model}…",
|
||||
"chat.waitingSubagent": "Warte auf freien Subagent-Slot ({agent})…",
|
||||
"chat.anySubagent": "Limit für alle Subagenten",
|
||||
"tool.background": "Hintergrund",
|
||||
"share.title": "Session teilen",
|
||||
"share.hint": "Ausgewählte Benutzer können diese Session lesend verfolgen.",
|
||||
"share.noUsers": "Keine weiteren Benutzer.",
|
||||
"settings.tab.users": "Benutzer",
|
||||
"settings.tab.folders": "Freigegebene Ordner",
|
||||
"settings.tab.account": "Konto",
|
||||
"settings.maxParallelSubagents": "Parallele Subagenten",
|
||||
"settings.maxParallelSubagentsHint": "Max. gleichzeitig laufende Subagenten über alle Sessions (0 = unbegrenzt).",
|
||||
"settings.maxConcurrency": "Parallele Anfragen",
|
||||
"settings.maxConcurrencyHint": "Max. gleichzeitige Anfragen an diesen Host/dieses Modell; weitere warten in einer Warteschlange. 1 für Server, die nur eine Anfrage gleichzeitig verarbeiten.",
|
||||
"settings.slots": "Slots",
|
||||
"account.title": "Konto",
|
||||
"account.current": "Aktuelles Passwort",
|
||||
"account.new": "Neues Passwort",
|
||||
"account.change": "Passwort ändern",
|
||||
"account.changed": "Passwort geändert. Andere Geräte wurden abgemeldet.",
|
||||
"account.hint": "Mindestens 8 Zeichen.",
|
||||
"users.intro": "Benutzer arbeiten in ihrem eigenen Ordner <workspace>/<benutzername> und in den für sie freigegebenen Ordnern. Admins können den ganzen Workspace nutzen und den Server verwalten.",
|
||||
"users.username": "Benutzername",
|
||||
"users.usernameHint": "Buchstaben, Ziffern, - _ . – gleichzeitig der Name des Benutzerordners.",
|
||||
"users.displayName": "Anzeigename",
|
||||
"users.role": "Rolle",
|
||||
"users.roleUser": "Benutzer",
|
||||
"users.roleAdmin": "Admin",
|
||||
"users.login": "Anmeldung",
|
||||
"users.password": "Passwort",
|
||||
"users.passwordHint": "Leer lassen für reine Single-Sign-on-Anmeldung.",
|
||||
"users.created": "Angelegt",
|
||||
"users.you": "du",
|
||||
"users.setPassword": "Passwort setzen",
|
||||
"users.newPasswordFor": "Neues Passwort für {name} (mind. 8 Zeichen):",
|
||||
"users.enable": "Aktivieren",
|
||||
"users.disable": "Deaktivieren",
|
||||
"users.deleteConfirm": "Benutzer „{name}“ löschen? Seine Sessions bleiben für Admins sichtbar.",
|
||||
"users.add": "Benutzer hinzufügen",
|
||||
"folders.intro": "Ordner im Workspace, in denen ausgewählte Benutzer zusätzlich zu ihrem eigenen Ordner arbeiten dürfen.",
|
||||
"folders.none": "Noch keine freigegebenen Ordner.",
|
||||
"folders.noUsers": "Keine Benutzer ohne Admin-Rolle.",
|
||||
"folders.add": "Ordner freigeben",
|
||||
"folders.addHint": "Der Ordner wird angelegt, falls er nicht existiert. Bestehende Projektordner lassen sich ebenso freigeben.",
|
||||
"folders.remove": "Freigabe aufheben",
|
||||
"folders.removeConfirm": "Freigabe dieses Ordners aufheben? Die Dateien bleiben erhalten.",
|
||||
"agents.intro": "Modi und Subagenten mit ihrem Modell. Ein Subagent kann auf einem anderen Modell oder Host laufen – z. B. ein kleines Modell, das im Hintergrund Doku oder Übersetzungen schreibt.",
|
||||
"agents.primaryTitle": "Modi",
|
||||
"agents.subagentTitle": "Subagenten",
|
||||
"agents.modePrimary": "Modus",
|
||||
"agents.modeSubagent": "Subagent",
|
||||
"agents.source.builtin": "eingebaut",
|
||||
"agents.source.config": "Config",
|
||||
"agents.source.file": "Datei",
|
||||
"agents.model": "Modell",
|
||||
"agents.sessionModel": "Session-Modell",
|
||||
"agents.modelHint": "Festes Modell für diesen Agenten; leer = das im Chat gewählte Modell.",
|
||||
"agents.maxParallel": "Max. parallel",
|
||||
"agents.maxParallelShort": "max. {n} parallel",
|
||||
"agents.description": "Beschreibung",
|
||||
"agents.descriptionHint": "Anhand dieser Beschreibung entscheidet der Hauptagent, wann er delegiert.",
|
||||
"agents.mode": "Art",
|
||||
"agents.tools": "Tools",
|
||||
"agents.toolsAll": "alle Tools (z. B. read, glob, grep)",
|
||||
"agents.prompt": "Anweisungen",
|
||||
"agents.reset": "Zurücksetzen",
|
||||
"agents.resetConfirm": "„{name}“ auf die eingebauten Standardwerte zurücksetzen?",
|
||||
"agents.deleteConfirm": "Agent „{name}“ löschen?",
|
||||
"agents.add": "Agent hinzufügen",
|
||||
"agents.addWriter": "Writer (Doku, Zusammenfassungen, Übersetzungen)",
|
||||
"agents.addBlank": "Leerer Agent",
|
||||
"stats.user": "Benutzer",
|
||||
"stats.allUsers": "Alle Benutzer",
|
||||
"stats.byUser": "Nach Benutzer",
|
||||
};
|
||||
+223
-8
@@ -13,8 +13,116 @@ export const en = {
|
||||
"nav.settings": "Settings",
|
||||
"nav.stats": "Statistics",
|
||||
|
||||
"login.prompt": "Enter the access token configured on the server.",
|
||||
"login.token": "Access token",
|
||||
"nav.images": "Images",
|
||||
"images.disabled": "Image generation is not set up: an admin enables it under Settings → General (fal.ai key in FAL_KEY).",
|
||||
"images.promptPlaceholder": "Describe the image: subject, style, composition, colours, lighting… (Ctrl+Enter)",
|
||||
"images.model": "Model",
|
||||
"images.size": "Format",
|
||||
"images.count": "Number",
|
||||
"images.countN": "{n}×",
|
||||
"images.folder": "Folder",
|
||||
"images.generate": "Generate",
|
||||
"images.generating": "Generating…",
|
||||
"images.empty": "No images yet.",
|
||||
"images.reuse": "Reuse prompt",
|
||||
"images.copyPath": "Copy file path",
|
||||
"images.deleteConfirm": "Delete this image (including the file)?",
|
||||
"images.size.square_hd": "Square HD",
|
||||
"images.size.square": "Square",
|
||||
"images.size.landscape_4_3": "Landscape 4:3",
|
||||
"images.size.landscape_16_9": "Landscape 16:9",
|
||||
"images.size.portrait_4_3": "Portrait 3:4",
|
||||
"images.size.portrait_16_9": "Portrait 9:16",
|
||||
"settings.images": "Image generation (fal.ai)",
|
||||
"settings.imagesEnabled": "Enabled",
|
||||
"settings.imagesKey": "API key",
|
||||
"settings.imagesDefault": "Default model",
|
||||
"settings.imagesDir": "Folder in the project",
|
||||
"settings.imagesHint": "Models and prices per image are edited in the config file ([images]).",
|
||||
"budget.title": "Budgets this month",
|
||||
"budget.spent": "Spent",
|
||||
"budget.budget": "Budget",
|
||||
"budget.remaining": "Remaining",
|
||||
"budget.provider": "According to provider",
|
||||
"budget.balance": "Balance {v}",
|
||||
"budget.providerSpent": "Billed {v}",
|
||||
"budget.orgWide": "Organisation-wide cost report of the provider",
|
||||
"budget.remoteError": "not available",
|
||||
"budget.hint": "Spent = costs recorded by CodeCraft with your configured prices. Provider values come from their APIs (OpenRouter, Anthropic with admin key, fal.ai with admin key).",
|
||||
"budget.chip": "Budget {name}: {p}",
|
||||
"budget.chipHint": "A monthly budget is nearly used up – see Statistics.",
|
||||
"settings.monthlyBudget": "Monthly budget",
|
||||
"settings.monthlyBudgetTotal": "Monthly budget (total)",
|
||||
"settings.monthlyBudgetHint": "In the configured currency; 0 = none. Warnings appear in the chat header.",
|
||||
"settings.budgetWarning": "Warn at",
|
||||
"settings.adminKey": "Admin key (optional)",
|
||||
"settings.adminKeyHint": "Anthropic Admin API key (sk-ant-admin01-…) to show the real, organisation-wide monthly cost.",
|
||||
"settings.fetchPrices": "Fetch prices",
|
||||
"settings.pricesFetched": "Prices updated for {n} models – save to keep them.",
|
||||
"stats.perMillion": "per 1M tok",
|
||||
"stats.perMillionHint": "Effective cost per million tokens (input incl. cache + output)",
|
||||
"queue.pending": "{n} message(s) waiting – delivered at the agent's next step",
|
||||
"queue.stopped": "{n} message(s) waiting",
|
||||
"queue.sendNow": "Send now",
|
||||
"queue.remove": "Remove from queue",
|
||||
"queue.backgroundResult": "Result of a background task",
|
||||
"permissionMode.auto": "Automatic (asks once, safe steps run)",
|
||||
"nav.files": "Files",
|
||||
"files.search": "Search files…",
|
||||
"files.newFile": "New file",
|
||||
"files.newFolder": "New folder",
|
||||
"files.upload": "Upload",
|
||||
"files.showHidden": "Show hidden files",
|
||||
"files.newFileName": "File name:",
|
||||
"files.newFolderName": "Folder name:",
|
||||
"files.renameTo": "New name:",
|
||||
"files.copyTo": "Name of the copy:",
|
||||
"files.rename": "Rename",
|
||||
"files.duplicate": "Duplicate",
|
||||
"files.download": "Download",
|
||||
"files.actions": "Actions",
|
||||
"files.deleteConfirm": "Delete \"{name}\"?",
|
||||
"files.deleteFolderConfirm": "Delete the folder \"{name}\" with everything in it?",
|
||||
"files.noHits": "No matches",
|
||||
"files.emptyFolder": "Empty folder",
|
||||
"files.dropHint": "Drop files here to upload",
|
||||
"files.selectFile": "Select a file",
|
||||
"files.edit": "Edit",
|
||||
"files.preview": "Preview",
|
||||
"files.binary": "Binary file – download it to open.",
|
||||
"files.tooLarge": "File too large for the editor – download it.",
|
||||
"files.discardChanges": "Discard unsaved changes?",
|
||||
"files.conflict": "The file was changed on disk (e.g. by an agent) since you opened it. Overwrite it anyway?",
|
||||
"files.tabFiles": "Files",
|
||||
"git.init": "Initialise git repository",
|
||||
"git.clone": "Clone repository",
|
||||
"git.cloneUrl": "Repository URL (https://… or git@…):",
|
||||
"git.cloneFolder": "Folder name:",
|
||||
"git.branch": "Branch",
|
||||
"git.newBranch": "New branch",
|
||||
"git.newBranchName": "Name of the new branch:",
|
||||
"git.fetch": "Fetch",
|
||||
"git.pull": "Pull",
|
||||
"git.push": "Push",
|
||||
"git.done": "Done.",
|
||||
"git.changes": "Changes",
|
||||
"git.history": "History",
|
||||
"git.staged": "Staged",
|
||||
"git.unstaged": "Changes",
|
||||
"git.stage": "Stage",
|
||||
"git.unstage": "Unstage",
|
||||
"git.stageAll": "Stage all",
|
||||
"git.unstageAll": "Unstage all",
|
||||
"git.discard": "Discard changes",
|
||||
"git.discardConfirm": "Discard all changes to {file}?",
|
||||
"git.openFile": "Open file",
|
||||
"git.clean": "No changes.",
|
||||
"git.message": "Commit message",
|
||||
"git.amend": "Amend last commit",
|
||||
"git.commit": "Commit",
|
||||
"git.noCommits": "No commits yet.",
|
||||
"git.noDiff": "No differences.",
|
||||
"login.prompt": "Sign in to continue.",
|
||||
"login.invalid": "Invalid token.",
|
||||
"login.submit": "Sign in",
|
||||
|
||||
@@ -39,9 +147,23 @@ export const en = {
|
||||
"new.firstPrompt": "What should be done? (optional)",
|
||||
"new.start": "Start in {dir}",
|
||||
|
||||
"activity.thinking": "Thinking…",
|
||||
"activity.writing": "Writing…",
|
||||
"activity.tool": "Running {tool}…",
|
||||
"activity.subagent": "Subagent working: {name}",
|
||||
"activity.waitingForYou": "Paused – waiting for your answer",
|
||||
"activity.working": "Working",
|
||||
"activity.titleBusy": "{n} working",
|
||||
"activity.titleInput": "Input needed",
|
||||
"chat.reasoningLive": "Thinking…",
|
||||
"chat.reasoningDone": "Thought · {n} words",
|
||||
"settings.reasoningDisplay": "Model reasoning",
|
||||
"settings.reasoningCollapsed": "Collapsed (click to open)",
|
||||
"settings.reasoningExpanded": "Always expanded",
|
||||
"settings.reasoningHidden": "Off",
|
||||
"settings.reasoningHint": "Thinking of the model (if the model and provider return it).",
|
||||
"settings.reasoningLive": "Open while thinking, then collapse",
|
||||
"chat.empty": "Send a message to start.",
|
||||
"chat.reasoning": "Reasoning",
|
||||
"chat.thinking": "Working…",
|
||||
"chat.compacted": "Conversation compacted – summary",
|
||||
"chat.context": "Context",
|
||||
"chat.contextHint": "{tokens} tokens in the last request",
|
||||
@@ -65,7 +187,7 @@ export const en = {
|
||||
"command.compact": "Summarize the conversation to free up context",
|
||||
"command.new": "Start a new session",
|
||||
|
||||
"permissionMode.default": "Ask before changes",
|
||||
"permissionMode.default": "Ask (except read-only commands)",
|
||||
"permissionMode.accept_edits": "Auto-accept edits",
|
||||
"permissionMode.bypass": "Allow everything",
|
||||
|
||||
@@ -74,6 +196,7 @@ export const en = {
|
||||
|
||||
"tool.input": "Input",
|
||||
"tool.output": "Output",
|
||||
"tool.todosUpdated": "Tasks updated ({done}/{total} done)",
|
||||
"tool.todos": "Tasks",
|
||||
"tool.openSubagent": "Open subagent session",
|
||||
|
||||
@@ -106,8 +229,6 @@ export const en = {
|
||||
"settings.language": "Language",
|
||||
"settings.theme": "Theme",
|
||||
"settings.themeSystem": "System",
|
||||
"settings.themeDark": "Dark",
|
||||
"settings.themeLight": "Light",
|
||||
"settings.notifications": "Notifications",
|
||||
"settings.notificationsEnable": "Enable",
|
||||
"settings.notificationsOn": "Enabled",
|
||||
@@ -158,6 +279,11 @@ export const en = {
|
||||
"settings.rawHint": "The complete configuration file including comments. It is validated before saving.",
|
||||
"settings.revert": "Revert",
|
||||
|
||||
"mcp.load": "Tools for the model",
|
||||
"mcp.loadAuto": "Automatic (on demand when many tools)",
|
||||
"mcp.loadAlways": "Always send all tools",
|
||||
"mcp.loadOnDemand": "On demand (mcp_search / mcp_call)",
|
||||
"mcp.loadHint": "Every tool sent directly costs context on every request. Large servers are better loaded on demand.",
|
||||
"mcp.reconnect": "Reconnect all",
|
||||
"mcp.none": "No MCP servers configured.",
|
||||
"mcp.tools": "{n} tools",
|
||||
@@ -182,7 +308,6 @@ export const en = {
|
||||
"permissions.ask": "Ask",
|
||||
"permissions.deny": "Deny",
|
||||
|
||||
"agents.intro": "Modes and subagents. Add your own in the config file ([agents.*]) or as Markdown in .codecraft/agents or .claude/agents.",
|
||||
"agents.title": "Agents",
|
||||
"agents.readOnly": "read-only",
|
||||
"agents.commands": "Slash commands",
|
||||
@@ -224,6 +349,96 @@ export const en = {
|
||||
"stats.total": "Total",
|
||||
"stats.other": "Other",
|
||||
"stats.noData": "No requests in this period.",
|
||||
|
||||
"common.cancel": "Cancel",
|
||||
"nav.logout": "Sign out",
|
||||
"login.username": "Username",
|
||||
"login.password": "Password",
|
||||
"login.passwordRepeat": "Repeat password",
|
||||
"login.setupPrompt": "No users yet. Enter the setup code from the server log and create the first admin.",
|
||||
"login.setupCode": "Setup code",
|
||||
"login.createAdmin": "Create admin",
|
||||
"login.invalidCode": "Invalid setup code.",
|
||||
"login.mismatch": "The passwords do not match.",
|
||||
"login.or": "or",
|
||||
"sidebar.shared": "Shared with you",
|
||||
"chat.readOnly": "read-only",
|
||||
"chat.readOnlyHint": "This session was shared with you – you can follow it, but not write.",
|
||||
"chat.backgroundDone": "Background task finished",
|
||||
"chat.waitingModel": "Waiting for a free slot on {model}…",
|
||||
"chat.waitingSubagent": "Waiting for a free subagent slot ({agent})…",
|
||||
"chat.anySubagent": "limit for all subagents",
|
||||
"tool.background": "background",
|
||||
"share.title": "Share session",
|
||||
"share.hint": "Selected users can follow this session read-only.",
|
||||
"share.noUsers": "No other users.",
|
||||
"settings.tab.users": "Users",
|
||||
"settings.tab.folders": "Shared folders",
|
||||
"settings.tab.account": "Account",
|
||||
"settings.maxParallelSubagents": "Parallel subagents",
|
||||
"settings.maxParallelSubagentsHint": "Max. subagents running at the same time across all sessions (0 = unlimited).",
|
||||
"settings.maxConcurrency": "Parallel requests",
|
||||
"settings.maxConcurrencyHint": "Max. simultaneous requests to this host/model; more wait in a queue. 1 for servers that process one request at a time.",
|
||||
"settings.slots": "Slots",
|
||||
"account.title": "Account",
|
||||
"account.current": "Current password",
|
||||
"account.new": "New password",
|
||||
"account.change": "Change password",
|
||||
"account.changed": "Password changed. Other devices were signed out.",
|
||||
"account.hint": "At least 8 characters.",
|
||||
"users.intro": "Users work in their own folder <workspace>/<username> plus the folders shared with them. Admins can use the whole workspace and manage the server.",
|
||||
"users.username": "Username",
|
||||
"users.usernameHint": "Letters, digits, - _ . – also the name of the user's folder.",
|
||||
"users.displayName": "Display name",
|
||||
"users.role": "Role",
|
||||
"users.roleUser": "User",
|
||||
"users.roleAdmin": "Admin",
|
||||
"users.login": "Sign-in",
|
||||
"users.password": "Password",
|
||||
"users.passwordHint": "Leave empty for single sign-on only.",
|
||||
"users.created": "Created",
|
||||
"users.you": "you",
|
||||
"users.setPassword": "Set password",
|
||||
"users.newPasswordFor": "New password for {name} (min. 8 characters):",
|
||||
"users.enable": "Enable",
|
||||
"users.disable": "Disable",
|
||||
"users.deleteConfirm": "Delete user \"{name}\"? Their sessions stay visible to admins.",
|
||||
"users.add": "Add user",
|
||||
"folders.intro": "Folders inside the workspace that selected users may work in, in addition to their own folder.",
|
||||
"folders.none": "No shared folders yet.",
|
||||
"folders.noUsers": "No users without admin role.",
|
||||
"folders.add": "Share a folder",
|
||||
"folders.addHint": "The folder is created if it does not exist. Existing project folders can be shared too.",
|
||||
"folders.remove": "Stop sharing",
|
||||
"folders.removeConfirm": "Stop sharing this folder? The files are kept.",
|
||||
"agents.intro": "Modes and subagents with their model. A subagent can run on another model or host – e.g. a small model that writes docs or translations in the background.",
|
||||
"agents.primaryTitle": "Modes",
|
||||
"agents.subagentTitle": "Subagents",
|
||||
"agents.modePrimary": "mode",
|
||||
"agents.modeSubagent": "subagent",
|
||||
"agents.source.builtin": "built-in",
|
||||
"agents.source.config": "config",
|
||||
"agents.source.file": "file",
|
||||
"agents.model": "Model",
|
||||
"agents.sessionModel": "Session model",
|
||||
"agents.modelHint": "Fixed model for this agent; empty = the model selected in the chat.",
|
||||
"agents.maxParallel": "Max. parallel",
|
||||
"agents.maxParallelShort": "max. {n} parallel",
|
||||
"agents.description": "Description",
|
||||
"agents.descriptionHint": "The main agent decides by this description when to delegate.",
|
||||
"agents.mode": "Type",
|
||||
"agents.tools": "Tools",
|
||||
"agents.toolsAll": "all tools (e.g. read, glob, grep)",
|
||||
"agents.prompt": "Instructions",
|
||||
"agents.reset": "Reset",
|
||||
"agents.resetConfirm": "Reset \"{name}\" to the built-in defaults?",
|
||||
"agents.deleteConfirm": "Delete agent \"{name}\"?",
|
||||
"agents.add": "Add agent",
|
||||
"agents.addWriter": "Writer (docs, summaries, translations)",
|
||||
"agents.addBlank": "Empty agent",
|
||||
"stats.user": "User",
|
||||
"stats.allUsers": "All users",
|
||||
"stats.byUser": "By user",
|
||||
};
|
||||
|
||||
export type Messages = Record<keyof typeof en, string>;
|
||||
@@ -15,11 +15,10 @@ export const languages: Record<string, { name: string; messages: Messages }> = {
|
||||
|
||||
const STORAGE_KEY = "codecraft.lang";
|
||||
|
||||
/** English is the default; other languages are chosen in the settings. */
|
||||
function detect(): string {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved && languages[saved]) return saved;
|
||||
const nav = navigator.language.slice(0, 2);
|
||||
return languages[nav] ? nav : "en";
|
||||
return saved && languages[saved] ? saved : "en";
|
||||
}
|
||||
|
||||
let current = detect();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Per-device display preferences (localStorage), observable from React.
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type ReasoningMode = "collapsed" | "live" | "expanded" | "hidden";
|
||||
|
||||
const KEY = "codecraft.reasoning";
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getReasoningMode(): ReasoningMode {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return v === "live" || v === "expanded" || v === "hidden" ? v : "collapsed";
|
||||
}
|
||||
|
||||
export function setReasoningMode(mode: ReasoningMode) {
|
||||
localStorage.setItem(KEY, mode);
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function useReasoningMode(): ReasoningMode {
|
||||
return useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
getReasoningMode,
|
||||
);
|
||||
}
|
||||
+24
-2
@@ -1,6 +1,8 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { get } from "./api";
|
||||
import type {
|
||||
QueueItem,
|
||||
User,
|
||||
McpServerStatus,
|
||||
Message,
|
||||
Part,
|
||||
@@ -22,11 +24,14 @@ export interface SessionError {
|
||||
}
|
||||
|
||||
export interface State {
|
||||
me: User | null;
|
||||
connected: boolean;
|
||||
sessions: Session[];
|
||||
/** Includes subagent sessions loaded on demand. */
|
||||
sessionById: Record<string, Session>;
|
||||
messages: Record<string, Message[]>;
|
||||
/** Sessions the current user may only read (shared with them). */
|
||||
readOnly: Record<string, boolean>;
|
||||
drafts: Record<string, Draft | undefined>;
|
||||
busy: Record<string, boolean>;
|
||||
progress: Record<string, string>;
|
||||
@@ -34,13 +39,19 @@ export interface State {
|
||||
questions: QuestionRequest[];
|
||||
errors: Record<string, SessionError[]>;
|
||||
mcp: Record<string, McpServerStatus>;
|
||||
/** Messages waiting for a busy session. */
|
||||
queue: Record<string, QueueItem[]>;
|
||||
/** Why a session waits: "model:<spec>" or "subagent:<name>". */
|
||||
waiting: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
let state: State = {
|
||||
me: null,
|
||||
connected: false,
|
||||
sessions: [],
|
||||
sessionById: {},
|
||||
messages: {},
|
||||
readOnly: {},
|
||||
drafts: {},
|
||||
busy: {},
|
||||
progress: {},
|
||||
@@ -48,6 +59,8 @@ let state: State = {
|
||||
questions: [],
|
||||
errors: {},
|
||||
mcp: {},
|
||||
queue: {},
|
||||
waiting: {},
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
@@ -157,6 +170,12 @@ export function applyEvent(ev: ServerEvent) {
|
||||
},
|
||||
}));
|
||||
break;
|
||||
case "queue_updated":
|
||||
setState((s) => ({ queue: { ...s.queue, [ev.session_id]: ev.items } }));
|
||||
break;
|
||||
case "waiting":
|
||||
setState((s) => ({ waiting: { ...s.waiting, [ev.session_id]: ev.reason ?? undefined } }));
|
||||
break;
|
||||
case "mcp_status":
|
||||
setState(() => ({ mcp: ev.servers }));
|
||||
break;
|
||||
@@ -188,18 +207,21 @@ export async function loadSessions() {
|
||||
}
|
||||
|
||||
export async function loadSession(id: string) {
|
||||
const data = await get<{ session: Session; messages: Message[]; busy: boolean }>(`/sessions/${id}`);
|
||||
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[] }>(`/sessions/${id}`);
|
||||
setState((s) => ({
|
||||
...upsertSession(s, data.session),
|
||||
sessionById: { ...s.sessionById, [id]: data.session },
|
||||
messages: { ...s.messages, [id]: data.messages },
|
||||
busy: { ...s.busy, [id]: data.busy },
|
||||
readOnly: { ...s.readOnly, [id]: !data.can_write },
|
||||
queue: { ...s.queue, [id]: data.queue ?? [] },
|
||||
}));
|
||||
}
|
||||
|
||||
/** After reconnects: reload everything that could have changed. */
|
||||
export async function resync() {
|
||||
const [pending, mcp] = await Promise.all([get<any[]>("/pending"), get("/mcp")]);
|
||||
const isAdmin = getState().me?.role === "admin";
|
||||
const [pending, mcp] = await Promise.all([get<any[]>("/pending"), isAdmin ? get("/mcp") : Promise.resolve({})]);
|
||||
setState(() => ({
|
||||
permissions: pending.filter((e) => e.type === "permission_request"),
|
||||
questions: pending.filter((e) => e.type === "question_request"),
|
||||
|
||||
+772
-137
@@ -1,126 +1,6 @@
|
||||
/* CodeCraft – Gruvbox theme (dark default, light via data-theme or OS). */
|
||||
/* CodeCraft – layout and components. Colours come exclusively from the theme
|
||||
variables defined in src/themes/*.css. */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg0: #282828;
|
||||
--bg1: #32302f;
|
||||
--bg2: #3c3836;
|
||||
--bg3: #504945;
|
||||
--fg0: #fbf1c7;
|
||||
--fg1: #ebdbb2;
|
||||
--fg2: #d5c4a1;
|
||||
--fg3: #a89984;
|
||||
--muted: #928374;
|
||||
--accent: #fabd2f;
|
||||
--accent2: #fe8019;
|
||||
--red: #fb4934;
|
||||
--green: #b8bb26;
|
||||
--aqua: #8ec07c;
|
||||
--blue: #83a598;
|
||||
--purple: #d3869b;
|
||||
--diff-add: rgba(184, 187, 38, 0.16);
|
||||
--diff-del: rgba(251, 73, 52, 0.16);
|
||||
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
|
||||
/* Data viz: validated categorical palette (dark steps on #282828). */
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
/* Sequential (heatmap), blue ramp: low → high on dark. */
|
||||
--seq-0: #32302f;
|
||||
--seq-1: #104281;
|
||||
--seq-2: #184f95;
|
||||
--seq-3: #256abf;
|
||||
--seq-4: #3987e5;
|
||||
--seq-5: #6da7ec;
|
||||
--seq-6: #9ec5f4;
|
||||
--radius: 8px;
|
||||
--font: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:where(:not([data-theme="dark"])) {
|
||||
color-scheme: light;
|
||||
--bg0: #fbf1c7;
|
||||
--bg1: #f2e5bc;
|
||||
--bg2: #ebdbb2;
|
||||
--bg3: #d5c4a1;
|
||||
--fg0: #282828;
|
||||
--fg1: #3c3836;
|
||||
--fg2: #504945;
|
||||
--fg3: #665c54;
|
||||
--muted: #7c6f64;
|
||||
--accent: #b57614;
|
||||
--accent2: #af3a03;
|
||||
--red: #9d0006;
|
||||
--green: #79740e;
|
||||
--aqua: #427b58;
|
||||
--blue: #076678;
|
||||
--purple: #8f3f71;
|
||||
--diff-add: rgba(121, 116, 14, 0.16);
|
||||
--diff-del: rgba(157, 0, 6, 0.12);
|
||||
--shadow: 0 6px 24px rgba(60, 56, 54, 0.18);
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
--series-3: #1baf7a;
|
||||
--series-4: #eda100;
|
||||
--series-5: #e87ba4;
|
||||
--series-6: #008300;
|
||||
--series-7: #6250d6;
|
||||
--series-8: #e34948;
|
||||
--seq-0: #f2e5bc;
|
||||
--seq-1: #cde2fb;
|
||||
--seq-2: #9ec5f4;
|
||||
--seq-3: #6da7ec;
|
||||
--seq-4: #3987e5;
|
||||
--seq-5: #256abf;
|
||||
--seq-6: #184f95;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg0: #fbf1c7;
|
||||
--bg1: #f2e5bc;
|
||||
--bg2: #ebdbb2;
|
||||
--bg3: #d5c4a1;
|
||||
--fg0: #282828;
|
||||
--fg1: #3c3836;
|
||||
--fg2: #504945;
|
||||
--fg3: #665c54;
|
||||
--muted: #7c6f64;
|
||||
--accent: #b57614;
|
||||
--accent2: #af3a03;
|
||||
--red: #9d0006;
|
||||
--green: #79740e;
|
||||
--aqua: #427b58;
|
||||
--blue: #076678;
|
||||
--purple: #8f3f71;
|
||||
--diff-add: rgba(121, 116, 14, 0.16);
|
||||
--diff-del: rgba(157, 0, 6, 0.12);
|
||||
--shadow: 0 6px 24px rgba(60, 56, 54, 0.18);
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
--series-3: #1baf7a;
|
||||
--series-4: #eda100;
|
||||
--series-5: #e87ba4;
|
||||
--series-6: #008300;
|
||||
--series-7: #6250d6;
|
||||
--series-8: #e34948;
|
||||
--seq-0: #f2e5bc;
|
||||
--seq-1: #cde2fb;
|
||||
--seq-2: #9ec5f4;
|
||||
--seq-3: #6da7ec;
|
||||
--seq-4: #3987e5;
|
||||
--seq-5: #256abf;
|
||||
--seq-6: #184f95;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -176,7 +56,7 @@ button:disabled {
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #282828;
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
button.danger {
|
||||
@@ -335,7 +215,8 @@ textarea:focus {
|
||||
border-top: 1px solid var(--bg2);
|
||||
padding: 8px;
|
||||
padding-bottom: max(8px, env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
.sidebar-foot button {
|
||||
@@ -503,13 +384,41 @@ textarea:focus {
|
||||
padding-left: 10px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.reasoning summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
.reasoning-head {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 2px 0;
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.reasoning-head:hover {
|
||||
color: var(--fg2);
|
||||
}
|
||||
.reasoning .body {
|
||||
white-space: pre-wrap;
|
||||
margin-top: 4px;
|
||||
font-style: italic;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.reasoning.live .body {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.reasoning.live {
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.streaming-text::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0.5em;
|
||||
height: 1em;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
background: var(--muted);
|
||||
animation: blink 1s steps(1) infinite;
|
||||
}
|
||||
|
||||
.tool {
|
||||
@@ -750,8 +659,29 @@ textarea:focus {
|
||||
font-size: 12.5px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
.composer-bar .grow {
|
||||
.composer-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.composer-options > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.todo-line {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
margin: 4px 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mode-tabs {
|
||||
display: inline-flex;
|
||||
@@ -768,7 +698,7 @@ textarea:focus {
|
||||
}
|
||||
.mode-tabs button.active {
|
||||
background: var(--accent);
|
||||
color: #282828;
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.attachments {
|
||||
@@ -916,6 +846,9 @@ table.grid input {
|
||||
width: 100%;
|
||||
min-width: 60px;
|
||||
}
|
||||
table.grid input[type="number"] {
|
||||
min-width: 92px;
|
||||
}
|
||||
.raw-editor {
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
@@ -1123,46 +1056,83 @@ table.grid input {
|
||||
.chat {
|
||||
padding: 12px 10px 20px;
|
||||
}
|
||||
.hide-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
.topbar {
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
.topbar .path {
|
||||
display: none;
|
||||
}
|
||||
.topbar button.small {
|
||||
padding: 2px 5px;
|
||||
}
|
||||
/* One scrollable row of options, actions stay visible. */
|
||||
.composer-bar {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.composer-options {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.composer-options::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.composer-options select {
|
||||
max-width: 150px;
|
||||
}
|
||||
/* Avoid iOS zoom on focus. */
|
||||
.composer textarea,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- syntax highlighting (highlight.js classes, Gruvbox colours) ----------- */
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-built_in {
|
||||
color: var(--red);
|
||||
color: var(--syntax-keyword);
|
||||
}
|
||||
.hljs-string,
|
||||
.hljs-attr,
|
||||
.hljs-regexp {
|
||||
color: var(--green);
|
||||
color: var(--syntax-string);
|
||||
}
|
||||
.hljs-number,
|
||||
.hljs-literal {
|
||||
color: var(--purple);
|
||||
color: var(--syntax-number);
|
||||
}
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: var(--muted);
|
||||
color: var(--syntax-comment);
|
||||
font-style: italic;
|
||||
}
|
||||
.hljs-title,
|
||||
.hljs-section,
|
||||
.hljs-function {
|
||||
color: var(--aqua);
|
||||
color: var(--syntax-function);
|
||||
}
|
||||
.hljs-type,
|
||||
.hljs-class,
|
||||
.hljs-tag {
|
||||
color: var(--accent);
|
||||
color: var(--syntax-type);
|
||||
}
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-params {
|
||||
color: var(--blue);
|
||||
color: var(--syntax-variable);
|
||||
}
|
||||
.hljs-meta,
|
||||
.hljs-symbol {
|
||||
color: var(--accent2);
|
||||
color: var(--syntax-meta);
|
||||
}
|
||||
.hljs-addition {
|
||||
background: var(--diff-add);
|
||||
@@ -1170,3 +1140,668 @@ table.grid input {
|
||||
.hljs-deletion {
|
||||
background: var(--diff-del);
|
||||
}
|
||||
|
||||
a.button {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: var(--fg1);
|
||||
border: 1px solid var(--bg3);
|
||||
background: var(--bg2);
|
||||
border-radius: 6px;
|
||||
padding: 5px 12px;
|
||||
}
|
||||
a.button:hover {
|
||||
border-color: var(--fg3);
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--bg2);
|
||||
font-size: 13px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.sidebar-user > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 30;
|
||||
padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg3);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
width: min(440px, 100%);
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.modal h3 {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.check-list label {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
}
|
||||
.readonly-bar {
|
||||
border-top: 1px solid var(--bg2);
|
||||
padding: 10px 14px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding-bottom: max(10px, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* --- activity animations ---------------------------------------------------- */
|
||||
|
||||
.activity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
font-size: 13px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
.activity.paused {
|
||||
color: var(--accent2);
|
||||
}
|
||||
.dots {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.dots i {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: dot-bounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
.dots i:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.dots i:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
@keyframes dot-bounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.35;
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-5px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.pause-icon {
|
||||
width: 10px;
|
||||
height: 12px;
|
||||
border-left: 3px solid var(--accent2);
|
||||
border-right: 3px solid var(--accent2);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg3);
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.spinner.small {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.tool.running {
|
||||
border-color: var(--accent);
|
||||
animation: glow 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes glow {
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.busy-ring {
|
||||
flex-shrink: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg3);
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Blinking caret at the end of streaming text. */
|
||||
.streaming .markdown > :last-child::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0.55em;
|
||||
height: 1.05em;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
background: var(--accent);
|
||||
animation: blink 1s steps(1) infinite;
|
||||
}
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Indeterminate progress bar under the top bar. */
|
||||
.workbar {
|
||||
height: 2px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.workbar.on::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 35%;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
animation: slide 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes slide {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(300%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dots i,
|
||||
.spinner,
|
||||
.busy-ring,
|
||||
.tool.running,
|
||||
.streaming .markdown > :last-child::after,
|
||||
.streaming-text::after,
|
||||
.workbar.on::before {
|
||||
animation: none;
|
||||
}
|
||||
.dots i {
|
||||
opacity: 1;
|
||||
}
|
||||
.workbar.on::before {
|
||||
width: 100%;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- image gallery --------------------------------------------------------- */
|
||||
|
||||
.gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.gallery.small {
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
margin-top: 6px;
|
||||
}
|
||||
.gallery-item {
|
||||
margin: 0;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg2);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.gallery-item img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
cursor: zoom-in;
|
||||
background: var(--bg2);
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.gallery-item.placeholder {
|
||||
aspect-ratio: 4 / 3;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.gallery-item figcaption {
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.gallery-item .prompt {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
color: var(--fg1);
|
||||
}
|
||||
.gallery-item .meta,
|
||||
.viewer .meta {
|
||||
color: var(--muted);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
a.button.small {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.viewer {
|
||||
margin: 0;
|
||||
max-width: min(1200px, 96vw);
|
||||
max-height: 92vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: var(--fg0);
|
||||
}
|
||||
.viewer img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.budget-bar {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.budget-bar > div {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* --- message queue --------------------------------------------------------- */
|
||||
.queue {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 6px 12px 0;
|
||||
width: 100%;
|
||||
}
|
||||
.queue-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px dashed var(--bg3);
|
||||
border-radius: var(--radius);
|
||||
padding: 5px 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
background: var(--bg1);
|
||||
}
|
||||
.queue-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.queue-item img {
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* --- file manager ------------------------------------------------------------ */
|
||||
|
||||
.files-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 360px) 1fr;
|
||||
}
|
||||
.files-side {
|
||||
border-right: 1px solid var(--bg2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.files-main {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
.files-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
.files-list.drag {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
.files-toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.files-toolbar button.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.files-search {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
}
|
||||
.crumbs {
|
||||
padding: 0 10px 6px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
.entries {
|
||||
flex: 1;
|
||||
}
|
||||
.entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
button.entry {
|
||||
padding: 6px 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
.entry:hover,
|
||||
.entry.active {
|
||||
background: var(--bg2);
|
||||
}
|
||||
.entry-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 6px 4px 6px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.entry .name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.entry .size {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.entry-menu {
|
||||
position: relative;
|
||||
}
|
||||
.entry-menu summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.entry-menu summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.entry-menu .menu {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 100%;
|
||||
z-index: 5;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg3);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
min-width: 150px;
|
||||
padding: 4px;
|
||||
}
|
||||
.entry-menu .menu > * {
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.entry-menu .menu > *:hover {
|
||||
background: var(--bg2);
|
||||
}
|
||||
.drop-hint {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.git-mark {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
color: var(--accent2);
|
||||
}
|
||||
.git-mark.m-A,
|
||||
.git-mark.m-U {
|
||||
color: var(--green);
|
||||
}
|
||||
.git-mark.m-D {
|
||||
color: var(--red);
|
||||
}
|
||||
.git-mark.m-dot {
|
||||
color: var(--muted);
|
||||
}
|
||||
.editor-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.editor-pane.empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.editor-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
}
|
||||
.editor-bar .grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dirty {
|
||||
color: var(--accent);
|
||||
}
|
||||
.code-editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.code-editor .cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
.pane-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.image-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: repeating-conic-gradient(var(--bg1) 0% 25%, var(--bg0) 0% 50%) 50% / 20px 20px;
|
||||
}
|
||||
.image-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.files-status {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
bottom: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.git-init {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--bg2);
|
||||
}
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* git panel */
|
||||
.git-panel {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.git-branch {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.git-branch select {
|
||||
max-width: 160px;
|
||||
}
|
||||
.git-output {
|
||||
font-size: 12px;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg2);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
white-space: pre-wrap;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
.git-output.err {
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
.git-section {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.git-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.git-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.git-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 3px 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.git-commit textarea {
|
||||
width: 100%;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.git-log {
|
||||
display: grid;
|
||||
}
|
||||
.git-commit-row {
|
||||
display: grid;
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
.git-commit-row:hover {
|
||||
background: var(--bg2);
|
||||
}
|
||||
.git-commit-row .subject {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.git-commit-row .meta {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.files-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.files-main {
|
||||
display: none;
|
||||
}
|
||||
.files-layout.show-editor .files-side {
|
||||
display: none;
|
||||
}
|
||||
.files-layout.show-editor .files-main {
|
||||
display: flex;
|
||||
}
|
||||
.mobile-only {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
+29
-6
@@ -1,15 +1,38 @@
|
||||
import "./themes";
|
||||
|
||||
const KEY = "codecraft.theme";
|
||||
|
||||
export type Theme = "system" | "dark" | "light";
|
||||
/** Available themes; "system" follows the OS light/dark preference. */
|
||||
export const THEMES = [
|
||||
{ id: "system", name: "System", dark: true },
|
||||
{ id: "gruvbox-dark", name: "Gruvbox Dark", dark: true },
|
||||
{ id: "gruvbox-light", name: "Gruvbox Light", dark: false },
|
||||
] as const;
|
||||
|
||||
export type Theme = (typeof THEMES)[number]["id"];
|
||||
|
||||
/** Themes used for "system" in dark and light OS mode. */
|
||||
const SYSTEM_DARK = "gruvbox-dark";
|
||||
const SYSTEM_LIGHT = "gruvbox-light";
|
||||
|
||||
export function getTheme(): Theme {
|
||||
return (localStorage.getItem(KEY) as Theme) ?? "system";
|
||||
const saved = localStorage.getItem(KEY);
|
||||
// Migrate the old values "dark"/"light".
|
||||
if (saved === "dark") return "gruvbox-dark";
|
||||
if (saved === "light") return "gruvbox-light";
|
||||
return THEMES.some((t) => t.id === saved) ? (saved as Theme) : "system";
|
||||
}
|
||||
|
||||
const media = matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
export function applyTheme(theme: Theme = getTheme()) {
|
||||
localStorage.setItem(KEY, theme);
|
||||
if (theme === "system") delete document.documentElement.dataset.theme;
|
||||
else document.documentElement.dataset.theme = theme;
|
||||
const dark = theme === "dark" || (theme === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", dark ? "#282828" : "#fbf1c7");
|
||||
const resolved = theme === "system" ? (media.matches ? SYSTEM_DARK : SYSTEM_LIGHT) : theme;
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
const color = getComputedStyle(document.documentElement).getPropertyValue("--theme-color").trim();
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", color || "#282828");
|
||||
}
|
||||
|
||||
media.addEventListener("change", () => {
|
||||
if (getTheme() === "system") applyTheme("system");
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/* Theme-independent tokens. */
|
||||
:root {
|
||||
--radius: 8px;
|
||||
--font: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Gruvbox Dark – also the default before a theme is applied. */
|
||||
:root,
|
||||
:root[data-theme="gruvbox-dark"] {
|
||||
color-scheme: dark;
|
||||
--theme-color: #282828;
|
||||
|
||||
--bg0: #282828;
|
||||
--bg1: #32302f;
|
||||
--bg2: #3c3836;
|
||||
--bg3: #504945;
|
||||
--fg0: #fbf1c7;
|
||||
--fg1: #ebdbb2;
|
||||
--fg2: #d5c4a1;
|
||||
--fg3: #a89984;
|
||||
--muted: #928374;
|
||||
|
||||
--accent: #fabd2f;
|
||||
--accent2: #fe8019;
|
||||
--on-accent: #282828;
|
||||
--red: #fb4934;
|
||||
--green: #b8bb26;
|
||||
--aqua: #8ec07c;
|
||||
--blue: #83a598;
|
||||
--purple: #d3869b;
|
||||
|
||||
--diff-add: rgba(184, 187, 38, 0.16);
|
||||
--diff-del: rgba(251, 73, 52, 0.16);
|
||||
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
|
||||
--syntax-keyword: #fb4934;
|
||||
--syntax-string: #b8bb26;
|
||||
--syntax-number: #d3869b;
|
||||
--syntax-comment: #928374;
|
||||
--syntax-function: #8ec07c;
|
||||
--syntax-type: #fabd2f;
|
||||
--syntax-variable: #83a598;
|
||||
--syntax-meta: #fe8019;
|
||||
|
||||
/* Validated categorical palette (dark steps on #282828). */
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
--seq-0: #32302f;
|
||||
--seq-1: #104281;
|
||||
--seq-2: #184f95;
|
||||
--seq-3: #256abf;
|
||||
--seq-4: #3987e5;
|
||||
--seq-5: #6da7ec;
|
||||
--seq-6: #9ec5f4;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* Gruvbox Light */
|
||||
:root[data-theme="gruvbox-light"] {
|
||||
color-scheme: light;
|
||||
--theme-color: #fbf1c7;
|
||||
|
||||
--bg0: #fbf1c7;
|
||||
--bg1: #f2e5bc;
|
||||
--bg2: #ebdbb2;
|
||||
--bg3: #d5c4a1;
|
||||
--fg0: #282828;
|
||||
--fg1: #3c3836;
|
||||
--fg2: #504945;
|
||||
--fg3: #665c54;
|
||||
--muted: #7c6f64;
|
||||
|
||||
--accent: #b57614;
|
||||
--accent2: #af3a03;
|
||||
--on-accent: #fbf1c7;
|
||||
--red: #9d0006;
|
||||
--green: #79740e;
|
||||
--aqua: #427b58;
|
||||
--blue: #076678;
|
||||
--purple: #8f3f71;
|
||||
|
||||
--diff-add: rgba(121, 116, 14, 0.16);
|
||||
--diff-del: rgba(157, 0, 6, 0.12);
|
||||
--shadow: 0 6px 24px rgba(60, 56, 54, 0.18);
|
||||
|
||||
--syntax-keyword: #9d0006;
|
||||
--syntax-string: #79740e;
|
||||
--syntax-number: #8f3f71;
|
||||
--syntax-comment: #928374;
|
||||
--syntax-function: #427b58;
|
||||
--syntax-type: #b57614;
|
||||
--syntax-variable: #076678;
|
||||
--syntax-meta: #af3a03;
|
||||
|
||||
/* Validated categorical palette (light steps on #fbf1c7). */
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
--series-3: #1baf7a;
|
||||
--series-4: #eda100;
|
||||
--series-5: #e87ba4;
|
||||
--series-6: #008300;
|
||||
--series-7: #6250d6;
|
||||
--series-8: #e34948;
|
||||
--seq-0: #f2e5bc;
|
||||
--seq-1: #cde2fb;
|
||||
--seq-2: #9ec5f4;
|
||||
--seq-3: #6da7ec;
|
||||
--seq-4: #3987e5;
|
||||
--seq-5: #256abf;
|
||||
--seq-6: #184f95;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// All bundled themes. Order of imports = cascade order; base first.
|
||||
import "./base.css";
|
||||
import "./gruvbox-dark.css";
|
||||
import "./gruvbox-light.css";
|
||||
+36
-1
@@ -48,13 +48,16 @@ export interface Session {
|
||||
cwd: string;
|
||||
model: string;
|
||||
agent: string;
|
||||
permission_mode: "default" | "accept_edits" | "bypass";
|
||||
permission_mode: "default" | "auto" | "accept_edits" | "bypass";
|
||||
parent_id?: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
todos: Todo[] | null;
|
||||
last_usage?: Usage | null;
|
||||
busy?: boolean;
|
||||
owner_id?: string | null;
|
||||
/** Listed because it was shared with the current user. */
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
export interface QuestionOption {
|
||||
@@ -103,6 +106,8 @@ export type ServerEvent =
|
||||
| QuestionRequest
|
||||
| { type: "question_resolved"; id: string; session_id: string }
|
||||
| { type: "error"; session_id: string; message: string }
|
||||
| { type: "waiting"; session_id: string; reason: string | null }
|
||||
| { type: "queue_updated"; session_id: string; items: QueueItem[] }
|
||||
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
|
||||
| { type: "resync" };
|
||||
|
||||
@@ -129,6 +134,10 @@ export interface AgentInfo {
|
||||
description: string;
|
||||
mode: "primary" | "subagent";
|
||||
read_only: boolean;
|
||||
model?: string | null;
|
||||
tools?: string[] | null;
|
||||
source: "builtin" | "config" | "file";
|
||||
max_parallel?: number | null;
|
||||
}
|
||||
|
||||
export interface Aggregate {
|
||||
@@ -145,3 +154,29 @@ export interface Aggregate {
|
||||
cache_hit_rate: number;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: "admin" | "user";
|
||||
has_password: boolean;
|
||||
oidc: boolean;
|
||||
disabled: boolean;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface Info {
|
||||
name: string;
|
||||
version: string;
|
||||
setup_required: boolean;
|
||||
oidc: { label: string } | null;
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
parts: Part[];
|
||||
source: "user" | "background";
|
||||
created_at: number;
|
||||
}
|
||||
Reference in new issue
Block a user