token-studio 4.8.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/.nvmrc +1 -0
- package/CHANGELOG.md +89 -0
- package/Dockerfile +17 -0
- package/LICENSE +22 -0
- package/NOTICE.md +21 -0
- package/PRIVACY.md +68 -0
- package/README.en.md +220 -0
- package/README.md +220 -0
- package/config/collectors.json +54 -0
- package/data/.gitkeep +1 -0
- package/docker-compose.yml +17 -0
- package/docs/assets/.gitkeep +1 -0
- package/docs/assets/token-studio-v44-dashboard.png +0 -0
- package/docs/assets/token-studio-v44-live.png +0 -0
- package/docs/assets/token-studio-v44-review-mobile.png +0 -0
- package/docs/assets/token-studio-v44-review.png +0 -0
- package/docs/assets/token-studio-v45-dashboard.png +0 -0
- package/docs/assets/token-studio-v45-live.png +0 -0
- package/docs/assets/token-studio-v45-review-mobile.png +0 -0
- package/docs/assets/token-studio-v45-review.png +0 -0
- package/docs/blog-case-study.md +34 -0
- package/docs/collector-support-matrix.md +65 -0
- package/docs/competitive-notes.md +87 -0
- package/docs/demo-data/README.md +12 -0
- package/docs/demo-data/token-studio-v2-demo.json +146 -0
- package/docs/demo-flow.md +39 -0
- package/docs/first-run.md +95 -0
- package/docs/local-collectors.md +49 -0
- package/docs/public-launch-checklist.md +45 -0
- package/docs/resume-bullets.md +7 -0
- package/docs/statusline.md +52 -0
- package/index.html +16 -0
- package/package.json +36 -0
- package/render.yaml +17 -0
- package/src/auto-attribution.mjs +396 -0
- package/src/ccusage-bridge.mjs +74 -0
- package/src/ccusage-import.mjs +415 -0
- package/src/cli.mjs +643 -0
- package/src/client/dashboard/App.jsx +1734 -0
- package/src/client/dashboard/annotation-presets.js +138 -0
- package/src/client/dashboard/attribution.js +328 -0
- package/src/client/dashboard/components-charts.jsx +622 -0
- package/src/client/dashboard/components-tables.jsx +1531 -0
- package/src/client/dashboard/components-top.jsx +307 -0
- package/src/client/dashboard/import-budget.js +41 -0
- package/src/client/dashboard/model-usage.js +108 -0
- package/src/client/dashboard/onboarding.js +80 -0
- package/src/client/dashboard/styles.css +2606 -0
- package/src/client/live/LiveApp.jsx +226 -0
- package/src/client/live/styles.css +446 -0
- package/src/client/main.jsx +20 -0
- package/src/client/review/ReviewApp.jsx +507 -0
- package/src/client/review/closure-progress.js +165 -0
- package/src/client/review/markdown-report.js +401 -0
- package/src/client/review/model-strategy.js +273 -0
- package/src/client/review/roi-advisor.js +255 -0
- package/src/client/review/roi-evidence.js +78 -0
- package/src/client/review/savings-simulator.js +252 -0
- package/src/client/review/sections-1.jsx +277 -0
- package/src/client/review/sections-2.jsx +927 -0
- package/src/client/review/styles.css +2321 -0
- package/src/client/review/utils.js +345 -0
- package/src/client/shared/utils.js +236 -0
- package/src/closure-check.mjs +537 -0
- package/src/closure-import.mjs +646 -0
- package/src/collect.mjs +247 -0
- package/src/collector-config.mjs +82 -0
- package/src/collector-registry.mjs +333 -0
- package/src/collectors/claude-code.mjs +355 -0
- package/src/collectors/codex.mjs +418 -0
- package/src/collectors/copilot.mjs +19 -0
- package/src/collectors/cursor.mjs +23 -0
- package/src/collectors/gemini.mjs +530 -0
- package/src/collectors/goose.mjs +15 -0
- package/src/collectors/hermes.mjs +206 -0
- package/src/collectors/kimi.mjs +15 -0
- package/src/collectors/openclaw.mjs +400 -0
- package/src/collectors/opencode.mjs +349 -0
- package/src/collectors/qwen.mjs +15 -0
- package/src/collectors/structured-usage.mjs +437 -0
- package/src/collectors/utils.mjs +93 -0
- package/src/db.mjs +1397 -0
- package/src/demo-seed.mjs +39 -0
- package/src/dev.mjs +43 -0
- package/src/live.mjs +428 -0
- package/src/model-policy.mjs +147 -0
- package/src/pricing.mjs +434 -0
- package/src/privacy-check.mjs +126 -0
- package/src/server.mjs +1240 -0
- package/src/source-health.mjs +195 -0
- package/src/statusline.mjs +156 -0
- package/src/terminal-report.mjs +245 -0
- package/src/update-pricing.mjs +8 -0
- package/test/annotation-presets.test.mjs +137 -0
- package/test/api-annotations.test.mjs +202 -0
- package/test/api-auto-attribution.test.mjs +169 -0
- package/test/api-source-health.test.mjs +109 -0
- package/test/api-v2.test.mjs +278 -0
- package/test/api-v43.test.mjs +151 -0
- package/test/api-v44.test.mjs +128 -0
- package/test/attribution-summary.test.mjs +164 -0
- package/test/auto-attribution.test.mjs +116 -0
- package/test/ccusage-bridge.test.mjs +36 -0
- package/test/ccusage-import.test.mjs +93 -0
- package/test/cli-v43.test.mjs +64 -0
- package/test/cli-v45.test.mjs +34 -0
- package/test/cli-v46.test.mjs +129 -0
- package/test/cli-v47.test.mjs +98 -0
- package/test/closure-check.test.mjs +202 -0
- package/test/closure-import.test.mjs +263 -0
- package/test/collector-config.test.mjs +25 -0
- package/test/collector-registry.test.mjs +56 -0
- package/test/csv.test.mjs +19 -0
- package/test/db-annotations.test.mjs +186 -0
- package/test/db-v2.test.mjs +200 -0
- package/test/db-v4.test.mjs +178 -0
- package/test/experimental-collectors.test.mjs +103 -0
- package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
- package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
- package/test/fixtures/collectors/goose/usage.jsonl +2 -0
- package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
- package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
- package/test/import-budget.test.mjs +40 -0
- package/test/live.test.mjs +256 -0
- package/test/markdown-report.test.mjs +193 -0
- package/test/model-policy.test.mjs +34 -0
- package/test/model-strategy.test.mjs +116 -0
- package/test/model-usage.test.mjs +99 -0
- package/test/official-pricing.test.mjs +70 -0
- package/test/onboarding.test.mjs +55 -0
- package/test/privacy-check.test.mjs +33 -0
- package/test/review-closure-progress.test.mjs +99 -0
- package/test/roi-advisor.test.mjs +188 -0
- package/test/roi-evidence.test.mjs +48 -0
- package/test/roi-summary.test.mjs +101 -0
- package/test/savings-simulator.test.mjs +141 -0
- package/test/source-health.test.mjs +62 -0
- package/test/statusline.test.mjs +148 -0
- package/vite.config.js +23 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/* =============================================================
|
|
2
|
+
Filter bar, KPI cards, sparklines — top of dashboard
|
|
3
|
+
============================================================= */
|
|
4
|
+
|
|
5
|
+
import { useState, useEffect, useMemo, useRef } from 'react';
|
|
6
|
+
import { U } from '../shared/utils.js';
|
|
7
|
+
|
|
8
|
+
// ───────────────────────────────────────────────────────────────
|
|
9
|
+
// Topbar
|
|
10
|
+
// ───────────────────────────────────────────────────────────────
|
|
11
|
+
function Topbar({ lastSync, onRefresh, refreshing, onCollect, collecting, collectStatus, demoMode = false, onOpenImportBudget }) {
|
|
12
|
+
return (
|
|
13
|
+
<div className="topbar">
|
|
14
|
+
<div className="topbar-left">
|
|
15
|
+
<div className="brand">
|
|
16
|
+
<div className="brand-mark">TS</div>
|
|
17
|
+
<div>
|
|
18
|
+
<h1>Token Studio</h1>
|
|
19
|
+
<p className="brand-sub">个人 AI 工作流复盘 · 项目任务归因</p>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
<div className="page-switch">
|
|
23
|
+
<span className="page-chip active">看板</span>
|
|
24
|
+
<a href="/review" className="page-chip">复盘</a>
|
|
25
|
+
<a href="/live" className="page-chip">实时</a>
|
|
26
|
+
</div>
|
|
27
|
+
{demoMode && <span className="demo-mode-badge">Demo Mode</span>}
|
|
28
|
+
</div>
|
|
29
|
+
<div className="topbar-right">
|
|
30
|
+
{collectStatus && (
|
|
31
|
+
<div className={`collect-pill collect-${collectStatus.type}`} title={collectStatus.message}>
|
|
32
|
+
<span className="collect-dot"></span>
|
|
33
|
+
<span>{collectStatus.type === 'running' ? '采集中' : collectStatus.type === 'ok' ? '采集完成' : '采集失败'}</span>
|
|
34
|
+
</div>
|
|
35
|
+
)}
|
|
36
|
+
<div className="sync-pill">
|
|
37
|
+
<span className="sync-dot"></span>
|
|
38
|
+
<span>最后同步 <strong style={{color:'var(--text)', fontWeight:600}}>{lastSync}</strong></span>
|
|
39
|
+
</div>
|
|
40
|
+
<button className="btn" onClick={onOpenImportBudget} title="导入与预算">
|
|
41
|
+
<svg className="icon" viewBox="0 0 16 16" fill="none">
|
|
42
|
+
<path d="M3 4.5h10M3 8h10M3 11.5h6" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
|
|
43
|
+
<path d="M11.5 10v3M10 11.5h3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
|
|
44
|
+
</svg>
|
|
45
|
+
导入/预算
|
|
46
|
+
</button>
|
|
47
|
+
<button className={`btn btn-primary ${collecting ? 'loading' : ''}`} onClick={onCollect} disabled={collecting || refreshing}>
|
|
48
|
+
<svg className={`icon ${collecting ? 'spin' : ''}`} viewBox="0 0 16 16" fill="none" style={{opacity:1}}>
|
|
49
|
+
<path d="M4 6.5h8M4 9.5h8" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round"/>
|
|
50
|
+
<path d="M3.5 4.5c0-.83 2.01-1.5 4.5-1.5s4.5.67 4.5 1.5v7c0 .83-2.01 1.5-4.5 1.5s-4.5-.67-4.5-1.5v-7Z" stroke="currentColor" strokeWidth="1.35"/>
|
|
51
|
+
<circle cx="8" cy="8" r="1.25" fill="currentColor"/>
|
|
52
|
+
</svg>
|
|
53
|
+
{collecting ? '采集中' : '采集'}
|
|
54
|
+
</button>
|
|
55
|
+
<button className={`btn btn-primary ${refreshing ? 'loading' : ''}`} onClick={onRefresh}>
|
|
56
|
+
<svg className={`icon ${refreshing ? 'spin' : ''}`} viewBox="0 0 16 16" fill="none" style={{opacity:1}}>
|
|
57
|
+
<path d="M3 3v3h3M13 13v-3h-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
58
|
+
<path d="M13 7A5 5 0 0 0 4 5M3 9a5 5 0 0 0 9 2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
|
59
|
+
</svg>
|
|
60
|
+
{refreshing ? '同步中' : '刷新'}
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ───────────────────────────────────────────────────────────────
|
|
68
|
+
// Filter bar
|
|
69
|
+
// ───────────────────────────────────────────────────────────────
|
|
70
|
+
function FilterBar({ f, setF, allSources, allDevices, allModels, availableRange, onExport }) {
|
|
71
|
+
const RANGES = [
|
|
72
|
+
{ id: '7d', label: '7 天', days: 7 },
|
|
73
|
+
{ id: '14d', label: '14 天', days: 14 },
|
|
74
|
+
{ id: '30d', label: '30 天', days: 30 },
|
|
75
|
+
{ id: '90d', label: '90 天', days: 90 },
|
|
76
|
+
{ id: 'all', label: '全部' }
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
const setRange = (r) => {
|
|
80
|
+
if (r.id === 'all') {
|
|
81
|
+
setF({ ...f, rangeId: r.id, startDate: availableRange.startDate, endDate: availableRange.endDate });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
setF({ ...f, rangeId: r.id, startDate: U.daysAgo(r.days - 1), endDate: U.daysAgo(0) });
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const toggleSet = (key, value) => {
|
|
88
|
+
const next = new Set(f[key]);
|
|
89
|
+
if (next.has(value)) next.delete(value); else next.add(value);
|
|
90
|
+
setF({ ...f, [key]: next });
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const clearAll = () => {
|
|
94
|
+
setF({ ...f, sources: new Set(), devices: new Set(), models: new Set() });
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const filtersActive = f.sources.size + f.devices.size + f.models.size;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="filterbar">
|
|
101
|
+
<div className="filter-row filter-row-primary">
|
|
102
|
+
<div className="filter-group">
|
|
103
|
+
<span className="filter-label">时间</span>
|
|
104
|
+
<div className="chip-row">
|
|
105
|
+
{RANGES.map(r => (
|
|
106
|
+
<button key={r.id}
|
|
107
|
+
className={`chip ${f.rangeId === r.id ? 'active' : ''}`}
|
|
108
|
+
onClick={() => setRange(r)}>{r.label}</button>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div className="divider"/>
|
|
114
|
+
|
|
115
|
+
<div className="filter-group filter-group-sources">
|
|
116
|
+
<span className="filter-label">来源</span>
|
|
117
|
+
{allSources.map(s => (
|
|
118
|
+
<button key={s}
|
|
119
|
+
className={`pill ${f.sources.has(s) ? 'active' : ''}`}
|
|
120
|
+
style={f.sources.has(s) ? {color: U.PALETTE[s] || ''} : {}}
|
|
121
|
+
onClick={() => toggleSet('sources', s)}>
|
|
122
|
+
<span className="pill-dot" style={{background: U.PALETTE[s] || ''}}/>
|
|
123
|
+
{s}
|
|
124
|
+
</button>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div className="filter-row filter-row-secondary">
|
|
130
|
+
<div className="filter-group">
|
|
131
|
+
<span className="filter-label">设备</span>
|
|
132
|
+
<MultiSelect
|
|
133
|
+
options={allDevices}
|
|
134
|
+
selected={f.devices}
|
|
135
|
+
onChange={v => setF({...f, devices: v})}
|
|
136
|
+
placeholder="全部设备"/>
|
|
137
|
+
<span className="filter-label" style={{marginLeft: 4}}>模型</span>
|
|
138
|
+
<MultiSelect
|
|
139
|
+
options={allModels}
|
|
140
|
+
selected={f.models}
|
|
141
|
+
onChange={v => setF({...f, models: v})}
|
|
142
|
+
placeholder="全部模型"/>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div className="filter-spacer"/>
|
|
146
|
+
|
|
147
|
+
{filtersActive > 0 && (
|
|
148
|
+
<button className="btn" onClick={clearAll}>
|
|
149
|
+
<svg className="icon" viewBox="0 0 16 16" fill="none">
|
|
150
|
+
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/>
|
|
151
|
+
</svg>
|
|
152
|
+
清除筛选 · {filtersActive}
|
|
153
|
+
</button>
|
|
154
|
+
)}
|
|
155
|
+
<button className={`toggle ${f.compare ? 'on' : ''}`} onClick={() => setF({...f, compare: !f.compare})}>
|
|
156
|
+
<span className="toggle-slot"/>
|
|
157
|
+
对比上一周期
|
|
158
|
+
</button>
|
|
159
|
+
<button className="btn" onClick={onExport}>
|
|
160
|
+
<svg className="icon" viewBox="0 0 16 16" fill="none">
|
|
161
|
+
<path d="M8 2v8M5 7l3 3 3-3M3 13h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
162
|
+
</svg>
|
|
163
|
+
导出
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ───────────────────────────────────────────────────────────────
|
|
171
|
+
// MultiSelect — dropdown with checkboxes
|
|
172
|
+
// ───────────────────────────────────────────────────────────────
|
|
173
|
+
function MultiSelect({ options, selected, onChange, placeholder }) {
|
|
174
|
+
const [open, setOpen] = useState(false);
|
|
175
|
+
const ref = useRef(null);
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
const onDocClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
|
179
|
+
document.addEventListener('mousedown', onDocClick);
|
|
180
|
+
return () => document.removeEventListener('mousedown', onDocClick);
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
const label = selected.size === 0
|
|
184
|
+
? placeholder
|
|
185
|
+
: selected.size === 1
|
|
186
|
+
? Array.from(selected)[0]
|
|
187
|
+
: `${selected.size} 项已选`;
|
|
188
|
+
|
|
189
|
+
const toggle = (v) => {
|
|
190
|
+
const next = new Set(selected);
|
|
191
|
+
if (next.has(v)) next.delete(v); else next.add(v);
|
|
192
|
+
onChange(next);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<div ref={ref} style={{position:'relative', display:'inline-block'}}>
|
|
197
|
+
<button className={`pill ${selected.size ? 'active' : ''}`} onClick={() => setOpen(o => !o)}
|
|
198
|
+
style={{paddingLeft: 10, fontWeight: selected.size ? 600 : 400}}>
|
|
199
|
+
{label}
|
|
200
|
+
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" style={{marginLeft:2, opacity:0.5}}>
|
|
201
|
+
<path d="M1 3l3.5 3L8 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
|
|
202
|
+
</svg>
|
|
203
|
+
</button>
|
|
204
|
+
{open && (
|
|
205
|
+
<div style={{
|
|
206
|
+
position:'absolute', top:'calc(100% + 6px)', left:0, zIndex:30,
|
|
207
|
+
minWidth: 220, background: 'var(--surface)',
|
|
208
|
+
border: '1px solid var(--border)', borderRadius: 8,
|
|
209
|
+
boxShadow: '0 10px 30px -10px rgb(0 0 0 / 0.15)',
|
|
210
|
+
padding: 4, maxHeight: 280, overflowY: 'auto'
|
|
211
|
+
}}>
|
|
212
|
+
{selected.size > 0 && (
|
|
213
|
+
<button className="chip" style={{width:'100%', justifyContent:'flex-start', color: 'var(--c-indigo)', fontSize: 11.5}}
|
|
214
|
+
onClick={() => onChange(new Set())}>清除选择</button>
|
|
215
|
+
)}
|
|
216
|
+
{options.map(o => (
|
|
217
|
+
<button key={o} className="chip"
|
|
218
|
+
onClick={() => toggle(o)}
|
|
219
|
+
style={{width:'100%', justifyContent:'flex-start', gap:8, fontWeight:400}}>
|
|
220
|
+
<span style={{
|
|
221
|
+
width: 14, height: 14, borderRadius: 3,
|
|
222
|
+
border: '1.5px solid ' + (selected.has(o) ? 'var(--c-indigo)' : 'var(--border)'),
|
|
223
|
+
background: selected.has(o) ? 'var(--c-indigo)' : 'transparent',
|
|
224
|
+
display: 'grid', placeItems: 'center', flexShrink: 0
|
|
225
|
+
}}>
|
|
226
|
+
{selected.has(o) && (
|
|
227
|
+
<svg width="9" height="9" viewBox="0 0 9 9" fill="none">
|
|
228
|
+
<path d="M1.5 4.5L4 7l3.5-5" stroke="white" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
|
|
229
|
+
</svg>
|
|
230
|
+
)}
|
|
231
|
+
</span>
|
|
232
|
+
<span style={{overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', fontSize: 12}}>{o}</span>
|
|
233
|
+
</button>
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ───────────────────────────────────────────────────────────────
|
|
242
|
+
// Sparkline SVG
|
|
243
|
+
// ───────────────────────────────────────────────────────────────
|
|
244
|
+
function Spark({ values, color, height = 30, fill = true }) {
|
|
245
|
+
if (!values || values.length === 0) return null;
|
|
246
|
+
const w = 100, h = height;
|
|
247
|
+
const max = Math.max(...values, 1);
|
|
248
|
+
const min = Math.min(...values, 0);
|
|
249
|
+
const range = max - min || 1;
|
|
250
|
+
const pts = values.map((v, i) => {
|
|
251
|
+
const x = (i / (values.length - 1 || 1)) * w;
|
|
252
|
+
const y = h - ((v - min) / range) * (h - 2) - 1;
|
|
253
|
+
return [x, y];
|
|
254
|
+
});
|
|
255
|
+
const d = pts.map((p, i) => (i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`)).join(' ');
|
|
256
|
+
const dArea = d + ` L${w},${h} L0,${h} Z`;
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<svg className="kpi-spark" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none">
|
|
260
|
+
{fill && <path d={dArea} fill={color} opacity="0.12"/>}
|
|
261
|
+
<path d={d} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke"/>
|
|
262
|
+
</svg>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ───────────────────────────────────────────────────────────────
|
|
267
|
+
// Delta pill
|
|
268
|
+
// ───────────────────────────────────────────────────────────────
|
|
269
|
+
function Delta({ value, suffix = '%', invert = false }) {
|
|
270
|
+
if (value == null || !isFinite(value)) {
|
|
271
|
+
return <span className="delta flat">—</span>;
|
|
272
|
+
}
|
|
273
|
+
const positive = value > 0.05;
|
|
274
|
+
const negative = value < -0.05;
|
|
275
|
+
const flat = !positive && !negative;
|
|
276
|
+
const cls = flat ? 'flat' : (positive ? (invert ? 'down' : 'up') : (invert ? 'up' : 'down'));
|
|
277
|
+
const arrow = flat ? '·' : (positive ? '↑' : '↓');
|
|
278
|
+
return (
|
|
279
|
+
<span className={`delta ${cls}`}>
|
|
280
|
+
{arrow} {Math.abs(value).toFixed(1)}{suffix}
|
|
281
|
+
</span>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ───────────────────────────────────────────────────────────────
|
|
286
|
+
// KPI card
|
|
287
|
+
// ───────────────────────────────────────────────────────────────
|
|
288
|
+
function KPI({ label, value, sub, delta, dotColor, sparkValues, sparkColor }) {
|
|
289
|
+
return (
|
|
290
|
+
<div className="kpi">
|
|
291
|
+
<div className="kpi-label">
|
|
292
|
+
<span style={{display:'inline-flex', alignItems:'center', gap:6}}>
|
|
293
|
+
{dotColor && <span className="dot" style={{color: dotColor}}/>}
|
|
294
|
+
{label}
|
|
295
|
+
</span>
|
|
296
|
+
</div>
|
|
297
|
+
<div className="kpi-value">{value}</div>
|
|
298
|
+
<div className="kpi-sub">
|
|
299
|
+
{delta != null && <Delta value={delta}/>}
|
|
300
|
+
<span>{sub}</span>
|
|
301
|
+
</div>
|
|
302
|
+
{sparkValues && <Spark values={sparkValues} color={sparkColor || 'var(--c-indigo)'}/>}
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export { Topbar, FilterBar, KPI, Delta };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const CCUSAGE_BRIDGE_REPORTS = ['daily', 'weekly', 'monthly', 'session', 'blocks'];
|
|
2
|
+
|
|
3
|
+
export const BUDGET_TEMPLATES = [
|
|
4
|
+
{ id: 'claude-5h', label: 'Claude 5h', source: 'Claude Code', windowType: 'fixed', windowMinutes: 300, warningThreshold: 0.75 },
|
|
5
|
+
{ id: 'claude-weekly', label: 'Claude weekly', source: 'Claude Code', windowType: 'fixed', windowMinutes: 10080, warningThreshold: 0.75 },
|
|
6
|
+
{ id: 'codex-5h', label: 'Codex 5h', source: 'Codex CLI', windowType: 'fixed', windowMinutes: 300, warningThreshold: 0.75 },
|
|
7
|
+
{ id: 'copilot-weekly', label: 'Copilot weekly', source: 'GitHub Copilot CLI', windowType: 'fixed', windowMinutes: 10080, warningThreshold: 0.75 }
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export function buildCcusageBridgeCommand({ report = 'session', apply = false } = {}) {
|
|
11
|
+
const normalized = CCUSAGE_BRIDGE_REPORTS.includes(String(report).toLowerCase())
|
|
12
|
+
? String(report).toLowerCase()
|
|
13
|
+
: 'session';
|
|
14
|
+
return [
|
|
15
|
+
'npx token-studio import-usage',
|
|
16
|
+
'--format=ccusage-cli',
|
|
17
|
+
`--report=${normalized}`,
|
|
18
|
+
apply ? '--apply' : '--dry-run',
|
|
19
|
+
'--yes'
|
|
20
|
+
].join(' ');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function defaultResetAnchor(now = new Date()) {
|
|
24
|
+
const date = new Date(now);
|
|
25
|
+
date.setSeconds(0, 0);
|
|
26
|
+
return date.toISOString().slice(0, 16);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function applyBudgetTemplate(current = {}, template = {}, now = new Date()) {
|
|
30
|
+
return {
|
|
31
|
+
...current,
|
|
32
|
+
source: template.source || current.source || '',
|
|
33
|
+
label: template.label || current.label || '',
|
|
34
|
+
windowType: template.windowType || current.windowType || 'rolling',
|
|
35
|
+
windowMinutes: template.windowMinutes || current.windowMinutes || 60,
|
|
36
|
+
warningThreshold: template.warningThreshold ?? current.warningThreshold ?? 0.75,
|
|
37
|
+
resetAnchor: template.windowType === 'fixed'
|
|
38
|
+
? current.resetAnchor || defaultResetAnchor(now)
|
|
39
|
+
: current.resetAnchor || ''
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export function sessionModel(session = {}) {
|
|
2
|
+
return session.model || session.pricingModel || '';
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function filterSessionsByDashboardFilters(sessions = [], filters = {}) {
|
|
6
|
+
const sources = filters.sources || new Set();
|
|
7
|
+
const devices = filters.devices || new Set();
|
|
8
|
+
const models = filters.models || new Set();
|
|
9
|
+
const startDate = filters.startDate || '';
|
|
10
|
+
const endDate = filters.endDate || '';
|
|
11
|
+
|
|
12
|
+
return sessions.filter(session => {
|
|
13
|
+
const lastActivity = session.lastActivity || '';
|
|
14
|
+
const model = sessionModel(session);
|
|
15
|
+
return (!lastActivity || (!startDate || lastActivity >= startDate) && (!endDate || lastActivity <= endDate))
|
|
16
|
+
&& (sources.size === 0 || sources.has(session.source))
|
|
17
|
+
&& (devices.size === 0 || devices.has(session.device))
|
|
18
|
+
&& (models.size === 0 || models.has(model));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildModelUsageRows(dailyRows = [], sessions = []) {
|
|
23
|
+
const rows = new Map();
|
|
24
|
+
|
|
25
|
+
const ensure = (model) => {
|
|
26
|
+
const key = model || 'unknown';
|
|
27
|
+
if (!rows.has(key)) {
|
|
28
|
+
rows.set(key, {
|
|
29
|
+
model: key,
|
|
30
|
+
sources: new Set(),
|
|
31
|
+
days: new Set(),
|
|
32
|
+
sessionKeys: new Set(),
|
|
33
|
+
inputTokens: 0,
|
|
34
|
+
outputTokens: 0,
|
|
35
|
+
cacheReadTokens: 0,
|
|
36
|
+
cacheCreationTokens: 0,
|
|
37
|
+
cachedInputTokens: 0,
|
|
38
|
+
reasoningOutputTokens: 0,
|
|
39
|
+
totalTokens: 0,
|
|
40
|
+
costUSD: 0,
|
|
41
|
+
pricingStatuses: new Set(),
|
|
42
|
+
hasDaily: false
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return rows.get(key);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
for (const row of dailyRows) {
|
|
49
|
+
const model = row.model || 'unknown';
|
|
50
|
+
const target = ensure(model);
|
|
51
|
+
target.hasDaily = true;
|
|
52
|
+
target.sources.add(row.source);
|
|
53
|
+
if (row.usageDate) target.days.add(row.usageDate);
|
|
54
|
+
target.inputTokens += row.inputTokens || 0;
|
|
55
|
+
target.outputTokens += row.outputTokens || 0;
|
|
56
|
+
target.cacheReadTokens += row.cacheReadTokens || 0;
|
|
57
|
+
target.cacheCreationTokens += row.cacheCreationTokens || 0;
|
|
58
|
+
target.cachedInputTokens += row.cachedInputTokens || 0;
|
|
59
|
+
target.reasoningOutputTokens += row.reasoningOutputTokens || 0;
|
|
60
|
+
target.totalTokens += row.totalTokens || 0;
|
|
61
|
+
target.costUSD += row.costUSD || 0;
|
|
62
|
+
if (row.pricingStatus) target.pricingStatuses.add(row.pricingStatus);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const session of sessions) {
|
|
66
|
+
const model = sessionModel(session);
|
|
67
|
+
if (!model) continue;
|
|
68
|
+
const target = ensure(model);
|
|
69
|
+
target.sources.add(session.source);
|
|
70
|
+
if (session.lastActivity) target.days.add(session.lastActivity);
|
|
71
|
+
target.sessionKeys.add(`${session.device || ''}::${session.source || ''}::${session.sessionId || ''}`);
|
|
72
|
+
if (!target.hasDaily) {
|
|
73
|
+
target.inputTokens += session.inputTokens || 0;
|
|
74
|
+
target.outputTokens += session.outputTokens || 0;
|
|
75
|
+
target.cacheReadTokens += session.cacheReadTokens || 0;
|
|
76
|
+
target.cacheCreationTokens += session.cacheCreationTokens || 0;
|
|
77
|
+
target.cachedInputTokens += session.cachedInputTokens || 0;
|
|
78
|
+
target.reasoningOutputTokens += session.reasoningOutputTokens || 0;
|
|
79
|
+
target.totalTokens += session.totalTokens || 0;
|
|
80
|
+
target.costUSD += session.costUSD || 0;
|
|
81
|
+
if (session.pricingStatus) target.pricingStatuses.add(session.pricingStatus);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return Array.from(rows.values())
|
|
86
|
+
.map(row => {
|
|
87
|
+
const pricingStatus = row.pricingStatuses.has('priced')
|
|
88
|
+
? (row.pricingStatuses.size > 1 ? '部分定价' : '已定价')
|
|
89
|
+
: (row.pricingStatuses.size > 0 ? '未定价' : '无价格');
|
|
90
|
+
return {
|
|
91
|
+
model: row.model,
|
|
92
|
+
sourceCount: row.sources.size,
|
|
93
|
+
sources: Array.from(row.sources).sort(),
|
|
94
|
+
dayCount: row.days.size,
|
|
95
|
+
sessionCount: row.sessionKeys.size,
|
|
96
|
+
inputTokens: row.inputTokens,
|
|
97
|
+
outputTokens: row.outputTokens,
|
|
98
|
+
cacheReadTokens: row.cacheReadTokens,
|
|
99
|
+
cacheCreationTokens: row.cacheCreationTokens,
|
|
100
|
+
cachedInputTokens: row.cachedInputTokens,
|
|
101
|
+
reasoningOutputTokens: row.reasoningOutputTokens,
|
|
102
|
+
totalTokens: row.totalTokens,
|
|
103
|
+
costUSD: row.costUSD,
|
|
104
|
+
pricingStatus
|
|
105
|
+
};
|
|
106
|
+
})
|
|
107
|
+
.sort((a, b) => b.totalTokens - a.totalTokens);
|
|
108
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export function buildFirstRunState(data = {}) {
|
|
2
|
+
const dailyCount = data.daily?.length || 0;
|
|
3
|
+
const sessionCount = data.sessions?.length || 0;
|
|
4
|
+
const budgetCount = data.budgetProfiles?.length || 0;
|
|
5
|
+
const actionCount = data.advisorActions?.length || 0;
|
|
6
|
+
const tokenEventCount = data.tokenEvents?.length || 0;
|
|
7
|
+
const hasUsage = dailyCount > 0 || sessionCount > 0;
|
|
8
|
+
const hasBudget = budgetCount > 0;
|
|
9
|
+
const hasActions = actionCount > 0;
|
|
10
|
+
const hasLiveEvents = tokenEventCount > 0;
|
|
11
|
+
|
|
12
|
+
const steps = [
|
|
13
|
+
{
|
|
14
|
+
id: 'data',
|
|
15
|
+
status: hasUsage ? 'done' : 'todo',
|
|
16
|
+
title: '准备用量数据',
|
|
17
|
+
detail: hasUsage
|
|
18
|
+
? `${dailyCount} 条 daily、${sessionCount} 个 session 已可复盘`
|
|
19
|
+
: '先用 demo,或通过 ccusage JSON dry-run 后再写入 SQLite',
|
|
20
|
+
action: hasUsage ? '已完成' : '打开导入/预算'
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'budget',
|
|
24
|
+
status: hasBudget ? 'done' : 'todo',
|
|
25
|
+
title: '创建预算窗口',
|
|
26
|
+
detail: hasBudget
|
|
27
|
+
? `${budgetCount} 个自定义预算窗口已配置`
|
|
28
|
+
: '只设置你自己的 token/USD 窗口,不内置供应商套餐额度',
|
|
29
|
+
action: hasBudget ? '已完成' : '创建预算'
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'review',
|
|
33
|
+
status: hasActions ? 'done' : 'todo',
|
|
34
|
+
title: '进入复盘行动',
|
|
35
|
+
detail: hasActions
|
|
36
|
+
? `${actionCount} 条 Advisor action 可在周报里追踪`
|
|
37
|
+
: '去 /review 把节省模拟或 ROI 建议加入行动清单',
|
|
38
|
+
action: hasActions ? '已完成' : '打开 /review'
|
|
39
|
+
}
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const notices = [];
|
|
43
|
+
if (!hasUsage) {
|
|
44
|
+
notices.push({
|
|
45
|
+
id: 'no-data',
|
|
46
|
+
tone: 'primary',
|
|
47
|
+
title: '还没有可复盘的用量数据',
|
|
48
|
+
detail: '运行 npm run demo 看完整流程,或打开导入/预算粘贴 ccusage JSON。真实采集仍需你显式确认。',
|
|
49
|
+
action: '打开导入/预算'
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (hasUsage && !hasActions) {
|
|
53
|
+
notices.push({
|
|
54
|
+
id: 'no-actions',
|
|
55
|
+
tone: 'review',
|
|
56
|
+
title: '下一步是把建议变成行动清单',
|
|
57
|
+
detail: '数据已经存在,但还没有 open/done/dismissed actions。去 /review 处理 Savings Simulator 和 ROI Advisor。',
|
|
58
|
+
action: '打开 /review'
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (hasBudget && !hasLiveEvents) {
|
|
62
|
+
notices.push({
|
|
63
|
+
id: 'budget-no-live-events',
|
|
64
|
+
tone: 'live',
|
|
65
|
+
title: '预算窗口已配置,但 /live 需要事件级 token 数据',
|
|
66
|
+
detail: '/live 只看最近窗口内的 token_events;只有 session 聚合数据时预算仍会保存,但实时 burn rate 可能为空。',
|
|
67
|
+
action: '打开 /live'
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
hasUsage,
|
|
73
|
+
hasBudget,
|
|
74
|
+
hasActions,
|
|
75
|
+
hasLiveEvents,
|
|
76
|
+
shouldShow: notices.length > 0 || steps.some(step => step.status !== 'done'),
|
|
77
|
+
steps,
|
|
78
|
+
notices
|
|
79
|
+
};
|
|
80
|
+
}
|