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:
Yaowei Zheng
2026-07-19 14:06:53 +08:00
committed by GitHub
parent 056bed7aeb
commit 45bfae6e94
543 changed files with 92949 additions and 0 deletions
+25
View File
@@ -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;
}
}
+48
View File
@@ -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 };
}
+61
View File
@@ -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]!;
}
+15
View File
@@ -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;
}
+129
View File
@@ -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;
}
+180
View File
@@ -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
+303
View File
@@ -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)}`
);
}
+19
View File
@@ -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;
+29
View File
@@ -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;
}
+149
View File
@@ -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,
};
}
+34
View File
@@ -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;
+732
View File
@@ -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",
},
};
+726
View File
@@ -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;
}
+11
View File
@@ -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]);
}