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.
Files changed (139) hide show
  1. package/.nvmrc +1 -0
  2. package/CHANGELOG.md +89 -0
  3. package/Dockerfile +17 -0
  4. package/LICENSE +22 -0
  5. package/NOTICE.md +21 -0
  6. package/PRIVACY.md +68 -0
  7. package/README.en.md +220 -0
  8. package/README.md +220 -0
  9. package/config/collectors.json +54 -0
  10. package/data/.gitkeep +1 -0
  11. package/docker-compose.yml +17 -0
  12. package/docs/assets/.gitkeep +1 -0
  13. package/docs/assets/token-studio-v44-dashboard.png +0 -0
  14. package/docs/assets/token-studio-v44-live.png +0 -0
  15. package/docs/assets/token-studio-v44-review-mobile.png +0 -0
  16. package/docs/assets/token-studio-v44-review.png +0 -0
  17. package/docs/assets/token-studio-v45-dashboard.png +0 -0
  18. package/docs/assets/token-studio-v45-live.png +0 -0
  19. package/docs/assets/token-studio-v45-review-mobile.png +0 -0
  20. package/docs/assets/token-studio-v45-review.png +0 -0
  21. package/docs/blog-case-study.md +34 -0
  22. package/docs/collector-support-matrix.md +65 -0
  23. package/docs/competitive-notes.md +87 -0
  24. package/docs/demo-data/README.md +12 -0
  25. package/docs/demo-data/token-studio-v2-demo.json +146 -0
  26. package/docs/demo-flow.md +39 -0
  27. package/docs/first-run.md +95 -0
  28. package/docs/local-collectors.md +49 -0
  29. package/docs/public-launch-checklist.md +45 -0
  30. package/docs/resume-bullets.md +7 -0
  31. package/docs/statusline.md +52 -0
  32. package/index.html +16 -0
  33. package/package.json +36 -0
  34. package/render.yaml +17 -0
  35. package/src/auto-attribution.mjs +396 -0
  36. package/src/ccusage-bridge.mjs +74 -0
  37. package/src/ccusage-import.mjs +415 -0
  38. package/src/cli.mjs +643 -0
  39. package/src/client/dashboard/App.jsx +1734 -0
  40. package/src/client/dashboard/annotation-presets.js +138 -0
  41. package/src/client/dashboard/attribution.js +328 -0
  42. package/src/client/dashboard/components-charts.jsx +622 -0
  43. package/src/client/dashboard/components-tables.jsx +1531 -0
  44. package/src/client/dashboard/components-top.jsx +307 -0
  45. package/src/client/dashboard/import-budget.js +41 -0
  46. package/src/client/dashboard/model-usage.js +108 -0
  47. package/src/client/dashboard/onboarding.js +80 -0
  48. package/src/client/dashboard/styles.css +2606 -0
  49. package/src/client/live/LiveApp.jsx +226 -0
  50. package/src/client/live/styles.css +446 -0
  51. package/src/client/main.jsx +20 -0
  52. package/src/client/review/ReviewApp.jsx +507 -0
  53. package/src/client/review/closure-progress.js +165 -0
  54. package/src/client/review/markdown-report.js +401 -0
  55. package/src/client/review/model-strategy.js +273 -0
  56. package/src/client/review/roi-advisor.js +255 -0
  57. package/src/client/review/roi-evidence.js +78 -0
  58. package/src/client/review/savings-simulator.js +252 -0
  59. package/src/client/review/sections-1.jsx +277 -0
  60. package/src/client/review/sections-2.jsx +927 -0
  61. package/src/client/review/styles.css +2321 -0
  62. package/src/client/review/utils.js +345 -0
  63. package/src/client/shared/utils.js +236 -0
  64. package/src/closure-check.mjs +537 -0
  65. package/src/closure-import.mjs +646 -0
  66. package/src/collect.mjs +247 -0
  67. package/src/collector-config.mjs +82 -0
  68. package/src/collector-registry.mjs +333 -0
  69. package/src/collectors/claude-code.mjs +355 -0
  70. package/src/collectors/codex.mjs +418 -0
  71. package/src/collectors/copilot.mjs +19 -0
  72. package/src/collectors/cursor.mjs +23 -0
  73. package/src/collectors/gemini.mjs +530 -0
  74. package/src/collectors/goose.mjs +15 -0
  75. package/src/collectors/hermes.mjs +206 -0
  76. package/src/collectors/kimi.mjs +15 -0
  77. package/src/collectors/openclaw.mjs +400 -0
  78. package/src/collectors/opencode.mjs +349 -0
  79. package/src/collectors/qwen.mjs +15 -0
  80. package/src/collectors/structured-usage.mjs +437 -0
  81. package/src/collectors/utils.mjs +93 -0
  82. package/src/db.mjs +1397 -0
  83. package/src/demo-seed.mjs +39 -0
  84. package/src/dev.mjs +43 -0
  85. package/src/live.mjs +428 -0
  86. package/src/model-policy.mjs +147 -0
  87. package/src/pricing.mjs +434 -0
  88. package/src/privacy-check.mjs +126 -0
  89. package/src/server.mjs +1240 -0
  90. package/src/source-health.mjs +195 -0
  91. package/src/statusline.mjs +156 -0
  92. package/src/terminal-report.mjs +245 -0
  93. package/src/update-pricing.mjs +8 -0
  94. package/test/annotation-presets.test.mjs +137 -0
  95. package/test/api-annotations.test.mjs +202 -0
  96. package/test/api-auto-attribution.test.mjs +169 -0
  97. package/test/api-source-health.test.mjs +109 -0
  98. package/test/api-v2.test.mjs +278 -0
  99. package/test/api-v43.test.mjs +151 -0
  100. package/test/api-v44.test.mjs +128 -0
  101. package/test/attribution-summary.test.mjs +164 -0
  102. package/test/auto-attribution.test.mjs +116 -0
  103. package/test/ccusage-bridge.test.mjs +36 -0
  104. package/test/ccusage-import.test.mjs +93 -0
  105. package/test/cli-v43.test.mjs +64 -0
  106. package/test/cli-v45.test.mjs +34 -0
  107. package/test/cli-v46.test.mjs +129 -0
  108. package/test/cli-v47.test.mjs +98 -0
  109. package/test/closure-check.test.mjs +202 -0
  110. package/test/closure-import.test.mjs +263 -0
  111. package/test/collector-config.test.mjs +25 -0
  112. package/test/collector-registry.test.mjs +56 -0
  113. package/test/csv.test.mjs +19 -0
  114. package/test/db-annotations.test.mjs +186 -0
  115. package/test/db-v2.test.mjs +200 -0
  116. package/test/db-v4.test.mjs +178 -0
  117. package/test/experimental-collectors.test.mjs +103 -0
  118. package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
  119. package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
  120. package/test/fixtures/collectors/goose/usage.jsonl +2 -0
  121. package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
  122. package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
  123. package/test/import-budget.test.mjs +40 -0
  124. package/test/live.test.mjs +256 -0
  125. package/test/markdown-report.test.mjs +193 -0
  126. package/test/model-policy.test.mjs +34 -0
  127. package/test/model-strategy.test.mjs +116 -0
  128. package/test/model-usage.test.mjs +99 -0
  129. package/test/official-pricing.test.mjs +70 -0
  130. package/test/onboarding.test.mjs +55 -0
  131. package/test/privacy-check.test.mjs +33 -0
  132. package/test/review-closure-progress.test.mjs +99 -0
  133. package/test/roi-advisor.test.mjs +188 -0
  134. package/test/roi-evidence.test.mjs +48 -0
  135. package/test/roi-summary.test.mjs +101 -0
  136. package/test/savings-simulator.test.mjs +141 -0
  137. package/test/source-health.test.mjs +62 -0
  138. package/test/statusline.test.mjs +148 -0
  139. package/vite.config.js +23 -0
