zcbot/web/static/js/format.js

122 lines
5.0 KiB
JavaScript
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.

// 纯格式化 / 转义工具(无 DOM、无状态依赖)。dom.js / markdown.js / main.js 共用。
export function escapeHtml(s) {
return (s || "").replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]
));
}
export function humanSize(n) {
if (n == null) return "";
if (n < 1024) return n + " B";
if (n < 1024*1024) return (n/1024).toFixed(1) + " K";
if (n < 1024*1024*1024) return (n/1024/1024).toFixed(1) + " M";
return (n/1024/1024/1024).toFixed(1) + " G";
}
export function fmtTime(iso) {
if (!iso) return "";
try { return new Date(iso).toLocaleString(); } catch (e) { return iso; }
}
// 紧凑 token 显示:<1k 原数,<10k 一位小数 k,>=10k 整数 k,>=1M 一位小数 M
// 目的:让列表行 "N tok" 槽位宽度有上限,跨行对齐
export function fmtTokens(n) {
n = n || 0;
if (n < 1000) return String(n);
if (n < 10000) return (n / 1000).toFixed(1) + "k";
if (n < 1000000) return Math.round(n / 1000) + "k";
return (n / 1000000).toFixed(1) + "M";
}
// 紧凑成本显示(¥,已按缓存折价的真实花费):0 不显;<0.01 三位小数;否则两位
export function fmtCost(n) {
n = n || 0;
if (n <= 0) return "";
if (n < 0.01) return "¥" + n.toFixed(3);
return "¥" + n.toFixed(2);
}
// 任务累计用量的 hover 详情(多行):输入/输出拆分 · 缓存命中 + 命中率 · 真实花费。
// 列表行 + 顶栏共用(列表只显 tok 数,花费/缓存藏 tooltip;顶栏额外内联简版)。
export function taskUsageTooltip(t) {
const pin = t.tokens_prompt || 0;
const pout = t.tokens_completion || 0;
const hit = t.tokens_cache_hit || 0;
const lines = [`输入 ${pin.toLocaleString()} / 输出 ${pout.toLocaleString()} tok合计 ${(pin + pout).toLocaleString()}`];
if (pin > 0 && hit > 0) {
lines.push(`前缀缓存命中 ${hit.toLocaleString()} tok命中率 ${Math.round(hit / pin * 100)}%,命中部分按低价计费)`);
}
if (t.cost_cny > 0) {
lines.push(`真实花费 ¥${(t.cost_cny).toFixed(4)}(已按缓存命中折价)`);
}
return lines.join("\n");
}
// 任务级累计用量(顶栏):总 token · 缓存命中率 · 真实花费;详情走 taskUsageTooltip。
// 缓存命中率 = cache_hit / 总输入(tokens_prompt);命中越高说明前缀复用越好、越省钱。
export function formatTaskUsage(t) {
const tok = t.tokens || 0;
if (!tok) return "";
const hit = t.tokens_cache_hit || 0;
const pin = t.tokens_prompt || 0;
const bits = [`${fmtTokens(tok)} tok`];
if (pin > 0 && hit > 0) {
bits.push(`缓存命中 ${Math.round(hit / pin * 100)}%`);
}
const cost = fmtCost(t.cost_cny);
if (cost) bits.push(cost);
return `<span class="muted" title="${escapeHtml(taskUsageTooltip(t))}" style="white-space:nowrap;">${bits.join(" · ")}</span>`;
}
export function formatContextStats(d) {
d = d || {};
const orig = d.context_original_chars || 0;
const sent = d.context_sent_chars || 0;
const saved = d.context_saved_chars || 0;
const tools = d.context_compacted_tool_messages || 0;
const skills = d.context_compacted_skill_messages || 0;
if (!orig) return "准备中…";
const bits = [`ctx ${fmtTokens(orig)}${fmtTokens(sent)} chars`];
if (saved > 0) bits.push(`${fmtTokens(saved)}`);
if (tools > 0) bits.push(`压缩工具 ${tools}`);
if (skills > 0) bits.push(`skill ${skills}`);
return bits.join(" · ");
}
export function formatUsageStats(d, contextStats) {
d = d || {};
const pt = d.prompt_tokens || 0;
const ct = d.completion_tokens || 0;
const hit = d.cache_hit_tokens || 0;
const miss = d.cache_miss_tokens || 0;
const bits = [`${fmtTokens(pt)}+${fmtTokens(ct)} tok`];
if (hit || miss) bits.push(`cache ${fmtTokens(hit)}/${fmtTokens(miss)}`);
if (contextStats && contextStats.context_saved_chars) {
bits.push(`ctx省 ${fmtTokens(contextStats.context_saved_chars)}`);
}
return bits.join(" · ");
}
// 相对时间(任务列表用):刚刚 / N 分钟前 / N 小时前 / 昨天 HH:MM / MM-DD / YYYY-MM-DD
export function fmtTimeAgo(iso) {
if (!iso) return "";
let d;
try { d = new Date(iso); } catch (e) { return iso; }
if (isNaN(d.getTime())) return iso;
const now = new Date();
const diffSec = Math.floor((now - d) / 1000);
if (diffSec < 0) return d.toLocaleString(); // 时钟漂移兜底
if (diffSec < 60) return "刚刚";
if (diffSec < 3600) return `${Math.floor(diffSec / 60)} 分钟前`;
if (diffSec < 86400 && now.getDate() === d.getDate()) return `${Math.floor(diffSec / 3600)} 小时前`;
const pad = (n) => String(n).padStart(2, "0");
const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const yest = new Date(now); yest.setDate(now.getDate() - 1);
if (d.getFullYear() === yest.getFullYear() && d.getMonth() === yest.getMonth() && d.getDate() === yest.getDate()) {
return `昨天 ${hhmm}`;
}
if (d.getFullYear() === now.getFullYear()) return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hhmm}`;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}