Initial CodeCraft: web-based coding harness for AI models
Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash, web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare), permissions with modes and rules, Anthropic + OpenAI-compatible providers, MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics, compaction, commented TOML config editable via toml_edit. React PWA: chat with live streaming, permission/question cards, modes (build/plan/craft), settings for providers/models/prices/MCP/permissions, raw config editor, statistics dashboard, i18n (en/de). Docker setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f3a2fe3816
62 files changed
+21835
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ApiError, connectEvents, get, getToken } from "./api";
|
||||
import { useT } from "./i18n";
|
||||
import { navigate, useRoute } from "./router";
|
||||
import { applyEvent, loadSessions, resync, setState } from "./store";
|
||||
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";
|
||||
|
||||
type AuthState = "checking" | "login" | "ok";
|
||||
|
||||
export function App() {
|
||||
const t = useT();
|
||||
const route = useRoute();
|
||||
const [auth, setAuth] = useState<AuthState>("checking");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// 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");
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth !== "ok") return;
|
||||
return connectEvents({
|
||||
onEvent: applyEvent,
|
||||
onOpen: () => {
|
||||
setState(() => ({ connected: true }));
|
||||
void resync().catch(() => undefined);
|
||||
},
|
||||
onClose: () => setState(() => ({ connected: false })),
|
||||
});
|
||||
}, [auth]);
|
||||
|
||||
// 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")} />;
|
||||
|
||||
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 content = <NewSession onMenu={() => setMenuOpen(true)} onCreated={(id) => navigate(`/s/${id}`)} />;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
|
||||
<Sidebar open={menuOpen} activeId={section === "s" ? arg : undefined} section={section ?? ""} />
|
||||
<main className="main">{content}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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);
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
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 text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) throw new ApiError(res.status, data?.error ?? res.statusText);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
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 });
|
||||
export const put = <T = any>(path: string, json: unknown) => api<T>(path, { method: "PUT", json });
|
||||
export const del = <T = any>(path: string) => api<T>(path, { method: "DELETE" });
|
||||
|
||||
/** WebSocket with automatic reconnect. `onOpen` runs after every (re)connect so
|
||||
* the caller can resync state that may have been missed. */
|
||||
export function connectEvents(handlers: {
|
||||
onEvent: (ev: ServerEvent) => void;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
}): () => void {
|
||||
let ws: WebSocket | null = null;
|
||||
let stopped = false;
|
||||
let retry = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const open = () => {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const token = encodeURIComponent(getToken());
|
||||
ws = new WebSocket(`${proto}://${location.host}/api/ws?token=${token}`);
|
||||
ws.onopen = () => {
|
||||
retry = 0;
|
||||
handlers.onOpen();
|
||||
};
|
||||
ws.onmessage = (m) => {
|
||||
try {
|
||||
handlers.onEvent(JSON.parse(m.data));
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
handlers.onClose();
|
||||
if (stopped) return;
|
||||
retry = Math.min(retry + 1, 6);
|
||||
timer = setTimeout(open, 500 * 2 ** retry);
|
||||
};
|
||||
};
|
||||
open();
|
||||
// Reconnect immediately when the app comes back to the foreground (mobile PWA).
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible" && ws && ws.readyState > WebSocket.OPEN) {
|
||||
clearTimeout(timer);
|
||||
open();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
ws?.close();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Hand-written SVG charts following the dataviz method: categorical colors in
|
||||
// fixed slot order, thin marks with 2px gaps, recessive axes, hover tooltips,
|
||||
// legends, and a table view for every chart.
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
export const SERIES = Array.from({ length: 8 }, (_, i) => `var(--series-${i + 1})`);
|
||||
const SEQ = Array.from({ length: 7 }, (_, i) => `var(--seq-${i})`);
|
||||
|
||||
function useWidth<T extends HTMLElement>() {
|
||||
const ref = useRef<T>(null);
|
||||
const [width, setWidth] = useState(600);
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
|
||||
ro.observe(ref.current);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
return [ref, width] as const;
|
||||
}
|
||||
|
||||
/** Round up to 1/2/2.5/5 × 10^n for clean axis ticks. */
|
||||
function niceMax(v: number): number {
|
||||
if (v <= 0) return 1;
|
||||
const exp = Math.pow(10, Math.floor(Math.log10(v)));
|
||||
for (const m of [1, 2, 2.5, 5, 10]) if (v <= m * exp) return m * exp;
|
||||
return 10 * exp;
|
||||
}
|
||||
|
||||
export interface Series {
|
||||
key: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function Legend({ series }: { series: Series[] }) {
|
||||
return (
|
||||
<div className="legend">
|
||||
{series.map((s) => (
|
||||
<span key={s.key}>
|
||||
<i style={{ background: s.color }} />
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Tip {
|
||||
x: number;
|
||||
y: number;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
/** Stacked bar chart over categories (days). values[i][seriesKey]. */
|
||||
export function StackedBars({
|
||||
categories,
|
||||
categoryLabel,
|
||||
series,
|
||||
values,
|
||||
format,
|
||||
height = 220,
|
||||
}: {
|
||||
categories: string[];
|
||||
categoryLabel: (c: string) => string;
|
||||
series: Series[];
|
||||
values: Record<string, number>[];
|
||||
format: (v: number) => string;
|
||||
height?: number;
|
||||
}) {
|
||||
const [ref, width] = useWidth<HTMLDivElement>();
|
||||
const [tip, setTip] = useState<Tip>();
|
||||
const [hover, setHover] = useState<number>();
|
||||
const left = 56;
|
||||
const bottom = 22;
|
||||
const top = 8;
|
||||
const plotW = Math.max(width - left - 4, 50);
|
||||
const plotH = height - top - bottom;
|
||||
const totals = values.map((v) => series.reduce((a, s) => a + (v[s.key] ?? 0), 0));
|
||||
const max = niceMax(Math.max(...totals, 0));
|
||||
const n = Math.max(categories.length, 1);
|
||||
const slot = plotW / n;
|
||||
const barW = Math.max(Math.min(slot * 0.72, 28), 2);
|
||||
const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => f * max);
|
||||
const labelEvery = Math.ceil(n / Math.max(Math.floor(plotW / 70), 1));
|
||||
const y = (v: number) => top + plotH - (v / max) * plotH;
|
||||
|
||||
return (
|
||||
<div className="chart" ref={ref} onMouseLeave={() => (setTip(undefined), setHover(undefined))}>
|
||||
<svg height={height} role="img">
|
||||
<g className="axis">
|
||||
{ticks.map((tk) => (
|
||||
<g key={tk}>
|
||||
<line className="grid-line" x1={left} x2={left + plotW} y1={y(tk)} y2={y(tk)} />
|
||||
<text x={left - 6} y={y(tk) + 4} textAnchor="end">
|
||||
{format(tk)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{categories.map((c, i) =>
|
||||
i % labelEvery === 0 ? (
|
||||
<text key={c} x={left + slot * i + slot / 2} y={height - 6} textAnchor="middle">
|
||||
{categoryLabel(c)}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
</g>
|
||||
{categories.map((c, i) => {
|
||||
let acc = 0;
|
||||
const x = left + slot * i + (slot - barW) / 2;
|
||||
const segs = series.filter((s) => (values[i][s.key] ?? 0) > 0);
|
||||
return (
|
||||
<g key={c} opacity={hover === undefined || hover === i ? 1 : 0.55}>
|
||||
{segs.map((s, si) => {
|
||||
const v = values[i][s.key] ?? 0;
|
||||
const y0 = y(acc);
|
||||
acc += v;
|
||||
const y1 = y(acc);
|
||||
// 2px surface gap between stacked segments.
|
||||
const h = Math.max(y0 - y1 - (si > 0 ? 2 : 0), 1);
|
||||
const yTop = y1;
|
||||
const isTop = si === segs.length - 1;
|
||||
const r = isTop ? Math.min(4, barW / 2, h) : 0;
|
||||
const d = `M${x},${yTop + h} V${yTop + r} Q${x},${yTop} ${x + r},${yTop} H${x + barW - r} Q${x + barW},${yTop} ${x + barW},${yTop + r} V${yTop + h} Z`;
|
||||
return <path key={s.key} d={d} fill={s.color} />;
|
||||
})}
|
||||
{/* Hit target: full slot height, wider than the bar. */}
|
||||
<rect
|
||||
x={left + slot * i}
|
||||
y={top}
|
||||
width={slot}
|
||||
height={plotH}
|
||||
fill="transparent"
|
||||
onMouseMove={(e) => {
|
||||
setHover(i);
|
||||
const box = (e.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
|
||||
setTip({
|
||||
x: Math.min(e.clientX - box.left + 12, width - 180),
|
||||
y: e.clientY - box.top + 12,
|
||||
content: (
|
||||
<>
|
||||
<b>{categoryLabel(c)}</b>
|
||||
{[...series].reverse().map((s) =>
|
||||
values[i][s.key] ? (
|
||||
<div key={s.key} className="tt-row">
|
||||
<span>
|
||||
<i style={{ background: s.color }} /> {s.label}
|
||||
</span>
|
||||
<span>{format(values[i][s.key])}</span>
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
<div className="tt-row" style={{ borderTop: "1px solid var(--bg3)", marginTop: 3 }}>
|
||||
<span>Σ</span>
|
||||
<b>{format(totals[i])}</b>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{tip && (
|
||||
<div className="tooltip" style={{ left: tip.x, top: tip.y }}>
|
||||
{tip.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Weekday × hour heatmap with a 7-step sequential ramp. cells[weekday*24+hour]. */
|
||||
export function Heatmap({
|
||||
cells,
|
||||
weekdays,
|
||||
format,
|
||||
firstDay = 1,
|
||||
}: {
|
||||
cells: number[];
|
||||
weekdays: string[];
|
||||
format: (v: number) => string;
|
||||
firstDay?: number;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [ref, width] = useWidth<HTMLDivElement>();
|
||||
const [tip, setTip] = useState<Tip>();
|
||||
const left = 40;
|
||||
const top = 18;
|
||||
const cell = Math.max(Math.floor((width - left) / 24), 8);
|
||||
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)));
|
||||
|
||||
return (
|
||||
<div className="chart" ref={ref} onMouseLeave={() => setTip(undefined)}>
|
||||
<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">
|
||||
{h}
|
||||
</text>
|
||||
))}
|
||||
{order.map((d, row) => (
|
||||
<text key={d} x={left - 6} y={top + row * cell + cell / 2 + 4} textAnchor="end">
|
||||
{weekdays[d]}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
{order.map((d, row) =>
|
||||
Array.from({ length: 24 }, (_, h) => {
|
||||
const v = cells[d * 24 + h] ?? 0;
|
||||
return (
|
||||
<rect
|
||||
key={`${d}-${h}`}
|
||||
x={left + h * cell + 1}
|
||||
y={top + row * cell + 1}
|
||||
width={cell - 2}
|
||||
height={cell - 2}
|
||||
rx={3}
|
||||
fill={SEQ[bin(v)]}
|
||||
onMouseMove={(e) => {
|
||||
const box = (e.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
|
||||
setTip({
|
||||
x: Math.min(e.clientX - box.left + 12, width - 140),
|
||||
y: e.clientY - box.top + 12,
|
||||
content: (
|
||||
<>
|
||||
<b>
|
||||
{weekdays[d]} {String(h).padStart(2, "0")}:00–{String(h).padStart(2, "0")}:59
|
||||
</b>
|
||||
<div>{format(v)}</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</svg>
|
||||
<div className="legend" style={{ marginTop: 6 }}>
|
||||
<span>{format(0)}</span>
|
||||
{SEQ.slice(1).map((c, i) => (
|
||||
<i key={i} style={{ background: c, width: 18 }} />
|
||||
))}
|
||||
<span>{format(max)}</span>
|
||||
</div>
|
||||
{tip && (
|
||||
<div className="tooltip" style={{ left: tip.x, top: tip.y }}>
|
||||
{tip.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { del, get, patch, post } from "../api";
|
||||
import { useModels } from "../hooks";
|
||||
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 { Composer } from "./Composer";
|
||||
import { PermissionCard, QuestionCard } from "./Interaction";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { TodoList, ToolCard } from "./ToolCard";
|
||||
|
||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||
|
||||
const EMPTY: Message[] = [];
|
||||
|
||||
interface SessionStats {
|
||||
total: Aggregate;
|
||||
by_model: (Aggregate & { model: string })[];
|
||||
currency: string;
|
||||
}
|
||||
|
||||
function Reasoning({ text, live }: { text: string; live: boolean }) {
|
||||
const t = useT();
|
||||
if (!text.trim()) return null;
|
||||
return (
|
||||
<details className="reasoning" open={live}>
|
||||
<summary>{t("chat.reasoning")}</summary>
|
||||
<div className="body">{text}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantParts({ parts, results, running, live }: { parts: Part[]; results: Map<string, ToolResult>; running: boolean; live: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{parts.map((p, i) => {
|
||||
switch (p.type) {
|
||||
case "text":
|
||||
return <Markdown key={i} text={p.text} />;
|
||||
case "reasoning":
|
||||
return <Reasoning key={i} text={p.text} live={live && i === parts.length - 1} />;
|
||||
case "tool_call":
|
||||
return <ToolCard key={p.id} call={p} result={results.get(p.id)} running={running} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsPanel({ stats }: { stats: SessionStats }) {
|
||||
const t = useT();
|
||||
const a = stats.total;
|
||||
return (
|
||||
<div className="panel" style={{ margin: "8px 0" }}>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("stats.model")}</th>
|
||||
<th className="num">{t("stats.requests")}</th>
|
||||
<th className="num">{t("stats.input")}</th>
|
||||
<th className="num">{t("stats.cacheRead")}</th>
|
||||
<th className="num">{t("stats.output")}</th>
|
||||
<th className="num">{t("stats.ttft")}</th>
|
||||
<th className="num">{t("stats.outputSpeed")}</th>
|
||||
<th className="num">{t("stats.cost")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...stats.by_model, { ...a, model: t("stats.total") }].map((r) => (
|
||||
<tr key={r.model}>
|
||||
<td>{r.model}</td>
|
||||
<td className="num">{fmtNumber(r.requests)}</td>
|
||||
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
|
||||
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
|
||||
<td className="num">{fmtCompact(r.output_tokens)}</td>
|
||||
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
|
||||
<td className="num">{fmtNumber(r.output_tps, 1)} tok/s</td>
|
||||
<td className="num">{fmtCost(r.cost, stats.currency)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 6 }}>
|
||||
{t("stats.prefillSpeed")}: {fmtNumber(a.prefill_tps)} tok/s · {t("stats.cacheHit")}: {fmtPercent(a.cache_hit_rate)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () => void }) {
|
||||
const t = useT();
|
||||
const session = useStore((s) => s.sessionById[sessionId]);
|
||||
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
|
||||
const draft = useStore((s) => s.drafts[sessionId]);
|
||||
const busy = useStore((s) => !!s.busy[sessionId]);
|
||||
const errors = useStore((s) => s.errors[sessionId]);
|
||||
const allPermissions = useStore((s) => s.permissions);
|
||||
const allQuestions = useStore((s) => s.questions);
|
||||
const models = useModels();
|
||||
const [loadError, setLoadError] = useState<string>();
|
||||
const [text, setText] = useState("");
|
||||
const [stats, setStats] = useState<SessionStats>();
|
||||
const [showStats, setShowStats] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const stick = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadSession(sessionId).catch((e) => setLoadError(e.message));
|
||||
}, [sessionId]);
|
||||
|
||||
const permissions = allPermissions.filter((p) => p.session_id === sessionId || p.root_session_id === sessionId);
|
||||
const questions = allQuestions.filter((q) => q.session_id === sessionId || q.root_session_id === sessionId);
|
||||
|
||||
// Session cost/tokens; refreshed when messages arrive.
|
||||
useEffect(() => {
|
||||
get<SessionStats>(`/sessions/${sessionId}/stats`).then(setStats).catch(() => undefined);
|
||||
}, [sessionId, messages.length]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
const m = new Map<string, ToolResult>();
|
||||
for (const msg of messages) for (const p of msg.parts) if (p.type === "tool_result") m.set(p.call_id, p);
|
||||
return m;
|
||||
}, [messages]);
|
||||
|
||||
// Keep scrolled to the bottom while the user hasn't scrolled up.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
|
||||
if (loadError) return <div className="empty">{loadError}</div>;
|
||||
if (!session) return <div className="empty">{t("common.loading")}</div>;
|
||||
|
||||
const update = async (p: Partial<Session>) => {
|
||||
try {
|
||||
await patch(`/sessions/${sessionId}`, p);
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const send = async (raw: string, images: ImageData[]) => {
|
||||
const txt = raw.trim();
|
||||
if (txt === "/new") {
|
||||
setText("");
|
||||
return navigate("/new");
|
||||
}
|
||||
try {
|
||||
await post(`/sessions/${sessionId}/prompt`, { text: txt, images });
|
||||
setText("");
|
||||
stick.current = true;
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const rewind = async (m: Message) => {
|
||||
if (!confirm(t("chat.rewindConfirm"))) return;
|
||||
const txt = m.parts.map((p) => (p.type === "text" ? p.text : "")).join("");
|
||||
await post(`/sessions/${sessionId}/rewind`, { message_id: m.id });
|
||||
await loadSession(sessionId);
|
||||
setText(txt);
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!confirm(t("chat.deleteConfirm"))) return;
|
||||
await del(`/sessions/${sessionId}`);
|
||||
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
|
||||
};
|
||||
|
||||
// Context usage of the last request relative to the model's window.
|
||||
const [prov, ...rest] = session.model.split("/");
|
||||
const modelInfo = models.find((p) => p.provider === prov)?.models.find((m) => m.id === rest.join("/"));
|
||||
const u = session.last_usage;
|
||||
const ctxTokens = u ? u.input_tokens + u.cache_read_tokens + u.cache_write_tokens + u.output_tokens : 0;
|
||||
const ctxShare = modelInfo?.context && ctxTokens ? ctxTokens / modelInfo.context : undefined;
|
||||
const todos = Array.isArray(session.todos) ? session.todos : [];
|
||||
const openTodos = todos.filter((x) => x.status !== "completed");
|
||||
const lastIsAssistant = messages.length > 0 && messages[messages.length - 1].role === "assistant";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
☰
|
||||
</button>
|
||||
<div className="grow">
|
||||
{editingTitle ? (
|
||||
<input
|
||||
autoFocus
|
||||
defaultValue={session.title}
|
||||
onBlur={(e) => (setEditingTitle(false), update({ title: e.target.value }))}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
) : (
|
||||
<h1 onDoubleClick={() => setEditingTitle(true)} title={t("chat.renameHint")}>
|
||||
{session.parent_id && (
|
||||
<a href={`#/s/${session.parent_id}`} style={{ marginRight: 8 }}>
|
||||
↩
|
||||
</a>
|
||||
)}
|
||||
{session.title || t("session.untitled")}
|
||||
</h1>
|
||||
)}
|
||||
<div className="path">{session.cwd}</div>
|
||||
</div>
|
||||
{ctxShare !== undefined && (
|
||||
<span className={`chip ${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)}
|
||||
</button>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
<div
|
||||
className="chat-scroll"
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
}}
|
||||
>
|
||||
<div className="chat">
|
||||
{showStats && stats && <StatsPanel stats={stats} />}
|
||||
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
|
||||
{messages.map((m) => {
|
||||
if (m.role === "assistant") {
|
||||
return (
|
||||
<div key={m.id} className="msg">
|
||||
<AssistantParts parts={m.parts} results={results} running={busy} live={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const summary = m.parts.find((p) => p.type === "summary");
|
||||
if (summary && summary.type === "summary") {
|
||||
return (
|
||||
<details key={m.id} className="summary-divider">
|
||||
<summary>{t("chat.compacted")}</summary>
|
||||
<Markdown text={summary.text} />
|
||||
</details>
|
||||
);
|
||||
}
|
||||
const texts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
||||
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
||||
if (texts.length === 0 && imgs.length === 0) return null;
|
||||
return (
|
||||
<div key={m.id} className="msg">
|
||||
<div className="msg-user">
|
||||
{!busy && (
|
||||
<div className="msg-actions">
|
||||
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
|
||||
✎
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{texts.map((p) => p.text).join("\n\n")}
|
||||
{imgs.length > 0 && (
|
||||
<div>
|
||||
{imgs.map((p, i) => (
|
||||
<img key={i} src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{draft && (
|
||||
<div className="msg">
|
||||
<AssistantParts parts={draft.parts} results={results} running={busy} live />
|
||||
</div>
|
||||
)}
|
||||
{busy && !draft && <div className="msg-meta">{t("chat.thinking")}</div>}
|
||||
{openTodos.length > 0 && (
|
||||
<div className="todos">
|
||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
|
||||
<TodoList todos={todos} />
|
||||
</div>
|
||||
)}
|
||||
{permissions.map((p) => (
|
||||
<PermissionCard key={p.id} req={p} currentId={sessionId} />
|
||||
))}
|
||||
{questions.map((q) => (
|
||||
<QuestionCard key={q.id} req={q} currentId={sessionId} />
|
||||
))}
|
||||
{errors?.map((e) => (
|
||||
<div key={e.id} className="error-box" role="alert">
|
||||
<span>{e.message}</span>
|
||||
<button className="small ghost" onClick={() => dismissError(sessionId, e.id)} aria-label={t("common.close")}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{session.agent === "plan" && !busy && lastIsAssistant && (
|
||||
<div className="row" style={{ justifyContent: "center", margin: "12px 0" }}>
|
||||
<button
|
||||
className="primary"
|
||||
onClick={async () => {
|
||||
await update({ agent: "build" });
|
||||
await send(t("plan.implementPrompt"), []);
|
||||
}}
|
||||
>
|
||||
{t("plan.implement")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Composer
|
||||
session={session}
|
||||
busy={busy}
|
||||
text={text}
|
||||
setText={setText}
|
||||
onSend={send}
|
||||
onAbort={() => post(`/sessions/${sessionId}/abort`)}
|
||||
onUpdate={update}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAgents, useFetch } from "../hooks";
|
||||
import { useT, type Key } from "../i18n";
|
||||
import type { ImageData, Session } from "../types";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
|
||||
interface Suggestion {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const BUILTIN: { name: string; key: Key }[] = [
|
||||
{ name: "compact", key: "command.compact" },
|
||||
{ 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);
|
||||
});
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
session,
|
||||
busy,
|
||||
text,
|
||||
setText,
|
||||
onSend,
|
||||
onAbort,
|
||||
onUpdate,
|
||||
}: {
|
||||
session: Session;
|
||||
busy: boolean;
|
||||
text: string;
|
||||
setText: (s: string) => void;
|
||||
onSend: (text: string, images: ImageData[]) => void;
|
||||
onAbort: () => void;
|
||||
onUpdate: (patch: Partial<Session>) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const [images, setImages] = useState<ImageData[]>([]);
|
||||
const [active, setActive] = useState(0);
|
||||
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary");
|
||||
const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
|
||||
|
||||
// Auto-grow.
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, window.innerHeight * 0.4)}px`;
|
||||
}, [text]);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const m = text.match(/^\/(\S*)$/);
|
||||
if (!m) return [];
|
||||
const all: Suggestion[] = [
|
||||
...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })),
|
||||
...(cmdData?.commands ?? []),
|
||||
];
|
||||
return all.filter((c) => c.name.startsWith(m[1])).slice(0, 12);
|
||||
}, [text, cmdData, t]);
|
||||
|
||||
const send = () => {
|
||||
if (!text.trim() && images.length === 0) return;
|
||||
onSend(text, images);
|
||||
setImages([]);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (suggestions.length > 0) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => (a + (e.key === "ArrowDown" ? 1 : suggestions.length - 1)) % suggestions.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
setText(`/${suggestions[Math.min(active, suggestions.length - 1)].name} `);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Enter sends on desktop; on touch devices Enter inserts a newline.
|
||||
const touch = matchMedia("(pointer: coarse)").matches;
|
||||
if (e.key === "Enter" && !e.shiftKey && !touch && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
if (e.key === "Escape" && busy) onAbort();
|
||||
};
|
||||
|
||||
const addFiles = async (files: FileList | File[]) => {
|
||||
const imgs = await Promise.all([...files].filter((f) => f.type.startsWith("image/")).map(readImage));
|
||||
setImages((cur) => [...cur, ...imgs]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="composer-wrap">
|
||||
<div
|
||||
className="composer"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
void addFiles(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
{suggestions.length > 0 && (
|
||||
<div className="suggest" role="listbox">
|
||||
{suggestions.map((s, i) => (
|
||||
<div
|
||||
key={s.name}
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
className={i === active ? "active" : ""}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setText(`/${s.name} `);
|
||||
ref.current?.focus();
|
||||
}}
|
||||
>
|
||||
<span className="name">/{s.name}</span>
|
||||
<span className="desc">{s.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{images.length > 0 && (
|
||||
<div className="attachments">
|
||||
{images.map((img, i) => (
|
||||
<div className="att" key={i}>
|
||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={ref}
|
||||
rows={1}
|
||||
value={text}
|
||||
placeholder={busy ? t("composer.placeholderBusy") : t("composer.placeholder")}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={(e) => {
|
||||
const files = [...e.clipboardData.files];
|
||||
if (files.some((f) => f.type.startsWith("image/"))) {
|
||||
e.preventDefault();
|
||||
void addFiles(files);
|
||||
}
|
||||
}}
|
||||
aria-label={t("composer.placeholder")}
|
||||
/>
|
||||
<div className="composer-bar">
|
||||
<div className="mode-tabs" role="tablist" aria-label={t("composer.mode")}>
|
||||
{agents.map((a) => (
|
||||
<button
|
||||
key={a.name}
|
||||
role="tab"
|
||||
aria-selected={session.agent === a.name}
|
||||
className={session.agent === a.name ? "active" : ""}
|
||||
title={a.description}
|
||||
onClick={() => onUpdate({ agent: a.name })}
|
||||
>
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select
|
||||
value={session.permission_mode}
|
||||
onChange={(e) => onUpdate({ permission_mode: e.target.value as Session["permission_mode"] })}
|
||||
title={t("composer.permissionMode")}
|
||||
>
|
||||
<option value="default">{t("permissionMode.default")}</option>
|
||||
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
|
||||
<option value="bypass">{t("permissionMode.bypass")}</option>
|
||||
</select>
|
||||
<ModelSelect value={session.model} onChange={(m) => onUpdate({ model: m })} title={t("composer.model")} />
|
||||
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
|
||||
📎
|
||||
<input type="file" accept="image/*" multiple hidden onChange={(e) => e.target.files && addFiles(e.target.files)} />
|
||||
</label>
|
||||
<span className="grow" />
|
||||
{busy && (
|
||||
<button className="danger" onClick={onAbort} title="Esc">
|
||||
■ {t("composer.stop")}
|
||||
</button>
|
||||
)}
|
||||
<button className="primary" onClick={send} disabled={!text.trim() && images.length === 0}>
|
||||
{busy ? t("composer.queue") : t("composer.send")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function Diff({ diff }: { diff: string }) {
|
||||
const lines = diff.split("\n");
|
||||
return (
|
||||
<div className="diff">
|
||||
{lines.map((l, i) => {
|
||||
let cls = "";
|
||||
if (l.startsWith("+++") || l.startsWith("---")) cls = "meta";
|
||||
else if (l.startsWith("@@")) cls = "hunk";
|
||||
else if (l.startsWith("+")) cls = "add";
|
||||
else if (l.startsWith("-")) cls = "del";
|
||||
return (
|
||||
<div key={i} className={cls}>
|
||||
{l || " "}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useState } from "react";
|
||||
import { post } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import type { PermissionRequest, QuestionRequest } from "../types";
|
||||
import { Diff } from "./Diff";
|
||||
|
||||
function SubagentHint({ sessionId, currentId }: { sessionId: string; currentId: string }) {
|
||||
const t = useT();
|
||||
const title = useStore((s) => s.sessionById[sessionId]?.title);
|
||||
if (sessionId === currentId) return null;
|
||||
return (
|
||||
<div className="from">
|
||||
{t("interaction.fromSubagent")}{" "}
|
||||
<a href={`#/s/${sessionId}`} onClick={(e) => (e.preventDefault(), navigate(`/s/${sessionId}`))}>
|
||||
{title || sessionId.slice(-6)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PermissionCard({ req, currentId }: { req: PermissionRequest; currentId: string }) {
|
||||
const t = useT();
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const reply = async (r: "once" | "always" | "reject") => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await post(`/permissions/${req.id}`, { reply: r, message: r === "reject" ? feedback : undefined });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const command: string | undefined = req.tool === "bash" ? req.input?.command : undefined;
|
||||
const alwaysLabel =
|
||||
req.tool === "bash"
|
||||
? t("permission.alwaysCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") })
|
||||
: t("permission.alwaysTool", { tool: req.tool });
|
||||
|
||||
return (
|
||||
<div className="card" role="alertdialog" aria-label={t("permission.title")}>
|
||||
<h3>
|
||||
🔐 {t("permission.title")} <span className="chip mono">{req.tool}</span>
|
||||
</h3>
|
||||
<SubagentHint sessionId={req.session_id} currentId={currentId} />
|
||||
<div style={{ margin: "6px 0" }}>{req.title}</div>
|
||||
{command && <pre className="mono" style={{ whiteSpace: "pre-wrap", margin: "6px 0" }}>$ {command}</pre>}
|
||||
{req.diff ? (
|
||||
<Diff diff={req.diff} />
|
||||
) : (
|
||||
!command && (
|
||||
<details>
|
||||
<summary style={{ cursor: "pointer", color: "var(--muted)" }}>{t("tool.input")}</summary>
|
||||
<pre className="mono" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{JSON.stringify(req.input, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)
|
||||
)}
|
||||
<input
|
||||
style={{ width: "100%", marginTop: 8 }}
|
||||
placeholder={t("permission.feedback")}
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && reply("reject")}
|
||||
/>
|
||||
<div className="actions">
|
||||
<button className="primary" disabled={busy} onClick={() => reply("once")} autoFocus>
|
||||
{t("permission.allowOnce")}
|
||||
</button>
|
||||
<button disabled={busy} onClick={() => reply("always")}>
|
||||
{alwaysLabel}
|
||||
</button>
|
||||
<button className="danger" disabled={busy} onClick={() => reply("reject")}>
|
||||
{feedback ? t("permission.rejectWithFeedback") : t("permission.reject")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const OTHER = "__other__";
|
||||
|
||||
export function QuestionCard({ req, currentId }: { req: QuestionRequest; currentId: string }) {
|
||||
const t = useT();
|
||||
const [selected, setSelected] = useState<Record<number, string[]>>({});
|
||||
const [other, setOther] = useState<Record<number, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const toggle = (qi: number, label: string, multi: boolean) => {
|
||||
setSelected((s) => {
|
||||
const cur = s[qi] ?? [];
|
||||
if (!multi) return { ...s, [qi]: [label] };
|
||||
return { ...s, [qi]: cur.includes(label) ? cur.filter((x) => x !== label) : [...cur, label] };
|
||||
});
|
||||
};
|
||||
|
||||
const answerFor = (qi: number): string | string[] | undefined => {
|
||||
const q = req.questions[qi];
|
||||
const sel = (selected[qi] ?? []).map((l) => (l === OTHER ? other[qi]?.trim() ?? "" : l)).filter(Boolean);
|
||||
if (q.options.length === 0) return other[qi]?.trim() || undefined;
|
||||
if (sel.length === 0) return undefined;
|
||||
return q.multi_select ? sel : sel[0];
|
||||
};
|
||||
|
||||
const complete = req.questions.every((_, i) => answerFor(i) !== undefined);
|
||||
|
||||
const submit = async (dismiss = false) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const answers = dismiss ? null : Object.fromEntries(req.questions.map((q, i) => [q.question, answerFor(i)]));
|
||||
await post(`/questions/${req.id}`, { answers });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const source = req.source.startsWith("mcp:") ? t("question.fromMcp", { server: req.source.slice(4) }) : t("question.fromAgent");
|
||||
|
||||
return (
|
||||
<div className="card" role="dialog" aria-label={t("question.title")}>
|
||||
<h3>❓ {t("question.title")}</h3>
|
||||
<div className="from">{source}</div>
|
||||
<SubagentHint sessionId={req.session_id} currentId={currentId} />
|
||||
{req.questions.map((q, qi) => (
|
||||
<div key={qi} style={{ marginTop: 10 }}>
|
||||
{q.header && <span className="q-header">{q.header}</span>}
|
||||
<div style={{ whiteSpace: "pre-wrap", color: "var(--fg0)" }}>{q.question}</div>
|
||||
{q.options.length > 0 ? (
|
||||
<div className="q-options" role={q.multi_select ? "group" : "radiogroup"}>
|
||||
{[...q.options, { label: OTHER, description: "" }].map((o) => {
|
||||
const isSel = (selected[qi] ?? []).includes(o.label);
|
||||
return (
|
||||
<div
|
||||
key={o.label}
|
||||
className={`q-option ${isSel ? "selected" : ""}`}
|
||||
role={q.multi_select ? "checkbox" : "radio"}
|
||||
aria-checked={isSel}
|
||||
tabIndex={0}
|
||||
onClick={() => toggle(qi, o.label, !!q.multi_select)}
|
||||
onKeyDown={(e) => (e.key === " " || e.key === "Enter") && toggle(qi, o.label, !!q.multi_select)}
|
||||
>
|
||||
<span>{q.multi_select ? (isSel ? "☑" : "☐") : isSel ? "◉" : "○"}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
{o.label === OTHER ? (
|
||||
<input
|
||||
style={{ width: "100%" }}
|
||||
placeholder={t("question.other")}
|
||||
value={other[qi] ?? ""}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onFocus={() => !isSel && toggle(qi, OTHER, !!q.multi_select)}
|
||||
onChange={(e) => setOther((s) => ({ ...s, [qi]: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div>{o.label}</div>
|
||||
{o.description && <div className="desc">{o.description}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
style={{ width: "100%", marginTop: 6 }}
|
||||
placeholder={t("question.answer")}
|
||||
value={other[qi] ?? ""}
|
||||
onChange={(e) => setOther((s) => ({ ...s, [qi]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="actions">
|
||||
<button className="primary" disabled={busy || !complete} onClick={() => submit()}>
|
||||
{t("question.submit")}
|
||||
</button>
|
||||
<button disabled={busy} onClick={() => submit(true)}>
|
||||
{t("question.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { get, setToken } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
export function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
const t = useT();
|
||||
const [token, setValue] = useState("");
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setToken(token.trim());
|
||||
try {
|
||||
await get("/sessions");
|
||||
onSuccess();
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
};
|
||||
|
||||
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")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { memo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
|
||||
export const Markdown = memo(function Markdown({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
|
||||
components={{ a: (props) => <a {...props} target="_blank" rel="noreferrer" /> }}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
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 }) {
|
||||
const options = modelOptions(useModels());
|
||||
const known = options.some((o) => o.value === value);
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} title={title}>
|
||||
{!known && value && <option value={value}>{value}</option>}
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { post } from "../api";
|
||||
import { useAgents, useFetch } from "../hooks";
|
||||
import { useT } from "../i18n";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
|
||||
interface DirListing {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
workspace: string;
|
||||
dirs: { name: string; path: string; git: boolean }[];
|
||||
}
|
||||
|
||||
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 { data: listing, error: dirError } = useFetch<DirListing>(`/fs/dirs${path ? `?path=${encodeURIComponent(path)}` : ""}`);
|
||||
const config = useFetch<{ default_model?: string }>("/config").data;
|
||||
const [model, setModel] = useState("");
|
||||
const [agent, setAgent] = useState("build");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [error, setError] = useState<string>();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const agents = useAgents(listing?.path).filter((a) => a.mode === "primary");
|
||||
|
||||
useEffect(() => {
|
||||
if (!model && config?.default_model) setModel(config.default_model);
|
||||
}, [config, model]);
|
||||
|
||||
// A remembered directory that vanished: fall back to the workspace root.
|
||||
useEffect(() => {
|
||||
if (dirError && path) setPath("");
|
||||
}, [dirError, path]);
|
||||
|
||||
const create = async () => {
|
||||
if (!listing) return;
|
||||
setCreating(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
localStorage.setItem(LAST_DIR, listing.path);
|
||||
const s = await post<{ id: string }>("/sessions", { cwd: listing.path, model: model || undefined, agent });
|
||||
if (prompt.trim()) await post(`/sessions/${s.id}/prompt`, { text: prompt.trim() });
|
||||
onCreated(s.id);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
☰
|
||||
</button>
|
||||
<div className="grow">
|
||||
<h1>{t("new.title")}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page">
|
||||
<div className="page-inner" style={{ maxWidth: 760 }}>
|
||||
<div className="panel">
|
||||
<h3>{t("new.directory")}</h3>
|
||||
<div className="row" style={{ marginBottom: 8 }}>
|
||||
<code className="grow" style={{ flex: 1 }}>
|
||||
{rel}
|
||||
</code>
|
||||
{listing?.parent && (
|
||||
<button className="small" onClick={() => setPath(listing.parent!)}>
|
||||
↑ {t("new.up")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="dir-list">
|
||||
{listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>}
|
||||
{listing?.dirs.map((d) => (
|
||||
<button key={d.path} onClick={() => setPath(d.path)}>
|
||||
<span>📁</span>
|
||||
<span style={{ flex: 1 }}>{d.name}</span>
|
||||
{d.git && <span className="chip">git</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="form">
|
||||
<label>{t("new.model")}</label>
|
||||
<ModelSelect value={model} onChange={setModel} />
|
||||
<label>{t("new.mode")}</label>
|
||||
<select value={agent} onChange={(e) => setAgent(e.target.value)}>
|
||||
{agents.map((a) => (
|
||||
<option key={a.name} value={a.name}>
|
||||
{a.name} – {a.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<h3>{t("new.firstPrompt")}</h3>
|
||||
<textarea
|
||||
style={{ width: "100%", minHeight: 110 }}
|
||||
value={prompt}
|
||||
placeholder={t("composer.placeholder")}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) void create();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<button className="primary" disabled={!listing || creating} onClick={create}>
|
||||
{t("new.start", { dir: rel })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { get, patch as apiPatch, post, put } from "../api";
|
||||
import { useFetch, useModels } from "../hooks";
|
||||
import { getLang, languages, setLang, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import { applyTheme, getTheme, type Theme } from "../theme";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
|
||||
type Json = any;
|
||||
interface ConfigResponse {
|
||||
config: Json;
|
||||
workspace: string;
|
||||
currency: string;
|
||||
}
|
||||
type Patch = { path: string[]; value: unknown };
|
||||
|
||||
const MASK = "••••••••";
|
||||
|
||||
async function savePatches(patches: Patch[]) {
|
||||
await apiPatch("/config", { patches });
|
||||
}
|
||||
|
||||
function useSaver(reload: () => void) {
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||||
const t = useT();
|
||||
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 view = notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>;
|
||||
return { run, view };
|
||||
}
|
||||
|
||||
// --- general ----------------------------------------------------------------
|
||||
|
||||
function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
const t = useT();
|
||||
const s = cfg.config.server;
|
||||
const c = cfg.config.craft;
|
||||
const [form, setForm] = useState({
|
||||
default_model: s.default_model ?? "",
|
||||
small_model: s.small_model ?? "",
|
||||
currency: s.currency ?? "USD",
|
||||
auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
|
||||
clean_room: c.clean_room ?? true,
|
||||
chromium: c.chromium ?? "chromium",
|
||||
widths: (c.widths ?? []).join(", "),
|
||||
});
|
||||
const [theme, setThemeState] = useState<Theme>(getTheme());
|
||||
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
|
||||
const { run, view } = useSaver(reload);
|
||||
|
||||
const save = () =>
|
||||
run(() =>
|
||||
savePatches([
|
||||
{ path: ["server", "default_model"], value: form.default_model },
|
||||
{ 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: ["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) },
|
||||
]),
|
||||
);
|
||||
|
||||
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>
|
||||
<div className="panel">
|
||||
<h3>{t("settings.server")}</h3>
|
||||
<div className="form">
|
||||
<label>{t("settings.workspace")}</label>
|
||||
<code>{cfg.workspace}</code>
|
||||
<label>{t("settings.defaultModel")}</label>
|
||||
<ModelSelect value={form.default_model} onChange={(v) => setForm({ ...form, default_model: v })} />
|
||||
<label>{t("settings.smallModel")}</label>
|
||||
<ModelSelect value={form.small_model} onChange={(v) => setForm({ ...form, small_model: v })} />
|
||||
<div className="hint">{t("settings.smallModelHint")}</div>
|
||||
<label>{t("settings.currency")}</label>
|
||||
<input value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value.toUpperCase() })} maxLength={3} />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>{t("settings.craft")}</h3>
|
||||
<div className="form">
|
||||
<label>{t("settings.cleanRoom")}</label>
|
||||
<input type="checkbox" checked={form.clean_room} onChange={(e) => setForm({ ...form, clean_room: e.target.checked })} style={{ justifySelf: "start" }} />
|
||||
<div className="hint">{t("settings.cleanRoomHint")}</div>
|
||||
<label>{t("settings.chromium")}</label>
|
||||
<input value={form.chromium} onChange={(e) => setForm({ ...form, chromium: e.target.value })} />
|
||||
<label>{t("settings.widths")}</label>
|
||||
<input value={form.widths} onChange={(e) => setForm({ ...form, widths: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
{view}
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- providers --------------------------------------------------------------
|
||||
|
||||
interface ModelRow {
|
||||
id: string;
|
||||
name?: string;
|
||||
context?: number;
|
||||
max_output?: number;
|
||||
reasoning?: boolean;
|
||||
vision?: boolean;
|
||||
effort?: string;
|
||||
input_price?: number;
|
||||
output_price?: number;
|
||||
cache_read_price?: number;
|
||||
cache_write_price?: number;
|
||||
}
|
||||
|
||||
const PRESETS: Record<string, { kind: string; base_url: string; api_key: string; auth_prefix?: string }> = {
|
||||
anthropic: { kind: "anthropic", base_url: "https://api.anthropic.com/v1", api_key: "${ANTHROPIC_API_KEY}" },
|
||||
gemini: { kind: "openai", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", api_key: "${GEMINI_API_KEY}" },
|
||||
"opencode-go": { kind: "openai", base_url: "https://opencode.ai/inference/go/openai/v1", api_key: "${OPENCODE_API_KEY}" },
|
||||
fal: { kind: "openai", base_url: "https://fal.run/openrouter/router/openai/v1", api_key: "${FAL_KEY}", auth_prefix: "Key " },
|
||||
openrouter: { kind: "openai", base_url: "https://openrouter.ai/api/v1", api_key: "${OPENROUTER_API_KEY}" },
|
||||
ollama: { kind: "openai", base_url: "http://host.docker.internal:11434/v1", api_key: "" },
|
||||
custom: { kind: "openai", base_url: "http://", api_key: "" },
|
||||
};
|
||||
|
||||
function normalizeModels(models: Json[]): ModelRow[] {
|
||||
return (models ?? []).map((m) => (typeof m === "string" ? { id: m } : { ...m }));
|
||||
}
|
||||
|
||||
function cleanModel(m: ModelRow) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(m)) {
|
||||
if (v === undefined || v === "" || (typeof v === "number" && Number.isNaN(v))) continue;
|
||||
if (v === false && (k === "reasoning" || k === "vision")) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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 [models, setModels] = useState<ModelRow[]>(normalizeModels(p.models));
|
||||
const [open, setOpen] = useState(false);
|
||||
const discovered = useModels().find((x) => x.provider === name)?.models ?? [];
|
||||
const { run, view } = useSaver(reload);
|
||||
|
||||
const num = (v: string) => (v === "" ? undefined : Number(v));
|
||||
const setModel = (i: number, patch: Partial<ModelRow>) => setModels((ms) => ms.map((m, j) => (j === i ? { ...m, ...patch } : m)));
|
||||
|
||||
const save = () =>
|
||||
run(() => {
|
||||
const patches: Patch[] = [
|
||||
{ path: ["providers", name, "kind"], value: form.kind },
|
||||
{ path: ["providers", name, "base_url"], value: form.base_url },
|
||||
{ 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 },
|
||||
];
|
||||
// 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 });
|
||||
return savePatches(patches);
|
||||
});
|
||||
|
||||
const remove = () => {
|
||||
if (!confirm(t("settings.deleteProviderConfirm", { name }))) return;
|
||||
void run(() => savePatches([{ path: ["providers", name], value: null }]));
|
||||
};
|
||||
|
||||
const addDiscovered = () => {
|
||||
const have = new Set(models.map((m) => m.id));
|
||||
setModels([...models, ...discovered.filter((d) => !have.has(d.id)).map((d) => ({ id: d.id, context: d.context }))]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
<input type="checkbox" checked={!!form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} title={t("settings.enabled")} />
|
||||
{name}
|
||||
<span className="chip">{form.kind}</span>
|
||||
<span className="chip">{t("settings.modelCount", { n: models.length || discovered.length })}</span>
|
||||
<span className="spacer" />
|
||||
<button className="small" onClick={() => setOpen(!open)}>
|
||||
{open ? t("common.collapse") : t("common.edit")}
|
||||
</button>
|
||||
</h3>
|
||||
{open && (
|
||||
<>
|
||||
<div className="form">
|
||||
<label>{t("settings.kind")}</label>
|
||||
<select value={form.kind} onChange={(e) => setForm({ ...form, kind: e.target.value })}>
|
||||
<option value="anthropic">Anthropic Messages API</option>
|
||||
<option value="openai">OpenAI-compatible</option>
|
||||
</select>
|
||||
<label>{t("settings.baseUrl")}</label>
|
||||
<input value={form.base_url} onChange={(e) => setForm({ ...form, base_url: e.target.value })} />
|
||||
<label>{t("settings.apiKey")}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.api_key}
|
||||
placeholder={p.api_key === MASK ? t("settings.apiKeyStored") : "${ENV_VAR}"}
|
||||
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||
/>
|
||||
<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 })} />
|
||||
</div>
|
||||
<h3 style={{ marginTop: 16 }}>
|
||||
{t("settings.models")}
|
||||
<span className="spacer" />
|
||||
{discovered.length > 0 && (
|
||||
<button className="small" onClick={addDiscovered}>
|
||||
{t("settings.addDiscovered", { n: discovered.length })}
|
||||
</button>
|
||||
)}
|
||||
<button className="small" onClick={() => setModels([...models, { id: "" }])}>
|
||||
+ {t("settings.addModel")}
|
||||
</button>
|
||||
</h3>
|
||||
<div className="hint" style={{ marginBottom: 6, fontSize: 12, color: "var(--muted)" }}>
|
||||
{t("settings.pricesHint")}
|
||||
</div>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>{t("settings.context")}</th>
|
||||
<th>{t("settings.maxOutput")}</th>
|
||||
<th title={t("settings.reasoning")}>🧠</th>
|
||||
<th title={t("settings.vision")}>👁</th>
|
||||
<th>{t("settings.effort")}</th>
|
||||
<th>{t("settings.priceIn")}</th>
|
||||
<th>{t("settings.priceOut")}</th>
|
||||
<th>{t("settings.priceCacheRead")}</th>
|
||||
<th>{t("settings.priceCacheWrite")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.map((m, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input value={m.id} onChange={(e) => setModel(i, { id: e.target.value })} style={{ minWidth: 160 }} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" value={m.context ?? ""} onChange={(e) => setModel(i, { context: num(e.target.value) })} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" value={m.max_output ?? ""} onChange={(e) => setModel(i, { max_output: num(e.target.value) })} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" checked={!!m.reasoning} onChange={(e) => setModel(i, { reasoning: e.target.checked })} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" checked={!!m.vision} onChange={(e) => setModel(i, { vision: e.target.checked })} />
|
||||
</td>
|
||||
<td>
|
||||
<select value={m.effort ?? ""} onChange={(e) => setModel(i, { effort: e.target.value || undefined })}>
|
||||
<option value="">–</option>
|
||||
{["low", "medium", "high", "xhigh", "max"].map((x) => (
|
||||
<option key={x}>{x}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
{(["input_price", "output_price", "cache_read_price", "cache_write_price"] as const).map((k) => (
|
||||
<td key={k}>
|
||||
<input type="number" step="0.01" value={m[k] ?? ""} onChange={(e) => setModel(i, { [k]: num(e.target.value) })} />
|
||||
</td>
|
||||
))}
|
||||
<td>
|
||||
<button className="small ghost danger" onClick={() => setModels(models.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{models.length === 0 && <div className="hint" style={{ color: "var(--muted)", fontSize: 12 }}>{t("settings.modelsEmptyHint")}</div>}
|
||||
{view}
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
<button className="danger" onClick={remove}>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
const t = useT();
|
||||
const [name, setName] = useState("");
|
||||
const [preset, setPreset] = useState("anthropic");
|
||||
const { run, view } = useSaver(reload);
|
||||
const providers = cfg.config.providers ?? {};
|
||||
|
||||
const add = () => {
|
||||
const n = name.trim() || preset;
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
|
||||
const p = PRESETS[preset];
|
||||
const value: Record<string, unknown> = { kind: p.kind, base_url: p.base_url, api_key: p.api_key, enabled: true, models: [] };
|
||||
if (p.auth_prefix) value.auth_prefix = p.auth_prefix;
|
||||
void run(() => savePatches([{ path: ["providers", n], value }]));
|
||||
setName("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<button onClick={() => post("/providers/reload").then(reload)}>{t("settings.reloadProviders")}</button>
|
||||
</div>
|
||||
{Object.entries(providers).map(([n, p]) => (
|
||||
<ProviderEditor key={n + JSON.stringify(p)} name={n} p={p} reload={reload} />
|
||||
))}
|
||||
<div className="panel">
|
||||
<h3>{t("settings.addProvider")}</h3>
|
||||
<div className="row">
|
||||
<select value={preset} onChange={(e) => setPreset(e.target.value)}>
|
||||
{Object.keys(PRESETS).map((k) => (
|
||||
<option key={k}>{k}</option>
|
||||
))}
|
||||
</select>
|
||||
<input placeholder={t("settings.providerName")} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<button className="primary" onClick={add}>
|
||||
+ {t("common.add")}
|
||||
</button>
|
||||
</div>
|
||||
{view}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- MCP --------------------------------------------------------------------
|
||||
|
||||
function linesToMap(text: string, sep: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of text.split("\n")) {
|
||||
const i = line.indexOf(sep);
|
||||
if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + sep.length).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mapToLines(m: Record<string, string> | undefined, sep: string) {
|
||||
return Object.entries(m ?? {})
|
||||
.map(([k, v]) => `${k}${sep}${v}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload: () => void; isNew?: boolean }) {
|
||||
const t = useT();
|
||||
const status = useStore((s) => s.mcp[name]);
|
||||
const [form, setForm] = useState({
|
||||
name,
|
||||
type: m.type ?? "stdio",
|
||||
command: m.command ?? "",
|
||||
args: (m.args ?? []).join("\n"),
|
||||
env: mapToLines(m.env, "="),
|
||||
url: m.url ?? "",
|
||||
headers: mapToLines(m.headers, ": "),
|
||||
enabled: m.enabled ?? true,
|
||||
});
|
||||
const [open, setOpen] = useState(!!isNew);
|
||||
const { run, view } = useSaver(reload);
|
||||
|
||||
const save = () => {
|
||||
const n = form.name.trim();
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
|
||||
const value: Record<string, unknown> = { type: form.type, enabled: form.enabled };
|
||||
if (form.type === "stdio") {
|
||||
value.command = form.command;
|
||||
value.args = form.args.split("\n").map((s: string) => s.trim()).filter(Boolean);
|
||||
value.env = linesToMap(form.env, "=");
|
||||
} else {
|
||||
value.url = form.url;
|
||||
value.headers = linesToMap(form.headers, ":");
|
||||
}
|
||||
return run(async () => {
|
||||
const patches: Patch[] = [];
|
||||
if (!isNew && n !== name) patches.push({ path: ["mcp", name], value: null });
|
||||
patches.push({ path: ["mcp", n], value });
|
||||
await savePatches(patches);
|
||||
});
|
||||
};
|
||||
|
||||
const st = status?.status;
|
||||
const stateLabel = st ? t(`mcp.state.${st.state}` as Key) : isNew ? "" : t("mcp.state.unknown");
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
{isNew ? t("settings.addMcp") : name}
|
||||
{!isNew && <span className={`chip ${st?.state === "failed" ? "warn" : ""}`}>{stateLabel}</span>}
|
||||
{st?.state === "connected" && <span className="chip">{t("mcp.tools", { n: st.tools ?? 0 })}</span>}
|
||||
<span className="spacer" />
|
||||
{!isNew && (
|
||||
<button className="small" onClick={() => setOpen(!open)}>
|
||||
{open ? t("common.collapse") : t("common.edit")}
|
||||
</button>
|
||||
)}
|
||||
</h3>
|
||||
{st?.state === "failed" && <div className="notice err">{st.error}</div>}
|
||||
{open && (
|
||||
<>
|
||||
<div className="form">
|
||||
<label>{t("settings.name")}</label>
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
<label>{t("settings.kind")}</label>
|
||||
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
|
||||
<option value="stdio">stdio</option>
|
||||
<option value="http">Streamable HTTP</option>
|
||||
</select>
|
||||
{form.type === "stdio" ? (
|
||||
<>
|
||||
<label>{t("mcp.command")}</label>
|
||||
<input value={form.command} onChange={(e) => setForm({ ...form, command: e.target.value })} placeholder="npx" />
|
||||
<label>{t("mcp.args")}</label>
|
||||
<textarea rows={3} value={form.args} onChange={(e) => setForm({ ...form, args: e.target.value })} className="mono" />
|
||||
<label>{t("mcp.env")}</label>
|
||||
<textarea rows={2} value={form.env} onChange={(e) => setForm({ ...form, env: e.target.value })} className="mono" placeholder="KEY=${SECRET}" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label>URL</label>
|
||||
<input value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} placeholder="https://…/mcp" />
|
||||
<label>{t("mcp.headers")}</label>
|
||||
<textarea rows={2} value={form.headers} onChange={(e) => setForm({ ...form, headers: e.target.value })} className="mono" placeholder="Authorization: Bearer ${TOKEN}" />
|
||||
</>
|
||||
)}
|
||||
<label>{t("settings.enabled")}</label>
|
||||
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} style={{ justifySelf: "start" }} />
|
||||
</div>
|
||||
{status?.tools?.length ? (
|
||||
<details style={{ marginTop: 10 }}>
|
||||
<summary style={{ cursor: "pointer" }}>{t("mcp.tools", { n: status.tools.length })}</summary>
|
||||
<ul style={{ fontSize: 12.5 }}>
|
||||
{status.tools.map((tool) => (
|
||||
<li key={tool.name}>
|
||||
<code>{tool.name}</code> – {tool.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
{view}
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
{!isNew && (
|
||||
<button className="danger" onClick={() => confirm(t("settings.deleteMcpConfirm", { name })) && run(() => savePatches([{ path: ["mcp", name], value: null }]))}>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Mcp({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
const t = useT();
|
||||
const servers = cfg.config.mcp ?? {};
|
||||
return (
|
||||
<>
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<button onClick={() => post("/mcp/reload")}>{t("mcp.reconnect")}</button>
|
||||
</div>
|
||||
{Object.keys(servers).length === 0 && <div className="notice">{t("mcp.none")}</div>}
|
||||
{Object.entries(servers).map(([n, m]) => (
|
||||
<McpEditor key={n + JSON.stringify(m)} name={n} m={m} reload={reload} />
|
||||
))}
|
||||
<McpEditor key={`new-${Object.keys(servers).length}`} name="" m={{}} reload={reload} isNew />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- permissions ------------------------------------------------------------
|
||||
|
||||
function RuleTable({ rules, setRules, choices, keyLabel }: { rules: [string, string][]; setRules: (r: [string, string][]) => void; choices: string[]; keyLabel: string }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{keyLabel}</th>
|
||||
<th>{t("permissions.rule")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map(([k, v], i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input className="mono" value={k} onChange={(e) => setRules(rules.map((r, j) => (j === i ? [e.target.value, r[1]] : r)))} />
|
||||
</td>
|
||||
<td>
|
||||
<select value={v} onChange={(e) => setRules(rules.map((r, j) => (j === i ? [r[0], e.target.value] : r)))}>
|
||||
{choices.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`permissions.${c}` as Key)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button className="small ghost danger" onClick={() => setRules(rules.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td colSpan={3}>
|
||||
<button className="small" onClick={() => setRules([...rules, ["", choices[0]]])}>
|
||||
+ {t("common.add")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
const t = useT();
|
||||
const { bash_rules = {}, ...tools } = cfg.config.permissions ?? {};
|
||||
const [toolRules, setToolRules] = useState<[string, string][]>(Object.entries(tools) as [string, string][]);
|
||||
const [bashRules, setBashRules] = useState<[string, string][]>(Object.entries(bash_rules) as [string, string][]);
|
||||
const { run, view } = useSaver(reload);
|
||||
|
||||
const save = () =>
|
||||
run(() => {
|
||||
const patches: Patch[] = [];
|
||||
for (const k of Object.keys(tools)) if (!toolRules.some(([n]) => n === k)) patches.push({ path: ["permissions", k], value: null });
|
||||
for (const [k, v] of toolRules) if (k.trim()) patches.push({ path: ["permissions", k.trim()], value: v });
|
||||
for (const k of Object.keys(bash_rules)) if (!bashRules.some(([n]) => n === k)) patches.push({ path: ["permissions", "bash_rules", k], value: null });
|
||||
for (const [k, v] of bashRules) if (k.trim()) patches.push({ path: ["permissions", "bash_rules", k.trim()], value: v });
|
||||
return savePatches(patches);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="notice">{t("permissions.intro")}</div>
|
||||
<div className="two-col">
|
||||
<div className="panel">
|
||||
<h3>{t("permissions.tools")}</h3>
|
||||
<RuleTable rules={toolRules} setRules={setToolRules} choices={["allow", "ask", "deny"]} keyLabel={t("permissions.tool")} />
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>{t("permissions.bash")}</h3>
|
||||
<div style={{ fontSize: 12, color: "var(--muted)", marginBottom: 6 }}>{t("permissions.bashHint")}</div>
|
||||
<RuleTable rules={bashRules} setRules={setBashRules} choices={["allow", "deny"]} keyLabel={t("permissions.pattern")} />
|
||||
</div>
|
||||
</div>
|
||||
{view}
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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 }) {
|
||||
const t = useT();
|
||||
const [text, setText] = useState<string>();
|
||||
const { run, view } = useSaver(reload);
|
||||
useEffect(() => {
|
||||
get<{ text: string }>("/config/raw").then((r) => setText(r.text));
|
||||
}, []);
|
||||
if (text === undefined) return <div className="empty">{t("common.loading")}</div>;
|
||||
return (
|
||||
<>
|
||||
<div className="notice">{t("settings.rawHint")}</div>
|
||||
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
|
||||
{view}
|
||||
<div className="row" style={{ marginTop: 8 }}>
|
||||
<button className="primary" onClick={() => run(() => put("/config/raw", { text }))}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
<button onClick={() => get<{ text: string }>("/config/raw").then((r) => setText(r.text))}>{t("settings.revert")}</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- page -------------------------------------------------------------------
|
||||
|
||||
const TABS = ["general", "providers", "mcp", "permissions", "agents", "raw"] 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");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
☰
|
||||
</button>
|
||||
<div className="grow">
|
||||
<h1>{t("nav.settings")}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page">
|
||||
<div className="page-inner">
|
||||
<div className="tabs" role="tablist">
|
||||
{TABS.map((x) => (
|
||||
<button key={x} role="tab" aria-selected={active === x} className={active === x ? "active" : ""} onClick={() => navigate(`/settings/${x}`)}>
|
||||
{t(`settings.tab.${x}` as Key)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
{!cfg && !error && <div className="empty">{t("common.loading")}</div>}
|
||||
{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 />}
|
||||
{active === "raw" && <Raw reload={reload} />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMemo } from "react";
|
||||
import { fmtRelative, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import type { Session } from "../types";
|
||||
|
||||
function basename(p: string) {
|
||||
return p.split("/").filter(Boolean).pop() ?? p;
|
||||
}
|
||||
|
||||
function groupKey(ms: number): Key {
|
||||
const day = 86400000;
|
||||
const startOfToday = new Date().setHours(0, 0, 0, 0);
|
||||
if (ms >= startOfToday) return "sidebar.today";
|
||||
if (ms >= startOfToday - day) return "sidebar.yesterday";
|
||||
if (ms >= startOfToday - 7 * day) return "sidebar.week";
|
||||
return "sidebar.older";
|
||||
}
|
||||
|
||||
export function Sidebar({ open, activeId, section }: { open: boolean; activeId?: string; section: string }) {
|
||||
const t = useT();
|
||||
const sessions = useStore((s) => s.sessions);
|
||||
const busy = useStore((s) => s.busy);
|
||||
const connected = useStore((s) => s.connected);
|
||||
const permissions = useStore((s) => s.permissions);
|
||||
const questions = useStore((s) => s.questions);
|
||||
|
||||
const attention = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const p of permissions) ids.add(p.root_session_id ?? p.session_id);
|
||||
for (const q of questions) ids.add(q.root_session_id ?? q.session_id);
|
||||
return ids;
|
||||
}, [permissions, questions]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const out: { key: Key; items: Session[] }[] = [];
|
||||
for (const s of sessions) {
|
||||
const key = groupKey(s.updated_at);
|
||||
const g = out.find((x) => x.key === key);
|
||||
if (g) g.items.push(s);
|
||||
else out.push({ key, items: [s] });
|
||||
}
|
||||
return out;
|
||||
}, [sessions]);
|
||||
|
||||
return (
|
||||
<aside className={`sidebar ${open ? "open" : ""}`}>
|
||||
<div className="sidebar-head">
|
||||
<div className="logo">
|
||||
<img src="/icon.svg" alt="" />
|
||||
CodeCraft
|
||||
</div>
|
||||
<span className={`conn-dot ${connected ? "on" : ""}`} title={connected ? t("sidebar.connected") : t("sidebar.disconnected")} />
|
||||
</div>
|
||||
<div style={{ padding: "0 12px 8px" }}>
|
||||
<button className="primary" style={{ width: "100%" }} onClick={() => navigate("/new")}>
|
||||
+ {t("sidebar.newSession")}
|
||||
</button>
|
||||
</div>
|
||||
<nav className="session-list">
|
||||
{sessions.length === 0 && <div className="empty">{t("sidebar.noSessions")}</div>}
|
||||
{groups.map((g) => (
|
||||
<div key={g.key}>
|
||||
<div className="session-group">{t(g.key)}</div>
|
||||
{g.items.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
className={`session-item ${s.id === activeId ? "active" : ""}`}
|
||||
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" />}
|
||||
<span>{s.title || t("session.untitled")}</span>
|
||||
</div>
|
||||
<div className="sub">
|
||||
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
|
||||
{t("nav.stats")}
|
||||
</button>
|
||||
<button className={`ghost ${section === "settings" ? "active" : ""}`} onClick={() => navigate("/settings")}>
|
||||
{t("nav.settings")}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
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 type { Aggregate } from "../types";
|
||||
import { Heatmap, Legend, SERIES, StackedBars, type Series } from "./Charts";
|
||||
|
||||
type Row<K extends string> = Aggregate & Record<K, string>;
|
||||
|
||||
interface StatsData {
|
||||
total: Aggregate;
|
||||
by_day: Row<"day">[];
|
||||
by_day_model: { day: string; model: string; cost: number; input_tokens: number; output_tokens: number }[];
|
||||
by_model: Row<"model">[];
|
||||
by_provider: Row<"provider">[];
|
||||
by_agent: Row<"agent">[];
|
||||
heatmap: (Aggregate & { slot: number })[];
|
||||
top_sessions: (Aggregate & { session_id: string; title: string })[];
|
||||
currency: string;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
const RANGES: { key: string; days: number; label: Key }[] = [
|
||||
{ key: "7", days: 7, label: "stats.range7" },
|
||||
{ key: "30", days: 30, label: "stats.range30" },
|
||||
{ key: "90", days: 90, label: "stats.range90" },
|
||||
{ key: "365", days: 365, label: "stats.range365" },
|
||||
{ key: "all", days: 0, label: "stats.rangeAll" },
|
||||
];
|
||||
|
||||
type HeatMetric = "requests" | "tokens" | "cost";
|
||||
|
||||
function dayKey(ms: number) {
|
||||
const d = new Date(ms);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** All days between from and to (local time), so empty days show as gaps. */
|
||||
function dayRange(fromMs: number, toMs: number, firstDataDay?: string): string[] {
|
||||
const start = new Date(firstDataDay && fromMs === 0 ? `${firstDataDay}T00:00:00` : fromMs);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const out: string[] = [];
|
||||
for (let d = new Date(start); d.getTime() <= toMs && out.length < 800; d.setDate(d.getDate() + 1)) out.push(dayKey(d.getTime()));
|
||||
return out;
|
||||
}
|
||||
|
||||
function Kpi({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<div className="kpi">
|
||||
<div className="k-label">{label}</div>
|
||||
<div className="k-value">{value}</div>
|
||||
{sub && <div className="k-sub">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{label}</th>
|
||||
<th className="num">{t("stats.requests")}</th>
|
||||
<th className="num">{t("stats.input")}</th>
|
||||
<th className="num">{t("stats.cacheRead")}</th>
|
||||
<th className="num">{t("stats.output")}</th>
|
||||
<th className="num">{t("stats.ttft")}</th>
|
||||
<th className="num">{t("stats.outputSpeed")}</th>
|
||||
<th className="num">{t("stats.prefillSpeed")}</th>
|
||||
<th className="num">{t("stats.cost")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r[keyName]}>
|
||||
<td>{link ? <a href="#" onClick={(e) => (e.preventDefault(), link(r))}>{r[keyName]}</a> : r[keyName]}</td>
|
||||
<td className="num">{fmtNumber(r.requests)}</td>
|
||||
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
|
||||
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
|
||||
<td className="num">{fmtCompact(r.output_tokens)}</td>
|
||||
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
|
||||
<td className="num">{fmtNumber(r.output_tps, 1)}</td>
|
||||
<td className="num">{fmtNumber(r.prefill_tps)}</td>
|
||||
<td className="num">{fmtCost(r.cost, currency)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
const t = useT();
|
||||
const [range, setRange] = useState("30");
|
||||
const [heat, setHeat] = useState<HeatMetric>("requests");
|
||||
const [tableView, setTableView] = useState(false);
|
||||
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 view = useMemo(() => {
|
||||
if (!data) return undefined;
|
||||
const cats = dayRange(data.from, data.to, data.by_day[0]?.day);
|
||||
// Up to 7 models by cost get their own color; the rest fold into "Other".
|
||||
const ranked = [...data.by_model].sort((a, b) => b.cost - a.cost || b.requests - a.requests);
|
||||
const named = ranked.slice(0, ranked.length > 8 ? 7 : 8).map((m) => m.model);
|
||||
const other = t("stats.other");
|
||||
const modelSeries: Series[] = [...named, ...(ranked.length > 8 ? [other] : [])].map((m, i) => ({ key: m, label: m, color: SERIES[i] }));
|
||||
const costByDay = cats.map(() => ({}) as Record<string, number>);
|
||||
const idx = new Map(cats.map((c, i) => [c, i]));
|
||||
for (const r of data.by_day_model) {
|
||||
const i = idx.get(r.day);
|
||||
if (i === undefined) continue;
|
||||
const key = named.includes(r.model) ? r.model : other;
|
||||
costByDay[i][key] = (costByDay[i][key] ?? 0) + r.cost;
|
||||
}
|
||||
const tokenSeries: Series[] = [
|
||||
{ key: "input", label: t("stats.input"), color: SERIES[0] },
|
||||
{ key: "cache", label: t("stats.cacheRead"), color: SERIES[1] },
|
||||
{ key: "output", label: t("stats.output"), color: SERIES[2] },
|
||||
];
|
||||
const tokensByDay = cats.map((c): Record<string, number> => {
|
||||
const d = data.by_day.find((x) => x.day === c);
|
||||
return d ? { input: d.input_tokens + d.cache_write_tokens, cache: d.cache_read_tokens, output: d.output_tokens } : {};
|
||||
});
|
||||
const cells = Array<number>(168).fill(0);
|
||||
for (const h of data.heatmap) {
|
||||
cells[h.slot] =
|
||||
heat === "requests" ? h.requests : heat === "cost" ? h.cost : h.input_tokens + h.cache_read_tokens + h.cache_write_tokens + h.output_tokens;
|
||||
}
|
||||
return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells };
|
||||
}, [data, heat, t]);
|
||||
|
||||
const dayLabel = (d: string) =>
|
||||
new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "short" }).format(new Date(`${d}T00:00:00`));
|
||||
const cur = data?.currency ?? "USD";
|
||||
const tot = data?.total;
|
||||
const heatFormat = (v: number) => (heat === "cost" ? fmtCost(v, cur) : fmtCompact(v));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
☰
|
||||
</button>
|
||||
<div className="grow">
|
||||
<h1>{t("nav.stats")}</h1>
|
||||
{data && (
|
||||
<div className="path">
|
||||
{data.from ? fmtDate(data.from, false) : t("stats.rangeAll")} – {fmtDate(data.to, false)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mode-tabs" role="tablist">
|
||||
{RANGES.map((r) => (
|
||||
<button key={r.key} className={range === r.key ? "active" : ""} onClick={() => setRange(r.key)}>
|
||||
{t(r.label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="small" onClick={() => setTableView(!tableView)}>
|
||||
{tableView ? t("stats.chartView") : t("stats.tableView")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="page">
|
||||
<div className="page-inner">
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
{!data && !error && <div className="empty">{t("common.loading")}</div>}
|
||||
{data && tot && view && (
|
||||
<>
|
||||
<div className="kpis">
|
||||
<Kpi label={t("stats.cost")} value={fmtCost(tot.cost, cur)} sub={t("stats.perRequest", { v: fmtCost(tot.requests ? tot.cost / tot.requests : 0, cur) })} />
|
||||
<Kpi label={t("stats.requests")} value={fmtNumber(tot.requests)} sub={tot.errors ? t("stats.errors", { n: tot.errors }) : undefined} />
|
||||
<Kpi label={t("stats.input")} value={fmtCompact(tot.input_tokens + tot.cache_read_tokens + tot.cache_write_tokens)} sub={`${t("stats.cacheRead")}: ${fmtCompact(tot.cache_read_tokens)}`} />
|
||||
<Kpi label={t("stats.output")} value={fmtCompact(tot.output_tokens)} />
|
||||
<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`} />
|
||||
</div>
|
||||
|
||||
{tot.requests === 0 ? (
|
||||
<div className="empty">{t("stats.noData")}</div>
|
||||
) : tableView ? (
|
||||
<div className="panel">
|
||||
<h3>{t("stats.perDay")}</h3>
|
||||
<AggTable rows={data.by_day} keyName="day" label={t("stats.day")} currency={cur} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="panel">
|
||||
<h3>{t("stats.costPerDay")}</h3>
|
||||
<Legend series={view.modelSeries} />
|
||||
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.modelSeries} values={view.costByDay} format={(v) => fmtCost(v, cur)} />
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>{t("stats.tokensPerDay")}</h3>
|
||||
<Legend series={view.tokenSeries} />
|
||||
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.tokenSeries} values={view.tokensByDay} format={fmtCompact} />
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>
|
||||
{t("stats.heatmap")}
|
||||
<span className="spacer" />
|
||||
<select value={heat} onChange={(e) => setHeat(e.target.value as HeatMetric)}>
|
||||
<option value="requests">{t("stats.requests")}</option>
|
||||
<option value="tokens">{t("stats.tokens")}</option>
|
||||
<option value="cost">{t("stats.cost")}</option>
|
||||
</select>
|
||||
</h3>
|
||||
<Heatmap cells={view.cells} weekdays={weekdayNames()} format={heatFormat} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tot.requests > 0 && (
|
||||
<>
|
||||
<div className="panel">
|
||||
<h3>{t("stats.byModel")}</h3>
|
||||
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} />
|
||||
</div>
|
||||
<div className="two-col">
|
||||
<div className="panel">
|
||||
<h3>{t("stats.byProvider")}</h3>
|
||||
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>{t("stats.byAgent")}</h3>
|
||||
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
|
||||
</div>
|
||||
</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}`)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import type { Part, Todo } from "../types";
|
||||
import { Diff } from "./Diff";
|
||||
|
||||
type ToolCall = Extract<Part, { type: "tool_call" }>;
|
||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||
|
||||
function short(s: unknown, n = 90): string {
|
||||
const str = typeof s === "string" ? s : JSON.stringify(s);
|
||||
return str.length > n ? `${str.slice(0, n)}…` : str;
|
||||
}
|
||||
|
||||
/** One-line summary of a tool call. */
|
||||
export function toolTitle(name: string, input: any): string {
|
||||
switch (name) {
|
||||
case "read":
|
||||
case "write":
|
||||
case "edit":
|
||||
return input?.path ?? "";
|
||||
case "list":
|
||||
return input?.path ?? ".";
|
||||
case "bash":
|
||||
return input?.description || input?.command || "";
|
||||
case "glob":
|
||||
case "grep":
|
||||
return `${input?.pattern ?? ""}${input?.path ? ` in ${input.path}` : ""}`;
|
||||
case "web_fetch":
|
||||
case "browser_snapshot":
|
||||
return input?.url ?? "";
|
||||
case "task":
|
||||
return `${input?.subagent_type ?? "general"}: ${input?.description ?? ""}`;
|
||||
case "image_compare":
|
||||
return `${input?.original ?? ""} ↔ ${input?.candidate ?? ""}`;
|
||||
case "skill":
|
||||
return input?.name ?? "";
|
||||
case "ask_user":
|
||||
return input?.questions?.[0]?.question ?? "";
|
||||
default:
|
||||
return short(input);
|
||||
}
|
||||
}
|
||||
|
||||
export function TodoList({ todos }: { todos: Todo[] }) {
|
||||
const icon = { completed: "☑", in_progress: "▶", pending: "☐" } as const;
|
||||
return (
|
||||
<ul>
|
||||
{todos.map((td, i) => (
|
||||
<li key={i} className={td.status}>
|
||||
<span>{icon[td.status] ?? "☐"}</span>
|
||||
<span>{td.content}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolCard({ call, result, running }: { call: ToolCall; result?: ToolResult; running: boolean }) {
|
||||
const t = useT();
|
||||
const progress = useStore((s) => s.progress[call.id]);
|
||||
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
|
||||
const statusText = result ? (result.is_error ? "✗" : "✓") : running ? "…" : "–";
|
||||
const meta = result?.meta;
|
||||
|
||||
// The todo list is shown in its own panel; keep the card compact.
|
||||
if (call.name === "todo_write" && !result?.is_error) {
|
||||
return (
|
||||
<div className="todos">
|
||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
|
||||
<TodoList todos={call.input?.todos ?? []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="tool" open={status === "err" && !!result}>
|
||||
<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>
|
||||
</summary>
|
||||
<div className="tool-body">
|
||||
{call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>}
|
||||
{(call.name === "edit" || call.name === "write") && meta?.diff && <Diff diff={meta.diff} />}
|
||||
{call.name === "task" && meta?.session_id && (
|
||||
<button className="small" onClick={() => navigate(`/s/${meta.session_id}`)}>
|
||||
{t("tool.openSubagent")}
|
||||
</button>
|
||||
)}
|
||||
{!["bash", "edit", "write", "task"].includes(call.name) && (
|
||||
<>
|
||||
<div className="label">{t("tool.input")}</div>
|
||||
<pre className="mono">{JSON.stringify(call.input, null, 2)}</pre>
|
||||
</>
|
||||
)}
|
||||
{!result && progress && (
|
||||
<>
|
||||
<div className="label">{t("tool.output")}</div>
|
||||
<pre className="mono">{progress}</pre>
|
||||
</>
|
||||
)}
|
||||
{result && !((call.name === "edit" || call.name === "write") && meta?.diff && !result.is_error) && (
|
||||
<>
|
||||
<div className="label">{t("tool.output")}</div>
|
||||
<pre className="mono">{result.content}</pre>
|
||||
</>
|
||||
)}
|
||||
{result?.images?.map((img, i) => (
|
||||
<img key={i} src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { get } from "./api";
|
||||
import type { AgentInfo, ProviderModels } from "./types";
|
||||
|
||||
/** Fetch JSON and refetch on demand. */
|
||||
export function useFetch<T>(path: string | null) {
|
||||
const [data, setData] = useState<T | undefined>();
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!path) return;
|
||||
let alive = true;
|
||||
get<T>(path)
|
||||
.then((d) => alive && (setData(d), setError(undefined)))
|
||||
.catch((e) => alive && setError(String(e.message ?? e)));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [path, tick]);
|
||||
const reload = useCallback(() => setTick((x) => x + 1), []);
|
||||
return { data, error, reload };
|
||||
}
|
||||
|
||||
export function useModels() {
|
||||
return useFetch<ProviderModels[]>("/models").data ?? [];
|
||||
}
|
||||
|
||||
export function useAgents(cwd?: string) {
|
||||
const q = cwd ? `?cwd=${encodeURIComponent(cwd)}` : "";
|
||||
return useFetch<AgentInfo[]>(`/agents${q}`).data ?? [];
|
||||
}
|
||||
|
||||
export function modelOptions(models: ProviderModels[]): { value: string; label: string }[] {
|
||||
return models.flatMap((p) => p.models.map((m) => ({ value: `${p.provider}/${m.id}`, label: `${p.provider} · ${m.name}` })));
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { Messages } from "./en";
|
||||
|
||||
export const de: Messages = {
|
||||
"common.loading": "Lädt…",
|
||||
"common.save": "Speichern",
|
||||
"common.add": "Hinzufügen",
|
||||
"common.edit": "Bearbeiten",
|
||||
"common.delete": "Löschen",
|
||||
"common.remove": "Entfernen",
|
||||
"common.close": "Schließen",
|
||||
"common.collapse": "Einklappen",
|
||||
|
||||
"nav.menu": "Menü",
|
||||
"nav.settings": "Einstellungen",
|
||||
"nav.stats": "Statistik",
|
||||
|
||||
"login.prompt": "Gib das auf dem Server konfigurierte Zugriffstoken ein.",
|
||||
"login.token": "Zugriffstoken",
|
||||
"login.invalid": "Ungültiges Token.",
|
||||
"login.submit": "Anmelden",
|
||||
|
||||
"sidebar.newSession": "Neue Session",
|
||||
"sidebar.noSessions": "Noch keine Sessions",
|
||||
"sidebar.today": "Heute",
|
||||
"sidebar.yesterday": "Gestern",
|
||||
"sidebar.week": "Letzte 7 Tage",
|
||||
"sidebar.older": "Älter",
|
||||
"sidebar.connected": "Verbunden",
|
||||
"sidebar.disconnected": "Getrennt – verbinde neu…",
|
||||
"sidebar.needsInput": "Wartet auf deine Eingabe",
|
||||
|
||||
"session.untitled": "Neue Session",
|
||||
|
||||
"new.title": "Neue Session",
|
||||
"new.directory": "Projektverzeichnis",
|
||||
"new.up": "Hoch",
|
||||
"new.noSubdirs": "Keine Unterverzeichnisse",
|
||||
"new.model": "Modell",
|
||||
"new.mode": "Modus",
|
||||
"new.firstPrompt": "Was soll erledigt werden? (optional)",
|
||||
"new.start": "Starten in {dir}",
|
||||
|
||||
"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",
|
||||
"chat.statsHint": "Tokens und Kosten dieser Session (Klick für Details)",
|
||||
"chat.renameHint": "Doppelklick zum Umbenennen",
|
||||
"chat.rewind": "Ab hier bearbeiten und neu senden",
|
||||
"chat.rewindConfirm": "Diese Nachricht und alles danach entfernen? Dateiänderungen werden nicht rückgängig gemacht.",
|
||||
"chat.delete": "Session löschen",
|
||||
"chat.deleteConfirm": "Diese Session inklusive Subagenten löschen? Die Statistik bleibt erhalten.",
|
||||
|
||||
"composer.placeholder": "Nachricht… (/ für Befehle, Bilder einfügen)",
|
||||
"composer.placeholderBusy": "Nachricht wird eingereiht und beim nächsten Schritt gesendet…",
|
||||
"composer.send": "Senden",
|
||||
"composer.queue": "Einreihen",
|
||||
"composer.stop": "Stopp",
|
||||
"composer.mode": "Modus",
|
||||
"composer.model": "Modell",
|
||||
"composer.permissionMode": "Berechtigungsmodus",
|
||||
"composer.attach": "Bilder anhängen",
|
||||
|
||||
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
|
||||
"command.new": "Neue Session starten",
|
||||
|
||||
"permissionMode.default": "Vor Änderungen fragen",
|
||||
"permissionMode.accept_edits": "Änderungen automatisch annehmen",
|
||||
"permissionMode.bypass": "Alles erlauben",
|
||||
|
||||
"plan.implement": "Zu Build wechseln und Plan umsetzen",
|
||||
"plan.implementPrompt": "Setze den Plan um.",
|
||||
|
||||
"tool.input": "Eingabe",
|
||||
"tool.output": "Ausgabe",
|
||||
"tool.todos": "Aufgaben",
|
||||
"tool.openSubagent": "Subagent-Session öffnen",
|
||||
|
||||
"interaction.fromSubagent": "Vom Subagenten:",
|
||||
|
||||
"permission.title": "Freigabe erforderlich",
|
||||
"permission.allowOnce": "Einmal erlauben",
|
||||
"permission.alwaysTool": "{tool} in dieser Session immer erlauben",
|
||||
"permission.alwaysCommand": "„{prefix}“ in dieser Session immer erlauben",
|
||||
"permission.reject": "Ablehnen",
|
||||
"permission.rejectWithFeedback": "Mit Rückmeldung ablehnen",
|
||||
"permission.feedback": "Optional: Sag dem Agenten, was er stattdessen tun soll",
|
||||
|
||||
"question.title": "Frage an dich",
|
||||
"question.fromAgent": "Der Agent braucht eine Entscheidung",
|
||||
"question.fromMcp": "MCP-Server „{server}“ fragt",
|
||||
"question.other": "Andere Antwort (Freitext)",
|
||||
"question.answer": "Deine Antwort",
|
||||
"question.submit": "Antworten",
|
||||
"question.dismiss": "Verwerfen",
|
||||
|
||||
"settings.tab.general": "Allgemein",
|
||||
"settings.tab.providers": "Provider & Modelle",
|
||||
"settings.tab.mcp": "MCP",
|
||||
"settings.tab.permissions": "Berechtigungen",
|
||||
"settings.tab.agents": "Agenten & Befehle",
|
||||
"settings.tab.raw": "Config-Datei",
|
||||
"settings.saved": "Gespeichert – Änderungen sind aktiv.",
|
||||
"settings.interface": "Oberfläche (dieses Gerät)",
|
||||
"settings.language": "Sprache",
|
||||
"settings.theme": "Design",
|
||||
"settings.themeSystem": "System",
|
||||
"settings.themeDark": "Dunkel",
|
||||
"settings.themeLight": "Hell",
|
||||
"settings.notifications": "Benachrichtigungen",
|
||||
"settings.notificationsEnable": "Aktivieren",
|
||||
"settings.notificationsOn": "Aktiv",
|
||||
"settings.notificationsHint": "Benachrichtigen, wenn ein Agent eine Frage stellt, während die App im Hintergrund ist.",
|
||||
"settings.server": "Server",
|
||||
"settings.workspace": "Workspace",
|
||||
"settings.defaultModel": "Standardmodell",
|
||||
"settings.smallModel": "Kleines Modell",
|
||||
"settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.",
|
||||
"settings.currency": "Währung",
|
||||
"settings.autoCompact": "Auto-Komprimierung bei",
|
||||
"settings.autoCompactHint": "Anteil des Kontextfensters (0 = aus).",
|
||||
"settings.craft": "Craft-Modus",
|
||||
"settings.cleanRoom": "Clean Room",
|
||||
"settings.cleanRoomHint": "Nur nutzen, was ein Besucher sieht. Aus = Deep-Modus (Quelltext und Skripte dürfen gelesen werden).",
|
||||
"settings.chromium": "Chromium-Programm",
|
||||
"settings.widths": "Bildschirmbreiten (px)",
|
||||
"settings.kind": "Typ",
|
||||
"settings.baseUrl": "Basis-URL",
|
||||
"settings.apiKey": "API-Key",
|
||||
"settings.apiKeyStored": "Gespeichert – leer lassen zum Beibehalten",
|
||||
"settings.apiKeyHint": "Am besten ${ENV_VAR}-Verweise nutzen; direkte Keys stehen im Klartext in der Config-Datei.",
|
||||
"settings.authPrefix": "Auth-Präfix",
|
||||
"settings.enabled": "Aktiv",
|
||||
"settings.modelCount": "{n} Modelle",
|
||||
"settings.models": "Modelle & Preise",
|
||||
"settings.addModel": "Modell",
|
||||
"settings.addDiscovered": "{n} gefundene hinzufügen",
|
||||
"settings.pricesHint": "Preise pro 1 Mio. Tokens in der eingestellten Währung. Cache-Preise fallen auf den Input-Preis zurück.",
|
||||
"settings.modelsEmptyHint": "Keine Modelle eingetragen: Sie werden automatisch beim Provider abgefragt.",
|
||||
"settings.context": "Kontext",
|
||||
"settings.maxOutput": "Max. Ausgabe",
|
||||
"settings.reasoning": "Reasoning",
|
||||
"settings.vision": "Bilder",
|
||||
"settings.effort": "Effort",
|
||||
"settings.priceIn": "Input",
|
||||
"settings.priceOut": "Output",
|
||||
"settings.priceCacheRead": "Cache lesen",
|
||||
"settings.priceCacheWrite": "Cache schreiben",
|
||||
"settings.addProvider": "Provider hinzufügen",
|
||||
"settings.providerName": "Name (optional)",
|
||||
"settings.reloadProviders": "Provider & Modelle neu laden",
|
||||
"settings.deleteProviderConfirm": "Provider „{name}“ löschen?",
|
||||
"settings.invalidName": "Namen dürfen nur Buchstaben, Ziffern, - und _ enthalten.",
|
||||
"settings.name": "Name",
|
||||
"settings.addMcp": "MCP-Server hinzufügen",
|
||||
"settings.deleteMcpConfirm": "MCP-Server „{name}“ löschen?",
|
||||
"settings.rawHint": "Die komplette Konfigurationsdatei inklusive Kommentaren. Sie wird vor dem Speichern geprüft.",
|
||||
"settings.revert": "Zurücksetzen",
|
||||
|
||||
"mcp.reconnect": "Alle neu verbinden",
|
||||
"mcp.none": "Keine MCP-Server konfiguriert.",
|
||||
"mcp.tools": "{n} Tools",
|
||||
"mcp.command": "Befehl",
|
||||
"mcp.args": "Argumente (eins pro Zeile)",
|
||||
"mcp.env": "Umgebung (KEY=Wert)",
|
||||
"mcp.headers": "Header (Name: Wert)",
|
||||
"mcp.state.disabled": "deaktiviert",
|
||||
"mcp.state.connecting": "verbinde…",
|
||||
"mcp.state.connected": "verbunden",
|
||||
"mcp.state.failed": "Fehler",
|
||||
"mcp.state.unknown": "nicht gestartet",
|
||||
|
||||
"permissions.intro": "Regeln für die Tool-Nutzung. „Fragen“ zeigt eine Freigabekarte. MCP-Tools: mcp__<server>__<tool>, Platzhalter erlaubt (mcp__*).",
|
||||
"permissions.tools": "Tools",
|
||||
"permissions.bash": "Shell-Befehle",
|
||||
"permissions.bashHint": "Glob-Muster auf den ganzen Befehl. Zusammengesetzte Befehle (&&, ;, |) sind nur erlaubt, wenn jeder Teil erlaubt ist.",
|
||||
"permissions.tool": "Tool",
|
||||
"permissions.pattern": "Muster",
|
||||
"permissions.rule": "Regel",
|
||||
"permissions.allow": "Erlauben",
|
||||
"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",
|
||||
"agents.skills": "Skills",
|
||||
|
||||
"stats.range7": "7 Tage",
|
||||
"stats.range30": "30 Tage",
|
||||
"stats.range90": "90 Tage",
|
||||
"stats.range365": "1 Jahr",
|
||||
"stats.rangeAll": "Alles",
|
||||
"stats.tableView": "Tabellenansicht",
|
||||
"stats.chartView": "Diagrammansicht",
|
||||
"stats.cost": "Kosten",
|
||||
"stats.perRequest": "{v} pro Anfrage",
|
||||
"stats.requests": "Anfragen",
|
||||
"stats.errors": "{n} fehlgeschlagen",
|
||||
"stats.input": "Input",
|
||||
"stats.output": "Output",
|
||||
"stats.cacheRead": "Cache gelesen",
|
||||
"stats.cacheHit": "Cache-Trefferquote",
|
||||
"stats.tokens": "Tokens",
|
||||
"stats.ttft": "Zeit bis zum ersten Token",
|
||||
"stats.ttftHint": "Durchschnittliche Prefill-Wartezeit",
|
||||
"stats.outputSpeed": "Ausgabegeschwindigkeit",
|
||||
"stats.prefillSpeed": "Prefill-Geschwindigkeit",
|
||||
"stats.costPerDay": "Kosten pro Tag nach Modell",
|
||||
"stats.tokensPerDay": "Tokens pro Tag",
|
||||
"stats.heatmap": "Aktivität nach Wochentag und Uhrzeit",
|
||||
"stats.byModel": "Nach Modell",
|
||||
"stats.byProvider": "Nach Provider",
|
||||
"stats.byAgent": "Nach Modus",
|
||||
"stats.topSessions": "Teuerste Sessions",
|
||||
"stats.perDay": "Pro Tag",
|
||||
"stats.day": "Tag",
|
||||
"stats.model": "Modell",
|
||||
"stats.provider": "Provider",
|
||||
"stats.agent": "Modus",
|
||||
"stats.session": "Session",
|
||||
"stats.total": "Gesamt",
|
||||
"stats.other": "Sonstige",
|
||||
"stats.noData": "Keine Anfragen in diesem Zeitraum.",
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
// Reference language. Every other language must define the same keys.
|
||||
export const en = {
|
||||
"common.loading": "Loading…",
|
||||
"common.save": "Save",
|
||||
"common.add": "Add",
|
||||
"common.edit": "Edit",
|
||||
"common.delete": "Delete",
|
||||
"common.remove": "Remove",
|
||||
"common.close": "Close",
|
||||
"common.collapse": "Collapse",
|
||||
|
||||
"nav.menu": "Menu",
|
||||
"nav.settings": "Settings",
|
||||
"nav.stats": "Statistics",
|
||||
|
||||
"login.prompt": "Enter the access token configured on the server.",
|
||||
"login.token": "Access token",
|
||||
"login.invalid": "Invalid token.",
|
||||
"login.submit": "Sign in",
|
||||
|
||||
"sidebar.newSession": "New session",
|
||||
"sidebar.noSessions": "No sessions yet",
|
||||
"sidebar.today": "Today",
|
||||
"sidebar.yesterday": "Yesterday",
|
||||
"sidebar.week": "Last 7 days",
|
||||
"sidebar.older": "Older",
|
||||
"sidebar.connected": "Connected",
|
||||
"sidebar.disconnected": "Disconnected – reconnecting…",
|
||||
"sidebar.needsInput": "Waiting for your input",
|
||||
|
||||
"session.untitled": "New session",
|
||||
|
||||
"new.title": "New session",
|
||||
"new.directory": "Project directory",
|
||||
"new.up": "Up",
|
||||
"new.noSubdirs": "No subdirectories",
|
||||
"new.model": "Model",
|
||||
"new.mode": "Mode",
|
||||
"new.firstPrompt": "What should be done? (optional)",
|
||||
"new.start": "Start in {dir}",
|
||||
|
||||
"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",
|
||||
"chat.statsHint": "Tokens and cost of this session (click for details)",
|
||||
"chat.renameHint": "Double-click to rename",
|
||||
"chat.rewind": "Edit and resend from here",
|
||||
"chat.rewindConfirm": "Remove this message and everything after it? File changes are not undone.",
|
||||
"chat.delete": "Delete session",
|
||||
"chat.deleteConfirm": "Delete this session including its subagents? Statistics are kept.",
|
||||
|
||||
"composer.placeholder": "Message… (/ for commands, paste images)",
|
||||
"composer.placeholderBusy": "Message is queued and sent at the next step…",
|
||||
"composer.send": "Send",
|
||||
"composer.queue": "Queue",
|
||||
"composer.stop": "Stop",
|
||||
"composer.mode": "Mode",
|
||||
"composer.model": "Model",
|
||||
"composer.permissionMode": "Permission mode",
|
||||
"composer.attach": "Attach images",
|
||||
|
||||
"command.compact": "Summarize the conversation to free up context",
|
||||
"command.new": "Start a new session",
|
||||
|
||||
"permissionMode.default": "Ask before changes",
|
||||
"permissionMode.accept_edits": "Auto-accept edits",
|
||||
"permissionMode.bypass": "Allow everything",
|
||||
|
||||
"plan.implement": "Switch to build and implement the plan",
|
||||
"plan.implementPrompt": "Implement the plan.",
|
||||
|
||||
"tool.input": "Input",
|
||||
"tool.output": "Output",
|
||||
"tool.todos": "Tasks",
|
||||
"tool.openSubagent": "Open subagent session",
|
||||
|
||||
"interaction.fromSubagent": "From subagent:",
|
||||
|
||||
"permission.title": "Permission required",
|
||||
"permission.allowOnce": "Allow once",
|
||||
"permission.alwaysTool": "Always allow {tool} in this session",
|
||||
"permission.alwaysCommand": "Always allow \"{prefix}\" in this session",
|
||||
"permission.reject": "Reject",
|
||||
"permission.rejectWithFeedback": "Reject with feedback",
|
||||
"permission.feedback": "Optional: tell the agent what to do instead",
|
||||
|
||||
"question.title": "Question for you",
|
||||
"question.fromAgent": "The agent needs a decision",
|
||||
"question.fromMcp": "MCP server \"{server}\" asks",
|
||||
"question.other": "Other (free text)",
|
||||
"question.answer": "Your answer",
|
||||
"question.submit": "Answer",
|
||||
"question.dismiss": "Dismiss",
|
||||
|
||||
"settings.tab.general": "General",
|
||||
"settings.tab.providers": "Providers & models",
|
||||
"settings.tab.mcp": "MCP",
|
||||
"settings.tab.permissions": "Permissions",
|
||||
"settings.tab.agents": "Agents & commands",
|
||||
"settings.tab.raw": "Config file",
|
||||
"settings.saved": "Saved – changes are active.",
|
||||
"settings.interface": "Interface (this device)",
|
||||
"settings.language": "Language",
|
||||
"settings.theme": "Theme",
|
||||
"settings.themeSystem": "System",
|
||||
"settings.themeDark": "Dark",
|
||||
"settings.themeLight": "Light",
|
||||
"settings.notifications": "Notifications",
|
||||
"settings.notificationsEnable": "Enable",
|
||||
"settings.notificationsOn": "Enabled",
|
||||
"settings.notificationsHint": "Notify when an agent asks a question while the app is in the background.",
|
||||
"settings.server": "Server",
|
||||
"settings.workspace": "Workspace",
|
||||
"settings.defaultModel": "Default model",
|
||||
"settings.smallModel": "Small model",
|
||||
"settings.smallModelHint": "Used for session titles. Compaction uses the session model.",
|
||||
"settings.currency": "Currency",
|
||||
"settings.autoCompact": "Auto-compact at",
|
||||
"settings.autoCompactHint": "Fraction of the context window (0 = off).",
|
||||
"settings.craft": "Craft mode",
|
||||
"settings.cleanRoom": "Clean room",
|
||||
"settings.cleanRoomHint": "Only use what a visitor sees. Off = deep mode (page source and scripts may be read).",
|
||||
"settings.chromium": "Chromium binary",
|
||||
"settings.widths": "Screen widths (px)",
|
||||
"settings.kind": "Type",
|
||||
"settings.baseUrl": "Base URL",
|
||||
"settings.apiKey": "API key",
|
||||
"settings.apiKeyStored": "Stored – leave empty to keep",
|
||||
"settings.apiKeyHint": "Prefer ${ENV_VAR} references; literal keys are stored in plain text in the config file.",
|
||||
"settings.authPrefix": "Auth prefix",
|
||||
"settings.enabled": "Enabled",
|
||||
"settings.modelCount": "{n} models",
|
||||
"settings.models": "Models & prices",
|
||||
"settings.addModel": "Model",
|
||||
"settings.addDiscovered": "Add {n} discovered",
|
||||
"settings.pricesHint": "Prices per 1M tokens in the configured currency. Cache prices default to the input price.",
|
||||
"settings.modelsEmptyHint": "No models listed: they are discovered automatically from the provider.",
|
||||
"settings.context": "Context",
|
||||
"settings.maxOutput": "Max output",
|
||||
"settings.reasoning": "Reasoning",
|
||||
"settings.vision": "Vision",
|
||||
"settings.effort": "Effort",
|
||||
"settings.priceIn": "Input",
|
||||
"settings.priceOut": "Output",
|
||||
"settings.priceCacheRead": "Cache read",
|
||||
"settings.priceCacheWrite": "Cache write",
|
||||
"settings.addProvider": "Add provider",
|
||||
"settings.providerName": "Name (optional)",
|
||||
"settings.reloadProviders": "Reload providers & models",
|
||||
"settings.deleteProviderConfirm": "Delete provider \"{name}\"?",
|
||||
"settings.invalidName": "Names may only contain letters, digits, - and _.",
|
||||
"settings.name": "Name",
|
||||
"settings.addMcp": "Add MCP server",
|
||||
"settings.deleteMcpConfirm": "Delete MCP server \"{name}\"?",
|
||||
"settings.rawHint": "The complete configuration file including comments. It is validated before saving.",
|
||||
"settings.revert": "Revert",
|
||||
|
||||
"mcp.reconnect": "Reconnect all",
|
||||
"mcp.none": "No MCP servers configured.",
|
||||
"mcp.tools": "{n} tools",
|
||||
"mcp.command": "Command",
|
||||
"mcp.args": "Arguments (one per line)",
|
||||
"mcp.env": "Environment (KEY=value)",
|
||||
"mcp.headers": "Headers (Name: value)",
|
||||
"mcp.state.disabled": "disabled",
|
||||
"mcp.state.connecting": "connecting…",
|
||||
"mcp.state.connected": "connected",
|
||||
"mcp.state.failed": "failed",
|
||||
"mcp.state.unknown": "not started",
|
||||
|
||||
"permissions.intro": "Rules for tool use. \"Ask\" shows an approval card. MCP tools: mcp__<server>__<tool>, wildcards allowed (mcp__*).",
|
||||
"permissions.tools": "Tools",
|
||||
"permissions.bash": "Shell commands",
|
||||
"permissions.bashHint": "Glob patterns on the full command. Compound commands (&&, ;, |) are only allowed if every part is.",
|
||||
"permissions.tool": "Tool",
|
||||
"permissions.pattern": "Pattern",
|
||||
"permissions.rule": "Rule",
|
||||
"permissions.allow": "Allow",
|
||||
"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",
|
||||
"agents.skills": "Skills",
|
||||
|
||||
"stats.range7": "7 days",
|
||||
"stats.range30": "30 days",
|
||||
"stats.range90": "90 days",
|
||||
"stats.range365": "1 year",
|
||||
"stats.rangeAll": "All",
|
||||
"stats.tableView": "Table view",
|
||||
"stats.chartView": "Chart view",
|
||||
"stats.cost": "Cost",
|
||||
"stats.perRequest": "{v} per request",
|
||||
"stats.requests": "Requests",
|
||||
"stats.errors": "{n} failed",
|
||||
"stats.input": "Input",
|
||||
"stats.output": "Output",
|
||||
"stats.cacheRead": "Cache read",
|
||||
"stats.cacheHit": "Cache hit rate",
|
||||
"stats.tokens": "Tokens",
|
||||
"stats.ttft": "Time to first token",
|
||||
"stats.ttftHint": "Average prefill wait",
|
||||
"stats.outputSpeed": "Output speed",
|
||||
"stats.prefillSpeed": "Prefill speed",
|
||||
"stats.costPerDay": "Cost per day by model",
|
||||
"stats.tokensPerDay": "Tokens per day",
|
||||
"stats.heatmap": "Activity by weekday and hour",
|
||||
"stats.byModel": "By model",
|
||||
"stats.byProvider": "By provider",
|
||||
"stats.byAgent": "By mode",
|
||||
"stats.topSessions": "Most expensive sessions",
|
||||
"stats.perDay": "Per day",
|
||||
"stats.day": "Day",
|
||||
"stats.model": "Model",
|
||||
"stats.provider": "Provider",
|
||||
"stats.agent": "Mode",
|
||||
"stats.session": "Session",
|
||||
"stats.total": "Total",
|
||||
"stats.other": "Other",
|
||||
"stats.noData": "No requests in this period.",
|
||||
};
|
||||
|
||||
export type Messages = Record<keyof typeof en, string>;
|
||||
@@ -0,0 +1,104 @@
|
||||
// Minimal i18n: dictionaries per language, `{name}` placeholders, persisted choice.
|
||||
// To add a language: copy en.ts, translate the values, register it in `languages`.
|
||||
// TypeScript enforces that every language defines every key of `en`.
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { en, type Messages } from "./en";
|
||||
import { de } from "./de";
|
||||
|
||||
export type Key = keyof typeof en;
|
||||
|
||||
export const languages: Record<string, { name: string; messages: Messages }> = {
|
||||
en: { name: "English", messages: en },
|
||||
de: { name: "Deutsch", messages: de },
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "codecraft.lang";
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
let current = detect();
|
||||
document.documentElement.lang = current;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getLang() {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function setLang(lang: string) {
|
||||
if (!languages[lang]) return;
|
||||
current = lang;
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
document.documentElement.lang = lang;
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function t(key: Key, vars?: Record<string, string | number>): string {
|
||||
let s: string = languages[current].messages[key] ?? en[key] ?? key;
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Re-render on language change. Returns `t` for convenience. */
|
||||
export function useT() {
|
||||
useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
() => current,
|
||||
);
|
||||
return t;
|
||||
}
|
||||
|
||||
// --- locale-aware formatting -------------------------------------------------
|
||||
|
||||
export function fmtNumber(n: number, digits = 0) {
|
||||
return new Intl.NumberFormat(current, { maximumFractionDigits: digits }).format(n);
|
||||
}
|
||||
|
||||
export function fmtCompact(n: number) {
|
||||
return new Intl.NumberFormat(current, { notation: "compact", maximumFractionDigits: 1 }).format(n);
|
||||
}
|
||||
|
||||
export function fmtCost(n: number, currency: string) {
|
||||
const digits = n !== 0 && Math.abs(n) < 1 ? 4 : 2;
|
||||
try {
|
||||
return new Intl.NumberFormat(current, { style: "currency", currency, maximumFractionDigits: digits }).format(n);
|
||||
} catch {
|
||||
return `${n.toFixed(digits)} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function fmtPercent(n: number) {
|
||||
return new Intl.NumberFormat(current, { style: "percent", maximumFractionDigits: 1 }).format(n);
|
||||
}
|
||||
|
||||
export function fmtDate(ms: number, withTime = true) {
|
||||
return new Intl.DateTimeFormat(current, withTime ? { dateStyle: "medium", timeStyle: "short" } : { dateStyle: "medium" }).format(ms);
|
||||
}
|
||||
|
||||
export function fmtRelative(ms: number) {
|
||||
const diff = (ms - Date.now()) / 1000;
|
||||
const rtf = new Intl.RelativeTimeFormat(current, { numeric: "auto" });
|
||||
const abs = Math.abs(diff);
|
||||
if (abs < 60) return rtf.format(Math.round(diff), "second");
|
||||
if (abs < 3600) return rtf.format(Math.round(diff / 60), "minute");
|
||||
if (abs < 86400) return rtf.format(Math.round(diff / 3600), "hour");
|
||||
return rtf.format(Math.round(diff / 86400), "day");
|
||||
}
|
||||
|
||||
export function weekdayNames(): string[] {
|
||||
// Index 0 = Sunday, matching SQLite's %w.
|
||||
const base = new Date(Date.UTC(2024, 0, 7)); // a Sunday
|
||||
return Array.from({ length: 7 }, (_, i) =>
|
||||
new Intl.DateTimeFormat(current, { weekday: "short", timeZone: "UTC" }).format(new Date(base.getTime() + i * 86400000)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
import "./styles.css";
|
||||
import { App } from "./App";
|
||||
import { applyTheme } from "./theme";
|
||||
|
||||
applyTheme();
|
||||
registerSW({ immediate: true });
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/** Hash routes: #/ , #/s/<id>, #/new, #/settings[/tab], #/stats */
|
||||
export function useRoute(): string[] {
|
||||
const hash = useSyncExternalStore(
|
||||
(l) => {
|
||||
window.addEventListener("hashchange", l);
|
||||
return () => window.removeEventListener("hashchange", l);
|
||||
},
|
||||
() => location.hash,
|
||||
);
|
||||
return hash.replace(/^#\/?/, "").split("/").filter(Boolean);
|
||||
}
|
||||
|
||||
export function navigate(path: string) {
|
||||
location.hash = path.startsWith("/") ? path : `/${path}`;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { get } from "./api";
|
||||
import type {
|
||||
McpServerStatus,
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
ServerEvent,
|
||||
Session,
|
||||
} from "./types";
|
||||
|
||||
/** The assistant message currently being streamed. */
|
||||
export interface Draft {
|
||||
id: string;
|
||||
parts: Part[];
|
||||
}
|
||||
|
||||
export interface SessionError {
|
||||
id: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
connected: boolean;
|
||||
sessions: Session[];
|
||||
/** Includes subagent sessions loaded on demand. */
|
||||
sessionById: Record<string, Session>;
|
||||
messages: Record<string, Message[]>;
|
||||
drafts: Record<string, Draft | undefined>;
|
||||
busy: Record<string, boolean>;
|
||||
progress: Record<string, string>;
|
||||
permissions: PermissionRequest[];
|
||||
questions: QuestionRequest[];
|
||||
errors: Record<string, SessionError[]>;
|
||||
mcp: Record<string, McpServerStatus>;
|
||||
}
|
||||
|
||||
let state: State = {
|
||||
connected: false,
|
||||
sessions: [],
|
||||
sessionById: {},
|
||||
messages: {},
|
||||
drafts: {},
|
||||
busy: {},
|
||||
progress: {},
|
||||
permissions: [],
|
||||
questions: [],
|
||||
errors: {},
|
||||
mcp: {},
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function setState(fn: (s: State) => Partial<State>) {
|
||||
state = { ...state, ...fn(state) };
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function useStore<T>(selector: (s: State) => T): T {
|
||||
return useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
() => selector(state),
|
||||
);
|
||||
}
|
||||
|
||||
function upsertSession(s: State, session: Session): Partial<State> {
|
||||
const sessionById = { ...s.sessionById, [session.id]: session };
|
||||
if (session.parent_id) return { sessionById };
|
||||
const others = s.sessions.filter((x) => x.id !== session.id);
|
||||
const sessions = [session, ...others].sort((a, b) => b.updated_at - a.updated_at);
|
||||
return { sessions, sessionById };
|
||||
}
|
||||
|
||||
let errorSeq = 0;
|
||||
|
||||
function draftFor(s: State, sid: string, mid: string): Draft {
|
||||
const d = s.drafts[sid];
|
||||
return d && d.id === mid ? { ...d, parts: [...d.parts] } : { id: mid, parts: [] };
|
||||
}
|
||||
|
||||
export function applyEvent(ev: ServerEvent) {
|
||||
switch (ev.type) {
|
||||
case "session_updated":
|
||||
setState((s) => upsertSession(s, ev.session));
|
||||
break;
|
||||
case "session_deleted":
|
||||
setState((s) => {
|
||||
const { [ev.session_id]: _, ...rest } = s.sessionById;
|
||||
return { sessions: s.sessions.filter((x) => x.id !== ev.session_id), sessionById: rest };
|
||||
});
|
||||
break;
|
||||
case "session_status":
|
||||
setState((s) => ({
|
||||
busy: { ...s.busy, [ev.session_id]: ev.busy },
|
||||
drafts: ev.busy ? s.drafts : { ...s.drafts, [ev.session_id]: undefined },
|
||||
}));
|
||||
break;
|
||||
case "message_added":
|
||||
setState((s) => {
|
||||
const list = s.messages[ev.session_id];
|
||||
const drafts = s.drafts[ev.session_id]?.id === ev.message.id ? { ...s.drafts, [ev.session_id]: undefined } : s.drafts;
|
||||
if (!list) return { drafts };
|
||||
if (list.some((m) => m.id === ev.message.id)) return { drafts };
|
||||
return { messages: { ...s.messages, [ev.session_id]: [...list, ev.message] }, drafts };
|
||||
});
|
||||
break;
|
||||
case "text_delta":
|
||||
case "reasoning_delta":
|
||||
setState((s) => {
|
||||
const d = draftFor(s, ev.session_id, ev.message_id);
|
||||
const kind = ev.type === "text_delta" ? "text" : "reasoning";
|
||||
const last = d.parts[d.parts.length - 1];
|
||||
if (last && last.type === kind) {
|
||||
d.parts[d.parts.length - 1] = { ...last, text: last.text + ev.text } as Part;
|
||||
} else {
|
||||
d.parts.push({ type: kind, text: ev.text } as Part);
|
||||
}
|
||||
return { drafts: { ...s.drafts, [ev.session_id]: d } };
|
||||
});
|
||||
break;
|
||||
case "tool_started":
|
||||
setState((s) => {
|
||||
const d = draftFor(s, ev.session_id, ev.message_id);
|
||||
d.parts.push({ type: "tool_call", id: ev.call_id, name: ev.name, input: ev.input });
|
||||
return { drafts: { ...s.drafts, [ev.session_id]: d } };
|
||||
});
|
||||
break;
|
||||
case "tool_progress":
|
||||
setState((s) => ({ progress: { ...s.progress, [ev.call_id]: ev.output } }));
|
||||
break;
|
||||
case "permission_request":
|
||||
setState((s) => ({ permissions: [...s.permissions.filter((p) => p.id !== ev.id), ev] }));
|
||||
break;
|
||||
case "permission_resolved":
|
||||
setState((s) => ({ permissions: s.permissions.filter((p) => p.id !== ev.id) }));
|
||||
break;
|
||||
case "question_request":
|
||||
setState((s) => ({ questions: [...s.questions.filter((q) => q.id !== ev.id), ev] }));
|
||||
notify(ev.questions[0]?.question ?? "");
|
||||
break;
|
||||
case "question_resolved":
|
||||
setState((s) => ({ questions: s.questions.filter((q) => q.id !== ev.id) }));
|
||||
break;
|
||||
case "error":
|
||||
setState((s) => ({
|
||||
errors: {
|
||||
...s.errors,
|
||||
[ev.session_id]: [...(s.errors[ev.session_id] ?? []), { id: ++errorSeq, message: ev.message }].slice(-5),
|
||||
},
|
||||
}));
|
||||
break;
|
||||
case "mcp_status":
|
||||
setState(() => ({ mcp: ev.servers }));
|
||||
break;
|
||||
case "resync":
|
||||
void resync();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Notify when the app is in the background and an agent needs input. */
|
||||
function notify(body: string) {
|
||||
if (document.visibilityState === "visible" || !("Notification" in window)) return;
|
||||
if (Notification.permission === "granted") {
|
||||
new Notification("CodeCraft", { body, icon: "/icon-192.png" });
|
||||
}
|
||||
}
|
||||
|
||||
export function dismissError(sessionId: string, id: number) {
|
||||
setState((s) => ({ errors: { ...s.errors, [sessionId]: (s.errors[sessionId] ?? []).filter((e) => e.id !== id) } }));
|
||||
}
|
||||
|
||||
export async function loadSessions() {
|
||||
const list = await get<Session[]>("/sessions");
|
||||
setState((s) => ({
|
||||
sessions: list,
|
||||
sessionById: { ...s.sessionById, ...Object.fromEntries(list.map((x) => [x.id, x])) },
|
||||
busy: { ...s.busy, ...Object.fromEntries(list.map((x) => [x.id, !!x.busy])) },
|
||||
}));
|
||||
}
|
||||
|
||||
export async function loadSession(id: string) {
|
||||
const data = await get<{ session: Session; messages: Message[]; busy: boolean }>(`/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 },
|
||||
}));
|
||||
}
|
||||
|
||||
/** After reconnects: reload everything that could have changed. */
|
||||
export async function resync() {
|
||||
const [pending, mcp] = await Promise.all([get<any[]>("/pending"), get("/mcp")]);
|
||||
setState(() => ({
|
||||
permissions: pending.filter((e) => e.type === "permission_request"),
|
||||
questions: pending.filter((e) => e.type === "question_request"),
|
||||
mcp,
|
||||
}));
|
||||
await loadSessions();
|
||||
const loaded = Object.keys(getState().messages);
|
||||
await Promise.all(loaded.map((id) => loadSession(id).catch(() => undefined)));
|
||||
}
|
||||
+1172
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,15 @@
|
||||
const KEY = "codecraft.theme";
|
||||
|
||||
export type Theme = "system" | "dark" | "light";
|
||||
|
||||
export function getTheme(): Theme {
|
||||
return (localStorage.getItem(KEY) as Theme) ?? "system";
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Mirrors server/src/types.rs
|
||||
|
||||
export interface ImageData {
|
||||
media_type: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export type Part =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; image: ImageData }
|
||||
| { type: "reasoning"; text: string; signature?: string }
|
||||
| { type: "tool_call"; id: string; name: string; input: any }
|
||||
| {
|
||||
type: "tool_result";
|
||||
call_id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
images?: ImageData[];
|
||||
is_error: boolean;
|
||||
meta?: any;
|
||||
}
|
||||
| { type: "summary"; text: string };
|
||||
|
||||
export interface Usage {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
parts: Part[];
|
||||
model?: string;
|
||||
usage?: Usage;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface Todo {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
title: string;
|
||||
cwd: string;
|
||||
model: string;
|
||||
agent: string;
|
||||
permission_mode: "default" | "accept_edits" | "bypass";
|
||||
parent_id?: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
todos: Todo[] | null;
|
||||
last_usage?: Usage | null;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
question: string;
|
||||
header?: string;
|
||||
options: QuestionOption[];
|
||||
multi_select?: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionRequest {
|
||||
type: "permission_request";
|
||||
id: string;
|
||||
session_id: string;
|
||||
root_session_id?: string;
|
||||
tool: string;
|
||||
title: string;
|
||||
input: any;
|
||||
diff?: string;
|
||||
}
|
||||
|
||||
export interface QuestionRequest {
|
||||
type: "question_request";
|
||||
id: string;
|
||||
session_id: string;
|
||||
root_session_id?: string;
|
||||
source: string;
|
||||
questions: Question[];
|
||||
}
|
||||
|
||||
export type ServerEvent =
|
||||
| { type: "session_updated"; session: Session }
|
||||
| { type: "session_deleted"; session_id: string }
|
||||
| { type: "session_status"; session_id: string; busy: boolean }
|
||||
| { type: "message_added"; session_id: string; message: Message }
|
||||
| { type: "text_delta"; session_id: string; message_id: string; text: string }
|
||||
| { type: "reasoning_delta"; session_id: string; message_id: string; text: string }
|
||||
| { type: "tool_started"; session_id: string; message_id: string; call_id: string; name: string; input: any }
|
||||
| { type: "tool_progress"; session_id: string; call_id: string; output: string }
|
||||
| PermissionRequest
|
||||
| { type: "permission_resolved"; id: string; session_id: string }
|
||||
| QuestionRequest
|
||||
| { type: "question_resolved"; id: string; session_id: string }
|
||||
| { type: "error"; session_id: string; message: string }
|
||||
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
|
||||
| { type: "resync" };
|
||||
|
||||
export interface McpServerStatus {
|
||||
status: { state: "disabled" | "connecting" | "connected" | "failed"; tools?: number; error?: string };
|
||||
tools: { name: string; description: string }[];
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
context?: number;
|
||||
reasoning: boolean;
|
||||
vision: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderModels {
|
||||
provider: string;
|
||||
models: ModelInfo[];
|
||||
}
|
||||
|
||||
export interface AgentInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
mode: "primary" | "subagent";
|
||||
read_only: boolean;
|
||||
}
|
||||
|
||||
export interface Aggregate {
|
||||
requests: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
cost: number;
|
||||
errors: number;
|
||||
avg_ttft_ms: number;
|
||||
output_tps: number;
|
||||
prefill_tps: number;
|
||||
cache_hit_rate: number;
|
||||
duration_ms: number;
|
||||
}
|
||||
Reference in new issue
Block a user