dsh-whale-widget 0.3.16 → 0.3.18

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/lib/index.js CHANGED
@@ -28,9 +28,20 @@ const IMAGE_CANDIDATES = [
28
28
  path.join(PACKAGE_ROOT, 'assets', 'DSniang02.png'),
29
29
  ]
30
30
  // 自定义角色图片存放目录(第一个可写的会被使用;roles.json 存角色元数据)
31
+ // ⭐ 2026-10-02(用户要求"并成一份"):**用户数据的候选表一律 $DSH_HOME 优先**,开发机路径降级为兜底。
32
+ // 原因:桌面客户端加载的是 strip 过的发布副本(只认 $DSH_HOME),而开发版宿主原先把开发机路径排在第一位
33
+ // ⇒ 本机上网页端与桌面端**各写各的**(实测两端同时各写一份 .dshw-usage.json / .dshw-bubble.json),
34
+ // 互相看不到对方的改动 —— 表现就是"删掉的模块又冒出来""记账数对不上"。
35
+ // ⚠️ 这个常量必须在**所有**用到它的候选表之前定义(const 有 TDZ),所以放在最前面。
36
+ // ⚠️ `_strip-dev-paths.mjs` 会把发布副本里含开发机路径的行删掉,发布版只剩前两条 $DSH_HOME ✓
37
+ const DATA_CANDIDATES_TAIL = [
38
+ ]
39
+ // ⭐ 2026-10-02(用户要求"并成一份"):**角色图库目录也 $DSH_HOME 优先**(开发机路径降级为兜底)。
31
40
  const ROLE_DIR_CANDIDATES = [
32
41
  path.join(DSH_HOME, 'whale-roles'),
33
42
  path.join(DSH_HOME, 'profiles', 'web', 'whale-roles'),
43
+ DATA_CANDIDATES_TAIL[0] + 'whale-roles/',
44
+ DATA_CANDIDATES_TAIL[1] + 'whale-roles/',
34
45
  ]
35
46
  const ROLE_INDEX_NAME = 'roles.json'
36
47
  const ROLE_DEFAULT_ID = 'default'
@@ -38,6 +49,8 @@ const ROLE_DEFAULT_ID = 'default'
38
49
  const AUDIO_DIR_CANDIDATES = [
39
50
  path.join(DSH_HOME, 'whale-audio'),
40
51
  path.join(DSH_HOME, 'profiles', 'web', 'whale-audio'),
52
+ DATA_CANDIDATES_TAIL[0] + 'whale-audio/',
53
+ DATA_CANDIDATES_TAIL[1] + 'whale-audio/',
41
54
  ]
42
55
  const AUDIO_INDEX_NAME = 'audio.json'
43
56
  // 内置预设音效组(不可删、不可改),对应 assets 里的 Ya/D 系列
@@ -70,9 +83,12 @@ const BUILTIN_FRAGMENT_FILES = {
70
83
  }
71
84
  // —— 自定义 API 模型(v655):非 DeepSeek 厂商的余额 / 用量 / 提醒 ——
72
85
  // 注册表落在 $DSH_HOME/.dshw-api.json;密钥走 DSH 官方凭据(ctx.credentials),本文件不存明文。
86
+ // ⭐ 2026-10-02(用户要求"并成一份"):$DSH_HOME 优先(自定义 API 模型清单也属于用户数据,原先两端各存一份)。
73
87
  const API_FILE_CANDIDATES = [
74
88
  path.join(DSH_HOME, '.dshw-api.json'),
75
89
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-api.json'),
90
+ DATA_CANDIDATES_TAIL[0] + '.dshw-api.json',
91
+ DATA_CANDIDATES_TAIL[1] + '.dshw-api.json',
76
92
  ]
77
93
  const API_BUILTIN_ID = 'deepseek'
78
94
  // 「充值 / 余额校正」只属于固定的 DeepSeek(内置):模型条目下发 canAdjustBalance,路由层再校验一次。
@@ -163,7 +179,17 @@ const API_TEMPLATES = {
163
179
  quota: {
164
180
  url: 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
165
181
  auth: '{key}', // 智谱此接口不带 Bearer
166
- json: { percent: 'data.limits[0].TOKENS_LIMIT.percentage', resetAt: 'data.limits[0].nextResetTime', level: 'data.level' },
182
+ // v789(issue #180/#181 修):`data.limits[]` 是扁平条目数组,`TOKENS_LIMIT` 是条目的 `type` **值**,
183
+ // 且 5h 窗口不保证排在 [0](pro 账号 [0] 是 TIME_LIMIT、max 账号 [0] 才是 TOKENS_LIMIT)
184
+ // ⇒ 一律按 type(+unit) 选条目:unit=3 = 5 小时窗口、unit=6 = 周窗口;
185
+ // 新注册/credit 制账号的 type 是 CREDIT_LIMIT ⇒ 一并兼容。
186
+ json: {
187
+ level: 'data.level',
188
+ windows: [
189
+ { key: 'rolling', label: '5h', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 3 }, percent: 'percentage', resetAt: 'nextResetTime' },
190
+ { key: 'weekly', label: '周', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 6 }, percent: 'percentage', resetAt: 'nextResetTime' },
191
+ ],
192
+ },
167
193
  },
168
194
  probeUrl: 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
169
195
  },
@@ -346,7 +372,14 @@ const API_TEMPLATES = {
346
372
  quota: {
347
373
  url: 'https://api.z.ai/api/monitor/usage/quota/limit',
348
374
  auth: '{key}', // 与国内站一样不带 Bearer
349
- json: { percent: 'data.limits[0].TOKENS_LIMIT.percentage', resetAt: 'data.limits[0].nextResetTime', level: 'data.level' },
375
+ // v789:与国内站同一套 `data.limits[]` 结构(见 zhipu_glm_coding 的注释),一并按 type+unit 选条目
376
+ json: {
377
+ level: 'data.level',
378
+ windows: [
379
+ { key: 'rolling', label: '5h', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 3 }, percent: 'percentage', resetAt: 'nextResetTime' },
380
+ { key: 'weekly', label: '周', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 6 }, percent: 'percentage', resetAt: 'nextResetTime' },
381
+ ],
382
+ },
350
383
  },
