zcbot/web/static/js/format.js

166 lines
7.3 KiB
JavaScript
Raw Permalink 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;金额/缓存命中率挪 hover 详情
// (taskUsageTooltip 本就有),明面槽位让给压缩指示环(ctxRingHtml,0.54.1)。
export function formatTaskUsage(t) {
const tok = t.tokens || 0;
if (!tok) return "";
return `<span class="muted" title="${escapeHtml(taskUsageTooltip(t))}" style="white-space:nowrap;">${fmtTokens(tok)} tok</span>`;
}
// ───── 压缩指示环(§8.8 上下文治理的头部可视化)─────
// 环 = 当前窗口体量 / 模型容量(reliable_context×2.5 chars)。色阶对应三段机制:
// 绿 <50% 未压缩(上下文原样发送);琥珀 50-85% 压缩区(发送时旧工具正文被压);
// 红 ≥85% 达折叠阈值(下次发消息 run 起点自动整理成摘要)。容量未知 → 灰空环。
// 数据源:打开任务时 GET /v1/tasks/{id} 的 context_* 字段;run 中由 llm_start /
// context_fold 事件实时刷(chat.js refreshCtxRing)。
const _RING_C = 2 * Math.PI * 6; // r=6 的周长,dasharray 基准
export function ctxRingTitle(t) {
const p = t.context_pressure;
const lines = [];
if (typeof p === "number") {
lines.push(`上下文窗口占用 ${Math.round(p * 100)}%`
+ `${fmtTokens(t.context_window_chars || 0)} / ${fmtTokens(t.context_limit_chars || 0)} chars`);
if (p >= 0.85) lines.push("已达折叠阈值 85%:下次发消息时自动把早期内容整理为摘要");
else if (p >= 0.5) lines.push("已过 50% 压缩门槛:发送时旧工具输出会被压缩");
else lines.push("未压缩:体量未到 50% 门槛,上下文原样发送");
} else {
lines.push("窗口占用未知(模型容量档案不可用)");
}
if (t.context_folds > 0) {
lines.push(`已整理 ${t.context_folds} 次(早期消息折叠为摘要,原文在历史里可查)`);
}
return lines.join("\n");
}
export function ctxRingHtml(t) {
t = t || {};
const p = t.context_pressure;
const known = typeof p === "number" && p >= 0;
const pct = known ? Math.min(p, 1) : 0;
const color = !known ? "#999"
: p >= 0.85 ? "#c0392b"
: p >= 0.5 ? "#d99a1b"
: "#3a9d5d";
const label = known ? `${Math.round(p * 100)}%` : "";
const folds = t.context_folds || 0;
const foldBadge = folds > 0
? `<span style="font-size:11px;">已整理×${folds}</span>` : "";
return `<span id="ctx-ring" class="muted" title="${escapeHtml(ctxRingTitle(t))}"`
+ ` style="display:inline-flex;align-items:center;gap:3px;white-space:nowrap;cursor:default;">`
+ `<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true" style="flex:none;">`
+ `<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-opacity="0.18" stroke-width="2.5"></circle>`
+ (pct > 0
? `<circle cx="8" cy="8" r="6" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round"`
+ ` stroke-dasharray="${Math.max(pct * _RING_C, 0.8).toFixed(2)} ${_RING_C.toFixed(2)}"`
+ ` transform="rotate(-90 8 8)"></circle>`
: "")
+ `</svg><span style="font-size:11px;">${label}</span>${foldBadge}</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())}`;
}