@@ -0,0 +1,927 @@
1
+ /* =============================================================
2
+ Review-page sections — Tools, Efficiency, Insights
3
+ ============================================================= */
4
+
5
+ import { useEffect, useMemo, useRef, useState } from 'react';
6
+ import * as echarts from 'echarts';
7
+ import { U } from '../shared/utils.js';
8
+ import { RU } from './utils.js';
9
+
10
+ // ───────────────────────────────────────────────────────────────
11
+ // Closure progress — real data acceptance gate
12
+ // ───────────────────────────────────────────────────────────────
13
+ function ClosureProgressSection({ progress }) {
14
+ if (!progress) return null;
15
+ const pct = Math.round(progress.completionShare * 100);
16
+
17
+ return (
18
+ <section className="story closure-section">
19
+ <div className="section-label">01 · 闭环</div>
20
+ <h2 className="section-title">真实数据闭环还差什么</h2>
21
+ <p className="section-sub">这里按双轨口径检查当前周期:人工确认仍是最高可信;自动高置信完整归因用于懒人模式先跑通复盘,但不等同人工事实。</p>
22
+
23
+ <div className={`closure-hero ${progress.status === 'complete' ? 'complete' : 'needs-work'}`}>
24
+ <div>
25
+ <span>验收进度</span>
26
+ <strong>{pct}%</strong>
27
+ <p>{progress.completedChecks} / {progress.totalChecks} 项已满足 · {progress.totals.sessionCount} 个 session · {U.compactCN(progress.totals.totalTokens)} tokens</p>
28
+ </div>
29
+ <div className="closure-meter" aria-hidden="true">
30
+ <span style={{width: `${pct}%`}}/>
31
+ </div>
32
+ </div>
33
+
34
+ <div className="closure-grid">
35
+ {progress.checks.map(check => (
36
+ <article key={check.id} className={`closure-card ${check.complete ? 'done' : 'todo'}`}>
37
+ <div className="closure-card-head">
38
+ <span>{check.complete ? '已满足' : '待完成'}</span>
39
+ <b>{check.current} / {check.target}</b>
40
+ </div>
41
+ <h3>{check.label}</h3>
42
+ <p>{check.detail}</p>
43
+ {!check.complete && <strong>{check.action}</strong>}
44
+ </article>
45
+ ))}
46
+ </div>
47
+
48
+ {progress.topGaps.length > 0 && (
49
+ <div className="closure-gaps">
50
+ <div className="closure-gaps-head">
51
+ <h3>优先补齐的真实 session</h3>
52
+ <span>按官方价和 token 降序,只列结构化字段缺口</span>
53
+ </div>
54
+ <div className="closure-gap-list">
55
+ {progress.topGaps.slice(0, 3).map((row, index) => (
56
+ <div key={`${row.sessionId}:${index}`} className="closure-gap-row">
57
+ <div className="closure-gap-rank">{String(index + 1).padStart(2, '0')}</div>
58
+ <div>
59
+ <strong>{row.project}</strong>
60
+ <span>{row.sessionId || '未命名 session'} · 缺 {row.missingFields.join('、')}</span>
61
+ </div>
62
+ <div>
63
+ <b>{U.compactCN(row.totalTokens)}</b>
64
+ <span>{row.costUSD > 0 ? U.fmtUS.format(row.costUSD) : '未定价/无官方价'}</span>
65
+ </div>
66
+ </div>
67
+ ))}
68
+ </div>
69
+ </div>
70
+ )}
71
+
72
+ {progress.nextActions.length > 0 && (
73
+ <div className="closure-actions">
74
+ <h3>下一步</h3>
75
+ {progress.nextActions.slice(0, 3).map(action => (
76
+ <p key={action}>{action}</p>
77
+ ))}
78
+ </div>
79
+ )}
80
+ </section>
81
+ );
82
+ }
83
+
84
+ function RoiEvidenceSection({ evidence }) {
85
+ if (!evidence) return null;
86
+ return (
87
+ <section className="story evidence-section">
88
+ <div className="section-label">02 · 证据</div>
89
+ <h2 className="section-title">这些 Token 是否足够支撑 ROI 判断</h2>
90
+ <p className="section-sub">Token Studio ROI 不只统计消耗,还检查项目、任务、目的、阶段、价值、产出和人工确认是否完整。</p>
91
+
92
+ <div className="evidence-hero">
93
+ <div>
94
+ <span>ROI Evidence Score</span>
95
+ <strong>{evidence.evidenceScore}</strong>
96
+ <p>{evidence.complete} / {evidence.sessionCount} 个 session 证据完整 · {evidence.workItemCount} 个 work item</p>
97
+ </div>
98
+ <div className="evidence-meter" aria-hidden="true">
99
+ <span style={{width: `${Math.max(0, Math.min(100, evidence.evidenceScore))}%`}}/>
100
+ </div>
101
+ </div>
102
+
103
+ <div className="evidence-grid">
104
+ <EvidenceStat label="人工确认" value={`${evidence.manualConfirmed}`} note={`${evidence.autoOrMissing} 个仍是自动或缺失`} />
105
+ <EvidenceStat label="有产出链接" value={`${evidence.withOutput}`} note="只保存 URL、标签、类型" />
106
+ <EvidenceStat label="未完成证据成本" value={evidence.incompleteCostUSD > 0 ? U.fmtUS.format(evidence.incompleteCostUSD) : '—'} note="官方价换算,不是账单" />
107
+ </div>
108
+
109
+ {evidence.highCostGaps.length > 0 && (
110
+ <div className="evidence-gaps">
111
+ {evidence.highCostGaps.slice(0, 3).map((row, index) => (
112
+ <div key={`${row.sessionId}:${index}`} className="evidence-gap-row">
113
+ <span>{String(index + 1).padStart(2, '0')}</span>
114
+ <div>
115
+ <strong>{row.project}</strong>
116
+ <p>缺 {row.missing.join('、')} · {U.compactCN(row.totalTokens)} tokens</p>
117
+ </div>
118
+ <b>{row.costUSD > 0 ? U.fmtUS.format(row.costUSD) : '未定价'}</b>
119
+ </div>
120
+ ))}
121
+ </div>
122
+ )}
123
+ </section>
124
+ );
125
+ }
126
+
127
+ function EvidenceStat({ label, value, note }) {
128
+ return (
129
+ <article className="evidence-stat">
130
+ <span>{label}</span>
131
+ <strong>{value}</strong>
132
+ <p>{note}</p>
133
+ </article>
134
+ );
135
+ }
136
+
137
+ // ───────────────────────────────────────────────────────────────
138
+ // Tools donut + per-tool list
139
+ // ───────────────────────────────────────────────────────────────
140
+ function ToolsSection({ daily, totalTokens }) {
141
+ const tools = useMemo(() => {
142
+ const list = RU.aggregateBy(daily, 'source').sort((a, b) => b.totalTokens - a.totalTokens);
143
+ return list.map(t => ({
144
+ ...t,
145
+ topModel: RU.topModelFor(daily, r => r.source === t.key),
146
+ share: (t.totalTokens / (totalTokens || 1)) * 100
147
+ }));
148
+ }, [daily, totalTokens]);
149
+
150
+ const donutRef = useRef(null);
151
+ const donutChart = useRef(null);
152
+
153
+ useEffect(() => {
154
+ if (!donutRef.current) return;
155
+ if (!donutChart.current) {
156
+ donutChart.current = echarts.init(donutRef.current, null, { renderer: 'canvas' });
157
+ }
158
+ donutChart.current.setOption({
159
+ backgroundColor: 'transparent',
160
+ animation: true,
161
+ tooltip: {
162
+ trigger: 'item',
163
+ backgroundColor: 'oklch(0.16 0.010 60)',
164
+ borderColor: 'transparent',
165
+ textStyle: { color: 'oklch(0.97 0.008 80)', fontSize: 12 },
166
+ extraCssText: 'border-radius: 8px; box-shadow: 0 8px 24px -8px rgb(0 0 0 / 0.3);',
167
+ formatter: p => `<div style="font-weight:600">${p.name}</div>
168
+ <div style="font-size:13px;margin-top:4px;font-feature-settings:'tnum'">${U.compactCN(p.value)} tokens · ${(p.percent || 0).toFixed(1)}%</div>`
169
+ },
170
+ series: [{
171
+ type: 'pie',
172
+ radius: ['60%', '92%'],
173
+ center: ['50%', '50%'],
174
+ avoidLabelOverlap: true,
175
+ label: { show: false },
176
+ labelLine: { show: false },
177
+ itemStyle: { borderColor: 'oklch(0.97 0.008 80)', borderWidth: 4 },
178
+ data: tools.map(t => ({
179
+ name: t.key,
180
+ value: t.totalTokens,
181
+ itemStyle: { color: U.getSourceColor(t.key) }
182
+ }))
183
+ }]
184
+ }, true);
185
+ const onResize = () => donutChart.current?.resize();
186
+ window.addEventListener('resize', onResize);
187
+ return () => window.removeEventListener('resize', onResize);
188
+ }, [tools]);
189
+
190
+ if (!tools.length) return null;
191
+ const top = tools[0];
192
+
193
+ return (
194
+ <section className="story">
195
+ <div className="section-label">04 · 工具</div>
196
+ <h2 className="section-title">你是怎么用这些工具的</h2>
197
+ <p className="section-sub">每个工具背后挑选了不同模型、有不同的官方价结构。这是它们各自的份额与组合。</p>
198
+
199
+ <div className="tools-split">
200
+ <div style={{display: 'flex', justifyContent: 'center'}}>
201
+ <div className="donut-wrap">
202
+ <div ref={donutRef} style={{width: 280, height: 280}}/>
203
+ <div className="donut-center">
204
+ <div>
205
+ <div className="l">主导工具</div>
206
+ <div className="v">{top.share.toFixed(0)}%</div>
207
+ <div className="s">{top.key}</div>
208
+ </div>
209
+ </div>
210
+ </div>
211
+ </div>
212
+
213
+ <div className="tool-list">
214
+ {tools.map(t => (
215
+ <div key={t.key} className="tool-card">
216
+ <span className="tool-dot" style={{background: U.getSourceColor(t.key)}}/>
217
+ <div className="tool-info">
218
+ <h3 className="tool-name">{t.key}</h3>
219
+ <div className="tool-model">
220
+ 常用模型 · {t.topModel}
221
+ </div>
222
+ </div>
223
+ <div className="tool-stats">
224
+ <div>
225
+ <div className="tokens">{U.compactCN(t.totalTokens)}</div>
226
+ <div className="cost">{t.costUSD > 0 ? U.fmtUS.format(t.costUSD) : '—'}</div>
227
+ </div>
228
+ <span className="tool-badge" title="Cache hit rate">
229
+ <svg viewBox="0 0 12 12" fill="none">
230
+ <ellipse cx="6" cy="3.5" rx="4.5" ry="1.8" stroke="currentColor" strokeWidth="1.2"/>
231
+ <path d="M1.5 3.5v3c0 1 2 1.8 4.5 1.8s4.5-.8 4.5-1.8v-3" stroke="currentColor" strokeWidth="1.2"/>
232
+ <path d="M1.5 6.5v3c0 1 2 1.8 4.5 1.8s4.5-.8 4.5-1.8v-3" stroke="currentColor" strokeWidth="1.2"/>
233
+ </svg>
234
+ {t.cacheHitRate.toFixed(0)}%
235
+ </span>
236
+ </div>
237
+ </div>
238
+ ))}
239
+ </div>
240
+ </div>
241
+ </section>
242
+ );
243
+ }
244
+
245
+ // ───────────────────────────────────────────────────────────────
246
+ // Efficiency analysis cards
247
+ // ───────────────────────────────────────────────────────────────
248
+ function EfficiencySection({ daily, period }) {
249
+ const totals = useMemo(() => ({
250
+ total: RU.sumField(daily, 'totalTokens'),
251
+ input: RU.sumField(daily, 'inputTokens'),
252
+ output: RU.sumField(daily, 'outputTokens'),
253
+ cacheRead: RU.sumField(daily, 'cacheReadTokens'),
254
+ reasoning: RU.sumField(daily, 'reasoningOutputTokens')
255
+ }), [daily]);
256
+
257
+ const cacheRate = totals.total ? (totals.cacheRead / totals.total) * 100 : 0;
258
+ const ioRatio = totals.output ? totals.input / totals.output : 0;
259
+ const reasonPct = totals.total ? (totals.reasoning / totals.total) * 100 : 0;
260
+
261
+ // sparklines for each metric over daily
262
+ const daysArr = useMemo(() => RU.dailyTotals(daily, period), [daily, period]);
263
+
264
+ const cacheSeries = useMemo(() => {
265
+ const m = new Map();
266
+ for (const r of daily) {
267
+ const x = m.get(r.usageDate) || { tot: 0, cr: 0 };
268
+ x.tot += r.totalTokens; x.cr += r.cacheReadTokens;
269
+ m.set(r.usageDate, x);
270
+ }
271
+ return daysArr.map(d => {
272
+ const x = m.get(d.date);
273
+ return x && x.tot ? (x.cr / x.tot) * 100 : 0;
274
+ });
275
+ }, [daily, daysArr]);
276
+
277
+ const ioSeries = useMemo(() => {
278
+ const m = new Map();
279
+ for (const r of daily) {
280
+ const x = m.get(r.usageDate) || { i: 0, o: 0 };
281
+ x.i += r.inputTokens; x.o += r.outputTokens;
282
+ m.set(r.usageDate, x);
283
+ }
284
+ return daysArr.map(d => {
285
+ const x = m.get(d.date);
286
+ return x && x.o ? x.i / x.o : 0;
287
+ });
288
+ }, [daily, daysArr]);
289
+
290
+ const reasonSeries = useMemo(() => {
291
+ const m = new Map();
292
+ for (const r of daily) {
293
+ const x = m.get(r.usageDate) || { r: 0, t: 0 };
294
+ x.r += r.reasoningOutputTokens; x.t += r.totalTokens;
295
+ m.set(r.usageDate, x);
296
+ }
297
+ return daysArr.map(d => {
298
+ const x = m.get(d.date);
299
+ return x && x.t ? (x.r / x.t) * 100 : 0;
300
+ });
301
+ }, [daily, daysArr]);
302
+
303
+ return (
304
+ <section className="story">
305
+ <div className="section-label">05 · 效率</div>
306
+ <h2 className="section-title">你的 Token 用得高效吗</h2>
307
+ <p className="section-sub">从三个角度看 token 的"性价比"——重复利用率、信息密度、推理强度。</p>
308
+
309
+ <div className="eff-grid">
310
+ <EffCard
311
+ label="Cache 命中率"
312
+ value={cacheRate.toFixed(1)}
313
+ unit="%"
314
+ note={`每命中一次 cache,官方价约为普通输入价的一部分。本期一共节省 ${U.compactCN(totals.cacheRead)} tokens 的重复计算。`}
315
+ spark={cacheSeries}
316
+ color="oklch(0.55 0.16 265)"/>
317
+ <EffCard
318
+ label="Input / Output 比"
319
+ value={ioRatio.toFixed(1)}
320
+ unit=":1"
321
+ note={`平均喂给模型 ${ioRatio.toFixed(1)} 个 token,模型生成 1 个。比值越低说明指令越紧凑、生成越密集。`}
322
+ spark={ioSeries}
323
+ color="oklch(0.65 0.11 200)"/>
324
+ <EffCard
325
+ label="Reasoning 占比"
326
+ value={reasonPct.toFixed(1)}
327
+ unit="%"
328
+ note={`推理 token 比例越高,说明你交给模型的任务越复杂——通常对应代码重构、调试或多步规划。`}
329
+ spark={reasonSeries}
330
+ color="oklch(0.65 0.12 150)"/>
331
+ </div>
332
+ </section>
333
+ );
334
+ }
335
+
336
+ function EffCard({ label, value, unit, note, spark, color }) {
337
+ return (
338
+ <div className="eff-card">
339
+ <div className="eff-label">{label}</div>
340
+ <div className="eff-value">
341
+ {value}<span className="unit">{unit}</span>
342
+ </div>
343
+ <p className="eff-note">{note}</p>
344
+ {spark && spark.length > 0 && (
345
+ <div className="eff-spark">
346
+ <MiniSpark values={spark} color={color}/>
347
+ </div>
348
+ )}
349
+ </div>
350
+ );
351
+ }
352
+
353
+ function MiniSpark({ values, color }) {
354
+ if (!values || values.length === 0) return null;
355
+ const w = 200, h = 32;
356
+ const max = Math.max(...values, 1);
357
+ const min = Math.min(...values, 0);
358
+ const range = max - min || 1;
359
+ const pts = values.map((v, i) => {
360
+ const x = (i / Math.max(1, values.length - 1)) * w;
361
+ const y = h - ((v - min) / range) * (h - 2) - 1;
362
+ return [x, y];
363
+ });
364
+ const d = pts.map((p, i) => (i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`)).join(' ');
365
+ const dArea = d + ` L${w},${h} L0,${h} Z`;
366
+ return (
367
+ <svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{width: '100%', height: h, display: 'block'}}>
368
+ <path d={dArea} fill={color} opacity="0.14"/>
369
+ <path d={d} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke"/>
370
+ </svg>
371
+ );
372
+ }
373
+
374
+ // ───────────────────────────────────────────────────────────────
375
+ // Savings simulator — official-price model switching simulation
376
+ // ───────────────────────────────────────────────────────────────
377
+ function SavingsSimulatorSection({ simulation, actionsByRule = new Map(), onAddAction, onSetActionStatus }) {
378
+ if (!simulation) return null;
379
+ const suggestions = simulation.suggestions || [];
380
+ const unpriced = simulation.unpriced || {};
381
+
382
+ return (
383
+ <section className="story savings-section">
384
+ <div className="section-label">06 · 节省模拟</div>
385
+ <h2 className="section-title">哪些 token 可以少花一点</h2>
386
+ <p className="section-sub">官方价换算节省模拟只用于比较模型策略,不是供应商账单。未公开官方美元价的模型不会参与节省金额计算。</p>
387
+
388
+ <div className="savings-hero">
389
+ <div>
390
+ <span>模拟可节省</span>
391
+ <strong>{simulation.potentialSavingsUSD > 0 ? U.fmtUS4.format(simulation.potentialSavingsUSD) : '—'}</strong>
392
+ <p>{suggestions.length} 条模型切换建议 · 覆盖 {U.compactCN(suggestions.reduce((sum, row) => sum + row.totalTokens, 0))} tokens</p>
393
+ </div>
394
+ <div>
395
+ <span>当前官方价</span>
396
+ <strong>{simulation.totalCostUSD > 0 ? U.fmtUS4.format(simulation.totalCostUSD) : '—'}</strong>
397
+ <p>本期 {U.compactCN(simulation.totalTokens)} tokens;未定价 tokens 不进入美元节省判断。</p>
398
+ </div>
399
+ </div>
400
+
401
+ {suggestions.length ? (
402
+ <div className="savings-list">
403
+ {suggestions.map((item, index) => (
404
+ <article key={item.id} className="savings-card">
405
+ <div className="savings-rank">{String(index + 1).padStart(2, '0')}</div>
406
+ <div className="savings-body">
407
+ <div className="savings-head">
408
+ <div>
409
+ <span>{tierLabel(item.currentTier)} → {tierLabel(item.suggestedTier)}</span>
410
+ <h3>{item.title}</h3>
411
+ </div>
412
+ <strong>{U.fmtUS4.format(item.savingsUSD)}</strong>
413
+ </div>
414
+ <p>{item.recommendation}</p>
415
+ <div className="savings-metrics">
416
+ <SavingsMetric label="Sessions" value={item.sessionCount} />
417
+ <SavingsMetric label="Tokens" value={U.compactCN(item.totalTokens)} />
418
+ <SavingsMetric label="当前官方价" value={U.fmtUS4.format(item.currentCostUSD)} />
419
+ <SavingsMetric label="模拟后" value={U.fmtUS4.format(item.simulatedCostUSD)} />
420
+ </div>
421
+ <div className="savings-detail">
422
+ <span>为什么</span>
423
+ <p>{item.why}</p>
424
+ <span>建议动作</span>
425
+ <p>{item.action}</p>
426
+ <span>参考模型</span>
427
+ <p>{item.suggestedModels.join('、') || tierLabel(item.suggestedTier)}</p>
428
+ </div>
429
+ <AdvisorActionControls
430
+ existing={actionsByRule.get(`savings:${item.id}`)}
431
+ onAdd={() => onAddAction?.({
432
+ sourceRule: `savings:${item.id}`,
433
+ category: '节省模拟',
434
+ title: item.title,
435
+ action: item.action,
436
+ evidence: `${item.sessionCount} sessions · ${U.compactCN(item.totalTokens)} tokens · 可节省 ${U.fmtUS4.format(item.savingsUSD)}`
437
+ })}
438
+ onSetStatus={onSetActionStatus}
439
+ />
440
+ </div>
441
+ </article>
442
+ ))}
443
+ </div>
444
+ ) : (
445
+ <div className="no-data">当前周期没有触发可计算的官方价节省建议。高价值已完成/已发布任务不会被建议降级模型。</div>
446
+ )}
447
+
448
+ {unpriced.sessionCount > 0 && (
449
+ <div className="savings-unpriced">
450
+ <h3>未纳入成本决策的模型</h3>
451
+ <p>{unpriced.sessionCount} 个 session、{U.compactCN(unpriced.totalTokens)} tokens 没有公开官方美元价:{unpriced.models.join('、') || 'unknown'}。这些模型只用于 token 和产出复盘,不按 $0 计算节省。</p>
452
+ </div>
453
+ )}
454
+ </section>
455
+ );
456
+ }
457
+
458
+ function SavingsMetric({ label, value }) {
459
+ return (
460
+ <div>
461
+ <span>{label}</span>
462
+ <strong>{value}</strong>
463
+ </div>
464
+ );
465
+ }
466
+
467
+ // ───────────────────────────────────────────────────────────────
468
+ // ROI Advisor — local rule based recommendations
469
+ // ───────────────────────────────────────────────────────────────
470
+ function RoiAdvisorSection({ suggestions, actionsByRule = new Map(), onAddAction, onSetActionStatus }) {
471
+ const [copiedId, setCopiedId] = useState(null);
472
+ const copyAdvisor = async (item, mode) => {
473
+ const text = mode === 'action'
474
+ ? item.action
475
+ : [
476
+ item.title,
477
+ `建议分类:${item.category || '未分类'}`,
478
+ `影响级别:${item.impact}`,
479
+ `建议:${item.recommendation}`,
480
+ `原因:${item.reason}`,
481
+ `证据:${item.evidence}`,
482
+ `建议动作:${item.action}`
483
+ ].join('\n');
484
+ await copyText(text);
485
+ const id = `${item.id}:${mode}`;
486
+ setCopiedId(id);
487
+ window.setTimeout(() => setCopiedId(null), 1400);
488
+ };
489
+
490
+ if (!suggestions.length) {
491
+ return (
492
+ <section className="story roi-advisor-section">
493
+ <div className="section-label">07 · ROI 建议</div>
494
+ <h2 className="section-title">当前没有明显的 ROI 风险</h2>
495
+ <div className="no-data">本期没有触发模型选择、归因缺口或上下文效率建议。</div>
496
+ </section>
497
+ );
498
+ }
499
+
500
+ return (
501
+ <section className="story roi-advisor-section">
502
+ <div className="section-label">07 · ROI 建议</div>
503
+ <h2 className="section-title">用有限 token 做更高 ROI 的事</h2>
504
+ <p className="section-sub">这些建议只基于本地结构化数据和规则,不调用模型,不读取对话正文。</p>
505
+
506
+ <div className="advisor-list">
507
+ {suggestions.map((item, index) => (
508
+ <article key={item.id} className={`advisor-card advisor-${item.tone}`}>
509
+ <div className="advisor-rank">{String(index + 1).padStart(2, '0')}</div>
510
+ <div className="advisor-body">
511
+ <div className="advisor-head">
512
+ <div>
513
+ <div className="advisor-meta">
514
+ <span className="advisor-category">{item.category || '未分类'}</span>
515
+ <span className="advisor-impact">{item.impact}影响</span>
516
+ </div>
517
+ <h3>{item.title}</h3>
518
+ </div>
519
+ <span className="advisor-tone">{toneLabel(item.tone)}</span>
520
+ </div>
521
+ <p className="advisor-recommendation">{item.recommendation}</p>
522
+ <div className="advisor-grid">
523
+ <div>
524
+ <span>原因</span>
525
+ <p>{item.reason}</p>
526
+ </div>
527
+ <div>
528
+ <span>证据</span>
529
+ <p>{item.evidence}</p>
530
+ </div>
531
+ </div>
532
+ <div className="advisor-action">
533
+ <span>建议动作</span>
534
+ <strong>{item.action}</strong>
535
+ </div>
536
+ <div className="advisor-actions">
537
+ <button type="button" onClick={() => copyAdvisor(item, 'full')}>
538
+ {copiedId === `${item.id}:full` ? '已复制' : '复制建议'}
539
+ </button>
540
+ <button type="button" onClick={() => copyAdvisor(item, 'action')}>
541
+ {copiedId === `${item.id}:action` ? '已复制' : '复制行动项'}
542
+ </button>
543
+ </div>
544
+ <AdvisorActionControls
545
+ existing={actionsByRule.get(`advisor:${item.id}`)}
546
+ onAdd={() => onAddAction?.({
547
+ sourceRule: `advisor:${item.id}`,
548
+ category: item.category || 'ROI Advisor',
549
+ title: item.title,
550
+ action: item.action,
551
+ evidence: item.evidence
552
+ })}
553
+ onSetStatus={onSetActionStatus}
554
+ />
555
+ </div>
556
+ </article>
557
+ ))}
558
+ </div>
559
+ </section>
560
+ );
561
+ }
562
+
563
+ function AdvisorActionControls({ existing, onAdd, onSetStatus }) {
564
+ const [busy, setBusy] = useState(false);
565
+
566
+ const run = async (fn) => {
567
+ if (!fn) return;
568
+ setBusy(true);
569
+ try {
570
+ await fn();
571
+ } finally {
572
+ setBusy(false);
573
+ }
574
+ };
575
+
576
+ if (!existing) {
577
+ return (
578
+ <div className="advisor-action-loop">
579
+ <button type="button" disabled={busy} onClick={() => run(onAdd)}>
580
+ {busy ? '保存中' : '加入行动清单'}
581
+ </button>
582
+ </div>
583
+ );
584
+ }
585
+
586
+ return (
587
+ <div className="advisor-action-loop">
588
+ <span className={`advisor-action-status status-${existing.status}`}>
589
+ {existing.status === 'done' ? '已完成' : existing.status === 'dismissed' ? '已忽略' : '行动中'}
590
+ </span>
591
+ {existing.status !== 'done' && (
592
+ <button type="button" disabled={busy} onClick={() => run(() => onSetStatus?.(existing, 'done'))}>
593
+ 标为完成
594
+ </button>
595
+ )}
596
+ {existing.status !== 'dismissed' && (
597
+ <button type="button" disabled={busy} onClick={() => run(() => onSetStatus?.(existing, 'dismissed'))}>
598
+ 忽略本次
599
+ </button>
600
+ )}
601
+ {existing.status !== 'open' && (
602
+ <button type="button" disabled={busy} onClick={() => run(() => onSetStatus?.(existing, 'open'))}>
603
+ 重新打开
604
+ </button>
605
+ )}
606
+ </div>
607
+ );
608
+ }
609
+
610
+ function AdvisorActionSummarySection({ actions = [], period, onSetActionStatus }) {
611
+ const [busyId, setBusyId] = useState(null);
612
+ const counts = useMemo(() => ({
613
+ open: actions.filter(action => action.status === 'open').length,
614
+ done: actions.filter(action => action.status === 'done').length,
615
+ dismissed: actions.filter(action => action.status === 'dismissed').length
616
+ }), [actions]);
617
+ const ordered = useMemo(() => [...actions].sort((a, b) => {
618
+ const rank = { open: 0, done: 1, dismissed: 2 };
619
+ return (rank[a.status] ?? 3) - (rank[b.status] ?? 3)
620
+ || String(b.createdAt || '').localeCompare(String(a.createdAt || ''));
621
+ }), [actions]);
622
+
623
+ const setStatus = async (action, status) => {
624
+ setBusyId(action.id);
625
+ try {
626
+ await onSetActionStatus?.(action, status);
627
+ } finally {
628
+ setBusyId(null);
629
+ }
630
+ };
631
+
632
+ const exportActions = () => {
633
+ const lines = [
634
+ '# Token Studio Advisor Actions',
635
+ '',
636
+ `Period: ${period.start} to ${period.end}`,
637
+ '',
638
+ `- Open: ${counts.open}`,
639
+ `- Done: ${counts.done}`,
640
+ `- Dismissed: ${counts.dismissed}`,
641
+ '',
642
+ '## Actions',
643
+ ''
644
+ ];
645
+ if (!ordered.length) {
646
+ lines.push('No advisor actions in this period.');
647
+ } else {
648
+ for (const action of ordered) {
649
+ lines.push(`### [${action.status}] ${action.title}`);
650
+ lines.push(`- Category: ${action.category || 'ROI Advisor'}`);
651
+ lines.push(`- Action: ${action.action}`);
652
+ lines.push(`- Evidence: ${action.evidence || '—'}`);
653
+ lines.push(`- Rule: ${action.sourceRule || 'manual'}`);
654
+ lines.push('');
655
+ }
656
+ }
657
+ U.downloadText(`token-studio-actions-${period.start}-${period.end}.md`, lines.join('\n'), 'text/markdown;charset=utf-8');
658
+ };
659
+
660
+ return (
661
+ <section className="story advisor-action-summary-section">
662
+ <div className="section-label">08 · 行动清单</div>
663
+ <h2 className="section-title">建议有没有变成下一步动作</h2>
664
+ <p className="section-sub">这里只跟踪本期 Savings Simulator 和 ROI Advisor 生成的行动状态;完成行动不等同真实节省因果,只用于下次看同类 token / 官方价趋势。</p>
665
+
666
+ <div className="action-summary-hero">
667
+ <div>
668
+ <span>Open</span>
669
+ <strong>{counts.open}</strong>
670
+ <p>需要下周继续执行的模型、上下文或归因动作。</p>
671
+ </div>
672
+ <div>
673
+ <span>Done</span>
674
+ <strong>{counts.done}</strong>
675
+ <p>已完成动作会进入周报的行动状态。</p>
676
+ </div>
677
+ <div>
678
+ <span>Dismissed</span>
679
+ <strong>{counts.dismissed}</strong>
680
+ <p>忽略只代表本期不处理,不会删除原建议证据。</p>
681
+ </div>
682
+ </div>
683
+
684
+ <div className="action-summary-toolbar">
685
+ <button type="button" onClick={exportActions} disabled={!actions.length}>
686
+ 导出行动清单 Markdown
687
+ </button>
688
+ </div>
689
+
690
+ {ordered.length ? (
691
+ <div className="action-summary-list">
692
+ {ordered.map(action => (
693
+ <article key={action.id} className={`action-summary-card status-${action.status}`}>
694
+ <div className="action-summary-card-head">
695
+ <span>{action.category || 'ROI Advisor'}</span>
696
+ <b>{action.status === 'done' ? '已完成' : action.status === 'dismissed' ? '已忽略' : '行动中'}</b>
697
+ </div>
698
+ <h3>{action.title}</h3>
699
+ <p>{action.action}</p>
700
+ {action.evidence && <small>{action.evidence}</small>}
701
+ <div className="action-summary-card-actions">
702
+ {action.status !== 'done' && (
703
+ <button type="button" disabled={busyId === action.id} onClick={() => setStatus(action, 'done')}>标为完成</button>
704
+ )}
705
+ {action.status !== 'dismissed' && (
706
+ <button type="button" disabled={busyId === action.id} onClick={() => setStatus(action, 'dismissed')}>忽略本次</button>
707
+ )}
708
+ {action.status !== 'open' && (
709
+ <button type="button" disabled={busyId === action.id} onClick={() => setStatus(action, 'open')}>重新打开</button>
710
+ )}
711
+ </div>
712
+ </article>
713
+ ))}
714
+ </div>
715
+ ) : (
716
+ <div className="no-data">还没有行动项。先在“节省模拟”或“ROI 建议”里把可执行建议加入行动清单。</div>
717
+ )}
718
+ </section>
719
+ );
720
+ }
721
+
722
+ function toneLabel(tone) {
723
+ if (tone === 'risk') return '先处理';
724
+ if (tone === 'optimize') return '可优化';
725
+ if (tone === 'good') return '可复用';
726
+ return '需留意';
727
+ }
728
+
729
+ async function copyText(text) {
730
+ if (navigator.clipboard?.writeText) {
731
+ try {
732
+ await navigator.clipboard.writeText(text);
733
+ return;
734
+ } catch {
735
+ // Fall through to the hidden-textarea fallback for restricted contexts.
736
+ }
737
+ }
738
+ const el = document.createElement('textarea');
739
+ el.value = text;
740
+ el.setAttribute('readonly', '');
741
+ el.style.position = 'fixed';
742
+ el.style.left = '-9999px';
743
+ document.body.appendChild(el);
744
+ el.select();
745
+ document.execCommand('copy');
746
+ document.body.removeChild(el);
747
+ }
748
+
749
+ // ───────────────────────────────────────────────────────────────
750
+ // Model Strategy — what model should be used for which work
751
+ // ───────────────────────────────────────────────────────────────
752
+ function ModelStrategySection({ strategy }) {
753
+ if (!strategy) return null;
754
+ const coverage = strategy.coverage;
755
+ const taskRows = strategy.byTaskType.slice(0, 4);
756
+ const stageRows = strategy.byStage.slice(0, 4);
757
+ const valueRows = strategy.byValue.slice(0, 4);
758
+
759
+ return (
760
+ <section className="story model-strategy-section">
761
+ <div className="section-label">09 · 模型策略</div>
762
+ <h2 className="section-title">什么任务该用什么模型</h2>
763
+ <p className="section-sub">按任务类型、工作阶段和产出价值观察模型使用效果。这里不读取正文,只使用你手动标注后的结构化字段。</p>
764
+
765
+ <div className="strategy-coverage">
766
+ <div>
767
+ <span>策略样本覆盖</span>
768
+ <strong>{(coverage.annotatedShare * 100).toFixed(0)}%</strong>
769
+ <p>{coverage.annotatedSessionCount} / {coverage.sessionCount} 个 session 已有任务、阶段或价值标注</p>
770
+ </div>
771
+ <div>
772
+ <span>已归因 tokens</span>
773
+ <strong>{U.compactCN(coverage.annotatedTokens)}</strong>
774
+ <p>占本期 {coverage.totalTokens ? ((coverage.annotatedTokenShare) * 100).toFixed(1) : '0.0'}%</p>
775
+ </div>
776
+ </div>
777
+
778
+ <div className="strategy-playbook">
779
+ {strategy.playbook.map(row => (
780
+ <article key={row.id} className={`strategy-policy strategy-policy-${row.targetTier}`}>
781
+ <div className="strategy-policy-head">
782
+ <span>{row.label}</span>
783
+ <b>{row.evidenceState}</b>
784
+ </div>
785
+ <h3>{row.title}</h3>
786
+ <p>{row.action}</p>
787
+ <div className="strategy-policy-evidence">
788
+ <div>
789
+ <span>样本</span>
790
+ <strong>{row.sessionCount} sessions</strong>
791
+ </div>
792
+ <div>
793
+ <span>Tokens</span>
794
+ <strong>{U.compactCN(row.totalTokens)}</strong>
795
+ </div>
796
+ <div>
797
+ <span>常用模型</span>
798
+ <strong>{row.topModel}</strong>
799
+ </div>
800
+ <div>
801
+ <span>官方价</span>
802
+ <strong>{row.costUSD > 0 ? U.fmtUS.format(row.costUSD) : '—'}</strong>
803
+ </div>
804
+ </div>
805
+ </article>
806
+ ))}
807
+ </div>
808
+
809
+ <div className="strategy-grid">
810
+ <StrategyPanel title="按任务类型" rows={taskRows} empty="先给 session 标注任务类型,才能看到不同任务的模型策略。"/>
811
+ <StrategyPanel title="按工作阶段" rows={stageRows} empty="先标注探索、实现、验证、发布等阶段,才能判断模型切换时机。"/>
812
+ <StrategyPanel title="按价值等级" rows={valueRows} empty="先标注产出价值,才能识别高价值低成本的可复用模型组合。"/>
813
+ </div>
814
+
815
+ {strategy.riskModels.length > 0 && (
816
+ <div className="strategy-risk">
817
+ <h3>低价值 / 废弃成本集中在哪些模型</h3>
818
+ <div className="strategy-risk-list">
819
+ {strategy.riskModels.map(row => (
820
+ <div key={row.model} className="strategy-risk-row">
821
+ <div>
822
+ <strong>{row.model}</strong>
823
+ <span>{tierLabel(row.tier)} · {row.sessionCount} sessions</span>
824
+ </div>
825
+ <div>
826
+ <b>{U.compactCN(row.totalTokens)}</b>
827
+ <span>{row.costUSD > 0 ? U.fmtUS.format(row.costUSD) : '未定价/无官方价'}</span>
828
+ </div>
829
+ </div>
830
+ ))}
831
+ </div>
832
+ </div>
833
+ )}
834
+
835
+ <div className="strategy-recommendations">
836
+ {strategy.recommendations.map(item => (
837
+ <article key={item.id}>
838
+ <h3>{item.title}</h3>
839
+ <p>{item.detail}</p>
840
+ <strong>{item.action}</strong>
841
+ </article>
842
+ ))}
843
+ </div>
844
+ </section>
845
+ );
846
+ }
847
+
848
+ function StrategyPanel({ title, rows, empty }) {
849
+ return (
850
+ <div className="strategy-panel">
851
+ <h3>{title}</h3>
852
+ {rows.length ? rows.map(row => (
853
+ <div key={row.key} className="strategy-row">
854
+ <div>
855
+ <strong>{row.key}</strong>
856
+ <span>{row.sessionCount} sessions · 常用 {row.topModel}</span>
857
+ </div>
858
+ <div>
859
+ <b>{U.compactCN(row.totalTokens)}</b>
860
+ <span>{row.costUSD > 0 ? U.fmtUS.format(row.costUSD) : '—'}</span>
861
+ </div>
862
+ </div>
863
+ )) : <p className="strategy-empty">{empty}</p>}
864
+ </div>
865
+ );
866
+ }
867
+
868
+ function tierLabel(tier) {
869
+ if (tier === 'heavy') return '重模型';
870
+ if (tier === 'mid') return '中模型';
871
+ if (tier === 'light') return '轻量模型';
872
+ if (tier === 'unpriced') return '未定价';
873
+ return '未分层';
874
+ }
875
+
876
+ // ───────────────────────────────────────────────────────────────
877
+ // Insights — expandable cards
878
+ // ───────────────────────────────────────────────────────────────
879
+ function InsightsSection({ insights }) {
880
+ const [openIdx, setOpenIdx] = useState(null);
881
+
882
+ if (!insights.length) {
883
+ return (
884
+ <section className="story">
885
+ <div className="section-label">10 · 复盘</div>
886
+ <h2 className="section-title">几件值得复盘的小事</h2>
887
+ <div className="no-data">本期没有明显的异常或趋势变化。</div>
888
+ </section>
889
+ );
890
+ }
891
+
892
+ return (
893
+ <section className="story">
894
+ <div className="section-label">10 · 复盘</div>
895
+ <h2 className="section-title">几件值得复盘的小事</h2>
896
+ <p className="section-sub">基于你本期与上一周期的对比,自动挑出最值得关注的几条。点击展开看支撑数据。</p>
897
+
898
+ <div className="insights">
899
+ {insights.map((ins, i) => (
900
+ <div key={i} className={`insight ${openIdx === i ? 'open' : ''}`}
901
+ onClick={() => setOpenIdx(openIdx === i ? null : i)}>
902
+ <div className="insight-head">
903
+ <div className={`insight-emoji ${ins.kind}`}>{ins.emoji}</div>
904
+ <div className="insight-text">{ins.headline}</div>
905
+ <svg className="insight-arrow" width="14" height="14" viewBox="0 0 14 14" fill="none">
906
+ <path d="M3 5l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
907
+ </svg>
908
+ </div>
909
+ <div className="insight-body">
910
+ <div className="insight-detail">
911
+ {ins.detail.map((d, di) => (
912
+ <div key={di}>
913
+ <div className="k">{d.k}</div>
914
+ <div className="v">{d.v}</div>
915
+ </div>
916
+ ))}
917
+ </div>
918
+ <p className="insight-narrative">{ins.narrative}</p>
919
+ </div>
920
+ </div>
921
+ ))}
922
+ </div>
923
+ </section>
924
+ );
925
+ }
926
+
927
+ export { ToolsSection, EfficiencySection, ClosureProgressSection, RoiEvidenceSection, SavingsSimulatorSection, RoiAdvisorSection, AdvisorActionSummarySection, ModelStrategySection, InsightsSection };