@feiyang666/dsh-usage-plugin 1.9.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/CHANGELOG.md +135 -0
- package/LICENSE +21 -0
- package/README.en.md +272 -0
- package/README.md +272 -0
- package/cordis.patch.yml +22 -0
- package/lib/client.js +1247 -0
- package/lib/index.js +911 -0
- package/package.json +69 -0
- package/scripts/check-package.js +84 -0
- package/scripts/wire.js +108 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-plugin — HOST half.
|
|
3
|
+
*
|
|
4
|
+
* Permanent Cordis plugin for a DeepSeek Harness web/desktop profile:
|
|
5
|
+
* - listens to `llm/stream`, records every model call's token usage,
|
|
6
|
+
* cache-hit/miss counts and finish reason;
|
|
7
|
+
* - persists records to `<session workspace>/dsh-usage/usage-records.json`;
|
|
8
|
+
* - serves a JSON API at `POST /usage/api` for the client half.
|
|
9
|
+
*
|
|
10
|
+
* The apply body is instrumented: every step is appended to a diagnostics
|
|
11
|
+
* buffer and flushed to `dsh-usage-boot.log` (resolved relative to the fs
|
|
12
|
+
* provider cwd) so activation failures are visible without app logs.
|
|
13
|
+
*
|
|
14
|
+
* Cross-platform note: path handling uses node:path (join / dirname) with the
|
|
15
|
+
* host platform's separator, so the plugin works on Windows, macOS and Linux.
|
|
16
|
+
*/
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
export default {
|
|
19
|
+
inject: ['fs', 'webServer', 'subprocess', 'credentials', 'sandboxPolicy', 'agents'],
|
|
20
|
+
apply(ctx) {
|
|
21
|
+
const diag = { ok: true, steps: [], error: null }
|
|
22
|
+
const push = (s) => { try { diag.steps.push(String(s)) } catch (e) {} }
|
|
23
|
+
const flushDiag = () => {
|
|
24
|
+
try {
|
|
25
|
+
const fs = ctx.get('fs')
|
|
26
|
+
if (fs && typeof fs.resolve === 'function' && typeof fs.writeText === 'function') {
|
|
27
|
+
fs.resolve('dsh-usage-boot.log')
|
|
28
|
+
.then((target) => fs.writeText(target, JSON.stringify({ time: Date.now(), ...diag }, null, 2)))
|
|
29
|
+
.catch(() => {})
|
|
30
|
+
}
|
|
31
|
+
} catch (e) {}
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
push('apply-start')
|
|
35
|
+
|
|
36
|
+
const records = []
|
|
37
|
+
const MAX_RECORDS = 100000
|
|
38
|
+
|
|
39
|
+
const PRICING = {
|
|
40
|
+
base: {
|
|
41
|
+
'deepseek-v4-flash': { cacheHit: 0.02, cacheMiss: 1.0, output: 2.0 },
|
|
42
|
+
'deepseek-v4-pro': { cacheHit: 0.025, cacheMiss: 3.0, output: 6.0 }
|
|
43
|
+
},
|
|
44
|
+
peakValley: {
|
|
45
|
+
'deepseek-v4-flash': {
|
|
46
|
+
offPeak: { cacheHit: 0.05, cacheMiss: 1.5, output: 4.5 },
|
|
47
|
+
peak: { cacheHit: 0.1, cacheMiss: 3.0, output: 9.0 }
|
|
48
|
+
},
|
|
49
|
+
'deepseek-v4-pro': {
|
|
50
|
+
offPeak: { cacheHit: 0.15, cacheMiss: 4.5, output: 13.5 },
|
|
51
|
+
peak: { cacheHit: 0.3, cacheMiss: 9.0, output: 27.0 }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const DEFAULT_PRICING = JSON.parse(JSON.stringify(PRICING))
|
|
56
|
+
const PRICE_MODELS = ['deepseek-v4-flash', 'deepseek-v4-pro']
|
|
57
|
+
// 新价格表(峰谷价)生效时间:北京时间 2026-08-17 00:00。
|
|
58
|
+
// 在此之前的调用按旧价格表(基础价 base)计费;之后按新价格表(峰谷价)计费。
|
|
59
|
+
const EFFECTIVE_AT = Date.parse('2026-08-17T00:00:00+08:00')
|
|
60
|
+
|
|
61
|
+
function modelKey(model) {
|
|
62
|
+
const m = String(model || '').toLowerCase()
|
|
63
|
+
if (m.indexOf('flash') >= 0) return 'deepseek-v4-flash'
|
|
64
|
+
if (m.indexOf('pro') >= 0) return 'deepseek-v4-pro'
|
|
65
|
+
return 'unknown'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isPeak(ts) {
|
|
69
|
+
const d = new Date(ts + 8 * 3600 * 1000)
|
|
70
|
+
const t = d.getUTCHours() * 60 + d.getUTCMinutes()
|
|
71
|
+
return (t >= 9 * 60 && t < 12 * 60) || (t >= 14 * 60 && t < 18 * 60)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// regime: 'base' = 旧价格表(基础价) | 'peakValley' = 新价格表(峰谷价) | 'auto' = 按生效日期自动切换
|
|
75
|
+
function costFor(rec, regime) {
|
|
76
|
+
const mk = modelKey(rec.model)
|
|
77
|
+
const hit = rec.cacheReadTokens || 0
|
|
78
|
+
const miss = rec.inputTokens || 0
|
|
79
|
+
const out = rec.outputTokens || 0
|
|
80
|
+
if (regime === 'base') {
|
|
81
|
+
const p = PRICING.base[mk]
|
|
82
|
+
if (!p) return 0
|
|
83
|
+
return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
|
|
84
|
+
}
|
|
85
|
+
if (regime === 'auto') {
|
|
86
|
+
// 生效前用旧价格表(基础价);生效后按峰谷时段用新价格表
|
|
87
|
+
if (rec.time < EFFECTIVE_AT) {
|
|
88
|
+
const p = PRICING.base[mk]
|
|
89
|
+
if (!p) return 0
|
|
90
|
+
return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
|
|
91
|
+
}
|
|
92
|
+
const pv = PRICING.peakValley[mk]
|
|
93
|
+
if (!pv) return 0
|
|
94
|
+
const p = isPeak(rec.time) ? pv.peak : pv.offPeak
|
|
95
|
+
return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
|
|
96
|
+
}
|
|
97
|
+
const pv = PRICING.peakValley[mk]
|
|
98
|
+
if (!pv) return 0
|
|
99
|
+
const p = isPeak(rec.time) ? pv.peak : pv.offPeak
|
|
100
|
+
return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const msg = (e) => String((e && e.message) || e)
|
|
104
|
+
const fail = (message) => ({ ok: false, error: message })
|
|
105
|
+
const pad2 = (n) => (n < 10 ? '0' : '') + n
|
|
106
|
+
const fmtInt = (n) => String(Math.round(n || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
|
107
|
+
const fmtTime = (ts) => {
|
|
108
|
+
const d = new Date(ts + 8 * 3600 * 1000)
|
|
109
|
+
return `${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`
|
|
110
|
+
}
|
|
111
|
+
const fmtMoney = (n) => {
|
|
112
|
+
if (!n) return '0.0000'
|
|
113
|
+
if (n < 0.0001) return n.toExponential(2)
|
|
114
|
+
if (n < 1) return n.toFixed(4)
|
|
115
|
+
return n.toFixed(2)
|
|
116
|
+
}
|
|
117
|
+
const IS_WIN = typeof process !== 'undefined' && process.platform === 'win32'
|
|
118
|
+
const IS_MAC = typeof process !== 'undefined' && process.platform === 'darwin'
|
|
119
|
+
// Windows 保留原有行为:把 / 统一成 \;POSIX 上保持原样(不做 / → \ 转换)。
|
|
120
|
+
const normPath = (p) => {
|
|
121
|
+
const s = String(p == null ? '' : p)
|
|
122
|
+
return IS_WIN ? s.replace(/\//g, '\\') : s
|
|
123
|
+
}
|
|
124
|
+
// 平台化拼接:Windows 用反斜杠,POSIX 用正斜杠。
|
|
125
|
+
const joinPath = (...parts) => path.join(...parts.map((p) => String(p == null ? '' : p)))
|
|
126
|
+
const stamp = () => {
|
|
127
|
+
const d = new Date()
|
|
128
|
+
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── daily aggregates (Beijing-time calendar) ───────────────────────────
|
|
132
|
+
function bjKey(ts) {
|
|
133
|
+
const d = new Date(Number(ts) + 8 * 3600 * 1000)
|
|
134
|
+
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildDays() {
|
|
138
|
+
const map = {}
|
|
139
|
+
for (const r of records) {
|
|
140
|
+
const key = bjKey(r.time)
|
|
141
|
+
let d = map[key]
|
|
142
|
+
if (!d) {
|
|
143
|
+
d = {
|
|
144
|
+
day: key, calls: 0, miss: 0, hit: 0, write: 0, out: 0, reason: 0,
|
|
145
|
+
peakCalls: 0, offPeakCalls: 0, baseCost: 0, peakValleyCost: 0, autoCost: 0,
|
|
146
|
+
// 高峰/空闲时段消耗拆分(每个计费档位各一桶),供日历/概览区分展示
|
|
147
|
+
basePeakCost: 0, baseOffPeakCost: 0,
|
|
148
|
+
pvPeakCost: 0, pvOffPeakCost: 0,
|
|
149
|
+
autoPeakCost: 0, autoOffPeakCost: 0
|
|
150
|
+
}
|
|
151
|
+
map[key] = d
|
|
152
|
+
}
|
|
153
|
+
d.calls++
|
|
154
|
+
d.miss += r.inputTokens || 0
|
|
155
|
+
d.hit += r.cacheReadTokens || 0
|
|
156
|
+
d.write += r.cacheWriteTokens || 0
|
|
157
|
+
d.out += r.outputTokens || 0
|
|
158
|
+
d.reason += r.reasoningTokens || 0
|
|
159
|
+
const cBase = costFor(r, 'base')
|
|
160
|
+
const cPv = costFor(r, 'peakValley')
|
|
161
|
+
const cAuto = costFor(r, 'auto')
|
|
162
|
+
if (isPeak(r.time)) {
|
|
163
|
+
d.peakCalls++
|
|
164
|
+
d.basePeakCost += cBase
|
|
165
|
+
d.pvPeakCost += cPv
|
|
166
|
+
d.autoPeakCost += cAuto
|
|
167
|
+
} else {
|
|
168
|
+
d.offPeakCalls++
|
|
169
|
+
d.baseOffPeakCost += cBase
|
|
170
|
+
d.pvOffPeakCost += cPv
|
|
171
|
+
d.autoOffPeakCost += cAuto
|
|
172
|
+
}
|
|
173
|
+
d.baseCost += cBase
|
|
174
|
+
d.peakValleyCost += cPv
|
|
175
|
+
d.autoCost += cAuto
|
|
176
|
+
}
|
|
177
|
+
const days = []
|
|
178
|
+
for (const k in map) days.push(map[k])
|
|
179
|
+
days.sort((a, b) => (a.day < b.day ? 1 : a.day > b.day ? -1 : 0))
|
|
180
|
+
return days
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const fs = ctx.get('fs')
|
|
184
|
+
push('fs=' + (fs ? 'present' : 'undefined'))
|
|
185
|
+
let root = ''
|
|
186
|
+
let dataPath = ''
|
|
187
|
+
let pricingPath = ''
|
|
188
|
+
let persistOk = false
|
|
189
|
+
let persistError = ''
|
|
190
|
+
let initPromise = null
|
|
191
|
+
let writeChain = Promise.resolve()
|
|
192
|
+
let cachedPolicy = null
|
|
193
|
+
|
|
194
|
+
const dirs = () => ({
|
|
195
|
+
data: joinPath(root, 'dsh-usage'),
|
|
196
|
+
csv: joinPath(root, 'dsh-usage', 'csv'),
|
|
197
|
+
json: joinPath(root, 'dsh-usage', 'json'),
|
|
198
|
+
images: joinPath(root, 'dsh-usage', 'images')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
function currentAgent() {
|
|
202
|
+
try {
|
|
203
|
+
const agents = ctx.get('agents')
|
|
204
|
+
if (agents && typeof agents.currentInitiator === 'function') return agents.currentInitiator()
|
|
205
|
+
} catch (e) {}
|
|
206
|
+
return undefined
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function sessionPolicy() {
|
|
210
|
+
if (cachedPolicy) return cachedPolicy
|
|
211
|
+
try {
|
|
212
|
+
const agent = currentAgent()
|
|
213
|
+
const sp = ctx.get('sandboxPolicy')
|
|
214
|
+
if (sp && typeof sp.resolve === 'function' && agent && agent.session) {
|
|
215
|
+
const policy = sp.resolve({ session: agent.session })
|
|
216
|
+
if (policy && policy.workspaceRoot) {
|
|
217
|
+
cachedPolicy = policy
|
|
218
|
+
return policy
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} catch (e) {}
|
|
222
|
+
return undefined
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function persistNow() {
|
|
226
|
+
if (!fs || !dataPath || !persistOk) return Promise.resolve()
|
|
227
|
+
const text = JSON.stringify(records)
|
|
228
|
+
const policy = sessionPolicy()
|
|
229
|
+
writeChain = writeChain.then(() =>
|
|
230
|
+
fs.resolve(dataPath).then((target) =>
|
|
231
|
+
fs.writeText(target, text, undefined, undefined, policy || undefined)
|
|
232
|
+
)
|
|
233
|
+
).catch(() => {})
|
|
234
|
+
return writeChain
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function persistPricing() {
|
|
238
|
+
if (!fs || !pricingPath || !persistOk) return Promise.resolve()
|
|
239
|
+
const text = JSON.stringify(PRICING)
|
|
240
|
+
const policy = sessionPolicy()
|
|
241
|
+
return fs.resolve(pricingPath)
|
|
242
|
+
.then((target) => fs.writeText(target, text, undefined, undefined, policy || undefined))
|
|
243
|
+
.catch(() => {})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function loadPricing(policy) {
|
|
247
|
+
if (!fs || !pricingPath) return
|
|
248
|
+
try {
|
|
249
|
+
const target = await fs.resolve(pricingPath)
|
|
250
|
+
const data = JSON.parse(await fs.readText(target))
|
|
251
|
+
if (!data || typeof data !== 'object') return
|
|
252
|
+
for (const regime of ['base', 'peakValley']) {
|
|
253
|
+
const src = data[regime]
|
|
254
|
+
const dst = PRICING[regime]
|
|
255
|
+
if (!src || typeof src !== 'object' || !dst) continue
|
|
256
|
+
for (const mk of PRICE_MODELS) {
|
|
257
|
+
const row = src[mk]
|
|
258
|
+
if (!row || typeof row !== 'object' || !dst[mk]) continue
|
|
259
|
+
for (const k of ['cacheHit', 'cacheMiss', 'output']) {
|
|
260
|
+
const v = Number(row[k])
|
|
261
|
+
if (Number.isFinite(v) && v >= 0) dst[mk][k] = v
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} catch (e) {}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function normalizeRecord(raw) {
|
|
269
|
+
if (!raw || typeof raw !== 'object') return null
|
|
270
|
+
const time = Number(raw.time)
|
|
271
|
+
if (!Number.isFinite(time) || time <= 0) return null
|
|
272
|
+
const toNum = (v, d) => { const n = Number(v); return Number.isFinite(n) ? n : (d === undefined ? 0 : d) }
|
|
273
|
+
return {
|
|
274
|
+
time,
|
|
275
|
+
model: String(raw.model || ''),
|
|
276
|
+
provider: String(raw.provider || ''),
|
|
277
|
+
purpose: String(raw.purpose || ''),
|
|
278
|
+
inputTokens: toNum(raw.inputTokens),
|
|
279
|
+
outputTokens: toNum(raw.outputTokens),
|
|
280
|
+
cacheReadTokens: toNum(raw.cacheReadTokens),
|
|
281
|
+
cacheWriteTokens: toNum(raw.cacheWriteTokens),
|
|
282
|
+
reasoningTokens: toNum(raw.reasoningTokens),
|
|
283
|
+
finishReason: String(raw.finishReason || '')
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function tryInitWithRoot(candidate, policy) {
|
|
288
|
+
const tryPath = joinPath(normPath(candidate), 'dsh-usage', 'usage-records.json')
|
|
289
|
+
try {
|
|
290
|
+
const target = await fs.resolve(tryPath)
|
|
291
|
+
const arr = JSON.parse(await fs.readText(target))
|
|
292
|
+
if (Array.isArray(arr) && arr.length > 0) {
|
|
293
|
+
const existing = {}
|
|
294
|
+
for (let i = 0; i < records.length; i++) existing[records[i].time] = true
|
|
295
|
+
for (let i = 0; i < arr.length; i++) {
|
|
296
|
+
const rec = normalizeRecord(arr[i])
|
|
297
|
+
if (!rec || existing[rec.time]) continue
|
|
298
|
+
existing[rec.time] = true
|
|
299
|
+
records.push(rec)
|
|
300
|
+
}
|
|
301
|
+
if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
|
|
302
|
+
records.sort((a, b) => a.time - b.time)
|
|
303
|
+
}
|
|
304
|
+
} catch (e) {}
|
|
305
|
+
try {
|
|
306
|
+
const target = await fs.resolve(tryPath)
|
|
307
|
+
await fs.writeText(target, JSON.stringify(records), undefined, undefined, policy || undefined)
|
|
308
|
+
root = normPath(candidate)
|
|
309
|
+
dataPath = tryPath
|
|
310
|
+
pricingPath = joinPath(path.dirname(dataPath), 'pricing.json')
|
|
311
|
+
await loadPricing(policy)
|
|
312
|
+
persistOk = true
|
|
313
|
+
persistError = ''
|
|
314
|
+
return { ok: true }
|
|
315
|
+
} catch (e) {
|
|
316
|
+
return { ok: false, error: msg(e) }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function migrateLegacy(candidates) {
|
|
321
|
+
if (!fs) return
|
|
322
|
+
const paths = []
|
|
323
|
+
for (const c of candidates) {
|
|
324
|
+
paths.push(joinPath(normPath(c), '.dsh-usage-records.json'))
|
|
325
|
+
paths.push(joinPath(normPath(c), 'dsh-usage', 'usage-records.json'))
|
|
326
|
+
}
|
|
327
|
+
for (const p of paths) {
|
|
328
|
+
try {
|
|
329
|
+
const arr = JSON.parse(await fs.readText(await fs.resolve(p)))
|
|
330
|
+
if (Array.isArray(arr)) {
|
|
331
|
+
const existing = {}
|
|
332
|
+
for (let j = 0; j < records.length; j++) existing[records[j].time] = true
|
|
333
|
+
for (const raw of arr) {
|
|
334
|
+
const rec = normalizeRecord(raw)
|
|
335
|
+
if (!rec || existing[rec.time]) continue
|
|
336
|
+
existing[rec.time] = true
|
|
337
|
+
records.push(rec)
|
|
338
|
+
}
|
|
339
|
+
if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
|
|
340
|
+
records.sort((a, b) => a.time - b.time)
|
|
341
|
+
}
|
|
342
|
+
} catch (e) {}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function ensureSessionRoot() {
|
|
347
|
+
if (!fs) return
|
|
348
|
+
const policy = sessionPolicy()
|
|
349
|
+
if (!policy || !policy.workspaceRoot) return
|
|
350
|
+
const cwd = normPath(String(policy.workspaceRoot))
|
|
351
|
+
if (cwd === root && persistOk) return
|
|
352
|
+
const r = await tryInitWithRoot(cwd, policy)
|
|
353
|
+
if (r.ok) {
|
|
354
|
+
const sp = ctx.get('sandboxPolicy')
|
|
355
|
+
await migrateLegacy([cwd, normPath(String((sp && sp.workspaceRoot) || ''))])
|
|
356
|
+
persistNow()
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function initPersistence() {
|
|
361
|
+
if (!fs) { persistError = '文件服务不可用'; return }
|
|
362
|
+
const candidates = []
|
|
363
|
+
const agent = currentAgent()
|
|
364
|
+
if (agent && agent.session && agent.session.header && agent.session.header.cwd) candidates.push(normPath(String(agent.session.header.cwd)))
|
|
365
|
+
try {
|
|
366
|
+
const sp = ctx.get('sandboxPolicy')
|
|
367
|
+
if (sp && sp.workspaceRoot) candidates.push(normPath(String(sp.workspaceRoot)))
|
|
368
|
+
} catch (e) {}
|
|
369
|
+
try {
|
|
370
|
+
const t = await fs.resolve('dsh-usage-probe')
|
|
371
|
+
const p = String(t.displayPath || '')
|
|
372
|
+
const i = p.lastIndexOf('dsh-usage-probe')
|
|
373
|
+
if (i > 0) candidates.push(p.slice(0, i))
|
|
374
|
+
} catch (e) {}
|
|
375
|
+
const seen = {}
|
|
376
|
+
let lastError = ''
|
|
377
|
+
for (const c of candidates) {
|
|
378
|
+
if (!c || seen[c]) continue
|
|
379
|
+
seen[c] = true
|
|
380
|
+
const r = await tryInitWithRoot(c, sessionPolicy())
|
|
381
|
+
if (r.ok) {
|
|
382
|
+
await migrateLegacy(candidates)
|
|
383
|
+
persistNow()
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
lastError = r.error || '写入失败'
|
|
387
|
+
}
|
|
388
|
+
persistError = lastError || '未找到可写的持久化目录'
|
|
389
|
+
persistOk = false
|
|
390
|
+
root = ''
|
|
391
|
+
dataPath = ''
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const ensureInit = () => (initPromise ||= initPersistence())
|
|
395
|
+
|
|
396
|
+
try { ensureInit() } catch (e) { push('ensureInit-threw: ' + msg(e)) }
|
|
397
|
+
|
|
398
|
+
// ── capture ────────────────────────────────────────────────────────────
|
|
399
|
+
try {
|
|
400
|
+
ctx.on('llm/stream', function (options, next) {
|
|
401
|
+
const source = next()
|
|
402
|
+
const model = (options && options.model) || ''
|
|
403
|
+
const provider = (options && options.provider) || ''
|
|
404
|
+
const purpose = options && options.purpose ? String(options.purpose) : ''
|
|
405
|
+
const startedAt = Date.now()
|
|
406
|
+
let usage = null
|
|
407
|
+
let finishReason = ''
|
|
408
|
+
|
|
409
|
+
async function* observe() {
|
|
410
|
+
try {
|
|
411
|
+
for await (const chunk of source) {
|
|
412
|
+
if (chunk && chunk.type === 'usage' && chunk.usage) {
|
|
413
|
+
usage = chunk.usage
|
|
414
|
+
} else if (chunk && chunk.type === 'finish') {
|
|
415
|
+
const r = chunk.reason
|
|
416
|
+
finishReason = r ? String(r.kind || '') : ''
|
|
417
|
+
}
|
|
418
|
+
yield chunk
|
|
419
|
+
}
|
|
420
|
+
} finally {
|
|
421
|
+
if (usage) {
|
|
422
|
+
records.push({
|
|
423
|
+
time: startedAt,
|
|
424
|
+
model,
|
|
425
|
+
provider,
|
|
426
|
+
purpose,
|
|
427
|
+
inputTokens: usage.inputTokens || 0,
|
|
428
|
+
outputTokens: usage.outputTokens || 0,
|
|
429
|
+
cacheReadTokens: usage.cacheReadTokens || 0,
|
|
430
|
+
cacheWriteTokens: usage.cacheWriteTokens || 0,
|
|
431
|
+
reasoningTokens: usage.reasoningTokens || 0,
|
|
432
|
+
finishReason
|
|
433
|
+
})
|
|
434
|
+
if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
|
|
435
|
+
try {
|
|
436
|
+
const agent = currentAgent()
|
|
437
|
+
const sp = ctx.get('sandboxPolicy')
|
|
438
|
+
if (sp && typeof sp.resolve === 'function' && agent && agent.session) {
|
|
439
|
+
const policy = sp.resolve({ session: agent.session })
|
|
440
|
+
if (policy && policy.workspaceRoot) cachedPolicy = policy
|
|
441
|
+
}
|
|
442
|
+
} catch (e) {}
|
|
443
|
+
ensureSessionRoot().then(persistNow).catch(() => {})
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return observe()
|
|
449
|
+
})
|
|
450
|
+
push('llm-stream-listener-ok')
|
|
451
|
+
} catch (e) {
|
|
452
|
+
push('llm-stream-listener-threw: ' + (e && e.stack ? e.stack : msg(e)))
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ── balance ────────────────────────────────────────────────────────────
|
|
456
|
+
function parseBalance(text) {
|
|
457
|
+
let data
|
|
458
|
+
try { data = JSON.parse(text) } catch (e) { return fail('无法解析余额响应') }
|
|
459
|
+
const infos = []
|
|
460
|
+
const rawInfos = data && Array.isArray(data.balance_infos) ? data.balance_infos : []
|
|
461
|
+
for (const b of rawInfos) {
|
|
462
|
+
infos.push({
|
|
463
|
+
currency: String(b.currency || 'CNY'),
|
|
464
|
+
totalBalance: String(b.total_balance == null ? '0' : b.total_balance),
|
|
465
|
+
grantedBalance: String(b.granted_balance == null ? '0' : b.granted_balance),
|
|
466
|
+
toppedUpBalance: String(b.topped_up_balance == null ? '0' : b.topped_up_balance)
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
return { ok: true, queriedAt: Date.now(), isAvailable: data.is_available === true, infos }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// spawn 的 cwd:校验工作区目录真实存在,无效时回退到宿主进程的 cwd
|
|
473
|
+
// (POSIX 上根目录是伪路径/不存在时,直接传 cwd 会导致 spawn ENOENT)。
|
|
474
|
+
async function safeCwd() {
|
|
475
|
+
if (root && fs) {
|
|
476
|
+
try {
|
|
477
|
+
const t = await fs.resolve(root)
|
|
478
|
+
const info = await fs.stat(t)
|
|
479
|
+
if (info) return root
|
|
480
|
+
} catch (e) {}
|
|
481
|
+
}
|
|
482
|
+
return (typeof process !== 'undefined' && typeof process.cwd === 'function' && process.cwd()) || '.'
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// 通用子进程执行:收集 stdout/stderr,统一处理 cwd 与错误分类。
|
|
486
|
+
async function runCollect(argv, opts) {
|
|
487
|
+
const subprocess = ctx.get('subprocess')
|
|
488
|
+
if (!subprocess) return { ok: false, error: '命令执行服务不可用' }
|
|
489
|
+
let handle
|
|
490
|
+
try {
|
|
491
|
+
handle = subprocess.spawn({
|
|
492
|
+
argv,
|
|
493
|
+
cwd: await safeCwd(),
|
|
494
|
+
stdio: opts && opts.stdinData != null
|
|
495
|
+
? { stdin: { data: opts.stdinData }, stdout: { maxBytes: 65536 }, stderr: { maxBytes: 65536 } }
|
|
496
|
+
: { stdin: 'ignore', stdout: { maxBytes: 65536 }, stderr: { maxBytes: 65536 } },
|
|
497
|
+
graceMs: (opts && opts.graceMs) || 15000,
|
|
498
|
+
...(opts && opts.env ? { env: opts.env } : {})
|
|
499
|
+
})
|
|
500
|
+
} catch (e) { return { ok: false, error: '启动失败:' + msg(e) } }
|
|
501
|
+
let outcome
|
|
502
|
+
try { outcome = await handle.done } catch (e) { return { ok: false, error: '执行失败:' + msg(e) } }
|
|
503
|
+
const outText = handle.collected && handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
|
|
504
|
+
const errText = handle.collected && handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
|
|
505
|
+
return { ok: outcome.exitCode === 0, exitCode: outcome.exitCode, out: outText, err: errText }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// node 可执行文件候选:Windows 保留原有路径;POSIX 走 PATH;
|
|
509
|
+
// Electron 宿主(桌面端)下用 execPath + ELECTRON_RUN_AS_NODE=1 兜底。
|
|
510
|
+
const isElectron = typeof process !== 'undefined' && !!(process.versions && process.versions.electron)
|
|
511
|
+
function nodeCandidates() {
|
|
512
|
+
const list = IS_WIN
|
|
513
|
+
? ['node.exe', 'node', 'C:\\Program Files\\nodejs\\node.exe']
|
|
514
|
+
: ['node']
|
|
515
|
+
if (typeof process !== 'undefined' && process.execPath && !list.includes(process.execPath)) list.push(process.execPath)
|
|
516
|
+
return list
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function spawnNode(script, stdinData, env) {
|
|
520
|
+
const subprocess = ctx.get('subprocess')
|
|
521
|
+
if (!subprocess) return { ok: false, error: '命令执行服务不可用' }
|
|
522
|
+
let exe = null
|
|
523
|
+
for (const c of nodeCandidates()) {
|
|
524
|
+
try { exe = await subprocess.resolveExecutable(c); if (exe) break } catch (e) {}
|
|
525
|
+
}
|
|
526
|
+
if (!exe) return { ok: false, error: '未找到 node 可执行文件' }
|
|
527
|
+
const finalEnv = env || {}
|
|
528
|
+
if (isElectron && exe === process.execPath && !('ELECTRON_RUN_AS_NODE' in finalEnv)) {
|
|
529
|
+
finalEnv.ELECTRON_RUN_AS_NODE = '1'
|
|
530
|
+
}
|
|
531
|
+
const r = await runCollect([exe, '-e', script], { stdinData, env: finalEnv })
|
|
532
|
+
if (!r.ok) {
|
|
533
|
+
if (r.exitCode != null) return { ok: false, error: 'node 退出码 ' + r.exitCode + (r.err ? ':' + r.err.trim() : '') }
|
|
534
|
+
return { ok: false, error: r.error || '执行失败' }
|
|
535
|
+
}
|
|
536
|
+
return { ok: true, out: r.out }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function queryBalance() {
|
|
540
|
+
const credentials = ctx.get('credentials')
|
|
541
|
+
if (!credentials) return fail('凭据服务不可用')
|
|
542
|
+
let hit
|
|
543
|
+
try { hit = await credentials.resolve('DEEPSEEK_API_KEY') } catch (e) { return fail('读取凭据失败:' + msg(e)) }
|
|
544
|
+
if (!hit || !hit.value) return fail('未配置 DEEPSEEK_API_KEY,请在「设置 → 模型」中配置后重试')
|
|
545
|
+
const key = hit.value
|
|
546
|
+
const script = [
|
|
547
|
+
'const https=require("https");',
|
|
548
|
+
'const key=process.env.BALANCE_API_KEY||"";',
|
|
549
|
+
'const req=https.get("https://api.deepseek.com/user/balance",{headers:{Authorization:"Bearer "+key}},function(res){',
|
|
550
|
+
'var body="";',
|
|
551
|
+
'res.on("data",function(c){body+=c});',
|
|
552
|
+
'res.on("end",function(){process.stdout.write(JSON.stringify({statusCode:res.statusCode,body:body}))});',
|
|
553
|
+
'});',
|
|
554
|
+
'req.on("error",function(e){process.stdout.write(JSON.stringify({error:String(e&&e.message||e)}))});',
|
|
555
|
+
'req.setTimeout(20000,function(){req.destroy(new Error("timeout"))});'
|
|
556
|
+
].join('\n')
|
|
557
|
+
const r = await spawnNode(script, null, { BALANCE_API_KEY: key })
|
|
558
|
+
if (!r.ok) return fail(r.error)
|
|
559
|
+
let parsed
|
|
560
|
+
try { parsed = JSON.parse(r.out) } catch (e) { return fail('无法解析 node 输出') }
|
|
561
|
+
if (parsed.error) return fail(parsed.error)
|
|
562
|
+
if (parsed.statusCode !== 200) return fail('接口返回 HTTP ' + parsed.statusCode + ':' + String(parsed.body || '').slice(0, 300))
|
|
563
|
+
return parseBalance(parsed.body)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ── export helpers ─────────────────────────────────────────────────────
|
|
567
|
+
function csvCell(s) {
|
|
568
|
+
s = String(s == null ? '' : s)
|
|
569
|
+
if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'
|
|
570
|
+
return s
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function buildCsv() {
|
|
574
|
+
const header = ['time', 'model', 'provider', 'inputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'outputTokens', 'reasoningTokens', 'finishReason', 'period', 'baseCost', 'peakValleyCost', 'autoCost']
|
|
575
|
+
const lines = [header.join(',')]
|
|
576
|
+
for (const r of records) {
|
|
577
|
+
lines.push([
|
|
578
|
+
r.time, r.model, r.provider, r.inputTokens, r.cacheReadTokens, r.cacheWriteTokens,
|
|
579
|
+
r.outputTokens, r.reasoningTokens, r.finishReason,
|
|
580
|
+
isPeak(r.time) ? 'peak' : 'offPeak', costFor(r, 'base'), costFor(r, 'peakValley'), costFor(r, 'auto')
|
|
581
|
+
].map(csvCell).join(','))
|
|
582
|
+
}
|
|
583
|
+
return lines.join('\r\n')
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function writePngFile(base64, outPath) {
|
|
587
|
+
const script = [
|
|
588
|
+
'const fs=require("fs");',
|
|
589
|
+
'let d="";',
|
|
590
|
+
'process.stdin.on("data",function(c){d+=c});',
|
|
591
|
+
'process.stdin.on("end",function(){',
|
|
592
|
+
' const buf=Buffer.from(d,"base64");',
|
|
593
|
+
' fs.mkdirSync(require("path").dirname(process.env.PNG_PATH),{recursive:true});',
|
|
594
|
+
' fs.writeFileSync(process.env.PNG_PATH,buf);',
|
|
595
|
+
' process.stdout.write(JSON.stringify({ok:true,bytes:buf.length}));',
|
|
596
|
+
'});'
|
|
597
|
+
].join('\n')
|
|
598
|
+
return spawnNode(script, base64, { PNG_PATH: outPath })
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function writeTextFileViaNode(content, outPath) {
|
|
602
|
+
const script = [
|
|
603
|
+
'const fs=require("fs");',
|
|
604
|
+
'let d="";',
|
|
605
|
+
'process.stdin.on("data",function(c){d+=c});',
|
|
606
|
+
'process.stdin.on("end",function(){',
|
|
607
|
+
' fs.mkdirSync(require("path").dirname(process.env.OUT_PATH),{recursive:true});',
|
|
608
|
+
' fs.writeFileSync(process.env.OUT_PATH, Buffer.from(d,"utf8"));',
|
|
609
|
+
' process.stdout.write(JSON.stringify({ok:true}));',
|
|
610
|
+
'});'
|
|
611
|
+
].join('\n')
|
|
612
|
+
return spawnNode(script, content, { OUT_PATH: outPath })
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async function mkdirViaNode(dir) {
|
|
616
|
+
const script = [
|
|
617
|
+
'const fs=require("fs");',
|
|
618
|
+
'fs.mkdirSync(process.env.MKDIR_PATH,{recursive:true});',
|
|
619
|
+
'process.stdout.write(JSON.stringify({ok:true}));'
|
|
620
|
+
].join('\n')
|
|
621
|
+
return spawnNode(script, null, { MKDIR_PATH: dir })
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function pickDirectory() {
|
|
625
|
+
const subprocess = ctx.get('subprocess')
|
|
626
|
+
if (!subprocess) return fail('命令执行服务不可用')
|
|
627
|
+
// macOS:osascript 原生目录选择(POSIX path)。
|
|
628
|
+
if (IS_MAC) {
|
|
629
|
+
let exe = null
|
|
630
|
+
try { exe = await subprocess.resolveExecutable('osascript') } catch (e) {}
|
|
631
|
+
if (!exe) return fail('未找到 osascript(macOS 需安装命令行工具 Command Line Tools)')
|
|
632
|
+
const r = await runCollect([exe, '-e', 'POSIX path of (choose folder)'], { graceMs: 120000 })
|
|
633
|
+
if (!r.ok && r.error) return fail(r.error)
|
|
634
|
+
const path = normPath(r.out.trim())
|
|
635
|
+
if (!path) return { ok: false, cancelled: true }
|
|
636
|
+
return { ok: true, path }
|
|
637
|
+
}
|
|
638
|
+
// Linux:优先 zenity,其次 kdialog。
|
|
639
|
+
if (!IS_WIN) {
|
|
640
|
+
for (const c of ['zenity', 'kdialog']) {
|
|
641
|
+
let exe = null
|
|
642
|
+
try { exe = await subprocess.resolveExecutable(c) } catch (e) {}
|
|
643
|
+
if (!exe) continue
|
|
644
|
+
const argv = c === 'zenity'
|
|
645
|
+
? [exe, '--file-selection', '--directory', '--title=选择导出目录']
|
|
646
|
+
: [exe, '--getexistingdirectory', '选择导出目录']
|
|
647
|
+
const r = await runCollect(argv, { graceMs: 120000 })
|
|
648
|
+
if (!r.ok && r.error) return fail(r.error)
|
|
649
|
+
const path = normPath(r.out.trim())
|
|
650
|
+
if (!path) return { ok: false, cancelled: true }
|
|
651
|
+
return { ok: true, path }
|
|
652
|
+
}
|
|
653
|
+
return fail('未找到目录选择工具(请安装 zenity 或 kdialog)')
|
|
654
|
+
}
|
|
655
|
+
// Windows:PowerShell 原生目录选择。
|
|
656
|
+
let exe = null
|
|
657
|
+
for (const c of ['powershell.exe', 'pwsh.exe', 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe']) {
|
|
658
|
+
try { exe = await subprocess.resolveExecutable(c); if (exe) break } catch (e) {}
|
|
659
|
+
}
|
|
660
|
+
if (!exe) return fail('未找到 PowerShell')
|
|
661
|
+
const script = 'Add-Type -AssemblyName System.Windows.Forms; $f = New-Object System.Windows.Forms.FolderBrowserDialog; $f.Description = "选择导出目录"; if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($f.SelectedPath) }'
|
|
662
|
+
const r = await runCollect([exe, '-NoProfile', '-STA', '-NonInteractive', '-Command', script], { graceMs: 120000 })
|
|
663
|
+
if (!r.ok && r.error) return fail(r.error)
|
|
664
|
+
const path = normPath(r.out.trim())
|
|
665
|
+
if (!path) return { ok: false, cancelled: true }
|
|
666
|
+
return { ok: true, path }
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function revealDir(dirArg) {
|
|
670
|
+
const subprocess = ctx.get('subprocess')
|
|
671
|
+
if (!subprocess) return fail('命令执行服务不可用')
|
|
672
|
+
let target = ''
|
|
673
|
+
const isKey = dirArg === 'csv' || dirArg === 'json' || dirArg === 'images' || dirArg === 'data'
|
|
674
|
+
if (isKey) {
|
|
675
|
+
const d = dirs()
|
|
676
|
+
target = dirArg === 'csv' ? d.csv : dirArg === 'json' ? d.json : dirArg === 'images' ? d.images : d.data
|
|
677
|
+
target = normPath(target)
|
|
678
|
+
const policy = sessionPolicy()
|
|
679
|
+
try {
|
|
680
|
+
const t = await fs.resolve(joinPath(target, '.keep'))
|
|
681
|
+
await fs.writeText(t, '', undefined, undefined, policy || undefined)
|
|
682
|
+
} catch (e) {}
|
|
683
|
+
} else {
|
|
684
|
+
target = normPath(dirArg)
|
|
685
|
+
await mkdirViaNode(target)
|
|
686
|
+
}
|
|
687
|
+
// 平台化「在文件管理器中显示」:Windows explorer.exe / macOS open / Linux xdg-open。
|
|
688
|
+
const revealCmd = IS_WIN ? 'explorer.exe' : (IS_MAC ? 'open' : 'xdg-open')
|
|
689
|
+
let exe = null
|
|
690
|
+
try { exe = await subprocess.resolveExecutable(revealCmd) } catch (e) {}
|
|
691
|
+
if (!exe) return fail('未找到 ' + revealCmd)
|
|
692
|
+
try {
|
|
693
|
+
subprocess.spawn({ argv: [exe, target], cwd: await safeCwd(), stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, graceMs: 5000 })
|
|
694
|
+
return { ok: true }
|
|
695
|
+
} catch (e) { return fail(msg(e)) }
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ── API ────────────────────────────────────────────────────────────────
|
|
699
|
+
async function routeApi(body) {
|
|
700
|
+
const action = body && body.action ? String(body.action) : ''
|
|
701
|
+
try { await ensureInit() } catch (e) {}
|
|
702
|
+
switch (action) {
|
|
703
|
+
case 'list': {
|
|
704
|
+
const items = records.map((r) => ({
|
|
705
|
+
time: r.time, model: r.model, provider: r.provider, purpose: r.purpose,
|
|
706
|
+
inputTokens: r.inputTokens, outputTokens: r.outputTokens,
|
|
707
|
+
cacheReadTokens: r.cacheReadTokens, cacheWriteTokens: r.cacheWriteTokens,
|
|
708
|
+
reasoningTokens: r.reasoningTokens, finishReason: r.finishReason,
|
|
709
|
+
modelKey: modelKey(r.model),
|
|
710
|
+
baseCost: costFor(r, 'base'), peakValleyCost: costFor(r, 'peakValley'), autoCost: costFor(r, 'auto'),
|
|
711
|
+
peak: isPeak(r.time)
|
|
712
|
+
}))
|
|
713
|
+
return { ok: true, records: items, count: items.length, dataPath, persistOk, persistError, pricing: PRICING, effectiveAt: EFFECTIVE_AT, days: buildDays() }
|
|
714
|
+
}
|
|
715
|
+
case 'clear': {
|
|
716
|
+
const n = records.length
|
|
717
|
+
records.length = 0
|
|
718
|
+
persistNow()
|
|
719
|
+
return { ok: true, cleared: n }
|
|
720
|
+
}
|
|
721
|
+
case 'setPrices': {
|
|
722
|
+
const prices = body && body.prices
|
|
723
|
+
if (!prices || typeof prices !== 'object') return fail('缺少价格数据')
|
|
724
|
+
let changed = false
|
|
725
|
+
for (const regime of ['base', 'peakValley']) {
|
|
726
|
+
const src = prices[regime]
|
|
727
|
+
const dst = PRICING[regime]
|
|
728
|
+
if (!src || typeof src !== 'object' || !dst) continue
|
|
729
|
+
for (const mk of PRICE_MODELS) {
|
|
730
|
+
const row = src[mk]
|
|
731
|
+
if (!row || typeof row !== 'object' || !dst[mk]) continue
|
|
732
|
+
for (const k of ['cacheHit', 'cacheMiss', 'output']) {
|
|
733
|
+
const v = Number(row[k])
|
|
734
|
+
if (Number.isFinite(v) && v >= 0) { dst[mk][k] = v; changed = true }
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
if (!changed) return fail('没有可用的价格更新(价格必须是非负数字)')
|
|
739
|
+
persistPricing()
|
|
740
|
+
return { ok: true }
|
|
741
|
+
}
|
|
742
|
+
case 'resetPrices': {
|
|
743
|
+
for (const regime of ['base', 'peakValley']) {
|
|
744
|
+
const src = DEFAULT_PRICING[regime]
|
|
745
|
+
const dst = PRICING[regime]
|
|
746
|
+
if (!src || !dst) continue
|
|
747
|
+
for (const mk of PRICE_MODELS) {
|
|
748
|
+
if (!src[mk] || !dst[mk]) continue
|
|
749
|
+
dst[mk].cacheHit = src[mk].cacheHit
|
|
750
|
+
dst[mk].cacheMiss = src[mk].cacheMiss
|
|
751
|
+
dst[mk].output = src[mk].output
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
persistPricing()
|
|
755
|
+
return { ok: true }
|
|
756
|
+
}
|
|
757
|
+
case 'balance':
|
|
758
|
+
return queryBalance()
|
|
759
|
+
case 'pickDir':
|
|
760
|
+
return pickDirectory()
|
|
761
|
+
case 'export': {
|
|
762
|
+
if (!root) return fail('未找到工作区路径')
|
|
763
|
+
const kind = (body && body.kind) === 'json' ? 'json' : 'csv'
|
|
764
|
+
const name = 'dsh-usage-' + stamp() + (kind === 'json' ? '.json' : '.csv')
|
|
765
|
+
const content = kind === 'json'
|
|
766
|
+
? JSON.stringify({ exportedAt: Date.now(), pricing: PRICING, records }, null, 2)
|
|
767
|
+
: buildCsv()
|
|
768
|
+
const dirArg = body && body.dir ? normPath(String(body.dir)) : ''
|
|
769
|
+
if (dirArg) {
|
|
770
|
+
const outPath = joinPath(dirArg, name)
|
|
771
|
+
const r = await writeTextFileViaNode(content, outPath)
|
|
772
|
+
if (!r.ok) return fail(r.error)
|
|
773
|
+
return { ok: true, path: outPath, name, dir: dirArg }
|
|
774
|
+
}
|
|
775
|
+
const outPath = joinPath(kind === 'json' ? dirs().json : dirs().csv, name)
|
|
776
|
+
try {
|
|
777
|
+
const target = await fs.resolve(outPath)
|
|
778
|
+
await fs.writeText(target, content, undefined, undefined, sessionPolicy() || undefined)
|
|
779
|
+
return { ok: true, path: normPath(fs.processPath ? fs.processPath(target) : outPath), name, dir: kind === 'json' ? 'json' : 'csv' }
|
|
780
|
+
} catch (e) { return fail(msg(e)) }
|
|
781
|
+
}
|
|
782
|
+
case 'exportPng': {
|
|
783
|
+
const dataUrl = body && body.dataUrl ? String(body.dataUrl) : ''
|
|
784
|
+
if (!dataUrl) return fail('缺少图片数据')
|
|
785
|
+
const idx = dataUrl.indexOf('base64,')
|
|
786
|
+
const b64 = idx >= 0 ? dataUrl.slice(idx + 7) : dataUrl
|
|
787
|
+
if (!root) return fail('未找到工作区路径')
|
|
788
|
+
const name = 'dsh-usage-report-' + stamp() + '.png'
|
|
789
|
+
const dirArg = body && body.dir ? normPath(String(body.dir)) : ''
|
|
790
|
+
const outPath = normPath(joinPath(dirArg || dirs().images, name))
|
|
791
|
+
const r = await writePngFile(b64, outPath)
|
|
792
|
+
if (!r.ok) return fail(r.error)
|
|
793
|
+
return { ok: true, path: outPath, name, dir: dirArg || 'images' }
|
|
794
|
+
}
|
|
795
|
+
case 'import': {
|
|
796
|
+
const content = body && body.content != null ? String(body.content) : ''
|
|
797
|
+
const filename = body && body.filename ? String(body.filename) : ''
|
|
798
|
+
if (!content) return fail('请选择要导入的文件')
|
|
799
|
+
let parsed
|
|
800
|
+
if (String(filename || '').toLowerCase().indexOf('.csv') >= 0) {
|
|
801
|
+
const lines = String(content).split(/\r?\n/).filter((l) => l.trim().length > 0)
|
|
802
|
+
const header = lines[0] ? parseCsvLine(lines[0]) : []
|
|
803
|
+
const idx = {}
|
|
804
|
+
header.forEach((h, i) => { idx[String(h).trim()] = i })
|
|
805
|
+
parsed = lines.slice(1).map((line) => {
|
|
806
|
+
const cells = parseCsvLine(line)
|
|
807
|
+
const get = (name) => (idx[name] === undefined ? '' : (cells[idx[name]] === undefined ? '' : cells[idx[name]]))
|
|
808
|
+
return {
|
|
809
|
+
time: get('time'), model: get('model'), provider: get('provider'),
|
|
810
|
+
inputTokens: get('inputTokens'), outputTokens: get('outputTokens'),
|
|
811
|
+
cacheReadTokens: get('cacheReadTokens'), cacheWriteTokens: get('cacheWriteTokens'),
|
|
812
|
+
reasoningTokens: get('reasoningTokens'), finishReason: get('finishReason')
|
|
813
|
+
}
|
|
814
|
+
})
|
|
815
|
+
} else {
|
|
816
|
+
try {
|
|
817
|
+
const data = JSON.parse(content)
|
|
818
|
+
parsed = Array.isArray(data) ? data : (data && Array.isArray(data.records) ? data.records : null)
|
|
819
|
+
} catch (e) { parsed = null }
|
|
820
|
+
}
|
|
821
|
+
if (!parsed || !Array.isArray(parsed)) return fail('文件内容不是可识别的用量数据(支持 JSON 或 CSV)')
|
|
822
|
+
let imported = 0, skipped = 0, invalid = 0
|
|
823
|
+
const existing = {}
|
|
824
|
+
for (const r of records) existing[r.time] = true
|
|
825
|
+
for (const raw of parsed) {
|
|
826
|
+
const rec = normalizeRecord(raw)
|
|
827
|
+
if (!rec) { invalid++; continue }
|
|
828
|
+
if (existing[rec.time]) { skipped++; continue }
|
|
829
|
+
existing[rec.time] = true
|
|
830
|
+
records.push(rec)
|
|
831
|
+
imported++
|
|
832
|
+
}
|
|
833
|
+
if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
|
|
834
|
+
records.sort((a, b) => a.time - b.time)
|
|
835
|
+
persistNow()
|
|
836
|
+
return { ok: true, imported, skipped, invalid, total: records.length }
|
|
837
|
+
}
|
|
838
|
+
case 'reveal': {
|
|
839
|
+
const dirArg = body && body.dir ? String(body.dir) : 'data'
|
|
840
|
+
return revealDir(dirArg)
|
|
841
|
+
}
|
|
842
|
+
default:
|
|
843
|
+
return fail('未知操作:' + action)
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function parseCsvLine(line) {
|
|
848
|
+
const cells = []
|
|
849
|
+
let cur = ''
|
|
850
|
+
let inQ = false
|
|
851
|
+
for (let i = 0; i < line.length; i++) {
|
|
852
|
+
const ch = line[i]
|
|
853
|
+
if (inQ) {
|
|
854
|
+
if (ch === '"') {
|
|
855
|
+
if (line[i + 1] === '"') { cur += '"'; i++ } else inQ = false
|
|
856
|
+
} else cur += ch
|
|
857
|
+
} else if (ch === '"') inQ = true
|
|
858
|
+
else if (ch === ',') { cells.push(cur); cur = '' }
|
|
859
|
+
else cur += ch
|
|
860
|
+
}
|
|
861
|
+
cells.push(cur)
|
|
862
|
+
return cells
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function readBody(req) {
|
|
866
|
+
return new Promise((resolve) => {
|
|
867
|
+
let d = ''
|
|
868
|
+
req.on('data', (c) => { d += c })
|
|
869
|
+
req.on('end', () => { try { resolve(JSON.parse(d)) } catch (e) { resolve({}) } })
|
|
870
|
+
req.on('error', () => resolve({}))
|
|
871
|
+
})
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function sendJson(res, obj) {
|
|
875
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
876
|
+
res.end(JSON.stringify(obj))
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const webServer = ctx.get('webServer')
|
|
880
|
+
push('webServer=' + (webServer ? 'present' : 'undefined'))
|
|
881
|
+
if (webServer && typeof webServer.register === 'function') {
|
|
882
|
+
try {
|
|
883
|
+
webServer.register({
|
|
884
|
+
kind: 'exact',
|
|
885
|
+
path: '/usage/api',
|
|
886
|
+
handler: async (req, res) => {
|
|
887
|
+
try {
|
|
888
|
+
const body = await readBody(req)
|
|
889
|
+
sendJson(res, await routeApi(body))
|
|
890
|
+
} catch (e) {
|
|
891
|
+
sendJson(res, { ok: false, error: msg(e) })
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
})
|
|
895
|
+
push('route-registered')
|
|
896
|
+
} catch (e) {
|
|
897
|
+
push('route-register-threw: ' + (e && e.stack ? e.stack : msg(e)))
|
|
898
|
+
}
|
|
899
|
+
} else {
|
|
900
|
+
push('route-not-registered (no webServer)')
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
push('apply-end')
|
|
904
|
+
diag.ok = true
|
|
905
|
+
} catch (e) {
|
|
906
|
+
diag.ok = false
|
|
907
|
+
diag.error = (e && e.stack) ? e.stack : String(e)
|
|
908
|
+
}
|
|
909
|
+
flushDiag()
|
|
910
|
+
}
|
|
911
|
+
}
|