351
384
  probeUrl: 'https://api.z.ai/api/monitor/usage/quota/limit',
352
385
  },
@@ -365,20 +398,28 @@ const API_TEMPLATES = {
365
398
  },
366
399
  }
367
400
 
401
+ // ⭐ 2026-10-02(用户要求"并成一份"):这些**用户数据文件一律 $DSH_HOME 优先**,开发机路径降级为只读兜底。
402
+ // 原因见上方 AUDIO_DIR_CANDIDATES 的说明(桌面端只认 $DSH_HOME,两端原先各写各的)。
368
403
  // Size memory file: prefer writable DSH home locations, then legacy fallbacks.
369
404
  const SIZE_FILE_CANDIDATES = [
370
405
  path.join(DSH_HOME, '.dshw-size.json'),
371
406
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-size.json'),
407
+ DATA_CANDIDATES_TAIL[0] + '.dshw-size.json',
408
+ DATA_CANDIDATES_TAIL[1] + '.dshw-size.json',
372
409
  ]
373
410
  // Usage ledger file (小鲸鱼记账 mode): same policy as the size file.
374
411
  const USAGE_FILE_CANDIDATES = [
375
412
  path.join(DSH_HOME, '.dshw-usage.json'),
376
413
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-usage.json'),
414
+ DATA_CANDIDATES_TAIL[0] + '.dshw-usage.json',
415
+ DATA_CANDIDATES_TAIL[1] + '.dshw-usage.json',
377
416
  ]
378
417
  // 每轮消耗 seq 持久化文件:避免插件热重载后 seq 归零导致页面把新轮次误判为旧轮次吞掉
379
418
  const TURN_FILE_CANDIDATES = [
380
419
  path.join(DSH_HOME, '.dshw-turn.json'),
381
420
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-turn.json'),
421
+ DATA_CANDIDATES_TAIL[0] + '.dshw-turn.json',
422
+ DATA_CANDIDATES_TAIL[1] + '.dshw-turn.json',
382
423
  ]
383
424
  // Sound assets: package-relative first (ship Ya1/Ya2/D1/D2.mp3 in assets/),
384
425
  // legacy absolute paths as fallback.
@@ -387,6 +428,8 @@ const BUBBLE_FILE_CANDIDATES = [
387
428
  // 否则清完这个数组会变成空的:泡泡配置读不了也存不了(自定义泡泡整体失效)。
388
429
  path.join(DSH_HOME, '.dshw-bubble.json'),
389
430
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-bubble.json'),
431
+ DATA_CANDIDATES_TAIL[0] + '.dshw-bubble.json',
432
+ DATA_CANDIDATES_TAIL[1] + '.dshw-bubble.json',
390
433
  ]
391
434
  // 泡泡图库(独立于角色图库):图片文件目录 + 索引
392
435
  const BUBBLE_IMG_DIR_CANDIDATES = [
@@ -394,6 +437,8 @@ const BUBBLE_IMG_DIR_CANDIDATES = [
394
437
  // 空数组会让图库上传直接报错。
395
438
  path.join(DSH_HOME, 'whale-bubble-imgs'),
396
439
  path.join(DSH_HOME, 'profiles', 'web', 'whale-bubble-imgs'),
440
+ DATA_CANDIDATES_TAIL[0] + 'whale-bubble-imgs/',
441
+ DATA_CANDIDATES_TAIL[1] + 'whale-bubble-imgs/',
397
442
  ]
398
443
  const BUBBLE_IMG_INDEX_NAME = 'bubble-imgs.json'
399
444
  // 内置默认泡泡图(语义 id → assets 文件):面板常驻可选;全新安装无用户图库时
@@ -430,6 +475,8 @@ function soundSetFromUrl(url) {
430
475
  }
431
476
  const BALANCE_URL = 'https://api.deepseek.com/user/balance'
432
477
  const BALANCE_TTL_MS = 25000
478
+ // v781:账号登录路的兜底超时(毫秒)。DSH 账号服务自身默认 30s,太长会把前端 FETCH_TIMEOUT_MS(25s) 先耗掉。
479
+ const ACCOUNT_FALLBACK_MS = 5000
433
480
  const RUA_GIF_CANDIDATES = [
434
481
  path.join(PACKAGE_ROOT, 'assets', 'rua.gif'),
435
482
  ]
@@ -1017,10 +1064,13 @@ export default {
1017
1064
  // ⚠️ 必须用 `ctx.get('deepseekAccount')` 读**可选服务**,**绝不能写进 inject** —— 老宿主根本没有这个服务,
1018
1065
  // 一旦进 inject,整个插件会永远等不到服务而**完全不 apply**(比没有这个功能严重得多)。
1019
1066
  // ⚠️ 与 API key 路径**互不干扰**(API key 永远优先;DSH 自己的注释也是 "Neither route falls back to the other")。
1067
+ // v781:账号余额路的失败原因(只用于错误文案,不参与任何判定)。fetchAccountBalance 每次开头清空。
1068
+ let accountDiag = ''
1020
1069
  async function fetchAccountBalance() {
1021
1070
  let account = null
1071
+ accountDiag = ''
1022
1072
  try { account = typeof ctx.get === 'function' ? ctx.get('deepseekAccount') : null } catch (err) { account = null }
1023
- if (!account || typeof account.getBalance !== 'function') return null
1073
+ if (!account || typeof account.getBalance !== 'function') { accountDiag = '当前 DSH 没有账号服务'; return null }
1024
1074
  // 账户标识:优先用账号服务给的稳定 id(不同账号 → 不同账本 scope);取不到就退回固定标识。
1025
1075
  // 一律 sha256[:24] 十六进制 —— accounting.mjs 校验 scope 必须匹配 /^[a-zA-Z0-9_-]{1,80}$/
1026
1076
  // (带冒号这种可读写法会直接抛「账户标识无效」,见 issue #157 报告人踩的坑 1)。
@@ -1036,25 +1086,52 @@ export default {
1036
1086
  locale: String(process.env.DSH_LOCALE || '') || 'zh_CN',
1037
1087
  timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60, // 东为正的整秒
1038
1088
  })
1039
- } catch (err) { return null }
1089
+ } catch (err) {
1090
+ accountDiag = '账号余额接口调用失败: ' + String((err && err.message) || err).slice(0, 120)
1091
+ return null
1092
+ }
1040
1093
  // 未登录 / 没有 grant / 平台失败 → 一律返回 null,让调用方退回原来的文案(**不缓存错误**)
