dsh-whale-widget 0.3.17 → 0.4.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/PROVENANCE.md +2 -0
- package/README.md +45 -20
- package/assets/approve-default.wav +0 -0
- package/assets/ask-default.wav +0 -0
- package/assets/cost-default.wav +0 -0
- package/assets/whale-widget.js +5456 -279
- package/lib/accounting.mjs +39 -2
- package/lib/index.js +1055 -93
- package/package.json +3 -1
package/lib/index.js
CHANGED
|
@@ -4,6 +4,8 @@ import path from 'node:path'
|
|
|
4
4
|
import dns from 'node:dns'
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
6
|
import { createHash } from 'node:crypto'
|
|
7
|
+
import https from 'node:https'
|
|
8
|
+
import { exec } from 'node:child_process'
|
|
7
9
|
import {
|
|
8
10
|
preciseMoney, addMoney, sumMoney, beijingDay, dayOffset,
|
|
9
11
|
observeBalance, balanceSummary, daySummary, accountingDays, reconcileBalance,
|
|
@@ -28,9 +30,71 @@ const IMAGE_CANDIDATES = [
|
|
|
28
30
|
path.join(PACKAGE_ROOT, 'assets', 'DSniang02.png'),
|
|
29
31
|
]
|
|
30
32
|
// 自定义角色图片存放目录(第一个可写的会被使用;roles.json 存角色元数据)
|
|
33
|
+
// ⭐ 2026-10-02(用户要求"并成一份"):**用户数据的候选表一律 $DSH_HOME 优先**,开发机路径降级为兜底。
|
|
34
|
+
// 原因:桌面客户端加载的是 strip 过的发布副本(只认 $DSH_HOME),而开发版宿主原先把开发机路径排在第一位
|
|
35
|
+
// ⇒ 本机上网页端与桌面端**各写各的**(实测两端同时各写一份 .dshw-usage.json / .dshw-bubble.json),
|
|
36
|
+
// 互相看不到对方的改动 —— 表现就是"删掉的模块又冒出来""记账数对不上"。
|
|
37
|
+
// ⚠️ 这个常量必须在**所有**用到它的候选表之前定义(const 有 TDZ),所以放在最前面。
|
|
38
|
+
// ⚠️ `_strip-dev-paths.mjs` 会把发布副本里含开发机路径的行删掉,发布版只剩前两条 $DSH_HOME ✓
|
|
39
|
+
const DATA_CANDIDATES_TAIL = [
|
|
40
|
+
]
|
|
41
|
+
// v790(用户要求):菜单底部那行小字显示什么版本。
|
|
42
|
+
// · 发布版:显示当前版本(= package.json 的 version)
|
|
43
|
+
// · 开发版:显示**下一个补丁号 + beta**(例如 0.3.18 的开发版显示 0.3.19-beta),
|
|
44
|
+
// 因为开发版里的改动属于"将要发布的那一版"。
|
|
45
|
+
// ⚠️ BUILD_VERSION 与下面 payload 里两处 `version: '…'` 一起由 `_bump-version.mjs` 更新;
|
|
46
|
+
// ⚠️ BUILD_CHANNEL 由 `_strip-dev-paths.mjs` 在生成发布副本时改成 'release'(发布副本不带 dev 标记)。
|
|
47
|
+
const BUILD_VERSION = '0.4.0'
|
|
48
|
+
const BUILD_CHANNEL = 'release'
|
|
49
|
+
function versionLabelOf(version, channel) {
|
|
50
|
+
const s = String(version == null ? '' : version)
|
|
51
|
+
if (String(channel) !== 'dev') return s
|
|
52
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(s)
|
|
53
|
+
if (!m) return s ? s + '-beta' : ''
|
|
54
|
+
return m[1] + '.' + m[2] + '.' + (Number(m[3]) + 1) + '-beta'
|
|
55
|
+
}
|
|
56
|
+
const VERSION_LABEL = versionLabelOf(BUILD_VERSION, BUILD_CHANNEL)
|
|
57
|
+
// v793(用户要求):点菜单底部的版号 → 「关于 / 自检」面板。这一层配套的敏感信息处理:
|
|
58
|
+
// 面板与「复制诊断信息」只允许出现**白名单字段**;任何路径都先脱敏(用户名 → %USERPROFILE%、DSH 目录 → $DSH_HOME),
|
|
59
|
+
// **绝不包含** API key、账号 id、账本明细、会话内容。所有字段的组装集中在 aboutPayload(),便于审计。
|
|
60
|
+
function maskPath(input) {
|
|
61
|
+
let s = String(input == null ? '' : input)
|
|
62
|
+
try {
|
|
63
|
+
// ⚠️ 顺序要紧:先替换**更长的、更具体的** DSH_HOME,再替换家目录。
|
|
64
|
+
// 反过来的话 `C:\Users\x\.dsh\...` 会先被压成 `%USERPROFILE%\.dsh\...`,$DSH_HOME 就再也匹配不到了(探针抓到过)。
|
|
65
|
+
if (DSH_HOME) {
|
|
66
|
+
s = s.split(DSH_HOME).join('$DSH_HOME')
|
|
67
|
+
s = s.split(String(DSH_HOME).replace(/\\/g, '/')).join('$DSH_HOME')
|
|
68
|
+
}
|
|
69
|
+
const home = os.homedir()
|
|
70
|
+
if (home) {
|
|
71
|
+
s = s.split(home).join('%USERPROFILE%')
|
|
72
|
+
s = s.split(home.replace(/\\/g, '/')).join('%USERPROFILE%')
|
|
73
|
+
}
|
|
74
|
+
// 兜底:任何盘符/家目录形态的用户名段
|
|
75
|
+
s = s.replace(/[A-Za-z]:\\Users\\[^\\/]+/g, '%USERPROFILE%')
|
|
76
|
+
s = s.replace(/\/(Users|home)\/[^\/]+/g, '%USERPROFILE%')
|
|
77
|
+
} catch (err) {}
|
|
78
|
+
return s
|
|
79
|
+
}
|
|
80
|
+
function fileFacts(file) {
|
|
81
|
+
try {
|
|
82
|
+
const st = fs.statSync(file)
|
|
83
|
+
return { bytes: st.size, mtime: st.mtimeMs }
|
|
84
|
+
} catch (err) { return null }
|
|
85
|
+
}
|
|
86
|
+
function firstExisting(list) {
|
|
87
|
+
for (const p of (list || [])) {
|
|
88
|
+
try { if (fs.existsSync(p)) return p } catch (err) {}
|
|
89
|
+
}
|
|
90
|
+
return ''
|
|
91
|
+
}
|
|
92
|
+
// ⭐ 2026-10-02(用户要求"并成一份"):**角色图库目录也 $DSH_HOME 优先**(开发机路径降级为兜底)。
|
|
31
93
|
const ROLE_DIR_CANDIDATES = [
|
|
32
94
|
path.join(DSH_HOME, 'whale-roles'),
|
|
33
95
|
path.join(DSH_HOME, 'profiles', 'web', 'whale-roles'),
|
|
96
|
+
DATA_CANDIDATES_TAIL[0] + 'whale-roles/',
|
|
97
|
+
DATA_CANDIDATES_TAIL[1] + 'whale-roles/',
|
|
34
98
|
]
|
|
35
99
|
const ROLE_INDEX_NAME = 'roles.json'
|
|
36
100
|
const ROLE_DEFAULT_ID = 'default'
|
|
@@ -38,6 +102,8 @@ const ROLE_DEFAULT_ID = 'default'
|
|
|
38
102
|
const AUDIO_DIR_CANDIDATES = [
|
|
39
103
|
path.join(DSH_HOME, 'whale-audio'),
|
|
40
104
|
path.join(DSH_HOME, 'profiles', 'web', 'whale-audio'),
|
|
105
|
+
DATA_CANDIDATES_TAIL[0] + 'whale-audio/',
|
|
106
|
+
DATA_CANDIDATES_TAIL[1] + 'whale-audio/',
|
|
41
107
|
]
|
|
42
108
|
const AUDIO_INDEX_NAME = 'audio.json'
|
|
43
109
|
// 内置预设音效组(不可删、不可改),对应 assets 里的 Ya/D 系列
|
|
@@ -57,6 +123,14 @@ const PRESET_FRAGMENTS = {
|
|
|
57
123
|
// 任务结束音 A(随包资源):原为用户音效库里的自导入片段,现作为内置预设随包发布。
|
|
58
124
|
// 显示名与用户库里同名片段一致时,前端下拉按显示名去重(内置与前端的同名条目只出现一条)。
|
|
59
125
|
end_a: { id: 'end_a', name: 'A', preset: true, mime: 'audio/wav' },
|
|
126
|
+
// v859(用户要求):把"当前正在使用的"三个音效固化为**新的出厂默认**(随包发布,新装即此体验)
|
|
127
|
+
// · cost_a = 每轮消耗提示(走 taskEnd.sel)
|
|
128
|
+
// · ask_a = 提问提示(走 events.question.sel)
|
|
129
|
+
// · approve_a = 授权提示(走 events.approval.sel)
|
|
130
|
+
// 与 end_a 同一套做法:原为用户音效库里的自导入片段,现作为内置预设随包发布。
|
|
131
|
+
cost_a: { id: 'cost_a', name: '每轮消耗·默认', preset: true, mime: 'audio/wav' },
|
|
132
|
+
ask_a: { id: 'ask_a', name: '提问·默认', preset: true, mime: 'audio/wav' },
|
|
133
|
+
approve_a: { id: 'approve_a', name: '授权·默认', preset: true, mime: 'audio/wav' },
|
|
60
134
|
}
|
|
61
135
|
// 内置片段的音频文件:包内 assets 优先,开发环境回退到 skin 目录
|
|
62
136
|
const BUILTIN_FRAGMENT_FILES = {
|
|
@@ -67,12 +141,25 @@ const BUILTIN_FRAGMENT_FILES = {
|
|
|
67
141
|
end_a: [
|
|
68
142
|
path.join(PACKAGE_ROOT, 'assets', 'task-end-a.wav'),
|
|
69
143
|
],
|
|
144
|
+
// v859:三个新的出厂默认音效(随包 assets;开发环境回退到 skin 目录)
|
|
145
|
+
cost_a: [
|
|
146
|
+
path.join(PACKAGE_ROOT, 'assets', 'cost-default.wav'),
|
|
147
|
+
],
|
|
148
|
+
ask_a: [
|
|
149
|
+
path.join(PACKAGE_ROOT, 'assets', 'ask-default.wav'),
|
|
150
|
+
],
|
|
151
|
+
approve_a: [
|
|
152
|
+
path.join(PACKAGE_ROOT, 'assets', 'approve-default.wav'),
|
|
153
|
+
],
|
|
70
154
|
}
|
|
71
155
|
// —— 自定义 API 模型(v655):非 DeepSeek 厂商的余额 / 用量 / 提醒 ——
|
|
72
156
|
// 注册表落在 $DSH_HOME/.dshw-api.json;密钥走 DSH 官方凭据(ctx.credentials),本文件不存明文。
|
|
157
|
+
// ⭐ 2026-10-02(用户要求"并成一份"):$DSH_HOME 优先(自定义 API 模型清单也属于用户数据,原先两端各存一份)。
|
|
73
158
|
const API_FILE_CANDIDATES = [
|
|
74
159
|
path.join(DSH_HOME, '.dshw-api.json'),
|
|
75
160
|
path.join(DSH_HOME, 'profiles', 'web', '.dshw-api.json'),
|
|
161
|
+
DATA_CANDIDATES_TAIL[0] + '.dshw-api.json',
|
|
162
|
+
DATA_CANDIDATES_TAIL[1] + '.dshw-api.json',
|
|
76
163
|
]
|
|
77
164
|
const API_BUILTIN_ID = 'deepseek'
|
|
78
165
|
// 「充值 / 余额校正」只属于固定的 DeepSeek(内置):模型条目下发 canAdjustBalance,路由层再校验一次。
|
|
@@ -163,7 +250,17 @@ const API_TEMPLATES = {
|
|
|
163
250
|
quota: {
|
|
164
251
|
url: 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
|
|
165
252
|
auth: '{key}', // 智谱此接口不带 Bearer
|
|
166
|
-
|
|
253
|
+
// v789(issue #180/#181 修):`data.limits[]` 是扁平条目数组,`TOKENS_LIMIT` 是条目的 `type` **值**,
|
|
254
|
+
// 且 5h 窗口不保证排在 [0](pro 账号 [0] 是 TIME_LIMIT、max 账号 [0] 才是 TOKENS_LIMIT)
|
|
255
|
+
// ⇒ 一律按 type(+unit) 选条目:unit=3 = 5 小时窗口、unit=6 = 周窗口;
|
|
256
|
+
// 新注册/credit 制账号的 type 是 CREDIT_LIMIT ⇒ 一并兼容。
|
|
257
|
+
json: {
|
|
258
|
+
level: 'data.level',
|
|
259
|
+
windows: [
|
|
260
|
+
{ key: 'rolling', label: '5h', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 3 }, percent: 'percentage', resetAt: 'nextResetTime' },
|
|
261
|
+
{ key: 'weekly', label: '周', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 6 }, percent: 'percentage', resetAt: 'nextResetTime' },
|
|
262
|
+
],
|
|
263
|
+
},
|
|
167
264
|
},
|
|
168
265
|
probeUrl: 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
|
|
169
266
|
},
|
|
@@ -239,9 +336,12 @@ const API_TEMPLATES = {
|
|
|
239
336
|
},
|
|
240
337
|
anthropic: {
|
|
241
338
|
name: 'Anthropic Claude', currency: 'USD', keyRef: 'ANTHROPIC_API_KEY', noBalanceApi: true,
|
|
242
|
-
|
|
339
|
+
// v791:请求头支持「分号分隔列表」后,探活这条路终于能走了 —— Anthropic 要求 x-api-key + anthropic-version 两个头。
|
|
340
|
+
// 官方**没有余额接口**(余额/用量只能去控制台或 Admin API),所以这里只提供探活与模型列表。
|
|
341
|
+
apiNote: '官方无余额接口(余额/用量见控制台,或按会话 token 估算)。探活走 /v1/models:需要 x-api-key + anthropic-version 两个请求头(v791 起支持,模板已预置)',
|
|
243
342
|
matchIds: ['claude'],
|
|
244
|
-
balance: { url: '', auth: '
|
|
343
|
+
balance: { url: '', auth: 'x-api-key:{key};anthropic-version:2023-06-01', json: { remaining: '' } },
|
|
344
|
+
probeUrl: 'https://api.anthropic.com/v1/models',
|
|
245
345
|
},
|
|
246
346
|
gemini: {
|
|
247
347
|
name: 'Google Gemini', currency: 'USD', keyRef: 'GEMINI_API_KEY', noBalanceApi: true,
|
|
@@ -346,7 +446,14 @@ const API_TEMPLATES = {
|
|
|
346
446
|
quota: {
|
|
347
447
|
url: 'https://api.z.ai/api/monitor/usage/quota/limit',
|
|
348
448
|
auth: '{key}', // 与国内站一样不带 Bearer
|
|
349
|
-
|
|
449
|
+
// v789:与国内站同一套 `data.limits[]` 结构(见 zhipu_glm_coding 的注释),一并按 type+unit 选条目
|
|
450
|
+
json: {
|
|
451
|
+
level: 'data.level',
|
|
452
|
+
windows: [
|
|
453
|
+
{ key: 'rolling', label: '5h', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 3 }, percent: 'percentage', resetAt: 'nextResetTime' },
|
|
454
|
+
{ key: 'weekly', label: '周', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 6 }, percent: 'percentage', resetAt: 'nextResetTime' },
|
|
455
|
+
],
|
|
456
|
+
},
|
|
350
457
|
},
|
|
351
458
|
probeUrl: 'https://api.z.ai/api/monitor/usage/quota/limit',
|
|
352
459
|
},
|
|
@@ -365,20 +472,28 @@ const API_TEMPLATES = {
|
|
|
365
472
|
},
|
|
366
473
|
}
|
|
367
474
|
|
|
475
|
+
// ⭐ 2026-10-02(用户要求"并成一份"):这些**用户数据文件一律 $DSH_HOME 优先**,开发机路径降级为只读兜底。
|
|
476
|
+
// 原因见上方 AUDIO_DIR_CANDIDATES 的说明(桌面端只认 $DSH_HOME,两端原先各写各的)。
|
|
368
477
|
// Size memory file: prefer writable DSH home locations, then legacy fallbacks.
|
|
369
478
|
const SIZE_FILE_CANDIDATES = [
|
|
370
479
|
path.join(DSH_HOME, '.dshw-size.json'),
|
|
371
480
|
path.join(DSH_HOME, 'profiles', 'web', '.dshw-size.json'),
|
|
481
|
+
DATA_CANDIDATES_TAIL[0] + '.dshw-size.json',
|
|
482
|
+
DATA_CANDIDATES_TAIL[1] + '.dshw-size.json',
|
|
372
483
|
]
|
|
373
484
|
// Usage ledger file (小鲸鱼记账 mode): same policy as the size file.
|
|
374
485
|
const USAGE_FILE_CANDIDATES = [
|
|
375
486
|
path.join(DSH_HOME, '.dshw-usage.json'),
|
|
376
487
|
path.join(DSH_HOME, 'profiles', 'web', '.dshw-usage.json'),
|
|
488
|
+
DATA_CANDIDATES_TAIL[0] + '.dshw-usage.json',
|
|
489
|
+
DATA_CANDIDATES_TAIL[1] + '.dshw-usage.json',
|
|
377
490
|
]
|
|
378
491
|
// 每轮消耗 seq 持久化文件:避免插件热重载后 seq 归零导致页面把新轮次误判为旧轮次吞掉
|
|
379
492
|
const TURN_FILE_CANDIDATES = [
|
|
380
493
|
path.join(DSH_HOME, '.dshw-turn.json'),
|
|
381
494
|
path.join(DSH_HOME, 'profiles', 'web', '.dshw-turn.json'),
|
|
495
|
+
DATA_CANDIDATES_TAIL[0] + '.dshw-turn.json',
|
|
496
|
+
DATA_CANDIDATES_TAIL[1] + '.dshw-turn.json',
|
|
382
497
|
]
|
|
383
498
|
// Sound assets: package-relative first (ship Ya1/Ya2/D1/D2.mp3 in assets/),
|
|
384
499
|
// legacy absolute paths as fallback.
|
|
@@ -387,6 +502,8 @@ const BUBBLE_FILE_CANDIDATES = [
|
|
|
387
502
|
// 否则清完这个数组会变成空的:泡泡配置读不了也存不了(自定义泡泡整体失效)。
|
|
388
503
|
path.join(DSH_HOME, '.dshw-bubble.json'),
|
|
389
504
|
path.join(DSH_HOME, 'profiles', 'web', '.dshw-bubble.json'),
|
|
505
|
+
DATA_CANDIDATES_TAIL[0] + '.dshw-bubble.json',
|
|
506
|
+
DATA_CANDIDATES_TAIL[1] + '.dshw-bubble.json',
|
|
390
507
|
]
|
|
391
508
|
// 泡泡图库(独立于角色图库):图片文件目录 + 索引
|
|
392
509
|
const BUBBLE_IMG_DIR_CANDIDATES = [
|
|
@@ -394,6 +511,8 @@ const BUBBLE_IMG_DIR_CANDIDATES = [
|
|
|
394
511
|
// 空数组会让图库上传直接报错。
|
|
395
512
|
path.join(DSH_HOME, 'whale-bubble-imgs'),
|
|
396
513
|
path.join(DSH_HOME, 'profiles', 'web', 'whale-bubble-imgs'),
|
|
514
|
+
DATA_CANDIDATES_TAIL[0] + 'whale-bubble-imgs/',
|
|
515
|
+
DATA_CANDIDATES_TAIL[1] + 'whale-bubble-imgs/',
|
|
397
516
|
]
|
|
398
517
|
const BUBBLE_IMG_INDEX_NAME = 'bubble-imgs.json'
|
|
399
518
|
// 内置默认泡泡图(语义 id → assets 文件):面板常驻可选;全新安装无用户图库时
|
|
@@ -430,6 +549,8 @@ function soundSetFromUrl(url) {
|
|
|
430
549
|
}
|
|
431
550
|
const BALANCE_URL = 'https://api.deepseek.com/user/balance'
|
|
432
551
|
const BALANCE_TTL_MS = 25000
|
|
552
|
+
// v781:账号登录路的兜底超时(毫秒)。DSH 账号服务自身默认 30s,太长会把前端 FETCH_TIMEOUT_MS(25s) 先耗掉。
|
|
553
|
+
const ACCOUNT_FALLBACK_MS = 5000
|
|
433
554
|
const RUA_GIF_CANDIDATES = [
|
|
434
555
|
path.join(PACKAGE_ROOT, 'assets', 'rua.gif'),
|
|
435
556
|
]
|
|
@@ -794,9 +915,14 @@ export default {
|
|
|
794
915
|
function finalizeTurn(sessionId) {
|
|
795
916
|
const agg = turnAggs.get(sessionId)
|
|
796
917
|
if (agg && agg.cost > 0) {
|
|
918
|
+
// v862/v862b:子智能体的会话**照常记账**(金额与用量事件都写),
|
|
919
|
+
// 只在「子智能体完成时不提示」开着时跳过 lastTurnSeq ⇒ 前端不会因为它响提示音。
|
|
920
|
+
const isSub = subagentSessions.has(String(sessionId || '')) && subagentMuteOn()
|
|
797
921
|
lastTurn = { turn: agg.turn, amount: agg.cost, tokens: agg.tokens, ts: agg.lastTs }
|
|
798
|
-
|
|
799
|
-
|
|
922
|
+
if (!isSub) {
|
|
923
|
+
lastTurnSeq++
|
|
924
|
+
writeTurnSeq(lastTurnSeq)
|
|
925
|
+
}
|
|
800
926
|
// 按模型拆分写入用量事件(模型明细的唯一来源)
|
|
801
927
|
const byModel = agg.byModel || {}
|
|
802
928
|
const byModelTokens = agg.byModelTokens || {}
|
|
@@ -960,6 +1086,44 @@ export default {
|
|
|
960
1086
|
} catch (err) {}
|
|
961
1087
|
}
|
|
962
1088
|
|
|
1089
|
+
// v862(用户实测确认:跑一次子智能体,last-turn 的 seq 会 +1 ⇒ 前端跟着响"任务结束音")——
|
|
1090
|
+
// 机制:finalizeTurn(sessionId) 对**每个 session 的 turn/end** 都推进 lastTurnSeq,
|
|
1091
|
+
// 子智能体跑完自己的 turn 同样让 seq 变大 ⇒ 响。实测:seq 362 →(子智能体跑完)→ 363。
|
|
1092
|
+
// 区分依据(DSH Host Event 契约,已用 Inspector 核对):
|
|
1093
|
+
// 'subagent/start'(info: SubagentRunInfo) info = { runId, provider, id: SessionId, local }
|
|
1094
|
+
// 'subagent/end'(info: SubagentRunEndInfo) info = { runId, provider, id, local, stopReason, … }
|
|
1095
|
+
// ⇒ 子会话 id = info.id。
|
|
1096
|
+
// v862b(用户要求):做成**设置项**「子智能体完成时不提示」(键 = settings.subagent.mute,默认 true)。
|
|
1097
|
+
let subagentMuteCache = { at: 0, val: true }
|
|
1098
|
+
function subagentMuteOn() {
|
|
1099
|
+
try {
|
|
1100
|
+
const now = Date.now()
|
|
1101
|
+
if (now - subagentMuteCache.at < 2000) return subagentMuteCache.val
|
|
1102
|
+
const s = readUsageSettings() || {}
|
|
1103
|
+
const sg = (s && typeof s.subagent === 'object' && s.subagent) ? s.subagent : {}
|
|
1104
|
+
subagentMuteCache = { at: now, val: sg.mute !== false } // 缺省 = 不提示
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
subagentMuteCache = { at: Date.now(), val: true }
|
|
1107
|
+
}
|
|
1108
|
+
return subagentMuteCache.val
|
|
1109
|
+
}
|
|
1110
|
+
const subagentSessions = new Set()
|
|
1111
|
+
const SUBAGENT_ID_MAX = 500
|
|
1112
|
+
function rememberSubagentSession(info) {
|
|
1113
|
+
try {
|
|
1114
|
+
const id = info && info.id ? String(info.id) : ''
|
|
1115
|
+
if (!id || subagentSessions.has(id)) return
|
|
1116
|
+
if (subagentSessions.size >= SUBAGENT_ID_MAX) {
|
|
1117
|
+
const keep = Array.from(subagentSessions).slice(-Math.floor(SUBAGENT_ID_MAX / 2))
|
|
1118
|
+
subagentSessions.clear()
|
|
1119
|
+
for (const k of keep) subagentSessions.add(k)
|
|
1120
|
+
}
|
|
1121
|
+
subagentSessions.add(id)
|
|
1122
|
+
} catch (err) {}
|
|
1123
|
+
}
|
|
1124
|
+
try { disposers.push(ctx.on('subagent/start', (info) => rememberSubagentSession(info))) } catch (err) {}
|
|
1125
|
+
try { disposers.push(ctx.on('subagent/end', (info) => rememberSubagentSession(info))) } catch (err) {}
|
|
1126
|
+
|
|
963
1127
|
// 监听所有会话的追加事件;按会话 id 分桶,turn/end 时结算该会话本轮
|
|
964
1128
|
disposers.push(ctx.on('session/event', (session, event) => {
|
|
965
1129
|
const sid = session && session.id ? session.id : 'default'
|
|
@@ -1017,10 +1181,13 @@ export default {
|
|
|
1017
1181
|
// ⚠️ 必须用 `ctx.get('deepseekAccount')` 读**可选服务**,**绝不能写进 inject** —— 老宿主根本没有这个服务,
|
|
1018
1182
|
// 一旦进 inject,整个插件会永远等不到服务而**完全不 apply**(比没有这个功能严重得多)。
|
|
1019
1183
|
// ⚠️ 与 API key 路径**互不干扰**(API key 永远优先;DSH 自己的注释也是 "Neither route falls back to the other")。
|
|
1184
|
+
// v781:账号余额路的失败原因(只用于错误文案,不参与任何判定)。fetchAccountBalance 每次开头清空。
|
|
1185
|
+
let accountDiag = ''
|
|
1020
1186
|
async function fetchAccountBalance() {
|
|
1021
1187
|
let account = null
|
|
1188
|
+
accountDiag = ''
|
|
1022
1189
|
try { account = typeof ctx.get === 'function' ? ctx.get('deepseekAccount') : null } catch (err) { account = null }
|
|
1023
|
-
if (!account || typeof account.getBalance !== 'function') return null
|
|
1190
|
+
if (!account || typeof account.getBalance !== 'function') { accountDiag = '当前 DSH 没有账号服务'; return null }
|
|
1024
1191
|
// 账户标识:优先用账号服务给的稳定 id(不同账号 → 不同账本 scope);取不到就退回固定标识。
|
|
1025
1192
|
// 一律 sha256[:24] 十六进制 —— accounting.mjs 校验 scope 必须匹配 /^[a-zA-Z0-9_-]{1,80}$/
|
|
1026
1193
|
// (带冒号这种可读写法会直接抛「账户标识无效」,见 issue #157 报告人踩的坑 1)。
|
|
@@ -1036,25 +1203,52 @@ export default {
|
|
|
1036
1203
|
locale: String(process.env.DSH_LOCALE || '') || 'zh_CN',
|
|
1037
1204
|
timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60, // 东为正的整秒
|
|
1038
1205
|
})
|
|
1039
|
-
} catch (err) {
|
|
1206
|
+
} catch (err) {
|
|
1207
|
+
accountDiag = '账号余额接口调用失败: ' + String((err && err.message) || err).slice(0, 120)
|
|
1208
|
+
return null
|
|
1209
|
+
}
|
|
1040
1210
|
// 未登录 / 没有 grant / 平台失败 → 一律返回 null,让调用方退回原来的文案(**不缓存错误**)
|
|
1041
|
-
|
|
1211
|
+
// v781:失败时记下**原因**(accountDiag),供 fetchBalance 把两条路的原因一起报给界面 ——
|
|
1212
|
+
// 以前只有一个笼统的"账号余额不可用",用户/支持方分不清"没登录"和"接口失败"。
|
|
1213
|
+
if (!result) { accountDiag = '未登录 DeepSeek 账号(没有账号凭据)'; return null }
|
|
1214
|
+
if (result.status !== 'ready') { accountDiag = '账号余额接口未就绪(' + String(result.status || 'unknown') + ')'; return null }
|
|
1042
1215
|
const accNum = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null }
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1216
|
+
// v781:**"充值钱包为空、只有赠金"必须算成功** —— 这是"只登录客户端读不到余额"的主因。
|
|
1217
|
+
// 平台把钱包拆成 `normal_wallets`(充值)与 `bonus_wallets`(赠金);新用户/没充过值的用户
|
|
1218
|
+
// 常常**只有赠金**(甚至 normal_wallets 是空数组)⇒ 旧代码要求 `result.value` 非空,
|
|
1219
|
+
// 直接把这类账号判成"没有余额钱包",界面就只显示"读取不到余额"。
|
|
1220
|
+
// 判据对齐 DSH 自己的账号卡片(dsh-client-ui-settings-account):
|
|
1221
|
+
// `[...balance.value, ...balance.bonusWallets].some(w => new Big(w.balance).gt(0))`
|
|
1222
|
+
// —— 两个钱包一起看,不再单看充值钱包。
|
|
1223
|
+
const normRaw = Array.isArray(result.value) ? result.value : []
|
|
1224
|
+
const bonusRaw = Array.isArray(result.bonusWallets) ? result.bonusWallets : []
|
|
1225
|
+
const usable = (list) => list.filter((w) => w && accNum(w.balance) !== null)
|
|
1226
|
+
const normWallets = usable(normRaw)
|
|
1227
|
+
const bonusWallets = usable(bonusRaw)
|
|
1228
|
+
const allWallets = normWallets.concat(bonusWallets)
|
|
1229
|
+
if (allWallets.length === 0) {
|
|
1230
|
+
// 两边都没有可用钱包 = 真的没有数据(未激活/账号没有任何钱包),不是"余额 0"
|
|
1231
|
+
accountDiag = normRaw.length || bonusRaw.length ? '钱包字段不可用' : '账号没有余额钱包'
|
|
1232
|
+
return null
|
|
1233
|
+
}
|
|
1234
|
+
// 币种:优先「有余额的那个钱包」的币种(CNY 优先),其次任何 CNY 钱包,最后第一个
|
|
1235
|
+
const curOf = (w) => String((w && w.currency) || 'CNY').toUpperCase()
|
|
1236
|
+
const nonzero = allWallets.filter((w) => Number(w.balance) > 0)
|
|
1237
|
+
const curPick = nonzero.find((w) => curOf(w) === 'CNY') || nonzero[0]
|
|
1238
|
+
|| allWallets.find((w) => curOf(w) === 'CNY') || allWallets[0]
|
|
1239
|
+
const currency = curOf(curPick)
|
|
1240
|
+
const sumOf = (list) => list
|
|
1241
|
+
.filter((w) => w && curOf(w) === currency && accNum(w.balance) !== null)
|
|
1050
1242
|
.reduce((s, w) => s + Number(w.balance), 0)
|
|
1051
|
-
const recharge = sumOf(
|
|
1052
|
-
const bonus = sumOf(
|
|
1243
|
+
const recharge = sumOf(normWallets)
|
|
1244
|
+
const bonus = sumOf(bonusWallets)
|
|
1245
|
+
accountDiag = ''
|
|
1053
1246
|
return {
|
|
1054
1247
|
ok: true,
|
|
1055
1248
|
// 充值 + 赠金:与 API key 路径的 `total_balance` 同口径(那个字段本身也是两者相加),
|
|
1056
1249
|
// 否则"按余额差"推算的今日已用会偏小(issue #157 报告人的坑 2)。
|
|
1057
1250
|
totalBalance: Number((recharge + bonus).toFixed(6)),
|
|
1251
|
+
// v781:两个分量单独下发,供「赠金 / 账户余额」两个自定义模块使用(见 SPEC §25)
|
|
1058
1252
|
rechargeBalance: Number(recharge.toFixed(6)),
|
|
1059
1253
|
bonusBalance: Number(bonus.toFixed(6)),
|
|
1060
1254
|
currency,
|
|
@@ -1064,30 +1258,64 @@ export default {
|
|
|
1064
1258
|
}
|
|
1065
1259
|
}
|
|
1066
1260
|
|
|
1261
|
+
// v781:账号路的兜底(**两条路都失败才报错,并把两条路的原因都带上**)。
|
|
1262
|
+
// 为什么需要它:旧实现只在"完全没有 API key"时才试账号路 ⇒ 曾经配过 key(过期/写错/被删)
|
|
1263
|
+
// 但现在只用客户端登录的用户,余额**永远读不到**("只登录客户端读不到余额"报告里的另一大类)。
|
|
1264
|
+
// ⚠️ 超时上限:账号路走 DSH 自己的服务(默认 30s)⇒ 我们用 ACCOUNT_FALLBACK_MS 截断,
|
|
1265
|
+
// 保证"API key 两次 8s + 退避"之后仍在**前端的 25s** 之内回来,不让界面先 abort。
|
|
1266
|
+
async function accountFallback(keyErr) {
|
|
1267
|
+
let acc = null
|
|
1268
|
+
try {
|
|
1269
|
+
acc = await Promise.race([
|
|
1270
|
+
fetchAccountBalance(),
|
|
1271
|
+
new Promise((r) => setTimeout(() => { accountDiag = '账号余额请求超时'; r(null) }, ACCOUNT_FALLBACK_MS)),
|
|
1272
|
+
])
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
acc = null
|
|
1275
|
+
accountDiag = accountDiag || ('账号余额异常: ' + String((err && err.message) || err).slice(0, 120))
|
|
1276
|
+
}
|
|
1277
|
+
if (acc) return acc
|
|
1278
|
+
const keyPart = keyErr
|
|
1279
|
+
? ('API key 路:' + String((keyErr.error || keyErr.code || '失败')).slice(0, 160))
|
|
1280
|
+
: '未配置 DEEPSEEK_API_KEY'
|
|
1281
|
+
return {
|
|
1282
|
+
ok: false,
|
|
1283
|
+
code: keyErr ? 'BOTH_FAILED' : 'NO_KEY',
|
|
1284
|
+
error: keyPart + ';账号登录路:' + (accountDiag || '拿不到余额'),
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1067
1287
|
async function fetchBalance() {
|
|
1068
1288
|
// v739(用户反馈「每次新实例的第一次余额请求都失败」):
|
|
1069
1289
|
// ① 冷启动时凭据服务可能尚未就绪(resolve 抛错),这与「确实没配置」不同 → 短延迟重试一次;
|
|
1070
1290
|
// ② 单次超时从 20s 收到 8s,保证「2 次尝试 + 退避」明显短于前端 FETCH_TIMEOUT_MS(25s) ——
|
|
1071
1291
|
// 否则前端先 abort,用户看到的永远是「第一次失败」,只能干等 60 秒后的下一轮。
|
|
1072
1292
|
let cred = null
|
|
1293
|
+
let credErr = null
|
|
1073
1294
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1074
1295
|
try {
|
|
1075
1296
|
cred = await ctx.credentials.resolve('DEEPSEEK_API_KEY')
|
|
1297
|
+
credErr = null
|
|
1076
1298
|
break
|
|
1077
1299
|
} catch (err) {
|
|
1300
|
+
credErr = err
|
|
1078
1301
|
if (attempt === 0) { await new Promise((r) => setTimeout(r, 700)); continue }
|
|
1079
|
-
|
|
1302
|
+
// v781:凭据服务报错时**也要试账号路**(以前直接 return,登录态用户就被这条挡住)
|
|
1303
|
+
return await accountFallback({ code: 'NO_KEY', error: '凭据读取失败: ' + String((err && err.message) || err).slice(0, 160) })
|
|
1080
1304
|
}
|
|
1081
1305
|
}
|
|
1082
1306
|
if (!cred) {
|
|
1083
1307
|
// v759(issue #157):没有 API key 时退回 DSH 账号登录态;两条路互不干扰,API key 始终第一优先。
|
|
1084
|
-
|
|
1085
|
-
if (accountPayload) return accountPayload
|
|
1086
|
-
return {
|
|
1087
|
-
ok: false, code: 'NO_KEY',
|
|
1088
|
-
error: '未配置 DEEPSEEK_API_KEY,且账号余额不可用(未登录 DeepSeek 账号,或该账号没有余额钱包)',
|
|
1089
|
-
}
|
|
1308
|
+
return await accountFallback(null)
|
|
1090
1309
|
}
|
|
1310
|
+
const apiFail = await fetchApiKeyBalance(cred)
|
|
1311
|
+
if (apiFail.ok) return apiFail
|
|
1312
|
+
// v781:key 路**任何**失败都再试一次账号路(401/结构异常/超时都算)
|
|
1313
|
+
void credErr
|
|
1314
|
+
return await accountFallback(apiFail)
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// API key 路(原 fetchBalance 的主体,v781 抽出来以便"失败后回退账号路")
|
|
1318
|
+
async function fetchApiKeyBalance(cred) {
|
|
1091
1319
|
let lastErr = null
|
|
1092
1320
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1093
1321
|
let res
|
|
@@ -1117,9 +1345,15 @@ export default {
|
|
|
1117
1345
|
if (!info || info.total_balance === undefined || !Number.isFinite(Number(info.total_balance))) {
|
|
1118
1346
|
return { ok: false, code: 'SHAPE', error: '余额接口返回结构异常' }
|
|
1119
1347
|
}
|
|
1348
|
+
const numOr = (x) => { const n = Number(x); return Number.isFinite(n) ? n : null }
|
|
1120
1349
|
return {
|
|
1121
1350
|
ok: true,
|
|
1122
1351
|
totalBalance: Number(info.total_balance),
|
|
1352
|
+
// v781:官方 /user/balance 除 total_balance 外还给了 granted_balance(**未过期**赠金)与
|
|
1353
|
+
// topped_up_balance(充值)。取不到就是 null(界面显示 —,**不臆造 0** —— 0 会被记账
|
|
1354
|
+
// 当成"余额真的见底",那是另一种语义)。两个分量供自定义模块里的「赠金 / 账户余额」使用。
|
|
1355
|
+
bonusBalance: numOr(info.granted_balance),
|
|
1356
|
+
rechargeBalance: numOr(info.topped_up_balance),
|
|
1123
1357
|
accountTag: createHash('sha256').update(String(cred.value)).digest('hex').slice(0, 24),
|
|
1124
1358
|
currency: String(info.currency || 'CNY'),
|
|
1125
1359
|
balanceSource: 'apikey',
|
|
@@ -1146,15 +1380,44 @@ export default {
|
|
|
1146
1380
|
}
|
|
1147
1381
|
}
|
|
1148
1382
|
}
|
|
1383
|
+
// v860(issue #232 / #225):账本文件损坏时**不再抛错锁死**。
|
|
1384
|
+
// 旧行为:JSON 解析失败或结构不对就 throw ⇒ 一个坏文件让"读不了、写不了、不自愈",
|
|
1385
|
+
// 余额/今日已用/额度/音效设置一起失效,只能用户手删文件。
|
|
1386
|
+
// 新行为:把坏文件**改名备份**(.corrupt-<时间戳>.bak,数据不丢)→ 用默认结构继续跑,
|
|
1387
|
+
// 插件立刻恢复可用;控制台留一条 message 方便排查。
|
|
1388
|
+
function freshUsageLedger() {
|
|
1389
|
+
return { date: todayKey(), lastBalance: null, todayUsage: 0, history: {} }
|
|
1390
|
+
}
|
|
1391
|
+
function ledgerBackupCorrupt(p, why) {
|
|
1392
|
+
try {
|
|
1393
|
+
const bak = p + '.corrupt-' + Date.now() + '.bak'
|
|
1394
|
+
ledgerIo(() => fs.renameSync(p, bak))
|
|
1395
|
+
console.error('[whale-ledger] 账本损坏(' + why + ')→ 已备份为 ' + bak + ',并用默认结构重建(原文件未删除)')
|
|
1396
|
+
} catch (err) {
|
|
1397
|
+
console.error('[whale-ledger] 账本损坏(' + why + ')且备份失败:', (err && err.code) || (err && err.message))
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1149
1400
|
function readUsageLedger() {
|
|
1150
1401
|
for (const p of USAGE_FILE_CANDIDATES) {
|
|
1402
|
+
let raw = null
|
|
1403
|
+
try {
|
|
1404
|
+
raw = ledgerIo(() => fs.readFileSync(p, 'utf8'))
|
|
1405
|
+
} catch (err) {
|
|
1406
|
+
if (err.code === 'ENOENT') continue // 该候选路径没有文件:试下一个(正常情况)
|
|
1407
|
+
ledgerBackupCorrupt(p, (err && err.code) || 'read-error')
|
|
1408
|
+
return freshUsageLedger()
|
|
1409
|
+
}
|
|
1151
1410
|
try {
|
|
1152
|
-
const parsed = JSON.parse(
|
|
1411
|
+
const parsed = JSON.parse(raw)
|
|
1153
1412
|
if (parsed && typeof parsed === 'object' && typeof parsed.date === 'string') return parsed
|
|
1154
|
-
|
|
1155
|
-
|
|
1413
|
+
ledgerBackupCorrupt(p, 'bad-structure')
|
|
1414
|
+
return freshUsageLedger()
|
|
1415
|
+
} catch (err) {
|
|
1416
|
+
ledgerBackupCorrupt(p, 'bad-json')
|
|
1417
|
+
return freshUsageLedger()
|
|
1418
|
+
}
|
|
1156
1419
|
}
|
|
1157
|
-
return
|
|
1420
|
+
return freshUsageLedger()
|
|
1158
1421
|
}
|
|
1159
1422
|
function writeUsageLedger(led) {
|
|
1160
1423
|
const body = JSON.stringify(led)
|
|
@@ -1285,7 +1548,7 @@ export default {
|
|
|
1285
1548
|
const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
|
|
1286
1549
|
for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
|
|
1287
1550
|
return {
|
|
1288
|
-
ok: true, version: '0.
|
|
1551
|
+
ok: true, version: '0.4.0', versionLabel: VERSION_LABEL, today: todayData, days7,
|
|
1289
1552
|
total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
|
|
1290
1553
|
all: {
|
|
1291
1554
|
days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
|
|
@@ -1325,13 +1588,17 @@ export default {
|
|
|
1325
1588
|
// 没有它就无法区分"默认 100%"与"用户设了 100%",前端 soundVolumeOf() 靠它决定优先级。
|
|
1326
1589
|
// ⚠️ 与前端 soundEventDefaults().turnCost **必须逐字段一致**(探针 §⑥ 跨端一致钉住)。
|
|
1327
1590
|
turnCost: { vol: 1, volSet: false, bubbleOn: true },
|
|
1328
|
-
|
|
1329
|
-
|
|
1591
|
+
// v859:提问/授权的出厂默认音效改为随包内置的 ask_a / approve_a(= 当前正在用的那两个)
|
|
1592
|
+
question: { on: true, soundOn: false, sel: 'frag:ask_a', vol: 1, autoClose: true, ttlSec: 180, bubbleOn: true, lines: waitDefaultLines('question') },
|
|
1593
|
+
approval: { on: true, soundOn: false, sel: 'frag:approve_a', vol: 1, autoClose: true, ttlSec: 180, bubbleOn: true, lines: waitDefaultLines('approval') },
|
|
1330
1594
|
}
|
|
1331
1595
|
}
|
|
1332
1596
|
function usageSettingsDefaults() {
|
|
1333
1597
|
return { // 任务结束音:v774 起出厂默认 = **开**(作者当前用法),默认音 = 内置 A(end_a)
|
|
1334
|
-
|
|
1598
|
+
// v859:任务结束/每轮消耗音的出厂默认改为随包内置的 cost_a(= 当前正在用的那一个)
|
|
1599
|
+
taskEnd: { on: true, sel: 'frag:cost_a' },
|
|
1600
|
+
// v863:子智能体完成是否提示(默认 true = 不提示;键 = settings.subagent.mute)
|
|
1601
|
+
subagent: { mute: true },
|
|
1335
1602
|
// v777:「等待交互提示」的交互开关(用户 2026-09-26 要求)
|
|
1336
1603
|
// charClose=false(出厂默认)= 只有**点泡泡**能收起等待提示;true = **点角色**也能收起。
|
|
1337
1604
|
// 两种情况下"点掉的那条挂起"都不会再被每秒轮询自动弹回(前端 waitDismissedId)。
|
|
@@ -1385,6 +1652,15 @@ export default {
|
|
|
1385
1652
|
if (s.alert && typeof s.alert === 'object') d.alert = Object.assign({}, d.alert, s.alert)
|
|
1386
1653
|
if (s.budget && typeof s.budget === 'object') d.budget = Object.assign({}, d.budget, s.budget)
|
|
1387
1654
|
if (s.turnCost && typeof s.turnCost === 'object') d.turnCost = Object.assign({}, d.turnCost, s.turnCost)
|
|
1655
|
+
// v812(用户报"点按角色关闭提示气泡不会被保存"):这一项**写进去了却读不回来** ——
|
|
1656
|
+
// 写侧一直有(writeUsageSettings 的 `p.wait` 分支,磁盘上确实存着 {"charClose":true}),
|
|
1657
|
+
// 但读侧漏了 ⇒ GET 永远下发默认 false ⇒ 面板显示与运行期行为都被"打回默认",看着就像没保存。
|
|
1658
|
+
// ⚠️ 凡是 writeUsageSettings 支持的键,这里都必须有对应的读回分支(本轮核对过:
|
|
1659
|
+
// taskEnd / alert / budget / turnCost / wait / events / models —— 当时只漏了 wait)。
|
|
1660
|
+
if (s.wait && typeof s.wait === 'object') d.wait = Object.assign({}, d.wait, { charClose: !!s.wait.charClose })
|
|
1661
|
+
// v863:子智能体完成是否提示(默认 true = 不提示)。写侧有分支、读侧也必须合并,
|
|
1662
|
+
// 否则刷新后前端读回的是默认值("保存了但不生效"的经典半截活)。
|
|
1663
|
+
if (s.subagent && typeof s.subagent === 'object') d.subagent = Object.assign({}, d.subagent, { mute: s.subagent.mute !== false })
|
|
1388
1664
|
// v761(全局音效设置):四个事件逐个合并(缺字段回落到默认,不整块替换)
|
|
1389
1665
|
if (s.events && typeof s.events === 'object') {
|
|
1390
1666
|
for (const k of Object.keys(d.events)) {
|
|
@@ -1420,6 +1696,12 @@ export default {
|
|
|
1420
1696
|
if (p.turnCost && typeof p.turnCost === 'object') led.settings.turnCost = Object.assign({}, led.settings.turnCost || {}, p.turnCost)
|
|
1421
1697
|
// v777:「等待交互提示」的交互开关(点角色是否也能关闭等待提示气泡)
|
|
1422
1698
|
if (p.wait && typeof p.wait === 'object') led.settings.wait = Object.assign({}, led.settings.wait || {}, p.wait)
|
|
1699
|
+
// v863(用户报"取消勾选保存后重开仍是勾选"):**写侧原来没有 subagent 分支** ⇒
|
|
1700
|
+
// 前端 PUT 的 { subagent: { mute:false } } 被静默丢掉,重开只能读到默认值(true=不提示)。
|
|
1701
|
+
// 这里只放行 mute 一个键(与其它分支一样,白名单式合并)。
|
|
1702
|
+
if (p.subagent && typeof p.subagent === 'object') {
|
|
1703
|
+
led.settings.subagent = Object.assign({}, led.settings.subagent || {}, { mute: p.subagent.mute !== false })
|
|
1704
|
+
}
|
|
1423
1705
|
// v761(全局音效设置):events 补丁逐事件合并(前端只发改动的那几个字段)
|
|
1424
1706
|
if (p.events && typeof p.events === 'object') {
|
|
1425
1707
|
led.settings.events = led.settings.events && typeof led.settings.events === 'object' ? led.settings.events : {}
|
|
@@ -1439,7 +1721,7 @@ export default {
|
|
|
1439
1721
|
led.settings.wait = { charClose: false }
|
|
1440
1722
|
// 任务结束音也属于「音效类键」:宿主重置与面板显示必须口径一致(否则面板显示与真实状态撕裂)
|
|
1441
1723
|
// v774:重置目标 = 新的出厂默认(开 + 内置 A)
|
|
1442
|
-
led.settings.taskEnd = { on: true, sel: 'frag:
|
|
1724
|
+
led.settings.taskEnd = { on: true, sel: 'frag:cost_a' }
|
|
1443
1725
|
} // 按模型保存提醒/预算:{ modelId, alert?, budget? }
|
|
1444
1726
|
if (p.modelSettings && p.modelSettings.id) {
|
|
1445
1727
|
const mid = String(p.modelSettings.id)
|
|
@@ -1737,7 +2019,13 @@ export default {
|
|
|
1737
2019
|
let codexCatchupT = null
|
|
1738
2020
|
// 配置变更(例如用户在设置里关掉 Codex 统计)后必须让快照失效,
|
|
1739
2021
|
// 否则下一次请求还会命中关闭前的旧快照、开关看起来"没生效"。
|
|
1740
|
-
function codexInvalidate() {
|
|
2022
|
+
function codexInvalidate() {
|
|
2023
|
+
codexSnap = null; codexSnapAt = 0; codexStatsOnV = null; codexStatsOnAt = 0
|
|
2024
|
+
// v792:设置/注册表一变,**立刻**按新状态装载或摘掉预热定时器
|
|
2025
|
+
// (开关打开 ⇒ 马上挂上并预热;关掉 / 删掉 Codex 模型 ⇒ 一个定时器都不留)。
|
|
2026
|
+
// try/catch 是必要的:初始化期若在 `let codexPrewarmT` 求值之前走到这里,会撞 const/let 的 TDZ。
|
|
2027
|
+
try { codexRearmPrewarm(true) } catch (err) {}
|
|
2028
|
+
}
|
|
1741
2029
|
// 开关读取加 2 秒缓存:codexBackgroundRefresh 可能被高频调用(快照过期时每次 payload
|
|
1742
2030
|
// 构建都会试一次),而 readSizeConfig() 是同步文件读 —— 不能放在热路径上。
|
|
1743
2031
|
// 用户改设置时 codexInvalidate() 会清掉缓存,所以开关仍然是"立刻生效"。
|
|
@@ -1754,18 +2042,17 @@ export default {
|
|
|
1754
2042
|
} catch (err) {}
|
|
1755
2043
|
return false
|
|
1756
2044
|
}
|
|
2045
|
+
function codexStatsEnabledFresh() {
|
|
2046
|
+
try {
|
|
2047
|
+
if (!hasCodexModel()) return false // 没有 Codex 模型 → 默认关闭(不是"用户关掉的",见下面的提示文案)
|
|
2048
|
+
const cfg = readSizeConfig()
|
|
2049
|
+
return !cfg || cfg.codexStatsOn !== false
|
|
2050
|
+
} catch (err) { return false }
|
|
2051
|
+
}
|
|
1757
2052
|
function codexStatsOn() {
|
|
1758
2053
|
const now = Date.now()
|
|
1759
2054
|
if (codexStatsOnV !== null && now - codexStatsOnAt < 2000) return codexStatsOnV
|
|
1760
|
-
|
|
1761
|
-
try {
|
|
1762
|
-
if (!hasCodexModel()) {
|
|
1763
|
-
v = false // 没有 Codex 模型 → 默认关闭(不是"用户关掉的",见下面的提示文案)
|
|
1764
|
-
} else {
|
|
1765
|
-
const cfg = readSizeConfig()
|
|
1766
|
-
v = !cfg || cfg.codexStatsOn !== false
|
|
1767
|
-
}
|
|
1768
|
-
} catch (err) { v = true }
|
|
2055
|
+
const v = codexStatsEnabledFresh()
|
|
1769
2056
|
codexStatsOnV = v
|
|
1770
2057
|
codexStatsOnAt = now
|
|
1771
2058
|
return v
|
|
@@ -1813,21 +2100,38 @@ export default {
|
|
|
1813
2100
|
try { await Promise.race([p, new Promise((r) => setTimeout(r, Math.max(200, maxWaitMs || 2500)))]) } catch (err) {}
|
|
1814
2101
|
return codexSnap
|
|
1815
2102
|
}
|
|
1816
|
-
//
|
|
2103
|
+
// 后台预热:**只在真的配了 Codex 时才挂定时器**(v792,用户要求"没设置 Codex 就完全禁用扫描")。
|
|
2104
|
+
// 背景:真正读盘的 `codexScan()` 早已被 `codexStatsOn()` 拦住(没 Codex 模型 ⇒ 直接返回,一行 I/O 都不做),
|
|
2105
|
+
// 但那两个定时器仍会照常触发、每次都去读一遍注册表判"有没有 Codex 模型"。既然用户明确要求"完全禁用",
|
|
2106
|
+
// 这里改成**惰性装载**:启动时没有 Codex 模型 ⇒ **连定时器都不挂**;之后用户添加/删除 Codex 模型时由
|
|
2107
|
+
// `codexRearmPrewarm()` 按需挂上/摘掉(见模型保存路径)。
|
|
1817
2108
|
let codexPrewarmT = null, codexPrewarmI = null
|
|
2109
|
+
function codexPrewarmArmed() { return codexPrewarmT !== null || codexPrewarmI !== null }
|
|
2110
|
+
function codexDisarmPrewarm() {
|
|
2111
|
+
try { if (codexPrewarmT !== null) { clearTimeout(codexPrewarmT); codexPrewarmT = null } } catch (err) {}
|
|
2112
|
+
try { if (codexPrewarmI !== null) { clearInterval(codexPrewarmI); codexPrewarmI = null } } catch (err) {}
|
|
2113
|
+
try { if (codexCatchupT !== null) { clearTimeout(codexCatchupT); codexCatchupT = null } } catch (err) {}
|
|
2114
|
+
}
|
|
2115
|
+
function codexRearmPrewarm(immediate) {
|
|
2116
|
+
// ⚠️ 这里用**不带 memo** 的 fresh 判断:装载/改设置都要立刻生效,
|
|
2117
|
+
// 也不能把结果写进 `codexStatsOnV` 那份 2 秒记忆(否则刚改完设置会被旧值挡住,v792 踩过)。
|
|
2118
|
+
const on = codexStatsEnabledFresh()
|
|
2119
|
+
if (!on) { codexDisarmPrewarm(); return false }
|
|
2120
|
+
// 已经挂着就什么都不做(避免每次保存模型都触发一次扫描;5 分钟轮询 + 按需 codexSummaryEnsured 已够)
|
|
2121
|
+
if (codexPrewarmArmed()) return true
|
|
2122
|
+
try {
|
|
2123
|
+
codexPrewarmT = setTimeout(function () { codexPrewarmT = null; try { codexBackgroundRefresh(0) } catch (err) {} }, immediate ? 0 : 1500)
|
|
2124
|
+
codexPrewarmI = setInterval(function () { try { codexBackgroundRefresh(0) } catch (err) {} }, 5 * 60 * 1000)
|
|
2125
|
+
// issue #109:句柄必须存下并进 disposers,否则插件 dispose 后 interval 仍在引用事件循环(退出 dsh 卡住)。
|
|
2126
|
+
try { if (codexPrewarmT && codexPrewarmT.unref) codexPrewarmT.unref() } catch (err) {}
|
|
2127
|
+
try { if (codexPrewarmI && codexPrewarmI.unref) codexPrewarmI.unref() } catch (err) {}
|
|
2128
|
+
} catch (err) {}
|
|
2129
|
+
return true
|
|
2130
|
+
}
|
|
1818
2131
|
try {
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
// 事件循环里仍有一个被引用的 interval → 用户退出 dsh 时进程不结束(只能 Ctrl+C)。
|
|
1823
|
-
// 存句柄并纳入 disposers,卸载时一并清掉;unref 作为兜底(正常运行时由 HTTP 服务维持事件循环)。
|
|
1824
|
-
try { if (codexPrewarmT && codexPrewarmT.unref) codexPrewarmT.unref() } catch (err) {}
|
|
1825
|
-
try { if (codexPrewarmI && codexPrewarmI.unref) codexPrewarmI.unref() } catch (err) {}
|
|
1826
|
-
disposers.push(function () {
|
|
1827
|
-
if (codexPrewarmT !== null) { clearTimeout(codexPrewarmT); codexPrewarmT = null }
|
|
1828
|
-
if (codexPrewarmI !== null) { clearInterval(codexPrewarmI); codexPrewarmI = null }
|
|
1829
|
-
if (codexCatchupT !== null) { clearTimeout(codexCatchupT); codexCatchupT = null }
|
|
1830
|
-
})
|
|
2132
|
+
// 启动时按需装载:没配 Codex ⇒ 一个定时器都不建(这就是"完全禁用")
|
|
2133
|
+
codexRearmPrewarm(false)
|
|
2134
|
+
disposers.push(function () { codexDisarmPrewarm() })
|
|
1831
2135
|
} catch (err) {}
|
|
1832
2136
|
// ===== 自定义 API 模型注册表(v655)=====
|
|
1833
2137
|
// 文件:$DSH_HOME/.dshw-api.json → { version, models:[…], usage:{ [id]:{day,dayStart,lastBalance,delta,eventCost} } }
|
|
@@ -1847,10 +2151,14 @@ export default {
|
|
|
1847
2151
|
}
|
|
1848
2152
|
function writeApiRegistry(reg) {
|
|
1849
2153
|
const body = JSON.stringify(reg, null, 2)
|
|
2154
|
+
let ok = false
|
|
1850
2155
|
for (const p of API_FILE_CANDIDATES) {
|
|
1851
|
-
try { fs.writeFileSync(p, body, 'utf8');
|
|
2156
|
+
try { fs.writeFileSync(p, body, 'utf8'); ok = true; break } catch (err) {}
|
|
1852
2157
|
}
|
|
1853
|
-
|
|
2158
|
+
// v792(用户要求):注册表变了 ⇒ 重新评估要不要挂 Codex 预热。
|
|
2159
|
+
// 加了 Codex 模型 → 立刻挂上(并马上预热一次);删掉了 / 用户关了开关 → 摘掉定时器(连定时器都不留)。
|
|
2160
|
+
try { if (ok) codexRearmPrewarm(true) } catch (err) {}
|
|
2161
|
+
return ok
|
|
1854
2162
|
}
|
|
1855
2163
|
function apiTemplateOf(provider) {
|
|
1856
2164
|
return API_TEMPLATES[String(provider || '')] || null
|
|
@@ -1999,14 +2307,100 @@ export default {
|
|
|
1999
2307
|
}
|
|
2000
2308
|
return { key, code: '', error: '' }
|
|
2001
2309
|
}
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2310
|
+
// v789(issue #190):余额/额度接口不再局限于 GET —— 模板或模型条目可声明 method / body。
|
|
2311
|
+
// ① method 默认 GET;非 GET 时带 `Content-Type: application/json` 并把 body 发出去;
|
|
2312
|
+
// ② body 支持占位符:`{key}`(凭据)、`{base}`(Base URL)、以及 `model.params` 里的任意键(如 `{uuid}`)
|
|
2313
|
+
// —— 这样"POST /v1/enterprise/account {"uuid":"…"}" 这类自建网关不需要再额外挂一层转换服务;
|
|
2314
|
+
// ③ 安全边界不变:body 只来自配置(不接受请求方传入),目的地仍走 credentialMayGoTo() 白名单,
|
|
2315
|
+
// URL 仍过 assertSafeApiUrl()。
|
|
2316
|
+
function bodyWithPlaceholders(body, key, base, params) {
|
|
2317
|
+
let s = String(body == null ? '' : body)
|
|
2318
|
+
s = s.split('{key}').join(String(key == null ? '' : key))
|
|
2319
|
+
s = s.split('{base}').join(String(base == null ? '' : base))
|
|
2320
|
+
if (params && typeof params === 'object') {
|
|
2321
|
+
for (const k of Object.keys(params)) {
|
|
2322
|
+
const v = params[k]
|
|
2323
|
+
if (v === undefined || v === null || typeof v === 'object') continue
|
|
2324
|
+
s = s.split('{' + k + '}').join(String(v))
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
return s
|
|
2328
|
+
}
|
|
2329
|
+
function httpMethodOf(cfg) {
|
|
2330
|
+
const m = String((cfg && cfg.method) || 'GET').trim().toUpperCase()
|
|
2331
|
+
return /^[A-Z]{3,7}$/.test(m) ? m : 'GET'
|
|
2332
|
+
}
|
|
2333
|
+
// v791(用户要求 + 安全加固):请求头支持「英文分号分隔的 头名:值 列表」。
|
|
2334
|
+
// 语义:`auth` 从"Authorization 头的值"升级为**请求头列表**,例如
|
|
2335
|
+
// x-api-key:{key};anthropic-version:2023-06-01
|
|
2336
|
+
// 向后兼容:**不带冒号**的写法(`Bearer {key}` / `{key}`)仍按 **Authorization 头**处理
|
|
2337
|
+
// ⇒ 30 个内置模板与所有老用户配置一个字都不用改。
|
|
2338
|
+
// ⚠️ 安全(这是本能力最容易出事的地方,逐条设防):
|
|
2339
|
+
// ① 头名必须匹配 RFC7230 的 token 字符集(杜绝 `X: a` 这类含空格/控制字符的名字);
|
|
2340
|
+
// ② **禁** Host / Content-Length / Connection / Transfer-Encoding / TE / Upgrade / Cookie /
|
|
2341
|
+
// Proxy-* / Keep-Alive —— 这些能绕过宿主的 Host 信任栅栏(`DSHW_TRUSTED_HOSTS`)、
|
|
2342
|
+
// 或制造请求走私;被忽略的头会在日志里提示一次(不静默);
|
|
2343
|
+
// ③ 头值去掉 CR/LF(防头注入)并限长;单次最多 10 个头;整串限长 2000;
|
|
2344
|
+
// ④ `{key}` 只在**值**里替换(头名不参与),且密钥永不回显/打日志。
|
|
2345
|
+
const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
|
|
2346
|
+
const HEADER_DENY = new Set(['host', 'content-length', 'connection', 'transfer-encoding', 'te', 'upgrade',
|
|
2347
|
+
'cookie', 'proxy-authorization', 'proxy-connection', 'keep-alive'])
|
|
2348
|
+
const HEADER_MAX_COUNT = 10
|
|
2349
|
+
const HEADER_NAME_MAX = 64
|
|
2350
|
+
const HEADER_VALUE_MAX = 1000
|
|
2351
|
+
const HEADER_RAW_MAX = 2000
|
|
2352
|
+
const headerWarned = new Set()
|
|
2353
|
+
function headerWarnOnce(name) {
|
|
2354
|
+
const k = String(name)
|
|
2355
|
+
if (headerWarned.has(k)) return
|
|
2356
|
+
headerWarned.add(k)
|
|
2357
|
+
try { console.warn('[dsh-whale] 已忽略请求头「' + k + '」(安全策略:非法头名或禁止的头)') } catch (err) {}
|
|
2358
|
+
}
|
|
2359
|
+
function headersFromAuth(auth, key) {
|
|
2360
|
+
const out = {}
|
|
2361
|
+
let raw = String(auth == null ? 'Bearer {key}' : auth)
|
|
2362
|
+
if (raw.length > HEADER_RAW_MAX) raw = raw.slice(0, HEADER_RAW_MAX)
|
|
2363
|
+
let count = 0
|
|
2364
|
+
for (const seg of raw.split(';')) {
|
|
2365
|
+
const s = seg.trim()
|
|
2366
|
+
if (!s) continue
|
|
2367
|
+
const i = s.indexOf(':')
|
|
2368
|
+
if (i === 0) continue // 「:xxx」没有头名 → 跳过
|
|
2369
|
+
const name = (i > 0 ? s.slice(0, i) : 'Authorization').trim()
|
|
2370
|
+
let value = (i > 0 ? s.slice(i + 1) : s).trim()
|
|
2371
|
+
if (!name || !value) continue
|
|
2372
|
+
if (name.length > HEADER_NAME_MAX || !HEADER_NAME_RE.test(name)) { headerWarnOnce(name); continue }
|
|
2373
|
+
if (HEADER_DENY.has(name.toLowerCase())) { headerWarnOnce(name); continue }
|
|
2374
|
+
if (!Object.prototype.hasOwnProperty.call(out, name) && count >= HEADER_MAX_COUNT) continue
|
|
2375
|
+
value = value.replace(/[\r\n]+/g, ' ').slice(0, HEADER_VALUE_MAX).replace('{key}', String(key == null ? '' : key))
|
|
2376
|
+
out[name] = value
|
|
2377
|
+
count++
|
|
2378
|
+
}
|
|
2379
|
+
return out
|
|
2380
|
+
}
|
|
2381
|
+
function headerNameExists(headers, lowerName) {
|
|
2382
|
+
for (const k of Object.keys(headers)) if (k.toLowerCase() === lowerName) return true
|
|
2383
|
+
return false
|
|
2384
|
+
}
|
|
2385
|
+
async function apiFetchJson(url, auth, key, opts) {
|
|
2386
|
+
const o = opts || {}
|
|
2387
|
+
// v791:头列表(默认值仍是单个 Authorization,与老行为逐字节一致)
|
|
2388
|
+
const headers = headersFromAuth(auth, key)
|
|
2006
2389
|
// v724:URL 里也支持 {key}(如 Gemini 的 ?key=… 形式;auth 传空串时不带 Authorization 头)
|
|
2007
2390
|
const u = String(url).replace('{key}', key)
|
|
2008
2391
|
assertSafeApiUrl(u)
|
|
2009
|
-
const
|
|
2392
|
+
const init = { headers, signal: AbortSignal.timeout(15000) }
|
|
2393
|
+
const method = httpMethodOf(o)
|
|
2394
|
+
if (method !== 'GET') {
|
|
2395
|
+
init.method = method
|
|
2396
|
+
const raw = o.body
|
|
2397
|
+
if (raw !== undefined && raw !== null && String(raw).length) {
|
|
2398
|
+
// 用户自己在头列表里写了 Content-Type 就用他的(不覆盖)
|
|
2399
|
+
if (!headerNameExists(headers, 'content-type')) headers['Content-Type'] = 'application/json'
|
|
2400
|
+
init.body = bodyWithPlaceholders(raw, key, o.base, o.params)
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
const res = await fetch(u, init)
|
|
2010
2404
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
2011
2405
|
return await res.json()
|
|
2012
2406
|
}
|
|
@@ -2042,14 +2436,17 @@ export default {
|
|
|
2042
2436
|
}
|
|
2043
2437
|
const b = mergeNonEmpty(tpl.balance, model.balance) // v724:模型侧空串不再覆盖模板
|
|
2044
2438
|
const base = String(model.baseUrl || '').replace(/\/+$/, '')
|
|
2045
|
-
|
|
2439
|
+
// v789(issue #190):声明了 POST 等非 GET 方法时,测试就直接打**用户配置的余额接口**(带同一个 body)——
|
|
2440
|
+
// 否则会拿 GET 去打一个只支持 POST 的地址、误报"测试失败"(probeUrl 多是 models 列表,body 不通用)。
|
|
2441
|
+
const nonGet = httpMethodOf(b) !== 'GET'
|
|
2442
|
+
const url = String(nonGet ? (b.url || tpl.probeUrl || '') : (tpl.probeUrl || b.url || (base ? base + '/v1/models' : ''))).replace('{base}', base)
|
|
2046
2443
|
if (!url) return { ok: false, error: '没有可测试的接口(请填余额接口或 Base URL)' }
|
|
2047
2444
|
// v761(安全修复 S1):只在目的地可信时才带上凭据
|
|
2048
2445
|
const k = await keyForDestination(model, tpl, url)
|
|
2049
2446
|
if (!k.key) return { ok: false, code: k.code, error: k.error }
|
|
2050
2447
|
const key = k.key
|
|
2051
2448
|
try {
|
|
2052
|
-
const data = await apiFetchJson(url, b.auth, key)
|
|
2449
|
+
const data = await apiFetchJson(url, b.auth, key, { method: b.method, body: b.body, base: base, params: model.params })
|
|
2053
2450
|
const bizErr = apiBusinessError(data)
|
|
2054
2451
|
if (bizErr) return { ok: false, error: '测试失败: ' + bizErr }
|
|
2055
2452
|
let detail = 'HTTP 200'
|
|
@@ -2060,14 +2457,41 @@ export default {
|
|
|
2060
2457
|
return { ok: false, error: '测试失败: ' + String((err && err.message) || err).slice(0, 140) }
|
|
2061
2458
|
}
|
|
2062
2459
|
}
|
|
2460
|
+
// v789(issue #180/#181):额度接口里"多窗口 + 数组顺序不保证"的通用取数。
|
|
2461
|
+
// 智谱 `GET /api/monitor/usage/quota/limit` 返回的 `data.limits[]` 是**扁平条目数组**:
|
|
2462
|
+
// `TOKENS_LIMIT` 是每条的 `type` 字段值(不是 `limits[0]` 的子对象),5h 窗口不保证排在 `[0]`
|
|
2463
|
+
// (实测:pro 账号 `limits[0]` 是 TIME_LIMIT,max 账号 `limits[0]` 是 TOKENS_LIMIT ⇒ 按位置取数必然出错过),
|
|
2464
|
+
// 而且新注册/credit 制账号把 `type` 改成了 `CREDIT_LIMIT`。
|
|
2465
|
+
// 所以模板可以声明 `pick: { list, type/types, unit, number }`:先选中**那一条**,再把它的字段当作取数根。
|
|
2466
|
+
function pickQuotaEntry(data, pick) {
|
|
2467
|
+
try {
|
|
2468
|
+
if (!pick || !pick.list) return null
|
|
2469
|
+
const arr = pickJsonPath(data, pick.list)
|
|
2470
|
+
if (!Array.isArray(arr)) return null
|
|
2471
|
+
const types = []
|
|
2472
|
+
if (pick.type) types.push(String(pick.type))
|
|
2473
|
+
if (Array.isArray(pick.types)) for (const t of pick.types) types.push(String(t))
|
|
2474
|
+
const unit = (pick.unit === undefined || pick.unit === null) ? null : Number(pick.unit)
|
|
2475
|
+
const number = (pick.number === undefined || pick.number === null) ? null : Number(pick.number)
|
|
2476
|
+
for (const it of arr) {
|
|
2477
|
+
if (!it || typeof it !== 'object') continue
|
|
2478
|
+
if (types.length && types.indexOf(String(it.type)) < 0) continue
|
|
2479
|
+
if (unit !== null && Number(it.unit) !== unit) continue
|
|
2480
|
+
if (number !== null && Number(it.number) !== number) continue
|
|
2481
|
+
return it
|
|
2482
|
+
}
|
|
2483
|
+
return null
|
|
2484
|
+
} catch (err) { return null }
|
|
2485
|
+
}
|
|
2063
2486
|
// 订阅额度(Coding Plan)解析:各厂商字段不同,统一归一成「已用% + 重置时间 + 档位」
|
|
2064
|
-
// zhipu: data.limits[
|
|
2487
|
+
// zhipu: data.limits[] 里 type=TOKENS_LIMIT/CREDIT_LIMIT + unit=3(5h)/6(周) 的条目(v789 起按 pick 选)
|
|
2065
2488
|
// kimi: usage.remaining / usage.limit(剩余/总量)
|
|
2066
2489
|
// minimax: model_remains[0].current_interval_remaining_percent(剩余%)+ end_time(ms)
|
|
2067
2490
|
async function fetchModelQuota(model) {
|
|
2068
2491
|
if (!model) return { ok: false, error: '模型不存在' }
|
|
2069
2492
|
const tpl = apiTemplateOf(model.provider) || {}
|
|
2070
|
-
|
|
2493
|
+
// v789(issue #180 建议 3):模型条目也能覆盖额度配置 —— 厂商接口变动时用户可自救(不留空即覆盖)
|
|
2494
|
+
const q = mergeNonEmpty(tpl.quota, model.quota)
|
|
2071
2495
|
if (!q || !q.url) return { ok: false, code: 'NO_QUOTA', error: '该厂商没有订阅额度接口' }
|
|
2072
2496
|
const base = String(model.baseUrl || '').replace(/\/+$/, '')
|
|
2073
2497
|
const url = String(q.url).replace('{base}', base)
|
|
@@ -2076,31 +2500,33 @@ export default {
|
|
|
2076
2500
|
if (!k.key) return { ok: false, code: k.code, error: k.error }
|
|
2077
2501
|
const key = k.key
|
|
2078
2502
|
try {
|
|
2079
|
-
const data = await apiFetchJson(url, q.auth, key)
|
|
2503
|
+
const data = await apiFetchJson(url, q.auth, key, { method: q.method, body: q.body, base: base, params: model.params })
|
|
2080
2504
|
const bizErr = apiBusinessError(data)
|
|
2081
2505
|
if (bizErr) return { ok: false, code: 'BIZ', error: bizErr }
|
|
2082
2506
|
const j = q.json || {}
|
|
2083
2507
|
const num = (v) => { const n = Number(v); return isFinite(n) ? n : null }
|
|
2508
|
+
// v789:若模板声明了顶层 pick(单窗口 + 数组筛选),先把取数根换成选中的那条
|
|
2509
|
+
const jroot = j.pick ? (pickQuotaEntry(data, j.pick) || {}) : data
|
|
2084
2510
|
let usedPct = null, remainPct = null, resetAt = null, level = '', weeklyUsedPct = null
|
|
2085
|
-
if (j.percent) { const p = num(pickJsonPath(
|
|
2511
|
+
if (j.percent) { const p = num(pickJsonPath(jroot, j.percent)); if (p !== null) usedPct = p }
|
|
2086
2512
|
if (j.remainPct) {
|
|
2087
|
-
const p = num(pickJsonPath(
|
|
2513
|
+
const p = num(pickJsonPath(jroot, j.remainPct))
|
|
2088
2514
|
if (p !== null) { remainPct = p; if (usedPct === null) usedPct = Math.max(0, 100 - p) }
|
|
2089
2515
|
}
|
|
2090
2516
|
if (j.weeklyRemainPct) {
|
|
2091
|
-
const p = num(pickJsonPath(
|
|
2517
|
+
const p = num(pickJsonPath(jroot, j.weeklyRemainPct))
|
|
2092
2518
|
if (p !== null) weeklyUsedPct = Math.max(0, 100 - p)
|
|
2093
2519
|
}
|
|
2094
2520
|
if (j.remain && j.total) {
|
|
2095
|
-
const r0 = num(pickJsonPath(
|
|
2096
|
-
const t0 = num(pickJsonPath(
|
|
2521
|
+
const r0 = num(pickJsonPath(jroot, j.remain))
|
|
2522
|
+
const t0 = num(pickJsonPath(jroot, j.total))
|
|
2097
2523
|
if (r0 !== null && t0) {
|
|
2098
2524
|
remainPct = Math.max(0, Math.min(100, r0 / t0 * 100))
|
|
2099
2525
|
usedPct = Math.max(0, 100 - remainPct)
|
|
2100
2526
|
}
|
|
2101
2527
|
}
|
|
2102
|
-
if (j.resetAt) resetAt = pickJsonPath(
|
|
2103
|
-
if (j.resetAtMs) { const ms = num(pickJsonPath(
|
|
2528
|
+
if (j.resetAt) resetAt = pickJsonPath(jroot, j.resetAt)
|
|
2529
|
+
if (j.resetAtMs) { const ms = num(pickJsonPath(jroot, j.resetAtMs)); if (ms !== null) resetAt = ms }
|
|
2104
2530
|
if (j.level) level = String(pickJsonPath(data, j.level) || '')
|
|
2105
2531
|
// v0.3.1:多窗口额度 —— 有些订阅额度接口一次返回多个窗口(如 OpenCode Go 的
|
|
2106
2532
|
// rolling / weekly / monthly),每个窗口各有「已用% + 重置时间」。模板用
|
|
@@ -2111,11 +2537,13 @@ export default {
|
|
|
2111
2537
|
const list = []
|
|
2112
2538
|
for (const w of j.windows) {
|
|
2113
2539
|
if (!w) continue
|
|
2114
|
-
|
|
2540
|
+
// v789:每个窗口可以各自 pick 一条(智谱 5h=unit3 / 周=unit6),选中后以该条为取数根
|
|
2541
|
+
const wroot = w.pick ? (pickQuotaEntry(data, w.pick) || {}) : data
|
|
2542
|
+
let wp = w.percent ? num(pickJsonPath(wroot, w.percent)) : null
|
|
2115
2543
|
if (wp !== null) wp = Math.max(0, Math.min(100, wp))
|
|
2116
2544
|
let wr = null
|
|
2117
2545
|
if (w.resetAt) {
|
|
2118
|
-
const rv = pickJsonPath(
|
|
2546
|
+
const rv = pickJsonPath(wroot, w.resetAt)
|
|
2119
2547
|
if (rv !== undefined && rv !== null && rv !== '') wr = rv
|
|
2120
2548
|
}
|
|
2121
2549
|
if (wp === null && wr === null) continue
|
|
@@ -2168,7 +2596,8 @@ export default {
|
|
|
2168
2596
|
if (!k.key) return { ok: false, code: k.code, error: k.error }
|
|
2169
2597
|
const key = k.key
|
|
2170
2598
|
try {
|
|
2171
|
-
|
|
2599
|
+
// v789(issue #190):把 method / body / params 一并交给取数层(默认 GET,与以前完全一致)
|
|
2600
|
+
const data = await apiFetchJson(url, b.auth, key, { method: b.method, body: b.body, base: base, params: model.params })
|
|
2172
2601
|
const j = b.json || {}
|
|
2173
2602
|
let remaining = null
|
|
2174
2603
|
let total = null
|
|
@@ -2407,12 +2836,57 @@ export default {
|
|
|
2407
2836
|
if (p.allowCustomHost === true) model.allowCustomHost = true
|
|
2408
2837
|
else if (p.allowCustomHost === false) delete model.allowCustomHost
|
|
2409
2838
|
if (p.baseUrl !== undefined) model.baseUrl = String(p.baseUrl || '').slice(0, 300)
|
|
2839
|
+
// v789(issue #190):请求体里可用的 `{占位符}`(如 {uuid})—— 只允许标量,键名受限
|
|
2840
|
+
if (p.params && typeof p.params === 'object') {
|
|
2841
|
+
const pr = {}
|
|
2842
|
+
for (const k of Object.keys(p.params)) {
|
|
2843
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,30}$/.test(k)) continue
|
|
2844
|
+
const v = p.params[k]
|
|
2845
|
+
if (v === undefined || v === null || typeof v === 'object') continue
|
|
2846
|
+
pr[k] = String(v).slice(0, 300)
|
|
2847
|
+
}
|
|
2848
|
+
if (Object.keys(pr).length) model.params = pr
|
|
2849
|
+
else delete model.params
|
|
2850
|
+
}
|
|
2851
|
+
if (p.quota && typeof p.quota === 'object' && p.quota.json && typeof p.quota.json === 'object') {
|
|
2852
|
+
// v789(issue #180 建议 3):允许模型条目覆盖额度配置(厂商接口变动时用户可自救)。
|
|
2853
|
+
// 只收 JSON-safe 的形状,且路径字符串限长 —— 与模板同构,不做二次校验的滥用面。
|
|
2854
|
+
const qj = {}
|
|
2855
|
+
const pathOf = (v) => String(v || '').slice(0, 200)
|
|
2856
|
+
if (p.quota.url) qj.url = String(p.quota.url).slice(0, 500)
|
|
2857
|
+
if (p.quota.auth !== undefined) qj.auth = String(p.quota.auth || '').slice(0, 1000)
|
|
2858
|
+
const jin = p.quota.json
|
|
2859
|
+
for (const k of ['percent', 'remainPct', 'weeklyRemainPct', 'remain', 'total', 'resetAt', 'resetAtMs', 'level']) {
|
|
2860
|
+
if (jin[k]) qj[k] = pathOf(jin[k])
|
|
2861
|
+
}
|
|
2862
|
+
if (Array.isArray(jin.windows)) {
|
|
2863
|
+
qj.windows = jin.windows.slice(0, 6).map((w) => {
|
|
2864
|
+
const o = { key: String((w && w.key) || '').slice(0, 20), label: String((w && w.label) || '').slice(0, 20) }
|
|
2865
|
+
if (w && w.percent) o.percent = pathOf(w.percent)
|
|
2866
|
+
if (w && w.resetAt) o.resetAt = pathOf(w.resetAt)
|
|
2867
|
+
if (w && w.pick && typeof w.pick === 'object') {
|
|
2868
|
+
const pk = { list: pathOf(w.pick.list) }
|
|
2869
|
+
if (w.pick.type) pk.type = String(w.pick.type).slice(0, 40)
|
|
2870
|
+
if (Array.isArray(w.pick.types)) pk.types = w.pick.types.slice(0, 4).map((t) => String(t).slice(0, 40))
|
|
2871
|
+
if (w.pick.unit !== undefined) pk.unit = Number(w.pick.unit)
|
|
2872
|
+
if (w.pick.number !== undefined) pk.number = Number(w.pick.number)
|
|
2873
|
+
o.pick = pk
|
|
2874
|
+
}
|
|
2875
|
+
return o
|
|
2876
|
+
})
|
|
2877
|
+
}
|
|
2878
|
+
if (qj.url && (Object.keys(qj).length > 1)) model.quota = qj
|
|
2879
|
+
}
|
|
2410
2880
|
if (p.balance && typeof p.balance === 'object') {
|
|
2411
2881
|
// v724:空字段不写入(stripEmptyDeep)→ 模板默认值继续生效;
|
|
2412
2882
|
// 全部为空时直接删掉 model.balance,等于"完全用模板"
|
|
2413
2883
|
const balIn = stripEmptyDeep({
|
|
2414
2884
|
url: String(p.balance.url || '').slice(0, 500),
|
|
2415
|
-
|
|
2885
|
+
// v791:auth 现在是「头名:值」列表,比单个 Authorization 值长 ⇒ 上限 200 → 1000
|
|
2886
|
+
auth: String(p.balance.auth || '').slice(0, 1000),
|
|
2887
|
+
// v789(issue #190):可选的请求方法与请求体(留空 ⇒ 沿用模板/GET)
|
|
2888
|
+
method: String(p.balance.method || '').slice(0, 8).toUpperCase(),
|
|
2889
|
+
body: String(p.balance.body || '').slice(0, 4000),
|
|
2416
2890
|
json: {
|
|
2417
2891
|
remaining: String((p.balance.json && p.balance.json.remaining) || '').slice(0, 200),
|
|
2418
2892
|
total: String((p.balance.json && p.balance.json.total) || '').slice(0, 200),
|
|
@@ -2426,7 +2900,7 @@ export default {
|
|
|
2426
2900
|
model.balance = model.balance || {}
|
|
2427
2901
|
model.balance.usage = stripEmptyDeep({
|
|
2428
2902
|
url: String(p.balance.usage.url).slice(0, 500),
|
|
2429
|
-
auth: String(p.balance.usage.auth == null ? '' : p.balance.usage.auth).slice(0,
|
|
2903
|
+
auth: String(p.balance.usage.auth == null ? '' : p.balance.usage.auth).slice(0, 1000),
|
|
2430
2904
|
json: { used: String((p.balance.usage.json && p.balance.usage.json.used) || '').slice(0, 200), scale: isFinite(Number(p.balance.usage.json && p.balance.usage.json.scale)) ? Number(p.balance.usage.json.scale) : undefined },
|
|
2431
2905
|
})
|
|
2432
2906
|
}
|
|
@@ -2513,6 +2987,10 @@ export default {
|
|
|
2513
2987
|
// entry.balance 是数值,不能当描述用(否则面板保存会用空值覆盖描述)
|
|
2514
2988
|
const tplE = apiTemplateOf(m.provider) || {}
|
|
2515
2989
|
entry.balanceDesc = mergeNonEmpty(tplE.balance, m.balance) // v724:空串不覆盖模板
|
|
2990
|
+
// v789(issue #189):把当前的凭据放行标志也下发 —— 前端勾选框要能**显示现状**,
|
|
2991
|
+
// 否则"看不到 → 保存时显式传 false"会把用户手改文件开的口子又关掉(反而更糟)。
|
|
2992
|
+
entry.allowCustomHost = m.allowCustomHost === true
|
|
2993
|
+
entry.params = (m.params && typeof m.params === 'object') ? m.params : null
|
|
2516
2994
|
if (m.id === API_BUILTIN_ID) {
|
|
2517
2995
|
entry.balanceMode = 'api'
|
|
2518
2996
|
entry.hasBalanceApi = true
|
|
@@ -2635,7 +3113,7 @@ export default {
|
|
|
2635
3113
|
const summary = daySummary(led, todayKey())
|
|
2636
3114
|
const nowSec = Math.floor(Date.now() / 1000)
|
|
2637
3115
|
return {
|
|
2638
|
-
...visible, version: '0.
|
|
3116
|
+
...visible, version: '0.4.0', versionLabel: VERSION_LABEL, isPeak: isPeakTime(nowSec),
|
|
2639
3117
|
// 峰谷切换点与节假日清单:前端倒计时要与宿主同源(否则法定节假日会算错切换点)
|
|
2640
3118
|
peakNextChangeAt: nextPeakChangeAt(nowSec),
|
|
2641
3119
|
peakHolidays: HOLIDAY_VALLEY_LIST,
|
|
@@ -2701,6 +3179,8 @@ export default {
|
|
|
2701
3179
|
usageMode: normalizeUsageMode(parsed.usageMode),
|
|
2702
3180
|
peakMode: parsed.peakMode === 'liangwen' || parsed.peakMode === 'qiangqiang' ? parsed.peakMode : 'default',
|
|
2703
3181
|
bubbleOn: parsed.bubbleOn !== false,
|
|
3182
|
+
// v866(用户要求):按压/松开音的**独立**开关(原来是全局 soundOn 兼着当它用)
|
|
3183
|
+
pressOn: parsed.pressOn !== false,
|
|
2704
3184
|
turnCostOn: parsed.turnCostOn !== false,
|
|
2705
3185
|
turnCostCloseMs: typeof parsed.turnCostCloseMs === 'number' ? parsed.turnCostCloseMs : 5000,
|
|
2706
3186
|
scrollGapOn: parsed.scrollGapOn === true,
|
|
@@ -2716,10 +3196,11 @@ export default {
|
|
|
2716
3196
|
return null
|
|
2717
3197
|
}
|
|
2718
3198
|
|
|
2719
|
-
function writeSizeConfig(scale, sound, vol, soundSet, usageMode, peakMode, bubbleOn, turnCostOn, turnCostCloseMs, scrollGapOn, scrollGapPx, menuBtnHide, codexStatsOnArg) {
|
|
3199
|
+
function writeSizeConfig(scale, sound, vol, soundSet, usageMode, peakMode, bubbleOn, pressOnArg, turnCostOn, turnCostCloseMs, scrollGapOn, scrollGapPx, menuBtnHide, codexStatsOnArg) {
|
|
2720
3200
|
const um = normalizeUsageMode(usageMode)
|
|
2721
3201
|
const pm = peakMode === 'liangwen' || peakMode === 'qiangqiang' ? peakMode : 'default'
|
|
2722
3202
|
const bo = bubbleOn !== false
|
|
3203
|
+
const po = pressOnArg !== false // v866:按压/松开音独立开关(默认开)
|
|
2723
3204
|
const tco = turnCostOn !== false
|
|
2724
3205
|
const tcc = typeof turnCostCloseMs === 'number' ? (turnCostCloseMs > 0 ? turnCostCloseMs : 0) : 5000
|
|
2725
3206
|
const sgo = scrollGapOn === true
|
|
@@ -2734,6 +3215,7 @@ export default {
|
|
|
2734
3215
|
usageMode: um,
|
|
2735
3216
|
peakMode: pm,
|
|
2736
3217
|
bubbleOn: bo,
|
|
3218
|
+
pressOn: po,
|
|
2737
3219
|
turnCostOn: tco,
|
|
2738
3220
|
turnCostCloseMs: tcc,
|
|
2739
3221
|
scrollGapOn: sgo,
|
|
@@ -2755,6 +3237,7 @@ export default {
|
|
|
2755
3237
|
usageMode: um,
|
|
2756
3238
|
peakMode: pm,
|
|
2757
3239
|
bubbleOn: bo,
|
|
3240
|
+
pressOn: po,
|
|
2758
3241
|
turnCostOn: tco,
|
|
2759
3242
|
turnCostCloseMs: tcc,
|
|
2760
3243
|
scrollGapOn: sgo,
|
|
@@ -3104,14 +3587,31 @@ export default {
|
|
|
3104
3587
|
kind: 'exact',
|
|
3105
3588
|
path: '/dsh-whale/balance.json',
|
|
3106
3589
|
handler: async (req, res) => {
|
|
3590
|
+
const t0 = Date.now()
|
|
3107
3591
|
try {
|
|
3108
3592
|
const refresh = new URL(req.url || '/', 'http://localhost').searchParams.get('refresh') === '1'
|
|
3109
3593
|
const payload = await getBalance(refresh)
|
|
3594
|
+
// v793:给「关于/自检」面板留一条最近的取数结果(只记状态与耗时,不记任何凭据)
|
|
3595
|
+
try {
|
|
3596
|
+
balanceLast = {
|
|
3597
|
+
at: Date.now(),
|
|
3598
|
+
ms: Date.now() - t0,
|
|
3599
|
+
ok: !!payload.ok,
|
|
3600
|
+
code: String(payload.code || ''),
|
|
3601
|
+
error: payload.ok ? '' : String(payload.error || '').slice(0, 160),
|
|
3602
|
+
hasBalance: payload.totalBalance !== null && payload.totalBalance !== undefined,
|
|
3603
|
+
currency: String(payload.currency || ''),
|
|
3604
|
+
}
|
|
3605
|
+
} catch (err) {}
|
|
3110
3606
|
res.writeHead(200, JSON_HEADERS)
|
|
3111
|
-
|
|
3607
|
+
// v794:成功失败都带上版本 ⇒ 客户端不必依赖"余额成功"才拿到版号
|
|
3608
|
+
res.end(JSON.stringify(Object.assign({}, payload, { version: BUILD_VERSION, versionLabel: VERSION_LABEL })))
|
|
3112
3609
|
} catch (err) {
|
|
3610
|
+
try {
|
|
3611
|
+
balanceLast = { at: Date.now(), ms: Date.now() - t0, ok: false, code: 'THROW', error: String((err && err.message) || err).slice(0, 160), hasBalance: false, currency: '' }
|
|
3612
|
+
} catch (e2) {}
|
|
3113
3613
|
res.writeHead(200, JSON_HEADERS)
|
|
3114
|
-
res.end(JSON.stringify({ ok: false, code: 'ERROR', error: String((err && err.message) || err).slice(0, 200) }))
|
|
3614
|
+
res.end(JSON.stringify({ ok: false, code: 'ERROR', error: String((err && err.message) || err).slice(0, 200), version: BUILD_VERSION, versionLabel: VERSION_LABEL }))
|
|
3115
3615
|
}
|
|
3116
3616
|
},
|
|
3117
3617
|
}))
|
|
@@ -3164,6 +3664,7 @@ export default {
|
|
|
3164
3664
|
typeof parsed.usageMode === 'string' ? parsed.usageMode : old.usageMode,
|
|
3165
3665
|
typeof parsed.peakMode === 'string' ? parsed.peakMode : old.peakMode,
|
|
3166
3666
|
pickB(parsed.bubbleOn, old.bubbleOn, true),
|
|
3667
|
+
pickB(parsed.pressOn, old.pressOn, true),
|
|
3167
3668
|
pickB(parsed.turnCostOn, old.turnCostOn, true),
|
|
3168
3669
|
pickN(parsed.turnCostCloseMs, old.turnCostCloseMs, 5000),
|
|
3169
3670
|
pickB(parsed.scrollGapOn, old.scrollGapOn, false),
|
|
@@ -3182,7 +3683,12 @@ export default {
|
|
|
3182
3683
|
return
|
|
3183
3684
|
}
|
|
3184
3685
|
res.writeHead(200, JSON_HEADERS)
|
|
3185
|
-
|
|
3686
|
+
// v794:GET 也带上版本(客户端启动就会读这份配置 ⇒ 菜单底部那行版本小字**不必等余额请求**,
|
|
3687
|
+
// 余额首次请求失败时也不会一直空着 —— 这是用户报的"刚启动时版号消失")
|
|
3688
|
+
res.end(JSON.stringify(Object.assign({}, readSizeConfig() || {}, {
|
|
3689
|
+
version: BUILD_VERSION,
|
|
3690
|
+
versionLabel: VERSION_LABEL,
|
|
3691
|
+
})))
|
|
3186
3692
|
},
|
|
3187
3693
|
}))
|
|
3188
3694
|
|
|
@@ -3200,6 +3706,290 @@ export default {
|
|
|
3200
3706
|
}))
|
|
3201
3707
|
},
|
|
3202
3708
|
}))
|
|
3709
|
+
// ===== v793(用户要求):点版号 → 「关于 / 自检」面板 =====
|
|
3710
|
+
// 数据来源全部是宿主已有状态;路径一律脱敏;**点开才请求,不轮询**。
|
|
3711
|
+
let balanceLast = null // { at, ok, code, error, ms, hasBalance, keyRef, keyConfigured }
|
|
3712
|
+
function aboutPayload(req) {
|
|
3713
|
+
const client = clientKindOf(req)
|
|
3714
|
+
const hostFile = (() => { try { return fileURLToPath(import.meta.url) } catch (err) { return '' } })()
|
|
3715
|
+
const widgetFile = (() => {
|
|
3716
|
+
for (const p of WIDGET_FILE_CANDIDATES) { try { if (fs.existsSync(p)) return p } catch (err) {} }
|
|
3717
|
+
return ''
|
|
3718
|
+
})()
|
|
3719
|
+
const dataFiles = [
|
|
3720
|
+
['账本(记账)', USAGE_FILE_CANDIDATES],
|
|
3721
|
+
['模型注册表', API_FILE_CANDIDATES],
|
|
3722
|
+
['泡泡配置', BUBBLE_FILE_CANDIDATES],
|
|
3723
|
+
['外观/设置', SIZE_FILE_CANDIDATES],
|
|
3724
|
+
['轮次序号', TURN_FILE_CANDIDATES],
|
|
3725
|
+
['Codex 缓存', [path.join(DSH_HOME, '.dshw-codex.json')]],
|
|
3726
|
+
].map(([label, list]) => {
|
|
3727
|
+
const p = firstExisting(list)
|
|
3728
|
+
const f = p ? fileFacts(p) : null
|
|
3729
|
+
return {
|
|
3730
|
+
label: label,
|
|
3731
|
+
found: !!p,
|
|
3732
|
+
path: p ? maskPath(p) : '',
|
|
3733
|
+
candidates: (list || []).length,
|
|
3734
|
+
bytes: f ? f.bytes : null,
|
|
3735
|
+
mtime: f ? f.mtime : null,
|
|
3736
|
+
}
|
|
3737
|
+
})
|
|
3738
|
+
// 记账概览(只给统计,不给明细)
|
|
3739
|
+
let ledger = null
|
|
3740
|
+
try {
|
|
3741
|
+
const led = readUsageLedger()
|
|
3742
|
+
const today = todayKey()
|
|
3743
|
+
const sum = daySummary(led, today)
|
|
3744
|
+
let days = 0
|
|
3745
|
+
try { days = accountingDays(led).length } catch (err) { days = Object.keys(led.history || {}).length }
|
|
3746
|
+
ledger = {
|
|
3747
|
+
days: days,
|
|
3748
|
+
todayPartial: !!(sum && sum.partialDay),
|
|
3749
|
+
todaySource: (sum && sum.source) || '',
|
|
3750
|
+
lastObservedAt: (sum && sum.lastObservedAt) || null,
|
|
3751
|
+
needsReview: !!(sum && sum.needsReview),
|
|
3752
|
+
}
|
|
3753
|
+
} catch (err) { ledger = { error: String((err && err.message) || err).slice(0, 120) } }
|
|
3754
|
+
// Codex(v792 起:没配就完全禁用)
|
|
3755
|
+
let codex = { enabled: false, reason: '' }
|
|
3756
|
+
try {
|
|
3757
|
+
codex = {
|
|
3758
|
+
enabled: codexStatsOn(),
|
|
3759
|
+
reason: codexStatsOn() ? '' : codexDisabledReason(),
|
|
3760
|
+
snapAt: codexSnapAt || null,
|
|
3761
|
+
ok: !!(codexSnap && codexSnap.ok),
|
|
3762
|
+
files: (codexSnap && codexSnap.sessions) != null ? codexSnap.sessions : null,
|
|
3763
|
+
skipped: (codexSnap && codexSnap.skipped) != null ? codexSnap.skipped : null,
|
|
3764
|
+
deferred: (codexSnap && codexSnap.deferred) != null ? codexSnap.deferred : null,
|
|
3765
|
+
}
|
|
3766
|
+
} catch (err) {}
|
|
3767
|
+
return {
|
|
3768
|
+
ok: true,
|
|
3769
|
+
plugin: {
|
|
3770
|
+
name: 'dsh-whale-widget',
|
|
3771
|
+
version: BUILD_VERSION,
|
|
3772
|
+
channel: BUILD_CHANNEL,
|
|
3773
|
+
label: VERSION_LABEL,
|
|
3774
|
+
hostVersionLiterals: [...String(aboutPayloadSrc()).matchAll(/version: '([\d.]+)'/g)].map((m) => m[1]),
|
|
3775
|
+
},
|
|
3776
|
+
build: {
|
|
3777
|
+
// v796:`found` 必须给(v795 前端简化后靠它判断"未找到",缺了就会把宿主/前端文件误显示成"未找到")
|
|
3778
|
+
host: { found: !!hostFile, path: maskPath(hostFile), bytes: fileFacts(hostFile) ? fileFacts(hostFile).bytes : null, mtime: fileFacts(hostFile) ? fileFacts(hostFile).mtime : null },
|
|
3779
|
+
widget: { found: !!widgetFile, path: maskPath(widgetFile), bytes: fileFacts(widgetFile) ? fileFacts(widgetFile).bytes : null, mtime: fileFacts(widgetFile) ? fileFacts(widgetFile).mtime : null },
|
|
3780
|
+
// v819(自检发现):这里原来写的是 `indexOf('<开发机目录名>')` —— 检测"开发版路径"的代码自己带了
|
|
3781
|
+
// 那个字面量 ⇒ 发布包被 `tools/ci-audit.mjs` 判为"含开发机路径"(自己成了自己要检测的东西)。
|
|
3782
|
+
// 改成**结构性判定**:宿主源码里有没有写死的绝对盘符路径('D:/…' / "C:\\…")。
|
|
3783
|
+
// 开发版有 15+ 处(assets 兜底路径),strip 后的发布副本 0 处 ⇒ 判定更准,也不再出现那个字面量。
|
|
3784
|
+
hostHasDevPaths: (() => {
|
|
3785
|
+
try {
|
|
3786
|
+
const src = fs.readFileSync(hostFile, 'utf8')
|
|
3787
|
+
return /["'][A-Za-z]:[\\/]/.test(src)
|
|
3788
|
+
} catch (err) { return null }
|
|
3789
|
+
})(),
|
|
3790
|
+
},
|
|
3791
|
+
runtime: {
|
|
3792
|
+
platform: process.platform,
|
|
3793
|
+
arch: process.arch,
|
|
3794
|
+
node: process.version,
|
|
3795
|
+
dsh: dshVersionOf(),
|
|
3796
|
+
uptimeSec: Math.round(process.uptime()),
|
|
3797
|
+
dataHome: maskPath(DSH_HOME),
|
|
3798
|
+
// profile 名(`profiles/web`、`profiles/desktop`…):用来一眼看出"这份是哪个 profile 起来"的
|
|
3799
|
+
profile: (() => { try { const m = /profiles[\\/]([^\\/]+)/.exec(hostFile); return m ? m[1] : '' } catch (err) { return '' } })(),
|
|
3800
|
+
},
|
|
3801
|
+
client: client,
|
|
3802
|
+
data: dataFiles,
|
|
3803
|
+
ledger: ledger,
|
|
3804
|
+
balance: balanceLast,
|
|
3805
|
+
codex: codex,
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
// 只用于读取源码里的两处上报版本字面量(判断"三处是否一致");不读敏感内容。
|
|
3809
|
+
function aboutPayloadSrc() { try { return fs.readFileSync(fileURLToPath(import.meta.url), 'utf8') } catch (err) { return '' } }
|
|
3810
|
+
// v796:DSH 版本**真的去读**,而不是只看环境变量(用户反馈"DSH 版本读取不到 (?)")。
|
|
3811
|
+
// 顺序:① 环境变量(部分启动方式会带)② 从 argv/execPath 往上找 `@deepseek-ai/dsh` 的 package.json
|
|
3812
|
+
// (npx 启动时 argv 就在 `_npx/<hash>/node_modules/@deepseek-ai/dsh/...` 里)③ 读不到就老实回 'unknown'。
|
|
3813
|
+
// 全程 try/catch + 缓存(每个目录最多往上 8 层),绝不抛错。
|
|
3814
|
+
let dshVersionCache = null
|
|
3815
|
+
function dshVersionOf() {
|
|
3816
|
+
if (dshVersionCache !== null) return dshVersionCache
|
|
3817
|
+
let v = ''
|
|
3818
|
+
try { v = String(process.env.DSH_CLIENT_VERSION || process.env.DSH_VERSION || '') } catch (err) {}
|
|
3819
|
+
if (!v) {
|
|
3820
|
+
try {
|
|
3821
|
+
const roots = [process.argv[1], process.argv[0], process.execPath]
|
|
3822
|
+
for (const r of roots) {
|
|
3823
|
+
if (!r) continue
|
|
3824
|
+
let dir = path.dirname(String(r))
|
|
3825
|
+
for (let up = 0; up < 8; up++) {
|
|
3826
|
+
try {
|
|
3827
|
+
const pj = path.join(dir, 'package.json')
|
|
3828
|
+
if (fs.existsSync(pj)) {
|
|
3829
|
+
const j = JSON.parse(fs.readFileSync(pj, 'utf8'))
|
|
3830
|
+
const nm = String((j && j.name) || '')
|
|
3831
|
+
if (nm === '@deepseek-ai/dsh' || nm === 'dsh' || nm === 'dsh-cli') {
|
|
3832
|
+
v = String((j && j.version) || '')
|
|
3833
|
+
if (v) break
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
} catch (err) {}
|
|
3837
|
+
const parent = path.dirname(dir)
|
|
3838
|
+
if (parent === dir) break
|
|
3839
|
+
dir = parent
|
|
3840
|
+
}
|
|
3841
|
+
if (v) break
|
|
3842
|
+
}
|
|
3843
|
+
} catch (err) {}
|
|
3844
|
+
}
|
|
3845
|
+
dshVersionCache = v || 'unknown'
|
|
3846
|
+
return dshVersionCache
|
|
3847
|
+
}
|
|
3848
|
+
// v795(用户反馈):客户端类型**看请求头的 UA**。前端用 `navigator.windowControlsOverlay` 判会误判 ——
|
|
3849
|
+
// Chrome 桌面浏览器也有这个 API,于是 `npx @deepseek-ai/dsh web` 起来也被认成"官方桌面客户端"。
|
|
3850
|
+
// 官方桌面客户端是 Electron ⇒ UA 里必有 `Electron/`。判定只回 kind(不回完整 UA,避免带出多余指纹)。
|
|
3851
|
+
function clientKindOf(req) {
|
|
3852
|
+
try {
|
|
3853
|
+
const ua = String((req && req.headers && (req.headers['user-agent'] || req.headers['User-Agent'])) || '')
|
|
3854
|
+
const electron = /electron/i.test(ua)
|
|
3855
|
+
return { kind: electron ? 'desktop' : 'web', via: electron ? 'ua-electron' : 'ua-browser' }
|
|
3856
|
+
} catch (err) { return { kind: 'unknown', via: 'error' } }
|
|
3857
|
+
}
|
|
3858
|
+
disposers.push(registerRoute({
|
|
3859
|
+
kind: 'exact',
|
|
3860
|
+
path: '/dsh-whale/about.json',
|
|
3861
|
+
handler: (req, res) => {
|
|
3862
|
+
try {
|
|
3863
|
+
res.writeHead(200, JSON_HEADERS)
|
|
3864
|
+
res.end(JSON.stringify(aboutPayload(req)))
|
|
3865
|
+
} catch (err) {
|
|
3866
|
+
res.writeHead(200, JSON_HEADERS)
|
|
3867
|
+
res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err).slice(0, 160) }))
|
|
3868
|
+
}
|
|
3869
|
+
},
|
|
3870
|
+
}))
|
|
3871
|
+
// 检查更新:**点了才联网**。⚠️ 实测教训(用户报的 "The operation was aborted due to timeout" 就是它):
|
|
3872
|
+
// ① 本机 Node 的 `fetch`(undici) 打 registry 会超时(npmmirror 6015ms / npmjs 6013ms 全挂),
|
|
3873
|
+
// 而 `https` 模块同一时刻 244ms / 891ms 就回来了 ⇒ **这里必须用 https,而不是 fetch**;
|
|
3874
|
+
// ② 顺序上**镜像优先**;v802 再补两个 CDN 源(jsdelivr / unpkg 直接返回 package.json,同样带 version),
|
|
3875
|
+
// 并把**所有失败源的原因一起报出来** —— 用户实测只看到最后一个源(`npmjs:超时`),看不出镜像也失败了。
|
|
3876
|
+
// v802:跟随重定向(unpkg 的 `@latest` 会 302 到具体版本路径;实测就是这么被误判成失败的),最多 3 跳。
|
|
3877
|
+
function httpsGetJson(url, timeoutMs, hop) {
|
|
3878
|
+
return new Promise((resolve, reject) => {
|
|
3879
|
+
let done = false
|
|
3880
|
+
const finish = (err, val) => { if (done) return; done = true; if (err) reject(err); else resolve(val) }
|
|
3881
|
+
try {
|
|
3882
|
+
const req = https.get(url, { timeout: timeoutMs, headers: { 'user-agent': 'dsh-whale-widget', accept: 'application/json' } }, (res) => {
|
|
3883
|
+
const code = res.statusCode
|
|
3884
|
+
const loc = res.headers && res.headers.location
|
|
3885
|
+
if (code >= 300 && code < 400 && loc && (hop || 0) < 3) {
|
|
3886
|
+
try { res.resume() } catch (e) {}
|
|
3887
|
+
let next = ''
|
|
3888
|
+
try { next = new URL(loc, url).toString() } catch (e) { next = '' }
|
|
3889
|
+
if (next) { httpsGetJson(next, timeoutMs, (hop || 0) + 1).then((v) => finish(null, v), (e) => finish(e)); return }
|
|
3890
|
+
}
|
|
3891
|
+
if (code !== 200) { try { res.resume() } catch (e) {} finish(new Error('HTTP ' + code)); return }
|
|
3892
|
+
let body = ''
|
|
3893
|
+
res.setEncoding('utf8')
|
|
3894
|
+
res.on('data', (c) => {
|
|
3895
|
+
body += c
|
|
3896
|
+
if (body.length > 200000) { try { req.destroy() } catch (e) {} finish(new Error('响应过大')) }
|
|
3897
|
+
})
|
|
3898
|
+
res.on('end', () => { try { finish(null, JSON.parse(body)) } catch (e) { finish(new Error('响应不是 JSON')) } })
|
|
3899
|
+
})
|
|
3900
|
+
req.on('timeout', () => { try { req.destroy() } catch (e) {} finish(new Error('超时')) })
|
|
3901
|
+
req.on('error', (e) => finish(e))
|
|
3902
|
+
} catch (err) { finish(err) }
|
|
3903
|
+
})
|
|
3904
|
+
}
|
|
3905
|
+
// v803:npm CLI 兜底源 —— 走 npm 自己的 registry/代理配置,在受限网络里往往比裸 https 更能出去
|
|
3906
|
+
// (实测本机 `npm view dsh-whale-widget version` 611~776ms 就回来了)。
|
|
3907
|
+
// ⚠️ 必须用**异步** exec:同步 spawnSync 会卡住宿主事件循环,把模型流一起拖住。
|
|
3908
|
+
function npmCliVersion(timeoutMs) {
|
|
3909
|
+
return new Promise((resolve, reject) => {
|
|
3910
|
+
try {
|
|
3911
|
+
exec('npm view dsh-whale-widget version', { timeout: timeoutMs, windowsHide: true }, (err, stdout) => {
|
|
3912
|
+
if (err) { reject(new Error('npm 退出异常')); return }
|
|
3913
|
+
const lines = String(stdout || '').trim().split(/\r?\n/).filter((x) => x.trim())
|
|
3914
|
+
const v = lines.length ? lines[lines.length - 1].trim().replace(/^v/, '') : ''
|
|
3915
|
+
if (!/^\d+\.\d+\.\d+/.test(v)) { reject(new Error('npm 输出里没有版本')); return }
|
|
3916
|
+
resolve(v)
|
|
3917
|
+
})
|
|
3918
|
+
} catch (err) { reject(err) }
|
|
3919
|
+
})
|
|
3920
|
+
}
|
|
3921
|
+
const UPDATE_SOURCES = [
|
|
3922
|
+
{ name: 'npmmirror', url: 'https://registry.npmmirror.com/dsh-whale-widget/latest' },
|
|
3923
|
+
{ name: 'jsdelivr', url: 'https://cdn.jsdelivr.net/npm/dsh-whale-widget@latest/package.json' },
|
|
3924
|
+
{ name: 'unpkg', url: 'https://unpkg.com/dsh-whale-widget@latest/package.json' },
|
|
3925
|
+
{ name: 'npmjs', url: 'https://registry.npmjs.org/dsh-whale-widget/latest' },
|
|
3926
|
+
{ name: 'npm-cli', cli: true },
|
|
3927
|
+
]
|
|
3928
|
+
let updateCheckCache = null
|
|
3929
|
+
disposers.push(registerRoute({
|
|
3930
|
+
kind: 'exact',
|
|
3931
|
+
path: '/dsh-whale/update-check.json',
|
|
3932
|
+
handler: async (req, res) => {
|
|
3933
|
+
const now = Date.now()
|
|
3934
|
+
try {
|
|
3935
|
+
if (updateCheckCache && now - updateCheckCache.at < 10 * 60 * 1000) {
|
|
3936
|
+
res.writeHead(200, JSON_HEADERS)
|
|
3937
|
+
res.end(JSON.stringify(updateCheckCache.payload))
|
|
3938
|
+
return
|
|
3939
|
+
}
|
|
3940
|
+
} catch (err) {}
|
|
3941
|
+
let payload = null
|
|
3942
|
+
// v802:四个源**并行**试(单个源实测很不稳:npmmirror/jsdelivr 会超时、unpkg 会 302),
|
|
3943
|
+
// 并行后最坏也就 5 秒(串行会是 20 秒),谁先成功用谁,全失败就把每个源的原因都列出来。
|
|
3944
|
+
const results = await Promise.all(UPDATE_SOURCES.map(async (src) => {
|
|
3945
|
+
// v803:失败得**很快**(<1.5s,多为 DNS/连接抖动)就再试一次;超时那种就不重试了(不然最坏翻倍)
|
|
3946
|
+
const attempt = async () => {
|
|
3947
|
+
const t0 = Date.now()
|
|
3948
|
+
if (src.cli) return { v: await npmCliVersion(8000), ms: Date.now() - t0 }
|
|
3949
|
+
const j = await httpsGetJson(src.url, 5000, 0)
|
|
3950
|
+
const latest = String((j && j.version) || '')
|
|
3951
|
+
if (!latest) throw new Error('返回里没有 version')
|
|
3952
|
+
return { v: latest, ms: Date.now() - t0 }
|
|
3953
|
+
}
|
|
3954
|
+
try {
|
|
3955
|
+
const a = await attempt()
|
|
3956
|
+
return { ok: true, name: src.name, latest: a.v }
|
|
3957
|
+
} catch (err1) {
|
|
3958
|
+
// 只在"失败得很快"(<1.5s,多为连接抖动)时重试一次;超时那种不重试,避免最坏耗时翻倍
|
|
3959
|
+
const fast = !(err1 && /超时/.test(String(err1.message || '')))
|
|
3960
|
+
if (!fast) return { ok: false, name: src.name, why: String((err1 && err1.message) || err1).slice(0, 40) }
|
|
3961
|
+
try {
|
|
3962
|
+
const a2 = await attempt()
|
|
3963
|
+
return { ok: true, name: src.name, latest: a2.v }
|
|
3964
|
+
} catch (err2) {
|
|
3965
|
+
return { ok: false, name: src.name, why: String((err2 && err2.message) || err2).slice(0, 40) }
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
}))
|
|
3969
|
+
const hit = results.filter((r) => r.ok)[0]
|
|
3970
|
+
if (hit) {
|
|
3971
|
+
payload = {
|
|
3972
|
+
ok: true,
|
|
3973
|
+
current: BUILD_VERSION,
|
|
3974
|
+
channel: BUILD_CHANNEL,
|
|
3975
|
+
latest: hit.latest,
|
|
3976
|
+
hasUpdate: hit.latest !== BUILD_VERSION,
|
|
3977
|
+
source: hit.name,
|
|
3978
|
+
}
|
|
3979
|
+
} else {
|
|
3980
|
+
// 全失败 ⇒ 把**每一个源**的原因都列出来(只报最后一个会让人以为只有 npmjs 挂了)
|
|
3981
|
+
payload = {
|
|
3982
|
+
ok: false,
|
|
3983
|
+
current: BUILD_VERSION,
|
|
3984
|
+
error: results.map((r) => r.name + ':' + r.why).join(';'),
|
|
3985
|
+
tried: UPDATE_SOURCES.map((s) => s.name),
|
|
3986
|
+
}
|
|
3987
|
+
}
|
|
3988
|
+
try { updateCheckCache = { at: now, payload: payload } } catch (err) {}
|
|
3989
|
+
res.writeHead(200, JSON_HEADERS)
|
|
3990
|
+
res.end(JSON.stringify(payload))
|
|
3991
|
+
},
|
|
3992
|
+
}))
|
|
3203
3993
|
disposers.push(registerRoute({
|
|
3204
3994
|
kind: 'exact',
|
|
3205
3995
|
path: '/dsh-whale/usage-records.json',
|
|
@@ -3605,10 +4395,10 @@ export default {
|
|
|
3605
4395
|
},
|
|
3606
4396
|
}))
|
|
3607
4397
|
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
4398
|
+
// v860(issue #226):路由**不再以 .wav 结尾** —— 带后缀的 URL 会被部分环境(下载器/扩展/反代)
|
|
4399
|
+
// 当成"下载文件"劫持,导致试听与任务结束音无声。Content-Type 由 fragmentMime() 决定,
|
|
4400
|
+
// 与 URL 后缀无关 ⇒ 改成无后缀路径,同时**保留旧路径作为兼容别名**(老客户端仍能用)。
|
|
4401
|
+
const audioFragmentHandler = (req, res) => {
|
|
3612
4402
|
try {
|
|
3613
4403
|
const id = audioIdFromUrl(req.url)
|
|
3614
4404
|
const bytes = loadAudioFragmentBytes(id)
|
|
@@ -3626,8 +4416,9 @@ export default {
|
|
|
3626
4416
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
3627
4417
|
res.end('audio fragment unavailable')
|
|
3628
4418
|
}
|
|
3629
|
-
}
|
|
3630
|
-
}))
|
|
4419
|
+
}
|
|
4420
|
+
disposers.push(registerRoute({ kind: 'exact', path: '/dsh-whale/audio-fragment', handler: audioFragmentHandler }))
|
|
4421
|
+
disposers.push(registerRoute({ kind: 'exact', path: '/dsh-whale/audio-fragment.wav', handler: audioFragmentHandler }))
|
|
3631
4422
|
|
|
3632
4423
|
// 片段字节对应的 MIME:内置/预设片段按声明,自定义片段一律 wav
|
|
3633
4424
|
function fragmentMime(fragId) {
|
|
@@ -3779,6 +4570,169 @@ export default {
|
|
|
3779
4570
|
}
|
|
3780
4571
|
return null
|
|
3781
4572
|
}
|
|
4573
|
+
// ===== v815:自定义泡泡框 —— 宿主侧净化 =====
|
|
4574
|
+
// 用户可以在"泡泡框"里写 SVG 代码 ⇒ 这些片段最终会进 innerHTML ⇒ **必须白名单化**:
|
|
4575
|
+
// · 只放行无脚本、无外链的几何标签;
|
|
4576
|
+
// · 标签属性只放行几何/描边类;
|
|
4577
|
+
// · 直接删掉 <script>/<foreignObject>/on* 事件/href(javascript:)/style 里的 url()、expression。
|
|
4578
|
+
// 探针 `_v815-frame-lib-check.mjs` 带一批负样本(见 §验证),改这里必须同步那些断言。
|
|
4579
|
+
const FRAME_SVG_TAGS = ['path', 'ellipse', 'circle', 'rect', 'polygon', 'polyline', 'g']
|
|
4580
|
+
// v828:补 `r`(`<circle>` 的半径)—— 标签在白名单里却漏了属性,用户写 `<circle r="30"/>` 会被剥成画不出来
|
|
4581
|
+
const FRAME_SVG_ATTRS = ['d', 'points', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'width', 'height', 'fill', 'stroke',
|
|
4582
|
+
'stroke-width', 'stroke-dasharray', 'stroke-linejoin', 'stroke-linecap', 'transform', 'opacity', 'fill-opacity', 'stroke-opacity']
|
|
4583
|
+
function frameColorOk(v, def) {
|
|
4584
|
+
const s = String(v == null ? '' : v).trim()
|
|
4585
|
+
if (/^#[0-9a-fA-F]{3}$/.test(s) || /^#[0-9a-fA-F]{6}$/.test(s)) return s
|
|
4586
|
+
if (/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+)\s*)?\)$/.test(s)) return s
|
|
4587
|
+
if (s === 'transparent' || s === 'none') return s
|
|
4588
|
+
return def
|
|
4589
|
+
}
|
|
4590
|
+
function frameNumIn(v, def, min, max) {
|
|
4591
|
+
const n = Number(v)
|
|
4592
|
+
let x = isFinite(n) ? n : def
|
|
4593
|
+
if (typeof min === 'number' && x < min) x = min
|
|
4594
|
+
if (typeof max === 'number' && x > max) x = max
|
|
4595
|
+
return Math.round(x * 1000) / 1000
|
|
4596
|
+
}
|
|
4597
|
+
function frameSvgSanitize(code) {
|
|
4598
|
+
let s = String(code == null ? '' : code).slice(0, 8000)
|
|
4599
|
+
if (!s) return ''
|
|
4600
|
+
// 整块危险元素直接删(含内容)
|
|
4601
|
+
s = s.replace(/<\s*(script|foreignObject|style|iframe|image|use|animate|set)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
|
|
4602
|
+
s = s.replace(/<\s*(script|foreignObject|style|iframe|image|use|animate|set)\b[^>]*\/?>/gi, '')
|
|
4603
|
+
// 只保留白名单标签;其它标签(含自闭合)整体删除
|
|
4604
|
+
s = s.replace(/<\s*\/?\s*([A-Za-z][\w:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)\/?>/g, (m0, tag, attrs) => {
|
|
4605
|
+
const t = String(tag)
|
|
4606
|
+
if (FRAME_SVG_TAGS.indexOf(t) < 0) return ''
|
|
4607
|
+
const closing = /^<\s*\//.test(m0)
|
|
4608
|
+
// v823(P2):**保住自闭合的斜杠**。P0 首版一律重拼成 `<tag …>`,而内联 SVG 里 `<path>` 不是空元素
|
|
4609
|
+
// ⇒ HTML 解析器会把后面的兄弟塞进它里面(`<path a/><path b/>` → path a > path b,子节点不渲染)
|
|
4610
|
+
// ⇒ 用户写的多形状自定义 SVG 只有第一个能显示。P2 才引入"自定义 SVG 代码",所以坑到这时才暴露。
|
|
4611
|
+
const selfClose = /\/\s*>$/.test(m0)
|
|
4612
|
+
let out = '<' + (closing ? '/' : '') + t
|
|
4613
|
+
if (!closing) {
|
|
4614
|
+
const re = /([A-Za-z][\w:-]*)\s*=\s*("([^"]*)"|'([^']*)')/g
|
|
4615
|
+
let m
|
|
4616
|
+
let kept = 0
|
|
4617
|
+
while ((m = re.exec(attrs))) {
|
|
4618
|
+
const name = String(m[1]).toLowerCase()
|
|
4619
|
+
const val = String(m[3] !== undefined ? m[3] : m[4])
|
|
4620
|
+
if (FRAME_SVG_ATTRS.indexOf(name) < 0) continue
|
|
4621
|
+
// 值里也不许出现 javascript:/url(/expression
|
|
4622
|
+
if (/javascript:|url\s*\(|expression\s*\(/i.test(val)) continue
|
|
4623
|
+
if (/[<>]/.test(val)) continue
|
|
4624
|
+
out += ' ' + name + '="' + val.replace(/"/g, '"') + '"'
|
|
4625
|
+
kept++
|
|
4626
|
+
}
|
|
4627
|
+
void kept
|
|
4628
|
+
}
|
|
4629
|
+
return out + ((selfClose && !closing) ? '/>' : '>')
|
|
4630
|
+
})
|
|
4631
|
+
// v818(自检发现):再**清掉标签之外的所有裸文本**。为什么必须做:`<script>alert(1)`(未闭合)这类输入,
|
|
4632
|
+
// 块删除与标签白名单都只处理"标签",会留下裸文本 `alert(1)` —— 它在 SVG 里是惰性文本、不会执行,
|
|
4633
|
+
// 但把与形状无关的文字塞进 innerHTML 没有意义,也少留一条"文本被解释"的可能性。
|
|
4634
|
+
// 属性值里不可能含 < >(上面的属性白名单已拒),所以按 <…> 切分是安全的。
|
|
4635
|
+
s = s.replace(/[^<>]+(?=<|$)/g, '')
|
|
4636
|
+
return s
|
|
4637
|
+
}
|
|
4638
|
+
function frameSanitizeOne(raw, idx) {
|
|
4639
|
+
const s = raw && typeof raw === 'object' ? raw : {}
|
|
4640
|
+
let id = String(s.id == null ? '' : s.id).trim().slice(0, 64)
|
|
4641
|
+
if (!id) id = 'frame_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6)
|
|
4642
|
+
const slotsIn = s.slots && typeof s.slots === 'object' ? s.slots : {}
|
|
4643
|
+
function slot(kind) {
|
|
4644
|
+
const d = slotsIn[kind] && typeof slotsIn[kind] === 'object' ? slotsIn[kind] : {}
|
|
4645
|
+
const paint = d.paint && typeof d.paint === 'object' ? d.paint : {}
|
|
4646
|
+
const out = {
|
|
4647
|
+
dx: frameNumIn(d.dx, 0, -1026, 1026),
|
|
4648
|
+
dy: frameNumIn(d.dy, 0, -700, 700),
|
|
4649
|
+
scale: frameNumIn(d.scale, 1, 0.2, 4),
|
|
4650
|
+
kind: d.kind === 'image' ? 'image' : 'svg',
|
|
4651
|
+
svgPreset: ['classic', 'roundrect', 'zigzag', 'custom'].indexOf(String(d.svgPreset)) >= 0 ? String(d.svgPreset) : 'classic',
|
|
4652
|
+
svgCode: frameSvgSanitize(d.svgCode),
|
|
4653
|
+
imgId: String(d.imgId == null ? '' : d.imgId).slice(0, 64),
|
|
4654
|
+
imgScale: frameNumIn(d.imgScale, 1, 0.1, 6),
|
|
4655
|
+
imgOpacity: frameNumIn(d.imgOpacity, 1, 0, 1),
|
|
4656
|
+
paint: {
|
|
4657
|
+
bgOn: paint.bgOn === undefined ? true : paint.bgOn !== false,
|
|
4658
|
+
bg: frameColorOk(paint.bg, '#FFFFFF'),
|
|
4659
|
+
bgAlpha: frameNumIn(paint.bgAlpha, 1, 0, 1),
|
|
4660
|
+
lineOn: paint.lineOn === undefined ? true : paint.lineOn !== false,
|
|
4661
|
+
line: frameColorOk(paint.line, '#203170'),
|
|
4662
|
+
lineW: frameNumIn(paint.lineW, 18, 0, 120),
|
|
4663
|
+
lineAlpha: frameNumIn(paint.lineAlpha, 1, 0, 1),
|
|
4664
|
+
dashOn: paint.dashOn === true,
|
|
4665
|
+
dash: /^[0-9][0-9.,\s]*$/.test(String(paint.dash == null ? '' : paint.dash).trim()) ? String(paint.dash).trim().slice(0, 40) : '0',
|
|
4666
|
+
cap: ['butt', 'square', 'round'].indexOf(String(paint.cap)) >= 0 ? String(paint.cap) : 'round',
|
|
4667
|
+
join: ['miter', 'bevel', 'round'].indexOf(String(paint.join)) >= 0 ? String(paint.join) : 'round',
|
|
4668
|
+
},
|
|
4669
|
+
}
|
|
4670
|
+
if (kind === 'small1' || kind === 'small2') out.on = d.on === undefined ? true : d.on !== false
|
|
4671
|
+
return out
|
|
4672
|
+
}
|
|
4673
|
+
const safe = s.safe && typeof s.safe === 'object' ? s.safe : {}
|
|
4674
|
+
const w = frameNumIn(safe.w, 646, 40, 1026)
|
|
4675
|
+
const h = frameNumIn(safe.h, 400, 40, 700)
|
|
4676
|
+
return {
|
|
4677
|
+
id: id,
|
|
4678
|
+
name: String(s.name == null ? '' : s.name).trim().slice(0, 24) || ('泡泡框' + (idx + 1)),
|
|
4679
|
+
builtin: s.builtin === true,
|
|
4680
|
+
pinned: Math.round(frameNumIn(s.pinned, 0, 0, 9999)),
|
|
4681
|
+
createdAt: Math.round(frameNumIn(s.createdAt, Date.now(), 0, 1e15)),
|
|
4682
|
+
textColor: frameColorOk(s.textColor, ''),
|
|
4683
|
+
safe: { x: frameNumIn(safe.x, 190, 0, 1026 - w), y: frameNumIn(safe.y, 90, 0, 700 - h), w: w, h: h },
|
|
4684
|
+
slots: { big: slot('big'), small1: slot('small1'), small2: slot('small2') },
|
|
4685
|
+
}
|
|
4686
|
+
}
|
|
4687
|
+
// 整份框库净化:内置框强制只读(内容还原由前端做;宿主这里只保证"不许被改成非内置")
|
|
4688
|
+
function framesSanitize(list, defId) {
|
|
4689
|
+
const arr = Array.isArray(list) ? list.slice(0, 50) : []
|
|
4690
|
+
const out = []
|
|
4691
|
+
for (let i = 0; i < arr.length; i++) {
|
|
4692
|
+
const f = frameSanitizeOne(arr[i], i)
|
|
4693
|
+
if (f.id === 'frame_classic') { f.builtin = true; f.name = '经典(内置)'; continue } // 内置框不进库(前端会补)
|
|
4694
|
+
out.push(f)
|
|
4695
|
+
}
|
|
4696
|
+
let def = String(defId == null ? '' : defId).slice(0, 64)
|
|
4697
|
+
if (!out.some((x) => x.id === def)) def = 'frame_classic'
|
|
4698
|
+
return { frames: out, defaultFrameId: def }
|
|
4699
|
+
}
|
|
4700
|
+
// items 里的"框引用 / 外观覆盖":只保留已知形状的字段(逐字段白名单)
|
|
4701
|
+
function itemsSanitizeFrames(items) {
|
|
4702
|
+
const list = Array.isArray(items) ? items : []
|
|
4703
|
+
const cleanOne = (obj) => {
|
|
4704
|
+
if (!obj || typeof obj !== 'object') return obj
|
|
4705
|
+
const out = obj
|
|
4706
|
+
if (typeof out.frameId === 'string') out.frameId = out.frameId.slice(0, 64)
|
|
4707
|
+
else delete out.frameId
|
|
4708
|
+
const ov = out.frameOverride
|
|
4709
|
+
if (ov && typeof ov === 'object') {
|
|
4710
|
+
const keep = {}
|
|
4711
|
+
for (const slot of ['big', 'small1', 'small2']) {
|
|
4712
|
+
const raw = ov[slot]
|
|
4713
|
+
if (!raw || typeof raw !== 'object') continue
|
|
4714
|
+
const p = {}
|
|
4715
|
+
for (const k of ['bgOn', 'bg', 'bgAlpha', 'lineOn', 'line', 'lineW', 'lineAlpha', 'dashOn', 'dash', 'cap', 'join']) {
|
|
4716
|
+
if (Object.prototype.hasOwnProperty.call(raw, k)) p[k] = raw[k]
|
|
4717
|
+
}
|
|
4718
|
+
// 颜色/数值仍然过一遍校验,避免脏值写进配置
|
|
4719
|
+
const norm = frameSanitizeOne({ slots: { [slot]: { paint: p } } }, 0).slots[slot].paint
|
|
4720
|
+
const only = {}
|
|
4721
|
+
for (const k of Object.keys(p)) if (Object.prototype.hasOwnProperty.call(norm, k)) only[k] = norm[k]
|
|
4722
|
+
keep[slot] = only
|
|
4723
|
+
}
|
|
4724
|
+
if (Object.keys(keep).length) out.frameOverride = keep
|
|
4725
|
+
else delete out.frameOverride
|
|
4726
|
+
} else delete out.frameOverride
|
|
4727
|
+
return out
|
|
4728
|
+
}
|
|
4729
|
+
for (const it of list) {
|
|
4730
|
+
if (!it || typeof it !== 'object') continue
|
|
4731
|
+
cleanOne(it)
|
|
4732
|
+
if (it.kind === 'choice' && Array.isArray(it.options)) for (const o of it.options) { if (o) { cleanOne(o); if (o.item) cleanOne(o.item) } }
|
|
4733
|
+
}
|
|
4734
|
+
return list
|
|
4735
|
+
}
|
|
3782
4736
|
function writeBubbleConfig(cfg) {
|
|
3783
4737
|
const body = JSON.stringify(cfg, null, 2)
|
|
3784
4738
|
for (const p of BUBBLE_FILE_CANDIDATES) {
|
|
@@ -3801,7 +4755,15 @@ export default {
|
|
|
3801
4755
|
return
|
|
3802
4756
|
}
|
|
3803
4757
|
// v727:tapAdvance =「点按角色推进泡泡队列」(只存布尔值,不放行其它字段)
|
|
3804
|
-
|
|
4758
|
+
// v815:泡泡框(frames/defaultFrameId)与 items 上的框引用/外观覆盖走净化后落盘。
|
|
4759
|
+
// 没带 frames 的老客户端 ⇒ 不写这两个键(老配置零 churn)。
|
|
4760
|
+
const itemsClean = itemsSanitizeFrames(parsed.items)
|
|
4761
|
+
const cfg = { v: 1, items: itemsClean, lib: parsed.lib, tapAdvance: parsed.tapAdvance === true }
|
|
4762
|
+
if (Array.isArray(parsed.frames)) {
|
|
4763
|
+
const fr = framesSanitize(parsed.frames, parsed.defaultFrameId)
|
|
4764
|
+
cfg.frames = fr.frames
|
|
4765
|
+
cfg.defaultFrameId = fr.defaultFrameId
|
|
4766
|
+
}
|
|
3805
4767
|
writeBubbleConfig(cfg)
|
|
3806
4768
|
res.writeHead(200, JSON_HEADERS)
|
|
3807
4769
|
res.end(JSON.stringify({ ok: true, config: cfg }))
|