@spzxg/cli-ui 0.6.2 → 0.6.5

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 (131) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +137 -11
  3. package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-D4qfPHMd.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +14 -8
  11. package/src/board/BrowserPane.tsx +160 -74
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +32 -22
  15. package/src/board/FilesPane.tsx +32 -15
  16. package/src/board/PanelControls.tsx +28 -0
  17. package/src/board/blocks.tsx +5 -5
  18. package/src/board/context.ts +65 -0
  19. package/src/board/fileOps.ts +26 -3
  20. package/src/board/fileWatch.ts +88 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/panels.ts +33 -0
  23. package/src/board/textLoad.ts +2 -2
  24. package/src/board/workspace.ts +38 -9
  25. package/src/chat/CliChat.tsx +73 -30
  26. package/src/chat/Composer.tsx +41 -41
  27. package/src/chat/RunControls.tsx +99 -0
  28. package/src/chat/configuration.ts +15 -0
  29. package/src/chat/draft.ts +41 -0
  30. package/src/chat/export.ts +2 -2
  31. package/src/components.tsx +13 -0
  32. package/src/core/blocks.ts +2 -2
  33. package/src/core/callbacks.ts +8 -0
  34. package/src/core/client.ts +8 -6
  35. package/src/core/conversation.ts +3 -3
  36. package/src/core/id.ts +15 -0
  37. package/src/core/identity.ts +33 -0
  38. package/src/core/provider.tsx +24 -14
  39. package/src/core/store.ts +83 -26
  40. package/src/core/types.ts +34 -0
  41. package/src/elements.tsx +135 -29
  42. package/src/embed.ts +6 -8
  43. package/src/index.ts +24 -17
  44. package/src/layout/CliSplit.tsx +1 -1
  45. package/src/msglist/CliMsgList.tsx +10 -10
  46. package/src/operator/branchFlow.ts +35 -0
  47. package/src/operator/index.ts +9 -0
  48. package/src/operator/liveStream.ts +1 -0
  49. package/src/operator/node/AgentConnect.tsx +624 -0
  50. package/src/operator/node/AppearancePanel.tsx +35 -0
  51. package/src/operator/node/BranchHistory.tsx +67 -0
  52. package/src/operator/node/BranchNavigator.tsx +36 -0
  53. package/src/operator/node/BrowserPane.tsx +128 -0
  54. package/src/operator/node/BrowserPopup.tsx +22 -0
  55. package/src/operator/node/Chat.tsx +517 -0
  56. package/src/operator/node/CommandPalette.tsx +213 -0
  57. package/src/operator/node/Composer.tsx +287 -0
  58. package/src/operator/node/InsightsView.tsx +831 -0
  59. package/src/operator/node/Layout.tsx +190 -0
  60. package/src/operator/node/Markdown.tsx +61 -0
  61. package/src/operator/node/Modal.tsx +179 -0
  62. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  63. package/src/operator/node/OnboardingWizard.tsx +250 -0
  64. package/src/operator/node/Orchestrator.tsx +634 -0
  65. package/src/operator/node/ProjectLanding.tsx +86 -0
  66. package/src/operator/node/ProjectsColumn.tsx +137 -0
  67. package/src/operator/node/ProviderAuth.tsx +561 -0
  68. package/src/operator/node/RunControls.tsx +2 -0
  69. package/src/operator/node/RunSwitch.tsx +113 -0
  70. package/src/operator/node/Services.tsx +245 -0
  71. package/src/operator/node/SessionRail.tsx +260 -0
  72. package/src/operator/node/SessionView.tsx +288 -0
  73. package/src/operator/node/SettingsView.tsx +690 -0
  74. package/src/operator/node/TaskBoard.tsx +352 -0
  75. package/src/operator/node/TaskChanges.tsx +530 -0
  76. package/src/operator/node/TaskHero.tsx +50 -0
  77. package/src/operator/node/TasksColumn.tsx +200 -0
  78. package/src/operator/node/Terminal.tsx +143 -0
  79. package/src/operator/node/Transcript.tsx +463 -0
  80. package/src/operator/node/ViewFile.tsx +79 -0
  81. package/src/operator/node/Welcome.tsx +101 -0
  82. package/src/operator/node/WorktreePrune.tsx +333 -0
  83. package/src/operator/node/agents.ts +58 -0
  84. package/src/operator/node/api.ts +40 -0
  85. package/src/operator/node/boardBackend.ts +80 -0
  86. package/src/operator/node/boardEvents.ts +22 -0
  87. package/src/operator/node/boardInteraction.ts +15 -0
  88. package/src/operator/node/boardState.ts +57 -0
  89. package/src/operator/node/boardWorkspace.ts +12 -0
  90. package/src/operator/node/branchFlow.ts +1 -0
  91. package/src/operator/node/composerDraft.ts +52 -0
  92. package/src/operator/node/fileLinks.ts +81 -0
  93. package/src/operator/node/format.ts +290 -0
  94. package/src/operator/node/github.tsx +234 -0
  95. package/src/operator/node/icons.tsx +88 -0
  96. package/src/operator/node/liveStream.ts +270 -0
  97. package/src/operator/node/modals.tsx +720 -0
  98. package/src/operator/node/navHistory.ts +70 -0
  99. package/src/operator/node/persist.ts +26 -0
  100. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  101. package/src/operator/node/protocol/agents/types.ts +394 -0
  102. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  103. package/src/operator/node/protocol/authFailure.ts +66 -0
  104. package/src/operator/node/protocol/customModels.ts +23 -0
  105. package/src/operator/node/protocol/events.ts +13 -0
  106. package/src/operator/node/protocol/features.ts +70 -0
  107. package/src/operator/node/protocol/internalUsage.ts +1 -0
  108. package/src/operator/node/protocol/promptLimits.ts +52 -0
  109. package/src/operator/node/protocol/storageRetention.ts +13 -0
  110. package/src/operator/node/protocol/types.ts +332 -0
  111. package/src/operator/node/protocol/usageLimit.ts +72 -0
  112. package/src/operator/node/shared.tsx +89 -0
  113. package/src/operator/node/transcriptModel.ts +129 -0
  114. package/src/operator/node/types.ts +296 -0
  115. package/src/operator/node/useGlobalEvents.ts +97 -0
  116. package/src/operator/node/useOrchestrator.ts +696 -0
  117. package/src/operator/node/usePrefs.ts +114 -0
  118. package/src/operator/node/useRecaps.ts +91 -0
  119. package/src/operator/node/useTaskStream.ts +279 -0
  120. package/src/operator.base.css +1798 -0
  121. package/src/operator.css +186 -16
  122. package/src/styles.css +20 -0
  123. package/src/ui/Markdown.tsx +7 -2
  124. package/src/ui/Popover.tsx +77 -0
  125. package/src/ui/TextBlock.tsx +3 -3
  126. package/src/ui/Transcript.tsx +10 -8
  127. package/src/ui/fileLinks.ts +6 -0
  128. package/src/ui/format.ts +1 -1
  129. package/src/ui/shared.tsx +1 -1
  130. package/src/ui/transcriptModel.ts +2 -2
  131. package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