1041
- if (!result || result.status !== 'ready' || !Array.isArray(result.value) || result.value.length === 0) return null
1094
+ // v781:失败时记下**原因**(accountDiag),供 fetchBalance 把两条路的原因一起报给界面 ——
1095
+ // 以前只有一个笼统的"账号余额不可用",用户/支持方分不清"没登录"和"接口失败"。
1096
+ if (!result) { accountDiag = '未登录 DeepSeek 账号(没有账号凭据)'; return null }
1097
+ if (result.status !== 'ready') { accountDiag = '账号余额接口未就绪(' + String(result.status || 'unknown') + ')'; return null }
1042
1098
  const accNum = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null }
1043
- const wallets = result.value.filter((w) => w && accNum(w.balance) !== null)
1044
- if (wallets.length === 0) return null
1045
- const currency = wallets.some((w) => String(w.currency || '').toUpperCase() === 'CNY')
1046
- ? 'CNY'
1047
- : String((wallets[0] && wallets[0].currency) || 'CNY').toUpperCase()
1048
- const sumOf = (list) => (Array.isArray(list) ? list : [])
1049
- .filter((w) => w && String(w.currency || 'CNY').toUpperCase() === currency && accNum(w.balance) !== null)
1099
+ // v781:**"充值钱包为空、只有赠金"必须算成功** —— 这是"只登录客户端读不到余额"的主因。
1100
+ // 平台把钱包拆成 `normal_wallets`(充值)与 `bonus_wallets`(赠金);新用户/没充过值的用户
1101
+ // 常常**只有赠金**(甚至 normal_wallets 是空数组)⇒ 旧代码要求 `result.value` 非空,
1102
+ // 直接把这类账号判成"没有余额钱包",界面就只显示"读取不到余额"。
1103
+ // 判据对齐 DSH 自己的账号卡片(dsh-client-ui-settings-account):
1104
+ // `[...balance.value, ...balance.bonusWallets].some(w => new Big(w.balance).gt(0))`
1105
+ // —— 两个钱包一起看,不再单看充值钱包。
1106
+ const normRaw = Array.isArray(result.value) ? result.value : []
1107
+ const bonusRaw = Array.isArray(result.bonusWallets) ? result.bonusWallets : []
1108
+ const usable = (list) => list.filter((w) => w && accNum(w.balance) !== null)
1109
+ const normWallets = usable(normRaw)
1110
+ const bonusWallets = usable(bonusRaw)
1111
+ const allWallets = normWallets.concat(bonusWallets)
1112
+ if (allWallets.length === 0) {
1113
+ // 两边都没有可用钱包 = 真的没有数据(未激活/账号没有任何钱包),不是"余额 0"
1114
+ accountDiag = normRaw.length || bonusRaw.length ? '钱包字段不可用' : '账号没有余额钱包'
1115
+ return null
1116
+ }
1117
+ // 币种:优先「有余额的那个钱包」的币种(CNY 优先),其次任何 CNY 钱包,最后第一个
1118
+ const curOf = (w) => String((w && w.currency) || 'CNY').toUpperCase()
1119
+ const nonzero = allWallets.filter((w) => Number(w.balance) > 0)
1120
+ const curPick = nonzero.find((w) => curOf(w) === 'CNY') || nonzero[0]
1121
+ || allWallets.find((w) => curOf(w) === 'CNY') || allWallets[0]
1122
+ const currency = curOf(curPick)
1123
+ const sumOf = (list) => list
1124
+ .filter((w) => w && curOf(w) === currency && accNum(w.balance) !== null)
1050
1125
  .reduce((s, w) => s + Number(w.balance), 0)
1051
- const recharge = sumOf(result.value)
1052
- const bonus = sumOf(result.bonusWallets)
1126
+ const recharge = sumOf(normWallets)
1127
+ const bonus = sumOf(bonusWallets)
1128
+ accountDiag = ''
1053
1129
  return {
1054
1130
  ok: true,
1055
1131
  // 充值 + 赠金:与 API key 路径的 `total_balance` 同口径(那个字段本身也是两者相加),
1056
1132
  // 否则"按余额差"推算的今日已用会偏小(issue #157 报告人的坑 2)。
1057
1133
  totalBalance: Number((recharge + bonus).toFixed(6)),
1134
+ // v781:两个分量单独下发,供「赠金 / 账户余额」两个自定义模块使用(见 SPEC §25)
1058
1135
  rechargeBalance: Number(recharge.toFixed(6)),
1059
1136
  bonusBalance: Number(bonus.toFixed(6)),
1060
1137
  currency,
@@ -1064,30 +1141,64 @@ export default {
1064
1141
  }
1065
1142
  }
1066
1143
 
