Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* API error → display text: the server's message is hardcoded in Chinese
|
||||
* (`{error:{code,message}}`), but the UI language may be English — so for any
|
||||
* error the user might actually hit, we derive localized text from **code**;
|
||||
* the server message is only a fallback for unknown codes.
|
||||
*/
|
||||
import { ApiError } from "../api/client";
|
||||
import { S } from "./strings";
|
||||
|
||||
/**
|
||||
* ctx supplies context needed for localized text (e.g. the model id for a
|
||||
* missing key — the error body only has the code; the model id is filled in
|
||||
* by the caller: the model used by the current session/draft when sending).
|
||||
*/
|
||||
export function apiErrorText(err: unknown, ctx?: { modelId?: string }): string {
|
||||
if (!(err instanceof ApiError)) return S.common.unknownError;
|
||||
switch (err.code) {
|
||||
case "model_credential_missing":
|
||||
return ctx?.modelId ? S.errors.modelCredentialMissing(ctx.modelId) : err.message;
|
||||
case "no_default_model":
|
||||
return S.errors.noDefaultModel;
|
||||
default:
|
||||
return err.message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Parses image attachment lines out of user message text (for rendering in
|
||||
* the chat UI).
|
||||
*
|
||||
* When the session's model doesn't support images, core writes the input
|
||||
* images to the session scratchpad and appends
|
||||
* "[attached image: <path|URL>]" lines to the user text (see core
|
||||
* session-support). At render time, these lines are extracted and turned
|
||||
* back into images: http(s) URLs are referenced directly; local scratchpad
|
||||
* paths are mapped to the `/api/sessions/<sessionId>/scratchpad/<fileName>`
|
||||
* endpoint; unrecognized lines are left displayed as-is in the text (e.g. a
|
||||
* "could not be saved" note, or a path outside this system).
|
||||
*/
|
||||
|
||||
export interface ParsedAttachments {
|
||||
/** Body text with restored attachment lines removed (unrecognized lines are kept). */
|
||||
text: string;
|
||||
/** Restored image URLs (in order of appearance; usable directly as img src). */
|
||||
images: string[];
|
||||
}
|
||||
|
||||
const ATTACHMENT_LINE = /^\[attached image: (.+)\]$/;
|
||||
/** Local scratchpad path → session file endpoint (Windows separators supported). */
|
||||
const SCRATCHPAD_PATH = /[/\\]scratchpad[/\\]([^/\\]+)[/\\]([A-Za-z0-9._-]+)$/;
|
||||
|
||||
/** Resolves a single attachment line's address; returns null if unrecognized (the line is kept in the text). */
|
||||
function resolveAttachment(value: string): string | null {
|
||||
if (/^https?:\/\//i.test(value)) return value;
|
||||
const m = SCRATCHPAD_PATH.exec(value);
|
||||
if (m)
|
||||
return `/api/sessions/${encodeURIComponent(m[1]!)}/scratchpad/${encodeURIComponent(m[2]!)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Splits attachment lines out of user text into "body text + list of image addresses"; returns the input unchanged if there are no attachment lines. */
|
||||
export function splitImageAttachments(text: string): ParsedAttachments {
|
||||
if (!text.includes("[attached image: ")) return { text, images: [] };
|
||||
const kept: string[] = [];
|
||||
const images: string[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const m = ATTACHMENT_LINE.exec(line.trim());
|
||||
const src = m ? resolveAttachment(m[1]!) : null;
|
||||
if (src) images.push(src);
|
||||
else kept.push(line);
|
||||
}
|
||||
// Attachment lines are appended as a block at the end; clean up extra trailing blank lines after removal.
|
||||
return { text: kept.join("\n").replace(/\n+$/, ""), images };
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Category colors (for category-colored charts, e.g. the cost center's
|
||||
* "calls per Agent" pie chart): reuses the fixed five-color sequence from the
|
||||
* trace timeline bars — violet / sky / amber / rose / emerald (light shade
|
||||
* 500, dark shade 400 with reduced saturation) — extending to cyan / fuchsia
|
||||
* for more categories, then cycling.
|
||||
*
|
||||
* Uses Tailwind class names instead of hex: dark mode needs a different shade
|
||||
* step, and only classes can track the html.dark toggle.
|
||||
* (TOKEN_COLORS is the same blue hue family shared across light/dark shades,
|
||||
* so it uses hex — the two serve different purposes and aren't interchangeable.)
|
||||
*/
|
||||
export interface CategoryColor {
|
||||
/** SVG fill (pie chart slice). */
|
||||
fill: string;
|
||||
/** Legend swatch background. */
|
||||
swatch: string;
|
||||
}
|
||||
|
||||
export const CATEGORY_COLORS: readonly CategoryColor[] = [
|
||||
{ fill: "fill-violet-500 dark:fill-violet-400", swatch: "bg-violet-500 dark:bg-violet-400" },
|
||||
{ fill: "fill-sky-500 dark:fill-sky-400", swatch: "bg-sky-500 dark:bg-sky-400" },
|
||||
{ fill: "fill-amber-500 dark:fill-amber-400", swatch: "bg-amber-500 dark:bg-amber-400" },
|
||||
{ fill: "fill-rose-500 dark:fill-rose-400", swatch: "bg-rose-500 dark:bg-rose-400" },
|
||||
{ fill: "fill-emerald-500 dark:fill-emerald-400", swatch: "bg-emerald-500 dark:bg-emerald-400" },
|
||||
{ fill: "fill-cyan-500 dark:fill-cyan-400", swatch: "bg-cyan-500 dark:bg-cyan-400" },
|
||||
{ fill: "fill-fuchsia-500 dark:fill-fuchsia-400", swatch: "bg-fuchsia-500 dark:bg-fuchsia-400" },
|
||||
];
|
||||
|
||||
/** Color for the i-th category (cycles once past the palette length). */
|
||||
export function categoryColor(i: number): CategoryColor {
|
||||
return CATEGORY_COLORS[i % CATEGORY_COLORS.length]!;
|
||||
}
|
||||
|
||||
/** Line series color: text drives stroke/fill="currentColor" for the whole series, swatch is for the legend. */
|
||||
export interface SeriesColor {
|
||||
text: string;
|
||||
swatch: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed color sequence for line series (the eval center splits series by
|
||||
* model): maintained separately from CATEGORY_COLORS — adjacent line colors
|
||||
* must pass color-vision-deficiency (CVD) separation checks and dark shades
|
||||
* must land within the brightness band. The violet / amber / sky / rose
|
||||
* ordering passes the dataviz validator in both modes (dark-mode amber / sky
|
||||
* dropped to the 600 step); sky vs. amber falls below 3:1 contrast on white,
|
||||
* backstopped by legend text and detail tables (identity never relies on
|
||||
* color alone).
|
||||
*/
|
||||
export const SERIES_COLORS: readonly SeriesColor[] = [
|
||||
{ text: "text-violet-500", swatch: "bg-violet-500" },
|
||||
{ text: "text-amber-500 dark:text-amber-600", swatch: "bg-amber-500 dark:bg-amber-600" },
|
||||
{ text: "text-sky-500 dark:text-sky-600", swatch: "bg-sky-500 dark:bg-sky-600" },
|
||||
{ text: "text-rose-500", swatch: "bg-rose-500" },
|
||||
];
|
||||
|
||||
/** Color for the i-th series (cycles once past the end; the legend is always present, so identity never relies on color alone). */
|
||||
export function seriesColor(i: number): SeriesColor {
|
||||
return SERIES_COLORS[i % SERIES_COLORS.length]!;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Context window resolution: falls back to the default of 128000 when a
|
||||
* model has no `context_window` configured (or it resolves to a non-positive
|
||||
* value like `"unknown"`), giving the chat page and Trace page a consistent
|
||||
* upper bound for the context-usage ring.
|
||||
*/
|
||||
|
||||
/** Default upper bound when a model has no configured context window. */
|
||||
export const DEFAULT_CONTEXT_WINDOW = 128000;
|
||||
|
||||
/** Resolves the context window upper bound: uses the value if positive (or a string parseable as positive), otherwise falls back to 128000. */
|
||||
export function resolveContextWindow(x: number | string | undefined | null): number {
|
||||
const n = typeof x === "string" ? Number(x) : x;
|
||||
return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : DEFAULT_CONTEXT_WINDOW;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Whether inline code in a message body looks like a file path (used to
|
||||
* render clickable file-reference chips).
|
||||
* Heuristic, not exhaustive: no whitespace, no URL scheme, ends with a known
|
||||
* extension. Inline code text never contains newlines (mdast-util-to-hast
|
||||
* strips them), while block code text always ends with a newline — so
|
||||
* "reject if it contains whitespace" is itself the correct test for
|
||||
* distinguishing inline code from a code block, not an extra patch.
|
||||
*/
|
||||
const KNOWN_EXTENSIONS = new Set([
|
||||
// Text / code
|
||||
"txt",
|
||||
"md",
|
||||
"json",
|
||||
"js",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"ts",
|
||||
"tsx",
|
||||
"jsx",
|
||||
"py",
|
||||
"sh",
|
||||
"bash",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"css",
|
||||
"html",
|
||||
"htm",
|
||||
"csv",
|
||||
"log",
|
||||
"xml",
|
||||
"ini",
|
||||
"conf",
|
||||
"rs",
|
||||
"go",
|
||||
"java",
|
||||
"c",
|
||||
"h",
|
||||
"cpp",
|
||||
"hpp",
|
||||
"sql",
|
||||
"rb",
|
||||
"php",
|
||||
// Images / documents
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"svg",
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
// Media / archives
|
||||
"zip",
|
||||
"tar",
|
||||
"gz",
|
||||
"mp4",
|
||||
"mp3",
|
||||
"wav",
|
||||
]);
|
||||
|
||||
const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
||||
|
||||
export function isFilePathLike(text: string): boolean {
|
||||
const s = text.trim();
|
||||
if (s.length === 0 || s.length > 200) return false;
|
||||
if (/\s/.test(s)) return false;
|
||||
if (URL_SCHEME_RE.test(s) || s.startsWith("www.")) return false;
|
||||
const m = /\.([A-Za-z0-9]{1,8})$/.exec(s);
|
||||
if (!m) return false;
|
||||
const ext = m[1]!.toLowerCase();
|
||||
if (/^\d+$/.test(ext)) return false; // version numbers like "v1.2", "3.14" don't count as file paths
|
||||
return KNOWN_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
/** Max length for a single files/stat path (matches server-side validation). */
|
||||
const MAX_PATH_LEN = 512;
|
||||
|
||||
/**
|
||||
* Normalize a path mentioned in an assistant message to a path relative to
|
||||
* the Workspace root (used for file-card display and stat lookups). Returns
|
||||
* null (no card rendered) for anything that can't be resolved into the
|
||||
* current Workspace:
|
||||
* - An absolute path is stripped only when prefixed with
|
||||
* `${workspace}${sep}` (assistants commonly report absolute paths); if it
|
||||
* equals the workspace itself or the prefix doesn't match → null. A
|
||||
* Windows deployment's Workspace (core supports win32) uses backslash
|
||||
* paths: the prefix is joined with its own separator, and the stripped
|
||||
* relative segment is normalized to "/" (the browser-side directory
|
||||
* navigation splits on "/"). Conversion only happens on a matched Windows
|
||||
* prefix — backslash is a legal character in POSIX filenames, so no
|
||||
* global replacement is done;
|
||||
* - A path starting with `~` (home directory) can't be resolved → null;
|
||||
* - A relative path is lexically normalized by splitting on "/": drop "."
|
||||
* and empty segments, pop the stack on "..", and return null if popping
|
||||
* an empty stack would escape the Workspace root.
|
||||
*/
|
||||
export function toWorkspaceRelative(path: string, workspace: string | null): string | null {
|
||||
const s = path.trim();
|
||||
if (s.length === 0 || s.length > MAX_PATH_LEN) return null;
|
||||
if (s.startsWith("~")) return null;
|
||||
const ws = workspace !== null && workspace.length > 0 ? workspace : null;
|
||||
const winWs = ws?.includes("\\") ?? false;
|
||||
const sep = winWs ? "\\" : "/";
|
||||
const absolute = s.startsWith("/") || (winWs && (/^[A-Za-z]:/.test(s) || s.startsWith("\\")));
|
||||
let rel = s;
|
||||
if (absolute) {
|
||||
if (ws === null || !s.startsWith(`${ws}${sep}`)) return null;
|
||||
rel = s.slice(ws.length + sep.length);
|
||||
if (sep === "\\") rel = rel.replaceAll("\\", "/");
|
||||
}
|
||||
const stack: string[] = [];
|
||||
for (const seg of rel.split("/")) {
|
||||
if (seg === "" || seg === ".") continue;
|
||||
if (seg === "..") {
|
||||
if (stack.length === 0) return null;
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
stack.push(seg);
|
||||
}
|
||||
return stack.length > 0 ? stack.join("/") : null;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Human-friendly formatting utilities.
|
||||
*
|
||||
* Token and duration abbreviation conventions exactly match the CLI
|
||||
* (packages/cli/src/render.ts): `4k`, `1.2k`, `1.5M`, `820ms`, `5.1s`,
|
||||
* `2m10s`; deltas are explicitly signed (`+1k` / `-1k`). Also provides
|
||||
* display formats for cost (USD), byte counts, and dates, reused by the
|
||||
* stats page and Trace page.
|
||||
*/
|
||||
|
||||
/** Keep one decimal place but drop a trailing `.0` (same convention as the CLI). */
|
||||
function trimZero(v: number): string {
|
||||
const s = v.toFixed(1);
|
||||
return s.endsWith(".0") ? s.slice(0, -2) : s;
|
||||
}
|
||||
|
||||
/** Abbreviate a token count for humans: 1234→1.2k, 1500000→1.5M, <1000 unchanged. */
|
||||
export function humanizeTokens(n: number): string {
|
||||
const abs = Math.abs(n);
|
||||
if (abs < 1000) return `${n}`;
|
||||
if (abs < 1_000_000) return `${trimZero(n / 1000)}k`;
|
||||
return `${trimZero(n / 1_000_000)}M`;
|
||||
}
|
||||
|
||||
/** Convert milliseconds to a human-readable duration: `820ms`, `2.3s`, `1m3s`. */
|
||||
export function humanizeDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
const s = ms / 1000;
|
||||
if (s < 60) return `${trimZero(s)}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}m${Math.round(s % 60)}s`;
|
||||
}
|
||||
|
||||
/** Add an explicit sign to a delta string: non-negative gets `+`, negative already has `-` (negative when context shrinks after compaction). */
|
||||
export function signedDelta(formatted: string): string {
|
||||
return formatted.startsWith("-") ? formatted : `+${formatted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output TPS = output tokens ÷ LLM generation seconds (generation duration is
|
||||
* the `request_begin`–`request_end` wall clock, including tool-argument
|
||||
* generation but excluding tool execution); returns null when `llmMs ≤ 0`
|
||||
* (no timing) to avoid division by zero. The chat page and Trace page share
|
||||
* this same computation so the two TPS values are comparable.
|
||||
*/
|
||||
export function computeTps(outputTokens: number, llmMs: number): number | null {
|
||||
return llmMs > 0 ? outputTokens / (llmMs / 1000) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output TPS display: `< 1000` keeps one decimal place (dropping a trailing
|
||||
* `.0`, e.g. `42.5 tok/s` / `120 tok/s` — TPS is usually in the 20~200 range,
|
||||
* so rounding to an integer would lose precision); `≥ 1000` abbreviates by
|
||||
* k / M magnitude (`37.8k tok/s` / `1.2M tok/s`), matching humanizeTokens so
|
||||
* four-digit-plus values don't blur together. null (no LLM timing) or a
|
||||
* non-finite value → `—`.
|
||||
*/
|
||||
export function formatTps(tps: number | null | undefined): string {
|
||||
if (tps == null || !Number.isFinite(tps)) return "—";
|
||||
const v = Math.abs(tps) < 1000 ? trimZero(tps) : humanizeTokens(tps);
|
||||
return `${v} tok/s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ratio display (rounded to a whole percent): `0.714` → `71%`; null
|
||||
* (denominator is 0, undefined) or a non-finite value → `—`.
|
||||
* Used by the Trace page's **cache hit rate** (cache hits ÷ this turn's
|
||||
* input) and similar cases.
|
||||
*/
|
||||
export function formatPercent(ratio: number | null | undefined): string {
|
||||
if (ratio == null || !Number.isFinite(ratio)) return "—";
|
||||
return `${Math.round(ratio * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cost display (converted to the selected currency; prices are stored in
|
||||
* USD): null/undefined → `—`; 1 USD ≈ 7 CNY; decimal places scale with
|
||||
* magnitude (≥100 rounds to an integer, ≥1 uses two places, otherwise four).
|
||||
*/
|
||||
export function formatMoney(
|
||||
usd: number | null | undefined,
|
||||
currency: "USD" | "CNY" = "USD",
|
||||
): string {
|
||||
if (usd == null) return "—";
|
||||
const symbol = currency === "CNY" ? "¥" : "$";
|
||||
const v = currency === "CNY" ? usd * 7 : usd;
|
||||
if (v === 0) return `${symbol}0`;
|
||||
const abs = Math.abs(v);
|
||||
const digits = abs >= 100 ? 0 : abs >= 1 ? 2 : 4;
|
||||
return `${symbol}${v.toFixed(digits)}`;
|
||||
}
|
||||
|
||||
/** Benchmark total score display: integers unchanged, decimals keep one place (full-score convention is defined per-Benchmark by its scoring rubric). */
|
||||
export function formatScore(n: number): string {
|
||||
return Number.isInteger(n) ? `${n}` : trimZero(n);
|
||||
}
|
||||
|
||||
/** Abbreviate a byte count: `812B`, `3.4KB`, `1.2MB`. */
|
||||
export function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n}B`;
|
||||
if (n < 1024 * 1024) return `${trimZero(n / 1024)}KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${trimZero(n / (1024 * 1024))}MB`;
|
||||
return `${trimZero(n / (1024 * 1024 * 1024))}GB`;
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n < 10 ? `0${n}` : `${n}`;
|
||||
}
|
||||
|
||||
/** ISO timestamp → local `yyyy-MM-dd HH:mm` display; returns the input unchanged if parsing fails. */
|
||||
export function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Millisecond timestamp → human-readable message time: en `Jul 2, 2:58 PM` /
|
||||
* zh `7月2日 14:58`; returns an empty string for an invalid value.
|
||||
*
|
||||
* Follows the UI language instead of hardcoding English: date formatting is
|
||||
* a localization concern, and `Jul 2` would look jarring in a Chinese UI.
|
||||
* Includes month and day (not just `HH:mm:ss`) — when scrolling back through
|
||||
* old messages, "what time" is less useful than "what day, what time".
|
||||
*/
|
||||
export function formatMessageTime(ms: number, locale: "zh" | "en"): string {
|
||||
const d = new Date(ms);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleString(locale === "en" ? "en-US" : "zh-CN", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** Milliseconds at local calendar-day midnight (shared by relative-day calculations). */
|
||||
function startOfDayMs(d: Date): number {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO timestamp → relative days (used by the Agents card's "last modified"):
|
||||
* same day → "今天/today", one day back → "昨天/yesterday", earlier → local
|
||||
* calendar-day difference as "n 天前 / n days ago"; a future time (clock
|
||||
* skew) falls back to the absolute time, and parse failures return the
|
||||
* input unchanged.
|
||||
*/
|
||||
export function formatRelativeDays(iso: string, locale: "zh" | "en"): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const days = Math.round((startOfDayMs(new Date()) - startOfDayMs(d)) / 86_400_000);
|
||||
if (days < 0) return formatDateTime(iso);
|
||||
if (days === 0) return locale === "en" ? "today" : "今天";
|
||||
if (days === 1) return locale === "en" ? "yesterday" : "昨天";
|
||||
return locale === "en" ? `${days} days ago` : `${days} 天前`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO timestamp → semantic update time (skill card metadata): zh "今天更新/
|
||||
* 昨天更新/n 天前更新", en "updated today/yesterday/n days ago"; a future
|
||||
* time (clock skew) falls back to the date itself, and parse failures
|
||||
* return the input unchanged (without the "updated" wording).
|
||||
*/
|
||||
export function formatRelativeDate(iso: string, locale: "zh" | "en"): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const days = Math.round((startOfDayMs(new Date()) - startOfDayMs(d)) / 86_400_000);
|
||||
if (days < 0) return iso;
|
||||
if (days === 0) return locale === "en" ? "updated today" : "今天更新";
|
||||
if (days === 1) return locale === "en" ? "updated yesterday" : "昨天更新";
|
||||
return locale === "en" ? `updated ${days} days ago` : `${days} 天前更新`;
|
||||
}
|
||||
|
||||
/** ISO timestamp → local `HH:mm:ss` (inline display in the Trace timeline); returns the input unchanged if parsing fails. */
|
||||
export function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Session stream controller (connect-first + dedup): a pure
|
||||
* logic module, no React dependency, driven by use-session-stream (so protocol behavior is unit-testable).
|
||||
*
|
||||
* - Phase machine: buffering (history not ready yet, events are held in a
|
||||
* buffer) → live (fed straight to the reducer);
|
||||
* - Load epoch: incremented on every load/rebuild. The replay loop aborts the
|
||||
* current round as soon as it detects an epoch mismatch, handing the
|
||||
* remaining buffer off to the new round — this eliminates the
|
||||
* out-of-order/duplication that "a rebuild re-entering mid-buffer-replay" would otherwise cause;
|
||||
* - Authoritative running state: the server sends the current task_state
|
||||
* snapshot as the first initial event on every subscription; the
|
||||
* in-stream task_state overrides the Session list's snapshot, and history
|
||||
* finalization trusts only the in-stream state;
|
||||
* - resync rebuild: clears the pending-approvals table (the server then
|
||||
* resends still-pending approval_request events on the same connection),
|
||||
* swaps in a new model but injects the shared localDecisions set (an
|
||||
* approval clicked on this end is still labeled "manual" after the rebuild);
|
||||
* - Pending-approvals table key = `origin + " " + toolCallId` (approvalKey);
|
||||
* when a resent approval_request can't find its tool card (sub-session
|
||||
* messages aren't written to the parent Trace, so the card can be missing
|
||||
* after a reload), the toolCall carried by the event (with origin) is fed
|
||||
* to the reducer to rebuild the nested card, making the sub-session's approval visible and decidable.
|
||||
*/
|
||||
import { isEventMessage, isPartialPayload } from "@prismshadow/penguin-core/omnimessage";
|
||||
import type { OmniMessage, ToolCallPayload } from "@prismshadow/penguin-core/omnimessage";
|
||||
import type { ServerEvent, SessionStatus } from "@prismshadow/penguin-server/api";
|
||||
import {
|
||||
approvalKey,
|
||||
buildDedupIndex,
|
||||
createStreamModel,
|
||||
discardFragmentFor,
|
||||
finalizeHistory,
|
||||
findToolCard,
|
||||
isDuplicate,
|
||||
notifyTaskIdle,
|
||||
pushMessage,
|
||||
pushMessages,
|
||||
registerLocalDecision,
|
||||
} from "./stream-model";
|
||||
import type { StreamModel } from "./stream-model";
|
||||
|
||||
/** A single pending approval (keyed by approvalKey(origin, toolCallId)). */
|
||||
export interface PendingApproval {
|
||||
toolCall: OmniMessage<ToolCallPayload>;
|
||||
origin?: string[];
|
||||
}
|
||||
|
||||
type BufferedEvent = { kind: "omni"; msg: OmniMessage } | { kind: "server"; ev: ServerEvent };
|
||||
|
||||
export interface StreamControllerDeps {
|
||||
/** Fetch history messages (GET /api/sessions/:id/messages). */
|
||||
loadMessages: () => Promise<OmniMessage[]>;
|
||||
/** Authoritative running state from the stream (covers both the subscription snapshot and transition events). */
|
||||
onTaskState: (state: SessionStatus) => void;
|
||||
onLoading: (loading: boolean) => void;
|
||||
/** History load failure message (null = clear). */
|
||||
onError: (message: string | null) => void;
|
||||
/** View model content changed (triggers a re-render). */
|
||||
onModelChange: () => void;
|
||||
/** Pending-approvals table changed. */
|
||||
onPendingChange: () => void;
|
||||
/** Auto-generated title pushed by the server (for updating the Session list in place). */
|
||||
onSessionTitle?: (sessionId: string, title: string) => void;
|
||||
/** A new session has been registered (sub-sessions are pushed along the parent session's channel; used to refresh the Session list). */
|
||||
onSessionCreated?: (sessionId: string) => void;
|
||||
/** Local clock (injectable for tests). */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface StreamController {
|
||||
/** The current view model (a resync rebuild swaps in a new object). */
|
||||
readonly model: StreamModel;
|
||||
readonly pendingApprovals: ReadonlyMap<string, PendingApproval>;
|
||||
/** Load history for the first time (called once after connect-first). */
|
||||
load: () => Promise<void>;
|
||||
/** Retry entry point after a history load failure (keeps the buffer, refetches history). */
|
||||
retry: () => Promise<void>;
|
||||
/** SSE OmniMessage entry point. */
|
||||
handleOmni: (msg: OmniMessage) => void;
|
||||
/** SSE server-event entry point. */
|
||||
handleServer: (ev: ServerEvent) => void;
|
||||
/** Register that this end clicked an approval ("manual" label, persists across resync rebuilds). */
|
||||
markLocalDecision: (toolCallId: string) => void;
|
||||
/** Remove a pending approval by its composite key (optimistic update; also removed as a fallback when the event arrives). */
|
||||
resolveApproval: (key: string) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export function createStreamController(deps: StreamControllerDeps): StreamController {
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
/** Approvals decided on this end (persists at the hook level: injected into every generation of the model, never lost across a resync rebuild). */
|
||||
const localDecisions = new Set<string>();
|
||||
const pending = new Map<string, PendingApproval>();
|
||||
|
||||
let model = createStreamModel(localDecisions);
|
||||
let disposed = false;
|
||||
let phase: "buffering" | "live" = "buffering";
|
||||
let buffer: BufferedEvent[] = [];
|
||||
/** The most recent in-stream task_state (null = the snapshot hasn't arrived yet; history finalization trusts only this value). */
|
||||
let streamStatus: SessionStatus | null = null;
|
||||
/** Load epoch: incremented on rebuild/retry; any replay or finalization from an older epoch is discarded. */
|
||||
let epoch = 0;
|
||||
/** Whether the most recent load failed (retry only takes effect after a failure, to avoid mistakenly replaying history). */
|
||||
let failed = false;
|
||||
|
||||
const clearPending = (): void => {
|
||||
if (pending.size === 0) return;
|
||||
pending.clear();
|
||||
deps.onPendingChange();
|
||||
};
|
||||
|
||||
const feedOmni = (msg: OmniMessage, dedup: Set<string> | null): void => {
|
||||
// The SDK has already produced an approval_decision: sync the pending-approvals table (keyed by the origin composite key).
|
||||
if (isEventMessage(msg) && msg.payload.type === "approval_decision") {
|
||||
if (pending.delete(approvalKey(msg.origin, msg.payload.tool_call_id))) {
|
||||
deps.onPendingChange();
|
||||
}
|
||||
}
|
||||
if (dedup && !isPartialPayload(msg.payload) && isDuplicate(dedup, msg)) {
|
||||
// Overlap dedup: when a complete message matches, also discard the corresponding in-flight fragment.
|
||||
discardFragmentFor(model, msg);
|
||||
return;
|
||||
}
|
||||
pushMessage(model, msg, now());
|
||||
};
|
||||
|
||||
const handleServer = (ev: ServerEvent): void => {
|
||||
switch (ev.type) {
|
||||
case "approval_request": {
|
||||
const toolCallId = ev.toolCall.payload.tool_call_id;
|
||||
const entry: PendingApproval = { toolCall: ev.toolCall };
|
||||
if (ev.origin) entry.origin = ev.origin;
|
||||
pending.set(approvalKey(ev.origin, toolCallId), entry);
|
||||
// Resend scenario (reload / mid-stream join): sub-session messages
|
||||
// aren't in the parent Trace, so the tool card can be missing —
|
||||
// feed the event's toolCall (with origin) to the reducer to rebuild the nested card, so the approval button is visible.
|
||||
if (!findToolCard(model, ev.origin, toolCallId)) {
|
||||
const msg: OmniMessage = { ...ev.toolCall };
|
||||
if (ev.origin && ev.origin.length > 0) msg.origin = [...ev.origin];
|
||||
else delete msg.origin;
|
||||
pushMessage(model, msg, now());
|
||||
deps.onModelChange();
|
||||
}
|
||||
deps.onPendingChange();
|
||||
return;
|
||||
}
|
||||
case "task_state": {
|
||||
// The in-stream task_state is the authoritative running state (the
|
||||
// server sends the current snapshot as soon as it subscribes; the list's snapshot is only a first-frame placeholder).
|
||||
streamStatus = ev.state;
|
||||
deps.onTaskState(ev.state);
|
||||
if (ev.state === "idle") {
|
||||
// Task ended (or the snapshot confirms idle): finalize the current Task's stats; pending approvals have already converged server-side.
|
||||
notifyTaskIdle(model, now());
|
||||
clearPending();
|
||||
deps.onModelChange();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "resync_required": {
|
||||
// The buffer has been evicted: refetch history to rebuild the model, then continue consuming the same connection.
|
||||
void rebuild();
|
||||
return;
|
||||
}
|
||||
case "hello":
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const rebuild = async (): Promise<void> => {
|
||||
epoch += 1;
|
||||
phase = "buffering";
|
||||
buffer = [];
|
||||
// Clear the pending-approvals table: an approval decided while
|
||||
// disconnected shouldn't leave a lingering button; the server will
|
||||
// resend still-pending approval_request events on the same connection afterward, naturally rebuilding it.
|
||||
clearPending();
|
||||
// Swap in a new model, injecting the shared localDecisions: approvals decided on this end are still labeled "manual" after the rebuild.
|
||||
model = createStreamModel(localDecisions);
|
||||
await load(epoch);
|
||||
};
|
||||
|
||||
const load = async (currentEpoch: number): Promise<void> => {
|
||||
try {
|
||||
const messages = await deps.loadMessages();
|
||||
if (disposed || currentEpoch !== epoch) return;
|
||||
const target = model;
|
||||
pushMessages(target, messages, now());
|
||||
const dedup = buildDedupIndex(messages, 100);
|
||||
// Replay the buffer (events that arrived while fetching history), with dedup.
|
||||
const replay = buffer;
|
||||
buffer = [];
|
||||
for (let i = 0; i < replay.length; i += 1) {
|
||||
const e = replay[i]!;
|
||||
if (e.kind === "omni") feedOmni(e.msg, dedup);
|
||||
else handleServer(e.ev);
|
||||
if (disposed) return;
|
||||
if (currentEpoch !== epoch) {
|
||||
// A rebuild was triggered mid-replay (e.g. resync_required): this
|
||||
// round is discarded, and the remaining events are handed off to
|
||||
// the new round's buffer — phase is left unchanged and the old buffer is never fed to the new model.
|
||||
buffer.push(...replay.slice(i + 1));
|
||||
return;
|
||||
}
|
||||
}
|
||||
phase = "live";
|
||||
// History finalization trusts only the in-stream authoritative state:
|
||||
// finalize the last Task at the end of history only when idle; if the
|
||||
// snapshot hasn't arrived yet (rare), don't finalize — the
|
||||
// task_state:idle branch will complete the equivalent finalization once it arrives.
|
||||
if (streamStatus === "idle") finalizeHistory(target);
|
||||
failed = false;
|
||||
deps.onError(null);
|
||||
deps.onLoading(false);
|
||||
deps.onModelChange();
|
||||
} catch (e) {
|
||||
if (disposed || currentEpoch !== epoch) return;
|
||||
failed = true;
|
||||
deps.onLoading(false);
|
||||
deps.onError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get model() {
|
||||
return model;
|
||||
},
|
||||
get pendingApprovals(): ReadonlyMap<string, PendingApproval> {
|
||||
return pending;
|
||||
},
|
||||
load: () => {
|
||||
epoch += 1;
|
||||
return load(epoch);
|
||||
},
|
||||
retry: async () => {
|
||||
if (disposed || !failed) return;
|
||||
// On failure the model was never written to and the buffer kept accumulating: keep both, just refetch history and replay again.
|
||||
deps.onError(null);
|
||||
deps.onLoading(true);
|
||||
epoch += 1;
|
||||
await load(epoch);
|
||||
},
|
||||
handleOmni: (msg) => {
|
||||
if (disposed) return;
|
||||
if (phase === "buffering") {
|
||||
buffer.push({ kind: "omni", msg });
|
||||
return;
|
||||
}
|
||||
feedOmni(msg, null);
|
||||
deps.onModelChange();
|
||||
},
|
||||
handleServer: (ev) => {
|
||||
if (disposed) return;
|
||||
// session_title / session_created only affect list display (unrelated
|
||||
// to the view model/history): forwarded immediately at any phase, never buffered.
|
||||
if (ev.type === "session_title") {
|
||||
deps.onSessionTitle?.(ev.sessionId, ev.title);
|
||||
return;
|
||||
}
|
||||
if (ev.type === "session_created") {
|
||||
deps.onSessionCreated?.(ev.sessionId);
|
||||
return;
|
||||
}
|
||||
if (phase === "buffering") {
|
||||
// task_state is reflected immediately in the input area (authoritative
|
||||
// state, doesn't wait for history replay); model side effects like
|
||||
// finalization are still processed in replay order (idempotent on the same value, eventually consistent).
|
||||
if (ev.type === "task_state") {
|
||||
streamStatus = ev.state;
|
||||
deps.onTaskState(ev.state);
|
||||
}
|
||||
buffer.push({ kind: "server", ev });
|
||||
return;
|
||||
}
|
||||
handleServer(ev);
|
||||
},
|
||||
markLocalDecision: (toolCallId) => {
|
||||
registerLocalDecision(model, toolCallId);
|
||||
},
|
||||
resolveApproval: (key) => {
|
||||
if (pending.delete(key)) deps.onPendingChange();
|
||||
},
|
||||
dispose: () => {
|
||||
disposed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Task stats row semantics (aligned line-by-line with the CLI "stats info").
|
||||
*
|
||||
* Each Task shows one stats row on completion, covering context, Token usage, and elapsed time —
|
||||
* all as Session-level cumulative values plus this-Task deltas:
|
||||
* - Context: `request.total` from the most recent (main session) `token_usage`; delta = this
|
||||
* Task's ending value minus the previous Task's ending value. Can be negative (compaction
|
||||
* drop-off / provider stripping historical thinking); not clamped to non-negative;
|
||||
* - Token: main session's latest `session.total` plus each subagent's `request.total`,
|
||||
* Session-level cumulative; delta = sum of `total` across this Task's parent + subagent
|
||||
* Requests (delta and cumulative share the same basis);
|
||||
* - Elapsed: accumulated across Tasks; delta = this Task's elapsed time (= this round's last
|
||||
* non-compaction request_end minus the first message; see stream-model).
|
||||
* Compaction attribution depends on whether it falls **within or after this round's span**
|
||||
* (test: is there still a normal Request in this round after the compaction?):
|
||||
* - Automatic compaction **mid-round** (engine compacts then keeps running with carry-over) →
|
||||
* elapsed time is enclosed by the span, Token / cost count toward this round (see
|
||||
* commitPendingCompaction) — it genuinely is time and cost spent finishing this round's work;
|
||||
* - Compaction **after round end** (wrap-up auto-compaction / manual /compact) → falls after the
|
||||
* round's end, elapsed time and Token both excluded from this round.
|
||||
* Neither case updates the context basis or counts toward TPS (compaction is not a user request).
|
||||
* The session's `session.total` always tracks the provider (including compaction, so nothing
|
||||
* leaks at the session level; total session cost is reconciled separately server-side from the
|
||||
* session total). The compaction banner doesn't display Token.
|
||||
*
|
||||
* The stats row on the chat page reports **this round's usage** (`tokensByBucket` + `outputTps` +
|
||||
* `elapsedDeltaMs` + this round's cost) — all six answer "how much did this round cost";
|
||||
* **current context occupancy** is not duplicated in the stats row, it's shown separately by the
|
||||
* ring below the input box (`contextNow` / context window). Output TPS = this Task's main-session
|
||||
* output tokens ÷ LLM generation seconds (`taskMainOutput / taskLlmMs`, LLM duration taken from
|
||||
* paired request event wall-clock times; see stream-model).
|
||||
*
|
||||
* Pure logic module, no React dependency; driven by stream-model.ts.
|
||||
*/
|
||||
import type { TokenUsagePayload } from "@prismshadow/penguin-core/omnimessage";
|
||||
import { computeTps, formatTps, humanizeTokens } from "../format";
|
||||
|
||||
/** Token three buckets (cached input / uncached input / output). */
|
||||
export interface TokenBucketCounts {
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
output: number;
|
||||
}
|
||||
|
||||
/** Stats tracker: Session-level cumulative counts that persist across Tasks + this-Task delta counts. */
|
||||
export interface TaskStatsTracker {
|
||||
/** Current context occupancy = total from the most recent main-session request (not updated by compaction requests). */
|
||||
contextNow: number;
|
||||
/**
|
||||
* Context occupancy is stale: after a successful compaction the old value is invalid, and the
|
||||
* new value isn't known until the next normal Request's token_usage arrives. The live ring
|
||||
* draws an empty ring based on this flag (occupancy unknown) instead of falling back to the
|
||||
* pre-compaction value. Cleared as soon as the next normal Request reports in.
|
||||
*/
|
||||
contextStale: boolean;
|
||||
/** Context at the last stats row produced (delta baseline). */
|
||||
contextAtLastStats: number;
|
||||
/** session.total from the main session's most recent token_usage. */
|
||||
sessionTotal: number;
|
||||
/** Session-level cumulative of subagent request.total (persists across Tasks). */
|
||||
subagentTotal: number;
|
||||
/** This Task's cumulative parent + subagent Request total (the stats row's Token delta). */
|
||||
taskTokens: number;
|
||||
/** This Task's cumulative parent + subagent Request bucketed usage (for real-time cost calc of this round). */
|
||||
taskCacheRead: number;
|
||||
taskCacheWrite: number;
|
||||
taskOutput: number;
|
||||
/** This Task's main-session Request output-token cumulative (main session only, for output TPS; consistent with the Trace page's per-round basis). */
|
||||
taskMainOutput: number;
|
||||
/** This Task's main-session LLM generation duration cumulative (ms, request_begin↔request_end wall clock; compaction requests excluded). */
|
||||
taskLlmMs: number;
|
||||
/**
|
||||
* **Pending** bucket for compaction request usage: when compaction arrives, it's not yet known
|
||||
* whether it's **mid-round** (a normal Request still follows in this round → usage belongs to
|
||||
* this round) or **after round end** (wrap-up compaction / manual /compact → usage doesn't
|
||||
* belong to this round). Held here until the next non-compaction request_end arrives (proving
|
||||
* mid-round), at which point commitPendingCompaction settles it into this round; otherwise
|
||||
* discarded by resetTaskCounters when the Task closes. Counts only toward Token / cost, not
|
||||
* toward the context basis or TPS (consistent with compaction being its own round).
|
||||
*/
|
||||
pendingCompactionTokens: number;
|
||||
pendingCompactionCacheRead: number;
|
||||
pendingCompactionCacheWrite: number;
|
||||
pendingCompactionOutput: number;
|
||||
/** Whether token_usage occurred during this Task (if not, no stats row is produced). */
|
||||
hasUsage: boolean;
|
||||
/** Session total elapsed time (sum of each Task's elapsed time, ms). */
|
||||
sessionElapsedMs: number;
|
||||
/** Compaction in progress (between compaction_begin and end). */
|
||||
compactionActive: boolean;
|
||||
}
|
||||
|
||||
export function createTaskStatsTracker(): TaskStatsTracker {
|
||||
return {
|
||||
contextNow: 0,
|
||||
contextStale: false,
|
||||
contextAtLastStats: 0,
|
||||
sessionTotal: 0,
|
||||
subagentTotal: 0,
|
||||
taskTokens: 0,
|
||||
taskCacheRead: 0,
|
||||
taskCacheWrite: 0,
|
||||
taskOutput: 0,
|
||||
taskMainOutput: 0,
|
||||
taskLlmMs: 0,
|
||||
pendingCompactionTokens: 0,
|
||||
pendingCompactionCacheRead: 0,
|
||||
pendingCompactionCacheWrite: 0,
|
||||
pendingCompactionOutput: 0,
|
||||
hasUsage: false,
|
||||
sessionElapsedMs: 0,
|
||||
compactionActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** This Task's bucketed accumulation (shared by main + subagent sessions; for real-time cost calc). */
|
||||
function addBuckets(t: TaskStatsTracker, p: TokenUsagePayload): void {
|
||||
t.taskCacheRead += p.request.cache_read;
|
||||
t.taskCacheWrite += p.request.cache_write;
|
||||
t.taskOutput += p.request.output;
|
||||
}
|
||||
|
||||
/** Main-session token_usage: normal requests count toward this Task; compaction requests are held pending (attribution undetermined, see pendingCompactionTokens). */
|
||||
export function trackMainUsage(t: TaskStatsTracker, p: TokenUsagePayload): void {
|
||||
// The session cumulative always tracks the provider's session.total (including compaction, so
|
||||
// nothing is missed at the session level; total session cost is reconciled separately
|
||||
// server-side from the session total, unaffected by this).
|
||||
t.sessionTotal = p.session.total;
|
||||
if (t.compactionActive) {
|
||||
// Compaction usage is held pending: whether it belongs to this round depends on whether a
|
||||
// normal Request follows in this round (mid-round → belongs; after round end → doesn't).
|
||||
// Doesn't update the context basis (post-compaction occupancy comes from the next normal
|
||||
// request) or count toward TPS (compaction is not a user request).
|
||||
t.pendingCompactionTokens += p.request.total;
|
||||
t.pendingCompactionCacheRead += p.request.cache_read;
|
||||
t.pendingCompactionCacheWrite += p.request.cache_write;
|
||||
t.pendingCompactionOutput += p.request.output;
|
||||
return;
|
||||
}
|
||||
t.taskTokens += p.request.total;
|
||||
addBuckets(t, p);
|
||||
t.contextNow = p.request.total; // Current context occupancy = total from the most recent normal request
|
||||
t.contextStale = false; // New occupancy measured, ring resumes showing a real value
|
||||
t.taskMainOutput += p.request.output; // Output TPS numerator (normal requests only)
|
||||
t.hasUsage = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles pending compaction usage: called by stream-model at a **non-compaction** request_end —
|
||||
* reaching this point means the preceding compaction is followed by a normal Request in this
|
||||
* round (compaction **mid-round**), so its Token / cost belongs to this round. Counted into the
|
||||
* Token and cost buckets, but **not** into TPS (taskMainOutput — compaction output is not
|
||||
* user-requested generation) or the context basis.
|
||||
*/
|
||||
export function commitPendingCompaction(t: TaskStatsTracker): void {
|
||||
if (t.pendingCompactionTokens === 0) return;
|
||||
t.taskTokens += t.pendingCompactionTokens;
|
||||
t.taskCacheRead += t.pendingCompactionCacheRead;
|
||||
t.taskCacheWrite += t.pendingCompactionCacheWrite;
|
||||
t.taskOutput += t.pendingCompactionOutput;
|
||||
t.hasUsage = true;
|
||||
clearPendingCompaction(t);
|
||||
}
|
||||
|
||||
/** Discards pending compaction usage (compaction **after round end**: no further Request in this round follows, so it doesn't belong to this round). */
|
||||
function clearPendingCompaction(t: TaskStatsTracker): void {
|
||||
t.pendingCompactionTokens = 0;
|
||||
t.pendingCompactionCacheRead = 0;
|
||||
t.pendingCompactionCacheWrite = 0;
|
||||
t.pendingCompactionOutput = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one main-session LLM Request's wall-clock duration (request_begin↔request_end outer time
|
||||
* difference) to this Task's LLM duration (for output TPS); called by stream-model when pairing
|
||||
* request events, compaction requests excluded.
|
||||
*/
|
||||
export function addLlmDuration(t: TaskStatsTracker, ms: number): void {
|
||||
if (ms > 0) t.taskLlmMs += ms;
|
||||
}
|
||||
|
||||
/** Subagent (with origin) token_usage: request delta counts toward this Task and the Session-level subagent cumulative. */
|
||||
export function trackSubagentUsage(t: TaskStatsTracker, p: TokenUsagePayload): void {
|
||||
t.taskTokens += p.request.total;
|
||||
addBuckets(t, p);
|
||||
t.subagentTotal += p.request.total;
|
||||
t.hasUsage = true;
|
||||
}
|
||||
|
||||
/** Enter compaction interval (see module comment). */
|
||||
export function beginCompaction(t: TaskStatsTracker): void {
|
||||
t.compactionActive = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* End the compaction interval.
|
||||
*
|
||||
* On **successful** compaction, marks context occupancy stale (`contextStale`): the old context
|
||||
* has been replaced by a summary, so the pre-compaction number no longer holds, and the new
|
||||
* occupancy isn't known until the next normal Request's `token_usage` (the provider-reported
|
||||
* prompt size) arrives. Meanwhile the live ring draws an empty ring for "unknown" — it must not
|
||||
* still show "almost full" right after the user runs `/compact`, since after a manual compaction
|
||||
* the user specifically wants to see whether space was freed. On non-completed status
|
||||
* (abandoned/interrupted), the original context is retained (see core's
|
||||
* CompactionEndPayload.status) and the flag is not set.
|
||||
*
|
||||
* Note this only sets the flag, it does not clear `contextNow`: the latter also serves as
|
||||
* TaskStats' per-round history (`context` and the negative `contextDelta` for "compaction
|
||||
* drop-off"), and zeroing it would erase that record too. The two consumers need two different
|
||||
* views.
|
||||
*/
|
||||
export function endCompaction(t: TaskStatsTracker, status?: string): void {
|
||||
t.compactionActive = false;
|
||||
if (status === "completed") t.contextStale = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets this-Task counters when a new Task starts: usage outside Task boundaries (e.g. manual
|
||||
* compaction) must not be mistakenly counted into the next Task's delta (corresponds to the
|
||||
* CLI's endCompact semantics).
|
||||
*/
|
||||
export function resetTaskCounters(t: TaskStatsTracker): void {
|
||||
t.taskTokens = 0;
|
||||
t.taskCacheRead = 0;
|
||||
t.taskCacheWrite = 0;
|
||||
t.taskOutput = 0;
|
||||
t.taskMainOutput = 0;
|
||||
t.taskLlmMs = 0;
|
||||
// Unsettled pending compaction usage is discarded here: the round has ended and no further
|
||||
// Request follows in this round → compaction after round end, doesn't belong to this round.
|
||||
clearPendingCompaction(t);
|
||||
t.hasUsage = false;
|
||||
t.compactionActive = false;
|
||||
}
|
||||
|
||||
/** Structured data for one stats row (rendered by the view layer via formatTaskStats). */
|
||||
export interface TaskStats {
|
||||
context: number;
|
||||
contextDelta: number;
|
||||
tokens: number;
|
||||
tokensDelta: number;
|
||||
elapsedMs: number;
|
||||
elapsedDeltaMs: number;
|
||||
/** This Task's bucketed usage (the stats row's input/cached/output, also the basis for this round's cost calc; 0 when there's no token_usage). */
|
||||
tokensByBucket: TokenBucketCounts;
|
||||
/** This Task's output TPS = main-session output tokens ÷ LLM generation seconds; null when there's no LLM timing. */
|
||||
outputTps: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task end: settles Session elapsed time and produces a stats snapshot; returns null if no
|
||||
* token_usage occurred during this Task (no stats row shown, but elapsed time still accumulates).
|
||||
* Resets this-Task counters and advances the context delta baseline after producing the snapshot.
|
||||
*/
|
||||
export function endTask(t: TaskStatsTracker, elapsedMs: number): TaskStats | null {
|
||||
t.sessionElapsedMs += elapsedMs;
|
||||
// Unsettled pending compaction usage is discarded at close: the round's end is fixed and no
|
||||
// further Request follows in this round → compaction after round end, doesn't belong to this round.
|
||||
clearPendingCompaction(t);
|
||||
if (!t.hasUsage) {
|
||||
t.taskTokens = 0;
|
||||
return null;
|
||||
}
|
||||
const stats: TaskStats = {
|
||||
context: t.contextNow,
|
||||
contextDelta: t.contextNow - t.contextAtLastStats,
|
||||
tokens: t.sessionTotal + t.subagentTotal,
|
||||
tokensDelta: t.taskTokens,
|
||||
elapsedMs: t.sessionElapsedMs,
|
||||
elapsedDeltaMs: elapsedMs,
|
||||
tokensByBucket: {
|
||||
cacheRead: t.taskCacheRead,
|
||||
cacheWrite: t.taskCacheWrite,
|
||||
output: t.taskOutput,
|
||||
},
|
||||
outputTps: computeTps(t.taskMainOutput, t.taskLlmMs),
|
||||
};
|
||||
t.contextAtLastStats = t.contextNow;
|
||||
t.taskTokens = 0;
|
||||
t.taskCacheRead = 0;
|
||||
t.taskCacheWrite = 0;
|
||||
t.taskOutput = 0;
|
||||
t.taskMainOutput = 0;
|
||||
t.taskLlmMs = 0;
|
||||
t.hasUsage = false;
|
||||
t.compactionActive = false;
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stats row text (copy fallback / used when there's no body text): same basis as the stats row —
|
||||
* this round's usage "input tokens (including cached amount) · output tokens · output TPS", e.g.
|
||||
* `[统计信息] 输入 tokens 4k(已缓存 3k) · 输出 tokens 1.2k · 42.5 tok/s`; label can be overridden
|
||||
* per UI locale.
|
||||
*/
|
||||
export function formatTaskStats(s: TaskStats, label = "统计信息"): string {
|
||||
const b = s.tokensByBucket;
|
||||
return (
|
||||
`[${label}] 输入 tokens ${humanizeTokens(b.cacheRead + b.cacheWrite)}(已缓存 ${humanizeTokens(b.cacheRead)})` +
|
||||
` · 输出 tokens ${humanizeTokens(b.output)}` +
|
||||
` · ${formatTps(s.outputTps)}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Semantic id rules (matches the server's services/ids.ts; the contract
|
||||
* package only exposes the type, this regex mirrors it): starts with a
|
||||
* lowercase letter, contains only lowercase letters, digits, and
|
||||
* underscores, 2-64 characters. The hyphen is a reserved separator — it
|
||||
* only appears at the join point of a non-admin project_id's
|
||||
* `<username>-<suffix>`; usernames never contain a hyphen, so the
|
||||
* namespaces don't overlap.
|
||||
*/
|
||||
export const SEMANTIC_ID_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
|
||||
|
||||
/** Username: same character rules, length tightened to 2-32 (leaves room for the default Project id `<username>-default_project`). */
|
||||
export const USERNAME_PATTERN = /^[a-z][a-z0-9_]{1,31}$/;
|
||||
|
||||
/** Suffix segment of a non-admin project_id (after `<username>-`): lowercase letters, digits, and underscores only. */
|
||||
export const PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
|
||||
|
||||
/** Maximum total length of project_id. */
|
||||
export const PROJECT_ID_MAX_LENGTH = 64;
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Gesture math for the bottom Sheet (pure functions, unit-test covered):
|
||||
* - project: projects release velocity to an inertial endpoint using the
|
||||
* iOS scroll deceleration curve — the snap point is chosen from the
|
||||
* projected point rather than the release point, so a quick flick can
|
||||
* close/expand the sheet;
|
||||
* - rubberband: progressive damping past the boundary, so drag-past-edge
|
||||
* follow amount decreases instead of hard-stopping;
|
||||
* - nearestSnap: picks the closest candidate snap point.
|
||||
*/
|
||||
|
||||
/** Inertial projected displacement (px). velocity is in px/s; decelerationRate 0.998 ≈ typical iOS scroll feel. */
|
||||
export function project(velocity: number, decelerationRate = 0.998): number {
|
||||
return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);
|
||||
}
|
||||
|
||||
/** Overshoot damping: overshoot is the amount past the boundary (≥0), dimension is a reference size (e.g. panel height). Returns the displayed overshoot amount. */
|
||||
export function rubberband(overshoot: number, dimension: number, constant = 0.55): number {
|
||||
return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot));
|
||||
}
|
||||
|
||||
/** Picks the candidate snap point closest to position. */
|
||||
export function nearestSnap(position: number, snaps: readonly number[]): number {
|
||||
let best = snaps[0]!;
|
||||
for (const s of snaps) {
|
||||
if (Math.abs(s - position) < Math.abs(best - position)) best = s;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Lightweight spring animation utility: parameterized by Apple's
|
||||
* damping/response, with no animation library dependency. stepSpring is a
|
||||
* pure integration function (unit-testable); createSpringDriver is an rAF
|
||||
* driver — a gesture can call stop() at any time to take over from the
|
||||
* current rendered value (interruptible), and animateTo accepts an initial
|
||||
* velocity (handing off the release velocity from a drag, so drag and
|
||||
* animation blend seamlessly).
|
||||
*/
|
||||
|
||||
export interface SpringConfig {
|
||||
/** Damping ratio: 1 = critically damped, no overshoot; <1 allows overshoot (only used for gesture-momentum scenarios). */
|
||||
damping: number;
|
||||
/** Response time (seconds): how quickly it approaches the target. A spring has no fixed duration; settle time emerges from the parameters. */
|
||||
response: number;
|
||||
}
|
||||
|
||||
/** Default parameters for programmatic movement (no gesture momentum: critically damped, no overshoot). */
|
||||
export const SPRING_DEFAULT: SpringConfig = { damping: 1, response: 0.35 };
|
||||
/** Parameters for a gesture release (with momentum: allows slight bounce). */
|
||||
export const SPRING_MOMENTUM: SpringConfig = { damping: 0.8, response: 0.3 };
|
||||
|
||||
/**
|
||||
* Semi-implicit Euler single-frame integration. Internally advances in
|
||||
* ≤8ms substeps and clamps the frame interval to 64ms — so a huge dt from
|
||||
* a background tab resuming won't blow up numerically. Returns
|
||||
* [new value, new velocity (px/s)].
|
||||
*/
|
||||
export function stepSpring(
|
||||
value: number,
|
||||
velocity: number,
|
||||
target: number,
|
||||
config: SpringConfig,
|
||||
dtMs: number,
|
||||
): [number, number] {
|
||||
const omega = (2 * Math.PI) / config.response;
|
||||
const stiffness = omega * omega;
|
||||
const dampingCoeff = 2 * config.damping * omega;
|
||||
let x = value;
|
||||
let v = velocity;
|
||||
let remaining = Math.min(dtMs, 64);
|
||||
while (remaining > 0) {
|
||||
const dt = Math.min(remaining, 8) / 1000;
|
||||
const accel = -stiffness * (x - target) - dampingCoeff * v;
|
||||
v += accel * dt;
|
||||
x += v * dt;
|
||||
remaining -= 8;
|
||||
}
|
||||
return [x, v];
|
||||
}
|
||||
|
||||
/** Whether the spring can be considered settled (both position and velocity below the perceptible threshold). */
|
||||
export function isSettled(value: number, velocity: number, target: number): boolean {
|
||||
return Math.abs(value - target) < 0.5 && Math.abs(velocity) < 5;
|
||||
}
|
||||
|
||||
export interface SpringDriver {
|
||||
/** Current rendered value (interrupt/takeover continues from here). */
|
||||
readonly value: number;
|
||||
/** Current velocity (px/s). */
|
||||
readonly velocity: number;
|
||||
/** Animates from the current value to a target; opts.velocity overrides the current velocity (handoff from a gesture release). */
|
||||
animateTo(
|
||||
target: number,
|
||||
config: SpringConfig,
|
||||
opts?: { velocity?: number; onSettle?: () => void },
|
||||
): void;
|
||||
/** Gesture takeover: stops at the current value, cancels the callback, and returns [value, velocity]. */
|
||||
stop(): [number, number];
|
||||
/** Sets the value directly (drag tracking), cancels any in-progress animation, and resets velocity to zero. */
|
||||
set(value: number): void;
|
||||
/** Teardown cleanup. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function createSpringDriver(
|
||||
initial: number,
|
||||
onFrame: (value: number) => void,
|
||||
): SpringDriver {
|
||||
let value = initial;
|
||||
let velocity = 0;
|
||||
let target = initial;
|
||||
let config = SPRING_DEFAULT;
|
||||
let onSettle: (() => void) | undefined;
|
||||
let raf = 0;
|
||||
let lastT = 0;
|
||||
|
||||
const cancel = () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
lastT = 0;
|
||||
onSettle = undefined;
|
||||
};
|
||||
|
||||
const tick = (t: number) => {
|
||||
const dt = lastT ? t - lastT : 16;
|
||||
lastT = t;
|
||||
[value, velocity] = stepSpring(value, velocity, target, config, dt);
|
||||
if (isSettled(value, velocity, target)) {
|
||||
value = target;
|
||||
velocity = 0;
|
||||
raf = 0;
|
||||
lastT = 0;
|
||||
onFrame(value);
|
||||
const cb = onSettle;
|
||||
onSettle = undefined;
|
||||
cb?.();
|
||||
return;
|
||||
}
|
||||
onFrame(value);
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
get velocity() {
|
||||
return velocity;
|
||||
},
|
||||
animateTo(nextTarget, nextConfig, opts) {
|
||||
target = nextTarget;
|
||||
config = nextConfig;
|
||||
if (opts?.velocity !== undefined) velocity = opts.velocity;
|
||||
onSettle = opts?.onSettle;
|
||||
if (isSettled(value, velocity, target)) {
|
||||
value = target;
|
||||
velocity = 0;
|
||||
onFrame(value);
|
||||
const cb = onSettle;
|
||||
onSettle = undefined;
|
||||
cb?.();
|
||||
return;
|
||||
}
|
||||
if (!raf) raf = requestAnimationFrame(tick);
|
||||
},
|
||||
stop() {
|
||||
cancel();
|
||||
return [value, velocity];
|
||||
},
|
||||
set(next) {
|
||||
cancel();
|
||||
value = next;
|
||||
velocity = 0;
|
||||
onFrame(value);
|
||||
},
|
||||
dispose: cancel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Stat icons (24×24 line paths): **shared as a single source** by the chat
|
||||
* page header, the chat page stats row, and the Trace page turn cards.
|
||||
*
|
||||
* Arrows read by "where the tokens go": **up = input** (sent up to the
|
||||
* model), **down = output** (returned by the model). These paths used to be
|
||||
* duplicated across three components, making it easy to flip the direction
|
||||
* in only one place and leave the others wrong — so they're now consolidated
|
||||
* here as a single copy.
|
||||
*/
|
||||
export const STAT_ICONS = {
|
||||
/** Input (arrow rising from the baseline: tokens sent up to the model) */
|
||||
input: "M12 15V5m0 0L8 9m4-4l4 4M4 19h16",
|
||||
/** Output (arrow falling to the baseline: tokens returned by the model) */
|
||||
output: "M12 3v10m0 0l-4-4m4 4l4-4M4 19h16",
|
||||
/** Cache hit (bullseye: the portion of this turn's input that hit cache and didn't need recomputation) */
|
||||
cacheHit:
|
||||
"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zm0-5a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm0-3a1 1 0 1 0 0-2 1 1 0 0 0 0 2z",
|
||||
/** Token total (stacked cylinders / database: session-level total, used only in the chat page header) */
|
||||
tokens:
|
||||
"M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3zm0 0v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3",
|
||||
/** Tool calls (wrench) */
|
||||
toolCalls: "M14.7 6.3a4 4 0 0 0-5 5L4 17v3h3l5.7-5.7a4 4 0 0 0 5-5l-2.5 2.5-2-2 2.5-2.5z",
|
||||
/** Output TPS (speedometer: half ring + needle) */
|
||||
tps: "M5 18a8 8 0 1 1 14 0M12 12l4-3",
|
||||
/** Elapsed time (clock) */
|
||||
elapsed: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zm0-14v5l3 2",
|
||||
/** Cost (dollar sign in a circle) */
|
||||
cost: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zm0-15v12m2.6-9.3c-.5-.8-1.5-1.2-2.6-1.2-1.5 0-2.7.8-2.7 2 0 2.7 5.4 1.3 5.4 4 0 1.2-1.2 2-2.7 2-1.2 0-2.2-.5-2.7-1.4",
|
||||
/** Copy */
|
||||
copy: "M9 9h9v9a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V9zM7 15H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v1",
|
||||
/** Copied (checkmark) */
|
||||
check: "M5 13l4 4L19 7",
|
||||
} as const;
|
||||
@@ -0,0 +1,732 @@
|
||||
/**
|
||||
* English dictionary (constrained by the `Strings` type to the same shape as zh):
|
||||
* locale switching goes through state/locale.tsx.
|
||||
* Keep domain term capitalization consistent with zh — Agent, Workspace, Token, Task, etc.
|
||||
*/
|
||||
import type { Strings } from "./strings";
|
||||
|
||||
export const en: Strings = {
|
||||
appName: "PenguinHarness",
|
||||
|
||||
nav: {
|
||||
chat: "Chat",
|
||||
newChat: "New chat",
|
||||
agents: "Agents",
|
||||
skills: "Skills",
|
||||
models: "Models",
|
||||
usage: "Costs",
|
||||
traces: "Trajectory",
|
||||
benchmark: "Evaluation Center",
|
||||
collapseSidebar: "Collapse sidebar",
|
||||
expandSidebar: "Expand sidebar",
|
||||
collapseGroup: "Collapse",
|
||||
expandGroup: "Expand",
|
||||
},
|
||||
|
||||
settings: {
|
||||
language: "Language",
|
||||
theme: "Theme",
|
||||
themeLight: "Light",
|
||||
themeDark: "Dark",
|
||||
followSystem: "System",
|
||||
langZh: "中文",
|
||||
langEn: "English",
|
||||
fontSize: "Font size",
|
||||
fontSmall: "S",
|
||||
fontMedium: "M",
|
||||
fontLarge: "L",
|
||||
accent: "Accent",
|
||||
accentNames: {
|
||||
neutral: "Neutral",
|
||||
blue: "Blue",
|
||||
green: "Green",
|
||||
violet: "Violet",
|
||||
rose: "Rose",
|
||||
amber: "Amber",
|
||||
} as Record<string, string>,
|
||||
},
|
||||
|
||||
common: {
|
||||
save: "Save",
|
||||
cancel: "Cancel",
|
||||
create: "Create",
|
||||
delete: "Delete",
|
||||
edit: "Edit",
|
||||
settings: "Settings",
|
||||
confirm: "Confirm",
|
||||
close: "Close",
|
||||
loading: "Loading…",
|
||||
loadMore: "Load more",
|
||||
saved: "Saved",
|
||||
saving: "Saving…",
|
||||
none: "(none)",
|
||||
retry: "Retry",
|
||||
unknownError: "Request failed, please try again later",
|
||||
requiredField: "This field is required",
|
||||
copied: "Copied",
|
||||
ownerOnly: "Only the owner can modify this",
|
||||
},
|
||||
|
||||
auth: {
|
||||
loginTitle: "Sign in to PenguinHarness",
|
||||
username: "Username",
|
||||
usernameHint:
|
||||
"2–32 chars: starts with a lowercase letter; lowercase letters, digits and underscores only",
|
||||
password: "Password",
|
||||
passwordHint: "At least 8 characters",
|
||||
showPassword: "Show password",
|
||||
hidePassword: "Hide password",
|
||||
login: "Sign in",
|
||||
logout: "Sign out",
|
||||
admin: "Admin",
|
||||
defaultAdminNote:
|
||||
"First run: sign in as the built-in admin (admin / admin123), then change the password soon",
|
||||
},
|
||||
|
||||
account: {
|
||||
changePassword: "Change password",
|
||||
oldPassword: "Current password",
|
||||
newPassword: "New password",
|
||||
confirmPassword: "Confirm new password",
|
||||
passwordMismatch: "New passwords do not match",
|
||||
initialPasswordBanner: "This account is using its initial password. Please change it soon.",
|
||||
changeNow: "Change now",
|
||||
},
|
||||
|
||||
admin: {
|
||||
users: "Users",
|
||||
userId: "Username",
|
||||
role: "Role",
|
||||
roleAdmin: "Admin",
|
||||
roleUser: "User",
|
||||
createdAt: "Created",
|
||||
actions: "Actions",
|
||||
createUser: "Add user",
|
||||
initialPassword: "Initial password",
|
||||
initialPasswordFlag: "initial password",
|
||||
defaultProjectNote: (id: string): string => `A default Project will be created: ${id}`,
|
||||
resetPassword: "Reset password",
|
||||
resetPasswordTitle: (u: string): string => `Reset password for ${u}`,
|
||||
resetPasswordNote:
|
||||
"All sign-in sessions of this user will be revoked; they must sign in with the new password",
|
||||
deleteUser: "Delete",
|
||||
deleteUserTitle: (u: string): string => `Delete user ${u}`,
|
||||
deleteUserConfirm: (u: string): string =>
|
||||
`This deletes user ${u} and every Project they own (including data directories). This cannot be undone.`,
|
||||
},
|
||||
|
||||
project: {
|
||||
switcher: "Project",
|
||||
create: "New Project",
|
||||
createTitle: "New Project",
|
||||
id: "Project id",
|
||||
idHint:
|
||||
"2–64 chars: starts with a lowercase letter; lowercase letters, digits and underscores only. Cannot be changed later.",
|
||||
idPrefixHint:
|
||||
"The id is prefixed with your username and a hyphen; append lowercase letters, digits or underscores. Cannot be changed later.",
|
||||
name: "Display name (optional, defaults to the Project id)",
|
||||
settings: "Project settings",
|
||||
settingsTitle: "Project settings",
|
||||
members: "Members",
|
||||
addMember: "Add member",
|
||||
memberUsername: "Username",
|
||||
memberRole: "Role",
|
||||
memberActions: "Actions",
|
||||
removeMember: "Remove",
|
||||
owner: "owner",
|
||||
member: "member",
|
||||
deleteProject: "Delete Project",
|
||||
deleteConfirm:
|
||||
"Delete this Project? Its directory will be removed recursively and cannot be recovered.",
|
||||
deleteLastForbidden:
|
||||
"This is the last Project on this account; create another Project before deleting it",
|
||||
deleteDefaultForbidden:
|
||||
"default_project is shared with the CLI and cannot be deleted from the web",
|
||||
noCredentialTitle: "No model credential configured",
|
||||
noCredentialBody:
|
||||
"The default model of this Project has no API key yet. Configure it on the Models page before chatting.",
|
||||
goToModels: "Go to Models",
|
||||
later: "Later",
|
||||
},
|
||||
|
||||
agent: {
|
||||
listTitle: "Agents",
|
||||
create: "Create Agent",
|
||||
createTitle: "Create Agent",
|
||||
id: "Agent id",
|
||||
idHint:
|
||||
"2–64 chars: starts with a lowercase letter; lowercase letters, digits and underscores only. Cannot be changed later.",
|
||||
name: "Name",
|
||||
nameHint: "Leave empty to use the Agent id as the name",
|
||||
description: "Description",
|
||||
activeSessions: "Active Sessions",
|
||||
sessionCount: (n: number): string => `${n} session${n === 1 ? "" : "s"}`,
|
||||
toolCount: (n: number): string => `${n} tool${n === 1 ? "" : "s"}`,
|
||||
vaultKeyCount: (n: number): string => `${n} vault key${n === 1 ? "" : "s"}`,
|
||||
scheduleCount: (n: number): string => `${n} scheduled task${n === 1 ? "" : "s"}`,
|
||||
updatedAt: "Last modified",
|
||||
activity: (days: number): string => `${days}-day session activity`,
|
||||
settings: "Agent settings",
|
||||
backToList: "Back to Agents",
|
||||
tabOverview: "Overview",
|
||||
tabPrompt: "Prompt",
|
||||
tabRuntime: "Runtime",
|
||||
tabTools: "Tools",
|
||||
tabVault: "Vault",
|
||||
tabSchedules: "Schedules",
|
||||
stateDir: "State path",
|
||||
agentsMd: "AGENTS.md",
|
||||
systemPrompt: "system_prompt template",
|
||||
placeholdersTitle: "Available placeholders (click to insert)",
|
||||
insertPlaceholder: "Insert at the system_prompt cursor",
|
||||
/** Order must match the default system prompt (core default-config.ts DEFAULT_SYSTEM_PROMPT). */
|
||||
placeholders: [
|
||||
["{{AGENTS_MD}}", "Injects the AGENTS.md content"],
|
||||
["{{VAULT_KEYS}}", "Injects the vault key-name section (empty when no keys)"],
|
||||
["{{PLATFORM}}", "Runtime platform"],
|
||||
["{{OS_VERSION}}", "Operating system version"],
|
||||
["{{DATE}}", "Current date"],
|
||||
["{{CWD}}", "Absolute Workspace path"],
|
||||
["{{AGENT_ID}}", "Current Agent id"],
|
||||
["{{PROJECT_DIR}}", "Absolute Project directory (Agent State/scratchpad derive from it)"],
|
||||
["{{SESSION_ID}}", "Current Session id"],
|
||||
] as ReadonlyArray<readonly [string, string]>,
|
||||
maxTurns: "max_turns (max turns per Task, -1 = unlimited)",
|
||||
maxTokens: "model.max_tokens",
|
||||
thinkingLevel: "model.thinking_level",
|
||||
thinkingLevelOptions: [
|
||||
["", "Send no override — keep whatever is currently configured."],
|
||||
["none", "Disables extended reasoning; the fastest responses."],
|
||||
["low", "Enables a lower tier of extended reasoning."],
|
||||
[
|
||||
"medium",
|
||||
"Enables a medium tier of extended reasoning (the default tier for a newly created agent).",
|
||||
],
|
||||
["high", "Enables a higher tier of extended reasoning; slower responses."],
|
||||
[
|
||||
"xhigh",
|
||||
"Enables the highest tier of extended reasoning; identical to high on some models.",
|
||||
],
|
||||
] as ReadonlyArray<readonly [string, string]>,
|
||||
timeoutMs: "model.timeoutMs",
|
||||
timeoutMsHint: "Per-request timeout, ms",
|
||||
compaction: "Context compaction",
|
||||
maxContextLength: "max_context_length",
|
||||
maxContextLengthHint: "Context threshold that triggers compaction",
|
||||
maxSessionTurns: "max_session_turns",
|
||||
maxSessionTurnsHint: "Turn threshold that triggers compaction",
|
||||
compactionMode: "mode (compaction strategy)",
|
||||
compactionModeOptions: [
|
||||
["", "Send no override — keep whatever is currently configured."],
|
||||
[
|
||||
"summarize",
|
||||
"Summarizes the old context with the model, then continues from that summary in a fresh window (default).",
|
||||
],
|
||||
[
|
||||
"discard",
|
||||
"Drops the old context without summarizing; the next turn starts fresh in a new window.",
|
||||
],
|
||||
] as ReadonlyArray<readonly [string, string]>,
|
||||
compactionPrompt: "prompt (summarization prompt)",
|
||||
maxTurnsInvalid: "max_turns must be > 0 or -1",
|
||||
timeoutInvalid: "timeoutMs must be > 0 or -1",
|
||||
toolFieldInvalid: (name: string, field: string) =>
|
||||
`${name}: ${field} must be a positive integer or -1`,
|
||||
builtinTools: "Built-in tools",
|
||||
toolName: "Name",
|
||||
toolPermission: "permission",
|
||||
permissionReadLabel: "Read-only",
|
||||
permissionReadDescription: "Only reads. Auto-approved when the approval mode is read-only.",
|
||||
permissionReadWriteLabel: "Read & write",
|
||||
permissionReadWriteDescription:
|
||||
"Can modify things. Needs manual confirmation when the approval mode is read-only.",
|
||||
toolTimeout: "timeoutMs",
|
||||
toolMaxOutput: "maxOutputLength",
|
||||
mcpServers: "MCP Servers (read-only)",
|
||||
defaultValue: "(default)",
|
||||
deleteAgent: "Delete Agent",
|
||||
builtinUndeletable: "Built-in Agents cannot be deleted",
|
||||
deleteConfirm: (name: string): string =>
|
||||
`Delete Agent "${name}"? Its directory (including all Traces) will be removed recursively and cannot be recovered.`,
|
||||
stateVersion: "Agent State version",
|
||||
transferTitle: "Export / import",
|
||||
transferDesc:
|
||||
"Export the current Agent State snapshot (tar.gz); importing overwrites the whole directory and adopts the version inside the package.",
|
||||
exportSnapshot: "Export snapshot",
|
||||
importSnapshot: "Import snapshot",
|
||||
importing: "Importing…",
|
||||
importDone: (v: number): string => `Import finished, Agent State version v${v}`,
|
||||
importConflictTitle: "Version conflict",
|
||||
importConflictBody:
|
||||
"The snapshot's version is not newer than the current one; importing will overwrite the existing Agent State. Continue?",
|
||||
},
|
||||
|
||||
models: {
|
||||
title: "Models",
|
||||
addCustom: "Add custom model",
|
||||
addToGroup: "Add model",
|
||||
editTitle: "Model settings",
|
||||
addTitle: "Add model (OpenAI protocol)",
|
||||
addTitleVendor: "Add model (auto-routed by id)",
|
||||
addProtocolHint:
|
||||
"New models always use the OpenAI Chat Completions protocol (no auto-routing by model id); set the base URL to a compatible endpoint",
|
||||
addAutoRouteHint:
|
||||
"New models in this group are auto-routed by their upstream id to the vendor's official client: leave the base URL empty for the official endpoint, and an empty API key falls back to the resolved client's environment variable",
|
||||
autoRouteNone:
|
||||
"AgentHub cannot auto-route this id: double-check it, or add the model under Custom / a user-defined group with the OpenAI protocol",
|
||||
addGroup: "Add group",
|
||||
addGroupTitle: "Add group",
|
||||
addGroupDesc:
|
||||
"User-defined groups share Custom semantics: models use the OpenAI Chat Completions protocol (base URL required; an empty API key reads OPENAI_API_KEY). Groups live on model entries — the group appears once its first model is saved.",
|
||||
groupNameLabel: "Group name",
|
||||
groupNameHint: "Starts with a lowercase letter / digit; may contain - and _",
|
||||
groupNameInvalid:
|
||||
"Group names may only use lowercase letters, digits, - and _ (starting with a letter or digit), up to 32 characters",
|
||||
groupNameExists: "This name is taken by a built-in group or an existing entry",
|
||||
groupEmptyHint: "No models in this group yet; use “Add model” to create one",
|
||||
searchPlaceholder: "Search models: id / name / provider",
|
||||
noSearchResults: "No matching models",
|
||||
modelCount: (n: number): string => `${n} model${n === 1 ? "" : "s"}`,
|
||||
modelId: "Model ID",
|
||||
modelIdHint: "The upstream API model id, e.g. gpt-5.5",
|
||||
displayName: "Display name",
|
||||
displayNameHint: "Defaults to the model ID",
|
||||
providerGroup: "Group",
|
||||
contextWindow: "Context window",
|
||||
contextWindowUnit: "tokens",
|
||||
contextWindowHint: "Leave empty if unknown",
|
||||
clientTypeLocked: (t: string): string => `Protocol: ${t} (kept as configured; not editable)`,
|
||||
vision: "Supports image input (vision / multimodal)",
|
||||
visionHint:
|
||||
"When unchecked, chat images are passed as file paths and read via describe_image (vision model)",
|
||||
visionBadge: "Vision",
|
||||
visionModelBadge: "Proxy vision",
|
||||
setVisionModel: "Set as proxy vision model",
|
||||
visionModelHint: "Describes images via describe_image for models without vision",
|
||||
priceUnitShort: "/M tok",
|
||||
testConnection: "Test connection",
|
||||
testing: "Testing…",
|
||||
testOk: (ms: number): string => `Connected (${ms} ms)`,
|
||||
testFailed: (msg: string): string => `Failed: ${msg}`,
|
||||
modelIdRenameHint: "Renaming migrates the existing config and API key",
|
||||
priceCacheRead: "Cache read",
|
||||
priceCacheWrite: "Cache write",
|
||||
priceOutput: "Output",
|
||||
pricing: "Pricing",
|
||||
priceUnit: "per 1M tokens",
|
||||
currency: "Currency",
|
||||
currencyUsd: "USD $",
|
||||
currencyCny: "CNY ¥",
|
||||
currencyHint: "Prices convert at 1 USD ≈ 7 CNY; always stored in USD",
|
||||
firstModelDefault: "Set as the default model",
|
||||
credential: "Credential",
|
||||
apiKey: "API key",
|
||||
apiKeyKeepHint: "Leave empty to keep the current key",
|
||||
apiKeyEnvHint: (envKey: string): string => `Leave empty to use the ${envKey} env var`,
|
||||
useEnvVar: (envKey: string): string => `env ${envKey}`,
|
||||
keyConfigured: "Key configured",
|
||||
clearApiKey: "Clear stored API key",
|
||||
baseUrl: "Custom base URL",
|
||||
baseUrlHint: "Leave empty to use the provider default",
|
||||
baseUrlRequired: "A base URL is required",
|
||||
contextWindowDefaultHint: (n: number): string => `Defaults to ${n} if empty`,
|
||||
visionOnHint: "Images are read with read_image and fed to the model directly",
|
||||
visionOffHint:
|
||||
"Images are read with describe_image: described by the proxy vision model, text only; chat uploads are passed as file paths",
|
||||
confirmDeleteTitle: "Delete model",
|
||||
confirmDelete: (name: string): string =>
|
||||
`Delete "${name}"? Its configuration and API key will be removed.`,
|
||||
groupApiKey: "Set API key for group",
|
||||
groupApiKeyTitle: (label: string): string => `Set the API key for ${label}`,
|
||||
groupApiKeyHint: (n: number): string =>
|
||||
`Applies to all ${n} models in this group; leave empty to keep them unchanged.`,
|
||||
getApiKey: "Get API key",
|
||||
getModelIds: "Get model IDs",
|
||||
groupKeyApplied: (n: number): string => `API key set for ${n} models`,
|
||||
providerEnvNotes: {
|
||||
zhipu:
|
||||
"Defaults to the Z.AI global endpoint (api.z.ai); keys from bigmodel.cn need base URL https://open.bigmodel.cn/api/paas/v4",
|
||||
moonshot:
|
||||
"Defaults to the China endpoint (api.moonshot.cn); keys from platform.kimi.com need base URL https://api.moonshot.ai/v1",
|
||||
},
|
||||
confirmVisionModelTitle: "Set as proxy vision model",
|
||||
confirmVisionModel: (name: string): string =>
|
||||
`Make "${name}" the proxy vision model? Models without vision will read images through it via describe_image.`,
|
||||
confirmSaveTitle: "Save model settings",
|
||||
confirmSave: (name: string): string => `Save the changes to "${name}"?`,
|
||||
confirmDefaultTitle: "Set as default model",
|
||||
confirmDefault: (name: string): string =>
|
||||
`Make "${name}" the default model? New sessions will use it by default.`,
|
||||
createdAt: "Created",
|
||||
default: "Default",
|
||||
setDefault: "Set as default model",
|
||||
remove: "Delete model",
|
||||
saveAll: "Save all",
|
||||
readOnlyHint: "Members have read-only access; only owners can change models and credentials",
|
||||
empty: "No models configured yet",
|
||||
noKey: "No key",
|
||||
pendingSave: "(pending save)",
|
||||
modelIdExists: "This model id already exists",
|
||||
pricingAllOrNone: "Fill all three prices",
|
||||
pricingInvalid: "Must be a number",
|
||||
contextWindowInvalid: "Must be a number",
|
||||
},
|
||||
|
||||
vault: {
|
||||
desc: "Environment variables owned by this Agent (stored in agent_state/.vault.toml), injected into the environment of its shell commands (exec_command); key names are shared with the model, values never enter the model context. Subagents use their own vaults and do not inherit this one.",
|
||||
key: "Name",
|
||||
value: "Value",
|
||||
valueMasked: "Value (masked)",
|
||||
add: "Add",
|
||||
addTitle: "Add variable",
|
||||
remove: "Remove",
|
||||
deleteTitle: "Delete variable",
|
||||
deleteConfirm: (key: string): string =>
|
||||
`Delete variable "${key}"? Its value cannot be recovered.`,
|
||||
empty: "No variables configured yet",
|
||||
readOnlyHint: "Members are read-only; only the owner can edit the vault",
|
||||
keyHint: "Letters, digits and underscores; must not start with a digit",
|
||||
keyInvalid: "Invalid name: only letters, digits and underscores, not starting with a digit",
|
||||
valueRequired: "Value must not be empty",
|
||||
},
|
||||
|
||||
schedule: {
|
||||
desc: "Scheduled tasks (agent_state/schedule/*.toml): the prompt is sent to the target Session on schedule; files can also be edited by hand, and changes made here take effect immediately.",
|
||||
readOnlyHint: "Members are read-only; only the owner can modify schedules",
|
||||
colName: "Name",
|
||||
colStatus: "Status",
|
||||
colPeriod: "Period",
|
||||
colTarget: "Target",
|
||||
colFireTimes: "Next / last fired",
|
||||
colQueued: "Queue",
|
||||
statusNames: {
|
||||
active: "Active",
|
||||
disabled: "Disabled",
|
||||
expired: "Expired",
|
||||
done: "Done",
|
||||
missed: "Missed",
|
||||
invalid: "Invalid",
|
||||
} as Record<string, string>,
|
||||
queued: "Queued",
|
||||
once: "One-off",
|
||||
newSession: "New session",
|
||||
invalidFiles: "Files that failed to parse (skipped by the scheduler)",
|
||||
empty: "No scheduled tasks yet",
|
||||
enable: "Enable",
|
||||
disable: "Disable",
|
||||
addTitle: "New scheduled task",
|
||||
editTitle: (name: string): string => `Edit scheduled task "${name}"`,
|
||||
name: "Name",
|
||||
nameHint: "The file name (without .toml); cannot be changed later",
|
||||
prompt: "Prompt",
|
||||
enabled: "Enabled",
|
||||
startAt: "Start at",
|
||||
endAt: "End at (optional)",
|
||||
period: "Period",
|
||||
periodPlaceholder: "30m / 12h / 7d; leave empty for a one-off task",
|
||||
target: "Target",
|
||||
targetNew: "New session each time",
|
||||
targetSession: "Bound Session",
|
||||
sessionId: "Session id",
|
||||
workspace: "Workspace (optional, auto-created when empty)",
|
||||
model: "Model",
|
||||
modelDefault: "Project default",
|
||||
deleteTitle: "Delete scheduled task",
|
||||
deleteConfirm: (name: string): string => `Delete scheduled task "${name}"?`,
|
||||
},
|
||||
|
||||
skills: {
|
||||
pageTitle: "Skill library",
|
||||
pageDesc: "Built-in skill library: browse, quick-start a chat, or install to agents.",
|
||||
quickInvoke: "Quick start",
|
||||
quickInvokeText: (name: string): string => `use the ${name} skill`,
|
||||
manageInstall: "Manage installs",
|
||||
manageInstallTitle: (name: string): string => `Manage installs: ${name}`,
|
||||
install: "Install",
|
||||
installed: "Installed",
|
||||
uninstall: "Uninstall",
|
||||
skillCount: (n: number): string => (n === 1 ? "1 skill" : `${n} skills`),
|
||||
usedByAgents: (n: number): string =>
|
||||
n === 0 ? "not used yet" : n === 1 ? "used by 1 agent" : `used by ${n} agents`,
|
||||
installedToast: (skill: string, agent: string): string => `Installed ${skill} to ${agent}`,
|
||||
uninstalledToast: (skill: string, agent: string): string =>
|
||||
`Uninstalled ${skill} from ${agent}`,
|
||||
},
|
||||
|
||||
chat: {
|
||||
newSessionMenu: "New chat",
|
||||
chooseAgent: "Choose agent",
|
||||
chooseModel: "Choose model",
|
||||
workspaceUseThis: "Use this dir",
|
||||
workspaceUp: "Parent dir",
|
||||
workspaceNoSubdirs: "No subdirectories",
|
||||
workspaceAuto: "Auto temp directory",
|
||||
workspaceClear: "Use auto temp directory instead",
|
||||
workspaceDirInvalid: "Directory does not exist or is inaccessible; reverted",
|
||||
draftSubtitle: "The self-evolving agent that excels at AI development tasks",
|
||||
sessionList: "Sessions",
|
||||
defaultSessionTitle: "New chat",
|
||||
model: "Model",
|
||||
workspace: "Workspace",
|
||||
workspaceHint:
|
||||
"Leave empty for an auto-created temp directory; if set, it must be an existing directory on the server",
|
||||
approvalMode: "Approval mode",
|
||||
approvalModeNames: {
|
||||
"allow-all": "Approve everything",
|
||||
"deny-all": "Deny everything",
|
||||
"read-only": "Approve read-only",
|
||||
"always-ask": "Ask every time",
|
||||
} as Record<string, string>,
|
||||
approvalModes: {
|
||||
"allow-all": "Approve everything (allow-all)",
|
||||
"deny-all": "Deny everything (deny-all)",
|
||||
"read-only": "Approve read-only (read-only)",
|
||||
"always-ask": "Ask every time (always-ask)",
|
||||
} as Record<string, string>,
|
||||
statusIdle: "Idle",
|
||||
statusRunning: "Running",
|
||||
statusCompacting: "Compacting",
|
||||
pendingApprovals: (n: number) => `${n} pending approval${n > 1 ? "s" : ""}`,
|
||||
inputPlaceholder: "Type a message. Enter to send, Shift+Enter for newline, paste images",
|
||||
inputPlaceholderShort: "Type a message…",
|
||||
send: "Send",
|
||||
stop: "Stop",
|
||||
compactingHint: "Compacting context, input is temporarily disabled",
|
||||
compact: "Compact context",
|
||||
approve: "Allow",
|
||||
deny: "Deny",
|
||||
decisionAllow: "Approved",
|
||||
decisionDeny: "Denied",
|
||||
decisionManual: "manual",
|
||||
decisionAuto: "auto",
|
||||
thinking: "Thinking",
|
||||
subagent: "Subagent",
|
||||
subagentRunning: "Running",
|
||||
aborted: (reason?: string) => `[Aborted]${reason ? `: ${reason}` : ""}`,
|
||||
reconnect: (
|
||||
status: "timeout" | "malformed",
|
||||
state: "waiting" | "retried" | "gaveUp",
|
||||
attempt: number,
|
||||
) => {
|
||||
const cause =
|
||||
status === "timeout" ? "Connection timed out" : "Response incomplete or unparseable";
|
||||
const action =
|
||||
state === "gaveUp"
|
||||
? "no further retries"
|
||||
: state === "retried"
|
||||
? `retry #${attempt} sent`
|
||||
: `starting retry #${attempt}…`;
|
||||
return `[Retry] ${cause}; ${action}`;
|
||||
},
|
||||
imageAlt: "Image uploaded by user",
|
||||
toolImageAlt: "Image from tool output",
|
||||
imagesAsPathHint:
|
||||
"This model cannot view images directly: on send, images are saved to the session scratchpad and passed as file paths (viewed via describe_image)",
|
||||
infoPanel: "Session info",
|
||||
sessionStats: "Stats",
|
||||
statTokens: "Total Tokens",
|
||||
statCost: "Cost",
|
||||
statElapsed: "Elapsed",
|
||||
statInput: "Input tokens",
|
||||
statOutput: "Output tokens",
|
||||
statTps: "Output TPS",
|
||||
noSessions: "No Sessions yet",
|
||||
emptyStream: "Send a message to start the conversation",
|
||||
historyLoadFailed: "Failed to load history",
|
||||
sessionHealed: "Session healed with a new id",
|
||||
statsLabel: "Stats",
|
||||
removeImage: "Remove image",
|
||||
openWorkspace: "Open workspace",
|
||||
filesInMessage: (n: number) => `${n} ${n === 1 ? "file" : "files"}`,
|
||||
openPreview: "Click to preview",
|
||||
showMoreFiles: (n: number) => `Show ${n} more ${n === 1 ? "file" : "files"}`,
|
||||
showLess: "Show less",
|
||||
contextUsage: "Context usage",
|
||||
contextUnknown: "Context usage: unknown until the next request reports it",
|
||||
slashHint: "Type / for commands",
|
||||
mentionHint: "@ to handoff to another agent",
|
||||
mentionRemove: "Remove @ target",
|
||||
skillsSelect: "Skills",
|
||||
skillsSearchPlaceholder: "Search skills",
|
||||
skillsNoMatch: "No matching skills",
|
||||
skillsEmptyHint: "No skills installed yet — add some from the skill library",
|
||||
skillsAutoMessage: (names: string[]): string =>
|
||||
names.length === 1 ? `use the ${names[0]} skill` : `use the ${names.join(", ")} skills`,
|
||||
handoffFrom: (agent: string) => `Handed off from ${agent}'s conversation`,
|
||||
handoffBack: (title?: string) =>
|
||||
title ? `Back to the original conversation: ${title}` : "Back to the original conversation",
|
||||
scheduledFrom: (name: string) => `Triggered by scheduled task "${name}"`,
|
||||
/** Source badge on session list rows (user-created sessions have no source). */
|
||||
sourceNames: {
|
||||
schedule: "Scheduled",
|
||||
subagent: "Sub",
|
||||
} as Record<string, string>,
|
||||
commandCompact: "/compact",
|
||||
emptyGreeting: "Start a new conversation",
|
||||
compactionRunning: (mode: string) => `Compaction in progress (${mode})…`,
|
||||
compactionDone: (mode: string) =>
|
||||
mode === "discard"
|
||||
? "[Compaction] done, old context discarded"
|
||||
: "[Compaction] done, switched to the summarized context",
|
||||
compactionFailed: (status: string) =>
|
||||
`[Compaction] ${status === "aborted" ? "aborted" : "failed"}, keeping current context`,
|
||||
unknownTool: "(unknown tool)",
|
||||
toolCall: "Tool call",
|
||||
workGroupTitle: "Reasoning & tools",
|
||||
workRunning: "Running",
|
||||
workDone: "Done",
|
||||
workGroupSteps: (n: number) => `${n} ${n === 1 ? "step" : "steps"}`,
|
||||
approvalWaiting: "awaiting approval",
|
||||
copyCode: "Copy code",
|
||||
copyStats: "Copy stats",
|
||||
copyReply: "Copy reply",
|
||||
copyMessage: "Copy message",
|
||||
copied: "Copied",
|
||||
deleteSession: "Delete chat",
|
||||
renameSession: "Rename chat",
|
||||
renameSessionLabel: "Title",
|
||||
deleteSessionConfirm: (title: string) =>
|
||||
`Delete "${title}"? Its messages and Trace will be removed permanently.`,
|
||||
archiveSession: "Archive",
|
||||
unarchiveSession: "Unarchive",
|
||||
archivedGroup: (n: number) => `Archived (${n})`,
|
||||
skillsBanner: (names: string[]): string =>
|
||||
`Using skill${names.length === 1 ? "" : "s"}: ${names.join(", ")}`,
|
||||
},
|
||||
|
||||
files: {
|
||||
title: "Files",
|
||||
upload: "Upload",
|
||||
download: "Download",
|
||||
refresh: "Refresh",
|
||||
root: "Workspace root",
|
||||
empty: "Empty directory",
|
||||
name: "Name",
|
||||
size: "Size",
|
||||
modified: "Modified",
|
||||
previewUnsupported: "Preview not supported for this type; download instead",
|
||||
uploaded: "Uploaded",
|
||||
loadFailed: "Failed to load",
|
||||
previewTruncated: "File too large; preview truncated, download for the full file",
|
||||
details: "Details",
|
||||
workspacePath: "Workspace path",
|
||||
htmlRendered: "Rendered",
|
||||
htmlSource: "Source",
|
||||
backToList: "Back to list",
|
||||
resizeHandle: "Drag to resize, double-click to reset",
|
||||
},
|
||||
|
||||
usage: {
|
||||
title: "Costs & usage",
|
||||
today: "Today",
|
||||
last7d: "Last 7 days",
|
||||
total: "Total",
|
||||
tokens: "Tokens",
|
||||
cost: "Cost",
|
||||
requests: "Requests",
|
||||
from: "From",
|
||||
to: "To",
|
||||
colCacheRead: "cache_read",
|
||||
colCacheWrite: "cache_write",
|
||||
colOutput: "output",
|
||||
colCost: "Cost",
|
||||
trendTitle: "30-day trend",
|
||||
uncostedNote: "* Includes records without pricing; cost is a partial figure",
|
||||
filterAllAgents: "All Agents",
|
||||
filterAllModels: "All models",
|
||||
chartAgentCalls: "Calls per Agent",
|
||||
chartSuccessRate: "Model success rate",
|
||||
chartTokenTrend: "Daily token trend",
|
||||
chartCostTrend: "Daily cost trend",
|
||||
empty: "No usage records",
|
||||
successAborted: "Aborted (excluded)",
|
||||
errors: "Errors",
|
||||
errorsTotal: "Total",
|
||||
errorsUnexpected: "Unexpected",
|
||||
errorsExpected: "Expected",
|
||||
errorsTopCode: "Most common",
|
||||
errorsColTime: "Time",
|
||||
errorsColCode: "Source · code",
|
||||
errorsColKind: "Type",
|
||||
errorsColMessage: "Message",
|
||||
errorsEmpty: "No errors",
|
||||
},
|
||||
|
||||
traces: {
|
||||
title: "Traces",
|
||||
filterAll: "All",
|
||||
filterModel: "model_msg",
|
||||
filterEvent: "event_msg",
|
||||
analysis: "Trajectory view",
|
||||
timeline: "Execution timeline",
|
||||
laneLLM: "Model",
|
||||
laneTools: "Tools",
|
||||
phaseApproval: "approval",
|
||||
phaseExec: "exec",
|
||||
kindThinking: "thinking",
|
||||
kindModelReply: "model reply",
|
||||
kindToolGen: "tool call gen",
|
||||
legendToolExec: "tool exec",
|
||||
legendApprovalWait: "approval wait",
|
||||
task: (n: number) => `Turn ${n}`,
|
||||
globalSummary: "Overall",
|
||||
tasksLabel: "Turns",
|
||||
messages: "Messages",
|
||||
truncatedNote: (shown: number, total: number) => `Showing first ${shown} / ${total} messages`,
|
||||
zoom: "Zoom",
|
||||
zoomReset: "Double-click to reset zoom",
|
||||
zoomOut: "Zoom out",
|
||||
zoomIn: "Zoom in",
|
||||
linkHint:
|
||||
"Hover a timeline segment or event row to cross-highlight, click a segment to jump to its message; legend highlights its kind; drag the bar below to pan/zoom",
|
||||
filesTitle: "Trace files",
|
||||
selectSession: "Select a Session on the left",
|
||||
toolCalls: "Tool calls",
|
||||
taskInput: "Input tokens this turn",
|
||||
taskOutput: "Output tokens this turn",
|
||||
cacheHit: "Cache hits",
|
||||
hitRate: "Hit rate",
|
||||
toolFailRate: "failure rate",
|
||||
compactions: "compactions",
|
||||
compactionRound: "Compaction",
|
||||
usageTrend: "Token usage trend",
|
||||
selectFile: "Select a Trace file on the left",
|
||||
empty: "No Traces for this Agent",
|
||||
colTime: "Time",
|
||||
colType: "Type",
|
||||
colSummary: "Summary",
|
||||
lines: (n: number) => `${n} lines`,
|
||||
inProgress: "in progress",
|
||||
systemPrompt: "System prompt",
|
||||
toolDefs: (n: number) => `Tool definitions (${n})`,
|
||||
},
|
||||
|
||||
benchmark: {
|
||||
title: "Evaluation Center",
|
||||
selectBenchmark: "Select a Benchmark on the left",
|
||||
emptyAgent: "No Benchmarks for this Agent",
|
||||
caseCount: (n: number): string => `${n} case${n === 1 ? "" : "s"}`,
|
||||
trendTitle: (metric: string): string => `${metric} over time`,
|
||||
evaluations: "Evaluations",
|
||||
noEvaluations: "No evaluations yet",
|
||||
summaryLabel: "Summary",
|
||||
legendUnlabeled: "unlabeled model",
|
||||
colTime: "Time",
|
||||
colVersion: "Version",
|
||||
colModel: "Model",
|
||||
colScore: "Score",
|
||||
colCost: "Cost",
|
||||
colDuration: "Duration",
|
||||
colCase: "Case",
|
||||
colRun: "Run",
|
||||
colSession: "Session",
|
||||
},
|
||||
|
||||
errors: {
|
||||
unauthorized: "Session expired, please sign in again",
|
||||
networkError: "Network error, please check your connection",
|
||||
modelCredentialMissing: (modelId: string) =>
|
||||
`Model ${modelId} has no API key yet — configure it on the Models page first`,
|
||||
noDefaultModel: "This project has no default model yet — add one on the Models page first",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,726 @@
|
||||
/**
|
||||
* UI copy (bilingual): this file holds the Chinese dictionary `zh` and the runtime
|
||||
* active dictionary `S`; the English dictionary lives in strings-en.ts (constrained
|
||||
* to the same shape by the `Strings` type). Locale preference is resolved by
|
||||
* state/locale.tsx, which calls `setActiveStrings` to switch and remounts the whole
|
||||
* tree keyed by locale, so `S.x` reads in components always reflect the current
|
||||
* language (module-level constants do not update on switch — keep reads inside components).
|
||||
* Keep domain terms capitalized in English — Agent, Workspace, Token, Task, etc.
|
||||
*/
|
||||
export const zh = {
|
||||
appName: "PenguinHarness",
|
||||
|
||||
nav: {
|
||||
chat: "对话",
|
||||
newChat: "新对话",
|
||||
agents: "智能体仓库",
|
||||
skills: "技能库",
|
||||
models: "模型仓库",
|
||||
usage: "成本中心",
|
||||
traces: "轨迹观测",
|
||||
benchmark: "评估中心",
|
||||
collapseSidebar: "收起侧栏",
|
||||
expandSidebar: "展开侧栏",
|
||||
collapseGroup: "折叠",
|
||||
expandGroup: "展开",
|
||||
},
|
||||
|
||||
settings: {
|
||||
language: "语言",
|
||||
theme: "主题",
|
||||
themeLight: "浅色",
|
||||
themeDark: "深色",
|
||||
followSystem: "跟随系统",
|
||||
langZh: "中文",
|
||||
langEn: "English",
|
||||
fontSize: "字号",
|
||||
fontSmall: "小",
|
||||
fontMedium: "中",
|
||||
fontLarge: "大",
|
||||
accent: "主题色",
|
||||
accentNames: {
|
||||
neutral: "灰白",
|
||||
blue: "蓝",
|
||||
green: "绿",
|
||||
violet: "紫",
|
||||
rose: "红",
|
||||
amber: "橙",
|
||||
} as Record<string, string>,
|
||||
},
|
||||
|
||||
common: {
|
||||
save: "保存",
|
||||
cancel: "取消",
|
||||
create: "创建",
|
||||
delete: "删除",
|
||||
edit: "编辑",
|
||||
settings: "设置",
|
||||
confirm: "确认",
|
||||
close: "关闭",
|
||||
loading: "加载中…",
|
||||
loadMore: "加载更多",
|
||||
saved: "已保存",
|
||||
saving: "保存中…",
|
||||
none: "(无)",
|
||||
retry: "重试",
|
||||
unknownError: "请求失败,请稍后重试",
|
||||
requiredField: "此项必填",
|
||||
copied: "已复制",
|
||||
ownerOnly: "仅 owner 可修改",
|
||||
},
|
||||
|
||||
auth: {
|
||||
loginTitle: "登录 PenguinHarness",
|
||||
username: "用户名",
|
||||
usernameHint: "2~32 位:小写字母开头,仅小写字母、数字与下划线",
|
||||
password: "密码",
|
||||
passwordHint: "至少 8 个字符",
|
||||
showPassword: "显示密码",
|
||||
hidePassword: "隐藏密码",
|
||||
login: "登录",
|
||||
logout: "登出",
|
||||
admin: "管理员",
|
||||
defaultAdminNote: "首次使用请以内置管理员登录:admin / admin123,登录后请尽快修改密码",
|
||||
},
|
||||
|
||||
account: {
|
||||
changePassword: "修改密码",
|
||||
oldPassword: "当前密码",
|
||||
newPassword: "新密码",
|
||||
confirmPassword: "确认新密码",
|
||||
passwordMismatch: "两次输入的新密码不一致",
|
||||
initialPasswordBanner: "当前账号正在使用初始密码,建议尽快修改",
|
||||
changeNow: "去修改",
|
||||
},
|
||||
|
||||
admin: {
|
||||
users: "用户管理",
|
||||
userId: "用户名",
|
||||
role: "角色",
|
||||
roleAdmin: "管理员",
|
||||
roleUser: "用户",
|
||||
createdAt: "创建时间",
|
||||
actions: "操作",
|
||||
createUser: "新增用户",
|
||||
initialPassword: "初始密码",
|
||||
initialPasswordFlag: "初始密码",
|
||||
defaultProjectNote: (id: string): string => `将自动创建默认 Project:${id}`,
|
||||
resetPassword: "重置密码",
|
||||
resetPasswordTitle: (u: string): string => `重置 ${u} 的密码`,
|
||||
resetPasswordNote: "重置后该用户的登录会话全部失效,需用新密码重新登录",
|
||||
deleteUser: "删除",
|
||||
deleteUserTitle: (u: string): string => `删除用户 ${u}`,
|
||||
deleteUserConfirm: (u: string): string =>
|
||||
`将删除用户 ${u} 及其名下全部 Project(含数据目录),不可恢复。`,
|
||||
},
|
||||
|
||||
project: {
|
||||
switcher: "Project",
|
||||
create: "新建 Project",
|
||||
createTitle: "新建 Project",
|
||||
id: "Project id",
|
||||
idHint: "2~64 位:小写字母开头,仅小写字母、数字与下划线;创建后不可修改",
|
||||
idPrefixHint: "id 固定以「用户名-」为前缀,后接小写字母、数字或下划线;创建后不可修改",
|
||||
name: "显示名(可选,缺省为 Project id)",
|
||||
settings: "Project 设置",
|
||||
settingsTitle: "Project 设置",
|
||||
members: "成员",
|
||||
addMember: "添加成员",
|
||||
memberUsername: "用户名",
|
||||
memberRole: "角色",
|
||||
memberActions: "操作",
|
||||
removeMember: "移除",
|
||||
owner: "owner",
|
||||
member: "member",
|
||||
deleteProject: "删除 Project",
|
||||
deleteConfirm: "确认删除该 Project?项目目录将被递归删除,不可恢复。",
|
||||
deleteDefaultForbidden: "default_project 与 CLI 共用,不允许在 Web 端删除",
|
||||
deleteLastForbidden:
|
||||
"这是当前账号最后一个 Project,删除后将无 Project 可用;请先创建新的 Project",
|
||||
noCredentialTitle: "尚未配置模型 credential",
|
||||
noCredentialBody: "当前 Project 的默认模型尚未配置 API key,发起对话前请先前往模型页配置。",
|
||||
goToModels: "前往模型页",
|
||||
later: "稍后再说",
|
||||
},
|
||||
|
||||
agent: {
|
||||
listTitle: "Agents",
|
||||
create: "创建 Agent",
|
||||
createTitle: "创建 Agent",
|
||||
id: "Agent id",
|
||||
idHint: "2~64 位:小写字母开头,仅小写字母、数字与下划线;创建后不可修改",
|
||||
name: "名称",
|
||||
nameHint: "留空则使用 Agent id 作为名称",
|
||||
description: "描述",
|
||||
activeSessions: "活跃 Session",
|
||||
sessionCount: (n: number): string => `${n} 个 Session`,
|
||||
toolCount: (n: number): string => `${n} 个工具`,
|
||||
vaultKeyCount: (n: number): string => `${n} 个密钥`,
|
||||
scheduleCount: (n: number): string => `${n} 个定时任务`,
|
||||
updatedAt: "最后修改",
|
||||
activity: (days: number): string => `近 ${days} 天 Session 活跃度`,
|
||||
settings: "Agent 设置",
|
||||
backToList: "返回 Agents",
|
||||
tabOverview: "概览",
|
||||
tabPrompt: "Prompt",
|
||||
tabRuntime: "运行参数",
|
||||
tabTools: "工具",
|
||||
tabVault: "密钥保险柜",
|
||||
tabSchedules: "定时任务",
|
||||
stateDir: "State 路径",
|
||||
agentsMd: "AGENTS.md",
|
||||
systemPrompt: "system_prompt 模板",
|
||||
placeholdersTitle: "可用占位符(点击插入)",
|
||||
insertPlaceholder: "插入到 system_prompt 光标处",
|
||||
/** Order must match the default system prompt (core default-config.ts DEFAULT_SYSTEM_PROMPT). */
|
||||
placeholders: [
|
||||
["{{AGENTS_MD}}", "注入 AGENTS.md 内容"],
|
||||
["{{VAULT_KEYS}}", "注入密钥保险柜的键名小节(无键时为空)"],
|
||||
["{{PLATFORM}}", "运行平台"],
|
||||
["{{OS_VERSION}}", "操作系统版本"],
|
||||
["{{DATE}}", "当前日期"],
|
||||
["{{CWD}}", "Workspace 绝对路径"],
|
||||
["{{AGENT_ID}}", "当前 Agent id"],
|
||||
["{{PROJECT_DIR}}", "Project 目录绝对路径(Agent State/scratchpad 由此拼出)"],
|
||||
["{{SESSION_ID}}", "当前 Session id"],
|
||||
] as ReadonlyArray<readonly [string, string]>,
|
||||
maxTurns: "max_turns(单 Task 最大轮次,-1 不限制)",
|
||||
maxTokens: "model.max_tokens",
|
||||
thinkingLevel: "model.thinking_level",
|
||||
thinkingLevelOptions: [
|
||||
["", "不提交覆盖值,沿用当前生效的配置。"],
|
||||
["none", "关闭扩展推理,响应最快。"],
|
||||
["low", "开启较低强度的扩展推理。"],
|
||||
["medium", "开启中等强度的扩展推理(新建 Agent 的缺省档位)。"],
|
||||
["high", "开启较高强度的扩展推理,响应更慢。"],
|
||||
["xhigh", "开启最高强度的扩展推理,部分模型上效果与 high 相同。"],
|
||||
] as ReadonlyArray<readonly [string, string]>,
|
||||
timeoutMs: "model.timeoutMs",
|
||||
timeoutMsHint: "单次 Request 超时,毫秒",
|
||||
compaction: "上下文压缩(compaction)",
|
||||
maxContextLength: "max_context_length",
|
||||
maxContextLengthHint: "触发压缩的上下文阈值",
|
||||
maxSessionTurns: "max_session_turns",
|
||||
maxSessionTurnsHint: "触发压缩的轮数阈值",
|
||||
compactionMode: "mode(压缩方式)",
|
||||
compactionModeOptions: [
|
||||
["", "不提交覆盖值,沿用当前生效的配置。"],
|
||||
["summarize", "先让模型为旧上下文生成摘要,再从摘要续接新的上下文窗口(缺省)。"],
|
||||
["discard", "不生成摘要,直接丢弃旧上下文,下一轮从新窗口重新开始。"],
|
||||
] as ReadonlyArray<readonly [string, string]>,
|
||||
compactionPrompt: "prompt(摘要提示词)",
|
||||
maxTurnsInvalid: "max_turns 必须 > 0 或为 -1",
|
||||
timeoutInvalid: "timeoutMs 必须 > 0 或为 -1",
|
||||
toolFieldInvalid: (name: string, field: string) => `${name}: ${field} 必须是 > 0 的整数或 -1`,
|
||||
builtinTools: "内置工具",
|
||||
toolName: "名称",
|
||||
toolPermission: "permission",
|
||||
permissionReadLabel: "Read-only",
|
||||
permissionReadDescription: "仅读取。审批模式为 read-only 时自动放行,无需确认。",
|
||||
permissionReadWriteLabel: "Read & write",
|
||||
permissionReadWriteDescription: "可修改。审批模式为 read-only 时需人工确认。",
|
||||
toolTimeout: "timeoutMs",
|
||||
toolMaxOutput: "maxOutputLength",
|
||||
mcpServers: "MCP Server(只读)",
|
||||
defaultValue: "(缺省)",
|
||||
deleteAgent: "删除 Agent",
|
||||
builtinUndeletable: "内置 Agent 不可被删除",
|
||||
deleteConfirm: (name: string): string =>
|
||||
`确认删除 Agent「${name}」?其目录(含全部 Trace)将被递归删除,不可恢复。`,
|
||||
stateVersion: "Agent State 版本",
|
||||
transferTitle: "导出 / 导入",
|
||||
transferDesc: "导出当前 Agent State 快照包(tar.gz);导入整目录覆盖,并以包内版本为准。",
|
||||
exportSnapshot: "导出快照",
|
||||
importSnapshot: "导入快照",
|
||||
importing: "导入中…",
|
||||
importDone: (v: number): string => `导入完成,Agent State 版本 v${v}`,
|
||||
importConflictTitle: "版本冲突",
|
||||
importConflictBody: "快照包版本不高于当前版本,导入将覆盖现有 Agent State。确认继续?",
|
||||
},
|
||||
|
||||
models: {
|
||||
title: "模型配置",
|
||||
addCustom: "添加自定义模型",
|
||||
addToGroup: "新增模型",
|
||||
editTitle: "模型配置",
|
||||
addTitle: "新增模型(OpenAI 协议)",
|
||||
addTitleVendor: "新增模型(按 id 自动路由)",
|
||||
addProtocolHint:
|
||||
"新增模型固定走 OpenAI Chat Completions 兼容协议(不按模型 id 自动路由),base URL 填其兼容端点",
|
||||
addAutoRouteHint:
|
||||
"该分组的新模型按上游 id 由 AgentHub 自动路由到厂商官方客户端:base URL 留空即官方端点,API key 留空按解析出的客户端读取环境变量",
|
||||
autoRouteNone:
|
||||
"该 id 无法被 AgentHub 自动路由:请核对 id,或改在 Custom / 自建分组下以 OpenAI 协议接入",
|
||||
addGroup: "新增分组",
|
||||
addGroupTitle: "新增分组",
|
||||
addGroupDesc:
|
||||
"自建分组与 Custom 同语义:组内模型走 OpenAI Chat Completions 兼容协议(base URL 必填,API key 留空读取 OPENAI_API_KEY)。分组由模型条目承载,保存首个模型后即出现。",
|
||||
groupNameLabel: "分组名",
|
||||
groupNameHint: "小写字母 / 数字开头,可含 - 与 _",
|
||||
groupNameInvalid: "分组名只能用小写字母、数字、- 与 _(首字符为字母或数字),长度不超过 32",
|
||||
groupNameExists: "该分组名已被内置分组或既有条目占用",
|
||||
groupEmptyHint: "该分组暂无模型,点「新增模型」添加",
|
||||
searchPlaceholder: "搜索模型:id / 名称 / 厂商",
|
||||
noSearchResults: "没有匹配的模型",
|
||||
modelCount: (n: number): string => `${n} 个模型`,
|
||||
modelId: "模型 ID",
|
||||
modelIdHint: "上游 API 使用的模型 id,如 gpt-5.5",
|
||||
displayName: "模型名称",
|
||||
displayNameHint: "留空则展示模型 ID",
|
||||
providerGroup: "分组",
|
||||
contextWindow: "上下文窗口",
|
||||
contextWindowUnit: "tokens",
|
||||
contextWindowHint: "留空表示未知",
|
||||
clientTypeLocked: (t: string): string => `协议:${t}(沿用原配置,不可修改)`,
|
||||
vision: "支持图片输入(视觉/多模态)",
|
||||
visionHint:
|
||||
"取消勾选后,对话中上传的图片将以文件路径转交,读图经 describe_image 由视觉模型代读",
|
||||
visionBadge: "视觉",
|
||||
visionModelBadge: "视觉代理",
|
||||
setVisionModel: "设为视觉代理模型",
|
||||
visionModelHint: "供不支持图片的模型经 describe_image 代读图片",
|
||||
priceUnitShort: "/M tok",
|
||||
testConnection: "测试连通性",
|
||||
testing: "测试中…",
|
||||
testOk: (ms: number): string => `连通正常(${ms} ms)`,
|
||||
testFailed: (msg: string): string => `连通失败:${msg}`,
|
||||
modelIdRenameHint: "改 id 后原配置与 API key 一并迁移",
|
||||
priceCacheRead: "缓存读取",
|
||||
priceCacheWrite: "缓存写入",
|
||||
priceOutput: "输出",
|
||||
pricing: "价格",
|
||||
priceUnit: "每百万 Token",
|
||||
currency: "币种",
|
||||
currencyUsd: "美元 $",
|
||||
currencyCny: "人民币 ¥",
|
||||
currencyHint: "价格按 1 美元 ≈ 7 人民币折算;存储始终为美元",
|
||||
firstModelDefault: "已自动设为默认模型",
|
||||
credential: "凭据",
|
||||
apiKey: "API key",
|
||||
apiKeyKeepHint: "留空保留现有 key",
|
||||
apiKeyEnvHint: (envKey: string): string => `留空则使用环境变量 ${envKey}`,
|
||||
useEnvVar: (envKey: string): string => `环境变量 ${envKey}`,
|
||||
keyConfigured: "已配置 key",
|
||||
clearApiKey: "清除已存 API key",
|
||||
baseUrl: "自定义 base URL",
|
||||
baseUrlHint: "留空使用厂商默认地址",
|
||||
baseUrlRequired: "必须填写 base URL",
|
||||
contextWindowDefaultHint: (n: number): string => `留空按 ${n} 计`,
|
||||
visionOnHint: "读图用 read_image:图片直接回灌模型",
|
||||
visionOffHint:
|
||||
"读图用 describe_image:图片交给视觉代理模型描述、只回文本;对话中上传的图片以文件路径转交",
|
||||
confirmDeleteTitle: "删除模型",
|
||||
confirmDelete: (name: string): string =>
|
||||
`确定删除「${name}」?该模型的配置与 API key 将一并移除。`,
|
||||
groupApiKey: "统一配置 API key",
|
||||
groupApiKeyTitle: (label: string): string => `为「${label}」统一配置 API key`,
|
||||
groupApiKeyHint: (n: number): string => `将写入该分组下全部 ${n} 个模型;留空不改动。`,
|
||||
getApiKey: "获取 API key",
|
||||
getModelIds: "获取模型 id",
|
||||
groupKeyApplied: (n: number): string => `已为 ${n} 个模型配置 API key`,
|
||||
// Providers with separate domestic / international endpoints: note on the default
|
||||
// endpoint used when left blank via env var (the other side's key needs an explicit
|
||||
// base URL). Written to match AgentHub's actual behavior; rendered wherever the env fallback hint appears.
|
||||
providerEnvNotes: {
|
||||
zhipu:
|
||||
"缺省端点为 Z.AI 国际版(api.z.ai);智谱开放平台(bigmodel.cn)的 key 需填 base URL https://open.bigmodel.cn/api/paas/v4",
|
||||
moonshot:
|
||||
"缺省端点为国内版(api.moonshot.cn);platform.kimi.com(国际)的 key 需填 base URL https://api.moonshot.ai/v1",
|
||||
} as Record<string, string | undefined>,
|
||||
confirmVisionModelTitle: "设为视觉代理模型",
|
||||
confirmVisionModel: (name: string): string =>
|
||||
`确定把「${name}」设为视觉代理模型?不支持图片的模型将由它经 describe_image 代读图片。`,
|
||||
confirmSaveTitle: "保存模型配置",
|
||||
confirmSave: (name: string): string => `确定保存对「${name}」的配置修改?`,
|
||||
confirmDefaultTitle: "设为默认模型",
|
||||
confirmDefault: (name: string): string =>
|
||||
`确定把「${name}」设为默认模型?新建的 Session 将默认使用它。`,
|
||||
createdAt: "创建时间",
|
||||
default: "默认",
|
||||
setDefault: "设为默认模型",
|
||||
remove: "删除模型",
|
||||
saveAll: "保存全部",
|
||||
readOnlyHint: "member 只读;模型与 credential 修改仅 owner 可执行",
|
||||
empty: "尚未配置任何模型",
|
||||
noKey: "未配置 key",
|
||||
pendingSave: "(待保存)",
|
||||
modelIdExists: "该模型 id 已存在",
|
||||
pricingAllOrNone: "三项价格需一并填写",
|
||||
pricingInvalid: "必须为数字",
|
||||
contextWindowInvalid: "必须为数字",
|
||||
},
|
||||
|
||||
vault: {
|
||||
desc: "本 Agent 专属的环境变量(存于 agent_state/.vault.toml):键值对注入其 shell 命令(exec_command)的子进程环境;键名会告知模型,值不进入模型上下文。子 Agent 使用各自的保险柜,不继承。",
|
||||
key: "键名",
|
||||
value: "值",
|
||||
valueMasked: "值(掩码)",
|
||||
add: "添加",
|
||||
addTitle: "添加环境变量",
|
||||
remove: "删除",
|
||||
deleteTitle: "删除环境变量",
|
||||
deleteConfirm: (key: string): string => `确认删除环境变量「${key}」?值不可恢复。`,
|
||||
empty: "尚未配置任何环境变量",
|
||||
readOnlyHint: "member 只读;Vault 修改仅 owner 可执行",
|
||||
keyHint: "字母、数字与下划线,不能以数字开头",
|
||||
keyInvalid: "键名不合法:仅字母、数字与下划线,且不能以数字开头",
|
||||
valueRequired: "值不能为空",
|
||||
},
|
||||
|
||||
schedule: {
|
||||
desc: "定时任务(agent_state/schedule/*.toml):到点自动向目标 Session 发送 prompt;文件亦可手工编辑,Web 端修改后即时生效。",
|
||||
readOnlyHint: "member 只读;定时任务修改仅 owner 可执行",
|
||||
colName: "名称",
|
||||
colStatus: "状态",
|
||||
colPeriod: "周期",
|
||||
colTarget: "目标",
|
||||
colFireTimes: "下次 / 最近触发",
|
||||
colQueued: "排队",
|
||||
statusNames: {
|
||||
active: "生效",
|
||||
disabled: "停用",
|
||||
expired: "已过期",
|
||||
done: "已完成",
|
||||
missed: "已错过",
|
||||
invalid: "无效",
|
||||
} as Record<string, string>,
|
||||
queued: "排队中",
|
||||
once: "一次性",
|
||||
newSession: "新建会话",
|
||||
invalidFiles: "解析失败的文件(已跳过调度)",
|
||||
empty: "尚未配置定时任务",
|
||||
enable: "启用",
|
||||
disable: "停用",
|
||||
addTitle: "新建定时任务",
|
||||
editTitle: (name: string): string => `编辑定时任务「${name}」`,
|
||||
name: "名称",
|
||||
nameHint: "即文件名(不含 .toml),创建后不可改",
|
||||
prompt: "Prompt",
|
||||
enabled: "启用",
|
||||
startAt: "开始时间",
|
||||
endAt: "结束时间(可选)",
|
||||
period: "周期",
|
||||
periodPlaceholder: "30m / 12h / 7d,留空为一次性",
|
||||
target: "目标",
|
||||
targetNew: "每次新建会话",
|
||||
targetSession: "绑定 Session",
|
||||
sessionId: "Session id",
|
||||
workspace: "Workspace(可选,留空自动创建)",
|
||||
model: "Model",
|
||||
modelDefault: "Project 默认",
|
||||
deleteTitle: "删除定时任务",
|
||||
deleteConfirm: (name: string): string => `确认删除定时任务「${name}」?`,
|
||||
},
|
||||
|
||||
skills: {
|
||||
pageTitle: "技能库",
|
||||
pageDesc: "内置 Skill 库:浏览、快捷调用,或安装到 Agent。",
|
||||
quickInvoke: "快捷调用",
|
||||
/** Pre-filled body for quick invoke (per UI language; English is `use the <name> skill`). */
|
||||
quickInvokeText: (name: string): string => `使用 ${name} 技能`,
|
||||
manageInstall: "管理安装",
|
||||
manageInstallTitle: (name: string): string => `管理安装:${name}`,
|
||||
install: "安装",
|
||||
installed: "已安装",
|
||||
uninstall: "卸载",
|
||||
/** Skill count in the group header (small text to the right of the group name). */
|
||||
skillCount: (n: number): string => `${n} 个技能`,
|
||||
/** Usage count in the card metadata (shows "unused" instead of a bare 0). */
|
||||
usedByAgents: (n: number): string => (n === 0 ? "未被使用" : `${n} 个 Agent 在用`),
|
||||
/** Top toast shown on successful install / uninstall. */
|
||||
installedToast: (skill: string, agent: string): string => `已将 ${skill} 安装到 ${agent}`,
|
||||
uninstalledToast: (skill: string, agent: string): string => `已从 ${agent} 卸载 ${skill}`,
|
||||
},
|
||||
|
||||
chat: {
|
||||
newSessionMenu: "新建对话",
|
||||
chooseAgent: "选择 Agent",
|
||||
chooseModel: "选择模型",
|
||||
workspaceUseThis: "使用此目录",
|
||||
workspaceUp: "上级目录",
|
||||
workspaceNoSubdirs: "无子目录",
|
||||
workspaceAuto: "自动临时目录",
|
||||
workspaceClear: "改用自动临时目录",
|
||||
workspaceDirInvalid: "目录不存在或无法访问,已回退",
|
||||
draftSubtitle: "最擅长 AI 开发任务的自进化 Agent",
|
||||
sessionList: "Session",
|
||||
defaultSessionTitle: "新对话",
|
||||
model: "Model",
|
||||
workspace: "Workspace",
|
||||
workspaceHint: "留空自动创建临时目录;指定时必须是服务器上已存在的目录",
|
||||
approvalMode: "审批模式",
|
||||
/** Short description (the trigger button shows only the description, not the mode id). */
|
||||
approvalModeNames: {
|
||||
"allow-all": "全部放行",
|
||||
"deny-all": "全部拒绝",
|
||||
"read-only": "放行只读",
|
||||
"always-ask": "总是询问",
|
||||
} as Record<string, string>,
|
||||
approvalModes: {
|
||||
"allow-all": "全部放行(allow-all)",
|
||||
"deny-all": "全部拒绝(deny-all)",
|
||||
"read-only": "放行只读(read-only)",
|
||||
"always-ask": "总是询问(always-ask)",
|
||||
} as Record<string, string>,
|
||||
statusIdle: "空闲",
|
||||
statusRunning: "运行中",
|
||||
statusCompacting: "压缩中",
|
||||
pendingApprovals: (n: number) => `${n} 个待审批`,
|
||||
inputPlaceholder: "输入消息,Enter 发送,Shift+Enter 换行,可粘贴图片",
|
||||
inputPlaceholderShort: "输入消息…",
|
||||
send: "发送",
|
||||
stop: "停止",
|
||||
compactingHint: "上下文压缩中,暂不接受新输入",
|
||||
compact: "压缩上下文",
|
||||
approve: "允许",
|
||||
deny: "拒绝",
|
||||
decisionAllow: "已批准",
|
||||
decisionDeny: "已拒绝",
|
||||
decisionManual: "手动",
|
||||
decisionAuto: "自动",
|
||||
thinking: "思考",
|
||||
subagent: "子会话",
|
||||
subagentRunning: "运行中",
|
||||
aborted: (reason?: string) => `[已中断]${reason ? `:${reason}` : ""}`,
|
||||
reconnect: (
|
||||
status: "timeout" | "malformed",
|
||||
state: "waiting" | "retried" | "gaveUp",
|
||||
attempt: number,
|
||||
) => {
|
||||
const cause = status === "timeout" ? "连接超时或网络中断" : "响应不完整或无法解析";
|
||||
const action =
|
||||
state === "gaveUp"
|
||||
? "已停止重试"
|
||||
: state === "retried"
|
||||
? `已发起第 ${attempt} 次重试`
|
||||
: `正在发起第 ${attempt} 次重试…`;
|
||||
return `[重试] ${cause},${action}`;
|
||||
},
|
||||
imageAlt: "用户上传的图片",
|
||||
toolImageAlt: "工具输出的图片",
|
||||
imagesAsPathHint:
|
||||
"当前模型不支持直接查看图片:发送时图片将保存到会话临时目录,以文件路径转交(模型经 describe_image 查看)",
|
||||
infoPanel: "Session 信息",
|
||||
sessionStats: "统计",
|
||||
statTokens: "Token 累计",
|
||||
statCost: "成本",
|
||||
statElapsed: "用时",
|
||||
statInput: "输入 tokens",
|
||||
statOutput: "输出 tokens",
|
||||
statTps: "输出 TPS",
|
||||
noSessions: "还没有 Session",
|
||||
emptyStream: "发送一条消息开始对话",
|
||||
historyLoadFailed: "历史消息加载失败",
|
||||
sessionHealed: "Session 已自愈为新 id",
|
||||
statsLabel: "统计信息",
|
||||
removeImage: "移除图片",
|
||||
openWorkspace: "打开工作区",
|
||||
/** File summary card at the end of a message (Codex-style): title, inline preview action, and collapsed row. */
|
||||
filesInMessage: (n: number) => `${n} 个文件`,
|
||||
openPreview: "点击预览",
|
||||
showMoreFiles: (n: number) => `显示其余 ${n} 个文件`,
|
||||
showLess: "收起",
|
||||
contextUsage: "上下文占用",
|
||||
contextUnknown: "上下文占用:压缩后待下次请求回报",
|
||||
slashHint: "输入 / 使用命令",
|
||||
mentionHint: "@ handoff 给其他 Agent",
|
||||
mentionRemove: "移除 @ 目标",
|
||||
/** Skill multi-select dropdown (input toolbar): button text, search box, empty state, and no-match hint. */
|
||||
skillsSelect: "技能",
|
||||
skillsSearchPlaceholder: "搜索技能",
|
||||
skillsNoMatch: "没有匹配的技能",
|
||||
skillsEmptyHint: "暂无已装技能,去技能库添加",
|
||||
/** Auto-generated invocation text when skills are selected and the body is empty (wrapped in <use_skills> before sending). */
|
||||
skillsAutoMessage: (names: string[]): string => `使用 ${names.join("、")} 技能`,
|
||||
handoffFrom: (agent: string) => `由 ${agent} 的对话交接而来`,
|
||||
handoffBack: (title?: string) => (title ? `回到原对话:${title}` : "回到原对话"),
|
||||
scheduledFrom: (name: string) => `由定时任务「${name}」触发`,
|
||||
/** Source badge on session list rows (user-created sessions have no source). */
|
||||
sourceNames: {
|
||||
schedule: "定时",
|
||||
subagent: "子",
|
||||
} as Record<string, string>,
|
||||
commandCompact: "/compact",
|
||||
emptyGreeting: "开始一段新对话",
|
||||
compactionRunning: (mode: string) => `压缩进行中(${mode})…`,
|
||||
compactionDone: (mode: string): string =>
|
||||
mode === "discard" ? "[压缩] 完成,旧上下文已丢弃" : "[压缩] 完成,已切换到摘要后的新上下文",
|
||||
compactionFailed: (status: string) =>
|
||||
`[压缩] ${status === "aborted" ? "已中断" : "失败"},保留当前上下文`,
|
||||
unknownTool: "(未知工具)",
|
||||
toolCall: "工具调用",
|
||||
workGroupTitle: "推理与工具",
|
||||
workRunning: "运行中",
|
||||
workDone: "运行完毕",
|
||||
workGroupSteps: (n: number) => `${n} 步`,
|
||||
approvalWaiting: "待审批",
|
||||
copyCode: "复制代码",
|
||||
copyStats: "复制统计",
|
||||
copyReply: "复制回复",
|
||||
copyMessage: "复制消息",
|
||||
copied: "已复制",
|
||||
deleteSession: "删除对话",
|
||||
renameSession: "重命名对话",
|
||||
renameSessionLabel: "标题",
|
||||
deleteSessionConfirm: (title: string) =>
|
||||
`确定删除「${title}」?该对话的消息与 Trace 将被移除,且不可恢复。`,
|
||||
archiveSession: "归档",
|
||||
unarchiveSession: "取消归档",
|
||||
archivedGroup: (n: number) => `已归档(${n})`,
|
||||
skillsBanner: (names: string[]): string => `使用技能:${names.join("、")}`,
|
||||
},
|
||||
|
||||
files: {
|
||||
title: "文件",
|
||||
upload: "上传",
|
||||
download: "下载",
|
||||
refresh: "刷新",
|
||||
root: "根目录",
|
||||
empty: "空目录",
|
||||
name: "名称",
|
||||
size: "大小",
|
||||
modified: "修改时间",
|
||||
previewUnsupported: "该类型不支持预览,请下载查看",
|
||||
uploaded: "已上传",
|
||||
loadFailed: "加载失败",
|
||||
previewTruncated: "内容过大,预览已截断,请下载查看完整文件",
|
||||
details: "详情",
|
||||
workspacePath: "Workspace 路径",
|
||||
htmlRendered: "渲染视图",
|
||||
htmlSource: "源码",
|
||||
backToList: "返回列表",
|
||||
resizeHandle: "拖拽调整宽度,双击恢复默认",
|
||||
},
|
||||
|
||||
usage: {
|
||||
title: "成本与统计",
|
||||
today: "今日",
|
||||
last7d: "近 7 天",
|
||||
total: "累计",
|
||||
tokens: "Token",
|
||||
cost: "成本",
|
||||
requests: "Requests",
|
||||
from: "起始日期",
|
||||
to: "结束日期",
|
||||
colCacheRead: "cache_read",
|
||||
colCacheWrite: "cache_write",
|
||||
colOutput: "output",
|
||||
colCost: "成本",
|
||||
trendTitle: "近 30 天趋势",
|
||||
uncostedNote: "* 含未配置 pricing 的记录,成本为不完全统计",
|
||||
filterAllAgents: "全部 Agent",
|
||||
filterAllModels: "全部模型",
|
||||
chartAgentCalls: "各 Agent 调用次数",
|
||||
chartSuccessRate: "各模型成功率",
|
||||
chartTokenTrend: "Token 逐日变化",
|
||||
chartCostTrend: "成本逐日变化",
|
||||
empty: "暂无用量记录",
|
||||
successAborted: "已中断(不计入)",
|
||||
errors: "异常",
|
||||
errorsTotal: "总数",
|
||||
errorsUnexpected: "未预期",
|
||||
errorsExpected: "预期内",
|
||||
errorsTopCode: "最常见",
|
||||
errorsColTime: "时间",
|
||||
errorsColCode: "来源 · 错误码",
|
||||
errorsColKind: "类型",
|
||||
errorsColMessage: "消息",
|
||||
errorsEmpty: "暂无异常",
|
||||
},
|
||||
|
||||
traces: {
|
||||
title: "轨迹观测",
|
||||
filterAll: "全部",
|
||||
filterModel: "model_msg",
|
||||
filterEvent: "event_msg",
|
||||
analysis: "轨迹观测",
|
||||
timeline: "执行时间线",
|
||||
laneLLM: "模型",
|
||||
laneTools: "工具",
|
||||
phaseApproval: "审批",
|
||||
phaseExec: "执行",
|
||||
kindThinking: "思考",
|
||||
kindModelReply: "模型回复",
|
||||
kindToolGen: "工具调用生成",
|
||||
legendToolExec: "工具调用执行",
|
||||
legendApprovalWait: "审批等待",
|
||||
task: (n: number) => `第 ${n} 轮`,
|
||||
globalSummary: "全局统计",
|
||||
tasksLabel: "轮次",
|
||||
messages: "消息",
|
||||
truncatedNote: (shown: number, total: number) => `仅展示前 ${shown} / ${total} 条消息`,
|
||||
zoom: "缩放",
|
||||
zoomReset: "双击复位缩放",
|
||||
zoomOut: "缩小",
|
||||
zoomIn: "放大",
|
||||
linkHint:
|
||||
"鼠标移到时间线段或消息行即可联动高亮,点击时间线段跳转到对应消息;图例可高亮同类;拖动下方滑块平移/缩放",
|
||||
filesTitle: "Trace 文件",
|
||||
selectSession: "在左侧选择一个 Session",
|
||||
toolCalls: "工具调用",
|
||||
taskInput: "本轮输入 tokens",
|
||||
taskOutput: "本轮输出 tokens",
|
||||
cacheHit: "命中缓存",
|
||||
hitRate: "命中率",
|
||||
toolFailRate: "失败率",
|
||||
compactions: "压缩次数",
|
||||
compactionRound: "压缩",
|
||||
usageTrend: "Token 用量趋势",
|
||||
selectFile: "在左侧选择一个 Trace 文件",
|
||||
empty: "该 Agent 暂无 Trace",
|
||||
colTime: "时间",
|
||||
colType: "类型",
|
||||
colSummary: "摘要",
|
||||
lines: (n: number) => `${n} 行`,
|
||||
inProgress: "进行中",
|
||||
systemPrompt: "系统提示词",
|
||||
toolDefs: (n: number) => `工具定义(${n})`,
|
||||
},
|
||||
|
||||
benchmark: {
|
||||
title: "评估中心",
|
||||
selectBenchmark: "在左侧选择一个 Benchmark",
|
||||
emptyAgent: "该 Agent 暂无 Benchmark",
|
||||
caseCount: (n: number): string => `${n} 题`,
|
||||
/** Chart title, varies by selected metric (score / cost / duration over time). */
|
||||
trendTitle: (metric: string): string => `${metric}随时间变化`,
|
||||
evaluations: "评估明细",
|
||||
noEvaluations: "暂无评估记录",
|
||||
/** Evaluation notes (scoreboard's summary: score source and notes on this round's changes). */
|
||||
summaryLabel: "评估说明",
|
||||
/** Chart legend: older evaluation records with no model label (gray series). */
|
||||
legendUnlabeled: "未标注模型",
|
||||
colTime: "时间",
|
||||
colVersion: "版本",
|
||||
colModel: "模型",
|
||||
colScore: "总分",
|
||||
colCost: "成本",
|
||||
colDuration: "耗时",
|
||||
colCase: "题目",
|
||||
colRun: "运行",
|
||||
colSession: "Session",
|
||||
},
|
||||
|
||||
// Server error code → localized copy (the server's message is hardcoded Chinese; this is only a fallback for unknown codes).
|
||||
errors: {
|
||||
unauthorized: "登录已过期,请重新登录",
|
||||
networkError: "网络错误,请检查连接",
|
||||
modelCredentialMissing: (modelId: string) =>
|
||||
`模型 ${modelId} 还没有可用的 API key,请先在「模型」页为它配置`,
|
||||
noDefaultModel: "该 Project 还没有默认模型,请先在「模型」页添加模型并设为默认",
|
||||
},
|
||||
};
|
||||
|
||||
/** Dictionary shape (constrains the English dictionary so keys and function signatures line up). */
|
||||
export type Strings = typeof zh;
|
||||
|
||||
/**
|
||||
* Runtime active dictionary (live binding): the locale Provider calls setActiveStrings
|
||||
* to switch before render, and remounts the whole tree keyed by locale so every `S.x`
|
||||
* read reflects the current language.
|
||||
*/
|
||||
export let S: Strings = zh;
|
||||
|
||||
export function setActiveStrings(next: Strings): void {
|
||||
S = next;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Colors for token buckets (same blue hue family, shared across light/dark):
|
||||
* used by both the cost center's stacked chart and the trace observation's
|
||||
* component bars, keeping "cacheRead lightest / cacheWrite mid / output
|
||||
* darkest" as one consistent meaning site-wide.
|
||||
*/
|
||||
export const TOKEN_COLORS = {
|
||||
cacheRead: "#7dd3fc", // sky-300, lightest
|
||||
cacheWrite: "#0ea5e9", // sky-500, mid
|
||||
output: "#0369a1", // sky-700, darkest
|
||||
} as const;
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Sync the browser tab title (document.title): "{title} · PenguinHarness"
|
||||
* when a page title is set, falling back to the app name otherwise. Called
|
||||
* at the top level of each page component, updating instantly on route
|
||||
* changes or title changes (e.g. an auto-generated Session title delivered
|
||||
* via a session_title event).
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { S } from "./strings";
|
||||
|
||||
export function useDocumentTitle(title: string | null | undefined): void {
|
||||
useEffect(() => {
|
||||
document.title = title ? `${title} · ${S.appName}` : S.appName;
|
||||
}, [title]);
|
||||
}
|
||||
Reference in New Issue
Block a user