@@ -0,0 +1,831 @@
1
+ "use client";
2
+
3
+ // Insights — the usage/analytics dashboard (opened from the top bar; replaces
4
+ // the tasks+session columns like Settings does). One fetch pulls per-day facts
5
+ // grouped by (day, project, agent) for the widest range plus the same width
6
+ // again (GET /api/insights), so every filter change — range, project, agent,
7
+ // cache toggle — recomputes locally without touching the server. Ported from
8
+ // the "Operator Insights" Claude Design mock; chart styling conventions:
9
+ // thin stacked columns with 2px gaps, a crosshair+tooltip hover layer, fixed
10
+ // per-entity hues that never repaint when filters change series count.
11
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
12
+ import { agentLabel, capsFor } from "./agents";
13
+ import { modelLabel } from "./format";
14
+ import type { AgentsBundle } from "./types";
15
+
16
+ // Mirrors InsightsData in lib/store.ts.
17
+ interface Payload {
18
+ projects: { id: string; name: string; color: string; deprecated: number }[];
19
+ usage: { d: string; p: string; a: string; cost: number; inp: number; out: number; cr: number; cw: number }[];
20
+ internal: { d: string; p: string; a: string; job: string; n: number; cost: number; inp: number; out: number; cr: number; cw: number }[];
21
+ shipped: { d: string; p: string; a: string; n: number }[];
22
+ merges: { d: string; p: string; a: string; add: number; del: number }[];
23
+ models: { a: string; m: string }[];
24
+ }
25
+
26
+ type Range = "7d" | "30d" | "90d";
27
+ interface DayRow {
28
+ key: string;
29
+ date: Date;
30
+ spend: number; inp: number; out: number; cr: number; cw: number; tokens: number;
31
+ overheadSpend: number; overheadFresh: number; overheadTokens: number;
32
+ tasks: number; add: number; del: number;
33
+ byAgent: Record<string, { spend: number; tokens: number; tasks: number }>;
34
+ }
35
+
36
+ // ---------- formatting ----------
37
+ const fmtMoney = (n: number) => "$" + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
38
+ const fmtCompact = (n: number) => {
39
+ n = Math.round(n);
40
+ if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
41
+ if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "k";
42
+ return String(n);
43
+ };
44
+ const fmtDate = (d: Date) => `${d.getMonth() + 1}/${d.getDate()}`;
45
+ const MONTHS = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"];
46
+ const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
47
+ const fmtDateLong = (d: Date) => `${MONTHS[d.getMonth()]}${d.getDate()}日 ${WEEKDAYS[d.getDay()]}`;
48
+
49
+ // Local-time YYYY-MM-DD — must match the server's `date(..., 'localtime')`
50
+ // bucketing (single-user local-first: the server's clock is the user's clock).
51
+ function dayKey(d: Date): string {
52
+ const m = String(d.getMonth() + 1).padStart(2, "0");
53
+ const day = String(d.getDate()).padStart(2, "0");
54
+ return `${d.getFullYear()}-${m}-${day}`;
55
+ }
56
+ function daysBack(n: number, endOffset: number): { key: string; date: Date }[] {
57
+ const out: { key: string; date: Date }[] = [];
58
+ const today = new Date();
59
+ today.setHours(12, 0, 0, 0); // noon: immune to DST hour shifts when stepping days
60
+ for (let k = n - 1 + endOffset; k >= endOffset; k--) {
61
+ const d = new Date(today);
62
+ d.setDate(today.getDate() - k);
63
+ out.push({ key: dayKey(d), date: d });
64
+ }
65
+ return out;
66
+ }
67
+
68
+ // Day-resolution "last active" (the facts cube has no finer grain).
69
+ function relDay(key: string): string {
70
+ const diff = Math.round((new Date(dayKey(new Date()) + "T12:00").getTime() - new Date(key + "T12:00").getTime()) / 86_400_000);
71
+ if (diff <= 0) return "今天";
72
+ if (diff === 1) return "昨天";
73
+ if (diff < 14) return `${diff} 天前`;
74
+ return `${Math.round(diff / 7)} 周前`;
75
+ }
76
+
77
+ // ---------- color assignment ----------
78
+ // Fixed per-entity hues: an agent keeps its color across every chart and filter
79
+ // state (color follows the entity, never its rank). Known drivers are pinned;
80
+ // any future driver takes the next unused hue in bundle order.
81
+ const HUE_POOL = ["var(--blue)", "var(--green)", "var(--amber)", "var(--coral)"];
82
+ const HUE_PINS: Record<string, string> = { claude: "var(--blue)", codex: "var(--green)" };
83
+ function agentHues(ids: string[]): Record<string, string> {
84
+ const used = new Set(ids.map((id) => HUE_PINS[id]).filter(Boolean));
85
+ const pool = HUE_POOL.filter((h) => !used.has(h));
86
+ const out: Record<string, string> = {};
87
+ let i = 0;
88
+ for (const id of ids) out[id] = HUE_PINS[id] ?? pool[i++ % pool.length];
89
+ return out;
90
+ }
91
+ const TOKEN_HUES = { inp: "var(--blue)", out: "var(--green)", cw: "var(--amber)", cr: "var(--coral)" } as const;
92
+ const OPERATOR_HUE = "var(--operator)";
93
+
94
+ const JOBS: Record<string, { label: string; settings: string }> = {
95
+ summarizeProjectRecap: { label: "项目回顾", settings: "run" },
96
+ summarizeTranscript: { label: "会话摘要(/clear)", settings: "general" },
97
+ draftProjectContext: { label: "上下文草稿(用 AI 刷新)", settings: "run" },
98
+ verify: { label: "智能体验证", settings: "agents" },
99
+ };
100
+
101
+ // ---------- chart primitives (ported from the design) ----------
102
+ function Sparkline({ vals, color, w = 120, h = 30 }: { vals: number[]; color: string; w?: number; h?: number }) {
103
+ const mx = Math.max(...vals, 1e-9);
104
+ const mn = Math.min(...vals, 0);
105
+ const span = mx - mn || 1;
106
+ const n = vals.length;
107
+ const pts = vals
108
+ .map((v, i) => {
109
+ const x = n <= 1 ? w / 2 : (i / (n - 1)) * w;
110
+ const y = h - 2 - ((v - mn) / span) * (h - 4);
111
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
112
+ })
113
+ .join(" ");
114
+ const area = `M0,${h} L${pts.split(" ").join(" L")} L${w},${h} Z`;
115
+ return (
116
+ <svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" style={{ display: "block" }}>
117
+ <path d={area} fill={color} opacity={0.1} />
118
+ <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" opacity={0.95} />
119
+ </svg>
120
+ );
121
+ }
122
+
123
+ interface TipData { title: string; rows: { label: string; val: string; color?: string; strong?: boolean }[] }
124
+ function Tip({ pct, data }: { pct: number; data: TipData }) {
125
+ const anchor =
126
+ pct < 22 ? { left: 0 } : pct > 78 ? { right: 0 } : { left: `${pct}%`, transform: "translateX(-50%)" };
127
+ return (
128
+ <div className="in-tip" style={anchor}>
129
+ <div className="in-tip-t">{data.title}</div>
130
+ {data.rows.map((r, i) => (
131
+ <div key={i} className="in-tip-row">
132
+ {r.color && <span className="in-tip-dot" style={{ background: r.color }} />}
133
+ <span className="in-tip-l">{r.label}</span>
134
+ <span className={`mono in-tip-v${r.strong ? " strong" : ""}`}>{r.val}</span>
135
+ </div>
136
+ ))}
137
+ </div>
138
+ );
139
+ }
140
+
141
+ type Hover = { chart: string | null; i: number | null };
142
+ interface DailyProps {
143
+ id: string;
144
+ rows: DayRow[];
145
+ max: number;
146
+ segsFor: (r: DayRow) => { v: number; color: string }[];
147
+ tip: (r: DayRow) => TipData;
148
+ hover: Hover;
149
+ onHover: (h: Hover) => void;
150
+ height?: number;
151
+ }
152
+
153
+ // Stacked daily columns + crosshair/tooltip hover layer. Flex column-reverse
154
+ // stacks segments from the baseline; non-hovered days dim while a day is active.
155
+ function DailyChart({ id, rows, max, segsFor, tip, hover, onHover, height = 152 }: DailyProps) {
156
+ const n = rows.length;
157
+ const active = hover.chart === id ? hover.i : null;
158
+ return (
159
+ <div style={{ position: "relative", height, width: "100%" }}>
160
+ {[0, 0.25, 0.5, 0.75, 1].map((g) => (
161
+ <div key={g} style={{ position: "absolute", left: 0, right: 0, bottom: `${g * 100}%`, height: 1, background: "var(--grid)" }} />
162
+ ))}
163
+ <div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "flex-end", gap: n > 45 ? 1 : 2, zIndex: 1 }}>
164
+ {rows.map((r, i) => {
165
+ const dim = active != null && active !== i;
166
+ return (
167
+ <div key={r.key} style={{ flex: 1, height: "100%", display: "flex", flexDirection: "column-reverse", opacity: dim ? 0.4 : 1, transition: "opacity .1s" }}>
168
+ {segsFor(r).map((s, si, arr) => (
169
+ <div
170
+ key={si}
171
+ style={{
172
+ height: `${Math.max(0, (s.v / max) * 100)}%`,
173
+ minHeight: s.v > 0 ? 1 : 0,
174
+ background: s.color,
175
+ borderRadius: si === arr.length - 1 ? "2px 2px 0 0" : 0,
176
+ }}
177
+ />
178
+ ))}
179
+ </div>
180
+ );
181
+ })}
182
+ </div>
183
+ {active != null && (
184
+ <>
185
+ <div style={{ position: "absolute", top: 0, bottom: 0, left: `${((active + 0.5) / n) * 100}%`, width: 1, background: "var(--line-strong)", zIndex: 2, pointerEvents: "none" }} />
186
+ <Tip pct={((active + 0.5) / n) * 100} data={tip(rows[active])} />
187
+ </>
188
+ )}
189
+ <div style={{ position: "absolute", inset: 0, display: "flex", zIndex: 3 }} onMouseLeave={() => onHover({ chart: null, i: null })}>
190
+ {rows.map((r, i) => (
191
+ <div key={r.key} style={{ flex: 1, cursor: "crosshair" }} onMouseEnter={() => onHover({ chart: id, i })} />
192
+ ))}
193
+ </div>
194
+ </div>
195
+ );
196
+ }
197
+
198
+ // Diverging daily columns around a zero baseline: additions up, deletions down.
199
+ function DivergingChart({ id, rows, max, tip, hover, onHover, height = 152 }: Omit<DailyProps, "segsFor" | "max"> & { max: number }) {
200
+ const n = rows.length;
201
+ const active = hover.chart === id ? hover.i : null;
202
+ return (
203
+ <div style={{ position: "relative", height, width: "100%" }}>
204
+ <div style={{ position: "absolute", left: 0, right: 0, top: "50%", height: 1, background: "var(--line-strong)", zIndex: 2 }} />
205
+ <div style={{ position: "absolute", inset: 0, display: "flex", gap: n > 45 ? 1 : 2, zIndex: 1 }}>
206
+ {rows.map((r, i) => {
207
+ const dim = active != null && active !== i;
208
+ return (
209
+ <div key={r.key} style={{ flex: 1, height: "100%", position: "relative", opacity: dim ? 0.4 : 1, transition: "opacity .1s" }}>
210
+ <div style={{ position: "absolute", left: "12%", right: "12%", bottom: "50%", height: `${Math.max(0, (r.add / max) * 49)}%`, minHeight: r.add > 0 ? 1 : 0, background: "var(--green)", borderRadius: "2px 2px 0 0" }} />
211
+ <div style={{ position: "absolute", left: "12%", right: "12%", top: "50%", height: `${Math.max(0, (r.del / max) * 49)}%`, minHeight: r.del > 0 ? 1 : 0, background: "var(--coral)", borderRadius: "0 0 2px 2px" }} />
212
+ </div>
213
+ );
214
+ })}
215
+ </div>
216
+ {active != null && (
217
+ <>
218
+ <div style={{ position: "absolute", top: 0, bottom: 0, left: `${((active + 0.5) / n) * 100}%`, width: 1, background: "var(--line-strong)", zIndex: 2, pointerEvents: "none" }} />
219
+ <Tip pct={((active + 0.5) / n) * 100} data={tip(rows[active])} />
220
+ </>
221
+ )}
222
+ <div style={{ position: "absolute", inset: 0, display: "flex", zIndex: 3 }} onMouseLeave={() => onHover({ chart: null, i: null })}>
223
+ {rows.map((r, i) => (
224
+ <div key={r.key} style={{ flex: 1, cursor: "crosshair" }} onMouseEnter={() => onHover({ chart: id, i })} />
225
+ ))}
226
+ </div>
227
+ </div>
228
+ );
229
+ }
230
+
231
+ // Evenly spaced date labels under a daily chart (5 for long ranges, all for 7d).
232
+ function XAxis({ rows }: { rows: DayRow[] }) {
233
+ const nLab = rows.length <= 7 ? rows.length : 5;
234
+ const labs: { left: string; text: string }[] = [];
235
+ for (let j = 0; j < nLab; j++) {
236
+ const idx = Math.round((j / (nLab - 1)) * (rows.length - 1));
237
+ labs.push({ left: `${(((idx + 0.5) / rows.length) * 100).toFixed(2)}%`, text: fmtDate(rows[idx].date) });
238
+ }
239
+ return (
240
+ <div style={{ position: "relative", height: 14 }}>
241
+ {labs.map((x, i) => (
242
+ <span key={i} className="mono in-xlab" style={{ left: x.left }}>{x.text}</span>
243
+ ))}
244
+ </div>
245
+ );
246
+ }
247
+
248
+ function ChartCard({ title, sub, right, children }: { title: string; sub: string; right?: ReactNode; children: ReactNode }) {
249
+ return (
250
+ <section className="in-card">
251
+ <div className="in-card-h">
252
+ <div>
253
+ <div className="in-card-t">{title}</div>
254
+ <div className="in-card-s">{sub}</div>
255
+ </div>
256
+ {right}
257
+ </div>
258
+ {children}
259
+ </section>
260
+ );
261
+ }
262
+
263
+ const LegendSwatch = ({ color, label, dim, onClick }: { color: string; label: string; dim?: boolean; onClick?: () => void }) => (
264
+ <span className="in-leg" style={dim ? { color: "var(--ink-4)" } : undefined}>
265
+ {onClick ? (
266
+ <button className="in-leg-btn" onClick={onClick} aria-pressed={!dim}>
267
+ <span className="in-leg-dot" style={{ background: color, opacity: dim ? 0.3 : 1 }} />{label}
268
+ </button>
269
+ ) : <><span className="in-leg-dot" style={{ background: color, opacity: dim ? 0.3 : 1 }} />{label}</>}
270
+ </span>
271
+ );
272
+
273
+ // ---------- the view ----------
274
+ export function InsightsView({ agents, onClose, onOpenSettings }: { agents: AgentsBundle; onClose: () => void; onOpenSettings: (section: string) => void }) {
275
+ const [data, setData] = useState<Payload | null>(null);
276
+ const [error, setError] = useState(false);
277
+ const [range, setRange] = useState<Range>("30d");
278
+ const [project, setProject] = useState<string>("all");
279
+ const [agent, setAgent] = useState<string>("all");
280
+ const [includeCache, setIncludeCache] = useState(false);
281
+ const [showOperator, setShowOperator] = useState(true);
282
+ const [menu, setMenu] = useState<"project" | "agent" | null>(null);
283
+ const [showAll, setShowAll] = useState(false);
284
+ const [hover, setHover] = useState<Hover>({ chart: null, i: null });
285
+
286
+ useEffect(() => {
287
+ fetch("/api/insights")
288
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
289
+ .then(setData)
290
+ .catch(() => setError(true));
291
+ }, []);
292
+
293
+ // Close any open dropdown on an outside click.
294
+ useEffect(() => {
295
+ if (!menu) return;
296
+ const close = () => setMenu(null);
297
+ window.addEventListener("click", close);
298
+ return () => window.removeEventListener("click", close);
299
+ }, [menu]);
300
+
301
+ const N = range === "7d" ? 7 : range === "90d" ? 90 : 30;
302
+ const rangeText = `最近 ${N} 天`;
303
+
304
+ const model = useMemo(() => {
305
+ if (!data) return null;
306
+ const matchP = (p: string) => project === "all" || p === project;
307
+ const matchA = (a: string) => agent === "all" || a === agent;
308
+
309
+ // Agent ids that ever appear (bundle first for stable hue order, then any
310
+ // id that exists only in historical data, e.g. a removed driver).
311
+ const agentIds = [...agents.agents.map((x) => x.id)];
312
+ for (const r of [...data.usage, ...data.internal]) if (!agentIds.includes(r.a)) agentIds.push(r.a);
313
+ const hues = agentHues(agentIds);
314
+
315
+ // ---- per-day rows for the current range + totals for the previous one ----
316
+ const blank = (key: string, date: Date): DayRow => ({ key, date, spend: 0, inp: 0, out: 0, cr: 0, cw: 0, tokens: 0, overheadSpend: 0, overheadFresh: 0, overheadTokens: 0, tasks: 0, add: 0, del: 0, byAgent: {} });
317
+ const rows = daysBack(N, 0).map(({ key, date }) => blank(key, date));
318
+ const byKey = new Map(rows.map((r) => [r.key, r]));
319
+ const prevKeys = new Set(daysBack(N, N).map((d) => d.key));
320
+ const prev = { spend: 0, tokens: 0, overheadSpend: 0, overheadTokens: 0, tasks: 0, add: 0, del: 0 };
321
+ const perAgent = (r: DayRow, a: string) => (r.byAgent[a] ??= { spend: 0, tokens: 0, tasks: 0 });
322
+
323
+ for (const u of data.usage) {
324
+ if (!matchP(u.p) || !matchA(u.a)) continue;
325
+ const tokens = u.inp + u.out + u.cr + u.cw;
326
+ const r = byKey.get(u.d);
327
+ if (r) {
328
+ r.spend += u.cost; r.inp += u.inp; r.out += u.out; r.cr += u.cr; r.cw += u.cw; r.tokens += tokens;
329
+ const pa = perAgent(r, u.a);
330
+ pa.spend += u.cost; pa.tokens += tokens;
331
+ } else if (prevKeys.has(u.d)) {
332
+ prev.spend += u.cost; prev.tokens += tokens;
333
+ }
334
+ }
335
+ for (const u of data.internal) {
336
+ if (!matchP(u.p) || !matchA(u.a)) continue;
337
+ const tokens = u.inp + u.out + u.cr + u.cw;
338
+ const r = byKey.get(u.d);
339
+ if (r) {
340
+ r.overheadSpend += u.cost;
341
+ r.overheadFresh += u.inp + u.out;
342
+ r.overheadTokens += tokens;
343
+ } else if (prevKeys.has(u.d)) {
344
+ prev.overheadSpend += u.cost;
345
+ prev.overheadTokens += tokens;
346
+ }
347
+ }
348
+ for (const s of data.shipped) {
349
+ if (!matchP(s.p) || !matchA(s.a)) continue;
350
+ const r = byKey.get(s.d);
351
+ if (r) { r.tasks += s.n; perAgent(r, s.a).tasks += s.n; }
352
+ else if (prevKeys.has(s.d)) prev.tasks += s.n;
353
+ }
354
+ for (const m of data.merges) {
355
+ if (!matchP(m.p) || !matchA(m.a)) continue;
356
+ const r = byKey.get(m.d);
357
+ if (r) { r.add += m.add; r.del += m.del; }
358
+ else if (prevKeys.has(m.d)) { prev.add += m.add; prev.del += m.del; }
359
+ }
360
+
361
+ const cur = { spend: 0, tokens: 0, overheadSpend: 0, overheadTokens: 0, tasks: 0, add: 0, del: 0 };
362
+ for (const r of rows) { cur.spend += r.spend; cur.tokens += r.tokens; cur.overheadSpend += r.overheadSpend; cur.overheadTokens += r.overheadTokens; cur.tasks += r.tasks; cur.add += r.add; cur.del += r.del; }
363
+ const activeDays = rows.filter((r) => r.spend > 0 || r.tokens > 0 || r.overheadSpend > 0 || r.overheadTokens > 0 || r.tasks > 0 || r.add > 0 || r.del > 0).length;
364
+
365
+ // Agents that actually have usage in the visible rows — drives the spend
366
+ // stack + provider panel, so a single-provider user sees a single series.
367
+ const presentAgents = agentIds.filter((a) => rows.some((r) => r.byAgent[a]));
368
+ const chartAgents = presentAgents.length ? presentAgents : agent === "all" ? [] : [agent];
369
+
370
+ // Agents whose cost is estimated from token counts rather than billed
371
+ // (capabilities.costIsEstimated) — their figures render with an ~.
372
+ const estIds = new Set(chartAgents.filter((a) => capsFor(agents, a)?.costIsEstimated));
373
+ const overheadEstimated = data.internal.some((u) => matchP(u.p) && matchA(u.a) && rows.some((r) => r.key === u.d) && capsFor(agents, u.a)?.costIsEstimated);
374
+
375
+ // ---- provider panel ----
376
+ const providers = chartAgents.map((a) => {
377
+ let spend = 0, tokens = 0, tasks = 0;
378
+ for (const r of rows) {
379
+ const pa = r.byAgent[a];
380
+ if (pa) { spend += pa.spend; tokens += pa.tokens; tasks += pa.tasks; }
381
+ }
382
+ const models = data.models
383
+ .filter((m) => m.a === a)
384
+ .map((m) => modelLabel(m.m, capsFor(agents, a)) || m.m);
385
+ return { id: a, spend, tokens, tasks, models: [...new Set(models)] };
386
+ });
387
+ const provSpendSum = providers.reduce((s, p) => s + p.spend, 0);
388
+
389
+ // ---- projects leaderboard (agent filter applies; project filter doesn't —
390
+ // clicking a row IS the project filter) ----
391
+ const dayIndex = new Map(rows.map((r, i) => [r.key, i]));
392
+ const perProject = new Map<string, { spend: number; tokens: number; tasks: number; add: number; del: number; lastKey: string; spark: number[] }>();
393
+ const proj = (p: string) => {
394
+ let e = perProject.get(p);
395
+ if (!e) { e = { spend: 0, tokens: 0, tasks: 0, add: 0, del: 0, lastKey: "", spark: rows.map(() => 0) }; perProject.set(p, e); }
396
+ return e;
397
+ };
398
+ const touch = (e: { lastKey: string }, d: string) => { if (d > e.lastKey) e.lastKey = d; };
399
+ for (const u of data.usage) {
400
+ if (!matchA(u.a)) continue;
401
+ const i = dayIndex.get(u.d);
402
+ if (i === undefined) continue;
403
+ const e = proj(u.p);
404
+ e.spend += u.cost; e.tokens += u.inp + u.out + u.cr + u.cw; e.spark[i] += u.cost; touch(e, u.d);
405
+ }
406
+ for (const s of data.shipped) {
407
+ if (!matchA(s.a)) continue;
408
+ if (dayIndex.get(s.d) === undefined) continue;
409
+ const e = proj(s.p);
410
+ e.tasks += s.n; touch(e, s.d);
411
+ }
412
+ for (const m of data.merges) {
413
+ if (!matchA(m.a)) continue;
414
+ if (dayIndex.get(m.d) === undefined) continue;
415
+ const e = proj(m.p);
416
+ e.add += m.add; e.del += m.del; touch(e, m.d);
417
+ }
418
+ const projMeta = new Map(data.projects.map((p) => [p.id, p]));
419
+ const projectRows = [...perProject.entries()]
420
+ .map(([id, e]) => ({ id, name: projMeta.get(id)?.name ?? "(deleted project)", color: projMeta.get(id)?.color ?? "var(--ink-4)", ...e }))
421
+ .sort((a, b) => b.spend - a.spend);
422
+
423
+ // ---- Operator convenience work, grouped into actionable job rows ----
424
+ const jobs = new Map<string, { job: string; n: number; tokens: number; cost: number; estimated: boolean; projects: Set<string> }>();
425
+ for (const u of data.internal) {
426
+ if (!matchP(u.p) || !matchA(u.a) || dayIndex.get(u.d) === undefined) continue;
427
+ const e = jobs.get(u.job) ?? { job: u.job, n: 0, tokens: 0, cost: 0, estimated: false, projects: new Set<string>() };
428
+ e.n += u.n;
429
+ e.tokens += u.inp + u.out + u.cr + u.cw;
430
+ e.cost += u.cost;
431
+ e.estimated ||= !!capsFor(agents, u.a)?.costIsEstimated;
432
+ e.projects.add(u.p);
433
+ jobs.set(u.job, e);
434
+ }
435
+ const jobRows = [...jobs.values()].sort((a, b) => b.cost - a.cost);
436
+
437
+ const isEmpty = data.usage.length === 0 && data.internal.length === 0 && data.shipped.length === 0 && data.merges.length === 0;
438
+
439
+ return { rows, cur, prev, activeDays, hues, chartAgents, estIds, overheadEstimated, providers, provSpendSum, projectRows, jobRows, isEmpty };
440
+ }, [data, N, project, agent, agents]);
441
+
442
+ const projName = (id: string) => data?.projects.find((p) => p.id === id)?.name ?? id;
443
+
444
+ if (error)
445
+ return (
446
+ <div className="col col-session insights">
447
+ <div className="in-wrap"><div className="in-card-s" style={{ padding: 40, textAlign: "center" }}>无法加载洞察数据 —— 请尝试刷新页面。</div></div>
448
+ </div>
449
+ );
450
+ if (!data || !model) return <div className="col col-session insights" />;
451
+
452
+ const { rows, cur, prev, activeDays, hues, chartAgents, estIds, overheadEstimated, providers, provSpendSum, projectRows, jobRows, isEmpty } = model;
453
+ // Spend framing when estimated-cost agents are in view: all-estimated vs a
454
+ // mix of billed + estimated figures.
455
+ const spendSub = estIds.size === 0 ? "等同 API 价格的折算值"
456
+ : estIds.size === chartAgents.length ? "按 token 数估算" : "等同 API 价格,部分为估算";
457
+
458
+ const delta = (c: number, p: number): { text: string; arrow: string; color: string } => {
459
+ if (!p || p <= 0) return { text: c > 0 ? "新增" : "—", arrow: "", color: "var(--ink-4)" };
460
+ const pct = Math.round(((c - p) / p) * 100);
461
+ if (pct === 0) return { text: "0%", arrow: "", color: "var(--ink-4)" };
462
+ return { text: `${Math.abs(pct)}%`, arrow: pct > 0 ? "▲ " : "▼ ", color: pct > 0 ? "var(--green)" : "var(--coral)" };
463
+ };
464
+
465
+ const totalSpend = cur.spend + cur.overheadSpend;
466
+ const prevTotalSpend = prev.spend + prev.overheadSpend;
467
+ const overheadPct = totalSpend > 0 ? (cur.overheadSpend / totalSpend) * 100 : 0;
468
+ const prevOverheadPct = prevTotalSpend > 0 ? (prev.overheadSpend / prevTotalSpend) * 100 : 0;
469
+ const overheadMark = overheadEstimated ? "~" : "";
470
+ const totalMark = overheadEstimated || estIds.size > 0 ? "~" : "";
471
+
472
+ const kpis: { label: string; value: ReactNode; sub: string; spark: ReactNode; d: { text: string; arrow: string; color: string } }[] = [
473
+ { label: "花费", value: <span className="kpi-big">{estIds.size === chartAgents.length && estIds.size > 0 && "~"}{fmtMoney(cur.spend)}</span>, sub: spendSub, spark: <Sparkline vals={rows.map((r) => r.spend)} color="var(--blue)" />, d: delta(cur.spend, prev.spend) },
474
+ { label: "Operator 额外开销", value: <span className="kpi-big">{overheadMark}{overheadPct.toFixed(overheadPct >= 10 ? 0 : 1)}%</span>, sub: `${overheadMark}${fmtMoney(cur.overheadSpend)} / ${totalMark}${fmtMoney(totalSpend)}`, spark: <Sparkline vals={rows.map((r) => r.overheadSpend)} color={OPERATOR_HUE} />, d: delta(overheadPct, prevOverheadPct) },
475
+ { label: "Token 用量", value: <span className="kpi-big">{fmtCompact(cur.tokens)}</span>, sub: "涵盖所有类别", spark: <Sparkline vals={rows.map((r) => r.tokens)} color="var(--green)" />, d: delta(cur.tokens, prev.tokens) },
476
+ { label: "已交付任务", value: <span className="kpi-big">{String(Math.round(cur.tasks))}</span>, sub: "已合并到基线分支", spark: <Sparkline vals={rows.map((r) => r.tasks)} color="var(--blue)" />, d: delta(cur.tasks, prev.tasks) },
477
+ {
478
+ label: "已合并代码行",
479
+ value: (
480
+ <span className="kpi-lines">
481
+ <span style={{ color: "var(--green)" }}>+{fmtCompact(cur.add)}</span>
482
+ <span style={{ color: "var(--coral)", fontSize: 18 }}>−{fmtCompact(cur.del)}</span>
483
+ </span>
484
+ ),
485
+ sub: "基线分支上的新增 / 删除", spark: <Sparkline vals={rows.map((r) => r.add)} color="var(--green)" />, d: delta(cur.add, prev.add),
486
+ },
487
+ { label: "活跃项目", value: <span className="kpi-big">{String(projectRows.length)}</span>, sub: "本周期内有活动", spark: <Sparkline vals={rows.map((r) => r.spend)} color="var(--ink-3)" />, d: { text: "", arrow: "", color: "var(--ink-4)" } },
488
+ ];
489
+
490
+ const spendMax = Math.max(...rows.map((r) => r.spend + (showOperator ? r.overheadSpend : 0)), 1e-9);
491
+ const tokenMax = Math.max(...rows.map((r) => (includeCache ? r.tokens : r.inp + r.out) + (showOperator ? (includeCache ? r.overheadTokens : r.overheadFresh) : 0)), 1e-9);
492
+ const taskMax = Math.max(...rows.map((r) => r.tasks), 1);
493
+ const mergedMax = Math.max(...rows.map((r) => Math.max(r.add, r.del)), 1);
494
+ const tokenCats: { k: "inp" | "out" | "cw" | "cr"; label: string }[] = includeCache
495
+ ? [{ k: "inp", label: "输入" }, { k: "out", label: "输出" }, { k: "cw", label: "缓存写入" }, { k: "cr", label: "缓存读取" }]
496
+ : [{ k: "inp", label: "输入" }, { k: "out", label: "输出" }];
497
+
498
+ const visibleProjects = showAll ? projectRows : projectRows.slice(0, 4);
499
+ const label = (id: string) => agentLabel(agents, id);
500
+
501
+ return (
502
+ <div className="col col-session insights">
503
+ <div className="in-wrap">
504
+ {/* header */}
505
+ <div className="in-head">
506
+ <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
507
+ <button className="in-back mono" onClick={onClose}>← 工作区</button>
508
+ <div>
509
+ <div className="in-title">洞察</div>
510
+ <div className="in-sub">你的工作台里程表 —— {rangeText},数据仅保存在本机。</div>
511
+ </div>
512
+ </div>
513
+ {!isEmpty && activeDays > 0 && activeDays < 7 && range !== "7d" && (
514
+ <span className="in-sparse mono">目前共有 {activeDays} 天有活动</span>
515
+ )}
516
+ </div>
517
+
518
+ {/* filter row */}
519
+ <div className="in-filters">
520
+ <div className="in-seg">
521
+ {(["7d", "30d", "90d"] as Range[]).map((v) => (
522
+ <button key={v} className={`mono${range === v ? " on" : ""}`} onClick={() => setRange(v)}>{v}</button>
523
+ ))}
524
+ </div>
525
+
526
+ <div style={{ position: "relative" }}>
527
+ <button className="in-dd" onClick={(e) => { e.stopPropagation(); setMenu(menu === "project" ? null : "project"); }}>
528
+ <span className="mono in-dd-k">项目</span>
529
+ <span className="in-dd-v">{project === "all" ? "全部项目" : projName(project)}</span>
530
+ <span className="in-dd-c">▾</span>
531
+ </button>
532
+ {menu === "project" && (
533
+ <div className="in-menu" onClick={(e) => e.stopPropagation()}>
534
+ {[{ id: "all", name: "全部项目" }, ...data.projects.filter((p) => !p.deprecated || perOf(projectRows, p.id))].map((p) => (
535
+ <button key={p.id} className={project === p.id ? "on" : ""} onClick={() => { setProject(p.id); setMenu(null); }}>
536
+ <span>{p.name}</span>
537
+ {p.id !== "all" && <span className="mono in-menu-m">{fmtMoney(perOf(projectRows, p.id)?.spend ?? 0)}</span>}
538
+ </button>
539
+ ))}
540
+ </div>
541
+ )}
542
+ </div>
543
+
544
+ <div style={{ position: "relative" }}>
545
+ <button className="in-dd" onClick={(e) => { e.stopPropagation(); setMenu(menu === "agent" ? null : "agent"); }}>
546
+ <span className="mono in-dd-k">智能体</span>
547
+ <span className="in-dd-v" style={{ display: "flex", alignItems: "center", gap: 7 }}>
548
+ <span className="in-leg-dot" style={{ background: agent === "all" ? "var(--ink-3)" : hues[agent], borderRadius: "50%" }} />
549
+ {agent === "all" ? "全部智能体" : label(agent)}
550
+ </span>
551
+ <span className="in-dd-c">▾</span>
552
+ </button>
553
+ {menu === "agent" && (
554
+ <div className="in-menu" onClick={(e) => e.stopPropagation()}>
555
+ {[{ id: "all" }, ...agents.agents].map((a) => (
556
+ <button key={a.id} className={agent === a.id ? "on" : ""} onClick={() => { setAgent(a.id); setMenu(null); }}>
557
+ <span style={{ display: "flex", alignItems: "center", gap: 9 }}>
558
+ <span className="in-leg-dot" style={{ background: a.id === "all" ? "var(--ink-3)" : hues[a.id], borderRadius: "50%" }} />
559
+ {a.id === "all" ? "全部智能体" : label(a.id)}
560
+ </span>
561
+ </button>
562
+ ))}
563
+ </div>
564
+ )}
565
+ </div>
566
+
567
+ {project !== "all" && (
568
+ <button className="in-clear mono" onClick={() => setProject("all")}>✕ 清除项目筛选</button>
569
+ )}
570
+ </div>
571
+
572
+ {isEmpty ? (
573
+ <div className="in-empty">
574
+ <div className="in-empty-bars">
575
+ {[16, 34, 24, 46, 28].map((h, i) => (<span key={i} style={{ height: h }} />))}
576
+ </div>
577
+ <div style={{ maxWidth: 460 }}>
578
+ <div className="in-empty-t">暂无活动</div>
579
+ <div className="in-empty-s">
580
+ 随着智能体开始工作,这里会逐渐填满。启动一个会话,本页面就会开始统计
581
+ 花费、token、已交付任务和已合并代码 —— 全部在本地计算,数据不会离开这台机器。
582
+ </div>
583
+ </div>
584
+ <button className="btn btn-accent" onClick={onClose}>开始一个会话</button>
585
+ <div className="mono in-empty-f">本地优先 · 单用户 · 无需账号</div>
586
+ </div>
587
+ ) : (
588
+ <>
589
+ {/* KPI row */}
590
+ <div className="in-kpis">
591
+ {kpis.map((k) => (
592
+ <div key={k.label} className="in-kpi">
593
+ <div className="in-kpi-h">
594
+ <span className="mono in-kpi-l">{k.label}</span>
595
+ <span className="mono in-kpi-d" style={{ color: k.d.color }}>{k.d.arrow}{k.d.text}</span>
596
+ </div>
597
+ <div style={{ marginTop: 11, marginBottom: 3 }}>{k.value}</div>
598
+ <div className="in-kpi-s">{k.sub}</div>
599
+ <div style={{ marginTop: "auto", paddingTop: 12 }}>{k.spark}</div>
600
+ </div>
601
+ ))}
602
+ </div>
603
+
604
+ {/* charts */}
605
+ <div className="in-grid">
606
+ <ChartCard
607
+ title="每日花费" sub="按天统计的 API 折算价"
608
+ right={<div className="in-legend">
609
+ {chartAgents.map((a) => <LegendSwatch key={a} color={hues[a]} label={label(a)} />)}
610
+ <LegendSwatch color={OPERATOR_HUE} label="Operator 自身" dim={!showOperator} onClick={() => setShowOperator((v) => !v)} />
611
+ </div>}
612
+ >
613
+ <DailyChart
614
+ id="spend" rows={rows} max={spendMax} hover={hover} onHover={setHover}
615
+ segsFor={(r) => [
616
+ ...chartAgents.map((a) => ({ v: r.byAgent[a]?.spend ?? 0, color: hues[a] })),
617
+ ...(showOperator ? [{ v: r.overheadSpend, color: OPERATOR_HUE }] : []),
618
+ ]}
619
+ tip={(r) => ({
620
+ title: fmtDateLong(r.date),
621
+ rows: [
622
+ ...chartAgents.map((a) => ({ label: label(a), val: `${estIds.has(a) ? "~" : ""}${fmtMoney(r.byAgent[a]?.spend ?? 0)}`, color: hues[a] })),
623
+ ...(showOperator ? [{ label: "Operator 自身", val: `${overheadMark}${fmtMoney(r.overheadSpend)}`, color: OPERATOR_HUE }] : []),
624
+ { label: "合计", val: `${totalMark}${fmtMoney(r.spend + (showOperator ? r.overheadSpend : 0))}`, strong: true },
625
+ ],
626
+ })}
627
+ />
628
+ <XAxis rows={rows} />
629
+ </ChartCard>
630
+
631
+ <ChartCard
632
+ title="每日 token" sub="按类别拆分"
633
+ right={
634
+ <label className="in-switch-l mono">
635
+ 含缓存
636
+ <button
637
+ role="switch" aria-checked={includeCache} className={`in-switch${includeCache ? " on" : ""}`}
638
+ onClick={() => setIncludeCache((v) => !v)}
639
+ ><span /></button>
640
+ </label>
641
+ }
642
+ >
643
+ <div className="in-legend" style={{ marginBottom: 4 }}>
644
+ {([{ k: "inp", label: "输入" }, { k: "out", label: "输出" }, { k: "cr", label: "缓存读取" }, { k: "cw", label: "缓存写入" }] as const).map((c) => (
645
+ <LegendSwatch key={c.k} color={TOKEN_HUES[c.k]} label={c.label} dim={!includeCache && c.k !== "inp" && c.k !== "out"} />
646
+ ))}
647
+ <LegendSwatch color={OPERATOR_HUE} label="Operator 自身" dim={!showOperator} onClick={() => setShowOperator((v) => !v)} />
648
+ </div>
649
+ <DailyChart
650
+ id="tokens" rows={rows} max={tokenMax} hover={hover} onHover={setHover}
651
+ segsFor={(r) => [
652
+ ...tokenCats.map((c) => ({ v: r[c.k], color: TOKEN_HUES[c.k] })),
653
+ ...(showOperator ? [{ v: includeCache ? r.overheadTokens : r.overheadFresh, color: OPERATOR_HUE }] : []),
654
+ ]}
655
+ tip={(r) => ({
656
+ title: fmtDateLong(r.date),
657
+ rows: [
658
+ ...tokenCats.map((c) => ({ label: c.label, val: fmtCompact(r[c.k]), color: TOKEN_HUES[c.k] })),
659
+ ...(showOperator ? [{ label: "Operator 自身", val: fmtCompact(includeCache ? r.overheadTokens : r.overheadFresh), color: OPERATOR_HUE }] : []),
660
+ { label: includeCache ? "合计" : "新增合计", val: fmtCompact((includeCache ? r.tokens : r.inp + r.out) + (showOperator ? (includeCache ? r.overheadTokens : r.overheadFresh) : 0)), strong: true },
661
+ ],
662
+ })}
663
+ />
664
+ <XAxis rows={rows} />
665
+ </ChartCard>
666
+
667
+ <ChartCard
668
+ title="每日交付任务" sub="已合并到基线分支"
669
+ right={<span className="mono in-card-n">共 {Math.round(cur.tasks)} 个</span>}
670
+ >
671
+ <DailyChart
672
+ id="tasks" rows={rows} max={taskMax} hover={hover} onHover={setHover}
673
+ segsFor={(r) => [{ v: r.tasks, color: "var(--blue)" }]}
674
+ tip={(r) => ({ title: fmtDateLong(r.date), rows: [{ label: "已交付任务", val: String(Math.round(r.tasks)), color: "var(--blue)" }] })}
675
+ />
676
+ <XAxis rows={rows} />
677
+ </ChartCard>
678
+
679
+ <ChartCard
680
+ title="每日合并代码" sub="新增 / 删除行数"
681
+ right={
682
+ <div className="in-legend">
683
+ <LegendSwatch color="var(--green)" label="新增" />
684
+ <LegendSwatch color="var(--coral)" label="删除" />
685
+ </div>
686
+ }
687
+ >
688
+ <DivergingChart
689
+ id="merged" rows={rows} max={mergedMax} hover={hover} onHover={setHover}
690
+ tip={(r) => ({
691
+ title: fmtDateLong(r.date),
692
+ rows: [
693
+ { label: "新增", val: `+${fmtCompact(r.add)}`, color: "var(--green)" },
694
+ { label: "删除", val: `−${fmtCompact(r.del)}`, color: "var(--coral)" },
695
+ ],
696
+ })}
697
+ />
698
+ <XAxis rows={rows} />
699
+ </ChartCard>
700
+ </div>
701
+
702
+ {/* Operator convenience work */}
703
+ <section className="in-card" style={{ padding: 0, overflow: "hidden", marginBottom: 16 }}>
704
+ <div className="in-card-h" style={{ padding: "17px 20px 14px" }}>
705
+ <div>
706
+ <div className="in-card-t">Operator 自身用量</div>
707
+ <div className="in-card-s">在任务对话之外运行智能体的便利功能 · {rangeText}</div>
708
+ </div>
709
+ <span className="mono in-card-n">合计 {overheadMark}{fmtMoney(cur.overheadSpend)}</span>
710
+ </div>
711
+ <div className="in-otable">
712
+ <div className="in-orow in-ohead mono">
713
+ <span>功能</span><span>运行次数</span><span>TOKEN</span><span>总花费</span><span>每次均值</span><span>设置</span>
714
+ </div>
715
+ {jobRows.map((j) => {
716
+ const meta = JOBS[j.job] ?? { label: j.job, settings: "run" };
717
+ const mark = j.estimated ? "~" : "";
718
+ return (
719
+ <div key={j.job} className="in-orow">
720
+ <span>
721
+ <span className="in-oname">{meta.label}</span>
722
+ <span className="mono in-oscope">{[...j.projects].map((p) => p ? projName(p) : "—").join(" · ")}</span>
723
+ </span>
724
+ <span className="mono dim">{j.n.toLocaleString()}</span>
725
+ <span className="mono dim">{fmtCompact(j.tokens)}</span>
726
+ <span className="mono" title={j.estimated ? "按 token 数 × 官方 API 价格估算" : undefined}>{mark}{fmtMoney(j.cost)}</span>
727
+ <span className="mono in-oavg" title={j.estimated ? "按 token 数 × 官方 API 价格估算" : undefined}>{mark}{fmtMoney(j.n > 0 ? j.cost / j.n : 0)}</span>
728
+ <button className="in-olink" onClick={() => onOpenSettings(meta.settings)}>设置 →</button>
729
+ </div>
730
+ );
731
+ })}
732
+ {jobRows.length === 0 && (
733
+ <div className="in-oempty">本周期内没有 Operator 的辅助工作。项目回顾、会话摘要、上下文草稿和智能体验证会计入这里 —— 你的任务对话不计入。</div>
734
+ )}
735
+ </div>
736
+ </section>
737
+
738
+ {/* by provider */}
739
+ <section className="in-card" style={{ marginBottom: 16 }}>
740
+ <div className="in-card-h" style={{ marginBottom: 16 }}>
741
+ <div>
742
+ <div className="in-card-t">按提供方</div>
743
+ <div className="in-card-s">任务花费占比 · {rangeText}</div>
744
+ </div>
745
+ <span className="mono in-card-n">合计 {fmtMoney(cur.spend)}</span>
746
+ </div>
747
+ <div className="in-provbar">
748
+ {providers.map((p) => (
749
+ <div key={p.id} title={label(p.id)} style={{ width: `${provSpendSum > 0 ? (p.spend / provSpendSum) * 100 : 100 / providers.length}%`, background: hues[p.id] }} />
750
+ ))}
751
+ </div>
752
+ <div className="in-provtable">
753
+ <div className="in-provhead mono">
754
+ <span>提供方</span><span>花费</span><span>TOKEN</span><span>任务</span>
755
+ </div>
756
+ {providers.map((p) => (
757
+ <div key={p.id} className="in-provrow">
758
+ <span style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
759
+ <span className="in-leg-dot" style={{ background: hues[p.id] }} />
760
+ <span style={{ minWidth: 0 }}>
761
+ <span className="in-provname">{label(p.id)}</span>
762
+ <span className="mono in-provmodels">{p.models.length ? p.models.join(" · ") : "—"}</span>
763
+ </span>
764
+ </span>
765
+ <span className="mono" title={estIds.has(p.id) ? "按 token 数 × 官方 API 价格估算" : undefined}>{estIds.has(p.id) && "~"}{fmtMoney(p.spend)}</span>
766
+ <span className="mono dim">{fmtCompact(p.tokens)}</span>
767
+ <span className="mono dim">{String(Math.round(p.tasks))}</span>
768
+ </div>
769
+ ))}
770
+ {providers.length === 0 && <div className="in-provrow"><span className="in-card-s">本周期内没有用量。</span></div>}
771
+ </div>
772
+ </section>
773
+
774
+ {/* projects leaderboard */}
775
+ <section className="in-card" style={{ padding: 0, overflow: "hidden" }}>
776
+ <div className="in-card-h" style={{ padding: "17px 20px 14px" }}>
777
+ <div>
778
+ <div className="in-card-t">项目</div>
779
+ <div className="in-card-s">点击某一行可筛选整个面板</div>
780
+ </div>
781
+ <span className="mono in-card-n">{projectRows.length} 个活跃</span>
782
+ </div>
783
+ <div className="in-ptable">
784
+ <div className="in-prow in-phead mono">
785
+ <span>项目</span><span>花费</span><span>TOKEN</span><span>任务</span><span>行数</span><span>最近活动</span><span>{N} 天活跃度</span>
786
+ </div>
787
+ {visibleProjects.map((p) => (
788
+ <button
789
+ key={p.id}
790
+ className={`in-prow in-pbtn${project === p.id ? " sel" : ""}`}
791
+ style={project === p.id ? { borderLeftColor: p.color } : undefined}
792
+ onClick={() => setProject(project === p.id ? "all" : p.id)}
793
+ >
794
+ <span style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
795
+ <span className="in-leg-dot" style={{ background: p.color, borderRadius: "50%" }} />
796
+ <span className="in-pname">{p.name}</span>
797
+ </span>
798
+ <span className="mono">{fmtMoney(p.spend)}</span>
799
+ <span className="mono dim">{fmtCompact(p.tokens)}</span>
800
+ <span className="mono dim">{String(Math.round(p.tasks))}</span>
801
+ <span className="mono" style={{ fontSize: 12 }}>
802
+ <span style={{ color: "var(--green)" }}>+{fmtCompact(p.add)}</span>{" "}
803
+ <span style={{ color: "var(--coral)" }}>−{fmtCompact(p.del)}</span>
804
+ </span>
805
+ <span className="mono dim" style={{ fontSize: 11.5 }}>{p.lastKey ? relDay(p.lastKey) : "—"}</span>
806
+ <span style={{ display: "flex", justifyContent: "flex-end" }}>
807
+ <span style={{ width: 120 }}><Sparkline vals={p.spark} color={p.color} h={26} /></span>
808
+ </span>
809
+ </button>
810
+ ))}
811
+ {projectRows.length === 0 && (
812
+ <div className="in-prow"><span className="in-card-s" style={{ gridColumn: "1 / -1" }}>本周期内没有项目活动。</span></div>
813
+ )}
814
+ {projectRows.length > 4 && (
815
+ <button className="in-pmore mono" onClick={() => setShowAll((v) => !v)}>
816
+ {showAll ? "▲ 收起" : `▾ 展开其余 ${projectRows.length - 4} 个`}
817
+ </button>
818
+ )}
819
+ </div>
820
+ </section>
821
+ </>
822
+ )}
823
+ </div>
824
+ </div>
825
+ );
826
+ }
827
+
828
+ // Row lookup for the project dropdown's spend column.
829
+ function perOf<T extends { id: string }>(rows: T[], id: string): T | undefined {
830
+ return rows.find((r) => r.id === id);
831
+ }