1144
+ // v781:账号路的兜底(**两条路都失败才报错,并把两条路的原因都带上**)。
1145
+ // 为什么需要它:旧实现只在"完全没有 API key"时才试账号路 ⇒ 曾经配过 key(过期/写错/被删)
1146
+ // 但现在只用客户端登录的用户,余额**永远读不到**("只登录客户端读不到余额"报告里的另一大类)。
1147
+ // ⚠️ 超时上限:账号路走 DSH 自己的服务(默认 30s)⇒ 我们用 ACCOUNT_FALLBACK_MS 截断,
1148
+ // 保证"API key 两次 8s + 退避"之后仍在**前端的 25s** 之内回来,不让界面先 abort。
1149
+ async function accountFallback(keyErr) {
1150
+ let acc = null
1151
+ try {
1152
+ acc = await Promise.race([
1153
+ fetchAccountBalance(),
1154
+ new Promise((r) => setTimeout(() => { accountDiag = '账号余额请求超时'; r(null) }, ACCOUNT_FALLBACK_MS)),
1155
+ ])
1156
+ } catch (err) {
1157
+ acc = null
1158
+ accountDiag = accountDiag || ('账号余额异常: ' + String((err && err.message) || err).slice(0, 120))
1159
+ }
1160
+ if (acc) return acc
1161
+ const keyPart = keyErr
1162
+ ? ('API key 路:' + String((keyErr.error || keyErr.code || '失败')).slice(0, 160))
1163
+ : '未配置 DEEPSEEK_API_KEY'
1164
+ return {
1165
+ ok: false,
1166
+ code: keyErr ? 'BOTH_FAILED' : 'NO_KEY',
1167
+ error: keyPart + ';账号登录路:' + (accountDiag || '拿不到余额'),
1168
+ }
1169
+ }
1067
1170
  async function fetchBalance() {
1068
1171
  // v739(用户反馈「每次新实例的第一次余额请求都失败」):
1069
1172
  // ① 冷启动时凭据服务可能尚未就绪(resolve 抛错),这与「确实没配置」不同 → 短延迟重试一次;
1070
1173
  // ② 单次超时从 20s 收到 8s,保证「2 次尝试 + 退避」明显短于前端 FETCH_TIMEOUT_MS(25s) ——
1071
1174
  // 否则前端先 abort,用户看到的永远是「第一次失败」,只能干等 60 秒后的下一轮。
1072
1175
  let cred = null
1176
+ let credErr = null
1073
1177
  for (let attempt = 0; attempt < 2; attempt++) {
1074
1178
  try {
1075
1179
  cred = await ctx.credentials.resolve('DEEPSEEK_API_KEY')
1180
+ credErr = null
1076
1181
  break
1077
1182
  } catch (err) {
1183
+ credErr = err
1078
1184
  if (attempt === 0) { await new Promise((r) => setTimeout(r, 700)); continue }
1079
- return { ok: false, code: 'NO_KEY', error: '凭据读取失败: ' + String((err && err.message) || err).slice(0, 160) }
1185
+ // v781:凭据服务报错时**也要试账号路**(以前直接 return,登录态用户就被这条挡住)
1186
+ return await accountFallback({ code: 'NO_KEY', error: '凭据读取失败: ' + String((err && err.message) || err).slice(0, 160) })
1080
1187
  }
1081
1188
  }
1082
1189
  if (!cred) {
1083
1190
  // v759(issue #157):没有 API key 时退回 DSH 账号登录态;两条路互不干扰,API key 始终第一优先。
1084
- const accountPayload = await fetchAccountBalance()
1085
- if (accountPayload) return accountPayload
1086
- return {
1087
- ok: false, code: 'NO_KEY',
1088
- error: '未配置 DEEPSEEK_API_KEY,且账号余额不可用(未登录 DeepSeek 账号,或该账号没有余额钱包)',
1089
- }
1191
+ return await accountFallback(null)
1090
1192
  }
1193
+ const apiFail = await fetchApiKeyBalance(cred)
1194
+ if (apiFail.ok) return apiFail
1195
+ // v781:key 路**任何**失败都再试一次账号路(401/结构异常/超时都算)
1196
+ void credErr
1197
+ return await accountFallback(apiFail)
1198
+ }
1199
+
1200
+ // API key 路(原 fetchBalance 的主体,v781 抽出来以便"失败后回退账号路")
1201
+ async function fetchApiKeyBalance(cred) {
1091
1202
  let lastErr = null
1092
1203
  for (let attempt = 0; attempt < 2; attempt++) {
1093
1204
  let res
@@ -1117,9 +1228,15 @@ export default {
1117
1228
  if (!info || info.total_balance === undefined || !Number.isFinite(Number(info.total_balance))) {
1118
1229
  return { ok: false, code: 'SHAPE', error: '余额接口返回结构异常' }
1119
1230
  }
1231
+ const numOr = (x) => { const n = Number(x); return Number.isFinite(n) ? n : null }
1120
1232
  return {
1121
1233
  ok: true,
1122
1234
  totalBalance: Number(info.total_balance),
1235
+ // v781:官方 /user/balance 除 total_balance 外还给了 granted_balance(**未过期**赠金)与
1236
+ // topped_up_balance(充值)。取不到就是 null(界面显示 —,**不臆造 0** —— 0 会被记账
1237
+ // 当成"余额真的见底",那是另一种语义)。两个分量供自定义模块里的「赠金 / 账户余额」使用。
1238
+ bonusBalance: numOr(info.granted_balance),
1239
+ rechargeBalance: numOr(info.topped_up_balance),
1123
1240
  accountTag: createHash('sha256').update(String(cred.value)).digest('hex').slice(0, 24),
1124
1241
  currency: String(info.currency || 'CNY'),
1125
1242
  balanceSource: 'apikey',
@@ -1285,7 +1402,7 @@ export default {
1285
1402
  const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
1286
1403
  for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
1287
1404
  return {
1288
- ok: true, version: '0.3.16', today: todayData, days7,
1405
+ ok: true, version: '0.3.18', today: todayData, days7,
1289
1406
  total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
1290
1407
  all: {
1291
1408
  days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
@@ -1321,7 +1438,10 @@ export default {
1321
1438
  // (只冒泡)—— 要响就把音效行的 [✓] 勾上。
1322
1439
  // v764:turnCost **刻意不带** autoClose / ttlSec —— 自动关闭的真实来源是 .dshw-size.json 的
1323
1440
  // turnCostCloseMs,留着这两个键会让同一个设置有两个来源(其中一个永远是假的)。
1324
- turnCost: { vol: 1, bubbleOn: true },
1441
+ // v778:`volSet` = 用户**显式**调过②区「提示音量」没有(默认 false = 跟随①区按压音量)。
1442
+ // 没有它就无法区分"默认 100%"与"用户设了 100%",前端 soundVolumeOf() 靠它决定优先级。
1443
+ // ⚠️ 与前端 soundEventDefaults().turnCost **必须逐字段一致**(探针 §⑥ 跨端一致钉住)。
1444
+ turnCost: { vol: 1, volSet: false, bubbleOn: true },
1325
1445
  question: { on: true, soundOn: false, sel: 'frag:exp_orb', vol: 1, autoClose: true, ttlSec: 180, bubbleOn: true, lines: waitDefaultLines('question') },
1326
1446
  approval: { on: true, soundOn: false, sel: 'frag:exp_orb', vol: 1, autoClose: true, ttlSec: 180, bubbleOn: true, lines: waitDefaultLines('approval') },
1327
1447
  }
@@ -1329,6 +1449,10 @@ export default {
1329
1449
  function usageSettingsDefaults() {
1330
1450
  return { // 任务结束音:v774 起出厂默认 = **开**(作者当前用法),默认音 = 内置 A(end_a)
1331
1451
  taskEnd: { on: true, sel: 'frag:end_a' },
1452
+ // v777:「等待交互提示」的交互开关(用户 2026-09-26 要求)
1453
+ // charClose=false(出厂默认)= 只有**点泡泡**能收起等待提示;true = **点角色**也能收起。
1454
+ // 两种情况下"点掉的那条挂起"都不会再被每秒轮询自动弹回(前端 waitDismissedId)。
1455
+ wait: { charClose: false },
1332
1456
  // v761:四个事件的音效/自动关闭/冒泡配置(面板「全局音效设置」)
1333
1457
  events: soundEventsDefaults(),
1334
1458
  // 默认固化自开发环境当前 usage.json 设置(全新安装即此体验)
@@ -1411,6 +1535,8 @@ export default {
1411
1535
  if (p.budget && typeof p.budget === 'object') led.settings.budget = Object.assign({}, led.settings.budget || {}, p.budget)
1412
1536
  // 每轮消耗提示内容(自定义提示窗口)
1413
1537
  if (p.turnCost && typeof p.turnCost === 'object') led.settings.turnCost = Object.assign({}, led.settings.turnCost || {}, p.turnCost)
1538
+ // v777:「等待交互提示」的交互开关(点角色是否也能关闭等待提示气泡)
1539
+ if (p.wait && typeof p.wait === 'object') led.settings.wait = Object.assign({}, led.settings.wait || {}, p.wait)
1414
1540
  // v761(全局音效设置):events 补丁逐事件合并(前端只发改动的那几个字段)
1415
1541
  if (p.events && typeof p.events === 'object') {
1416
1542
  led.settings.events = led.settings.events && typeof led.settings.events === 'object' ? led.settings.events : {}
@@ -1426,6 +1552,8 @@ export default {
1426
1552
  // 「恢复默认」:只重置音效/提示类键(不动外观、位置、账本、角色与泡泡自定义)
1427
1553
  if (p.resetEvents === true) {
1428
1554
  led.settings.events = soundEventsDefaults()
1555
+ // v777:等待提示的交互开关也属于"提示类键" ⇒ 一起恢复出厂默认(charClose=false)
1556
+ led.settings.wait = { charClose: false }
1429
1557
  // 任务结束音也属于「音效类键」:宿主重置与面板显示必须口径一致(否则面板显示与真实状态撕裂)
1430
1558
  // v774:重置目标 = 新的出厂默认(开 + 内置 A)
1431
1559
  led.settings.taskEnd = { on: true, sel: 'frag:end_a' }
@@ -1988,14 +2116,48 @@ export default {
1988
2116
  }
1989
2117
  return { key, code: '', error: '' }
1990
2118
  }
1991
- async function apiFetchJson(url, auth, key) {
2119
+ // v789(issue #190):余额/额度接口不再局限于 GET —— 模板或模型条目可声明 method / body。
2120
+ // ① method 默认 GET;非 GET 时带 `Content-Type: application/json` 并把 body 发出去;
2121
+ // ② body 支持占位符:`{key}`(凭据)、`{base}`(Base URL)、以及 `model.params` 里的任意键(如 `{uuid}`)
2122
+ // —— 这样"POST /v1/enterprise/account {"uuid":"…"}" 这类自建网关不需要再额外挂一层转换服务;
2123
+ // ③ 安全边界不变:body 只来自配置(不接受请求方传入),目的地仍走 credentialMayGoTo() 白名单,
2124
+ // URL 仍过 assertSafeApiUrl()。
2125
+ function bodyWithPlaceholders(body, key, base, params) {
2126
+ let s = String(body == null ? '' : body)
2127
+ s = s.split('{key}').join(String(key == null ? '' : key))
2128
+ s = s.split('{base}').join(String(base == null ? '' : base))
2129
+ if (params && typeof params === 'object') {
2130
+ for (const k of Object.keys(params)) {
2131
+ const v = params[k]
2132
+ if (v === undefined || v === null || typeof v === 'object') continue
2133
+ s = s.split('{' + k + '}').join(String(v))
2134
+ }
2135
+ }
2136
+ return s
2137
+ }
2138
+ function httpMethodOf(cfg) {
2139
+ const m = String((cfg && cfg.method) || 'GET').trim().toUpperCase()
2140
+ return /^[A-Z]{3,7}$/.test(m) ? m : 'GET'
2141
+ }
2142
+ async function apiFetchJson(url, auth, key, opts) {
2143
+ const o = opts || {}
1992
2144
  const headers = {}
1993
2145
  const a = String(auth == null ? 'Bearer {key}' : auth)
1994
2146
  if (a) headers.Authorization = a.replace('{key}', key)
1995
2147
  // v724:URL 里也支持 {key}(如 Gemini 的 ?key=… 形式;auth 传空串时不带 Authorization 头)
1996
2148
  const u = String(url).replace('{key}', key)
1997
2149
  assertSafeApiUrl(u)
1998
- const res = await fetch(u, { headers, signal: AbortSignal.timeout(15000) })
2150
+ const init = { headers, signal: AbortSignal.timeout(15000) }
2151
+ const method = httpMethodOf(o)
2152
+ if (method !== 'GET') {
2153
+ init.method = method
2154
+ const raw = o.body
2155
+ if (raw !== undefined && raw !== null && String(raw).length) {
2156
+ headers['Content-Type'] = 'application/json'
2157
+ init.body = bodyWithPlaceholders(raw, key, o.base, o.params)
2158
+ }
2159
+ }
2160
+ const res = await fetch(u, init)
1999
2161
  if (!res.ok) throw new Error('HTTP ' + res.status)
2000
2162
  return await res.json()
2001
2163
  }
@@ -2031,14 +2193,17 @@ export default {
2031
2193
  }
2032
2194
  const b = mergeNonEmpty(tpl.balance, model.balance) // v724:模型侧空串不再覆盖模板
2033
2195
  const base = String(model.baseUrl || '').replace(/\/+$/, '')
2034
- const url = String(tpl.probeUrl || b.url || (base ? base + '/v1/models' : '')).replace('{base}', base)
2196
+ // v789(issue #190):声明了 POST 等非 GET 方法时,测试就直接打**用户配置的余额接口**(带同一个 body)——
2197
+ // 否则会拿 GET 去打一个只支持 POST 的地址、误报"测试失败"(probeUrl 多是 models 列表,body 不通用)。
2198
+ const nonGet = httpMethodOf(b) !== 'GET'
2199
+ const url = String(nonGet ? (b.url || tpl.probeUrl || '') : (tpl.probeUrl || b.url || (base ? base + '/v1/models' : ''))).replace('{base}', base)
2035
2200
  if (!url) return { ok: false, error: '没有可测试的接口(请填余额接口或 Base URL)' }
2036
2201
  // v761(安全修复 S1):只在目的地可信时才带上凭据
2037
2202
  const k = await keyForDestination(model, tpl, url)
2038
2203
  if (!k.key) return { ok: false, code: k.code, error: k.error }
2039
2204
  const key = k.key
2040
2205
  try {
2041
- const data = await apiFetchJson(url, b.auth, key)
2206
+ const data = await apiFetchJson(url, b.auth, key, { method: b.method, body: b.body, base: base, params: model.params })
2042
2207
  const bizErr = apiBusinessError(data)
2043
2208
  if (bizErr) return { ok: false, error: '测试失败: ' + bizErr }
2044
2209
  let detail = 'HTTP 200'
@@ -2049,14 +2214,41 @@ export default {
2049
2214
  return { ok: false, error: '测试失败: ' + String((err && err.message) || err).slice(0, 140) }
2050
2215
  }
2051
2216
  }
2217
+ // v789(issue #180/#181):额度接口里"多窗口 + 数组顺序不保证"的通用取数。
2218
+ // 智谱 `GET /api/monitor/usage/quota/limit` 返回的 `data.limits[]` 是**扁平条目数组**:
2219
+ // `TOKENS_LIMIT` 是每条的 `type` 字段值(不是 `limits[0]` 的子对象),5h 窗口不保证排在 `[0]`
2220
+ // (实测:pro 账号 `limits[0]` 是 TIME_LIMIT,max 账号 `limits[0]` 是 TOKENS_LIMIT ⇒ 按位置取数必然出错过),
2221
+ // 而且新注册/credit 制账号把 `type` 改成了 `CREDIT_LIMIT`。
2222
+ // 所以模板可以声明 `pick: { list, type/types, unit, number }`:先选中**那一条**,再把它的字段当作取数根。
2223
+ function pickQuotaEntry(data, pick) {
2224
+ try {
2225
+ if (!pick || !pick.list) return null
2226
+ const arr = pickJsonPath(data, pick.list)
2227
+ if (!Array.isArray(arr)) return null
2228
+ const types = []
2229
+ if (pick.type) types.push(String(pick.type))
2230
+ if (Array.isArray(pick.types)) for (const t of pick.types) types.push(String(t))
2231
+ const unit = (pick.unit === undefined || pick.unit === null) ? null : Number(pick.unit)
2232
+ const number = (pick.number === undefined || pick.number === null) ? null : Number(pick.number)
2233
+ for (const it of arr) {
2234
+ if (!it || typeof it !== 'object') continue
2235
+ if (types.length && types.indexOf(String(it.type)) < 0) continue
2236
+ if (unit !== null && Number(it.unit) !== unit) continue
2237
+ if (number !== null && Number(it.number) !== number) continue
2238
+ return it
2239
+ }
2240
+ return null
2241
+ } catch (err) { return null }
2242
+ }
2052
2243
  // 订阅额度(Coding Plan)解析:各厂商字段不同,统一归一成「已用% + 重置时间 + 档位」
2053
- // zhipu: data.limits[0].TOKENS_LIMIT.percentage(已用%)+ nextResetTime
2244
+ // zhipu: data.limits[] 里 type=TOKENS_LIMIT/CREDIT_LIMIT + unit=3(5h)/6(周) 的条目(v789 起按 pick 选)
2054
2245
  // kimi: usage.remaining / usage.limit(剩余/总量)
2055
2246
  // minimax: model_remains[0].current_interval_remaining_percent(剩余%)+ end_time(ms)
2056
2247
  async function fetchModelQuota(model) {
2057
2248
  if (!model) return { ok: false, error: '模型不存在' }
2058
2249
  const tpl = apiTemplateOf(model.provider) || {}
2059
- const q = tpl.quota
2250
+ // v789(issue #180 建议 3):模型条目也能覆盖额度配置 —— 厂商接口变动时用户可自救(不留空即覆盖)
2251
+ const q = mergeNonEmpty(tpl.quota, model.quota)
2060
2252
  if (!q || !q.url) return { ok: false, code: 'NO_QUOTA', error: '该厂商没有订阅额度接口' }
2061
2253
  const base = String(model.baseUrl || '').replace(/\/+$/, '')
2062
2254
  const url = String(q.url).replace('{base}', base)
@@ -2065,31 +2257,33 @@ export default {
2065
2257
  if (!k.key) return { ok: false, code: k.code, error: k.error }
2066
2258
  const key = k.key
2067
2259
  try {
2068
- const data = await apiFetchJson(url, q.auth, key)
2260
+ const data = await apiFetchJson(url, q.auth, key, { method: q.method, body: q.body, base: base, params: model.params })
2069
2261
  const bizErr = apiBusinessError(data)
2070
2262
  if (bizErr) return { ok: false, code: 'BIZ', error: bizErr }
2071
2263
  const j = q.json || {}
2072
2264
  const num = (v) => { const n = Number(v); return isFinite(n) ? n : null }
2265
+ // v789:若模板声明了顶层 pick(单窗口 + 数组筛选),先把取数根换成选中的那条
2266
+ const jroot = j.pick ? (pickQuotaEntry(data, j.pick) || {}) : data
2073
2267
  let usedPct = null, remainPct = null, resetAt = null, level = '', weeklyUsedPct = null
2074
- if (j.percent) { const p = num(pickJsonPath(data, j.percent)); if (p !== null) usedPct = p }
2268
+ if (j.percent) { const p = num(pickJsonPath(jroot, j.percent)); if (p !== null) usedPct = p }
2075
2269
  if (j.remainPct) {
2076
- const p = num(pickJsonPath(data, j.remainPct))
2270
+ const p = num(pickJsonPath(jroot, j.remainPct))
2077
2271
  if (p !== null) { remainPct = p; if (usedPct === null) usedPct = Math.max(0, 100 - p) }
2078
2272
  }
2079
2273
  if (j.weeklyRemainPct) {
2080
- const p = num(pickJsonPath(data, j.weeklyRemainPct))
2274
+ const p = num(pickJsonPath(jroot, j.weeklyRemainPct))
2081
2275
  if (p !== null) weeklyUsedPct = Math.max(0, 100 - p)
2082
2276
  }
2083
2277
  if (j.remain && j.total) {
2084
- const r0 = num(pickJsonPath(data, j.remain))
2085
- const t0 = num(pickJsonPath(data, j.total))
2278
+ const r0 = num(pickJsonPath(jroot, j.remain))
2279
+ const t0 = num(pickJsonPath(jroot, j.total))
2086
2280
  if (r0 !== null && t0) {
2087
2281
  remainPct = Math.max(0, Math.min(100, r0 / t0 * 100))
2088
2282
  usedPct = Math.max(0, 100 - remainPct)
2089
2283
  }
2090
2284
  }
2091
- if (j.resetAt) resetAt = pickJsonPath(data, j.resetAt)
2092
- if (j.resetAtMs) { const ms = num(pickJsonPath(data, j.resetAtMs)); if (ms !== null) resetAt = ms }
2285
+ if (j.resetAt) resetAt = pickJsonPath(jroot, j.resetAt)
2286
+ if (j.resetAtMs) { const ms = num(pickJsonPath(jroot, j.resetAtMs)); if (ms !== null) resetAt = ms }
2093
2287
  if (j.level) level = String(pickJsonPath(data, j.level) || '')
2094
2288
  // v0.3.1:多窗口额度 —— 有些订阅额度接口一次返回多个窗口(如 OpenCode Go 的
2095
2289
  // rolling / weekly / monthly),每个窗口各有「已用% + 重置时间」。模板用
@@ -2100,11 +2294,13 @@ export default {
2100
2294
  const list = []
2101
2295
  for (const w of j.windows) {
2102
2296
  if (!w) continue
2103
- let wp = w.percent ? num(pickJsonPath(data, w.percent)) : null
2297
+ // v789:每个窗口可以各自 pick 一条(智谱 5h=unit3 / 周=unit6),选中后以该条为取数根
2298
+ const wroot = w.pick ? (pickQuotaEntry(data, w.pick) || {}) : data
2299
+ let wp = w.percent ? num(pickJsonPath(wroot, w.percent)) : null
2104
2300
  if (wp !== null) wp = Math.max(0, Math.min(100, wp))
2105
2301
  let wr = null
2106
2302
  if (w.resetAt) {
2107
- const rv = pickJsonPath(data, w.resetAt)
2303
+ const rv = pickJsonPath(wroot, w.resetAt)
2108
2304
  if (rv !== undefined && rv !== null && rv !== '') wr = rv
2109
2305
  }
2110
2306
  if (wp === null && wr === null) continue
@@ -2157,7 +2353,8 @@ export default {
2157
2353
  if (!k.key) return { ok: false, code: k.code, error: k.error }
2158
2354
  const key = k.key
2159
2355
  try {
2160
- const data = await apiFetchJson(url, b.auth, key)
2356
+ // v789(issue #190):把 method / body / params 一并交给取数层(默认 GET,与以前完全一致)
2357
+ const data = await apiFetchJson(url, b.auth, key, { method: b.method, body: b.body, base: base, params: model.params })
2161
2358
  const j = b.json || {}
2162
2359
  let remaining = null
2163
2360
  let total = null
@@ -2396,12 +2593,56 @@ export default {
2396
2593
  if (p.allowCustomHost === true) model.allowCustomHost = true
2397
2594
  else if (p.allowCustomHost === false) delete model.allowCustomHost
2398
2595
  if (p.baseUrl !== undefined) model.baseUrl = String(p.baseUrl || '').slice(0, 300)
2596
+ // v789(issue #190):请求体里可用的 `{占位符}`(如 {uuid})—— 只允许标量,键名受限
2597
+ if (p.params && typeof p.params === 'object') {
2598
+ const pr = {}
2599
+ for (const k of Object.keys(p.params)) {
2600
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,30}$/.test(k)) continue
2601
+ const v = p.params[k]
2602
+ if (v === undefined || v === null || typeof v === 'object') continue
2603
+ pr[k] = String(v).slice(0, 300)
2604
+ }
2605
+ if (Object.keys(pr).length) model.params = pr
2606
+ else delete model.params
2607
+ }
2608
+ if (p.quota && typeof p.quota === 'object' && p.quota.json && typeof p.quota.json === 'object') {
2609
+ // v789(issue #180 建议 3):允许模型条目覆盖额度配置(厂商接口变动时用户可自救)。
2610
+ // 只收 JSON-safe 的形状,且路径字符串限长 —— 与模板同构,不做二次校验的滥用面。
2611
+ const qj = {}
2612
+ const pathOf = (v) => String(v || '').slice(0, 200)
2613
+ if (p.quota.url) qj.url = String(p.quota.url).slice(0, 500)
2614
+ if (p.quota.auth !== undefined) qj.auth = String(p.quota.auth || '').slice(0, 200)
2615
+ const jin = p.quota.json
2616
+ for (const k of ['percent', 'remainPct', 'weeklyRemainPct', 'remain', 'total', 'resetAt', 'resetAtMs', 'level']) {
2617
+ if (jin[k]) qj[k] = pathOf(jin[k])
2618
+ }
2619
+ if (Array.isArray(jin.windows)) {
2620
+ qj.windows = jin.windows.slice(0, 6).map((w) => {
2621
+ const o = { key: String((w && w.key) || '').slice(0, 20), label: String((w && w.label) || '').slice(0, 20) }
2622
+ if (w && w.percent) o.percent = pathOf(w.percent)
2623
+ if (w && w.resetAt) o.resetAt = pathOf(w.resetAt)
2624
+ if (w && w.pick && typeof w.pick === 'object') {
2625
+ const pk = { list: pathOf(w.pick.list) }
2626
+ if (w.pick.type) pk.type = String(w.pick.type).slice(0, 40)
2627
+ if (Array.isArray(w.pick.types)) pk.types = w.pick.types.slice(0, 4).map((t) => String(t).slice(0, 40))
2628
+ if (w.pick.unit !== undefined) pk.unit = Number(w.pick.unit)
2629
+ if (w.pick.number !== undefined) pk.number = Number(w.pick.number)
2630
+ o.pick = pk
2631
+ }
2632
+ return o
2633
+ })
2634
+ }
2635
+ if (qj.url && (Object.keys(qj).length > 1)) model.quota = qj
2636
+ }
2399
2637
  if (p.balance && typeof p.balance === 'object') {
2400
2638
  // v724:空字段不写入(stripEmptyDeep)→ 模板默认值继续生效;
2401
2639
  // 全部为空时直接删掉 model.balance,等于"完全用模板"
2402
2640
  const balIn = stripEmptyDeep({
2403
2641
  url: String(p.balance.url || '').slice(0, 500),
2404
2642
  auth: String(p.balance.auth || '').slice(0, 200),
2643
+ // v789(issue #190):可选的请求方法与请求体(留空 ⇒ 沿用模板/GET)
2644
+ method: String(p.balance.method || '').slice(0, 8).toUpperCase(),
2645
+ body: String(p.balance.body || '').slice(0, 4000),
2405
2646
  json: {
2406
2647
  remaining: String((p.balance.json && p.balance.json.remaining) || '').slice(0, 200),
2407
2648
  total: String((p.balance.json && p.balance.json.total) || '').slice(0, 200),
@@ -2502,6 +2743,10 @@ export default {
2502
2743
  // entry.balance 是数值,不能当描述用(否则面板保存会用空值覆盖描述)
2503
2744
  const tplE = apiTemplateOf(m.provider) || {}
2504
2745
  entry.balanceDesc = mergeNonEmpty(tplE.balance, m.balance) // v724:空串不覆盖模板
2746
+ // v789(issue #189):把当前的凭据放行标志也下发 —— 前端勾选框要能**显示现状**,
2747
+ // 否则"看不到 → 保存时显式传 false"会把用户手改文件开的口子又关掉(反而更糟)。
2748
+ entry.allowCustomHost = m.allowCustomHost === true
2749
+ entry.params = (m.params && typeof m.params === 'object') ? m.params : null
2505
2750
  if (m.id === API_BUILTIN_ID) {
2506
2751
  entry.balanceMode = 'api'
2507
2752
  entry.hasBalanceApi = true
@@ -2624,7 +2869,7 @@ export default {
2624
2869
  const summary = daySummary(led, todayKey())
2625
2870
  const nowSec = Math.floor(Date.now() / 1000)
2626
2871
  return {
2627
- ...visible, version: '0.3.16', isPeak: isPeakTime(nowSec),
2872
+ ...visible, version: '0.3.18', isPeak: isPeakTime(nowSec),
2628
2873
  // 峰谷切换点与节假日清单:前端倒计时要与宿主同源(否则法定节假日会算错切换点)
2629
2874
  peakNextChangeAt: nextPeakChangeAt(nowSec),
2630
2875
  peakHolidays: HOLIDAY_VALLEY_LIST,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-widget",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义",
5
5
  "keywords": [
6
6
  "dsh",