zcode-usage-stats 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 +21 -0
- package/README.md +176 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +1124 -0
- package/lib/index.mjs +416 -0
- package/package.json +53 -0
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// dsh-usage-stats —— 使用统计(宿主侧)
|
|
2
|
+
// 扫描全历史会话(sessionPersistence.list + readFrom),回放事件聚合 Token 用量:
|
|
3
|
+
// - 按天聚合(趋势图 + 热力图素材)
|
|
4
|
+
// - 按小时聚合到天(单日窗口"按小时"趋势图素材)
|
|
5
|
+
// - 按模型聚合(仪表盘)
|
|
6
|
+
// - 额外统计:会话数、消息数、活跃天数、当前连续天数、最常用模型
|
|
7
|
+
// 暴露 GET /api/usage-stats (?refresh=1 强制重新扫描)
|
|
8
|
+
// 性能:扫描结果做内存缓存 + 磁盘持久化缓存;过期后"先返回旧数据、后台异步刷新"(SWR),
|
|
9
|
+
// 首屏由磁盘缓存秒速渲染,不再因全量扫描卡顿。
|
|
10
|
+
import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join, dirname } from "node:path";
|
|
13
|
+
|
|
14
|
+
// connection:exact 路由优先于 /api prefix 的信任栅栏,必须自己过栅栏(见 handler)
|
|
15
|
+
export const inject = ["sessionPersistence", "webServer", "connection"];
|
|
16
|
+
|
|
17
|
+
// 已知模型 → 固定颜色(与「使用统计子组件库」视觉一致);其余按名称散列取调色板
|
|
18
|
+
const MODEL_COLORS = {
|
|
19
|
+
"GLM-5.2": "#3d91e7",
|
|
20
|
+
"jiaorong-deepseek-v4-pro": "#41b87b",
|
|
21
|
+
"glm-5.2": "#8c64ef",
|
|
22
|
+
"deepseek-v4-flash": "#ff4d4f",
|
|
23
|
+
"deepseek-v4-pro": "#faad14",
|
|
24
|
+
};
|
|
25
|
+
const FALLBACK_PALETTE = [
|
|
26
|
+
"#4da6ff", "#50d682", "#a684ff", "#ff7a9e", "#f2b95b",
|
|
27
|
+
"#5ad0ce", "#e07bd0", "#7ac04f", "#e28a5a", "#6d8ef2",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function modelColor(model) {
|
|
31
|
+
if (MODEL_COLORS[model]) return MODEL_COLORS[model];
|
|
32
|
+
let h = 0;
|
|
33
|
+
for (let i = 0; i < model.length; i++) h = (h * 31 + model.charCodeAt(i)) >>> 0;
|
|
34
|
+
return FALLBACK_PALETTE[h % FALLBACK_PALETTE.length];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 把 UNIX 毫秒时间戳折叠成本地日期键 YYYY-MM-DD
|
|
38
|
+
function dateKey(ms) {
|
|
39
|
+
try {
|
|
40
|
+
const d = new Date(ms);
|
|
41
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
42
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
43
|
+
return `${d.getFullYear()}-${m}-${day}`;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 本地小时 0-23
|
|
50
|
+
function hourOf(ms) {
|
|
51
|
+
try {
|
|
52
|
+
return new Date(ms).getHours();
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 连续活跃天数(活跃日 = 当天有 ≥1 条 usage 事件):当前连续 / 历史最长
|
|
59
|
+
function computeStreaks(activeKeys) {
|
|
60
|
+
const sorted = [...activeKeys].sort();
|
|
61
|
+
let longest = 0;
|
|
62
|
+
let run = 0;
|
|
63
|
+
let prev = null;
|
|
64
|
+
for (const key of sorted) {
|
|
65
|
+
// 日期键 YYYY-MM-DD 按整日比较,相邻日恰好差 86400000ms
|
|
66
|
+
const diff = prev === null ? -1 : Math.round((Date.parse(key) - Date.parse(prev)) / 86_400_000);
|
|
67
|
+
run = diff === 1 ? run + 1 : 1;
|
|
68
|
+
if (run > longest) longest = run;
|
|
69
|
+
prev = key;
|
|
70
|
+
}
|
|
71
|
+
let current = 0;
|
|
72
|
+
if (sorted.length > 0 && activeKeys.has(dateKey(Date.now()))) {
|
|
73
|
+
current = 1;
|
|
74
|
+
const d = new Date();
|
|
75
|
+
while (true) {
|
|
76
|
+
d.setDate(d.getDate() - 1);
|
|
77
|
+
if (activeKeys.has(dateKey(d.getTime()))) current += 1;
|
|
78
|
+
else break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { current, longest };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function emptyMetrics() {
|
|
85
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function addMetrics(target, usage) {
|
|
89
|
+
const input = typeof usage?.inputTokens === "number" ? usage.inputTokens : 0;
|
|
90
|
+
const output = typeof usage?.outputTokens === "number" ? usage.outputTokens : 0;
|
|
91
|
+
const cacheRead = typeof usage?.cacheReadTokens === "number" ? usage.cacheReadTokens : 0;
|
|
92
|
+
const cacheWrite = typeof usage?.cacheWriteTokens === "number" ? usage.cacheWriteTokens : 0;
|
|
93
|
+
target.input += input;
|
|
94
|
+
target.output += output;
|
|
95
|
+
target.cacheRead += cacheRead;
|
|
96
|
+
target.cacheWrite += cacheWrite;
|
|
97
|
+
target.total += input + output + cacheRead + cacheWrite;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function usageTotal(usage) {
|
|
101
|
+
if (!usage) return 0;
|
|
102
|
+
return (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 有限并发池:最多并发 limit 个任务;超过 deadline(ms) 提前返回部分结果(已处理的计入)
|
|
106
|
+
async function mapLimit(items, limit, deadline, fn) {
|
|
107
|
+
const out = new Array(items.length);
|
|
108
|
+
let i = 0;
|
|
109
|
+
const run = async () => {
|
|
110
|
+
while (i < items.length) {
|
|
111
|
+
if (Date.now() > deadline) return; // 超时:跳过未处理项,保留已处理结果(部分结果)
|
|
112
|
+
const cur = i++;
|
|
113
|
+
out[cur] = await fn(items[cur], cur);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const n = Math.max(1, Math.min(limit || 8, items.length) || 1);
|
|
117
|
+
await Promise.all(Array.from({ length: n }, run));
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 一次全量扫描:返回聚合数据结构
|
|
122
|
+
async function scanSessions(sessionPersistence, { timeoutMs = 20000 } = {}) {
|
|
123
|
+
const days = new Map(); // dateKey -> { total, turns, byModel: Map(model->metrics) }
|
|
124
|
+
const hourly = new Map(); // dateKey -> Map<hour, { total, byModel: Map(model->metrics) }>
|
|
125
|
+
const modelTotals = new Map(); // model -> metrics
|
|
126
|
+
const warnings = []; // 扫描过程中的失败/降级提示(供前端展示)
|
|
127
|
+
const usageDays = new Set(); // 当天有 ≥1 条 usage 事件 → 活跃日(summary 连续天数口径)
|
|
128
|
+
let longestSessionMs = 0; // 最长聊天时长素材:max(会话内最后事件 time − 会话 createdAt)
|
|
129
|
+
const deadline = Date.now() + timeoutMs;
|
|
130
|
+
|
|
131
|
+
const ensureDay = (key) => {
|
|
132
|
+
let d = days.get(key);
|
|
133
|
+
if (!d) {
|
|
134
|
+
d = { total: 0, turns: 0, byModel: new Map() };
|
|
135
|
+
days.set(key, d);
|
|
136
|
+
}
|
|
137
|
+
return d;
|
|
138
|
+
};
|
|
139
|
+
const ensureModel = (map, model) => {
|
|
140
|
+
let m = map.get(model);
|
|
141
|
+
if (!m) {
|
|
142
|
+
m = emptyMetrics();
|
|
143
|
+
map.set(model, m);
|
|
144
|
+
}
|
|
145
|
+
return m;
|
|
146
|
+
};
|
|
147
|
+
// account:按天 + 按小时 同时累计(小时供单日窗口趋势图,规范)
|
|
148
|
+
const account = (dayKey, hour, model, usage) => {
|
|
149
|
+
if (!dayKey) return;
|
|
150
|
+
const day = ensureDay(dayKey);
|
|
151
|
+
const dayModel = ensureModel(day.byModel, model);
|
|
152
|
+
const cumModel = ensureModel(modelTotals, model);
|
|
153
|
+
addMetrics(dayModel, usage);
|
|
154
|
+
addMetrics(cumModel, usage);
|
|
155
|
+
day.total += usageTotal(usage);
|
|
156
|
+
if (typeof hour === "number") {
|
|
157
|
+
let hm = hourly.get(dayKey);
|
|
158
|
+
if (!hm) { hm = new Map(); hourly.set(dayKey, hm); }
|
|
159
|
+
let hb = hm.get(hour);
|
|
160
|
+
if (!hb) { hb = { total: 0, byModel: new Map() }; hm.set(hour, hb); }
|
|
161
|
+
const hmModel = ensureModel(hb.byModel, model);
|
|
162
|
+
addMetrics(hmModel, usage);
|
|
163
|
+
hb.total += usageTotal(usage);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
let sessions;
|
|
168
|
+
let messageCount = 0;
|
|
169
|
+
try {
|
|
170
|
+
sessions = sessionPersistence.list ? await sessionPersistence.list() : [];
|
|
171
|
+
} catch (e) {
|
|
172
|
+
sessions = [];
|
|
173
|
+
warnings.push("无法枚举会话:" + (e && e.message ? e.message : e));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const scanOne = async (snapshot) => {
|
|
177
|
+
// list() 项可能是 { header, ... } 快照,也可能直接就是 header(版本差异),两种都兼容
|
|
178
|
+
const header = snapshot && typeof snapshot === "object" && snapshot.header ? snapshot.header : snapshot;
|
|
179
|
+
let events;
|
|
180
|
+
try {
|
|
181
|
+
if (typeof sessionPersistence.readFrom === "function") {
|
|
182
|
+
// 旧接口(0.1.0-rc.5 时代):service 级 readFrom
|
|
183
|
+
({ events } = await sessionPersistence.readFrom(header.id, 0));
|
|
184
|
+
} else {
|
|
185
|
+
// 0.1.5-rc.1:handle 式接口(官方 dsh-message-feedback 同一用法)
|
|
186
|
+
const handle = await sessionPersistence.open(header.id, "read");
|
|
187
|
+
try {
|
|
188
|
+
({ events } = await handle.read(0));
|
|
189
|
+
} finally {
|
|
190
|
+
await handle.close();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch (e) {
|
|
194
|
+
warnings.push("会话读取失败 " + (header.id ?? "?") + ":" + (e && e.message ? e.message : e));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// currentModel 缺失时归一为 "other"(杂项),避免 unknown 散落为独立饼块(规范 H6)
|
|
198
|
+
let currentModel = "other";
|
|
199
|
+
let lastTurn = null;
|
|
200
|
+
let lastTime = null; // 会话内最后事件 time(毫秒)
|
|
201
|
+
const seenSteps = new Set();
|
|
202
|
+
const accountOnce = (model, ev, usage) => {
|
|
203
|
+
const turn = ev.data?.turn;
|
|
204
|
+
const step = ev.data?.step;
|
|
205
|
+
if (typeof turn === "number" && typeof step === "number") {
|
|
206
|
+
const stepKey = `${turn}:${step}`;
|
|
207
|
+
if (seenSteps.has(stepKey)) return;
|
|
208
|
+
seenSteps.add(stepKey);
|
|
209
|
+
}
|
|
210
|
+
account(dateKey(ev.time), hourOf(ev.time), model, usage);
|
|
211
|
+
};
|
|
212
|
+
for (const ev of events || []) {
|
|
213
|
+
if (!ev || typeof ev !== "object") continue;
|
|
214
|
+
if (typeof ev.time === "number") lastTime = ev.time;
|
|
215
|
+
switch (ev.type) {
|
|
216
|
+
case "request/context":
|
|
217
|
+
currentModel = ev.data && typeof ev.data.model === "string" ? ev.data.model : currentModel;
|
|
218
|
+
break;
|
|
219
|
+
case "step/end":
|
|
220
|
+
// turns 官方口径:一个新 turn 记 1 次。以 step/end 为权威——每个 step 必然发出,
|
|
221
|
+
// 覆盖 completed/failed/cancelled/max-tokens;而 assistant/message 会漏 cancelled、多算 max-tokens。
|
|
222
|
+
if (typeof ev.data.turn === "number" && ev.data.turn !== lastTurn) {
|
|
223
|
+
lastTurn = ev.data.turn;
|
|
224
|
+
const key = dateKey(ev.time);
|
|
225
|
+
if (key) ensureDay(key).turns += 1;
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
case "assistant/message":
|
|
229
|
+
messageCount += 1;
|
|
230
|
+
// usage 只在每步一次的全量 assistant/message 上取;assistant/chunk 的 usage 是同一步的相同全量值,再计会重复,故不再消费
|
|
231
|
+
if (ev.data && ev.data.usage !== undefined) {
|
|
232
|
+
const activeKey = dateKey(ev.time);
|
|
233
|
+
if (activeKey) usageDays.add(activeKey);
|
|
234
|
+
// 优先从消息自身识别模型(兜底 currentModel / other)
|
|
235
|
+
const m = ev.data && typeof ev.data.model === "string" ? ev.data.model : currentModel;
|
|
236
|
+
accountOnce(m, ev, ev.data.usage);
|
|
237
|
+
}
|
|
238
|
+
break;
|
|
239
|
+
default:
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// 最长聊天时长:会话内最后事件 time − 会话 createdAt(异常/倒挂的会话不计)
|
|
244
|
+
const createdAt = header && typeof header.createdAt === "number" ? header.createdAt : null;
|
|
245
|
+
if (typeof lastTime === "number" && createdAt !== null && lastTime > createdAt) {
|
|
246
|
+
const dur = lastTime - createdAt;
|
|
247
|
+
if (dur > longestSessionMs) longestSessionMs = dur;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// 有限并发读取会话(规范 H4);越期自动停止(规范 H5 超时保护)
|
|
252
|
+
await mapLimit(sessions || [], 8, deadline, scanOne);
|
|
253
|
+
if (Date.now() > deadline) warnings.push("扫描超时,返回部分统计结果");
|
|
254
|
+
|
|
255
|
+
// 计算当前连续活跃天数
|
|
256
|
+
let currentStreak = 0;
|
|
257
|
+
const sortedDates = [...days.keys()].sort();
|
|
258
|
+
if (sortedDates.length > 0) {
|
|
259
|
+
const todayKey = dateKey(Date.now());
|
|
260
|
+
const lastActive = sortedDates[sortedDates.length - 1];
|
|
261
|
+
if (lastActive === todayKey) {
|
|
262
|
+
currentStreak = 1;
|
|
263
|
+
const d = new Date();
|
|
264
|
+
while (true) {
|
|
265
|
+
d.setDate(d.getDate() - 1);
|
|
266
|
+
if (days.has(dateKey(d.getTime()))) {
|
|
267
|
+
currentStreak += 1;
|
|
268
|
+
} else {
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { days, hourly, modelTotals, sessionCount: sessions.length, messageCount, currentStreak, streaks: computeStreaks(usageDays), longestSessionMs, warnings };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------- 缓存 ----------
|
|
279
|
+
// 内存 + 磁盘双层缓存;SWR:过期后先返回旧数据、后台异步刷新,首屏不因全量扫描阻塞。
|
|
280
|
+
let cache = null;
|
|
281
|
+
let cacheAt = 0;
|
|
282
|
+
const CACHE_TTL = 60_000; // 60s 后触发后台刷新(SWR)
|
|
283
|
+
let inflight = null; // 并发重扫互斥:并发期间共享同一份扫描结果(规范 H3)
|
|
284
|
+
|
|
285
|
+
function cacheFilePath() {
|
|
286
|
+
return join(homedir(), ".dsh", "profiles", "web", "usage-stats-cache.json");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function loadDiskCache() {
|
|
290
|
+
try {
|
|
291
|
+
const s = await readFile(cacheFilePath(), "utf8");
|
|
292
|
+
const j = JSON.parse(s);
|
|
293
|
+
if (j && typeof j === "object" && (j.ok || j.error)) return j;
|
|
294
|
+
return null;
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function persistDiskCache(data) {
|
|
301
|
+
const p = cacheFilePath();
|
|
302
|
+
const tmp = p + ".tmp";
|
|
303
|
+
try {
|
|
304
|
+
await mkdir(dirname(p), { recursive: true });
|
|
305
|
+
await writeFile(tmp, JSON.stringify(data), "utf8");
|
|
306
|
+
await rename(tmp, p).catch(() => writeFile(p, JSON.stringify(data), "utf8"));
|
|
307
|
+
} catch {
|
|
308
|
+
// 写盘失败不影响主流程(下次启动退化回全量扫描)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 把扫描结果转成可序列化、可直接返回给前端的响应结构
|
|
313
|
+
function buildCache(s) {
|
|
314
|
+
const dayList = [...s.days.entries()]
|
|
315
|
+
.map(([date, d]) => {
|
|
316
|
+
const hours = [...(s.hourly.get(date) || new Map()).entries()]
|
|
317
|
+
.map(([hour, o]) => ({
|
|
318
|
+
hour,
|
|
319
|
+
total: o.total,
|
|
320
|
+
byModel: [...o.byModel.entries()].map(([model, m]) => ({ model, color: modelColor(model), ...m })),
|
|
321
|
+
}))
|
|
322
|
+
.sort((a, b) => a.hour - b.hour);
|
|
323
|
+
return {
|
|
324
|
+
date,
|
|
325
|
+
total: d.total,
|
|
326
|
+
turns: d.turns,
|
|
327
|
+
byModel: [...d.byModel.entries()].map(([model, m]) => ({ model, color: modelColor(model), ...m })),
|
|
328
|
+
hours: hours.length > 0 ? hours : undefined,
|
|
329
|
+
};
|
|
330
|
+
})
|
|
331
|
+
.sort((a, b) => (a.date < b.date ? -1 : 1));
|
|
332
|
+
const totals = [...s.modelTotals.entries()]
|
|
333
|
+
.map(([model, m]) => ({ model, color: modelColor(model), ...m }))
|
|
334
|
+
.sort((a, b) => b.total - a.total);
|
|
335
|
+
const grandTotal = totals.reduce((sum, m) => sum + m.total, 0);
|
|
336
|
+
const top = totals[0];
|
|
337
|
+
const topModel = top
|
|
338
|
+
? { name: top.model, color: top.color, tokens: top.total, percent: grandTotal > 0 ? Math.round((top.total / grandTotal) * 100) : 0 }
|
|
339
|
+
: null;
|
|
340
|
+
// 顶部指标卡(基于全历史,不受时间范围选择影响)
|
|
341
|
+
const peakDayTokens = dayList.reduce((mx, d) => Math.max(mx, d.total), 0);
|
|
342
|
+
const summary = {
|
|
343
|
+
totalTokens: grandTotal, // 累计 Token 数
|
|
344
|
+
peakDayTokens, // 峰值 Token 数(历史单日最大)
|
|
345
|
+
longestSessionMs: s.longestSessionMs || 0, // 最长聊天时长
|
|
346
|
+
currentStreak: s.streaks ? s.streaks.current : 0, // 当前连续天数
|
|
347
|
+
longestStreak: s.streaks ? s.streaks.longest : 0, // 最长连续天数
|
|
348
|
+
};
|
|
349
|
+
return {
|
|
350
|
+
ok: true,
|
|
351
|
+
generatedAt: Date.now(),
|
|
352
|
+
sessionCount: s.sessionCount,
|
|
353
|
+
messageCount: s.messageCount,
|
|
354
|
+
dayCount: dayList.length,
|
|
355
|
+
currentStreak: s.currentStreak,
|
|
356
|
+
summary,
|
|
357
|
+
grandTotal,
|
|
358
|
+
topModel,
|
|
359
|
+
totals,
|
|
360
|
+
days: dayList,
|
|
361
|
+
warnings: s.warnings, // 失败/降级提示(规范 H5)
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function apply(ctx) {
|
|
366
|
+
const { sessionPersistence, webServer, connection } = ctx;
|
|
367
|
+
if (!webServer) return;
|
|
368
|
+
|
|
369
|
+
// 启动时异步加载磁盘缓存,回填内存(不阻塞 apply)
|
|
370
|
+
loadDiskCache().then((c) => { if (c && c.ok && !cache) { cache = c; cacheAt = c.generatedAt || 0; } });
|
|
371
|
+
|
|
372
|
+
const ensureScan = () => {
|
|
373
|
+
if (!inflight) {
|
|
374
|
+
inflight = (async () => {
|
|
375
|
+
const s = await scanSessions(sessionPersistence, { timeoutMs: 20000 });
|
|
376
|
+
const built = buildCache(s);
|
|
377
|
+
cache = built;
|
|
378
|
+
cacheAt = built.generatedAt;
|
|
379
|
+
try { await persistDiskCache(built); } catch { /* 忽略写盘失败 */ }
|
|
380
|
+
})().catch((e) => {
|
|
381
|
+
cache = { ok: false, error: String(e && e.message ? e.message : e), generatedAt: Date.now() };
|
|
382
|
+
cacheAt = Date.now();
|
|
383
|
+
}).finally(() => { inflight = null; });
|
|
384
|
+
}
|
|
385
|
+
return inflight;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
webServer.register({
|
|
389
|
+
kind: "exact",
|
|
390
|
+
path: "/api/usage-stats",
|
|
391
|
+
handler: async (req, res) => {
|
|
392
|
+
// 本路由是 /api 下的 exact 路由,优先级高于 dsh-client-connection 挂在 /api prefix 上的
|
|
393
|
+
// 信任栅栏(Host/Origin + 浏览器认证 cookie),必须自己过栅栏(官方 dsh-host-open-in-app 同一做法)。
|
|
394
|
+
const rejection = connection && typeof connection.requestRejection === "function"
|
|
395
|
+
? connection.requestRejection(req)
|
|
396
|
+
: 503; // 拿不到栅栏服务时拒绝服务,而不是放行
|
|
397
|
+
if (rejection !== undefined) {
|
|
398
|
+
res.statusCode = rejection;
|
|
399
|
+
res.end(rejection === 401 ? "unauthorized" : rejection === 403 ? "forbidden" : "service unavailable");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const url = new URL(req.url, "http://x");
|
|
403
|
+
const refresh = url.searchParams.get("refresh") === "1";
|
|
404
|
+
if (!cache || refresh) {
|
|
405
|
+
// 无缓存:必须同步扫描首屏等待;或强制刷新:等待新结果
|
|
406
|
+
await ensureScan();
|
|
407
|
+
} else {
|
|
408
|
+
// 有缓存:立即返回;若过期则后台异步刷新,不阻塞本次响应(SWR)
|
|
409
|
+
const stale = Date.now() - (cache.generatedAt || 0) > CACHE_TTL;
|
|
410
|
+
if (stale) ensureScan();
|
|
411
|
+
}
|
|
412
|
+
res.writeHead(cache && cache.ok ? 200 : 500, { "Content-Type": "application/json" });
|
|
413
|
+
res.end(JSON.stringify(cache));
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "zcode-usage-stats",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "仿zcode的使用统计:在 DeepSeek Harness(DSH) 设置页渲染全量会话的 Token 用量统计——按天/按小时趋势图、模型用量仪表盘、活跃热力图,支持今天/昨天/最近7·30天/本月/上月/自定义区间。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/index.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.mjs"
|
|
10
|
+
},
|
|
11
|
+
"./client": {
|
|
12
|
+
"default": "./lib/client.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"lib",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"pack": "npm pack"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"dsh",
|
|
27
|
+
"deepseek-harness",
|
|
28
|
+
"dsh-plugin",
|
|
29
|
+
"token",
|
|
30
|
+
"usage",
|
|
31
|
+
"statistics",
|
|
32
|
+
"使用统计"
|
|
33
|
+
],
|
|
34
|
+
"dsh": {
|
|
35
|
+
"bundle": {
|
|
36
|
+
"patch": "./cordis.patch.yml"
|
|
37
|
+
},
|
|
38
|
+
"client": {
|
|
39
|
+
"platform": "web",
|
|
40
|
+
"inject": [
|
|
41
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
42
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1",
|
|
48
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.5 || ^0.1.1-rc.1",
|
|
49
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.5 || ^0.1.1-rc.1 || ^0.1.5-rc.1",
|
|
50
|
+
"react": "^18.2.0"
|
|
51
|
+
},
|
|
52
|
+
"license": "MIT"
|
|
53
|
+
}
|