Files
penguin-harness/packages/web/src/lib/format.ts
T
2026-07-23 00:27:54 +08:00

203 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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`;
}
/**
* Duration format for a still-running timer: whole seconds only (`0s`, `7s`, `1m3s`),
* counting up (floor, never showing a second early). Decimals are reserved for settled
* durations (humanizeDuration above) — a live timer showing tenths reads as jitter.
*/
export function humanizeDurationLive(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000));
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m${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`;
}
/**
* Cache hit rate = cacheRead ÷ (cacheRead + cacheWrite): the share of cached
* input actually served from cache; null when the denominator is 0 (no cache
* activity — the rate is undefined, callers omit the stat or let
* formatPercent render `—`). The single formula shared by the Trace page's
* turn/global summaries and the Cost center's cacheRead bubble, so the two pages can never drift apart.
*/
export function cacheHitRate(cacheRead: number, cacheWrite: number): number | null {
const total = cacheRead + cacheWrite;
return total > 0 ? cacheRead / total : null;
}
/**
* Ratio display (rounded to a whole percent): `0.714` → `71%`; null
* (denominator is 0, undefined) or a non-finite value → `—`.
* Used with cacheHitRate above (Trace page and the Cost center's cacheRead bubble) 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())}`;
}