166 lines
7.3 KiB
JavaScript
166 lines
7.3 KiB
JavaScript
// 纯格式化 / 转义工具(无 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;金额/缓存命中率挪 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())}`;
|
||
}
|