ai-token-usage-lite 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +24 -0
- package/README.md +106 -0
- package/bin/ai-token-usage-lite.js +8 -0
- package/package.json +40 -0
- package/public/index.html +1003 -0
- package/src/aggregate.js +100 -0
- package/src/cli.js +115 -0
- package/src/doctor.js +30 -0
- package/src/export.js +32 -0
- package/src/fs-utils.js +65 -0
- package/src/paths.js +16 -0
- package/src/providers/claude.js +69 -0
- package/src/providers/codex.js +127 -0
- package/src/server.js +117 -0
- package/src/status.js +40 -0
- package/src/sync.js +33 -0
- package/src/turns/aggregate-turns.js +68 -0
- package/src/turns/claude-turns.js +120 -0
- package/src/turns/codex-turns.js +117 -0
- package/src/turns/common.js +42 -0
- package/src/turns/index.js +15 -0
- package/src/turns/skill-detect.js +65 -0
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>AI Token 用量看板</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: light dark;
|
|
10
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
11
|
+
background: #08111f;
|
|
12
|
+
color: #ecf5ff;
|
|
13
|
+
--page: #08111f;
|
|
14
|
+
--panel: rgba(13, 24, 42, 0.92);
|
|
15
|
+
--panel-strong: rgba(17, 31, 53, 0.98);
|
|
16
|
+
--line: rgba(126, 164, 209, 0.22);
|
|
17
|
+
--muted: #8ea4bc;
|
|
18
|
+
--text: #ecf5ff;
|
|
19
|
+
--cyan: #3be0d0;
|
|
20
|
+
--blue: #5b8cff;
|
|
21
|
+
--green: #7bd66f;
|
|
22
|
+
--amber: #f4c255;
|
|
23
|
+
--red: #ff6f91;
|
|
24
|
+
--shadow: 0 24px 70px rgba(0, 0, 0, 0.32);
|
|
25
|
+
}
|
|
26
|
+
body {
|
|
27
|
+
margin: 0;
|
|
28
|
+
min-height: 100vh;
|
|
29
|
+
background:
|
|
30
|
+
linear-gradient(rgba(116, 164, 255, 0.06) 1px, transparent 1px),
|
|
31
|
+
linear-gradient(90deg, rgba(116, 164, 255, 0.05) 1px, transparent 1px),
|
|
32
|
+
radial-gradient(circle at 52% -10%, rgba(59, 224, 208, 0.18), transparent 32%),
|
|
33
|
+
var(--page);
|
|
34
|
+
background-size: 32px 32px, 32px 32px, auto, auto;
|
|
35
|
+
}
|
|
36
|
+
main {
|
|
37
|
+
max-width: 1240px;
|
|
38
|
+
margin: 0 auto;
|
|
39
|
+
padding: 30px 20px 56px;
|
|
40
|
+
}
|
|
41
|
+
header {
|
|
42
|
+
display: flex;
|
|
43
|
+
justify-content: space-between;
|
|
44
|
+
gap: 16px;
|
|
45
|
+
align-items: flex-start;
|
|
46
|
+
margin-bottom: 18px;
|
|
47
|
+
}
|
|
48
|
+
h1 {
|
|
49
|
+
margin: 0;
|
|
50
|
+
font-size: 30px;
|
|
51
|
+
line-height: 1.2;
|
|
52
|
+
letter-spacing: 0;
|
|
53
|
+
}
|
|
54
|
+
.muted {
|
|
55
|
+
color: var(--muted);
|
|
56
|
+
font-size: 13px;
|
|
57
|
+
}
|
|
58
|
+
.eyebrow {
|
|
59
|
+
display: inline-flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
gap: 8px;
|
|
62
|
+
margin-bottom: 10px;
|
|
63
|
+
color: var(--cyan);
|
|
64
|
+
font-size: 12px;
|
|
65
|
+
font-weight: 700;
|
|
66
|
+
letter-spacing: 0;
|
|
67
|
+
text-transform: uppercase;
|
|
68
|
+
}
|
|
69
|
+
.dot {
|
|
70
|
+
width: 8px;
|
|
71
|
+
height: 8px;
|
|
72
|
+
border-radius: 50%;
|
|
73
|
+
background: var(--green);
|
|
74
|
+
box-shadow: 0 0 18px rgba(123, 214, 111, 0.8);
|
|
75
|
+
}
|
|
76
|
+
.timestamp {
|
|
77
|
+
min-width: 230px;
|
|
78
|
+
text-align: right;
|
|
79
|
+
padding-top: 6px;
|
|
80
|
+
}
|
|
81
|
+
.tabs {
|
|
82
|
+
display: flex;
|
|
83
|
+
flex-wrap: wrap;
|
|
84
|
+
gap: 8px;
|
|
85
|
+
margin-bottom: 12px;
|
|
86
|
+
}
|
|
87
|
+
.tab-button {
|
|
88
|
+
min-height: 34px;
|
|
89
|
+
padding: 0 13px;
|
|
90
|
+
border: 1px solid rgba(142, 164, 188, 0.22);
|
|
91
|
+
border-radius: 8px;
|
|
92
|
+
background: rgba(13, 24, 42, 0.68);
|
|
93
|
+
color: #d8e8f9;
|
|
94
|
+
font: inherit;
|
|
95
|
+
font-size: 13px;
|
|
96
|
+
cursor: pointer;
|
|
97
|
+
}
|
|
98
|
+
.tab-button.active {
|
|
99
|
+
border-color: rgba(59, 224, 208, 0.58);
|
|
100
|
+
background: linear-gradient(135deg, rgba(91, 140, 255, 0.22), rgba(59, 224, 208, 0.16));
|
|
101
|
+
color: #ffffff;
|
|
102
|
+
box-shadow: 0 0 24px rgba(59, 224, 208, 0.14);
|
|
103
|
+
}
|
|
104
|
+
.filter-bar {
|
|
105
|
+
display: grid;
|
|
106
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
107
|
+
gap: 10px;
|
|
108
|
+
margin-bottom: 16px;
|
|
109
|
+
padding: 12px;
|
|
110
|
+
border: 1px solid var(--line);
|
|
111
|
+
border-radius: 8px;
|
|
112
|
+
background: rgba(13, 24, 42, 0.72);
|
|
113
|
+
}
|
|
114
|
+
.filter-field {
|
|
115
|
+
display: grid;
|
|
116
|
+
gap: 6px;
|
|
117
|
+
min-width: 0;
|
|
118
|
+
}
|
|
119
|
+
.filter-field label {
|
|
120
|
+
color: var(--muted);
|
|
121
|
+
font-size: 11px;
|
|
122
|
+
font-weight: 700;
|
|
123
|
+
}
|
|
124
|
+
select {
|
|
125
|
+
width: 100%;
|
|
126
|
+
min-height: 34px;
|
|
127
|
+
border: 1px solid rgba(142, 164, 188, 0.28);
|
|
128
|
+
border-radius: 8px;
|
|
129
|
+
background: rgba(8, 17, 31, 0.92);
|
|
130
|
+
color: var(--text);
|
|
131
|
+
padding: 0 10px;
|
|
132
|
+
font: inherit;
|
|
133
|
+
font-size: 13px;
|
|
134
|
+
}
|
|
135
|
+
.hero {
|
|
136
|
+
display: grid;
|
|
137
|
+
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.65fr);
|
|
138
|
+
gap: 16px;
|
|
139
|
+
margin-bottom: 16px;
|
|
140
|
+
}
|
|
141
|
+
.hero-total {
|
|
142
|
+
position: relative;
|
|
143
|
+
overflow: hidden;
|
|
144
|
+
background: linear-gradient(135deg, rgba(21, 41, 69, 0.96), rgba(9, 21, 38, 0.98));
|
|
145
|
+
border: 1px solid rgba(91, 140, 255, 0.34);
|
|
146
|
+
border-radius: 8px;
|
|
147
|
+
padding: 20px;
|
|
148
|
+
box-shadow: var(--shadow);
|
|
149
|
+
}
|
|
150
|
+
.hero-total::before {
|
|
151
|
+
content: "";
|
|
152
|
+
position: absolute;
|
|
153
|
+
inset: 0;
|
|
154
|
+
background:
|
|
155
|
+
linear-gradient(120deg, transparent 0%, rgba(59, 224, 208, 0.08) 42%, transparent 78%),
|
|
156
|
+
repeating-linear-gradient(90deg, rgba(255,255,255,0.035) 0 1px, transparent 1px 18px);
|
|
157
|
+
pointer-events: none;
|
|
158
|
+
}
|
|
159
|
+
.hero-total > * {
|
|
160
|
+
position: relative;
|
|
161
|
+
}
|
|
162
|
+
.total-label {
|
|
163
|
+
color: var(--muted);
|
|
164
|
+
font-size: 12px;
|
|
165
|
+
font-weight: 700;
|
|
166
|
+
text-transform: uppercase;
|
|
167
|
+
}
|
|
168
|
+
.total-value {
|
|
169
|
+
margin-top: 8px;
|
|
170
|
+
font-size: clamp(38px, 7vw, 70px);
|
|
171
|
+
line-height: 0.95;
|
|
172
|
+
font-weight: 800;
|
|
173
|
+
font-variant-numeric: tabular-nums;
|
|
174
|
+
}
|
|
175
|
+
.hero-meta {
|
|
176
|
+
display: flex;
|
|
177
|
+
flex-wrap: wrap;
|
|
178
|
+
gap: 8px;
|
|
179
|
+
margin-top: 18px;
|
|
180
|
+
}
|
|
181
|
+
.chip {
|
|
182
|
+
display: inline-flex;
|
|
183
|
+
align-items: center;
|
|
184
|
+
gap: 7px;
|
|
185
|
+
min-height: 30px;
|
|
186
|
+
padding: 0 10px;
|
|
187
|
+
border: 1px solid rgba(142, 164, 188, 0.26);
|
|
188
|
+
border-radius: 999px;
|
|
189
|
+
background: rgba(255, 255, 255, 0.045);
|
|
190
|
+
color: #d8e8f9;
|
|
191
|
+
font-size: 12px;
|
|
192
|
+
white-space: nowrap;
|
|
193
|
+
}
|
|
194
|
+
.health-panel {
|
|
195
|
+
background: var(--panel);
|
|
196
|
+
border: 1px solid var(--line);
|
|
197
|
+
border-radius: 8px;
|
|
198
|
+
padding: 16px;
|
|
199
|
+
box-shadow: var(--shadow);
|
|
200
|
+
}
|
|
201
|
+
.provider-list {
|
|
202
|
+
display: grid;
|
|
203
|
+
gap: 10px;
|
|
204
|
+
margin-top: 12px;
|
|
205
|
+
}
|
|
206
|
+
.provider {
|
|
207
|
+
display: grid;
|
|
208
|
+
grid-template-columns: 92px 1fr auto;
|
|
209
|
+
gap: 10px;
|
|
210
|
+
align-items: center;
|
|
211
|
+
color: #dceafe;
|
|
212
|
+
font-size: 13px;
|
|
213
|
+
}
|
|
214
|
+
.provider-bar {
|
|
215
|
+
height: 8px;
|
|
216
|
+
overflow: hidden;
|
|
217
|
+
border-radius: 999px;
|
|
218
|
+
background: rgba(142, 164, 188, 0.16);
|
|
219
|
+
}
|
|
220
|
+
.provider-fill {
|
|
221
|
+
height: 100%;
|
|
222
|
+
width: var(--w, 0%);
|
|
223
|
+
border-radius: inherit;
|
|
224
|
+
background: linear-gradient(90deg, var(--blue), var(--cyan));
|
|
225
|
+
}
|
|
226
|
+
.summary {
|
|
227
|
+
display: grid;
|
|
228
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
229
|
+
gap: 12px;
|
|
230
|
+
margin-bottom: 16px;
|
|
231
|
+
}
|
|
232
|
+
.panel, .metric {
|
|
233
|
+
background: var(--panel);
|
|
234
|
+
border: 1px solid var(--line);
|
|
235
|
+
border-radius: 8px;
|
|
236
|
+
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.22);
|
|
237
|
+
}
|
|
238
|
+
.metric {
|
|
239
|
+
padding: 15px;
|
|
240
|
+
min-height: 92px;
|
|
241
|
+
}
|
|
242
|
+
.metric .label {
|
|
243
|
+
color: var(--muted);
|
|
244
|
+
font-size: 12px;
|
|
245
|
+
font-weight: 700;
|
|
246
|
+
text-transform: uppercase;
|
|
247
|
+
margin-bottom: 8px;
|
|
248
|
+
}
|
|
249
|
+
.metric .value {
|
|
250
|
+
font-size: clamp(22px, 3vw, 30px);
|
|
251
|
+
line-height: 1.05;
|
|
252
|
+
font-weight: 700;
|
|
253
|
+
font-variant-numeric: tabular-nums;
|
|
254
|
+
overflow: hidden;
|
|
255
|
+
text-overflow: ellipsis;
|
|
256
|
+
white-space: nowrap;
|
|
257
|
+
}
|
|
258
|
+
.metric .sub {
|
|
259
|
+
margin-top: 8px;
|
|
260
|
+
color: var(--muted);
|
|
261
|
+
font-size: 12px;
|
|
262
|
+
}
|
|
263
|
+
.grid {
|
|
264
|
+
display: grid;
|
|
265
|
+
grid-template-columns: 1fr 1fr;
|
|
266
|
+
gap: 16px;
|
|
267
|
+
}
|
|
268
|
+
.grid > .panel:not(.module-hidden) {
|
|
269
|
+
grid-column: 1 / -1;
|
|
270
|
+
}
|
|
271
|
+
.module-hidden {
|
|
272
|
+
display: none !important;
|
|
273
|
+
}
|
|
274
|
+
.wide {
|
|
275
|
+
grid-column: 1 / -1;
|
|
276
|
+
}
|
|
277
|
+
.panel {
|
|
278
|
+
min-width: 0;
|
|
279
|
+
overflow: hidden;
|
|
280
|
+
}
|
|
281
|
+
.top-turns {
|
|
282
|
+
margin-bottom: 16px;
|
|
283
|
+
border-color: rgba(59, 224, 208, 0.28);
|
|
284
|
+
}
|
|
285
|
+
.panel-head {
|
|
286
|
+
display: flex;
|
|
287
|
+
justify-content: space-between;
|
|
288
|
+
align-items: center;
|
|
289
|
+
gap: 10px;
|
|
290
|
+
padding: 14px 14px 0;
|
|
291
|
+
}
|
|
292
|
+
.table-wrap {
|
|
293
|
+
overflow-x: auto;
|
|
294
|
+
padding: 4px 14px 12px;
|
|
295
|
+
max-width: 100%;
|
|
296
|
+
}
|
|
297
|
+
table {
|
|
298
|
+
width: 100%;
|
|
299
|
+
min-width: 100%;
|
|
300
|
+
border-collapse: collapse;
|
|
301
|
+
font-size: 13px;
|
|
302
|
+
}
|
|
303
|
+
#byModel,
|
|
304
|
+
#byProject,
|
|
305
|
+
#bySkill {
|
|
306
|
+
min-width: 100%;
|
|
307
|
+
}
|
|
308
|
+
#turnTable {
|
|
309
|
+
min-width: 980px;
|
|
310
|
+
}
|
|
311
|
+
th, td {
|
|
312
|
+
padding: 9px 6px;
|
|
313
|
+
border-bottom: 1px solid rgba(126, 164, 209, 0.14);
|
|
314
|
+
text-align: left;
|
|
315
|
+
white-space: nowrap;
|
|
316
|
+
vertical-align: middle;
|
|
317
|
+
}
|
|
318
|
+
th {
|
|
319
|
+
color: var(--muted);
|
|
320
|
+
font-size: 11px;
|
|
321
|
+
font-weight: 700;
|
|
322
|
+
text-transform: uppercase;
|
|
323
|
+
}
|
|
324
|
+
td.num, th.num {
|
|
325
|
+
text-align: right;
|
|
326
|
+
font-variant-numeric: tabular-nums;
|
|
327
|
+
white-space: nowrap;
|
|
328
|
+
}
|
|
329
|
+
td:first-child, th:first-child {
|
|
330
|
+
padding-left: 0;
|
|
331
|
+
}
|
|
332
|
+
td:last-child, th:last-child {
|
|
333
|
+
padding-right: 0;
|
|
334
|
+
}
|
|
335
|
+
.name-cell {
|
|
336
|
+
max-width: 260px;
|
|
337
|
+
overflow: hidden;
|
|
338
|
+
text-overflow: ellipsis;
|
|
339
|
+
white-space: nowrap;
|
|
340
|
+
}
|
|
341
|
+
.skill-list {
|
|
342
|
+
display: flex;
|
|
343
|
+
flex-wrap: wrap;
|
|
344
|
+
gap: 6px;
|
|
345
|
+
min-width: 0;
|
|
346
|
+
max-width: 100%;
|
|
347
|
+
white-space: normal;
|
|
348
|
+
overflow: hidden;
|
|
349
|
+
}
|
|
350
|
+
.skill-tag {
|
|
351
|
+
display: inline-flex;
|
|
352
|
+
align-items: center;
|
|
353
|
+
gap: 6px;
|
|
354
|
+
max-width: 100%;
|
|
355
|
+
min-width: 0;
|
|
356
|
+
padding: 3px 7px;
|
|
357
|
+
border: 1px solid rgba(59, 224, 208, 0.26);
|
|
358
|
+
border-radius: 999px;
|
|
359
|
+
background: rgba(59, 224, 208, 0.08);
|
|
360
|
+
color: #dffcff;
|
|
361
|
+
font-size: 12px;
|
|
362
|
+
}
|
|
363
|
+
.skill-name {
|
|
364
|
+
overflow: hidden;
|
|
365
|
+
text-overflow: ellipsis;
|
|
366
|
+
white-space: nowrap;
|
|
367
|
+
}
|
|
368
|
+
.skill-confidence {
|
|
369
|
+
flex: 0 0 auto;
|
|
370
|
+
color: var(--muted);
|
|
371
|
+
font-size: 11px;
|
|
372
|
+
}
|
|
373
|
+
.skill-empty {
|
|
374
|
+
color: var(--muted);
|
|
375
|
+
}
|
|
376
|
+
.bar-cell {
|
|
377
|
+
min-width: 92px;
|
|
378
|
+
white-space: nowrap;
|
|
379
|
+
}
|
|
380
|
+
.bar {
|
|
381
|
+
height: 7px;
|
|
382
|
+
border-radius: 999px;
|
|
383
|
+
background: rgba(142, 164, 188, 0.16);
|
|
384
|
+
overflow: hidden;
|
|
385
|
+
}
|
|
386
|
+
.bar > span {
|
|
387
|
+
display: block;
|
|
388
|
+
height: 100%;
|
|
389
|
+
width: var(--w, 0%);
|
|
390
|
+
border-radius: inherit;
|
|
391
|
+
background: linear-gradient(90deg, var(--green), var(--amber));
|
|
392
|
+
}
|
|
393
|
+
h2 {
|
|
394
|
+
font-size: 16px;
|
|
395
|
+
margin: 0;
|
|
396
|
+
}
|
|
397
|
+
.panel-count {
|
|
398
|
+
color: var(--muted);
|
|
399
|
+
font-size: 12px;
|
|
400
|
+
}
|
|
401
|
+
#bySource th:first-child,
|
|
402
|
+
#bySource td:first-child,
|
|
403
|
+
#byDay th:first-child,
|
|
404
|
+
#byDay td:first-child {
|
|
405
|
+
width: 140px;
|
|
406
|
+
}
|
|
407
|
+
#byModel th:first-child,
|
|
408
|
+
#byModel td:first-child,
|
|
409
|
+
#byProject th:first-child,
|
|
410
|
+
#byProject td:first-child,
|
|
411
|
+
#bySkill th:first-child,
|
|
412
|
+
#bySkill td:first-child {
|
|
413
|
+
width: 120px;
|
|
414
|
+
}
|
|
415
|
+
#byModel th:nth-child(2),
|
|
416
|
+
#byModel td:nth-child(2) {
|
|
417
|
+
min-width: 220px;
|
|
418
|
+
}
|
|
419
|
+
#byProject th:nth-child(2),
|
|
420
|
+
#byProject td:nth-child(2),
|
|
421
|
+
#turnTable th:nth-child(1),
|
|
422
|
+
#turnTable td:nth-child(1),
|
|
423
|
+
#turnTable th:nth-child(2),
|
|
424
|
+
#turnTable td:nth-child(2),
|
|
425
|
+
#turnTable th:nth-child(3),
|
|
426
|
+
#turnTable td:nth-child(3),
|
|
427
|
+
#turnTable th:nth-child(5),
|
|
428
|
+
#turnTable td:nth-child(5) {
|
|
429
|
+
min-width: 180px;
|
|
430
|
+
}
|
|
431
|
+
#turnTable th:nth-child(4),
|
|
432
|
+
#turnTable td:nth-child(4),
|
|
433
|
+
#turnTable th:nth-child(5),
|
|
434
|
+
#turnTable td:nth-child(5) {
|
|
435
|
+
min-width: 72px;
|
|
436
|
+
}
|
|
437
|
+
#turnTable th:nth-child(2),
|
|
438
|
+
#turnTable td:nth-child(2),
|
|
439
|
+
#turnTable th:nth-child(3),
|
|
440
|
+
#turnTable td:nth-child(3) {
|
|
441
|
+
min-width: 92px;
|
|
442
|
+
}
|
|
443
|
+
#turnTable th:nth-child(5),
|
|
444
|
+
#turnTable td:nth-child(5) {
|
|
445
|
+
white-space: normal;
|
|
446
|
+
min-width: 220px;
|
|
447
|
+
}
|
|
448
|
+
#bySkill th:nth-child(2),
|
|
449
|
+
#bySkill td:nth-child(2) {
|
|
450
|
+
min-width: 200px;
|
|
451
|
+
}
|
|
452
|
+
@media (max-width: 860px) {
|
|
453
|
+
.hero, .summary, .grid, .filter-bar {
|
|
454
|
+
grid-template-columns: 1fr;
|
|
455
|
+
}
|
|
456
|
+
header {
|
|
457
|
+
display: block;
|
|
458
|
+
}
|
|
459
|
+
.timestamp {
|
|
460
|
+
min-width: 0;
|
|
461
|
+
text-align: left;
|
|
462
|
+
margin-top: 12px;
|
|
463
|
+
}
|
|
464
|
+
.provider {
|
|
465
|
+
grid-template-columns: 82px 1fr;
|
|
466
|
+
}
|
|
467
|
+
.provider span:last-child {
|
|
468
|
+
grid-column: 2;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
@media (prefers-color-scheme: dark) {
|
|
472
|
+
:root {
|
|
473
|
+
color-scheme: dark;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
</style>
|
|
477
|
+
</head>
|
|
478
|
+
<body>
|
|
479
|
+
<main>
|
|
480
|
+
<header>
|
|
481
|
+
<div>
|
|
482
|
+
<div class="eyebrow"><span class="dot"></span> 本地实时快照</div>
|
|
483
|
+
<h1>AI Token 用量看板</h1>
|
|
484
|
+
<div class="muted">Codex / Claude Code Token 用量统计</div>
|
|
485
|
+
</div>
|
|
486
|
+
<div class="timestamp">
|
|
487
|
+
<div id="generated" class="muted"></div>
|
|
488
|
+
</div>
|
|
489
|
+
</header>
|
|
490
|
+
|
|
491
|
+
<nav class="tabs" aria-label="模块切换">
|
|
492
|
+
<button class="tab-button active" type="button" data-tab="overview">总览</button>
|
|
493
|
+
<button class="tab-button" type="button" data-tab="turns">最近轮次</button>
|
|
494
|
+
<button class="tab-button" type="button" data-tab="sources">工具来源</button>
|
|
495
|
+
<button class="tab-button" type="button" data-tab="models">模型</button>
|
|
496
|
+
<button class="tab-button" type="button" data-tab="days">日期</button>
|
|
497
|
+
<button class="tab-button" type="button" data-tab="projects">项目</button>
|
|
498
|
+
<button class="tab-button" type="button" data-tab="skills">Skills</button>
|
|
499
|
+
</nav>
|
|
500
|
+
|
|
501
|
+
<section class="filter-bar" aria-label="筛选">
|
|
502
|
+
<div class="filter-field">
|
|
503
|
+
<label for="modelFilter">模型</label>
|
|
504
|
+
<select id="modelFilter"></select>
|
|
505
|
+
</div>
|
|
506
|
+
<div class="filter-field">
|
|
507
|
+
<label for="sourceFilter">工具</label>
|
|
508
|
+
<select id="sourceFilter"></select>
|
|
509
|
+
</div>
|
|
510
|
+
<div class="filter-field">
|
|
511
|
+
<label for="skillFilter">Skills</label>
|
|
512
|
+
<select id="skillFilter"></select>
|
|
513
|
+
</div>
|
|
514
|
+
<div class="filter-field">
|
|
515
|
+
<label for="timeFilter">时间</label>
|
|
516
|
+
<select id="timeFilter">
|
|
517
|
+
<option value="all">全部时间</option>
|
|
518
|
+
<option value="today">今天</option>
|
|
519
|
+
<option value="3d">近三天</option>
|
|
520
|
+
<option value="7d">近一周</option>
|
|
521
|
+
<option value="30d">近一个月</option>
|
|
522
|
+
</select>
|
|
523
|
+
</div>
|
|
524
|
+
</section>
|
|
525
|
+
|
|
526
|
+
<section data-module="overview">
|
|
527
|
+
<section class="hero">
|
|
528
|
+
<div class="hero-total">
|
|
529
|
+
<div class="total-label">消耗总 Token</div>
|
|
530
|
+
<div id="total" class="total-value">-</div>
|
|
531
|
+
<div class="hero-meta">
|
|
532
|
+
<span id="sourceCount" class="chip">- sources</span>
|
|
533
|
+
<span id="modelCount" class="chip">- models</span>
|
|
534
|
+
<span id="dayCount" class="chip">- days</span>
|
|
535
|
+
</div>
|
|
536
|
+
</div>
|
|
537
|
+
<div class="health-panel">
|
|
538
|
+
<h2>来源占比</h2>
|
|
539
|
+
<div id="providerList" class="provider-list"></div>
|
|
540
|
+
</div>
|
|
541
|
+
</section>
|
|
542
|
+
|
|
543
|
+
<section class="summary">
|
|
544
|
+
<div class="metric"><div class="label">输入 Token</div><div id="input" class="value">-</div><div id="inputPct" class="sub"></div></div>
|
|
545
|
+
<div class="metric"><div class="label">输出 Token</div><div id="output" class="value">-</div><div id="outputPct" class="sub"></div></div>
|
|
546
|
+
<div class="metric"><div class="label">缓存命中</div><div id="cache" class="value">-</div><div id="cachePct" class="sub"></div></div>
|
|
547
|
+
<div class="metric"><div class="label">对话次数</div><div id="conversationTotal" class="value">-</div><div id="avgTokens" class="sub"></div></div>
|
|
548
|
+
</section>
|
|
549
|
+
</section>
|
|
550
|
+
|
|
551
|
+
<section class="panel top-turns module-hidden" data-module="turns">
|
|
552
|
+
<div class="panel-head"><h2>最近对话轮次</h2><span id="turnCount" class="panel-count"></span></div>
|
|
553
|
+
<div class="table-wrap"><table id="turnTable"></table></div>
|
|
554
|
+
</section>
|
|
555
|
+
|
|
556
|
+
<section class="grid">
|
|
557
|
+
<div class="panel module-hidden" data-module="sources"><div class="panel-head"><h2>按工具来源</h2><span id="bySourceCount" class="panel-count"></span></div><div class="table-wrap"><table id="bySource"></table></div></div>
|
|
558
|
+
<div class="panel module-hidden" data-module="models"><div class="panel-head"><h2>按模型</h2><span id="byModelCount" class="panel-count"></span></div><div class="table-wrap"><table id="byModel"></table></div></div>
|
|
559
|
+
<div class="panel module-hidden" data-module="days"><div class="panel-head"><h2>按日期</h2><span id="byDayCount" class="panel-count"></span></div><div class="table-wrap"><table id="byDay"></table></div></div>
|
|
560
|
+
<div class="panel module-hidden" data-module="projects"><div class="panel-head"><h2>按项目</h2><span id="byProjectCount" class="panel-count"></span></div><div class="table-wrap"><table id="byProject"></table></div></div>
|
|
561
|
+
<div class="panel module-hidden" data-module="skills"><div class="panel-head"><h2>按 Skills</h2><span id="bySkillCount" class="panel-count"></span></div><div class="table-wrap"><table id="bySkill"></table></div></div>
|
|
562
|
+
</section>
|
|
563
|
+
</main>
|
|
564
|
+
<script>
|
|
565
|
+
const fmt = new Intl.NumberFormat();
|
|
566
|
+
const state = {
|
|
567
|
+
raw: null,
|
|
568
|
+
activeTab: "overview",
|
|
569
|
+
referenceDate: new Date(),
|
|
570
|
+
controlsBound: false,
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
bindControls();
|
|
574
|
+
loadDashboard();
|
|
575
|
+
|
|
576
|
+
async function loadDashboard() {
|
|
577
|
+
try {
|
|
578
|
+
const data = await requestJson(apiUrl("summary"));
|
|
579
|
+
initDashboard(data);
|
|
580
|
+
} catch (error) {
|
|
581
|
+
state.raw = emptyDashboard();
|
|
582
|
+
state.referenceDate = new Date();
|
|
583
|
+
updateGenerated();
|
|
584
|
+
populateFilters(state.raw);
|
|
585
|
+
applyAndRender();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function initDashboard(data) {
|
|
590
|
+
state.raw = data;
|
|
591
|
+
state.referenceDate = new Date(data.generatedAt || Date.now());
|
|
592
|
+
updateGenerated();
|
|
593
|
+
populateFilters(data);
|
|
594
|
+
applyAndRender();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function bindControls() {
|
|
598
|
+
if (state.controlsBound) return;
|
|
599
|
+
state.controlsBound = true;
|
|
600
|
+
document.querySelectorAll("[data-tab]").forEach((button) => {
|
|
601
|
+
button.addEventListener("click", () => setActiveTab(button.dataset.tab));
|
|
602
|
+
});
|
|
603
|
+
["modelFilter", "sourceFilter", "skillFilter", "timeFilter"].forEach((id) => {
|
|
604
|
+
document.getElementById(id).addEventListener("change", applyAndRender);
|
|
605
|
+
});
|
|
606
|
+
setActiveTab(state.activeTab);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function setActiveTab(tab) {
|
|
610
|
+
state.activeTab = tab || "overview";
|
|
611
|
+
document.querySelectorAll("[data-tab]").forEach((button) => {
|
|
612
|
+
button.classList.toggle("active", button.dataset.tab === state.activeTab);
|
|
613
|
+
});
|
|
614
|
+
document.querySelectorAll("[data-module]").forEach((module) => {
|
|
615
|
+
module.classList.toggle("module-hidden", module.dataset.module !== state.activeTab);
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function populateFilters(data) {
|
|
620
|
+
setOptions("modelFilter", "全部模型", unique([
|
|
621
|
+
...(data.byModel || []).map((row) => row.model),
|
|
622
|
+
...turnsOf(data).map((turn) => turn.model),
|
|
623
|
+
]));
|
|
624
|
+
setOptions("sourceFilter", "全部工具", unique([
|
|
625
|
+
...(data.bySource || []).map((row) => row.source),
|
|
626
|
+
...turnsOf(data).map((turn) => turn.source),
|
|
627
|
+
]));
|
|
628
|
+
setOptions("skillFilter", "全部 Skills", unique([
|
|
629
|
+
...(data.turnAnalytics?.bySkill || []).map((row) => row.skill),
|
|
630
|
+
...turnsOf(data).flatMap((turn) => (turn.skills || []).map((skill) => skill.name)),
|
|
631
|
+
]));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function setOptions(id, allLabel, values) {
|
|
635
|
+
const el = document.getElementById(id);
|
|
636
|
+
if (!el) return;
|
|
637
|
+
const current = el.value || "all";
|
|
638
|
+
el.innerHTML = `<option value="all">${allLabel}</option>${values.map((value) => `<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`).join("")}`;
|
|
639
|
+
el.value = values.includes(current) ? current : "all";
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function applyAndRender() {
|
|
643
|
+
if (!state.raw) return;
|
|
644
|
+
const filters = currentFilters();
|
|
645
|
+
const data = hasActiveFilters(filters)
|
|
646
|
+
? aggregateTurnsForDashboard(filterTurns(turnsOf(state.raw), filters, state.referenceDate), state.raw)
|
|
647
|
+
: state.raw;
|
|
648
|
+
renderDashboard(data);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function currentFilters() {
|
|
652
|
+
return {
|
|
653
|
+
model: valueOf("modelFilter"),
|
|
654
|
+
source: valueOf("sourceFilter"),
|
|
655
|
+
skill: valueOf("skillFilter"),
|
|
656
|
+
time: valueOf("timeFilter"),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function valueOf(id) {
|
|
661
|
+
return document.getElementById(id)?.value || "all";
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function hasActiveFilters(filters) {
|
|
665
|
+
return Object.values(filters || {}).some((value) => value && value !== "all");
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function renderDashboard(data) {
|
|
669
|
+
const effectiveTotal = tokenMetric(data.summary);
|
|
670
|
+
const rawTotal = Number(data.summary.totalTokens || effectiveTotal || 0);
|
|
671
|
+
setText("total", formatToken(effectiveTotal), formatRaw(effectiveTotal, "Token"));
|
|
672
|
+
setText("input", formatToken(data.summary.inputTokens), formatRaw(data.summary.inputTokens, "Token"));
|
|
673
|
+
setText("output", formatToken(data.summary.outputTokens), formatRaw(data.summary.outputTokens, "Token"));
|
|
674
|
+
setText("cache", formatToken(data.summary.cachedInputTokens), formatRaw(data.summary.cachedInputTokens, "Token"));
|
|
675
|
+
setText("conversationTotal", formatCount(data.summary.conversationCount, "次"), formatRaw(data.summary.conversationCount, "次"));
|
|
676
|
+
document.getElementById("inputPct").textContent = `占原始总量 ${pct(data.summary.inputTokens, rawTotal)}`;
|
|
677
|
+
document.getElementById("outputPct").textContent = `占原始总量 ${pct(data.summary.outputTokens, rawTotal)}`;
|
|
678
|
+
document.getElementById("cachePct").textContent = `占原始总量 ${pct(data.summary.cachedInputTokens, rawTotal)}`;
|
|
679
|
+
document.getElementById("avgTokens").textContent = `平均 ${formatToken(Math.round(effectiveTotal / Math.max(data.summary.conversationCount || 1, 1)))} / 次`;
|
|
680
|
+
document.getElementById("sourceCount").textContent = formatCount(data.bySource.length, "个来源");
|
|
681
|
+
document.getElementById("modelCount").textContent = formatCount(data.byModel.length, "个模型");
|
|
682
|
+
document.getElementById("dayCount").textContent = formatCount(data.byDay.length, "天");
|
|
683
|
+
providers(data.bySource, effectiveTotal);
|
|
684
|
+
table("bySource", ["source", "effectiveTokens", "conversationCount", "share"], data.bySource);
|
|
685
|
+
table("byModel", ["source", "model", "effectiveTokens", "share"], data.byModel.slice(0, 20), data.byModel);
|
|
686
|
+
table("byDay", ["day", "effectiveTokens", "conversationCount", "share"], data.byDay, data.byDay);
|
|
687
|
+
table("byProject", ["source", "projectRef", "effectiveTokens", "share"], data.byProject.slice(0, 20), data.byProject);
|
|
688
|
+
table("bySkill", ["skill", "sources", "turnCount", "durationMs", "effectiveTokens", "share"], (data.turnAnalytics?.bySkill || []).slice(0, 20), data.turnAnalytics?.bySkill || []);
|
|
689
|
+
table("turnTable", ["startTime", "source", "model", "durationMs", "skills", "inputTokens", "outputTokens", "cachedInputTokens", "effectiveTokens"], (data.turnAnalytics?.turns || []).slice(0, 50), data.turnAnalytics?.turns || []);
|
|
690
|
+
count("bySourceCount", data.bySource.length);
|
|
691
|
+
count("byModelCount", data.byModel.length);
|
|
692
|
+
count("byDayCount", data.byDay.length);
|
|
693
|
+
count("byProjectCount", data.byProject.length);
|
|
694
|
+
count("bySkillCount", data.turnAnalytics?.bySkill?.length || 0);
|
|
695
|
+
count("turnCount", data.turnAnalytics?.turns?.length || 0);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function filterTurns(turns, filters, referenceDate = new Date()) {
|
|
699
|
+
return (turns || []).filter((turn) => {
|
|
700
|
+
if (filters.source !== "all" && turn.source !== filters.source) return false;
|
|
701
|
+
if (filters.model !== "all" && turn.model !== filters.model) return false;
|
|
702
|
+
if (filters.skill !== "all" && !(turn.skills || []).some((skill) => skill.name === filters.skill)) return false;
|
|
703
|
+
if (filters.time !== "all" && !matchesTimeRange(turn.startTime, filters.time, referenceDate)) return false;
|
|
704
|
+
return true;
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function aggregateTurnsForDashboard(turns, baseData = {}) {
|
|
709
|
+
const rows = Array.isArray(turns) ? turns : [];
|
|
710
|
+
return {
|
|
711
|
+
generatedAt: baseData.generatedAt || new Date().toISOString(),
|
|
712
|
+
summary: sumTurns(rows),
|
|
713
|
+
bySource: aggregateBy(rows, ["source"]),
|
|
714
|
+
byModel: aggregateBy(rows, ["source", "model"]),
|
|
715
|
+
byDay: aggregateBy(rows.map((turn) => ({ ...turn, day: dayOf(turn.startTime) })), ["day"], { sort: "dayDesc" }),
|
|
716
|
+
byProject: aggregateBy(rows.map((turn) => ({ ...turn, projectRef: turn.projectRef || "未识别项目" })), ["source", "projectRef"]),
|
|
717
|
+
turnAnalytics: {
|
|
718
|
+
turns: rows,
|
|
719
|
+
bySkill: aggregateBySkill(rows),
|
|
720
|
+
},
|
|
721
|
+
providers: baseData.providers || [],
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function aggregateBy(turns, keys, options = {}) {
|
|
726
|
+
const map = new Map();
|
|
727
|
+
for (const turn of turns || []) {
|
|
728
|
+
const key = keys.map((field) => turn[field] || "未识别").join("\u0000");
|
|
729
|
+
if (!map.has(key)) {
|
|
730
|
+
const row = {};
|
|
731
|
+
keys.forEach((field) => { row[field] = turn[field] || "未识别"; });
|
|
732
|
+
Object.assign(row, emptyTotals());
|
|
733
|
+
map.set(key, row);
|
|
734
|
+
}
|
|
735
|
+
addTurnTotals(map.get(key), turn);
|
|
736
|
+
}
|
|
737
|
+
const rows = Array.from(map.values());
|
|
738
|
+
if (options.sort === "dayDesc") return rows.sort((a, b) => String(b.day).localeCompare(String(a.day)));
|
|
739
|
+
return rows.sort((a, b) => b.effectiveTokens - a.effectiveTokens);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function aggregateBySkill(turns) {
|
|
743
|
+
const map = new Map();
|
|
744
|
+
for (const turn of turns || []) {
|
|
745
|
+
for (const skill of turn.skills || []) {
|
|
746
|
+
if (!skill?.name) continue;
|
|
747
|
+
if (!map.has(skill.name)) {
|
|
748
|
+
map.set(skill.name, {
|
|
749
|
+
skill: skill.name,
|
|
750
|
+
confidence: skill.confidence || "explicit",
|
|
751
|
+
sources: new Set(),
|
|
752
|
+
turnCount: 0,
|
|
753
|
+
durationMs: 0,
|
|
754
|
+
totalTokens: 0,
|
|
755
|
+
effectiveTokens: 0,
|
|
756
|
+
inputTokens: 0,
|
|
757
|
+
outputTokens: 0,
|
|
758
|
+
cachedInputTokens: 0,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
const row = map.get(skill.name);
|
|
762
|
+
if (skill.confidence === "explicit") row.confidence = "explicit";
|
|
763
|
+
row.sources.add(turn.source);
|
|
764
|
+
row.turnCount += 1;
|
|
765
|
+
row.durationMs += Number(turn.durationMs || 0);
|
|
766
|
+
row.totalTokens += Number(turn.totalTokens || 0);
|
|
767
|
+
row.effectiveTokens += effectiveTokensOf(turn);
|
|
768
|
+
row.inputTokens += Number(turn.inputTokens || 0);
|
|
769
|
+
row.outputTokens += Number(turn.outputTokens || 0);
|
|
770
|
+
row.cachedInputTokens += Number(turn.cachedInputTokens || 0);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return Array.from(map.values()).map((row) => ({
|
|
774
|
+
...row,
|
|
775
|
+
sources: Array.from(row.sources).sort(),
|
|
776
|
+
})).sort((a, b) => b.effectiveTokens - a.effectiveTokens);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function sumTurns(turns) {
|
|
780
|
+
const summary = emptyTotals();
|
|
781
|
+
for (const turn of turns || []) addTurnTotals(summary, turn);
|
|
782
|
+
return summary;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function emptyTotals() {
|
|
786
|
+
return {
|
|
787
|
+
inputTokens: 0,
|
|
788
|
+
outputTokens: 0,
|
|
789
|
+
cachedInputTokens: 0,
|
|
790
|
+
cacheCreationInputTokens: 0,
|
|
791
|
+
reasoningOutputTokens: 0,
|
|
792
|
+
totalTokens: 0,
|
|
793
|
+
effectiveTokens: 0,
|
|
794
|
+
conversationCount: 0,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function addTurnTotals(target, turn) {
|
|
799
|
+
target.inputTokens += Number(turn.inputTokens || 0);
|
|
800
|
+
target.outputTokens += Number(turn.outputTokens || 0);
|
|
801
|
+
target.cachedInputTokens += Number(turn.cachedInputTokens || 0);
|
|
802
|
+
target.cacheCreationInputTokens += Number(turn.cacheCreationInputTokens || 0);
|
|
803
|
+
target.reasoningOutputTokens += Number(turn.reasoningOutputTokens || 0);
|
|
804
|
+
target.totalTokens += Number(turn.totalTokens || 0);
|
|
805
|
+
target.effectiveTokens += effectiveTokensOf(turn);
|
|
806
|
+
target.conversationCount += 1;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function matchesTimeRange(value, range, referenceDate) {
|
|
810
|
+
const date = new Date(value);
|
|
811
|
+
if (Number.isNaN(date.getTime())) return false;
|
|
812
|
+
const start = startForRange(range, referenceDate);
|
|
813
|
+
return start ? date >= start && date <= referenceDate : true;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function startForRange(range, referenceDate) {
|
|
817
|
+
const ref = new Date(referenceDate);
|
|
818
|
+
const start = new Date(ref.getFullYear(), ref.getMonth(), ref.getDate());
|
|
819
|
+
const days = { today: 1, "3d": 3, "7d": 7, "30d": 30 }[range];
|
|
820
|
+
if (!days) return null;
|
|
821
|
+
start.setDate(start.getDate() - (days - 1));
|
|
822
|
+
return start;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function turnsOf(data) {
|
|
826
|
+
return data?.turnAnalytics?.turns || [];
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function unique(values) {
|
|
830
|
+
return Array.from(new Set((values || []).filter(Boolean))).sort((a, b) => String(a).localeCompare(String(b)));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function dayOf(value) {
|
|
834
|
+
if (!value) return "未识别";
|
|
835
|
+
return new Date(value).toISOString().slice(0, 10);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function providers(rows, total) {
|
|
839
|
+
const el = document.getElementById("providerList");
|
|
840
|
+
el.innerHTML = rows.map((row) => {
|
|
841
|
+
const share = pctNumber(tokenMetric(row), total);
|
|
842
|
+
return `<div class="provider"><span>${escapeHtml(row.source)}</span><div class="provider-bar"><div class="provider-fill" style="--w:${share}%"></div></div><span>${pct(tokenMetric(row), total)}</span></div>`;
|
|
843
|
+
}).join("");
|
|
844
|
+
}
|
|
845
|
+
function table(id, cols, rows, denominatorRows = rows) {
|
|
846
|
+
const el = document.getElementById(id);
|
|
847
|
+
const max = Math.max(...denominatorRows.map((r) => tokenMetric(r)), 1);
|
|
848
|
+
const empty = `<tr><td colspan="${cols.length}" class="muted">无匹配数据</td></tr>`;
|
|
849
|
+
el.innerHTML = `<thead><tr>${cols.map((c) => `<th class="${isNum(c) ? "num" : ""}">${label(c)}</th>`).join("")}</tr></thead><tbody>${rows.length ? rows.map((r) => `<tr>${cols.map((c) => cell(c, r, max)).join("")}</tr>`).join("") : empty}</tbody>`;
|
|
850
|
+
}
|
|
851
|
+
function cell(col, row, max) {
|
|
852
|
+
if (col === "share") {
|
|
853
|
+
const width = pctNumber(tokenMetric(row), max);
|
|
854
|
+
return `<td class="bar-cell"><div class="bar" title="${pct(tokenMetric(row), max)}"><span style="--w:${width}%"></span></div></td>`;
|
|
855
|
+
}
|
|
856
|
+
if (col === "durationMs") return `<td class="num">${formatDuration(row[col] || 0)}</td>`;
|
|
857
|
+
if (col === "startTime") return `<td>${formatTime(row[col])}</td>`;
|
|
858
|
+
if (col === "skills") return `<td title="${escapeHtml(skillText(row[col]))}">${skillHtml(row[col])}</td>`;
|
|
859
|
+
if (col === "sources") return `<td>${escapeHtml(Array.isArray(row[col]) ? row[col].join(", ") : row[col] || "")}</td>`;
|
|
860
|
+
if (isTokenCol(col)) return `<td class="num" title="${escapeHtml(formatRaw(tokenValue(col, row), "Token"))}">${escapeHtml(formatToken(tokenValue(col, row)))}</td>`;
|
|
861
|
+
if (isCountCol(col)) return `<td class="num" title="${escapeHtml(formatRaw(row[col] || 0, countUnit(col)))}">${escapeHtml(formatCount(row[col] || 0, countUnit(col)))}</td>`;
|
|
862
|
+
return `<td><div class="name-cell" title="${escapeHtml(row[col] || "")}">${escapeHtml(row[col] || "")}</div></td>`;
|
|
863
|
+
}
|
|
864
|
+
function count(id, value) { document.getElementById(id).textContent = formatCount(value, "行"); }
|
|
865
|
+
function isNum(key) { return isTokenCol(key) || isCountCol(key); }
|
|
866
|
+
function isTokenCol(key) { return /Tokens$/.test(key); }
|
|
867
|
+
function isCountCol(key) { return /Count$/.test(key); }
|
|
868
|
+
function countUnit(key) { return key === "turnCount" ? "轮" : "次"; }
|
|
869
|
+
function label(key) {
|
|
870
|
+
return ({
|
|
871
|
+
source: "来源",
|
|
872
|
+
model: "模型",
|
|
873
|
+
day: "日期",
|
|
874
|
+
projectRef: "项目",
|
|
875
|
+
totalTokens: "原始总 Token",
|
|
876
|
+
effectiveTokens: "消耗总 Token",
|
|
877
|
+
inputTokens: "输入",
|
|
878
|
+
outputTokens: "输出",
|
|
879
|
+
cachedInputTokens: "缓存",
|
|
880
|
+
conversationCount: "次数",
|
|
881
|
+
turnCount: "轮次",
|
|
882
|
+
durationMs: "耗时",
|
|
883
|
+
startTime: "开始时间",
|
|
884
|
+
skills: "调用 Skills",
|
|
885
|
+
skill: "Skill",
|
|
886
|
+
sources: "来源",
|
|
887
|
+
share: "占比"
|
|
888
|
+
})[key] || key;
|
|
889
|
+
}
|
|
890
|
+
function pct(value, total) { return `${pctNumber(value, total).toFixed(1)}%`; }
|
|
891
|
+
function pctNumber(value, total) { return total > 0 ? Math.max(0, Math.min(100, (Number(value || 0) / total) * 100)) : 0; }
|
|
892
|
+
function effectiveTokensOf(row) {
|
|
893
|
+
const input = Number(row?.inputTokens || 0);
|
|
894
|
+
const cached = Number(row?.cachedInputTokens || 0);
|
|
895
|
+
const output = Number(row?.outputTokens || 0);
|
|
896
|
+
const cacheCreation = Number(row?.cacheCreationInputTokens || 0);
|
|
897
|
+
return Math.max(0, input - cached) + output + cacheCreation;
|
|
898
|
+
}
|
|
899
|
+
function tokenMetric(row) {
|
|
900
|
+
return Number((row?.effectiveTokens ?? effectiveTokensOf(row)) || 0);
|
|
901
|
+
}
|
|
902
|
+
function tokenValue(col, row) {
|
|
903
|
+
if (col === "effectiveTokens") return tokenMetric(row);
|
|
904
|
+
return Number(row?.[col] || 0);
|
|
905
|
+
}
|
|
906
|
+
function emptyDashboard() {
|
|
907
|
+
return {
|
|
908
|
+
generatedAt: null,
|
|
909
|
+
summary: emptyTotals(),
|
|
910
|
+
bySource: [],
|
|
911
|
+
byModel: [],
|
|
912
|
+
byDay: [],
|
|
913
|
+
byProject: [],
|
|
914
|
+
turnAnalytics: { turns: [], bySkill: [] },
|
|
915
|
+
providers: [],
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
function apiUrl(name) {
|
|
919
|
+
const locationLike = globalThis.location || { href: "http://127.0.0.1/", pathname: "/" };
|
|
920
|
+
const href = String(locationLike.href || "http://127.0.0.1/");
|
|
921
|
+
const pathname = String(locationLike.pathname || "/");
|
|
922
|
+
const basePath = pathname.endsWith(".html") ? pathname.replace(/[^/]+$/, "") : pathname.replace(/\/?$/, "/");
|
|
923
|
+
return new URL(`api/${name}`, `http://127.0.0.1${basePath}`).pathname + new URL(href).search.replace(/^\?$/, "");
|
|
924
|
+
}
|
|
925
|
+
async function requestJson(url, options) {
|
|
926
|
+
const response = await fetch(url, options);
|
|
927
|
+
const text = await response.text();
|
|
928
|
+
let data = null;
|
|
929
|
+
if (text) {
|
|
930
|
+
try {
|
|
931
|
+
data = JSON.parse(text);
|
|
932
|
+
} catch {
|
|
933
|
+
data = null;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (!response.ok) {
|
|
937
|
+
const message = data && data.error ? data.error : text || `请求失败(${response.status})`;
|
|
938
|
+
const error = new Error(message);
|
|
939
|
+
error.status = response.status;
|
|
940
|
+
error.detail = data && data.detail ? data.detail : "";
|
|
941
|
+
throw error;
|
|
942
|
+
}
|
|
943
|
+
return data || {};
|
|
944
|
+
}
|
|
945
|
+
function updateGenerated() {
|
|
946
|
+
const label = state.raw?.generatedAt ? "生成时间" : "参考时间";
|
|
947
|
+
document.getElementById("generated").textContent = `${label}:${state.referenceDate.toLocaleString("zh-CN")}`;
|
|
948
|
+
}
|
|
949
|
+
function setText(id, text, title) {
|
|
950
|
+
const el = document.getElementById(id);
|
|
951
|
+
el.textContent = text;
|
|
952
|
+
if (title) el.title = title;
|
|
953
|
+
}
|
|
954
|
+
function formatToken(value) {
|
|
955
|
+
return `${formatChineseNumber(value)} Token`;
|
|
956
|
+
}
|
|
957
|
+
function formatCount(value, unit) {
|
|
958
|
+
const compact = formatChineseNumber(value);
|
|
959
|
+
return /[万亿]$/.test(compact) ? `${compact}${unit}` : `${compact} ${unit}`;
|
|
960
|
+
}
|
|
961
|
+
function formatRaw(value, unit) {
|
|
962
|
+
return `${fmt.format(Number(value || 0))} ${unit}`;
|
|
963
|
+
}
|
|
964
|
+
function formatChineseNumber(value) {
|
|
965
|
+
const num = Number(value || 0);
|
|
966
|
+
const abs = Math.abs(num);
|
|
967
|
+
if (abs >= 100000000) return `${trimNumber(num / 100000000)} 亿`;
|
|
968
|
+
if (abs >= 10000) return `${trimNumber(num / 10000)} 万`;
|
|
969
|
+
return fmt.format(num);
|
|
970
|
+
}
|
|
971
|
+
function trimNumber(value) {
|
|
972
|
+
return value.toFixed(2).replace(/\.00$/, "").replace(/(\.\d)0$/, "$1");
|
|
973
|
+
}
|
|
974
|
+
function skillText(value) {
|
|
975
|
+
if (!Array.isArray(value) || value.length === 0) return "未识别";
|
|
976
|
+
return value.map((item) => `${item.name}(${skillConfidenceText(item.confidence)})`).join(", ");
|
|
977
|
+
}
|
|
978
|
+
function skillHtml(value) {
|
|
979
|
+
if (!Array.isArray(value) || value.length === 0) return `<span class="skill-empty">未识别</span>`;
|
|
980
|
+
return `<div class="skill-list">${value.map((item) => `<span class="skill-tag"><span class="skill-name">${escapeHtml(item.name)}</span><span class="skill-confidence">${skillConfidenceText(item.confidence)}</span></span>`).join("")}</div>`;
|
|
981
|
+
}
|
|
982
|
+
function skillConfidenceText(value) {
|
|
983
|
+
return value === "inferred" ? "推断" : "明确";
|
|
984
|
+
}
|
|
985
|
+
function formatDuration(ms) {
|
|
986
|
+
const seconds = Math.round(Number(ms || 0) / 1000);
|
|
987
|
+
if (seconds < 60) return `${seconds}s`;
|
|
988
|
+
const minutes = Math.floor(seconds / 60);
|
|
989
|
+
const rest = seconds % 60;
|
|
990
|
+
if (minutes < 60) return `${minutes}m ${rest}s`;
|
|
991
|
+
const hours = Math.floor(minutes / 60);
|
|
992
|
+
return `${hours}h ${minutes % 60}m`;
|
|
993
|
+
}
|
|
994
|
+
function formatTime(value) {
|
|
995
|
+
if (!value) return "-";
|
|
996
|
+
return new Date(value).toLocaleString("zh-CN", { hour12: false });
|
|
997
|
+
}
|
|
998
|
+
function escapeHtml(value) {
|
|
999
|
+
return String(value).replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]));
|
|
1000
|
+
}
|
|
1001
|
+
</script>
|
|
1002
|
+
</body>
|
|
1003
|
+
</html>
|