@geoqiao/pi-usage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +222 -0
- package/bin/pi-usage.js +69 -0
- package/data/models.dev-LICENSE +21 -0
- package/data/prices.json +2678 -0
- package/extensions/usage-report.js +36 -0
- package/package.json +51 -0
- package/src/analytics.js +189 -0
- package/src/collect.js +34 -0
- package/src/network.js +25 -0
- package/src/report.js +43 -0
- package/vendor/vibe-usage/NOTICE.md +58 -0
- package/vendor/vibe-usage/src/cindy-roots.js +85 -0
- package/vendor/vibe-usage/src/claude-roots.js +165 -0
- package/vendor/vibe-usage/src/cline-roots.js +40 -0
- package/vendor/vibe-usage/src/codex-roots.js +46 -0
- package/vendor/vibe-usage/src/craft-roots.js +15 -0
- package/vendor/vibe-usage/src/extra-roots.js +312 -0
- package/vendor/vibe-usage/src/parsers/aggregate.js +196 -0
- package/vendor/vibe-usage/src/parsers/alma.js +94 -0
- package/vendor/vibe-usage/src/parsers/amp.js +156 -0
- package/vendor/vibe-usage/src/parsers/antigravity-db.js +359 -0
- package/vendor/vibe-usage/src/parsers/antigravity.js +530 -0
- package/vendor/vibe-usage/src/parsers/cindy-ledger.js +157 -0
- package/vendor/vibe-usage/src/parsers/claude-code.js +372 -0
- package/vendor/vibe-usage/src/parsers/cline.js +92 -0
- package/vendor/vibe-usage/src/parsers/codex-cache.js +138 -0
- package/vendor/vibe-usage/src/parsers/codex.js +1198 -0
- package/vendor/vibe-usage/src/parsers/contract.js +55 -0
- package/vendor/vibe-usage/src/parsers/copilot-cli.js +128 -0
- package/vendor/vibe-usage/src/parsers/craft-agent.js +21 -0
- package/vendor/vibe-usage/src/parsers/cursor.js +262 -0
- package/vendor/vibe-usage/src/parsers/dimagent.js +127 -0
- package/vendor/vibe-usage/src/parsers/droid.js +113 -0
- package/vendor/vibe-usage/src/parsers/dsh.js +563 -0
- package/vendor/vibe-usage/src/parsers/fs-utils.js +36 -0
- package/vendor/vibe-usage/src/parsers/gemini-cli.js +190 -0
- package/vendor/vibe-usage/src/parsers/grok.js +395 -0
- package/vendor/vibe-usage/src/parsers/hermes.js +123 -0
- package/vendor/vibe-usage/src/parsers/index.js +61 -0
- package/vendor/vibe-usage/src/parsers/kimi-code.js +467 -0
- package/vendor/vibe-usage/src/parsers/kiro.js +788 -0
- package/vendor/vibe-usage/src/parsers/mcode.js +182 -0
- package/vendor/vibe-usage/src/parsers/mimocode.js +88 -0
- package/vendor/vibe-usage/src/parsers/omp.js +10 -0
- package/vendor/vibe-usage/src/parsers/openclaw.js +142 -0
- package/vendor/vibe-usage/src/parsers/opencode.js +151 -0
- package/vendor/vibe-usage/src/parsers/pi-coding-agent.js +27 -0
- package/vendor/vibe-usage/src/parsers/pi-session-jsonl.js +166 -0
- package/vendor/vibe-usage/src/parsers/qwen-code.js +122 -0
- package/vendor/vibe-usage/src/parsers/roo-code.js +123 -0
- package/vendor/vibe-usage/src/parsers/sqlite.js +148 -0
- package/vendor/vibe-usage/src/parsers/trae-cli.js +171 -0
- package/vendor/vibe-usage/src/parsers/workbuddy.js +322 -0
- package/vendor/vibe-usage/src/parsers/zcode.js +115 -0
- package/vendor/vibe-usage/src/pi-roots.js +125 -0
- package/vendor/vibe-usage/src/tools.js +422 -0
- package/vendor/vibe-usage/src/workbuddy-roots.js +22 -0
- package/vendor/vibe-usage/upstream-files.json +48 -0
- package/web/report.css +10 -0
- package/web/report.html +81 -0
- package/web/report.js +310 -0
package/web/report.html
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<!--CSP-->
|
|
7
|
+
<title>Pi Usage · 用量分析</title>
|
|
8
|
+
<style>/*STYLE*/</style>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<a class="skip" href="#main">跳至分析看板</a>
|
|
12
|
+
<header class="app-header">
|
|
13
|
+
<a class="brand" href="#overview"><span class="brand-mark">π</span><span>Usage<span class="brand-sub">LOCAL ANALYTICS</span></span></a>
|
|
14
|
+
<nav class="views" aria-label="分析视图"><button data-view="overview" aria-pressed="true"><span data-icon="dashboard"></span>看板</button><button data-view="percentiles" aria-pressed="false"><span data-icon="range"></span>日分布</button><button data-view="simulation" aria-pressed="false"><span data-icon="compare"></span>模型计价</button><button data-view="records" aria-pressed="false"><span data-icon="table"></span>明细</button></nav>
|
|
15
|
+
<div class="actions"><button id="export" class="icon-button" data-icon="download" aria-label="导出当前筛选的日级明细 CSV" title="导出当前筛选的日级明细 CSV"></button><button id="print" class="icon-button" data-icon="print" aria-label="打印当前视图 / PDF" title="打印当前视图 / PDF"></button><button id="info" class="icon-button" data-icon="info" aria-label="来源状态、价格与统计口径" title="来源状态、价格与统计口径"></button></div>
|
|
16
|
+
</header>
|
|
17
|
+
<main id="main">
|
|
18
|
+
<section class="filters" aria-label="组合筛选">
|
|
19
|
+
<div class="date-filter"><span data-icon="calendar"></span><input type="date" id="from" aria-label="开始日期"><span class="muted">—</span><input type="date" id="to" aria-label="结束日期"></div>
|
|
20
|
+
<div class="presets" aria-label="截至报告日期的范围"><button data-days="7">7D</button><button data-days="30">30D</button><button data-days="90">90D</button><button data-days="all">全部</button></div>
|
|
21
|
+
<span class="filter-spacer"></span><button id="reset" class="icon-button" data-icon="reset" aria-label="重置全部筛选" title="重置全部筛选"></button>
|
|
22
|
+
<div class="dimension-filters">
|
|
23
|
+
<label><span data-icon="terminal"></span><span>Harness</span><select id="source"><option value="">全部</option></select></label>
|
|
24
|
+
<label><span data-icon="model"></span><span>模型</span><select id="model"><option value="">全部</option></select></label>
|
|
25
|
+
<label><span data-icon="folder"></span><span>项目</span><select id="project"><option value="">全部</option></select></label>
|
|
26
|
+
<label><span data-icon="monitor"></span><span>终端</span><select id="hostname"><option value="">全部</option></select></label>
|
|
27
|
+
<label><span data-icon="branch"></span><span>请求</span><select id="requestType" aria-label="请求类型"><option value="">全部</option></select></label>
|
|
28
|
+
</div>
|
|
29
|
+
</section>
|
|
30
|
+
<div class="scope-line"><p id="filter-status" role="status" aria-live="polite"></p><button id="snapshot" class="text-button" title="查看数据快照和来源状态"><span class="status-dot"></span><span id="snapshot-label">本地快照</span><span data-icon="info"></span></button></div>
|
|
31
|
+
<div id="breadcrumbs" class="breadcrumbs" aria-label="当前筛选,可逐项移除"></div>
|
|
32
|
+
<section id="metrics" class="metrics" aria-label="当前筛选的用量">
|
|
33
|
+
<div class="total-metric"><span class="eyebrow">TOKEN <span class="muted">/ 含缓存</span></span><strong id="total-value" class="number">—</strong><span id="net-value" class="metric-note"></span><div id="token-ribbon" class="token-ribbon" aria-hidden="true"></div></div>
|
|
34
|
+
<div id="composition" class="composition"></div>
|
|
35
|
+
<div class="cost-metric"><span id="cost-label" class="eyebrow">估算 · USD</span><strong id="cost-value" class="number">—</strong><div class="coverage-track" aria-hidden="true"><span id="coverage-bar"></span></div><span id="coverage-value" class="metric-note"></span></div>
|
|
36
|
+
</section>
|
|
37
|
+
<div id="empty-state" class="empty" hidden>当前筛选没有用量记录。调整筛选或重置即可继续。</div>
|
|
38
|
+
<section id="overview" class="view" aria-label="用量看板">
|
|
39
|
+
<div class="dashboard-grid">
|
|
40
|
+
<section class="panel trend-panel">
|
|
41
|
+
<div class="panel-head"><h1><span data-icon="trend"></span>每日趋势</h1><div class="actions"><span id="activity-count" class="badge"></span><select id="trend-metric" aria-label="趋势指标"><option value="tokens">Token 构成</option><option value="inputTokens">输入(含缓存写)</option><option value="cachedInputTokens">缓存读取</option><option value="outputTokens">输出</option><option value="reasoningOutputTokens">推理</option><option value="cost">已知估算金额</option></select><button class="icon-button small" data-help="trend" data-icon="info" aria-label="趋势图说明"></button></div></div>
|
|
42
|
+
<div id="trend"></div><div class="chart-footer"><div id="trend-legend" class="legend"></div><span class="muted">点击日期下钻</span></div>
|
|
43
|
+
</section>
|
|
44
|
+
<section class="panel request-panel">
|
|
45
|
+
<div class="panel-head"><h2><span data-icon="branch"></span>请求构成</h2><button class="icon-button small" data-help="request" data-icon="info" aria-label="请求类型分类口径"></button></div>
|
|
46
|
+
<div class="request-visual"><div id="request-ring"></div><div id="request-summary" aria-label="按请求类型筛选"></div></div><div id="classification-note" class="panel-foot"></div>
|
|
47
|
+
</section>
|
|
48
|
+
<section class="panel harness-panel"><div class="panel-head"><h2><span data-icon="terminal"></span>Harness</h2><span id="harness-count" class="badge"></span></div><div id="harness-ranking" class="ranking"></div><div class="panel-foot">含缓存 Token · 点击联动筛选</div></section>
|
|
49
|
+
<section class="panel ranking-panel">
|
|
50
|
+
<div class="panel-head"><h2><span data-icon="layers"></span>用量分布</h2><div class="actions"><select id="distribution-dimension" aria-label="分布维度"><option value="model">模型</option><option value="project">项目</option><option value="hostname">终端</option><option value="source">Harness</option><option value="requestType">请求类型</option></select><select id="distribution-metric" aria-label="分布指标"><option value="allTokens">Token</option><option value="inputTokens">输入</option><option value="cachedInputTokens">缓存读</option><option value="outputTokens">输出</option><option value="reasoningOutputTokens">推理</option><option value="knownCost">已知金额</option></select></div></div>
|
|
51
|
+
<div id="distributions" class="ranking"></div><div class="panel-foot"><span id="ranking-note"></span><button id="show-all" class="text-button" aria-expanded="false">全部</button></div>
|
|
52
|
+
</section>
|
|
53
|
+
<section class="panel daily-panel"><div class="panel-head"><h2><span data-icon="range"></span>典型一天</h2><button class="icon-button small" data-view="percentiles" data-icon="expand" aria-label="展开每日分位分析" title="展开每日分位分析"></button></div><div class="daily-hero"><div><span class="eyebrow">P50 · TOKEN / 日</span><strong id="daily-median" class="number">—</strong></div><span id="daily-sample" class="badge"></span></div><div id="daily-histogram"></div><div id="daily-range"></div><div id="daily-stats" class="six-stats"></div><div class="panel-foot">仅描述样本 · 不预测未来</div></section>
|
|
54
|
+
</div>
|
|
55
|
+
</section>
|
|
56
|
+
<section id="percentiles" class="view" aria-label="每日分布与分位" hidden>
|
|
57
|
+
<div class="view-heading"><h1>日分布</h1><div class="actions"><span class="muted">Min → Max · 同一样本</span><button class="icon-button small" data-help="quantile" data-icon="info" aria-label="分位与样本口径"></button></div></div>
|
|
58
|
+
<div class="sample-controls"><label class="checkbox"><input type="checkbox" id="include-zero">无记录日按 0 纳入</label><span id="sample-note"></span></div>
|
|
59
|
+
<div class="analysis-grid"><section class="panel"><div class="panel-head"><h2>Token 分位</h2><span class="muted">各行独立刻度</span></div><div id="quantile-charts"></div><details><summary>精确数值</summary><div id="quantiles" class="table-wrap" tabindex="0" aria-label="Token 分位表"></div></details></section><section class="panel"><div class="panel-head"><h2>日金额 · USD</h2><button class="icon-button small" data-help="cost" data-icon="info" aria-label="完整日金额样本与偏差"></button></div><div id="cost-sample-note" class="notice"></div><div id="cost-range"></div><div id="cost-stats" class="six-stats"></div><details><summary>完整金额 / 已知小计</summary><div id="cost-quantiles" class="table-wrap" tabindex="0" aria-label="日金额分位表"></div></details><details id="unpriced"><summary>未完整定价模型</summary><div id="unpriced-models" class="table-wrap" tabindex="0" aria-label="未定价模型"></div></details></section></div>
|
|
60
|
+
<section class="panel"><div class="panel-head"><h2>每日数值</h2><span class="muted">选择日期可下钻</span></div><div id="daily-table" class="table-wrap" tabindex="0" aria-label="每日用量"></div></section>
|
|
61
|
+
</section>
|
|
62
|
+
<section id="simulation" class="view" aria-label="跨模型计价" hidden>
|
|
63
|
+
<div class="view-heading"><h1>模型计价</h1><div class="actions"><button id="simulation-sample" class="text-button" data-view="percentiles"></button><select id="simulation-sort" aria-label="模型计价排序"><option value="p50">P50 ↑</option><option value="p90">P90 ↑</option><option value="min">Min ↑</option><option value="max">Max ↑</option></select><button class="icon-button small" data-help="simulation" data-icon="info" aria-label="同工作量计价假设与限制"></button></div></div>
|
|
64
|
+
<div class="simulation-caption"><span>固定每日四类 Token → 目标模型费率 → 日金额分布</span><span class="muted">USD / 日 · 非实际节省预测</span></div>
|
|
65
|
+
<section class="panel simulation-panel"><div class="panel-head"><span id="simulation-note" class="muted"></span><div class="range-legend"><span>细线 Min–Max</span><span>色带 P25–P75</span><span>● P50</span><span>◇ P90</span></div></div><div id="simulations" class="table-wrap" tabindex="0" aria-label="跨模型每日金额与分位"></div></section>
|
|
66
|
+
</section>
|
|
67
|
+
<section id="records" class="view" aria-label="日级明细" hidden><div class="view-heading"><h1>日级明细</h1><select id="sort" aria-label="明细排序"><option value="date">日期 ↓</option><option value="cost">已知金额 ↓</option><option value="tokens">Token ↓</option></select></div><section class="panel"><div class="panel-head"><span class="muted">日期 × Harness × 模型 × 项目 × 终端 × 请求类型</span><button class="icon-button small" data-help="details" data-icon="info" aria-label="明细与 CSV 导出口径"></button></div><div id="details" class="table-wrap" tabindex="0" aria-label="日级用量明细"></div><div class="pagination"><span id="page-status"></span><button id="previous" class="icon-button" data-icon="left" aria-label="上一页"></button><button id="next" class="icon-button" data-icon="right" aria-label="下一页"></button></div></section></section>
|
|
68
|
+
<footer><span><span data-icon="lock"></span>本地快照 · 离线可用</span><span id="footer-meta"></span></footer>
|
|
69
|
+
</main>
|
|
70
|
+
<dialog id="information" aria-labelledby="info-title"><div class="dialog-head"><h2 id="info-title">数据与口径</h2><button id="close-info" class="icon-button" data-icon="close" aria-label="关闭信息面板"></button></div><div class="dialog-body"><p id="help-message" class="notice" hidden></p><p id="metadata" class="meta"></p><p id="warnings" class="notice" hidden></p>
|
|
71
|
+
<details open><summary>来源状态</summary><div id="source-status" class="table-wrap" tabindex="0" aria-label="来源读取状态"></div></details>
|
|
72
|
+
<details><summary>价格与出处</summary><div id="price-table" class="table-wrap" tabindex="0" aria-label="模型费率出处"></div></details>
|
|
73
|
+
<details><summary>Token 与金额</summary><p>输入包含缓存写,缓存读取单列;输出不含已拆分的推理。allTokens = 输入 + 缓存读取 + 输出 + 推理;totalTokens 不含缓存读取。Token 不代表产出或生产力。</p><p>金额使用 models.dev 社区价格快照及本地覆盖,按美元 / 百万 Token 估算,不是实际扣款。未知费用为 null,不是免费;部分定价仅显示已知小计。可计价覆盖率按含缓存 Token 计算,不是账单覆盖率。</p><p>无法恢复缓存写入溢价、上下文阶梯、历史调价、批量折扣、媒体费用、税费或订阅抵扣。</p></details>
|
|
74
|
+
<details><summary>请求类型</summary><p>非工具调用请求:完整响应可确认没有工具调用。含工具调用请求:至少包含一次工具调用,文字与调用混合也归此类。其他(无法判定):缺少响应、无法可靠关联、旧汇总或尚不支持分类的来源。</p><p>整条请求 usage 互斥归类,三类合计等于总量,不逐工具分摊,不表示工具自身用量。占比是含缓存 Token 比例,不是请求次数。Pi / Oh My Pi、Claude Code、Codex、ZCode 和 Kimi Code 已支持有证据的分类:Codex 使用单次响应边界并核对完成账本;ZCode 精确关联 message_id;Kimi 匹配 step 完成证据。不把整个 turn 或相邻工具当作一次请求。累计回退、缺边界、关联冲突、损坏记录及其他未接入来源仍归其他。旧报告需重新读取日志才能补出分类。</p></details>
|
|
75
|
+
<details><summary>日期、分位与模型计价</summary><p>按报告时区归入日期;无记录可能是未使用、日志缺失或读取不全,不能自动视为零。截止日可能未结束。报告是主动查询快照,不做后台更新。</p><p>默认仅有用量日,无记录日按 0 纳入是显式假设。Min / P25 / P50 / P75 / P90 / Max 使用同一样本;分位采用线性插值,各指标不一定来自同一天,极值不是预算边界或预测。完整日金额分位排除未完整定价日期,可能产生样本偏差。</p><p>模型计价固定当前筛选的每天四类 Token,逐日按本报告用过的目标模型费率重新计价,然后计算分位,不把各类 Token 的分位乘价相加。目标费率齐备即可模拟,不要求原模型已定价。这不是实际节省、质量或速度预测。</p></details>
|
|
76
|
+
<details><summary>导出与隐私</summary><p>页面与下载 CSV 按日期 × Harness × 模型 × 项目 × 终端 × 请求类型聚合;同目录 details.csv / usage.json 保持原始粒度。Harness 对应 source 字段,亦包括 Cursor 等来源。</p><p>保留来源级去重,不推断工时或模型级会话关联;会话仅保留在本地 JSON / sessions.csv。来源读取可联网,采集结果不上传;本 HTML 不发起网络请求。</p></details>
|
|
77
|
+
</div></dialog>
|
|
78
|
+
<noscript>启用 JavaScript 查看报告,或打开同目录 details.csv 查看原始明细。</noscript>
|
|
79
|
+
<script>/*SCRIPT*/</script>
|
|
80
|
+
</body>
|
|
81
|
+
</html>
|
package/web/report.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
const $ = id => document.getElementById(id);
|
|
2
|
+
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
3
|
+
const num = value => value == null ? '—' : new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 }).format(value);
|
|
4
|
+
const money = value => value == null ? '未定价' : new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 2 }).format(value);
|
|
5
|
+
const full = value => value == null ? '—' : new Intl.NumberFormat('en-US', { maximumFractionDigits: 3 }).format(value);
|
|
6
|
+
const percent = value => value == null ? '—' : `${(value * 100).toFixed(1)}%`;
|
|
7
|
+
const labels = { inputTokens: '输入(含缓存写)', cachedInputTokens: '缓存读取', outputTokens: '输出', reasoningOutputTokens: '推理', totalTokens: 'Token(不含缓存)', allTokens: 'Token(含缓存)', estimatedCost: '完整日金额', knownCost: '已知金额小计' };
|
|
8
|
+
const shortLabels = ['输入 / 含缓存写', '缓存读取', '输出', '推理'];
|
|
9
|
+
const dimensions = { source: 'Harness', model: '模型', project: '项目', hostname: '终端', requestType: '请求类型' };
|
|
10
|
+
const colors = ['#3978ca', '#85aace', '#188c88', '#8972bc'];
|
|
11
|
+
const typeColors = ['#3978ca', '#188c88', '#b4bfce'];
|
|
12
|
+
const rangeLabels = ['Min', 'P25', 'P50', 'P75', 'P90', 'Max'];
|
|
13
|
+
const rangeValues = row => [row.min, ...row.values, row.max];
|
|
14
|
+
const dimensionValue = (key, value) => key === 'requestType' ? REQUEST_TYPES[value] ?? REQUEST_TYPES.other : value;
|
|
15
|
+
const icons = {
|
|
16
|
+
dashboard: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
|
|
17
|
+
range: '<path d="M3 12h18M3 8v8m18-8v8"/><rect x="7" y="6" width="10" height="12"/><path d="M12 6v12"/>',
|
|
18
|
+
compare: '<path d="M4 6h16m-4-4 4 4-4 4M20 18H4m4-4-4 4 4 4"/>',
|
|
19
|
+
table: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 10h18M9 10v10m6-10v10"/>',
|
|
20
|
+
download: '<path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/>',
|
|
21
|
+
print: '<path d="M7 8V3h10v5M7 16H3V9h18v7h-4"/><path d="M7 13h10v8H7zM17 10h1"/>',
|
|
22
|
+
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6m0-10h.01"/>',
|
|
23
|
+
calendar: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M7 3v4m10-4v4M3 11h18"/>',
|
|
24
|
+
reset: '<path d="M3 10a9 9 0 1 1 1 7M3 4v6h6"/>',
|
|
25
|
+
terminal: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m6 9 3 3-3 3m6 0h5"/>',
|
|
26
|
+
model: '<path d="m12 2 9 5v10l-9 5-9-5V7l9-5Zm-9 5 9 5 9-5m-9 5v10"/>',
|
|
27
|
+
folder: '<path d="M3 6h7l2 3h9v11H3V6Z"/>',
|
|
28
|
+
monitor: '<rect x="3" y="3" width="18" height="13" rx="2"/><path d="M12 16v5m-5 0h10"/>',
|
|
29
|
+
branch: '<circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="5" r="2"/><path d="M6 7v10m12-10v2c0 4-12 2-12 6"/>',
|
|
30
|
+
trend: '<path d="M3 3v18h18M6 16l5-6 4 3 6-8"/>',
|
|
31
|
+
layers: '<path d="m12 3 10 5-10 5L2 8l10-5Zm-10 9 10 5 10-5M2 16l10 5 10-5"/>',
|
|
32
|
+
expand: '<path d="M14 3h7v7m0-7-8 8M10 21H3v-7m0 7 8-8"/>',
|
|
33
|
+
left: '<path d="m14 5-7 7 7 7"/>', right: '<path d="m10 5 7 7-7 7"/>', close: '<path d="m6 6 12 12M6 18 18 6"/>',
|
|
34
|
+
lock: '<rect x="5" y="10" width="14" height="11" rx="2"/><path d="M8 10V6a4 4 0 0 1 8 0v4"/>',
|
|
35
|
+
};
|
|
36
|
+
const icon = name => `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">${icons[name] ?? icons.info}</svg>`;
|
|
37
|
+
for (const node of document.querySelectorAll('[data-icon]')) node.innerHTML = icon(node.dataset.icon);
|
|
38
|
+
let pageIndex = 0, currentRows = [], currentFilters, showAll = false, currentDays = [], calendar = [], activeView = 'overview';
|
|
39
|
+
for (const row of DATA.buckets) row.requestType ??= 'other';
|
|
40
|
+
const usedModels = [...new Set(DATA.buckets.map(row => row.model))].sort();
|
|
41
|
+
const help = {
|
|
42
|
+
trend: '每根柱表示一天,点击或按 Enter / 空格筛选。四类 Token 互斥堆叠;金额模式仅显示已知小计。灰色底标为无记录,不能断言未使用;黄色底标为未完整定价。截止日可能尚未结束。',
|
|
43
|
+
request: '占比按含缓存 Token 计算,不是请求次数。整次请求归入非工具、含工具、其他三类之一,合计等于总量,不逐工具分摊。文字与工具混合仍归含工具。已接入 Pi / OMP、Claude、Codex、ZCode、Kimi 的可靠响应证据;缺边界、累计回退或关联冲突等保留为其他,不等于非工具。',
|
|
44
|
+
quantile: 'Min / P25 / P50 / P75 / P90 / Max 使用同一样本,分位采用线性插值。默认只纳入有用量日;将无记录日按 0 纳入是计算假设。小样本仅描述已有记录,不代表稳定规律;极值不是预算或预测边界。',
|
|
45
|
+
cost: '完整日金额分布排除未完整定价日期,可能造成样本偏差,不代表全部使用日。已知小计分布另列,不冒充完整日金额。可计价 Token 覆盖率不是账单覆盖率。',
|
|
46
|
+
simulation: '固定当前筛选每天的输入(含缓存写)、缓存读取、输出、推理数量,分别按本报告用过的模型费率逐日计价,再求 Min / 分位 / Max。原模型未定价不影响目标模型计价;目标费率不足或无样本不显示 0。假设 Token 数不随模型变化,不是实际节省或质量预测。',
|
|
47
|
+
details: '当前 CSV 与页面相同,按日期 × Harness × 模型 × 项目 × 终端 × 请求类型聚合。knownCost 是已知小计,estimatedCost 仅在完整定价时有值,coverage 按含缓存 Token 计算。同目录 details.csv / usage.json 仍是原始粒度。',
|
|
48
|
+
};
|
|
49
|
+
function table(headers, rows) {
|
|
50
|
+
if (!rows.length) return '<p class="empty">没有可用数据,请调整筛选。</p>';
|
|
51
|
+
return `<table><thead><tr>${headers.map(h => `<th scope="col">${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(cell => `<td>${esc(cell)}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
|
52
|
+
}
|
|
53
|
+
function costText(row) {
|
|
54
|
+
if (!row.allTokens) return '无用量';
|
|
55
|
+
if (!row.pricedTokens) return '未定价';
|
|
56
|
+
return `${money(row.knownCost)}${row.unpricedRows ? ' · 已知小计' : ''}`;
|
|
57
|
+
}
|
|
58
|
+
function getFilters() { return Object.fromEntries(['from', 'to', ...DIMENSIONS].map(key => [key, $(key).value])); }
|
|
59
|
+
function query() { pageIndex = 0; showAll = false; render(); }
|
|
60
|
+
function openInfo(topic) {
|
|
61
|
+
$('help-message').hidden = !help[topic]; $('help-message').textContent = help[topic] ?? '';
|
|
62
|
+
$('information').showModal(); $('information').scrollTop = 0;
|
|
63
|
+
}
|
|
64
|
+
function switchView(view) {
|
|
65
|
+
if (!['overview', 'percentiles', 'simulation', 'records'].includes(view)) return;
|
|
66
|
+
activeView = view;
|
|
67
|
+
for (const node of document.querySelectorAll('.view')) node.hidden = node.id !== view;
|
|
68
|
+
for (const button of document.querySelectorAll('.views [data-view]')) button.setAttribute('aria-pressed', String(button.dataset.view === view));
|
|
69
|
+
// Replace rather than push: exploration does not flood the browser's back stack.
|
|
70
|
+
history.replaceState(null, '', `#${view}`);
|
|
71
|
+
if (view === 'overview' && calendar.length) renderTrend(calendar);
|
|
72
|
+
}
|
|
73
|
+
$('from').value = DATA.from; $('to').value = DATA.to;
|
|
74
|
+
for (const key of ['from', 'to']) { $(key).min = DATA.from; $(key).max = DATA.to; }
|
|
75
|
+
for (const key of DIMENSIONS) {
|
|
76
|
+
const values = key === 'requestType' ? Object.keys(REQUEST_TYPES) : [...new Set(DATA.buckets.map(row => row[key]))].sort();
|
|
77
|
+
for (const value of values) $(key).add(new Option(dimensionValue(key, value), value));
|
|
78
|
+
}
|
|
79
|
+
const generated = new Date(DATA.generatedAt).toLocaleString('zh-CN', { timeZone: DATA.timeZone });
|
|
80
|
+
$('metadata').textContent = `${DATA.from} — ${DATA.to} · ${DATA.timeZone} · 数据快照 ${generated} · 价格快照 ${DATA.priceSnapshot.date} · 本地覆盖 ${DATA.priceSnapshot.overrideCount} 项`;
|
|
81
|
+
$('footer-meta').textContent = `${DATA.timeZone} · ${generated}`;
|
|
82
|
+
const incomplete = DATA.statuses.filter(s => ['partial', 'error'].includes(s.state) || s.warningCount > 0);
|
|
83
|
+
$('snapshot-label').textContent = incomplete.length ? `${incomplete.length} 个来源需注意` : '本地快照';
|
|
84
|
+
$('snapshot').classList.toggle('has-warning', incomplete.length > 0);
|
|
85
|
+
if (incomplete.length) {
|
|
86
|
+
$('warnings').hidden = false;
|
|
87
|
+
$('warnings').textContent = `${incomplete.map(s => s.source).join('、')} 存在读取失败、未完成索引或警告。数据可能不完整;无记录不代表未使用。`;
|
|
88
|
+
}
|
|
89
|
+
$('source-status').innerHTML = table(['Harness', '状态', '原始记录(全历史)', '会话(全历史)', '说明'], DATA.statuses.map(s => [s.source, ({ ok: '已读取', empty: '无记录', partial: '部分数据', error: '读取失败' })[s.state] ?? s.state, s.buckets, s.sessions, s.note]));
|
|
90
|
+
$('price-table').innerHTML = table(['模型', '输入 $/M', '缓存读 $/M', '输出 $/M', '推理 $/M', '提供方', '出处', '备注'], usedModels.map(model => {
|
|
91
|
+
const r = findRate(model, DATA.prices);
|
|
92
|
+
return [model, r?.input, r?.cacheRead, r?.output, r?.reasoning, r?.provider ?? '未识别', r?.reference ?? '请添加本地覆盖', r?.tiered ? '仅基础档;上下文阶梯未展开' : '基础费率'];
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
function renderTrend(days) {
|
|
96
|
+
const field = $('trend-metric').value, cost = field === 'cost', stacked = cost || field === 'tokens';
|
|
97
|
+
const keys = cost ? ['inputCost', 'cacheCost', 'outputCost', 'reasoningCost'] : field === 'tokens' ? TOKEN_FIELDS : [field];
|
|
98
|
+
const total = day => cost ? day.knownCost : field === 'tokens' ? day.allTokens : day[field];
|
|
99
|
+
const width = Math.max(300, $('trend').clientWidth), height = 208, left = 54, top = 12, plotH = 165, plotW = width - left - 14;
|
|
100
|
+
const maximum = Math.max(1, ...days.map(total)), step = plotW / Math.max(1, days.length);
|
|
101
|
+
let svg = `<svg class="chart" viewBox="0 0 ${width} ${height}" role="group" aria-label="每日${cost ? '已知估算金额' : 'Token'},点击或键盘选择日期">`;
|
|
102
|
+
for (let i = 0; i <= 3; i++) {
|
|
103
|
+
const y = top + plotH * i / 3;
|
|
104
|
+
svg += `<line class="gridline" x1="${left}" x2="${width - 14}" y1="${y}" y2="${y}"/><text x="${left - 8}" y="${y + 3}" text-anchor="end">${esc(num(maximum * (1 - i / 3)))}</text>`;
|
|
105
|
+
}
|
|
106
|
+
const tickCount = Math.min(days.length, Math.max(2, Math.min(7, Math.floor(plotW / 70))));
|
|
107
|
+
const ticks = new Set(Array.from({ length: tickCount }, (_, i) => Math.round(i * (days.length - 1) / Math.max(1, tickCount - 1))));
|
|
108
|
+
days.forEach((day, i) => {
|
|
109
|
+
const title = `${day.name} · ${!day.recorded ? '无记录' : cost ? costText(day) : `${full(total(day))} Token`}\n${TOKEN_FIELDS.map(key => `${labels[key]} ${full(day[key])}`).join(' · ')}`;
|
|
110
|
+
const x = left + i * step + 0.5, w = Math.max(0.2, step - 1);
|
|
111
|
+
svg += `<g role="button" tabindex="0" data-date="${day.name}" aria-label="${esc(title)},筛选此日"><title>${esc(title)}</title><rect class="hit" x="${x}" y="${top}" width="${w}" height="${plotH}" fill="transparent"/>`;
|
|
112
|
+
let y = top + plotH;
|
|
113
|
+
for (const [j, key] of keys.entries()) {
|
|
114
|
+
const h = day[key] / maximum * plotH; y -= h;
|
|
115
|
+
svg += `<rect pointer-events="none" x="${x}" y="${y}" width="${w}" height="${h}" fill="${colors[stacked ? j : TOKEN_FIELDS.indexOf(field)]}"/>`;
|
|
116
|
+
}
|
|
117
|
+
if (!day.recorded || (cost && day.unpricedRows)) svg += `<rect x="${x}" y="${top + plotH + 3}" width="${w}" height="3" fill="${day.recorded ? '#b57826' : '#b4bfce'}"/>`;
|
|
118
|
+
svg += '</g>';
|
|
119
|
+
if (ticks.has(i)) svg += `<text x="${x}" y="202" text-anchor="${i === 0 ? 'start' : i === days.length - 1 ? 'end' : 'middle'}">${day.name.slice(5)}</text>`;
|
|
120
|
+
});
|
|
121
|
+
$('trend').innerHTML = svg + '</svg>';
|
|
122
|
+
$('trend-legend').innerHTML = (stacked ? TOKEN_FIELDS : [field]).map(key => `<span><i style="background:${colors[TOKEN_FIELDS.indexOf(key)]}"></i>${esc(labels[key])}${cost ? '金额' : ''}</span>`).join('');
|
|
123
|
+
$('daily-table').innerHTML = `<table><thead><tr><th>日期</th><th>Token(含缓存)</th><th>已知估算金额</th><th>可计价 Token</th></tr></thead><tbody>${days.map(day => `<tr><td><button data-date="${day.name}">${day.name}</button></td><td>${day.recorded ? full(day.allTokens) : '无记录'}</td><td>${esc(day.recorded ? costText(day) : '无记录')}</td><td>${percent(day.coverage)}</td></tr>`).join('')}</tbody></table>`;
|
|
124
|
+
}
|
|
125
|
+
function rankMarkup(groups, key, field, limit) {
|
|
126
|
+
const cost = field === 'knownCost', maximum = Math.max(1, ...groups.map(row => row[field])), total = groups.reduce((n, row) => n + row[field], 0);
|
|
127
|
+
return groups.slice(0, limit).map(row => {
|
|
128
|
+
const label = dimensionValue(key, row.name), title = `${label}\n${labels[field]}:${full(row[field])}\n估算:${costText(row)} · 可计价 Token ${percent(row.coverage)}\n点击筛选`;
|
|
129
|
+
const segments = field === 'allTokens' ? TOKEN_FIELDS.map((k, i) => `<span style="width:${row[k] / maximum * 100}%;background:${colors[i]}"></span>`).join('') : `<span style="width:${row[field] / maximum * 100}%;background:${colors[TOKEN_FIELDS.indexOf(field)] ?? colors[0]}"></span>`;
|
|
130
|
+
return `<button class="rank-row" data-dimension="${key}" data-value="${esc(row.name)}" title="${esc(title)}" aria-label="筛选${dimensions[key]}:${esc(label)};${cost ? esc(costText(row)) : `${full(row[field])} Token`}"><span class="rank-labels"><span class="rank-name">${esc(label)}</span><span class="rank-value number">${esc(cost ? costText(row) : num(row[field]))}</span><span class="rank-share number">${cost && !row.pricedTokens ? '—' : total ? percent(row[field] / total) : '—'}</span></span><span class="rank-track" aria-hidden="true">${segments}</span></button>`;
|
|
131
|
+
}).join('') || '<p class="empty">没有可用数据</p>';
|
|
132
|
+
}
|
|
133
|
+
function renderDistributions(rows) {
|
|
134
|
+
const key = $('distribution-dimension').value, field = $('distribution-metric').value;
|
|
135
|
+
const groups = groupRows(rows, key).sort((a, b) => b[field] - a[field] || a.name.localeCompare(b.name));
|
|
136
|
+
$('distributions').innerHTML = rankMarkup(groups, key, field, showAll ? groups.length : 5);
|
|
137
|
+
$('ranking-note').textContent = `${showAll ? groups.length : Math.min(5, groups.length)} / ${groups.length} 项${field === 'knownCost' ? ' · 占已知小计' : ' · 点击下钻'}`;
|
|
138
|
+
$('show-all').hidden = groups.length <= 5;
|
|
139
|
+
$('show-all').textContent = showAll ? '前 5 项' : `全部 ${groups.length} 项`;
|
|
140
|
+
$('show-all').setAttribute('aria-expanded', String(showAll));
|
|
141
|
+
const harness = groupRows(rows, 'source').sort((a, b) => b.allTokens - a.allTokens);
|
|
142
|
+
$('harness-ranking').innerHTML = rankMarkup(harness, 'source', 'allTokens', harness.length);
|
|
143
|
+
$('harness-count').textContent = `${harness.length} 项`;
|
|
144
|
+
}
|
|
145
|
+
function renderRequests(rows, sum, filters) {
|
|
146
|
+
const groups = groupRows(rows, 'requestType');
|
|
147
|
+
const values = Object.keys(REQUEST_TYPES).map(key => groups.find(r => r.name === key)?.allTokens ?? 0);
|
|
148
|
+
const classified = sum.allTokens ? (values[0] + values[1]) / sum.allTokens : null;
|
|
149
|
+
let offset = 0;
|
|
150
|
+
const arcs = values.map((value, i) => {
|
|
151
|
+
const part = sum.allTokens ? value / sum.allTokens * 100 : 0;
|
|
152
|
+
const arc = `<circle cx="70" cy="70" r="55" fill="none" stroke="${typeColors[i]}" stroke-width="12" pathLength="100" stroke-dasharray="${part} ${100 - part}" stroke-dashoffset="${-offset}" transform="rotate(-90 70 70)"/>`;
|
|
153
|
+
offset += part; return arc;
|
|
154
|
+
}).join('');
|
|
155
|
+
$('request-ring').innerHTML = `<svg viewBox="0 0 140 140" role="img" aria-label="可分类 Token ${percent(classified)},不是请求次数"><circle cx="70" cy="70" r="55" fill="none" stroke="#edf1f6" stroke-width="12"/>${arcs}<text x="70" y="70" text-anchor="middle" class="ring-center">${percent(classified)}</text><text x="70" y="89" text-anchor="middle" class="ring-caption">可分类 Token</text></svg>`;
|
|
156
|
+
const short = ['非工具调用', '含工具调用', '其他 / 未判定'];
|
|
157
|
+
$('request-summary').innerHTML = Object.entries(REQUEST_TYPES).map(([value, label], i) => `<button data-request-type="${value}" aria-pressed="${filters.requestType === value}" aria-label="${label},${full(values[i])} Token,点击切换筛选" title="${label} · ${full(values[i])} Token"><span class="request-label"><i style="background:${typeColors[i]}"></i>${short[i]}</span><strong>${sum.allTokens ? percent(values[i] / sum.allTokens) : '—'}</strong><span class="request-track" aria-hidden="true"><span style="width:${sum.allTokens ? values[i] / sum.allTokens * 100 : 0}%;background:${typeColors[i]}"></span></span></button>`).join('');
|
|
158
|
+
$('classification-note').textContent = '含缓存 Token 占比 · 非请求次数';
|
|
159
|
+
}
|
|
160
|
+
function rangeChart(row, maximum = row.max, formatter = num) {
|
|
161
|
+
if (row.min == null) return '<div class="range-empty">无可用样本</div>';
|
|
162
|
+
const scale = value => 10 + value / Math.max(maximum, 1e-9) * 280;
|
|
163
|
+
const [min, p25, p50, p75, p90, max] = rangeValues(row).map(scale);
|
|
164
|
+
const title = rangeValues(row).map((v, i) => `${rangeLabels[i]} ${formatter(v)}`).join(' · ');
|
|
165
|
+
return `<svg class="range-chart" viewBox="0 0 300 36" preserveAspectRatio="none" role="img" aria-label="${esc(title)}"><title>${esc(title)}</title><path class="range-line" d="M${min} 18H${max}M${min} 13v10M${max} 13v10"/><rect class="range-box" x="${p25}" y="11" width="${Math.max(.5, p75 - p25)}" height="14" rx="2"/><circle class="range-median" cx="${p50}" cy="18" r=".1"/><path class="range-p90" d="m${p90} 14 4 4-4 4-4-4Z"/></svg>`;
|
|
166
|
+
}
|
|
167
|
+
function sixStats(row, formatter = num) {
|
|
168
|
+
return rangeValues(row).map((value, i) => `<div title="${rangeLabels[i]}:${full(value)}"><span>${rangeLabels[i]}</span><strong class="${i === 2 ? 'median' : ''}">${esc(formatter(value))}</strong></div>`).join('');
|
|
169
|
+
}
|
|
170
|
+
function renderHistogram(days) {
|
|
171
|
+
if (!days.length) { $('daily-histogram').innerHTML = '<svg class="chart" viewBox="0 0 300 68" role="img" aria-label="无样本日"><line class="gridline" x1="0" x2="300" y1="45" y2="45"/></svg>'; return; }
|
|
172
|
+
const values = days.map(d => d.allTokens), max = Math.max(1, ...values), bins = Array(16).fill(0);
|
|
173
|
+
for (const value of values) bins[Math.min(15, Math.floor(value / max * 16))]++;
|
|
174
|
+
const peak = Math.max(1, ...bins);
|
|
175
|
+
$('daily-histogram').innerHTML = `<svg class="chart" viewBox="0 0 300 68" role="img" aria-label="每日含缓存 Token 频数分布,横轴用量,纵轴天数">${bins.map((count, i) => `<rect x="${i * 18.75 + 1}" y="${45 - count / peak * 40}" width="16" height="${count / peak * 40}" fill="#85aace"><title>${num(i / 16 * max)}–${num((i + 1) / 16 * max)} Token:${count} 天</title></rect>`).join('')}<text x="0" y="64">0</text><text x="150" y="64" text-anchor="middle">Token / 日 · 频数</text><text x="300" y="64" text-anchor="end">${num(max)}</text></svg>`;
|
|
176
|
+
}
|
|
177
|
+
function renderPercentiles(days) {
|
|
178
|
+
const quantiles = percentileRows(days), tokenRows = quantiles.filter(row => row.metric !== 'estimatedCost');
|
|
179
|
+
const all = tokenRows.find(row => row.metric === 'allTokens');
|
|
180
|
+
$('daily-median').textContent = num(all.values[1]);
|
|
181
|
+
$('daily-sample').textContent = `${days.length} 样本日${$('include-zero').checked ? ' · 含假设零日' : ''}`;
|
|
182
|
+
$('daily-range').innerHTML = rangeChart(all);
|
|
183
|
+
$('daily-stats').innerHTML = sixStats(all);
|
|
184
|
+
renderHistogram(days);
|
|
185
|
+
$('sample-note').textContent = `${$('include-zero').checked ? '日历日 · 无记录假设为 0' : '仅有用量日'} · ${days.length} 个样本${days.length < 7 ? ' · 小样本' : ''}`;
|
|
186
|
+
$('quantile-charts').innerHTML = tokenRows.map(row => `<div class="quantile-row"><div class="quantile-title"><span>${labels[row.metric]}</span><span>${row.sampleDays} 天</span></div>${rangeChart(row)}<div class="six-stats">${sixStats(row)}</div></div>`).join('');
|
|
187
|
+
const headings = ['指标', '样本日', ...rangeLabels];
|
|
188
|
+
$('quantiles').innerHTML = table(headings, tokenRows.map(row => [labels[row.metric], row.sampleDays, ...rangeValues(row).map(full)]));
|
|
189
|
+
const complete = quantiles.find(row => row.metric === 'estimatedCost'), excluded = days.length - complete.sampleDays;
|
|
190
|
+
$('cost-sample-note').textContent = `完整定价 ${complete.sampleDays} / ${days.length} 天 · 排除 ${excluded} 天。${excluded ? '排除日期可能造成样本偏差,不代表全部使用日。' : '公开费率估算,非实际账单。'}`;
|
|
191
|
+
$('cost-range').innerHTML = rangeChart(complete, complete.max, money);
|
|
192
|
+
$('cost-stats').innerHTML = sixStats(complete, value => value == null ? '—' : num(value));
|
|
193
|
+
$('cost-quantiles').innerHTML = table(headings, [
|
|
194
|
+
['完整日金额(USD)', complete.sampleDays, ...rangeValues(complete).map(v => v == null ? '无完整定价样本' : money(v))],
|
|
195
|
+
['已知小计(非完整金额)', days.length, ...[0, ...QUANTILES, 1].map(p => days.some(d => d.pricedTokens > 0) ? money(quantile(days.map(d => d.knownCost), p)) : '无可计价用量')],
|
|
196
|
+
]);
|
|
197
|
+
}
|
|
198
|
+
function renderSimulations() {
|
|
199
|
+
const sort = $('simulation-sort').value, value = row => sort === 'p50' ? row.values[1] : sort === 'p90' ? row.values[3] : row[sort];
|
|
200
|
+
const rows = simulateModels(currentDays, usedModels, DATA.prices).sort((a, b) => (value(a) ?? Infinity) - (value(b) ?? Infinity) || a.model.localeCompare(b.model));
|
|
201
|
+
const maximum = Math.max(1e-9, ...rows.map(row => row.max ?? 0));
|
|
202
|
+
$('simulation-sample').textContent = `${currentDays.length} 样本日 · ${$('include-zero').checked ? '含假设零日' : '仅有用量日'} ↗`;
|
|
203
|
+
$('simulation-note').textContent = `${rows.filter(r => r.min != null).length} / ${rows.length} 个模型可计价 · 统一线性刻度`;
|
|
204
|
+
$('simulations').innerHTML = `<table><thead><tr>${['目标模型', '日金额区间', '样本日', ...rangeLabels].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr><td>${esc(row.model)}</td><td>${row.min == null ? `<span class="unavailable">${currentDays.length ? '目标费率不足' : '无样本'}</span>` : rangeChart(row, maximum, money)}</td><td>${row.sampleDays}</td>${rangeValues(row).map(v => `<td title="${esc(v == null ? '不可计价' : `${full(v)} USD`)}">${v == null ? '—' : money(v)}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
|
205
|
+
}
|
|
206
|
+
function renderDetails() {
|
|
207
|
+
const sorted = [...currentRows].sort((a, b) => $('sort').value === 'cost' ? (b.pricedTokens ? b.knownCost : -1) - (a.pricedTokens ? a.knownCost : -1) : $('sort').value === 'tokens' ? b.allTokens - a.allTokens : b.date.localeCompare(a.date));
|
|
208
|
+
const pages = Math.max(1, Math.ceil(sorted.length / 50)); pageIndex = Math.min(pageIndex, pages - 1);
|
|
209
|
+
$('details').innerHTML = table(['日期', 'Harness', '模型', '项目', '终端', '请求类型', '输入(含缓存写)', '缓存读', '输出', '推理', 'Token(含缓存)', 'Token(不含缓存)', '已知估算金额', '可计价 Token'], sorted.slice(pageIndex * 50, (pageIndex + 1) * 50).map(row => [row.date, ...DIMENSIONS.map(key => dimensionValue(key, row[key])), ...TOKEN_FIELDS.map(key => full(row[key])), full(row.allTokens), full(row.totalTokens), costText(row), percent(row.coverage)]));
|
|
210
|
+
$('page-status').textContent = `${pageIndex + 1} / ${pages} · ${full(sorted.length)} 条`;
|
|
211
|
+
$('previous').disabled = pageIndex === 0; $('next').disabled = pageIndex >= pages - 1;
|
|
212
|
+
}
|
|
213
|
+
function render() {
|
|
214
|
+
const filters = getFilters();
|
|
215
|
+
const valid = filters.from && filters.to && filters.from <= filters.to && filters.from >= DATA.from && filters.to <= DATA.to;
|
|
216
|
+
for (const id of ['from', 'to']) $(id).setAttribute('aria-invalid', String(!valid));
|
|
217
|
+
if (!valid) {
|
|
218
|
+
$('filter-status').textContent = `请选择 ${DATA.from} 至 ${DATA.to} 内有效日期;保留上次有效结果。`;
|
|
219
|
+
$('export').disabled = true;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
$('export').disabled = false;
|
|
223
|
+
currentFilters = filters;
|
|
224
|
+
const { buckets } = selectData(DATA, filters);
|
|
225
|
+
currentRows = detailRows(buckets);
|
|
226
|
+
const sum = summarize(buckets), calendarDays = dailyRows(buckets, filters.from, filters.to, true), activeDays = calendarDays.filter(day => day.allTokens > 0);
|
|
227
|
+
calendar = calendarDays;
|
|
228
|
+
currentDays = $('include-zero').checked ? calendarDays : activeDays;
|
|
229
|
+
$('filter-status').textContent = `${filters.from} — ${filters.to} · ${activeDays.length} / ${calendarDays.length} 天有用量 · ${full(currentRows.length)} 条日明细`;
|
|
230
|
+
$('activity-count').textContent = `${activeDays.length} / ${calendarDays.length} 天有用量`;
|
|
231
|
+
$('empty-state').hidden = sum.allTokens > 0;
|
|
232
|
+
$('breadcrumbs').innerHTML = (filters.from !== DATA.from || filters.to !== DATA.to ? `<button data-clear="dates" aria-label="移除日期筛选">${filters.from} — ${filters.to}${icon('close')}</button>` : '') + DIMENSIONS.filter(key => filters[key]).map(key => `<button data-clear="${key}" title="${esc(dimensionValue(key, filters[key]))}" aria-label="移除${dimensions[key]}筛选:${esc(dimensionValue(key, filters[key]))}"><span class="chip-name">${dimensions[key]}:${esc(dimensionValue(key, filters[key]))}</span>${icon('close')}</button>`).join('');
|
|
233
|
+
if ($('breadcrumbs').childElementCount) $('breadcrumbs').insertAdjacentHTML('beforeend', '<button data-clear="all">清除全部</button>');
|
|
234
|
+
for (const button of document.querySelectorAll('[data-days]')) {
|
|
235
|
+
const from = button.dataset.days === 'all' ? DATA.from : [DATA.from, shiftDate(DATA.to, 1 - Number(button.dataset.days))].sort().at(-1);
|
|
236
|
+
button.setAttribute('aria-pressed', String(filters.from === from && filters.to === DATA.to && (button.dataset.days === 'all' || from !== DATA.from)));
|
|
237
|
+
}
|
|
238
|
+
$('total-value').textContent = num(sum.allTokens); $('total-value').title = `${full(sum.allTokens)} Token(含缓存)`;
|
|
239
|
+
$('total-value').dataset.value = String(sum.allTokens);
|
|
240
|
+
$('net-value').textContent = `${num(sum.totalTokens)} 不含缓存`; $('net-value').title = `${full(sum.totalTokens)} Token(不含缓存)`;
|
|
241
|
+
$('token-ribbon').innerHTML = TOKEN_FIELDS.map((key, i) => `<span style="width:${sum.allTokens ? sum[key] / sum.allTokens * 100 : 0}%;background:${colors[i]}"></span>`).join('');
|
|
242
|
+
$('composition').innerHTML = TOKEN_FIELDS.map((key, i) => `<div class="token-part"><span><i style="background:${colors[i]}"></i>${shortLabels[i]}</span><strong class="number" title="${full(sum[key])} Token">${num(sum[key])}</strong><small>${sum.allTokens ? percent(sum[key] / sum.allTokens) : '—'}</small></div>`).join('');
|
|
243
|
+
$('cost-label').textContent = sum.unpricedRows ? '估算已知小计 · USD' : '估算 · USD';
|
|
244
|
+
$('cost-value').textContent = !sum.allTokens ? '—' : !sum.pricedTokens ? '未定价' : money(sum.knownCost);
|
|
245
|
+
$('cost-value').title = `${costText(sum)};不是实际扣款`;
|
|
246
|
+
$('coverage-bar').style.width = `${(sum.coverage ?? 0) * 100}%`;
|
|
247
|
+
$('coverage-value').textContent = `${percent(sum.coverage)} Token 可计价`;
|
|
248
|
+
const unpriced = groupRows(buckets, 'model').filter(row => row.unpricedRows);
|
|
249
|
+
$('unpriced').hidden = !unpriced.length;
|
|
250
|
+
$('unpriced-models').innerHTML = table(['模型', '未定价 Token', '已知估算金额', '可计价 Token'], unpriced.map(row => [row.name, full(row.allTokens - row.pricedTokens), costText(row), percent(row.coverage)]));
|
|
251
|
+
renderDistributions(buckets); renderRequests(buckets, sum, filters); renderTrend(calendarDays); renderPercentiles(currentDays); renderSimulations(); renderDetails();
|
|
252
|
+
}
|
|
253
|
+
for (const id of ['from', 'to', ...DIMENSIONS, 'include-zero', 'trend-metric', 'distribution-metric', 'distribution-dimension']) $(id).addEventListener('change', query);
|
|
254
|
+
$('show-all').addEventListener('click', () => { showAll = !showAll; render(); });
|
|
255
|
+
for (const id of ['distributions', 'harness-ranking']) $(id).addEventListener('click', event => {
|
|
256
|
+
const button = event.target.closest('[data-dimension]');
|
|
257
|
+
if (button) { $(button.dataset.dimension).value = button.dataset.value; query(); $(button.dataset.dimension).focus(); }
|
|
258
|
+
});
|
|
259
|
+
$('breadcrumbs').addEventListener('click', event => {
|
|
260
|
+
const button = event.target.closest('[data-clear]');
|
|
261
|
+
if (!button) return;
|
|
262
|
+
if (button.dataset.clear === 'all') { $('reset').click(); $('reset').focus(); return; }
|
|
263
|
+
if (button.dataset.clear === 'dates') { $('from').value = DATA.from; $('to').value = DATA.to; }
|
|
264
|
+
else $(button.dataset.clear).value = '';
|
|
265
|
+
query(); $('reset').focus();
|
|
266
|
+
});
|
|
267
|
+
function selectDay(event) {
|
|
268
|
+
const target = event.target.closest('[data-date]');
|
|
269
|
+
if (!target || (event.type === 'keydown' && !['Enter', ' '].includes(event.key))) return;
|
|
270
|
+
event.preventDefault(); $('from').value = target.dataset.date; $('to').value = target.dataset.date;
|
|
271
|
+
query(); $('from').focus();
|
|
272
|
+
}
|
|
273
|
+
$('trend').addEventListener('click', selectDay); $('trend').addEventListener('keydown', selectDay);
|
|
274
|
+
$('daily-table').addEventListener('click', selectDay);
|
|
275
|
+
$('request-summary').addEventListener('click', event => {
|
|
276
|
+
const button = event.target.closest('[data-request-type]');
|
|
277
|
+
if (!button) return;
|
|
278
|
+
$('requestType').value = $('requestType').value === button.dataset.requestType ? '' : button.dataset.requestType;
|
|
279
|
+
query(); $('requestType').focus();
|
|
280
|
+
});
|
|
281
|
+
$('sort').addEventListener('change', () => { pageIndex = 0; renderDetails(); });
|
|
282
|
+
$('simulation-sort').addEventListener('change', renderSimulations);
|
|
283
|
+
$('previous').addEventListener('click', () => { pageIndex--; renderDetails(); });
|
|
284
|
+
$('next').addEventListener('click', () => { pageIndex++; renderDetails(); });
|
|
285
|
+
for (const button of document.querySelectorAll('[data-days]')) button.addEventListener('click', () => {
|
|
286
|
+
$('to').value = DATA.to;
|
|
287
|
+
$('from').value = button.dataset.days === 'all' ? DATA.from : [DATA.from, shiftDate(DATA.to, 1 - Number(button.dataset.days))].sort().at(-1);
|
|
288
|
+
query();
|
|
289
|
+
});
|
|
290
|
+
$('reset').addEventListener('click', () => {
|
|
291
|
+
for (const key of DIMENSIONS) $(key).value = '';
|
|
292
|
+
$('from').value = DATA.from; $('to').value = DATA.to; query();
|
|
293
|
+
});
|
|
294
|
+
for (const button of document.querySelectorAll('[data-view]')) button.addEventListener('click', () => {
|
|
295
|
+
switchView(button.dataset.view);
|
|
296
|
+
document.querySelector(`.views [data-view="${activeView}"]`).focus();
|
|
297
|
+
});
|
|
298
|
+
window.addEventListener('hashchange', () => switchView(location.hash.slice(1)));
|
|
299
|
+
window.addEventListener('resize', () => { if (activeView === 'overview') renderTrend(calendar); });
|
|
300
|
+
for (const button of document.querySelectorAll('[data-help]')) button.addEventListener('click', () => openInfo(button.dataset.help));
|
|
301
|
+
$('info').addEventListener('click', () => openInfo()); $('snapshot').addEventListener('click', () => openInfo());
|
|
302
|
+
$('close-info').addEventListener('click', () => $('information').close());
|
|
303
|
+
$('print').addEventListener('click', () => window.print());
|
|
304
|
+
$('export').addEventListener('click', () => {
|
|
305
|
+
const columns = ['date', ...DIMENSIONS, ...TOKEN_FIELDS, 'totalTokens', 'allTokens', 'knownCost', 'estimatedCost', 'coverage'];
|
|
306
|
+
const url = URL.createObjectURL(new Blob([toCsv(currentRows, columns)], { type: 'text/csv;charset=utf-8' }));
|
|
307
|
+
const anchor = document.createElement('a'); anchor.href = url; anchor.download = `pi-usage-daily-${currentFilters.from}-${currentFilters.to}.csv`;
|
|
308
|
+
document.body.append(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
309
|
+
});
|
|
310
|
+
render(); switchView(location.hash.slice(1) || 'overview');
|