cf27b13880
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
203 lines
8.5 KiB
TypeScript
203 lines
8.5 KiB
TypeScript
/**
|
||
* 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())}`;
|
||
}
|