// 纯格式化 / 转义工具(无 DOM、无状态依赖)。dom.js / markdown.js / main.js 共用。 export function escapeHtml(s) { return (s || "").replace(/[&<>"']/g, (c) => ( { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 `${bits.join(" · ")}`; } 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())}`; }