dsh-whale-widget 0.2.9 → 0.3.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/lib/index.js CHANGED
@@ -1,52 +1,397 @@
1
1
  import fs from 'node:fs'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
+ import dns from 'node:dns'
4
5
  import { fileURLToPath } from 'node:url'
5
6
 
7
+ // Node 的 fetch(undici) 可能优先 IPv6:个别域名(如 open.bigmodel.cn)有 AAAA 记录但本机 IPv6 不通,
8
+ // 会直接报 "fetch failed"(curl 会自动回退 IPv4,所以看起来正常)。统一改为 IPv4 优先。
9
+ try { dns.setDefaultResultOrder('ipv4first') } catch (err) {}
10
+
6
11
  // Package root: lib/index.js -> package root. Keeps the bundle relocatable
7
12
  // when installed as a normal DSH npm plugin (node_modules or a local link).
8
13
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
9
14
 
10
- // DSH home: used for the widget size/usage memory files, since node_modules may
15
+ // DSH home: used for the widget memory/role/audio files, since node_modules may
11
16
  // be read-only or cleaned on update.
12
17
  const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
13
18
 
14
- // Whale image: package-relative first, legacy absolute paths as fallback.
19
+ // Whale image: package-relative first (ship DSniang1/DSniang02.png in assets/),
20
+ // legacy absolute paths as fallback.
15
21
  const IMAGE_CANDIDATES = [
16
22
  path.join(PACKAGE_ROOT, 'assets', 'DSniang1.png'),
17
23
  path.join(PACKAGE_ROOT, 'assets', 'DSniang02.png'),
18
- 'D:/TestBox/deepseek/DSniang1.png',
19
- 'D:/TestBox/deepseek/DSniang02.png',
20
- 'D:/TestBox/deepseek/skin/DSniang02.png',
21
24
  ]
25
+ // 自定义角色图片存放目录(第一个可写的会被使用;roles.json 存角色元数据)
26
+ const ROLE_DIR_CANDIDATES = [
27
+ path.join(DSH_HOME, 'whale-roles'),
28
+ path.join(DSH_HOME, 'profiles', 'web', 'whale-roles'),
29
+ ]
30
+ const ROLE_INDEX_NAME = 'roles.json'
31
+ const ROLE_DEFAULT_ID = 'default'
32
+ // 自定义音频片段存放目录(每个片段一个 <id>.wav + audio.json 索引)
33
+ const AUDIO_DIR_CANDIDATES = [
34
+ path.join(DSH_HOME, 'whale-audio'),
35
+ path.join(DSH_HOME, 'profiles', 'web', 'whale-audio'),
36
+ ]
37
+ const AUDIO_INDEX_NAME = 'audio.json'
38
+ // 内置预设音效组(不可删、不可改),对应 assets 里的 Ya/D 系列
39
+ const PRESET_GROUPS = {
40
+ duck: { id: 'duck', name: '小黄鸭', press: 'ya1', release: 'ya2', preset: true },
41
+ fx1: { id: 'fx1', name: '音效1', press: 'd1', release: 'd2', preset: true },
42
+ }
43
+ // 内置预设片段(不可删):传统 4 个映射到 SOUND_SETS 的 mp3,exp_orb / end_a 映射到随包发布的 wav。
44
+ // mime 必须与文件字节一致(音频路由按它下发 Content-Type,不匹配会导致解码/听感异常)。
45
+ const PRESET_FRAGMENTS = {
46
+ ya1: { id: 'ya1', name: '小黄鸭·按下', preset: true, mime: 'audio/mpeg' },
47
+ ya2: { id: 'ya2', name: '小黄鸭·松开', preset: true, mime: 'audio/mpeg' },
48
+ d1: { id: 'd1', name: '音效1·按下', preset: true, mime: 'audio/mpeg' },
49
+ d2: { id: 'd2', name: '音效1·松开', preset: true, mime: 'audio/mpeg' },
50
+ // 任务结束音的内置默认音效(随包资源,用户库缺失时回退内置文件)
51
+ exp_orb: { id: 'exp_orb', name: 'Minecraft·经验球', preset: true, mime: 'audio/wav' },
52
+ // 任务结束音 A(随包资源):原为用户音效库里的自导入片段,现作为内置预设随包发布。
53
+ // 显示名与用户库里同名片段一致时,前端下拉按显示名去重(内置与前端的同名条目只出现一条)。
54
+ end_a: { id: 'end_a', name: 'A', preset: true, mime: 'audio/wav' },
55
+ }
56
+ // 内置片段的音频文件:包内 assets 优先,开发环境回退到 skin 目录
57
+ const BUILTIN_FRAGMENT_FILES = {
58
+ exp_orb: [
59
+ path.join(PACKAGE_ROOT, 'assets', 'minecraft-exp-orb.wav'),
60
+ ],
61
+ // 任务结束音 A:随包发布 assets/task-end-a.wav(1.81s / 48kHz / 立体声 / 16bit PCM)
62
+ end_a: [
63
+ path.join(PACKAGE_ROOT, 'assets', 'task-end-a.wav'),
64
+ ],
65
+ }
66
+ // —— 自定义 API 模型(v655):非 DeepSeek 厂商的余额 / 用量 / 提醒 ——
67
+ // 注册表落在 $DSH_HOME/.dshw-api.json;密钥走 DSH 官方凭据(ctx.credentials),本文件不存明文。
68
+ const API_FILE_CANDIDATES = [
69
+ path.join(DSH_HOME, '.dshw-api.json'),
70
+ path.join(DSH_HOME, 'profiles', 'web', '.dshw-api.json'),
71
+ ]
72
+ const API_BUILTIN_ID = 'deepseek'
73
+ // 内置厂商模板:balance 描述「怎么取余额」。json 路径支持 a.b[0].c;scale 为取值后的乘数。
74
+ // 余额接口已按官方文档 / 社区参考核对(DeepSeek、OpenRouter、Kimi、阶跃、Novita、OpenAI 兼容中转站);
75
+ // 硅基流动的余额接口已官方下线(410),故改为「无余额接口 + /v1/models 探活」;
76
+ // 火山方舟同理(余额/用量属火山引擎 AK/SK 签名的 OpenAPI)。其余厂商可选用 custom 手填 URL 与字段路径。
77
+ // 厂商模板下拉的排序键(v726):英文名直接用;中文名取首字的拼音,让中英文按 A→Z **混排**。
78
+ // 为什么不用 localeCompare:ICU 默认把汉字排到拉丁字母之后,中文厂商会被整段甩到列表末尾。
79
+ // 新增中文厂商时在下面补一行(键 = 厂商名首字)。
80
+ const TPL_PINYIN_INITIAL = { 阿: 'a', 百: 'bai', 本: 'ben', 硅: 'gui', 火: 'huo', 阶: 'jie', 魔: 'mo', 腾: 'teng', 讯: 'xun', 智: 'zhi', 自: 'zi' }
81
+ function tplSortKey(name) {
82
+ const s = String(name || '').trim()
83
+ if (!s) return ''
84
+ const py = TPL_PINYIN_INITIAL[s.slice(0, 1)]
85
+ return py || s.toLowerCase()
86
+ }
87
+ const API_TEMPLATES = {
88
+ deepseek: {
89
+ name: 'DeepSeek', currency: 'CNY', keyRef: 'DEEPSEEK_API_KEY', builtin: true,
90
+ balance: { url: 'https://api.deepseek.com/user/balance', auth: 'Bearer {key}', json: { remaining: 'balance_infos[0].total_balance' } },
91
+ },
92
+ openrouter: {
93
+ name: 'OpenRouter', currency: 'USD', keyRef: 'OPENROUTER_API_KEY',
94
+ balance: { url: 'https://openrouter.ai/api/v1/credits', auth: 'Bearer {key}', json: { total: 'data.total_credits', used: 'data.total_usage' } },
95
+ },
96
+ siliconflow_cn: {
97
+ name: '硅基流动(CN)', currency: 'CNY', keyRef: 'SILICONFLOW_API_KEY', noBalanceApi: true,
98
+ // ⚠️ 官方已下线余额接口(不是"暂时故障")——2026-08-11 更新公告《【接口服务调整】/user/info 接口将停止服务》:
99
+ // 「/user/info 已无法适配平台用户账户体系,该 API 将于 2026-08-14 正式停止服务,届时接口将不再可用」,
100
+ // 并称「后续将适时提供账户层面的替代 API,新接口上线后将在本页面另行通知」。
101
+ // 截至 2026-09-14,公告页最新条目(2026.09.09)**仍未见替代 API**;CN 文档「平台系列」也只剩「获取用户模型列表」。
102
+ // v724 路由实测:/v1 下未知路径 404、而 /v1/user/info 仍返 401(鉴权层)→ 路由没被摘掉,但按公告已不可用。
103
+ // → 日后官方恢复或上线替代接口:把 url 与 json 字段路径填回来即可(模板只是默认值,用户可覆盖)。
104
+ apiNote: '官方已下线 /user/info 余额接口(2026-08-14 起停止服务;公告称后续会提供替代 API,暂未上线)→ 余额显示「—」,今日已用按会话事件估算;「测试连通性」用 /v1/models 验证 key',
105
+ matchIds: ['siliconflow', 'Qwen', 'deepseek-ai'],
106
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
107
+ probeUrl: 'https://api.siliconflow.cn/v1/models',
108
+ },
109
+ siliconflow_en: {
110
+ name: '硅基流动(EN)', currency: 'USD', keyRef: 'SILICONFLOW_API_KEY', noBalanceApi: true,
111
+ // 同国内站:余额接口已停止服务。注意国际站文档 docs.siliconflow.com 至今仍挂着 Retrieve user info 页
112
+ //(文档未同步),不能据此认为接口可用 —— 以国内官方公告为准。
113
+ apiNote: '同国内站:/user/info 余额接口已停止服务(国际站文档尚未同步)→ 余额「—」,今日已用按会话事件估算',
114
+ matchIds: ['siliconflow', 'Qwen', 'deepseek-ai'],
115
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
116
+ probeUrl: 'https://api.siliconflow.com/v1/models',
117
+ },
118
+ moonshot: {
119
+ // 大陆站:api.moonshot.cn,余额为人民币(充值 + 代金券合计)。v724 实测:200 且字段命中 ✓
120
+ name: 'Kimi / Moonshot(CN)', currency: 'CNY', keyRef: 'MOONSHOT_API_KEY', matchIds: ['moonshot', 'kimi'],
121
+ balance: { url: 'https://api.moonshot.cn/v1/users/me/balance', auth: 'Bearer {key}', json: { remaining: 'data.available_balance' } },
122
+ probeUrl: 'https://api.moonshot.cn/v1/models',
123
+ },
124
+ moonshot_intl: {
125
+ // 国际站是独立账号体系(key 不通用),余额按美元计
126
+ name: 'Kimi / Moonshot(国际)', currency: 'USD', keyRef: 'MOONSHOT_INTL_API_KEY',
127
+ balance: { url: 'https://api.moonshot.ai/v1/users/me/balance', auth: 'Bearer {key}', json: { remaining: 'data.available_balance' } },
128
+ probeUrl: 'https://api.moonshot.ai/v1/models',
129
+ },
130
+ stepfun: {
131
+ name: '阶跃星辰 StepFun', currency: 'CNY', keyRef: 'STEPFUN_API_KEY', matchIds: ['stepfun', 'step-'],
132
+ balance: { url: 'https://api.stepfun.com/v1/accounts', auth: 'Bearer {key}', json: { remaining: 'balance' } },
133
+ },
134
+ novita: {
135
+ name: 'Novita AI', currency: 'USD', keyRef: 'NOVITA_API_KEY', matchIds: ['novita'],
136
+ balance: { url: 'https://api.novita.ai/v3/user/balance', auth: 'Bearer {key}', json: { remaining: 'availableBalance', scale: 0.0001 } },
137
+ },
138
+ volcengine_ark: {
139
+ name: '火山方舟 Ark', currency: 'CNY', keyRef: 'ARK_API_KEY', noBalanceApi: true,
140
+ // 方舟没有「用 API key 查余额」的接口(余额/用量属于火山引擎 AK/SK 签名的 OpenAPI)。
141
+ // v724 实测:/api/v3/models → 200(探活可用);/api/v3/balance → 404(无余额接口,确认)。
142
+ apiNote: '余额/用量需火山引擎 AK/SK 签名的 OpenAPI(或控制台)→ 余额「—」,今日已用按会话事件估算;探活用 /api/v3/models(实测可用)',
143
+ matchIds: ['doubao', 'ep-'],
144
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
145
+ probeUrl: 'https://ark.cn-beijing.volces.com/api/v3/models',
146
+ },
147
+ // —— 订阅额度(Coding Plan)模板:kind='quota',语义是"窗口用量% + 重置时间",不是钱 ——
148
+ // 注意:这些接口只对「订阅套餐」账号有效。买 Token 包(资源包)的账号查不到额度
149
+ // (智谱会返回「当前用户不存在coding plan」),那种情况用模型菜单里的
150
+ // 「额度(订阅/资源包)」按会话 token 自动统计。
151
+ zhipu_glm_coding: {
152
+ name: '智谱 GLM Coding Plan(订阅)', currency: 'CNY', keyRef: 'ZHIPU_API_KEY', kind: 'quota',
153
+ quota: {
154
+ url: 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
155
+ auth: '{key}', // 智谱此接口不带 Bearer
156
+ json: { percent: 'data.limits[0].TOKENS_LIMIT.percentage', resetAt: 'data.limits[0].nextResetTime', level: 'data.level' },
157
+ },
158
+ probeUrl: 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
159
+ },
160
+ kimi_coding: {
161
+ name: 'Kimi Coding(订阅)', currency: 'CNY', keyRef: 'KIMI_CODING_KEY', kind: 'quota',
162
+ quota: {
163
+ url: 'https://api.kimi.com/coding/v1/usages',
164
+ auth: 'Bearer {key}',
165
+ json: { remain: 'usage.remaining', total: 'usage.limit', resetAt: 'usage.resetTime' },
166
+ },
167
+ probeUrl: 'https://api.kimi.com/coding/v1/usages',
168
+ },
169
+ minimax_coding: {
170
+ name: 'MiniMax Coding(订阅)', currency: 'CNY', keyRef: 'MINIMAX_API_KEY', kind: 'quota',
171
+ quota: {
172
+ url: 'https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains',
173
+ auth: 'Bearer {key}',
174
+ json: {
175
+ remainPct: 'model_remains[0].current_interval_remaining_percent',
176
+ weeklyRemainPct: 'model_remains[0].current_weekly_remaining_percent',
177
+ resetAtMs: 'model_remains[0].end_time',
178
+ },
179
+ },
180
+ probeUrl: 'https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains',
181
+ },
182
+ openai_compat: {
183
+ name: 'OpenAI 兼容中转站', currency: 'USD', keyRef: 'CUSTOM_API_KEY', needsBaseUrl: true,
184
+ // OneAPI / New API 一类网关的经典账单接口:额度(美元) + 已用(美分)
185
+ balance: {
186
+ url: '{base}/v1/dashboard/billing/subscription', auth: 'Bearer {key}', json: { total: 'hard_limit_usd' },
187
+ usage: { url: '{base}/v1/dashboard/billing/usage', auth: 'Bearer {key}', json: { used: 'total_usage', scale: 0.01 } },
188
+ },
189
+ },
190
+ custom: {
191
+ name: '自定义 HTTP', currency: 'CNY', keyRef: 'CUSTOM_API_KEY',
192
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
193
+ },
194
+ // —— Codex 模式(第一期:本地会话统计)——
195
+ // 不查余额、不联网、不用凭据:直接读 ~/.codex/sessions 的 rollout-*.jsonl 统计 token。
196
+ // 好处:能覆盖「不在 DSH 里跑」的 Codex 使用;坏处:只有 token,没有金额(费用另由余额/额度口径处理)。
197
+ codex: {
198
+ name: 'Codex(本地会话)', currency: 'CNY', keyRef: '', kind: 'codex',
199
+ balance: { url: '', auth: '', json: { remaining: '' } },
200
+ },
201
+ // ================= v724:补齐「官方没有用 API key 查余额的接口」的常用厂商 =================
202
+ // 作用:① 选完自动填好 凭据名 / 币种 / 事件匹配(matchIds) / 探活地址 ②「测试连通性」可验证 key
203
+ // ③ 余额显示「—」,今日已用按本机会话事件估算(noBalanceApi + apiNote 会显示在面板提示里)。
204
+ // 说明:probeUrl 一律用各家的 OpenAI 兼容 /v1/models(官方文档形态,能否通过取决于 key 权限)。
205
+ openai: {
206
+ name: 'OpenAI', currency: 'USD', keyRef: 'OPENAI_API_KEY', noBalanceApi: true,
207
+ apiNote: '官方已下线 billing 余额接口,没有「用 API key 查余额」的公开接口(只能看控制台)→ 余额「—」,今日已用按会话事件估算',
208
+ matchIds: ['gpt', 'o1-', 'o3-', 'o4-', 'chatgpt'],
209
+ probeUrl: 'https://api.openai.com/v1/models',
210
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
211
+ },
212
+ anthropic: {
213
+ name: 'Anthropic Claude', currency: 'USD', keyRef: 'ANTHROPIC_API_KEY', noBalanceApi: true,
214
+ apiNote: '官方无余额接口;用量要走 Admin API(需 admin key)或控制台。注意其探活需要 x-api-key + anthropic-version 两个请求头,本挂件暂不支持 → 不提供探活',
215
+ matchIds: ['claude'],
216
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
217
+ },
218
+ gemini: {
219
+ name: 'Google Gemini', currency: 'USD', keyRef: 'GEMINI_API_KEY', noBalanceApi: true,
220
+ apiNote: '官方无余额接口(配额只在 AI Studio / Cloud 控制台)→ 余额「—」,今日已用按会话事件估算;探活用 ?key= 形式',
221
+ matchIds: ['gemini'],
222
+ probeUrl: 'https://generativelanguage.googleapis.com/v1beta/models?key={key}',
223
+ balance: { url: '', auth: '', json: { remaining: '' } },
224
+ },
225
+ xai: {
226
+ name: 'xAI Grok', currency: 'USD', keyRef: 'XAI_API_KEY', noBalanceApi: true,
227
+ apiNote: '官方无公开的余额查询接口(额度在 console.x.ai)→ 余额「—」,今日已用按会话事件估算',
228
+ matchIds: ['grok'],
229
+ probeUrl: 'https://api.x.ai/v1/models',
230
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
231
+ },
232
+ groq: {
233
+ name: 'Groq', currency: 'USD', keyRef: 'GROQ_API_KEY', noBalanceApi: true,
234
+ apiNote: '官方无余额接口(免费额度/速率在控制台看)→ 余额「—」,今日已用按会话事件估算',
235
+ matchIds: ['llama', 'mixtral', 'qwen', 'deepseek', 'gemma', 'whisper'],
236
+ probeUrl: 'https://api.groq.com/openai/v1/models',
237
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
238
+ },
239
+ mistral: {
240
+ name: 'Mistral AI', currency: 'USD', keyRef: 'MISTRAL_API_KEY', noBalanceApi: true,
241
+ apiNote: '官方无余额接口 → 余额「—」,今日已用按会话事件估算',
242
+ matchIds: ['mistral', 'codestral', 'magistral', 'pixtral', 'ministral'],
243
+ probeUrl: 'https://api.mistral.ai/v1/models',
244
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
245
+ },
246
+ together: {
247
+ name: 'Together AI', currency: 'USD', keyRef: 'TOGETHER_API_KEY', noBalanceApi: true,
248
+ apiNote: '官方无余额接口 → 余额「—」,今日已用按会话事件估算',
249
+ matchIds: ['meta-llama', 'Qwen', 'deepseek', 'mistralai', 'nvidia'],
250
+ probeUrl: 'https://api.together.xyz/v1/models',
251
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
252
+ },
253
+ fireworks: {
254
+ name: 'Fireworks AI', currency: 'USD', keyRef: 'FIREWORKS_API_KEY', noBalanceApi: true,
255
+ apiNote: '官方无余额接口 → 余额「—」,今日已用按会话事件估算',
256
+ matchIds: ['accounts/fireworks', 'llama-v3', 'qwen'],
257
+ probeUrl: 'https://api.fireworks.ai/inference/v1/models',
258
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
259
+ },
260
+ deepinfra: {
261
+ name: 'DeepInfra', currency: 'USD', keyRef: 'DEEPINFRA_API_KEY', noBalanceApi: true,
262
+ apiNote: '官方无余额接口 → 余额「—」,今日已用按会话事件估算',
263
+ matchIds: ['meta-llama', 'Qwen', 'deepseek'],
264
+ probeUrl: 'https://api.deepinfra.com/v1/openai/models',
265
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
266
+ },
267
+ cerebras: {
268
+ name: 'Cerebras', currency: 'USD', keyRef: 'CEREBRAS_API_KEY', noBalanceApi: true,
269
+ apiNote: '官方无余额接口 → 余额「—」,今日已用按会话事件估算',
270
+ matchIds: ['llama', 'qwen'],
271
+ probeUrl: 'https://api.cerebras.ai/v1/models',
272
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
273
+ },
274
+ dashscope: {
275
+ name: '阿里云百炼(通义千问)', currency: 'CNY', keyRef: 'DASHSCOPE_API_KEY', noBalanceApi: true,
276
+ apiNote: '云厂商:余额/账单要走阿里云 AK/SK 的 OpenAPI(或控制台),API key 查不到 → 余额「—」,今日已用按会话事件估算',
277
+ matchIds: ['qwen', 'qwq', 'qvq'],
278
+ probeUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1/models',
279
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
280
+ },
281
+ qianfan: {
282
+ name: '百度千帆(文心)', currency: 'CNY', keyRef: 'QIANFAN_API_KEY', noBalanceApi: true,
283
+ apiNote: '云厂商:余额/账单要走百度云 AK/SK(或控制台),API key 查不到 → 余额「—」,今日已用按会话事件估算',
284
+ matchIds: ['ernie'],
285
+ probeUrl: 'https://qianfan.baidubce.com/v2/models',
286
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
287
+ },
288
+ hunyuan: {
289
+ name: '腾讯混元', currency: 'CNY', keyRef: 'HUNYUAN_API_KEY', noBalanceApi: true,
290
+ apiNote: '云厂商:余额/账单要走腾讯云 SecretId/Key(或控制台),API key 查不到 → 余额「—」,今日已用按会话事件估算',
291
+ matchIds: ['hunyuan'],
292
+ probeUrl: 'https://api.hunyuan.cloud.tencent.com/v1/models',
293
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
294
+ },
295
+ spark: {
296
+ name: '讯飞星火', currency: 'CNY', keyRef: 'SPARK_API_KEY', noBalanceApi: true,
297
+ apiNote: '官方无余额接口(额度在控制台)→ 余额「—」,今日已用按会话事件估算',
298
+ matchIds: ['spark', 'generalv', '4.0ultra'],
299
+ probeUrl: 'https://spark-api-open.xf-yun.com/v1/models',
300
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
301
+ },
302
+ modelscope: {
303
+ name: '魔搭 ModelScope', currency: 'CNY', keyRef: 'MODELSCOPE_API_KEY', noBalanceApi: true,
304
+ apiNote: '官方无余额接口 → 余额「—」,今日已用按会话事件估算',
305
+ matchIds: ['Qwen', 'deepseek', 'MiniMax', 'glm'],
306
+ probeUrl: 'https://api-inference.modelscope.cn/v1/models',
307
+ balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
308
+ },
309
+ ollama: {
310
+ name: '本地模型(Ollama / LM Studio)', currency: 'CNY', keyRef: '', noBalanceApi: true, needsBaseUrl: true,
311
+ apiNote: '本地推理没有余额概念 → 余额「—」;填好 Base URL(如 http://127.0.0.1:11434/v1)后按会话事件统计 token',
312
+ matchIds: ['llama', 'qwen', 'gemma', 'deepseek', 'mistral', 'phi'],
313
+ probeUrl: '{base}/v1/models',
314
+ balance: { url: '', auth: '', json: { remaining: '' } },
315
+ },
316
+ zhipu_glm_coding_intl: {
317
+ name: '智谱 GLM Coding Plan(国际 z.ai)', currency: 'USD', keyRef: 'ZHIPU_INTL_API_KEY', kind: 'quota',
318
+ quota: {
319
+ url: 'https://api.z.ai/api/monitor/usage/quota/limit',
320
+ auth: '{key}', // 与国内站一样不带 Bearer
321
+ json: { percent: 'data.limits[0].TOKENS_LIMIT.percentage', resetAt: 'data.limits[0].nextResetTime', level: 'data.level' },
322
+ },
323
+ probeUrl: 'https://api.z.ai/api/monitor/usage/quota/limit',
324
+ },
325
+ minimax_coding_intl: {
326
+ name: 'MiniMax Coding(国际)', currency: 'USD', keyRef: 'MINIMAX_INTL_API_KEY', kind: 'quota',
327
+ quota: {
328
+ url: 'https://api.minimax.io/v1/api/openplatform/coding_plan/remains',
329
+ auth: 'Bearer {key}',
330
+ json: {
331
+ remainPct: 'model_remains[0].current_interval_remaining_percent',
332
+ weeklyRemainPct: 'model_remains[0].current_weekly_remaining_percent',
333
+ resetAtMs: 'model_remains[0].end_time',
334
+ },
335
+ },
336
+ probeUrl: 'https://api.minimax.io/v1/api/openplatform/coding_plan/remains',
337
+ },
338
+ }
22
339
 
23
340
  // Size memory file: prefer writable DSH home locations, then legacy fallbacks.
24
341
  const SIZE_FILE_CANDIDATES = [
25
342
  path.join(DSH_HOME, '.dshw-size.json'),
26
343
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-size.json'),
27
- 'D:/TestBox/deepseek/.dshw-size.json',
28
- 'D:/TestBox/deepseek/skin/.dshw-size.json',
29
344
  ]
30
-
31
345
  // Usage ledger file (小鲸鱼记账 mode): same policy as the size file.
32
346
  const USAGE_FILE_CANDIDATES = [
33
347
  path.join(DSH_HOME, '.dshw-usage.json'),
34
348
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-usage.json'),
35
- 'D:/TestBox/deepseek/.dshw-usage.json',
36
- 'D:/TestBox/deepseek/skin/.dshw-usage.json',
37
349
  ]
38
-
39
- // Sound assets: package-relative first (ship Ya1/Ya2/D1/D2.mp3 in assets/ for
40
- // sounds out of the box), legacy paths as fallback.
350
+ // 每轮消耗 seq 持久化文件:避免插件热重载后 seq 归零导致页面把新轮次误判为旧轮次吞掉
351
+ const TURN_FILE_CANDIDATES = [
352
+ path.join(DSH_HOME, '.dshw-turn.json'),
353
+ path.join(DSH_HOME, 'profiles', 'web', '.dshw-turn.json'),
354
+ ]
355
+ // Sound assets: package-relative first (ship Ya1/Ya2/D1/D2.mp3 in assets/),
356
+ // legacy absolute paths as fallback.
357
+ const BUBBLE_FILE_CANDIDATES = [
358
+ // v728:发布版会删掉开发机路径(§9.4),所以这里必须有 $DSH_HOME 兜底 ——
359
+ // 否则清完这个数组会变成空的:泡泡配置读不了也存不了(自定义泡泡整体失效)。
360
+ path.join(DSH_HOME, '.dshw-bubble.json'),
361
+ path.join(DSH_HOME, 'profiles', 'web', '.dshw-bubble.json'),
362
+ ]
363
+ // 泡泡图库(独立于角色图库):图片文件目录 + 索引
364
+ const BUBBLE_IMG_DIR_CANDIDATES = [
365
+ // v728:同理必须有 $DSH_HOME 兜底 —— pickBubbleImgDir() 遍历失败还会取 [0],
366
+ // 空数组会让图库上传直接报错。
367
+ path.join(DSH_HOME, 'whale-bubble-imgs'),
368
+ path.join(DSH_HOME, 'profiles', 'web', 'whale-bubble-imgs'),
369
+ ]
370
+ const BUBBLE_IMG_INDEX_NAME = 'bubble-imgs.json'
371
+ // 内置默认泡泡图(语义 id → assets 文件):面板常驻可选;全新安装无用户图库时
372
+ // bubble-img.png 从内置清单回退加载。文件同时位于 package assets/ 与开发回退目录。
373
+ const DEFAULT_BUBBLE_IMGS = [
374
+ { id: 'bimg_petpet', name: 'petpet', file: 'bubble-petpet.gif', format: 'gif' },
375
+ { id: 'bimg_money1', name: 'money1', file: 'bubble-money1.gif', format: 'gif' },
376
+ ]
377
+ function bubbleImgFileCandidates(file) {
378
+ return [
379
+ path.join(PACKAGE_ROOT, 'assets', file),
380
+ ]
381
+ }
382
+ function loadBuiltinBubbleImgBytes(def) {
383
+ if (!def || !def.file) return null
384
+ for (const p of bubbleImgFileCandidates(def.file)) {
385
+ try {
386
+ const bytes = fs.readFileSync(p)
387
+ if (bytes && bytes.length > 0) return bytes
388
+ } catch (err) {}
389
+ }
390
+ return null
391
+ }
41
392
  const SOUND_SETS = {
42
- duck: {
43
- press: [path.join(PACKAGE_ROOT, 'assets', 'Ya1.mp3'), 'D:/TestBox/deepseek/skin/Ya1.mp3'],
44
- release: [path.join(PACKAGE_ROOT, 'assets', 'Ya2.mp3'), 'D:/TestBox/deepseek/skin/Ya2.mp3'],
45
- },
46
- fx1: {
47
- press: [path.join(PACKAGE_ROOT, 'assets', 'D1.mp3'), 'D:/TestBox/deepseek/skin/D1.mp3'],
48
- release: [path.join(PACKAGE_ROOT, 'assets', 'D2.mp3'), 'D:/TestBox/deepseek/skin/D2.mp3'],
49
- },
393
+ duck: { press: [path.join(PACKAGE_ROOT, 'assets', 'Ya1.mp3')], release: [path.join(PACKAGE_ROOT, 'assets', 'Ya2.mp3')] },
394
+ fx1: { press: [path.join(PACKAGE_ROOT, 'assets', 'D1.mp3')], release: [path.join(PACKAGE_ROOT, 'assets', 'D2.mp3')] },
50
395
  }
51
396
  function soundSetFromUrl(url) {
52
397
  try {
@@ -59,29 +404,47 @@ const BALANCE_URL = 'https://api.deepseek.com/user/balance'
59
404
  const BALANCE_TTL_MS = 25000
60
405
  const RUA_GIF_CANDIDATES = [
61
406
  path.join(PACKAGE_ROOT, 'assets', 'rua.gif'),
62
- 'D:/TestBox/deepseek/skin/rua.gif',
63
- 'D:/TestBox/deepseek/rua.gif',
64
407
  ]
65
408
  // DeepSeek CNY prices per million tokens: [空闲时段价, 高峰时段价].
66
409
  // 高峰时段:工作日 9:00–12:00 和 14:00–18:00(北京时间);2026-08-23 起周末全天谷价。
67
- // Adjust here if DeepSeek changes pricing.
410
+ // 价格来源:官方 https://api-docs.deepseek.com/zh-cn/quick_start/pricing
411
+ // 2026-09-10 起 Flash 系列降价:缓存命中 0.05→0.02、未命中 1.5→1、输出 4.5→4(高峰=空闲×2)。
412
+ // 2026-09-14 复核:价格数字与上表一致,无需改动。
68
413
  const PEAK_HOURS = [
69
414
  [9, 12],
70
415
  [14, 18],
71
416
  ]
72
- const BASE_PRICE = { hit: [0.05, 0.1], miss: [1.5, 3.0], out: [4.5, 9.0] }
73
- // deepseek-v4-pro 为 flash 的 3 倍价(官方 2026-08-17 生效);vision-exp 与 flash 同价
417
+ // Flash(正式模型名 deepseek-flash = DeepSeek-V4.1-Flash)
418
+ const BASE_PRICE = { hit: [0.02, 0.04], miss: [1, 2], out: [4, 8] }
419
+ // Pro 为 Flash 的 3 倍价(官方 2026-08-17 生效)。官方 2026-09-14 公告:V4 Pro 继续提供 API 服务、
420
+ // 计费方式保持不变——此前"9/14 12:00 起 pro 请求路由到 V4.1 Flash 并按 Flash 价计费"的安排已取消,
421
+ // 因此这里不做按日期的降级切换。
74
422
  const PRO_PRICE = { hit: [0.15, 0.3], miss: [4.5, 9.0], out: [13.5, 27.0] }
75
423
  const PRICING = {
76
- 'deepseek-v4-flash-vision-exp': BASE_PRICE,
77
- 'deepseek-v4-flash': BASE_PRICE,
424
+ 'deepseek-flash': BASE_PRICE,
425
+ 'deepseek-v4-flash-vision-exp': BASE_PRICE, // 旧名已下线,请求由 V4.1-Flash 提供,按 Flash 价计费
426
+ 'deepseek-v4-flash': BASE_PRICE, // 同上
78
427
  'deepseek-v4-pro': PRO_PRICE,
79
- 'deepseek-chat': BASE_PRICE,
80
- 'deepseek-reasoner': BASE_PRICE,
81
428
  _default: BASE_PRICE,
82
429
  }
430
+ // 用户自定义单价(自定义 API 模型面板里填的「元/百万 token」),按 matchIds 子串匹配,优先于内置价目表
431
+ let CUSTOM_PRICES = {}
432
+ // 自定义单价的币种/汇率(与 CUSTOM_PRICES 同键):单价若按美元填写,记账时按用户填的汇率换算成人民币
433
+ let CUSTOM_PRICE_META = {}
434
+ function customPriceMetaFor(model) {
435
+ const m = String(model || '').toLowerCase()
436
+ // 按键长降序:最长命中优先,避免短关键字(如 pro)误伤别的模型
437
+ for (const key of Object.keys(CUSTOM_PRICE_META).sort((a, b) => b.length - a.length)) {
438
+ if (key && m.indexOf(key) !== -1) return CUSTOM_PRICE_META[key]
439
+ }
440
+ return null
441
+ }
83
442
  function priceFor(model) {
84
443
  const m = String(model || '').toLowerCase()
444
+ // 自定义单价同样按键长降序匹配(与 customPriceMetaFor 保持一致)
445
+ for (const key of Object.keys(CUSTOM_PRICES).sort((a, b) => b.length - a.length)) {
446
+ if (key && m.indexOf(key) !== -1) return CUSTOM_PRICES[key]
447
+ }
85
448
  for (const key of Object.keys(PRICING)) {
86
449
  if (key === '_default') continue
87
450
  if (m.indexOf(key) !== -1) return PRICING[key]
@@ -113,1327 +476,114 @@ const JSON_HEADERS = {
113
476
  'Cache-Control': 'no-store',
114
477
  }
115
478
 
116
- const WIDGET_JS = `(function () {
117
- if (window.__dshWhaleWidget) return
118
- window.__dshWhaleWidget = true
119
-
120
- var MIN_SCALE = 0.6
121
- var MAX_SCALE = 2.5
122
- var STEP = 0.1
123
- var CLICK_SQ = 9
124
- var REFRESH_MS = 60000
125
- var CHANGE_MS = 900
126
- var ANIM_MS = 700
127
- var BUBBLE_MS = 5000
128
- var FETCH_TIMEOUT_MS = 25000
129
- var BALANCE_URL = '/dsh-whale/balance.json'
130
- var SIZE_URL = '/dsh-whale/size.json'
131
- var IMG_URL = '/dsh-whale/image.png?v=2'
132
- var GIF_URL = '/dsh-whale/rua.gif'
133
-
134
- var css = [
135
- '.dshwv-root{position:fixed;right:0;bottom:0;--dshw-scale:1;--dshw-base:clamp(122px,calc(min(250px,min(100vw,100vh) * 0.28) * var(--dshw-scale)),625px);width:var(--dshw-base);height:var(--dshw-base);pointer-events:none;user-select:none;-webkit-user-select:none;z-index:9999;font-family:inherit;transition:left .16s ease,top .16s ease,transform .3s ease}',
136
- '.dshwv-root.dshwv-left{transform:scaleX(-1)}',
137
- '.dshwv-root.dshwv-dragging{cursor:grabbing;transition:none}',
138
- '.dshwv-body{position:absolute;left:0;top:0;width:100%;height:100%;transform-origin:50% 100%;transition:transform .22s cubic-bezier(.34,1.56,.64,1)}',
139
- '.dshwv-img{position:absolute;right:0;bottom:0;width:59.45%;height:59.45%;display:block;pointer-events:none;-webkit-user-drag:none;user-select:none}',
140
- '.dshwv-bubble{position:absolute;left:0;top:0;width:100%;aspect-ratio:1026/700;pointer-events:none;z-index:1;--dshw-u:calc(var(--dshw-base) / 1026)}',
141
- '.dshwv-bubble svg{display:block;width:100%;height:100%;pointer-events:none}',
142
- '.dshwv-bubble svg path,.dshwv-bubble svg ellipse{pointer-events:none;cursor:pointer}',
143
- '.dshwv-bubble.dshwv-bubble-open svg path,.dshwv-bubble.dshwv-bubble-open svg ellipse{pointer-events:visiblePainted}',
144
- '.dshwv-bubble .dshwv-bshape,.dshwv-bubble .dshwv-b1,.dshwv-bubble .dshwv-b2{opacity:0;transform:scale(.7);transform-box:fill-box;transform-origin:50% 50%;transition:opacity .2s ease,transform .2s ease}',
145
- '.dshwv-bubble.dshwv-bubble-open .dshwv-bshape,.dshwv-bubble.dshwv-bubble-open .dshwv-b1,.dshwv-bubble.dshwv-bubble-open .dshwv-b2{opacity:1;transform:none}',
146
- '.dshwv-gif{position:absolute;left:44.25%;top:38%;transform:translate(-50%,-50%);max-width:calc(var(--dshw-u) * 560);max-height:calc(var(--dshw-u) * 400);display:none;opacity:0;transition:opacity .2s ease;pointer-events:none;-webkit-user-drag:none;user-select:none;object-fit:contain}',
147
- '.dshwv-root.dshwv-left .dshwv-gif{transform:translate(-50%,-50%) scaleX(-1)}',
148
- '.dshwv-bubble.dshwv-bubble-open .dshwv-gif{opacity:1}',
149
- '.dshwv-bubble.dshwv-bubble-open .dshwv-b2{transition-delay:0s}',
150
- '.dshwv-bubble.dshwv-bubble-open .dshwv-b1{transition-delay:.13s}',
151
- '.dshwv-bubble.dshwv-bubble-open .dshwv-bshape{transition-delay:.26s}',
152
- '.dshwv-bubble .dshwv-bshape{transition-delay:.1s}',
153
- '.dshwv-bubble .dshwv-b1{transition-delay:.2s}',
154
- '.dshwv-bubble .dshwv-b2{transition-delay:.3s}',
155
- '.dshwv-text{position:absolute;left:44.25%;top:38%;transform:translate(-50%,-50%);text-align:center;color:#536ba9;line-height:1.15;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .16s ease,transform .3s ease}',
156
- '.dshwv-bubble.dshwv-bubble-open .dshwv-text{opacity:1;transition:opacity .16s ease .36s,transform .3s ease}',
157
- '.dshwv-root.dshwv-left .dshwv-text{transform:translate(-50%,-50%) scaleX(-1)}',
158
- '.dshwv-label{font-size:calc(var(--dshw-u) * 66);font-weight:600;letter-spacing:.06em}',
159
- '.dshwv-amount{font-size:calc(var(--dshw-u) * 128);font-weight:800;line-height:1.05}',
160
- '.dshwv-period{font-size:calc(var(--dshw-u) * 104);font-weight:800;line-height:1.05}',
161
- '.dshwv-wrap{white-space:normal;max-width:calc(var(--dshw-u) * 560);line-height:1.2}',
162
- '.dshwv-hint{font-size:calc(var(--dshw-u) * 56);color:#9fb0d9;letter-spacing:.02em;margin-top:calc(var(--dshw-u) * 9);min-height:calc(var(--dshw-u) * 64);line-height:1.15}',
163
- '.dshwv-menu-btn{position:absolute;top:calc(40.55% + 4px);right:4px;width:26px;height:26px;border:none;border-radius:6px;background:rgba(32,49,112,.85);cursor:pointer;pointer-events:auto;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;padding:0;z-index:2;opacity:0;transition:opacity .15s ease}',
164
- '.dshwv-menu-btn.dshwv-menu-btn-visible{opacity:1}',
165
- '.dshwv-menu-btn span{display:block;width:14px;height:2px;background:#fff;border-radius:1px}',
166
- '.dshwv-menu-btn:hover{background:#203170}',
167
- '.dshwv-menu{position:fixed;min-width:196px;background:rgba(255,255,255,.92);border:1px solid rgba(32,49,112,.35);border-radius:10px;padding:10px 12px;opacity:0;transform:scale(.92) translateY(-4px);transform-origin:top right;transition:opacity .18s ease,transform .2s cubic-bezier(.34,1.56,.64,1);pointer-events:none;z-index:10000;box-shadow:0 6px 18px rgba(0,0,0,.18);color-scheme:light}',
168
- '.dshwv-menu.dshwv-menu-open{opacity:1;transform:scale(1) translateY(0);pointer-events:auto}',
169
- '.dshwv-menu-row{display:flex;align-items:center;gap:8px;margin:5px 0;color:#203170;font-size:12px;white-space:nowrap}',
170
- '.dshwv-range{flex:1;min-width:0;accent-color:#203170}',
171
- '.dshwv-number{width:44px;border:1px solid rgba(32,49,112,.4);border-radius:6px;padding:2px 4px;font-size:12px;color:#203170;background:#fff;box-sizing:border-box}',
172
- '.dshwv-number:disabled{opacity:.4;background:rgba(32,49,112,.06);cursor:not-allowed}',
173
- '.dshwv-sound{flex:1;border:1px solid rgba(32,49,112,.4);border-radius:6px;background:rgba(32,49,112,.08);color:#203170;font-size:12px;padding:3px 0;cursor:pointer}',
174
- '.dshwv-sound:hover{background:rgba(32,49,112,.16)}',
175
- '.dshwv-check{width:16px;height:16px;accent-color:#203170;cursor:pointer;flex:0 0 auto}',
176
- '.dshwv-menu-sep{height:1px;background:rgba(32,49,112,.25);margin:6px 0}',
177
- '.dshwv-volpct{width:44px;text-align:right;color:#203170;font-size:12px}'
178
- ].join('\\n')
179
-
180
- var styleEl = document.createElement('style')
181
- styleEl.textContent = css
182
- document.head.appendChild(styleEl)
183
-
184
- var root = document.createElement('div')
185
- root.className = 'dshwv-root'
186
-
187
- var img = document.createElement('img')
188
- img.className = 'dshwv-img'
189
- img.src = IMG_URL
190
- img.alt = 'DeepSeek 余额'
191
- img.draggable = false
192
-
193
- var menuBtn = document.createElement('button')
194
- menuBtn.type = 'button'
195
- menuBtn.className = 'dshwv-menu-btn'
196
- menuBtn.title = '菜单'
197
- menuBtn.innerHTML = '<span></span><span></span><span></span>'
198
- menuBtn.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu() })
199
-
200
- var menuBox = document.createElement('div')
201
- menuBox.className = 'dshwv-menu'
202
- function menuLabel(text) {
203
- var s = document.createElement('span')
204
- s.textContent = text
205
- return s
206
- }
207
- function menuRow() {
208
- var r = document.createElement('div')
209
- r.className = 'dshwv-menu-row'
210
- return r
211
- }
212
- var scaleInput = document.createElement('input')
213
- scaleInput.type = 'range'
214
- scaleInput.min = String(MIN_SCALE)
215
- scaleInput.max = String(MAX_SCALE)
216
- scaleInput.step = '0.1'
217
- scaleInput.className = 'dshwv-range'
218
- scaleInput.value = '1.5'
219
- var scaleNumber = document.createElement('input')
220
- scaleNumber.type = 'number'
221
- scaleNumber.min = '1'
222
- scaleNumber.max = '20'
223
- scaleNumber.step = '1'
224
- scaleNumber.className = 'dshwv-number'
225
- scaleNumber.value = '10'
226
- scaleInput.addEventListener('pointerdown', function () { root.style.transition = 'none' })
227
- scaleInput.addEventListener('input', function () { setScale(scaleInput.value) })
228
- scaleInput.addEventListener('change', function () { root.style.transition = '' })
229
- scaleNumber.addEventListener('focus', function () { root.style.transition = 'none' })
230
- scaleNumber.addEventListener('blur', function () { root.style.transition = '' })
231
- scaleNumber.addEventListener('input', function () {
232
- var v = Math.round(Number(scaleNumber.value))
233
- var s = MIN_SCALE + Math.max(0, Math.min(20, v) - 1) * (MAX_SCALE - MIN_SCALE) / 19
234
- setScale(s)
235
- })
236
- scaleNumber.addEventListener('change', function () {
237
- var v = Math.round(Number(scaleNumber.value))
238
- var s = MIN_SCALE + Math.max(0, Math.min(20, v) - 1) * (MAX_SCALE - MIN_SCALE) / 19
239
- setScale(s)
240
- root.style.transition = ''
241
- })
242
- var soundSelect = document.createElement('select')
243
- soundSelect.className = 'dshwv-sound'
244
- function soundOpt(value, label) {
245
- var o = document.createElement('option')
246
- o.value = value
247
- o.textContent = label
248
- return o
249
- }
250
- soundSelect.appendChild(soundOpt('duck', '小黄鸭'))
251
- soundSelect.appendChild(soundOpt('fx1', '音效1'))
252
- soundSelect.addEventListener('change', function () { setSoundSet(soundSelect.value) })
253
- var usageSelect = document.createElement('select')
254
- usageSelect.className = 'dshwv-sound'
255
- usageSelect.appendChild(soundOpt('ledger', '小鲸鱼记账 (推荐)'))
256
- usageSelect.appendChild(soundOpt('token', '实时·令牌 (用法:去问dsh)'))
257
- usageSelect.addEventListener('change', function () { setUsageMode(usageSelect.value) })
258
- var peakSelect = document.createElement('select')
259
- peakSelect.className = 'dshwv-sound'
260
- peakSelect.appendChild(soundOpt('default', '默认'))
261
- peakSelect.appendChild(soundOpt('liangwen', '梁文峰谷'))
262
- peakSelect.appendChild(soundOpt('qiangqiang', '!?强强?!'))
263
- peakSelect.addEventListener('change', function () { setPeakMode(peakSelect.value) })
264
- var bubbleToggle = document.createElement('input')
265
- bubbleToggle.type = 'checkbox'
266
- bubbleToggle.className = 'dshwv-check'
267
- bubbleToggle.checked = true
268
- bubbleToggle.title = '开启/关闭思考气泡'
269
- bubbleToggle.addEventListener('change', function () { setBubbleOn(bubbleToggle.checked) })
270
- var turnCostToggle = document.createElement('input')
271
- turnCostToggle.type = 'checkbox'
272
- turnCostToggle.className = 'dshwv-check'
273
- turnCostToggle.checked = true
274
- turnCostToggle.title = '每轮对话结束后自动显示本轮消耗金额'
275
- turnCostToggle.addEventListener('change', function () { setTurnCostOn(turnCostToggle.checked) })
276
- var turnCostCloseInput = document.createElement('input')
277
- turnCostCloseInput.type = 'number'
278
- turnCostCloseInput.min = '0'
279
- turnCostCloseInput.step = '1'
280
- turnCostCloseInput.className = 'dshwv-number'
281
- turnCostCloseInput.value = '5'
282
- turnCostCloseInput.disabled = false // 跟随「每轮消耗提示」开关
283
- turnCostCloseInput.title = '填 0 表示不自动关闭,需手动点击关闭'
284
- turnCostCloseInput.addEventListener('input', function () { setTurnCostClose(turnCostCloseInput.value) })
285
- turnCostCloseInput.addEventListener('change', function () { setTurnCostClose(turnCostCloseInput.value) })
286
- var scrollGapToggle = document.createElement('input')
287
- scrollGapToggle.type = 'checkbox'
288
- scrollGapToggle.className = 'dshwv-check'
289
- scrollGapToggle.checked = false
290
- scrollGapToggle.title = '开启后挂件右侧按设定像素避开滚动条;关闭则贴边(盖住滚动条)'
291
- scrollGapToggle.addEventListener('change', function () { setScrollGapOn(scrollGapToggle.checked) })
292
- var scrollGapInput = document.createElement('input')
293
- scrollGapInput.type = 'number'
294
- scrollGapInput.min = '0'
295
- scrollGapInput.step = '1'
296
- scrollGapInput.className = 'dshwv-number'
297
- scrollGapInput.value = '17'
298
- scrollGapInput.disabled = true // 默认避让关 → 宽度不可修改,勾选后启用
299
- scrollGapInput.title = '避让滚动条的像素宽度,填 0 表示贴边'
300
- scrollGapInput.addEventListener('input', function () { setScrollGapPx(scrollGapInput.value) })
301
- scrollGapInput.addEventListener('change', function () { setScrollGapPx(scrollGapInput.value) })
302
- var row1 = menuRow()
303
- row1.appendChild(menuLabel('大小'))
304
- row1.appendChild(scaleInput)
305
- row1.appendChild(scaleNumber)
306
- var row2 = menuRow()
307
- row2.appendChild(menuLabel('音效'))
308
- row2.appendChild(soundSelect)
309
- var volInput = document.createElement('input')
310
- volInput.type = 'range'
311
- volInput.min = '0'
312
- volInput.max = '1'
313
- volInput.step = '0.05'
314
- volInput.className = 'dshwv-range'
315
- volInput.value = '0.9'
316
- var volPct = document.createElement('span')
317
- volPct.className = 'dshwv-volpct'
318
- volPct.textContent = '90%'
319
- volInput.addEventListener('input', function () { setVol(volInput.value) })
320
- var row3 = menuRow()
321
- row3.appendChild(menuLabel('音量'))
322
- row3.appendChild(volInput)
323
- row3.appendChild(volPct)
324
- var row4 = menuRow()
325
- row4.appendChild(menuLabel('用量'))
326
- row4.appendChild(usageSelect)
327
- var row5 = menuRow()
328
- row5.appendChild(menuLabel('峰谷'))
329
- row5.appendChild(peakSelect)
330
- var row6 = menuRow()
331
- row6.appendChild(menuLabel('气泡'))
332
- row6.appendChild(bubbleToggle)
333
- var menuSep1 = document.createElement('div')
334
- menuSep1.className = 'dshwv-menu-sep'
335
- var row7 = menuRow()
336
- row7.appendChild(menuLabel('每轮消耗提示'))
337
- row7.appendChild(turnCostToggle)
338
- row7.appendChild(menuLabel('自动关闭'))
339
- row7.appendChild(turnCostCloseInput)
340
- row7.appendChild(menuLabel('秒'))
341
- var row9 = menuRow()
342
- row9.appendChild(menuLabel('避让滚动条'))
343
- row9.appendChild(scrollGapToggle)
344
- row9.appendChild(menuLabel('宽度'))
345
- row9.appendChild(scrollGapInput)
346
- row9.appendChild(menuLabel('px'))
347
- menuBox.appendChild(row1)
348
- menuBox.appendChild(row2)
349
- menuBox.appendChild(row3)
350
- menuBox.appendChild(row4)
351
- menuBox.appendChild(row5)
352
- menuBox.appendChild(row6)
353
- menuBox.appendChild(row7)
354
- menuBox.appendChild(menuSep1)
355
- menuBox.appendChild(row9)
356
-
357
- var textBox = document.createElement('div')
358
- textBox.className = 'dshwv-text'
359
- var labelEl = document.createElement('div')
360
- labelEl.className = 'dshwv-label'
361
- labelEl.textContent = 'DeepSeek 余额'
362
- var amountEl = document.createElement('div')
363
- amountEl.className = 'dshwv-amount'
364
- var hintEl = document.createElement('div')
365
- hintEl.className = 'dshwv-hint'
366
- textBox.appendChild(labelEl)
367
- textBox.appendChild(amountEl)
368
- textBox.appendChild(hintEl)
369
-
370
- var bubbleBox = document.createElement('div')
371
- bubbleBox.className = 'dshwv-bubble'
372
- bubbleBox.innerHTML = '<svg viewBox="0 0 1026 700" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">' +
373
- '<path class="dshwv-bshape" fill="#FFFFFF" stroke="#203170" stroke-width="18" stroke-linejoin="round" stroke-linecap="round" d="M 827 248 A 373 232 0 1 0 81 246 A 373 232 0 0 0 301 465 A 57 32 10 0 0 413 484 A 373 232 0 0 0 827 248 Z"/>' +
374
- '<ellipse class="dshwv-b1" cx="352" cy="561" rx="37.5" ry="26" fill="#FFFFFF" stroke="#203170" stroke-width="18"/>' +
375
- '<ellipse class="dshwv-b2" cx="442" cy="646" rx="24.5" ry="18" fill="#FFFFFF" stroke="#203170" stroke-width="18"/>' +
376
- '</svg>'
377
- var gifEl = document.createElement('img')
378
- gifEl.className = 'dshwv-gif'
379
- gifEl.src = GIF_URL
380
- gifEl.alt = ''
381
- gifEl.draggable = false
382
- bubbleBox.appendChild(gifEl)
383
- var gifFailed = false
384
- gifEl.onerror = function () { gifFailed = true }
385
- bubbleBox.appendChild(textBox)
386
- bubbleBox.addEventListener('click', function (e) {
387
- e.stopPropagation()
388
- if (!bubbleShown) return
389
- if (costBubbleActive) {
390
- // 消耗金额泡泡:点击关闭(确认)
391
- hideCostBubble()
392
- return
393
- }
394
- if (bubbleRandomActive) {
395
- // 再次点击:关闭
396
- hideBubble()
397
- } else {
398
- // 首次点击:切到随机台词段,并重置自动关闭计时——
399
- // 保证第二段台词有完整停留时间(否则第 4 秒点击只看到 0.5 秒)
400
- bubbleRandomActive = true
401
- bubbleRandomLines = pickRandomLines()
402
- swapBubbleContent(function () { applyBubbleLines(bubbleRandomLines) })
403
- if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
404
- bubbleTimer = setTimeout(hideBubble, BUBBLE_MS)
405
- }
406
- })
407
-
408
- var body = document.createElement('div')
409
- body.className = 'dshwv-body'
410
- body.appendChild(img)
411
- body.appendChild(bubbleBox)
412
- root.appendChild(body)
413
- root.appendChild(menuBtn)
414
- document.body.appendChild(root)
415
- document.body.appendChild(menuBox)
416
-
417
- // Position model: the widget is ALWAYS expressed in left/top px (so edge snaps
418
- // animate smoothly via the CSS transition on both sides — switching to
419
- // right/auto cannot transition and flashes). The anchor info (h/v + offsets)
420
- // lives in state and is used by settle() to recompute coordinates on window
421
- // resize and size changes, keeping the widget glued to its anchored edge.
422
- var state = {
423
- scale: 1.5,
424
- h: 'right',
425
- hOff: 0,
426
- v: 'bottom',
427
- vOff: 0,
428
- left: 0,
429
- top: 0,
430
- balance: null,
431
- currency: null,
432
- todayUsage: null,
433
- isPeak: false,
434
- status: 'loading',
435
- message: ''
436
- }
437
- var busy = false
438
- var settleTimer = null
439
- var animDelayTimer = null
440
- var drag = null
441
- var shown = null
442
- var animId = null
443
- var bubbleShown = false
444
- var bubbleTimer = null
445
- var bubbleRandomActive = false
446
- var bubbleRandomLines = null
447
- var BUBBLE_STYLE_CLASS = { A: 'dshwv-label', B: 'dshwv-amount', P: 'dshwv-period', C: 'dshwv-hint' }
448
- function pickOne(arr) { return arr[Math.floor(Math.random() * arr.length)] }
449
- function singleCenter(style, text, color, wrap) { return [null, { t: text, s: style, c: color || '', w: !!wrap }, null] }
450
- function buildGroup1() {
451
- var peak = !!state.isPeak
452
- var offText = '空闲时段'
453
- var peakText = '高峰时段'
454
- if (peakMode === 'liangwen') {
455
- offText = '梁文谷'
456
- peakText = '梁文峰'
457
- } else if (peakMode === 'qiangqiang') {
458
- offText = '!?谷谷?!'
459
- peakText = '!?峰峰?!'
460
- }
461
- return [
462
- { t: '当前时间段为:', s: 'A', c: '' },
463
- { t: peak ? peakText : offText, s: 'P', c: peak ? '#e0433f' : '#2fa24c' },
464
- { t: '今日已用 ' + fmt(state.todayUsage, state.currency), s: 'C', c: '' },
465
- ]
466
- }
467
- var RANDOM_GROUPS = [
468
- { w: 45, lines: buildGroup1 },
469
- { w: 7, lines: function () { return singleCenter('B', pickOne(['好模型... ↓', '好女孩...↓'])) } },
470
- { w: 7, lines: function () { return singleCenter('A', pickOne(['不知道用户有什么用,先赶走吧~', '我...我...我也要挣钱吗?', '我去吃饭啦,测完叫我', '压力一只蓝色大肥鱼?!', 'DeepSleep...', '坏了...用户彻底怒了!']), '', true) } },
471
- { w: 10, lines: function () { return { gif: true } } },
472
- { w: 3, lines: function () { return singleCenter('A', pickOne(['你目录里的dsh是什么...大烧货吗...?', '恭喜你实现token自由!token全跑了!', '真当我是便宜货啊...']), '', true) } },
473
- { w: 1, lines: function () { return singleCenter('B', '哦鲸鲸... ') } },
479
+ // 浏览器端挂件代码已拆分为独立文件 whale-widget.js(与本文件同目录),
480
+ // 不再内嵌模板字面量:可直接 node --check / IDE 高亮,改 widget 后硬刷新页面即生效(无需重启 DSH)。
481
+ // 每次请求按 mtime 判断是否需要重读,避免常驻缓存导致改了不生效。
482
+ const WIDGET_FILE_CANDIDATES = [
483
+ path.join(path.dirname(fileURLToPath(import.meta.url)), 'whale-widget.js'),
484
+ path.join(PACKAGE_ROOT, 'whale-widget.js'),
485
+ path.join(PACKAGE_ROOT, 'assets', 'whale-widget.js'),
474
486
  ]
475
- function pickRandomLines() {
476
- var total = 0
477
- for (var i = 0; i < RANDOM_GROUPS.length; i++) total += RANDOM_GROUPS[i].w
478
- var r = Math.random() * total
479
- for (var i = 0; i < RANDOM_GROUPS.length; i++) {
480
- r -= RANDOM_GROUPS[i].w
481
- if (r < 0) return RANDOM_GROUPS[i].lines()
482
- }
483
- return RANDOM_GROUPS[RANDOM_GROUPS.length - 1].lines()
484
- }
485
- function applyBubbleLines(lines) {
486
- if (lines && lines.gif) {
487
- // gif 台词组:只显示 gif,隐藏三行文字(display 必须显式覆盖 CSS 的 none)
488
- if (gifFailed) {
489
- // gif 加载失败/路由缺失:降级为文字台词,避免空白白色气泡
490
- lines = singleCenter('A', pickOne(['gif 加载失败了...', '今天没有动图给你看~', '呜呜 动图不见了...']), '', true)
491
- } else {
492
- if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
493
- gifEl.style.display = 'block'
494
- gifEl.style.opacity = ''
495
- labelEl.style.display = 'none'
496
- amountEl.style.display = 'none'
497
- hintEl.style.display = 'none'
498
- return
499
- }
500
- }
501
- if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
502
- gifEl.style.display = 'none'
503
- gifEl.style.opacity = ''
504
- var els = [labelEl, amountEl, hintEl]
505
- for (var i = 0; i < 3; i++) {
506
- var el = els[i]
507
- var ln = lines && lines[i]
508
- if (ln) {
509
- el.style.display = ''
510
- el.className = (BUBBLE_STYLE_CLASS[ln.s] || 'dshwv-label') + (ln.w ? ' dshwv-wrap' : '')
511
- el.textContent = ln.t
512
- el.style.color = ln.c || ''
513
- } else {
514
- el.style.display = 'none'
515
- el.textContent = ''
516
- el.style.color = ''
517
- }
518
- }
519
- }
520
- var bubbleSwapTimer = null
521
- var hintFadeTimer = null
522
- var gifFadeTimer = null
523
- var lastHintText = null
524
- function setHint(text) {
525
- // 首次/恢复(lastHintText===null)时直接写文本,不做淡出淡入——否则
526
- // 气泡打开或按压重开时会先淡出再淡入,造成「消失一下又出现」。
527
- // 只有气泡打开期间的内容变化(加载中→今日已用)才走动画。
528
- if (text === lastHintText) return
529
- var first = lastHintText === null
530
- lastHintText = text
531
- if (first || !bubbleShown) {
532
- hintEl.textContent = text
533
- return
534
- }
535
- hintEl.style.transition = 'opacity .18s ease'
536
- hintEl.style.opacity = '0'
537
- hintFadeTimer = setTimeout(function () {
538
- hintFadeTimer = null
539
- hintEl.textContent = text
540
- hintEl.style.opacity = '1'
541
- setTimeout(function () {
542
- hintEl.style.transition = ''
543
- hintEl.style.opacity = ''
544
- }, 220)
545
- }, 190)
546
- }
547
- function swapBubbleContent(applyFn) {
548
- if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null }
549
- textBox.style.transition = 'opacity .18s ease'
550
- textBox.style.opacity = '0'
551
- bubbleSwapTimer = setTimeout(function () {
552
- bubbleSwapTimer = null
553
- applyFn()
554
- textBox.style.opacity = '1'
555
- setTimeout(function () {
556
- textBox.style.transition = ''
557
- textBox.style.opacity = ''
558
- }, 220)
559
- }, 190)
560
- }
561
- function restoreBubbleLines() {
562
- if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null }
563
- if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null }
564
- if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
565
- lastHintText = null
566
- textBox.style.transition = ''
567
- textBox.style.opacity = ''
568
- gifEl.style.display = 'none'
569
- gifEl.style.opacity = ''
570
- labelEl.style.display = ''
571
- labelEl.className = 'dshwv-label'
572
- labelEl.textContent = 'DeepSeek 余额'
573
- labelEl.style.color = ''
574
- amountEl.style.display = ''
575
- amountEl.className = 'dshwv-amount'
576
- amountEl.style.color = ''
577
- hintEl.style.display = ''
578
- hintEl.className = 'dshwv-hint'
579
- hintEl.style.color = ''
580
- render()
581
- }
582
- function showBubble() {
583
- if (!bubbleOn) return
584
- // 消耗金额泡泡显示期间,余额变动不再弹出普通泡泡
585
- if (costBubbleActive) return
586
- if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
587
- if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
588
- bubbleShown = true
589
- bubbleRandomActive = false
590
- restoreBubbleLines()
591
- bubbleBox.classList.add('dshwv-bubble-open')
592
- // 默认展示当前内容;点击气泡切到随机台词段;总时长 5 秒自动关闭
593
- bubbleTimer = setTimeout(hideBubble, BUBBLE_MS)
594
- }
595
- function hideBubble() {
596
- if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
597
- if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null }
598
- if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null }
599
- textBox.style.transition = ''
600
- textBox.style.opacity = ''
601
- hintEl.style.transition = ''
602
- hintEl.style.opacity = ''
603
- bubbleRandomActive = false
604
- bubbleRandomLines = null
605
- bubbleShown = false
606
- // 只销毁 gif 显示;三行文字保持现状让气泡自然淡出——不能在关闭瞬间
607
- // 恢复成余额内容(否则随机台词界面会闪现余额)。文字恢复交给下次
608
- // showBubble() 的 restoreBubbleLines()(那时气泡隐藏,恢复过程不可见)。
609
- bubbleBox.classList.remove('dshwv-bubble-open')
610
- // gif 靠 CSS opacity 过渡淡出;display:none 会跳过过渡,须等淡出完成再隐藏
611
- gifFadeTimer = setTimeout(function () {
612
- gifFadeTimer = null
613
- gifEl.style.display = 'none'
614
- }, 240)
615
- }
616
-
617
- // —— 每轮对话消耗金额泡泡 ——
618
- var costBubbleTimer = null
619
- function showCostBubble(amount) {
620
- if (!bubbleOn || !turnCostOn) return
621
- if (costBubbleTimer) { clearTimeout(costBubbleTimer); costBubbleTimer = null }
622
- if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
623
- if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
624
- // 取消进行中的余额数字滚动与延迟计时器,避免竞态覆盖成本金额
625
- if (animId) { cancelAnimationFrame(animId); animId = null }
626
- if (animDelayTimer) { clearTimeout(animDelayTimer); animDelayTimer = null }
627
- if (settleTimer) { clearTimeout(settleTimer); settleTimer = null }
628
- costBubbleActive = true
629
- bubbleRandomActive = false
630
- bubbleShown = true
631
- lastHintText = null
632
- // 样式:第一行 A(标签),第二行 B(红色金额),居中两行
633
- gifEl.style.display = 'none'
634
- gifEl.style.opacity = ''
635
- labelEl.style.display = ''
636
- labelEl.className = 'dshwv-label'
637
- labelEl.textContent = '上一轮对话消耗:'
638
- labelEl.style.color = ''
639
- amountEl.style.display = ''
640
- amountEl.className = 'dshwv-amount'
641
- amountEl.textContent = '¥ ' + (isFinite(amount) ? Number(amount).toFixed(2) : '--')
642
- amountEl.style.color = '#e0433f'
643
- hintEl.style.display = 'none'
644
- hintEl.textContent = ''
645
- hintEl.style.color = ''
646
- textBox.style.transition = ''
647
- textBox.style.opacity = ''
648
- bubbleBox.classList.add('dshwv-bubble-open')
649
- if (turnCostCloseMs > 0) {
650
- costBubbleTimer = setTimeout(hideCostBubble, turnCostCloseMs)
651
- }
652
- }
653
- function hideCostBubble() {
654
- if (costBubbleTimer) { clearTimeout(costBubbleTimer); costBubbleTimer = null }
655
- costBubbleActive = false
656
- hideBubble()
657
- }
658
-
659
- function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v) }
660
- function viewport() {
661
- return {
662
- w: window.innerWidth || document.documentElement.clientWidth || 1280,
663
- h: window.innerHeight || document.documentElement.clientHeight || 800
664
- }
665
- }
666
- function rightGap() {
667
- // 开关关闭:贴边(不避让滚动条)
668
- if (!scrollGapOn) return 0
669
- // 开启:用用户填写的像素;填 0 也贴边
670
- return scrollGapPx > 0 ? scrollGapPx : 0
671
- }
672
- function fmt(balance, currency) {
673
- var num = Number(balance)
674
- var fixed = isFinite(num) ? num.toFixed(2) : '--'
675
- return currency === 'CNY' ? '¥ ' + fixed : fixed + ' ' + currency
676
- }
677
- function animateAmount(from, to, currency, duration) {
678
- // 消耗金额泡泡显示期间,余额数字滚动不触碰金额行
679
- if (costBubbleActive) return
680
- if (animId) cancelAnimationFrame(animId)
681
- if (from === null || !isFinite(from)) from = to
682
- if (from === to) {
683
- shown = to
684
- amountEl.textContent = fmt(to, currency)
685
- return
686
- }
687
- var startTime = null
688
- function step(ts) {
689
- // 帧级保护:成本泡泡出现后立即停止滚动,避免后续帧把余额写进金额行
690
- if (costBubbleActive) {
691
- animId = null
692
- return
693
- }
694
- if (startTime === null) startTime = ts
695
- var t = Math.min(1, (ts - startTime) / duration)
696
- var eased = 1 - Math.pow(1 - t, 3)
697
- var val = from + (to - from) * eased
698
- amountEl.textContent = fmt(val, currency)
699
- if (t < 1) {
700
- animId = requestAnimationFrame(step)
701
- } else {
702
- animId = null
703
- shown = to
704
- amountEl.textContent = fmt(to, currency)
705
- }
706
- }
707
- animId = requestAnimationFrame(step)
708
- }
709
- function render() {
710
- // 消耗金额泡泡显示期间,余额渲染不覆盖其内容(金额行/标题行/提示行)
711
- if (costBubbleActive) return
712
- var amount, hint
713
- if (state.status === 'error') {
714
- amount = shown !== null ? fmt(shown, state.currency) : '--'
715
- hint = state.message ? state.message.slice(0, 14) : '获取失败 · 点击重试'
716
- } else if (state.balance === null) {
717
- amount = shown !== null ? fmt(shown, state.currency) : '…'
718
- hint = '加载中…'
719
- } else {
720
- amount = shown !== null ? fmt(shown, state.currency) : fmt(state.balance, state.currency)
721
- hint = '今日已用 ' + (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.currency) : '--')
722
- }
723
- amountEl.textContent = amount
724
- if (bubbleRandomActive && bubbleRandomLines) {
725
- applyBubbleLines(bubbleRandomLines)
726
- } else {
727
- setHint(hint)
728
- }
729
- }
730
- function express() {
731
- root.style.right = 'auto'
732
- root.style.bottom = 'auto'
733
- root.style.left = state.left + 'px'
734
- root.style.top = state.top + 'px'
735
- root.classList.toggle('dshwv-left', state.h === 'left')
736
- }
737
- function settle() {
738
- var vp = viewport()
739
- var w = root.offsetWidth || root.getBoundingClientRect().width || 0
740
- var h = root.offsetHeight || root.getBoundingClientRect().height || 0
741
- if (drag && drag.active) {
742
- // mid-drag resize: keep the pointer-follow position, just clamp into view
743
- state.left = clamp(state.left, 0, Math.max(0, vp.w - w - rightGap()))
744
- state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
745
- express()
746
- return
747
- }
748
- if (state.h === 'right') {
749
- state.left = Math.max(0, vp.w - w - state.hOff - rightGap())
750
- } else if (state.h === 'left') {
751
- state.left = state.hOff
752
- } else {
753
- state.left = clamp(state.left, 0, Math.max(0, vp.w - w - rightGap()))
754
- } if (state.v === 'bottom') {
755
- state.top = Math.max(0, vp.h - h - state.vOff)
756
- } else if (state.v === 'top') {
757
- state.top = state.vOff
758
- } else {
759
- state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
760
- }
761
- express()
762
- }
763
- function refresh(manual) {
764
- if (busy) return
765
- busy = true
766
- if (animDelayTimer) { clearTimeout(animDelayTimer); animDelayTimer = null }
767
- if (manual || state.balance === null) { state.status = 'loading'; render() }
768
- var ctrl = null
769
- var timer = null
770
- try {
771
- ctrl = new AbortController()
772
- timer = setTimeout(function () { try { ctrl.abort() } catch (err) {} }, FETCH_TIMEOUT_MS)
773
- } catch (err) {}
774
- fetch(BALANCE_URL, { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined })
775
- .then(function (r) { return r.json() })
776
- .then(function (data) {
777
- if (data && data.ok) {
778
- var nb = Number(data.totalBalance)
779
- var nc = String(data.currency || 'CNY')
780
- var changed = state.balance !== null && (nb !== state.balance || nc !== state.currency)
781
- var currencyChanged = state.currency !== null && nc !== state.currency
782
- state.balance = nb
783
- state.currency = nc
784
- state.message = ''
785
- state.todayUsage = data.todayUsage !== undefined ? data.todayUsage : null
786
- state.isPeak = !!data.isPeak
787
- if (changed && !currencyChanged) {
788
- if (!manual) {
789
- showBubble()
790
- state.status = 'changing'
791
- // balance-change bubble: wait 0.3s after it floats out, then roll the number
792
- if (animDelayTimer) clearTimeout(animDelayTimer)
793
- animDelayTimer = setTimeout(function () {
794
- animDelayTimer = null
795
- animateAmount(shown, nb, nc, ANIM_MS)
796
- }, 300)
797
- if (settleTimer) clearTimeout(settleTimer)
798
- settleTimer = setTimeout(function () {
799
- settleTimer = null
800
- if (state.status === 'changing') { state.status = 'ok'; render() }
801
- }, CHANGE_MS + 300)
802
- } else {
803
- animateAmount(shown, nb, nc, ANIM_MS)
804
- state.status = 'ok'
805
- render()
806
- }
807
- } else {
808
- if (animId === null) shown = nb
809
- state.status = 'ok'
810
- render()
811
- }
812
- } else {
813
- state.status = 'error'
814
- state.message = (data && data.error) ? String(data.error) : '获取失败'
815
- render()
816
- }
817
- })
818
- .catch(function () {
819
- state.status = 'error'
820
- state.message = '获取失败'
821
- render()
822
- })
823
- .finally(function () {
824
- busy = false
825
- if (timer) clearTimeout(timer)
826
- })
827
- }
828
- var soundOn = true
829
- var soundVol = 0.9
830
- var soundSet = 'duck'
831
- var usageMode = 'ledger'
832
- var peakMode = 'default'
833
- var bubbleOn = true
834
- var turnCostOn = true
835
- var turnCostCloseMs = 5000
836
- var costBubbleActive = false
837
- var scrollGapOn = false
838
- var scrollGapPx = 17
839
- function saveConfig() {
840
- try {
841
- fetch(SIZE_URL, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scale: state.scale, sound: soundOn, vol: soundVol, soundSet: soundSet, usageMode: usageMode, peakMode: peakMode, bubbleOn: bubbleOn, turnCostOn: turnCostOn, turnCostCloseMs: turnCostCloseMs, scrollGapOn: scrollGapOn, scrollGapPx: scrollGapPx }) })
842
- // 锚点位置记忆:记录相对边框的离边距离,窗口 resize 后保持(localStorage)。
843
- // v:2 = 净距离格式(剥离避让距离),v:1 旧格式含避让距离,恢复时废弃旧格式。
844
- var vp = viewport()
845
- var w = root.offsetWidth || root.getBoundingClientRect().width || 0
846
- var h = root.offsetHeight || root.getBoundingClientRect().height || 0
847
- var leftDist = state.left
848
- var rightDist = vp.w - state.left - w
849
- var topDist = state.top
850
- var bottomDist = vp.h - state.top - h
851
- var hAnchor = leftDist <= rightDist ? 'left' : 'right'
852
- var hDistRaw = Math.round(Math.min(leftDist, rightDist))
853
- var hDist = hAnchor === 'right' && scrollGapOn ? Math.max(0, hDistRaw - rightGap()) : hDistRaw
854
- localStorage.setItem('dshw-pos', JSON.stringify({
855
- v: 2,
856
- hAnchor: hAnchor,
857
- hDist: hDist,
858
- vAnchor: topDist <= bottomDist ? 'top' : 'bottom',
859
- vDist: Math.round(Math.min(topDist, bottomDist))
860
- }))
861
- } catch (err) {}
862
- }
863
- function setUsageMode(v) {
864
- usageMode = v === 'token' ? 'token' : 'ledger'
865
- usageSelect.value = usageMode
866
- saveConfig()
867
- refresh(false)
868
- }
869
- function setPeakMode(v) {
870
- peakMode = v === 'liangwen' || v === 'qiangqiang' ? v : 'default'
871
- peakSelect.value = peakMode
872
- saveConfig()
873
- }
874
- function setBubbleOn(v) {
875
- bubbleOn = !!v
876
- bubbleToggle.checked = bubbleOn
877
- saveConfig()
878
- // 必须走 hideCostBubble:残留的 costBubbleActive 会让 render()/showBubble() 永久早退
879
- if (!bubbleOn) hideCostBubble()
880
- }
881
- function setTurnCostOn(v) {
882
- turnCostOn = !!v
883
- turnCostToggle.checked = turnCostOn
884
- turnCostCloseInput.disabled = !turnCostOn
885
- saveConfig()
886
- if (!turnCostOn) hideCostBubble()
887
- }
888
- function setTurnCostClose(v) {
889
- if (!turnCostOn) return
890
- var n = Math.max(0, Math.round(Number(v) || 0))
891
- turnCostCloseMs = n * 1000
892
- turnCostCloseInput.value = String(n)
893
- saveConfig()
894
- }
895
- function setScrollGapOn(v) {
896
- scrollGapOn = !!v
897
- scrollGapToggle.checked = scrollGapOn
898
- scrollGapInput.disabled = !scrollGapOn
899
- saveConfig()
900
- settle()
901
- }
902
- function setScrollGapPx(v) {
903
- if (!scrollGapOn) return
904
- var n = Math.max(0, Math.round(Number(v) || 0))
905
- scrollGapPx = n
906
- scrollGapInput.value = String(n)
907
- saveConfig()
908
- settle()
909
- }
910
- function scaleToDisplay(s) {
911
- return Math.round((s - MIN_SCALE) / ((MAX_SCALE - MIN_SCALE) / 19)) + 1
912
- }
913
- function setScale(v) {
914
- var next = Math.round(Math.min(MAX_SCALE, Math.max(MIN_SCALE, Number(v))) * 10) / 10
915
- // 缩放测量需要 left/top 立即到位:临时禁用过渡(滚轮/数字框路径没有
916
- // 滑块 pointerdown 的 transition:none,否则 r2 测的是过渡起点导致错锚点)
917
- var prevTrans = root.style.transition
918
- root.style.transition = 'none'
919
- var rect = root.getBoundingClientRect()
920
- // fixed point: the whale's corner — bottom-right when unflipped, bottom-left
921
- // when flipped. Growing extends the widget up-left / up-right from that
922
- // corner; shrinking pulls it back toward the corner. The whale always hugs
923
- // its corner while scaling.
924
- var fx = state.h === 'left' ? rect.left : rect.right
925
- var fy = rect.bottom
926
- state.scale = next
927
- root.style.setProperty('--dshw-scale', String(next))
928
- scaleInput.value = String(next)
929
- scaleNumber.value = String(scaleToDisplay(next))
930
- saveConfig()
931
- // keep the corner fixed while resizing; the position correction applies
932
- // instantly because the caller disables the transition for the whole drag
933
- var r2 = root.getBoundingClientRect()
934
- var vp = viewport()
935
- if (state.h === 'left') {
936
- state.left = Math.min(Math.max(fx, 0), Math.max(0, vp.w - r2.width))
937
- } else {
938
- state.left = Math.min(Math.max(fx - r2.width, 0), Math.max(0, vp.w - r2.width))
939
- }
940
- state.top = Math.min(Math.max(fy - r2.height, 0), Math.max(0, vp.h - r2.height))
941
- express()
942
- // 恢复过渡必须延迟到下一帧:本帧 left/top 已在 none 下设置并提交,
943
- // 立即恢复会让浏览器对「刚改过的 left/top」重新评估并播放过渡动画
944
- // (翻转时叠加 transform .3s 更明显,表现为抽搐)。
945
- requestAnimationFrame(function () {
946
- root.style.transition = prevTrans
947
- })
948
- }
949
- function setVol(v) {
950
- var next = Math.round(Math.min(1, Math.max(0, Number(v))) * 100) / 100
951
- soundVol = next
952
- soundOn = next > 0
953
- volInput.value = String(next)
954
- volPct.textContent = Math.round(next * 100) + '%'
955
- try {
956
- if (pressAudio) pressAudio.volume = next
957
- if (releaseAudio) releaseAudio.volume = next
958
- } catch (err) {}
959
- saveConfig()
960
- }
961
- function setSoundSet(v) {
962
- soundSet = v === 'fx1' ? 'fx1' : 'duck'
963
- soundSelect.value = soundSet
964
- applySoundSet()
965
- saveConfig()
966
- }
967
- var SQUISH = 'scaleY(0.88) scaleX(1.05)'
968
- var pressAudio = null
969
- var releaseAudio = null
970
- var pressing = false
971
- var pressEnded = false
972
- var releasePlayed = false
973
- var releaseTimer = null
974
- function applySoundSet() {
975
- try {
976
- pressAudio = new Audio('/dsh-whale/sound/press.mp3?set=' + soundSet)
977
- pressAudio.preload = 'auto'
978
- pressAudio.volume = soundVol
979
- releaseAudio = new Audio('/dsh-whale/sound/release.mp3?set=' + soundSet)
980
- releaseAudio.preload = 'auto'
981
- releaseAudio.volume = soundVol
982
- } catch (err) {}
983
- }
984
- function playPress() {
985
- if (!pressAudio || !soundOn) return
986
- try {
987
- if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null }
988
- if (releaseAudio) {
989
- releaseAudio.pause()
990
- releaseAudio.currentTime = 0
991
- }
992
- pressEnded = false
993
- releasePlayed = false
994
- pressAudio.onended = function () {
995
- pressEnded = true
996
- // fallback (duration unknown): click → Ya2 right after Ya1 ends
997
- if (!pressing && !releasePlayed) playRelease()
998
- // hold: still pressed → wait for pressUp()
999
- }
1000
- pressAudio.currentTime = 0
1001
- var p = pressAudio.play()
1002
- if (p && typeof p.catch === 'function') p.catch(function () {})
1003
- } catch (err) {}
1004
- }
1005
- function playRelease() {
1006
- if (releasePlayed || !releaseAudio || !soundOn) return
1007
- releasePlayed = true
1008
- try {
1009
- releaseAudio.currentTime = 0
1010
- var p = releaseAudio.play()
1011
- if (p && typeof p.catch === 'function') p.catch(function () {})
1012
- } catch (err) {}
1013
- }
1014
- function pressDown() {
1015
- body.style.transform = SQUISH
1016
- pressing = true
1017
- playPress()
1018
- }
1019
- function pressUp() {
1020
- body.style.transform = 'scaleY(1) scaleX(1)'
1021
- pressing = false
1022
- if (pressEnded) {
1023
- // hold (or released after Ya1 finished) → Ya2 now
1024
- playRelease()
1025
- return
1026
- }
1027
- // click: start Ya2 in the last 100ms of Ya1's playback
1028
- var durKnown = false
1029
- var remainMs = 0
1030
- try {
1031
- var dur = pressAudio ? pressAudio.duration : 0
1032
- if (isFinite(dur) && dur > 0) {
1033
- durKnown = true
1034
- remainMs = (dur - pressAudio.currentTime) * 1000
1035
- }
1036
- } catch (err) {}
1037
- if (durKnown) {
1038
- releaseTimer = setTimeout(function () {
1039
- releaseTimer = null
1040
- playRelease()
1041
- }, Math.max(0, remainMs - 100))
1042
- }
1043
- // duration unknown → pressAudio.onended fallback plays Ya2 after Ya1 ends
1044
- }
1045
- var menuOpen = false
1046
- function toggleMenu() {
1047
- menuOpen = !menuOpen
1048
- if (menuOpen) positionMenu()
1049
- menuBox.classList.toggle('dshwv-menu-open', menuOpen)
1050
- if (menuOpen) menuBtn.classList.add('dshwv-menu-btn-visible')
1051
- }
1052
- function closeMenu() {
1053
- menuOpen = false
1054
- menuBox.classList.remove('dshwv-menu-open')
1055
- root.style.transition = ''
1056
- snapCheck()
1057
- }
1058
- function snapCheck() {
1059
- var rect = root.getBoundingClientRect()
1060
- var vp = viewport()
1061
- var w = rect.width, h = rect.height
1062
- var left = rect.left, top = rect.top
1063
- var centerX = left + w / 2
1064
- var centerY = top + h / 2
1065
- var moved = false
1066
- if (centerX < vp.w / 4) {
1067
- state.h = 'left'
1068
- state.hOff = 0
1069
- left = 0
1070
- moved = true
1071
- } else if (centerX > vp.w * 3 / 4) {
1072
- state.h = 'right'
1073
- state.hOff = 0
1074
- left = vp.w - w - rightGap()
1075
- moved = true
1076
- } else {
1077
- state.h = null
1078
- state.hOff = left
1079
- }
1080
- if (centerY < vp.h / 4) {
1081
- state.v = 'top'
1082
- state.vOff = 0
1083
- top = 0
1084
- moved = true
1085
- } else {
1086
- state.v = 'bottom'
1087
- state.vOff = Math.max(0, vp.h - top - h)
1088
- }
1089
- if (moved) {
1090
- state.left = left
1091
- state.top = top
1092
- settle()
487
+ let widgetJsCache = null // { text, mtimeMs }
488
+ function loadWidgetJs() {
489
+ for (const p of WIDGET_FILE_CANDIDATES) {
490
+ try {
491
+ const st = fs.statSync(p)
492
+ if (widgetJsCache && widgetJsCache.mtimeMs === st.mtimeMs) return widgetJsCache.text
493
+ const text = fs.readFileSync(p, 'utf8')
494
+ widgetJsCache = { text, mtimeMs: st.mtimeMs }
495
+ return text
496
+ } catch (err) {}
1093
497
  }
1094
- }
1095
- function positionMenu() {
1096
- try {
1097
- var r = root.getBoundingClientRect()
1098
- var b = menuBtn.getBoundingClientRect()
1099
- var vp = viewport()
1100
- var onLeft = r.left + r.width / 2 < vp.w / 2
1101
- // the menu appears ABOVE the button, anchored to its side:
1102
- // right side → menu bottom-right aligns with the button's top-right;
1103
- // left side → menu bottom-left aligns with the button's top-left
1104
- if (onLeft) {
1105
- menuBox.style.left = b.left + 'px'
1106
- menuBox.style.right = 'auto'
1107
- menuBox.style.transformOrigin = 'bottom left'
1108
- } else {
1109
- menuBox.style.right = (vp.w - b.right) + 'px'
1110
- menuBox.style.left = 'auto'
1111
- menuBox.style.transformOrigin = 'bottom right'
1112
- }
1113
- menuBox.style.bottom = (vp.h - b.top) + 'px'
1114
- menuBox.style.top = 'auto'
1115
- } catch (err) {}
498
+ return widgetJsCache ? widgetJsCache.text : ''
1116
499
  }
1117
500
 
1118
- var hitCanvas = null
1119
- var hitReady = false
1120
- function setupHitTest() {
1121
- try {
1122
- hitCanvas = document.createElement('canvas')
1123
- hitCanvas.width = 610
1124
- hitCanvas.height = 610
1125
- var probe = new Image()
1126
- probe.onload = function () {
1127
- try {
1128
- // 拉伸到 610×610 与 isWhaleHit 的坐标映射对齐;不指定尺寸会按原图大小绘制,
1129
- // 回退到非 610×610 素材(如 DSniang02.png)时命中区域会错位
1130
- hitCanvas.getContext('2d').drawImage(probe, 0, 0, 610, 610)
1131
- hitReady = true
1132
- } catch (err) {}
1133
- }
1134
- probe.onerror = function () {}
1135
- probe.src = IMG_URL
1136
- } catch (err) {}
1137
- }
1138
- function isWhaleHit(e) {
1139
- if (!hitCanvas || !hitReady) return true
1140
- try {
1141
- var r = img.getBoundingClientRect()
1142
- if (!r || r.width <= 0 || r.height <= 0) return false
1143
- var lx = (e.clientX - r.left) / r.width * 610
1144
- var ly = (e.clientY - r.top) / r.height * 610
1145
- if (lx < 0 || ly < 0 || lx >= 610 || ly >= 610) return false
1146
- if (state.h === 'left') lx = 610 - lx
1147
- var data = hitCanvas.getContext('2d').getImageData(Math.floor(lx), Math.floor(ly), 1, 1).data
1148
- return data[3] > 10
1149
- } catch (err) {
1150
- return true
1151
- }
1152
- }
1153
- function onDocPointerDown(e) {
1154
- if (e.target && e.target.closest) {
1155
- if (e.target.closest('.dshwv-bubble') || e.target.closest('.dshwv-menu') || e.target.closest('.dshwv-menu-btn')) return
1156
- }
1157
- if (menuOpen) {
1158
- closeMenu()
1159
- return
1160
- }
1161
- if (e.button !== 0 && e.pointerType === 'mouse') return
1162
- if (!isWhaleHit(e)) return
1163
- try { e.preventDefault(); e.stopPropagation() } catch (err) {}
1164
- var vp = viewport()
1165
- var rect = root.getBoundingClientRect()
1166
- drag = { active: true, startX: e.clientX, startY: e.clientY, origLeft: rect.left, origTop: rect.top, w: rect.width, h: rect.height, moved: false, vp: vp }
1167
- root.classList.add('dshwv-dragging')
1168
- pressDown()
1169
- setWidgetCursor('grabbing')
1170
- document.addEventListener('pointermove', onDocPointerMove, true)
1171
- document.addEventListener('pointerup', onDocPointerUp, true)
1172
- document.addEventListener('pointercancel', onDocPointerCancel, true)
1173
- }
1174
- function onDocPointerMove(e) {
1175
- if (!drag || !drag.active) return
1176
- var dx = e.clientX - drag.startX
1177
- var dy = e.clientY - drag.startY
1178
- if (dx * dx + dy * dy >= CLICK_SQ) drag.moved = true
1179
- // Keep the pre-drag flip orientation while dragging (state.h/v stay as they
1180
- // were); on release endDrag() recomputes the anchors and settle() flips the
1181
- // class with a smooth transition instead of reverting instantly.
1182
- state.left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w))
1183
- state.top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h))
1184
- express()
1185
- }
1186
- function onDocPointerUp(e) {
1187
- // 拦截鲸鱼区域内的 pointerup:防止下方元素(如文件行)监听 pointerup 穿透误触发
1188
- try { if (isWhaleHit(e)) { e.preventDefault(); e.stopPropagation() } } catch (err) {}
1189
- endDrag(e, true)
1190
- }
1191
- function onDocPointerCancel(e) { endDrag(e, false) }
1192
- function onDocClickStopper(e) {
1193
- // 只在鲸鱼命中区域拦截 click(保持透明区 pass-through)。
1194
- // 持久注册(不随 endDrag 移除)——click 在 pointerup 之后派发,
1195
- // 若在 endDrag 移除会导致 click 穿透到下方元素(如误打开文件)。
1196
- if (!isWhaleHit(e)) return
1197
- try { e.preventDefault(); e.stopPropagation() } catch (err) {}
1198
- }
1199
- document.addEventListener('pointerdown', onDocPointerDown, true)
1200
- document.addEventListener('click', onDocClickStopper, true)
1201
-
1202
- var widgetCursor = ''
1203
- function setWidgetCursor(v) {
1204
- if (v !== widgetCursor) {
1205
- widgetCursor = v
1206
- try { document.body.style.cursor = v } catch (err) {}
1207
- }
1208
- }
1209
- function onDocPointerMoveCursor(e) {
1210
- if (drag && drag.active) { setWidgetCursor('grabbing'); return }
1211
- var el = null
1212
- try { el = document.elementFromPoint(e.clientX, e.clientY) } catch (err) {}
1213
- if (el && el.closest && (el.closest('.dshwv-bubble') || el.closest('.dshwv-menu') || el.closest('.dshwv-menu-btn'))) {
1214
- setWidgetCursor('')
1215
- menuBtn.classList.add('dshwv-menu-btn-visible')
1216
- return
1217
- }
1218
- var over = isWhaleHit(e)
1219
- setWidgetCursor(over ? 'grab' : '')
1220
- menuBtn.classList.toggle('dshwv-menu-btn-visible', over || menuOpen)
1221
- }
1222
- document.addEventListener('pointermove', onDocPointerMoveCursor, true)
1223
-
1224
- function endDrag(e, clickAllowed) {
1225
- if (!drag || !drag.active) return
1226
- drag.active = false
1227
- document.removeEventListener('pointermove', onDocPointerMove, true)
1228
- document.removeEventListener('pointerup', onDocPointerUp, true)
1229
- document.removeEventListener('pointercancel', onDocPointerCancel, true)
1230
- pressUp()
1231
- root.classList.remove('dshwv-dragging')
1232
- setWidgetCursor(isWhaleHit(e) ? 'grab' : '')
1233
- if (clickAllowed && !drag.moved) { showBubble(); refresh(true); return }
1234
- var dx = e.clientX - drag.startX
1235
- var dy = e.clientY - drag.startY
1236
- var left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w))
1237
- var top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h))
1238
- var centerX = left + drag.w / 2
1239
- var centerY = top + drag.h / 2
1240
- if (centerX < drag.vp.w / 4) {
1241
- state.h = 'left'
1242
- state.hOff = 0
1243
- } else if (centerX > drag.vp.w * 3 / 4) {
1244
- state.h = 'right'
1245
- state.hOff = 0
1246
- } else {
1247
- state.h = null
1248
- state.hOff = left
1249
- }
1250
- if (centerY < drag.vp.h / 4) {
1251
- state.v = 'top'
1252
- state.vOff = 0
1253
- } else if (centerY > drag.vp.h * 3 / 4) {
1254
- state.v = 'bottom'
1255
- state.vOff = 0
1256
- } else {
1257
- state.v = null
1258
- state.vOff = top
1259
- }
1260
- state.left = left
1261
- state.top = top
1262
- settle()
1263
- // 拖拽结束立即保存锚点位置(否则刷新/关闭后位置回退到上次改菜单时)
1264
- saveConfig()
1265
- }
1266
- // 窗口尺寸变化时:自由位置的鲸鱼按相对边框锚点重算(保持离边距离,窗口恢复原状即回原位);
1267
- // 贴边吸附的鲸鱼走 settle()(保持贴边)
1268
- function applyAnchorPos() {
1269
- try {
1270
- var a = JSON.parse(localStorage.getItem('dshw-pos') || 'null')
1271
- if (!a || a.v !== 2 || (a.hAnchor !== 'left' && a.hAnchor !== 'right') || typeof a.hDist !== 'number' ||
1272
- (a.vAnchor !== 'top' && a.vAnchor !== 'bottom') || typeof a.vDist !== 'number') return false
1273
- var vp = viewport()
1274
- var w = root.offsetWidth || root.getBoundingClientRect().width || 0
1275
- var h = root.offsetHeight || root.getBoundingClientRect().height || 0
1276
- // 与加载恢复一致:锚点存净距离,右锚点按当前避让开关叠加
1277
- var effectiveRightDist = a.hAnchor === 'right' ? a.hDist + (scrollGapOn ? rightGap() : 0) : a.hDist
1278
- var l = a.hAnchor === 'left' ? a.hDist : vp.w - effectiveRightDist - w
1279
- var t = a.vAnchor === 'top' ? a.vDist : vp.h - a.vDist - h
1280
- state.left = clamp(l, 0, Math.max(0, vp.w - w))
1281
- state.top = clamp(t, 0, Math.max(0, vp.h - h))
1282
- state.h = a.hAnchor
1283
- state.hOff = 0
1284
- state.v = a.vAnchor
1285
- state.vOff = 0
1286
- express()
1287
- return true
1288
- } catch (err) { return false }
1289
- }
1290
- window.addEventListener('resize', function () {
1291
- if (state.h === null && state.v === null && applyAnchorPos()) return
1292
- settle()
1293
- })
1294
-
1295
- var rect0 = root.getBoundingClientRect()
1296
- state.left = rect0.left
1297
- state.top = rect0.top
1298
- express()
1299
- render()
1300
- applySoundSet()
1301
- setupHitTest()
1302
- fetch(SIZE_URL, { cache: 'no-store' })
1303
- .then(function (r) { return r.json() })
1304
- .then(function (d) {
1305
- if (d && typeof d.scale === 'number' && d.scale >= MIN_SCALE - 0.1 && d.scale <= MAX_SCALE + 0.1) {
1306
- state.scale = d.scale
1307
- root.style.setProperty('--dshw-scale', String(d.scale))
1308
- scaleInput.value = String(d.scale)
1309
- scaleNumber.value = String(scaleToDisplay(d.scale))
1310
- settle()
1311
- }
1312
- if (d && typeof d.vol === 'number') {
1313
- soundVol = d.vol
1314
- soundOn = soundVol > 0
1315
- volInput.value = String(soundVol)
1316
- volPct.textContent = Math.round(soundVol * 100) + '%'
501
+ export default {
502
+ name: 'whale-balance-widget',
503
+ inject: ['webServer', 'credentials', 'connection'],
504
+ apply(ctx) {
505
+ // —— 浏览器信任栅栏(对应 issue #92)——
506
+ // dsh 的 connection 服务提供 requestRejection(req):Host/Origin 被伪造(DNS 重绑定)
507
+ // 或未认证的请求必须被拒。官方插件(如 dsh-host-open-in-app)在自定义路由里都会先调用;
508
+ // 本插件所有 /dsh-whale/* 路由同样必须走一遍,否则接口可被任意网页读写。
509
+ function rejected(req, res) {
1317
510
  try {
1318
- if (pressAudio) pressAudio.volume = soundVol
1319
- if (releaseAudio) releaseAudio.volume = soundVol
1320
- } catch (err) {}
1321
- }
1322
- if (d && typeof d.soundSet === 'string') {
1323
- soundSet = d.soundSet === 'fx1' ? 'fx1' : 'duck'
1324
- soundSelect.value = soundSet
1325
- applySoundSet()
1326
- }
1327
- if (d && typeof d.usageMode === 'string') {
1328
- usageMode = d.usageMode === 'token' ? 'token' : 'ledger'
1329
- usageSelect.value = usageMode
1330
- }
1331
- if (d && typeof d.peakMode === 'string') {
1332
- peakMode = d.peakMode === 'liangwen' || d.peakMode === 'qiangqiang' ? d.peakMode : 'default'
1333
- peakSelect.value = peakMode
1334
- }
1335
- if (d && typeof d.bubbleOn === 'boolean') {
1336
- bubbleOn = d.bubbleOn
1337
- bubbleToggle.checked = bubbleOn
1338
- }
1339
- if (d && typeof d.turnCostOn === 'boolean') {
1340
- turnCostOn = d.turnCostOn
1341
- turnCostToggle.checked = turnCostOn
1342
- turnCostCloseInput.disabled = !turnCostOn
1343
- }
1344
- if (d && typeof d.turnCostCloseMs === 'number') {
1345
- turnCostCloseMs = d.turnCostCloseMs > 0 ? d.turnCostCloseMs : 0
1346
- turnCostCloseInput.value = String(Math.round(turnCostCloseMs / 1000))
1347
- }
1348
- if (d && typeof d.scrollGapOn === 'boolean') {
1349
- scrollGapOn = d.scrollGapOn
1350
- scrollGapToggle.checked = scrollGapOn
1351
- scrollGapInput.disabled = !scrollGapOn
1352
- }
1353
- if (d && typeof d.scrollGapPx === 'number') {
1354
- scrollGapPx = d.scrollGapPx > 0 ? Math.round(d.scrollGapPx) : 0
1355
- scrollGapInput.value = String(scrollGapPx)
1356
- }
1357
- // 相对边框恢复(localStorage 锚点):窗口变化后保持离边距离。
1358
- // 仅认 v:2 净距离格式;旧格式(含避让距离)废弃,挂件保持默认右下角吸附。
1359
- // 恢复时还原吸附状态(hAnchor/vAnchor → state.h/v),避免挂件变自由位置
1360
- // 导致避让调节不实时(settle 自由分支只 clamp 不重算位置)。
1361
- try {
1362
- var a = JSON.parse(localStorage.getItem('dshw-pos') || 'null')
1363
- if (a && a.v === 2 && (a.hAnchor === 'left' || a.hAnchor === 'right') && typeof a.hDist === 'number' &&
1364
- (a.vAnchor === 'top' || a.vAnchor === 'bottom') && typeof a.vDist === 'number') {
1365
- var vpA = viewport()
1366
- var wA = root.offsetWidth || root.getBoundingClientRect().width || 0
1367
- var hA = root.offsetHeight || root.getBoundingClientRect().height || 0
1368
- // 锚点存的是净距离:右锚点按当前避让开关叠加避让距离
1369
- var effectiveRightDist = a.hAnchor === 'right' ? a.hDist + (scrollGapOn ? rightGap() : 0) : a.hDist
1370
- var lA = a.hAnchor === 'left' ? a.hDist : vpA.w - effectiveRightDist - wA
1371
- var tA = a.vAnchor === 'top' ? a.vDist : vpA.h - a.vDist - hA
1372
- state.left = clamp(lA, 0, Math.max(0, vpA.w - wA))
1373
- state.top = clamp(tA, 0, Math.max(0, vpA.h - hA))
1374
- // 按锚点还原吸附状态(贴边锚点 → 吸附;自由位锚点 → 自由)
1375
- state.h = a.hAnchor
1376
- state.hOff = 0
1377
- state.v = a.vAnchor
1378
- state.vOff = 0
1379
- settle()
1380
- }
1381
- } catch (err) {}
1382
- refresh(false)
1383
- })
1384
- .catch(function () { refresh(false) })
1385
- setInterval(function () { refresh(false) }, REFRESH_MS)
1386
-
1387
- // —— 每轮对话消耗检测:轮询 last-turn.json,出现新 seq 时弹消耗金额泡泡 ——
1388
- var LAST_TURN_URL = '/dsh-whale/last-turn.json'
1389
- var lastCostSeq = 0
1390
- var lastCostAligned = false
1391
- function pollLastTurn() {
1392
- try {
1393
- fetch(LAST_TURN_URL, { cache: 'no-store' })
1394
- .then(function (r) { return r.json() })
1395
- .then(function (d) {
1396
- if (!d || !d.ok || typeof d.seq !== 'number') return
1397
- if (!lastCostAligned) {
1398
- // 首次拿到数据:只对齐 seq,不弹旧轮次
1399
- lastCostSeq = d.seq
1400
- lastCostAligned = true
1401
- return
1402
- }
1403
- if (d.seq > lastCostSeq) {
1404
- lastCostSeq = d.seq
1405
- if (d.turn !== null && d.amount !== null) {
1406
- showCostBubble(Number(d.amount))
511
+ const conn = ctx.get('connection') || ctx.connection
512
+ if (!conn || typeof conn.requestRejection !== 'function') {
513
+ // fail-open:此刻栅栏不可用(connection 服务缺失)。inject 已声明该服务,正常不会走到这里;
514
+ // 打一条 warn 便于发现"栅栏实际失效",而不是静默放行
515
+ if (!rejected.warned) {
516
+ rejected.warned = true
517
+ try { console.warn('[whale-balance] 信任栅栏不可用:connection 服务缺失,自定义路由将放行处理') } catch (err) {}
1407
518
  }
519
+ return false
1408
520
  }
521
+ const code = conn.requestRejection(req)
522
+ if (code === undefined || code === null || code === false) return false
523
+ res.statusCode = typeof code === 'number' ? code : 403
524
+ res.end()
525
+ return true
526
+ } catch (err) { return false }
527
+ }
528
+ // 统一注册入口:所有路由自动套上信任栅栏
529
+ function registerRoute(route) {
530
+ const inner = route && route.handler
531
+ const wrapped = Object.assign({}, route, {
532
+ handler: async (req, res) => {
533
+ if (rejected(req, res)) return
534
+ return inner(req, res)
535
+ },
1409
536
  })
1410
- .catch(function () {})
1411
- } catch (err) {}
1412
- }
1413
- setInterval(pollLastTurn, 1000)
1414
- })()`
1415
-
1416
-
1417
- const name = 'whale-balance-widget'
1418
- const inject = ['webServer', 'credentials']
1419
-
1420
- function apply(ctx) {
537
+ return ctx.webServer.register(wrapped)
538
+ }
1421
539
  let imageBytes = null
1422
540
  let balanceCache = null
1423
541
  let balanceInFlight = null
1424
542
  let gifBytes = null
1425
543
  // 每轮对话消耗统计:按 (session.id, turn) 分桶聚合,完成后写入 lastTurn。
1426
544
  // 用 Map 分桶避免主会话与子代理(spawn/fork)并行时串账。
1427
- let turnAggs = new Map() // sessionId -> { turn, cost, tokens, lastTs }
545
+ let turnAggs = new Map() // sessionId -> { turn, cost, tokens, byModel, byModelTokens, lastTs }
1428
546
  let lastTurn = null // { turn, amount, tokens, ts }
1429
547
  let lastTurnSeq = 0
1430
548
  const disposers = []
1431
549
 
550
+ // 持久化 seq:热重载/重启后 seq 继续递增,前端不会把新轮次误判为旧轮次
551
+ function readTurnSeq() {
552
+ for (const p of TURN_FILE_CANDIDATES) {
553
+ try {
554
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf8'))
555
+ if (parsed && typeof parsed.seq === 'number' && parsed.seq >= 0) return parsed.seq
556
+ } catch (err) {}
557
+ }
558
+ return 0
559
+ }
560
+ function writeTurnSeq(seq) {
561
+ const body = JSON.stringify({ seq, updatedAt: new Date().toISOString() })
562
+ for (const p of TURN_FILE_CANDIDATES) {
563
+ try { fs.writeFileSync(p, body, 'utf8'); return } catch (err) {}
564
+ }
565
+ }
566
+ lastTurnSeq = readTurnSeq()
567
+
1432
568
  function finalizeTurn(sessionId) {
1433
569
  const agg = turnAggs.get(sessionId)
1434
570
  if (agg && agg.cost > 0) {
1435
571
  lastTurn = { turn: agg.turn, amount: agg.cost, tokens: agg.tokens, ts: agg.lastTs }
1436
572
  lastTurnSeq++
573
+ writeTurnSeq(lastTurnSeq)
574
+ // 按模型拆分写入用量事件(模型明细的唯一来源)
575
+ const byModel = agg.byModel || {}
576
+ const byModelTokens = agg.byModelTokens || {}
577
+ const modelNames = Object.keys(byModel)
578
+ if (modelNames.length) {
579
+ for (const mname of modelNames) {
580
+ appendUsageEvent({ ts: agg.lastTs, model: mname, cost: byModel[mname], tokens: byModelTokens[mname] || 0 })
581
+ // 自定义模型:按 matchIds 归属到注册表里的模型(余额差不可用时作为该模型的今日已用兜底)
582
+ apiAttributeEvent(mname, byModel[mname], byModelTokens[mname] || 0)
583
+ }
584
+ } else {
585
+ appendUsageEvent({ ts: agg.lastTs, model: '未知', cost: agg.cost, tokens: agg.tokens })
586
+ }
1437
587
  }
1438
588
  turnAggs.delete(sessionId)
1439
589
  }
@@ -1455,19 +605,38 @@ function apply(ctx) {
1455
605
  let agg = turnAggs.get(sessionId)
1456
606
  if (!agg || agg.turn !== turn) {
1457
607
  if (agg) finalizeTurn(sessionId)
1458
- agg = { turn, cost: 0, tokens: 0, lastTs: Date.now() }
608
+ agg = { turn, cost: 0, tokens: 0, byModel: {}, byModelTokens: {}, lastTs: Date.now() }
1459
609
  turnAggs.set(sessionId, agg)
1460
610
  }
1461
611
  const input = Number(usage.inputTokens) || 0
1462
612
  const cache = Number(usage.cacheReadTokens) || 0
1463
613
  const output = Number(usage.outputTokens) || 0
1464
614
  const reasoning = Number(usage.reasoningTokens) || 0
1465
- agg.tokens += input + cache + output + reasoning
1466
- // 定价换算(CNY/百万 token;缓存命中=输入价,其余按各自档位)
615
+ // 口径修正(issue #89 / PR #83):dsh 保证 reasoningTokens ⊆ outputTokens
616
+ // (见 dsh-token-meter 的校验 reasoningTokens > outputTokens 即判非法),
617
+ // 所以 reasoning 不能再单独累加 —— 否则输出侧按输出价被重复计费(实测偏高约一倍)。
618
+ // 兜底:万一某 provider 上报的 reasoning 不是 output 的子集(reasoning > output),
619
+ // 就把差额补回计费输出,避免另一种口径下少算
620
+ const outputBilled = reasoning > output ? output + reasoning : output
621
+ const toks = input + cache + outputBilled
622
+ agg.tokens += toks
623
+ // 定价换算(CNY/百万 token;缓存命中=输入价,其余按各自档位),并按模型拆分
1467
624
  const model = d.message && d.message.source ? d.message.source.model : ''
625
+ // 每轮结算前刷新一次自定义单价(10 秒节流),让自定义 API 模型用用户填的价格计费
626
+ try { refreshCustomPrices() } catch (err) {}
1468
627
  const p = priceFor(model)
1469
628
  const off = isPeakTime(Math.floor(Date.now() / 1000)) ? 1 : 0
1470
- agg.cost += (cache / 1e6) * p.hit[off] + (input / 1e6) * p.miss[off] + ((output + reasoning) / 1e6) * p.out[off]
629
+ let costMsg = (cache / 1e6) * p.hit[off] + (input / 1e6) * p.miss[off] + (outputBilled / 1e6) * p.out[off]
630
+ // 自定义单价若按美元填写,按用户填的汇率换算成人民币(账本统一按 CNY 记账)
631
+ try {
632
+ const meta = customPriceMetaFor(model)
633
+ if (meta && meta.cur === 'USD' && Number(meta.rate) > 0) costMsg = costMsg * Number(meta.rate)
634
+ } catch (err) {}
635
+ agg.cost += costMsg
636
+ if (model) {
637
+ agg.byModel[model] = (agg.byModel[model] || 0) + costMsg
638
+ agg.byModelTokens[model] = (agg.byModelTokens[model] || 0) + toks
639
+ }
1471
640
  agg.lastTs = Date.now()
1472
641
  } catch (err) {}
1473
642
  }
@@ -1576,65 +745,6 @@ function apply(ctx) {
1576
745
  }
1577
746
  }
1578
747
 
1579
- async function fetchUsage() {
1580
- let cred
1581
- try {
1582
- cred = await ctx.credentials.resolve('DEEPSEEK_PLATFORM_TOKEN')
1583
- } catch (err) {
1584
- return { error: 'platform cred resolve failed' }
1585
- }
1586
- if (!cred) return { error: 'no platform token' }
1587
- const token = String(cred.value).replace(/^Bearer\s+/i, '')
1588
- try {
1589
- const now = new Date()
1590
- const tz = -now.getTimezoneOffset() * 60
1591
- const start = Math.floor(new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000)
1592
- const end = start + 86400
1593
- const url = 'https://platform.deepseek.com/api/v0/usage/by_api_key/amount?start=' + start + '&end=' + end + '&tz=' + tz
1594
- const res = await fetch(url, {
1595
- headers: { Authorization: 'Bearer ' + token },
1596
- signal: AbortSignal.timeout(15000),
1597
- })
1598
- if (!res.ok) return { error: 'http ' + res.status }
1599
- const data = await res.json()
1600
- const u = computeTodayUsage(data)
1601
- if (u && isFinite(u.amount)) return { amount: u.amount, tokens: u.tokens }
1602
- return { error: 'no usage' }
1603
- } catch (err) {
1604
- return { error: String((err && err.message) || err) }
1605
- }
1606
- }
1607
-
1608
- function computeTodayUsage(data) {
1609
- // data.data.biz_data.series[]: [{model, buckets:[{time, usage:{RESPONSE_TOKEN, PROMPT_CACHE_HIT_TOKEN, PROMPT_CACHE_MISS_TOKEN}}]}]
1610
- let d = data
1611
- if (d && d.data && d.data.biz_data && Array.isArray(d.data.biz_data.series)) d = d.data.biz_data
1612
- else if (d && d.data && Array.isArray(d.data.series)) d = d.data
1613
- const series = Array.isArray(d.series) ? d.series : null
1614
- if (!series || series.length === 0) return null
1615
- let cost = 0
1616
- let tokens = 0
1617
- let found = false
1618
- for (const s of series) {
1619
- if (!s || typeof s !== 'object') continue
1620
- const p = priceFor(s.model)
1621
- const buckets = Array.isArray(s.buckets) ? s.buckets : []
1622
- for (const b of buckets) {
1623
- const u = b && b.usage
1624
- if (!u || typeof u !== 'object') continue
1625
- const hit = Number(u.PROMPT_CACHE_HIT_TOKEN) || 0
1626
- const miss = Number(u.PROMPT_CACHE_MISS_TOKEN) || 0
1627
- const out = Number(u.RESPONSE_TOKEN) || 0
1628
- if (hit + miss + out === 0) continue
1629
- found = true
1630
- tokens += hit + miss + out
1631
- const pi = isPeakTime(b.time) ? 1 : 0
1632
- cost += (hit / 1e6) * p.hit[pi] + (miss / 1e6) * p.miss[pi] + (out / 1e6) * p.out[pi]
1633
- }
1634
- }
1635
- return found ? { amount: cost, tokens: tokens } : null
1636
- }
1637
-
1638
748
  function todayKey() {
1639
749
  const d = new Date()
1640
750
  const p = (n) => String(n).padStart(2, '0')
@@ -1659,82 +769,1235 @@ function apply(ctx) {
1659
769
  }
1660
770
  return false
1661
771
  }
1662
- // 记账模式:每次观测到余额后,用余额正差值累计当天用量(跨天自动归零并归档)。
1663
- // 币种感知:观测币种与上次不同时只重置基准、不记差值——数值跳变来自币种
1664
- // 切换而非真实消费([0] 选币时代 CNY/USD 随机切换曾记出巨额假账,见 #13)。
1665
- function recordLedgerUsage(currentBalance, currency) {
1666
- const t = todayKey()
1667
- let led = readUsageLedger()
1668
- const cur = String(currency || '')
1669
- const currencyChanged =
1670
- typeof led.lastCurrency === 'string' && led.lastCurrency !== '' &&
1671
- cur !== '' && led.lastCurrency !== cur
1672
- if (led.date !== t) {
1673
- if (led.date && typeof led.todayUsage === 'number') {
1674
- led.history = led.history || {}
1675
- led.history[led.date] = led.todayUsage
772
+ // —— 账本保留策略(v700):events 保留 90 天或最多 2 万条;history 保留 365 天;
773
+ // 超期部分归档到 .dshw-usage-archive.json(不丢历史,只是移出主账本,避免账本无限膨胀)——
774
+ function usageArchivePath() {
775
+ for (const p of USAGE_FILE_CANDIDATES) {
776
+ try {
777
+ fs.accessSync(path.dirname(p), fs.constants.W_OK)
778
+ return path.join(path.dirname(p), '.dshw-usage-archive.json')
779
+ } catch (err) {}
780
+ }
781
+ return path.join(DSH_HOME, '.dshw-usage-archive.json')
782
+ }
783
+ function readUsageArchive() {
784
+ try {
785
+ const j = JSON.parse(fs.readFileSync(usageArchivePath(), 'utf8'))
786
+ if (j && typeof j === 'object') return j
787
+ } catch (err) {}
788
+ return { version: 1, events: [], history: {} }
789
+ }
790
+ // 返回 true 表示账本被裁剪过(调用方随后写盘即可)
791
+ function pruneLedgerUsage(led) {
792
+ try {
793
+ const ev = Array.isArray(led.events) ? led.events : []
794
+ const hist = (led.history && typeof led.history === 'object') ? led.history : {}
795
+ const dateCut90 = dayAdd(todayKey(), -90)
796
+ const dateCut365 = dayAdd(todayKey(), -365)
797
+ const keepEv = []
798
+ const dropEv = []
799
+ for (const e of ev) {
800
+ const day = String((e && e.day) || '')
801
+ if (day && day < dateCut90) dropEv.push(e)
802
+ else keepEv.push(e)
1676
803
  }
1677
- led.date = t
1678
- led.lastBalance = currentBalance
1679
- led.lastCurrency = cur
1680
- led.todayUsage = 0
1681
- } else if (currencyChanged) {
1682
- // 币种切换:只换基准,不把差值记成消费
1683
- led.lastBalance = currentBalance
1684
- led.lastCurrency = cur
1685
- } else {
1686
- const prev = typeof led.lastBalance === 'number' ? led.lastBalance : currentBalance
1687
- if (typeof prev === 'number' && typeof currentBalance === 'number' && currentBalance < prev) {
1688
- led.todayUsage = (typeof led.todayUsage === 'number' ? led.todayUsage : 0) + (prev - currentBalance)
804
+ if (keepEv.length > 20000) {
805
+ const extra = keepEv.length - 20000
806
+ for (const e of keepEv.splice(0, extra)) dropEv.push(e)
1689
807
  }
1690
- led.lastBalance = currentBalance
1691
- led.lastCurrency = cur
1692
- }
1693
- const keys = Object.keys(led.history || {}).sort()
1694
- while (keys.length > 30) {
1695
- delete led.history[keys.shift()]
1696
- }
1697
- writeUsageLedger(led)
1698
- return led
808
+ const dropHist = {}
809
+ let histDropped = 0
810
+ for (const day of Object.keys(hist)) {
811
+ if (String(day) < dateCut365) {
812
+ dropHist[day] = hist[day]
813
+ delete hist[day]
814
+ histDropped++
815
+ }
816
+ }
817
+ if (!dropEv.length && !histDropped) return false
818
+ const ar = readUsageArchive()
819
+ ar.events = Array.isArray(ar.events) ? ar.events : []
820
+ for (const e of dropEv) ar.events.push(e)
821
+ if (ar.events.length > 200000) ar.events.splice(0, ar.events.length - 200000)
822
+ ar.history = Object.assign({}, ar.history || {}, dropHist)
823
+ ar.updatedAt = new Date().toISOString()
824
+ ar.note = '小鲸鱼记账归档:events 超 90 天或超 2 万条、history 超 365 天的部分'
825
+ try { fs.writeFileSync(usageArchivePath(), JSON.stringify(ar), 'utf8') } catch (err) { return false }
826
+ led.events = keepEv
827
+ led.history = hist
828
+ return true
829
+ } catch (err) { return false }
1699
830
  }
1700
- function normalizeUsageMode(m) {
1701
- return m === 'token' ? 'token' : 'ledger'
831
+ function dayKeyOfDate(d) {
832
+ const p = (n) => String(n).padStart(2, '0')
833
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
1702
834
  }
1703
-
1704
- async function getBalancePayload() {
1705
- const payload = await fetchBalance()
1706
- if (!payload.ok) return payload
1707
- // 无论哪种模式,都先把余额观测记入账本(自动累积「鲸鱼记账」数据)
1708
- const led = recordLedgerUsage(Number(payload.totalBalance), payload.currency)
1709
- const cfg = readSizeConfig() || {}
1710
- const mode = normalizeUsageMode(cfg.usageMode)
1711
- const full = { ...payload }
1712
- full.isPeak = isPeakTime(Math.floor(Date.now() / 1000))
1713
- if (mode === 'ledger') {
1714
- full.todayUsage = led.todayUsage
1715
- full.usageMode = 'ledger'
1716
- return full
835
+ function dayKeyFromTs(ts) { return dayKeyOfDate(new Date(Number(ts))) }
836
+ function dayAdd(baseDayStr, delta) {
837
+ const d = new Date(baseDayStr + 'T00:00:00')
838
+ d.setDate(d.getDate() + delta)
839
+ return dayKeyOfDate(d)
840
+ }
841
+ function round2(x) { return Math.round((Number(x) || 0) * 100) / 100 }
842
+ // 每轮结算后追加一条用量事件(模型明细/7天/全部记录的来源;上限 8000 条)
843
+ function appendUsageEvent(ev) {
844
+ const led = readUsageLedger()
845
+ led.events = Array.isArray(led.events) ? led.events : []
846
+ led.events.push({
847
+ ts: Number(ev.ts) || Date.now(),
848
+ day: dayKeyFromTs(ev.ts),
849
+ model: String(ev.model || '未知'),
850
+ cost: round2(ev.cost),
851
+ tokens: Math.round(Number(ev.tokens) || 0),
852
+ })
853
+ // 保留策略:events 90 天 / 最多 2 万条(超期或超量归档到 .dshw-usage-archive.json)
854
+ pruneLedgerUsage(led)
855
+ writeUsageLedger(led)
856
+ }
857
+ // 供面板/窗口使用的汇总:今日(按模型降序)、近7天、7天合计、全部记录
858
+ function usageRecordsPayload() {
859
+ const led = readUsageLedger()
860
+ const events = Array.isArray(led.events) ? led.events : []
861
+ const history = led.history && typeof led.history === 'object' ? led.history : {}
862
+ const today = dayKeyFromTs(Date.now())
863
+ function sumEv(d) { let s = 0; for (const e of events) if (e.day === d) s += Number(e.cost) || 0; return s }
864
+ // 当日真实消费 = 零点起始余额 − 当前余额(官方按真实扣款计,事件漏记时用它兜底)
865
+ function realDelta() {
866
+ if (typeof led.dayStart === 'number' && typeof led.lastBalance === 'number') {
867
+ const d = led.dayStart - led.lastBalance
868
+ if (d > 0) return d
869
+ }
870
+ return 0
1717
871
  }
1718
- // token:尝试平台令牌实时计算
1719
- let cred = null
1720
- try {
1721
- cred = await ctx.credentials.resolve('DEEPSEEK_PLATFORM_TOKEN')
1722
- } catch (err) {}
1723
- if (cred) {
1724
- const u = await fetchUsage()
1725
- if (u && u.amount !== undefined) {
1726
- full.todayUsage = u.amount
1727
- full.usageMode = 'token'
1728
- return full
872
+ // 单日合计:事件合计优先,其次历史归档值;今天以真实余额差为准——
873
+ // 官方账单按真实扣款计,事件计价可能出现系统性偏高(如重复/口径差异),
874
+ // 而余额差(零点余额−当前余额)与官网几乎一致,故今日不再与事件取大。
875
+ function totalDay(d) {
876
+ if (d === today) {
877
+ const dd = realDelta()
878
+ if (dd > 0) return round2(dd)
879
+ // 余额差不可用(充值/退款/无基准)时退回事件/历史
880
+ let v = sumEv(d)
881
+ const h = history[d]
882
+ if (typeof h === 'number' && h > v) v = h
883
+ return round2(v)
1729
884
  }
885
+ let v = sumEv(d)
886
+ const h = history[d]
887
+ if (typeof h === 'number' && h > v) v = h
888
+ return round2(v)
1730
889
  }
1731
- // 无令牌或令牌失败:回落记账模式
1732
- full.todayUsage = led.todayUsage
1733
- full.usageMode = 'ledger'
1734
- return full
1735
- }
1736
-
1737
- function getBalance() {
890
+ function modelsFor(d) {
891
+ const map = {}
892
+ for (const e of events) {
893
+ if (e.day !== d) continue
894
+ const k = e.model || '未知'
895
+ map[k] = (map[k] || 0) + (Number(e.cost) || 0)
896
+ }
897
+ const names = Object.keys(map).sort((a, b) => map[b] - map[a])
898
+ const tot = totalDay(d)
899
+ const evSum = names.reduce((s, k) => s + map[k], 0)
900
+ const arr = names.map((k) => ({ model: k, cost: map[k] }))
901
+ // 今天:模型明细需与合计一致——
902
+ // 事件合计 > 余额差(事件偏高)时按比例压缩到合计;
903
+ // 事件合计 < 合计(事件漏记/余额差兜底)时补一行「未入明细」。
904
+ if (d === today && evSum > 0.0001 && evSum > tot + 0.004) {
905
+ const k = tot / evSum
906
+ for (const it of arr) it.cost = round2(it.cost * k)
907
+ const now = arr.reduce((s, it) => s + it.cost, 0)
908
+ const diff = round2(tot - now)
909
+ if (Math.abs(diff) > 0.004) {
910
+ if (diff > 0) arr[0].cost = round2(arr[0].cost + diff)
911
+ else arr[0].cost = round2(Math.max(0, arr[0].cost + diff))
912
+ }
913
+ }
914
+ arr.forEach((it) => { it.cost = round2(it.cost) })
915
+ // 今天若有“未入明细”的真实消费(事件漏记部分),补一行使合计对得上
916
+ if (d === today) {
917
+ const gap = tot - evSum
918
+ if (gap > 0.004) arr.push({ model: '(未入明细)', cost: round2(gap) })
919
+ }
920
+ arr.sort((a, b) => b.cost - a.cost)
921
+ return arr
922
+ }
923
+ const days7 = []
924
+ let total7 = 0
925
+ // 今天在最上,往后退 6 天
926
+ for (let i = 0; i <= 6; i++) {
927
+ const d = dayAdd(today, -i)
928
+ const t = totalDay(d)
929
+ total7 += t
930
+ days7.push({ date: d, total: round2(t), models: modelsFor(d) })
931
+ }
932
+ const daySet = {}
933
+ Object.keys(history).forEach((d) => { if (/^\d{4}-\d{2}-\d{2}$/.test(d)) daySet[d] = 1 })
934
+ events.forEach((e) => { if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) daySet[e.day] = 1 })
935
+ const allDays = Object.keys(daySet).sort().reverse().map((d) => ({ date: d, total: totalDay(d), models: modelsFor(d) }))
936
+ const latestEvents = events.slice().sort((a, b) => b.ts - a.ts).slice(0, 500)
937
+ return {
938
+ ok: true,
939
+ today: { total: totalDay(today), models: modelsFor(today) },
940
+ days7: days7,
941
+ total7: round2(total7),
942
+ all: { days: allDays, events: latestEvents },
943
+ settings: readUsageSettings(),
944
+ }
945
+ }
946
+ // 用量相关设置(任务结束音 / 余额预警 / 今日预算)存于用量账本 settings
947
+ function usageSettingsDefaults() {
948
+ return {
949
+ // 任务结束音:默认关闭,但默认已选中内置的 Minecraft·经验球(exp_orb),打开即用
950
+ taskEnd: { on: false, sel: 'frag:exp_orb' },
951
+ // 默认固化自开发环境当前 usage.json 设置(全新安装即此体验)
952
+ // ⚠️ 这里的 alert / budget / turnCost 内容与前端 whale-widget.js 的
953
+ // usageRemindDefaultLines() / usageTurnCostDefaultLines() **必须逐字段一致**
954
+ // (host = 新用户默认内容;前端 = 编辑器里「恢复默认」的目标内容)。改一处必须同步另一处。
955
+ alert: {
956
+ on: true, below: 5,
957
+ msg: '余额预警:当前余额已低于设定值 {below}',
958
+ lines: [
959
+ { type: 'text', text: '老大~你的DS余额', size: 5, bold: true },
960
+ { type: 'text', text: '已经不足', size: 5, bold: true, row: 2 },
961
+ { type: 'text', text: '¥{below}', size: 5, bold: true, rgb: 'rouge', color: '', bgRgb: '', bg: '', row: 2 },
962
+ { type: 'text', text: '啦~', size: 5, bold: true, row: 2 },
963
+ { type: 'image', imgId: 'bimg_money1', size: 6, imgScale: 0.4 },
964
+ { type: 'link', text: '>> 喂 点 米 <<', url: 'https://platform.deepseek.com/top_up', size: 1, color: '#ffffff', rgb: '', bgRgb: 'indigo', bg: '', bold: true, ul: false },
965
+ ],
966
+ autoClose: false, ttlSec: 6,
967
+ },
968
+ budget: {
969
+ on: true, amount: 10,
970
+ msg: '今日已用已达预算 ¥{amount}',
971
+ lines: [
972
+ { type: 'text', text: '老大,今天花销已经超过', size: 6, bold: true, row: 1 },
973
+ { type: 'text', text: '¥{amount}', size: 7, bold: true, row: 1, rgb: 'rouge', color: '', italic: false, bgRgb: '', bg: '' },
974
+ { type: 'text', text: '啦,再花要变成穷光蛋啦...', size: 6, bold: true, row: 1 },
975
+ ],
976
+ autoClose: false, ttlSec: 6,
977
+ },
978
+ // 每轮消耗提示内容(自定义提示窗口):与预警/预算同一套模块化内容,{cost} = 本轮消耗金额。
979
+ // 内容 = 冻结的当前生效快照(与前端 usageTurnCostDefaultLines() 一致);
980
+ // 开关/自动关闭秒数仍存 .dshw-size.json(不迁移),这里只放内容。
981
+ turnCost: {
982
+ lines: [
983
+ { type: 'text', text: '上一轮对话消耗:', size: 8, bold: true },
984
+ { type: 'text', text: '¥ {cost}', size: 24, bold: true, color: '#e0433f' },
985
+ { type: 'today', size: 2, tpl: '今日已用 {expense_ds}', bold: false, rgb: '', color: '#ffffff', bgRgb: 'indigo', bg: '' },
986
+ ],
987
+ },
988
+ }
989
+ }
990
+ function readUsageSettings() {
991
+ const led = readUsageLedger()
992
+ const d = usageSettingsDefaults()
993
+ const s = led && led.settings && typeof led.settings === 'object' ? led.settings : {}
994
+ if (s.taskEnd && typeof s.taskEnd === 'object') d.taskEnd = Object.assign({}, d.taskEnd, s.taskEnd)
995
+ if (s.alert && typeof s.alert === 'object') d.alert = Object.assign({}, d.alert, s.alert)
996
+ if (s.budget && typeof s.budget === 'object') d.budget = Object.assign({}, d.budget, s.budget)
997
+ if (s.turnCost && typeof s.turnCost === 'object') d.turnCost = Object.assign({}, d.turnCost, s.turnCost)
998
+ // 每个模型各自的提醒/预算:内置 DeepSeek 沿用顶层 alert/budget(旧配置零迁移)
999
+ const byModel = s.models && typeof s.models === 'object' ? s.models : {}
1000
+ // 手动额度默认值:资源包/订阅制厂商没有额度接口时,总量与已用由用户在面板里手填
1001
+ const qDef = () => ({ on: false, mode: 'auto', total: 0, unit: 'tokens', used: 0, reset: 'none', baseAt: 0 })
1002
+ d.models = { [API_BUILTIN_ID]: { alert: d.alert, budget: d.budget, quota: Object.assign(qDef(), (byModel[API_BUILTIN_ID] && byModel[API_BUILTIN_ID].quota) || {}) } }
1003
+ for (const m of readApiRegistry().models) {
1004
+ if (!m || !m.id || m.id === API_BUILTIN_ID) continue
1005
+ const st = byModel[m.id] && typeof byModel[m.id] === 'object' ? byModel[m.id] : {}
1006
+ d.models[m.id] = {
1007
+ alert: Object.assign({}, usageSettingsDefaults().alert, st.alert || {}),
1008
+ budget: Object.assign({}, usageSettingsDefaults().budget, st.budget || {}),
1009
+ quota: Object.assign(qDef(), st.quota || {}),
1010
+ }
1011
+ }
1012
+ return d
1013
+ }
1014
+ function writeUsageSettings(patch) {
1015
+ const led = readUsageLedger()
1016
+ led.settings = led.settings && typeof led.settings === 'object' ? led.settings : {}
1017
+ const p = patch || {}
1018
+ if (p.taskEnd && typeof p.taskEnd === 'object') led.settings.taskEnd = Object.assign({}, led.settings.taskEnd || {}, p.taskEnd)
1019
+ if (p.alert && typeof p.alert === 'object') led.settings.alert = Object.assign({}, led.settings.alert || {}, p.alert)
1020
+ if (p.budget && typeof p.budget === 'object') led.settings.budget = Object.assign({}, led.settings.budget || {}, p.budget)
1021
+ // 每轮消耗提示内容(自定义提示窗口)
1022
+ if (p.turnCost && typeof p.turnCost === 'object') led.settings.turnCost = Object.assign({}, led.settings.turnCost || {}, p.turnCost)
1023
+ // 按模型保存提醒/预算:{ modelId, alert?, budget? }
1024
+ if (p.modelSettings && p.modelSettings.id) {
1025
+ const mid = String(p.modelSettings.id)
1026
+ led.settings.models = led.settings.models && typeof led.settings.models === 'object' ? led.settings.models : {}
1027
+ const cur = led.settings.models[mid] && typeof led.settings.models[mid] === 'object' ? led.settings.models[mid] : {}
1028
+ if (p.modelSettings.alert && typeof p.modelSettings.alert === 'object') cur.alert = Object.assign({}, cur.alert || {}, p.modelSettings.alert)
1029
+ if (p.modelSettings.budget && typeof p.modelSettings.budget === 'object') cur.budget = Object.assign({}, cur.budget || {}, p.modelSettings.budget)
1030
+ // 手动额度:整体覆盖(总量/单位/已用/重置周期/统计模式/开关)
1031
+ if (p.modelSettings.quota && typeof p.modelSettings.quota === 'object') {
1032
+ const q = Object.assign({}, p.modelSettings.quota)
1033
+ const wantReset = !!q.resetBase
1034
+ delete q.resetBase
1035
+ // 「重置基准」:把累计 token 的基准点设为当前值 → 已用从 0 重新计
1036
+ if (wantReset) {
1037
+ const u = apiUsageRaw(mid)
1038
+ q.baseAt = Number(u && u.tokensTotal) || 0
1039
+ }
1040
+ cur.quota = Object.assign({}, cur.quota || {}, q)
1041
+ }
1042
+ led.settings.models[mid] = cur
1043
+ // 内置 DeepSeek:同步回写顶层字段,旧读取方(泡泡提醒)不受影响
1044
+ if (mid === API_BUILTIN_ID) {
1045
+ if (cur.alert) led.settings.alert = cur.alert
1046
+ if (cur.budget) led.settings.budget = cur.budget
1047
+ }
1048
+ }
1049
+ if (!writeUsageLedger(led)) return { ok: false, error: '保存失败' }
1050
+ return { ok: true, settings: readUsageSettings() }
1051
+ }
1052
+ // ===== Codex 本地会话统计(Codex 适配 · 第一期)=====
1053
+ // 数据源:$CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl 与 archived_sessions/*.jsonl(明文 JSONL)
1054
+ // 每行:{ type, timestamp, ordinal, payload }
1055
+ // 逐轮用量:type=token_count 的 payload.info.last_token_usage / total_token_usage
1056
+ // 模型归属:type=turn_context 的 payload.model
1057
+ // 口径:优先用「累计量的差值」(total_token_usage 单调递增,天然免疫一次轮次多条 token_count 的重复),
1058
+ // 用量拆分按该事件的 last_token_usage 比例缩放;累计缺失时退回 last_token_usage。
1059
+ // 缓存:$DSH_HOME/.dshw-codex.json(只存各文件聚合与 size/mtime,不存任何凭据,也不写 ~/.codex)
1060
+ function codexHome() {
1061
+ const env = String(process.env.CODEX_HOME || '').trim()
1062
+ for (const c of [env, path.join(os.homedir(), '.codex')]) {
1063
+ try { if (c && fs.existsSync(c)) return c } catch (err) {}
1064
+ }
1065
+ return ''
1066
+ }
1067
+ function readCodexCache() {
1068
+ try {
1069
+ const j = JSON.parse(fs.readFileSync(path.join(DSH_HOME, '.dshw-codex.json'), 'utf8'))
1070
+ if (j && j.files && typeof j.files === 'object') return j
1071
+ } catch (err) {}
1072
+ return { version: 1, files: {} }
1073
+ }
1074
+ function writeCodexCache(c) {
1075
+ try { fs.writeFileSync(path.join(DSH_HOME, '.dshw-codex.json'), JSON.stringify(c), 'utf8'); return true } catch (err) { return false }
1076
+ }
1077
+ function listCodexSessionFiles(root) {
1078
+ const out = []
1079
+ const walk = (dir, depth) => {
1080
+ if (depth > 6) return
1081
+ let ents = []
1082
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }) } catch (err) { return }
1083
+ for (const e of ents) {
1084
+ const p = path.join(dir, e.name)
1085
+ if (e.isDirectory()) walk(p, depth + 1)
1086
+ else if (/^rollout-.*\.jsonl$/i.test(e.name)) out.push(p)
1087
+ }
1088
+ }
1089
+ walk(path.join(root, 'sessions'), 0)
1090
+ walk(path.join(root, 'archived_sessions'), 0)
1091
+ return out
1092
+ }
1093
+ // 解析单个会话文件 → { day: { model: {in,cached,cwrite,out,reason,total,turns} } }
1094
+ function parseCodexFile(file) {
1095
+ const days = {}
1096
+ let model = 'codex'
1097
+ let prevTotal = null
1098
+ let lastRl = null, rlTs = 0
1099
+ let text = ''
1100
+ try { text = fs.readFileSync(file, 'utf8') } catch (err) { return { days, rl: null, rlTs: 0 } }
1101
+ const bump = (day, mk, v) => {
1102
+ days[day] = days[day] || {}
1103
+ const cur = days[day][mk] || { in: 0, cached: 0, cwrite: 0, out: 0, reason: 0, total: 0, turns: 0 }
1104
+ cur.in += v.in; cur.cached += v.cached; cur.cwrite += v.cwrite
1105
+ cur.out += v.out; cur.reason += v.reason; cur.total += v.total; cur.turns += v.turns
1106
+ days[day][mk] = cur
1107
+ }
1108
+ for (const line of text.split('\n')) {
1109
+ if (!line || line.charCodeAt(0) !== 123) continue // 只处理 '{' 开头的行
1110
+ let o = null
1111
+ try { o = JSON.parse(line) } catch (err) { continue }
1112
+ const p = o && o.payload
1113
+ if (!p) continue
1114
+ if (o.type === 'turn_context') {
1115
+ if (p.model) model = String(p.model)
1116
+ continue
1117
+ }
1118
+ if (o.type !== 'event_msg' || p.type !== 'token_count') continue
1119
+ const info = p.info
1120
+ if (!info || typeof info !== 'object') continue
1121
+ const last = info.last_token_usage || null
1122
+ const tot = info.total_token_usage || null
1123
+ const ts = Date.parse(String(o.timestamp || '')) || 0
1124
+ if (!ts) continue
1125
+ const day = dayKeyFromTs(ts)
1126
+ // 该次增量
1127
+ let delta = null
1128
+ const totAll = tot ? Number(tot.total_tokens) || 0 : 0
1129
+ if (totAll > 0) {
1130
+ if (prevTotal === null || totAll < prevTotal) delta = totAll
1131
+ else delta = totAll - prevTotal
1132
+ prevTotal = totAll
1133
+ }
1134
+ if (delta === null || delta <= 0) {
1135
+ if (!last) continue
1136
+ delta = Number(last.total_tokens) || 0
1137
+ }
1138
+ if (delta <= 0) continue
1139
+ // 拆分:按 last_token_usage 的比例缩放到 delta
1140
+ const lTotal = last ? Number(last.total_tokens) || 0 : 0
1141
+ const k = (lTotal > 0 && last) ? delta / lTotal : 0
1142
+ const v = last && k > 0
1143
+ ? {
1144
+ in: Math.round((Number(last.input_tokens) || 0) * k),
1145
+ cached: Math.round((Number(last.cached_input_tokens) || 0) * k),
1146
+ cwrite: Math.round((Number(last.cache_write_input_tokens) || 0) * k),
1147
+ out: Math.round((Number(last.output_tokens) || 0) * k),
1148
+ reason: Math.round((Number(last.reasoning_output_tokens) || 0) * k),
1149
+ }
1150
+ : { in: 0, cached: 0, cwrite: 0, out: 0, reason: 0 }
1151
+ bump(day, model, { in: v.in, cached: v.cached, cwrite: v.cwrite, out: v.out, reason: v.reason, total: delta, turns: 1 })
1152
+ // 订阅窗口快照:Codex 在 token_count 事件里带 rate_limits(ChatGPT 订阅 provider 才有值,
1153
+ // DeepSeek 这类 API-key provider 是 null)。保留最新的非空快照,供第二期展示。
1154
+ const rl = p.rate_limits
1155
+ if (rl && typeof rl === 'object' && (rl.primary || rl.secondary || rl.plan_type || rl.credits)) {
1156
+ if (ts >= rlTs) { lastRl = rl; rlTs = ts }
1157
+ }
1158
+ }
1159
+ return { days, rl: lastRl, rlTs }
1160
+ }
1161
+ // rate_limits 字段名在不同 Codex 版本里不一致 → 多候选键容错,换算成「已用% + 重置时间戳」
1162
+ function normalizeCodexWindow(w) {
1163
+ if (!w || typeof w !== 'object') return null
1164
+ const pick = (keys) => {
1165
+ for (const k of keys) {
1166
+ const raw = w[k]
1167
+ if (raw === null || raw === undefined || raw === '') continue
1168
+ const n = Number(raw)
1169
+ if (isFinite(n)) return n
1170
+ }
1171
+ return null
1172
+ }
1173
+ let usedPct = pick(['used_percent', 'usedPercent', 'percent', 'used_pct', 'usage_percent', 'usagePercent'])
1174
+ const remainPct = pick(['remaining_percent', 'remainingPercent', 'left_percent', 'remaining_pct'])
1175
+ if (usedPct === null && remainPct !== null) usedPct = Math.max(0, 100 - remainPct)
1176
+ let resetAt = null
1177
+ const secs = pick(['resets_in_seconds', 'resetsInSeconds', 'reset_after_seconds', 'reset_in_seconds', 'seconds_until_reset'])
1178
+ if (secs !== null && secs >= 0) resetAt = Date.now() + secs * 1000
1179
+ else {
1180
+ const abs = w.resets_at || w.reset_at || w.reset_time || w.next_reset || w.resetAt || w.nextResetTime
1181
+ if (typeof abs === 'number' && isFinite(abs)) resetAt = abs < 1e12 ? abs * 1000 : abs
1182
+ else if (typeof abs === 'string' && abs) { const t = Date.parse(abs); if (isFinite(t)) resetAt = t }
1183
+ }
1184
+ const windowMinutes = pick(['window_minutes', 'windowMinutes', 'window', 'period_minutes'])
1185
+ const label = String(w.limit_name || w.name || w.label || w.window_name || '')
1186
+ if (usedPct === null && resetAt === null) return null
1187
+ return { usedPct, resetAt, windowMinutes, label }
1188
+ }
1189
+ function normalizeCodexRateLimits(rl) {
1190
+ if (!rl || typeof rl !== 'object') return null
1191
+ const primary = normalizeCodexWindow(rl.primary)
1192
+ const secondary = normalizeCodexWindow(rl.secondary)
1193
+ if (!primary && !secondary) return null
1194
+ return {
1195
+ primary, secondary,
1196
+ planType: rl.plan_type ? String(rl.plan_type) : '',
1197
+ limitName: rl.limit_name ? String(rl.limit_name) : (rl.limit_id ? String(rl.limit_id) : ''),
1198
+ reached: rl.rate_limit_reached_type ? String(rl.rate_limit_reached_type) : '',
1199
+ }
1200
+ }
1201
+ // 汇总(带增量缓存:只有变化的文件才重新解析)
1202
+ function codexSummary() {
1203
+ const home = codexHome()
1204
+ if (!home) return { ok: false, error: '未找到 Codex 目录($CODEX_HOME 或 ~/.codex)' }
1205
+ const cache = readCodexCache()
1206
+ const files = listCodexSessionFiles(home)
1207
+ const keep = {}
1208
+ let changed = 0
1209
+ for (const f of files) {
1210
+ let st = null
1211
+ try { st = fs.statSync(f) } catch (err) { continue }
1212
+ const prev = cache.files[f]
1213
+ if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs && prev.days) { keep[f] = prev; continue }
1214
+ const parsed = parseCodexFile(f)
1215
+ keep[f] = { size: st.size, mtimeMs: st.mtimeMs, days: parsed.days, rl: parsed.rl || null, rlTs: parsed.rlTs || 0 }
1216
+ changed++
1217
+ }
1218
+ if (changed > 0 || Object.keys(cache.files).length !== Object.keys(keep).length) {
1219
+ writeCodexCache({ version: 1, files: keep, builtAt: Date.now() })
1220
+ }
1221
+ const today = dayKeyFromTs(Date.now())
1222
+ const month = today.slice(0, 7)
1223
+ const byDay = {}
1224
+ const byModel = {}
1225
+ let totalTokens = 0, todayTokens = 0, monthTokens = 0, outTokens = 0, reasonTokens = 0, cachedTokens = 0
1226
+ let bestRl = null, bestRlTs = -1
1227
+ for (const f of Object.keys(keep)) {
1228
+ if (keep[f].rl && (keep[f].rlTs || 0) >= bestRlTs) { bestRl = keep[f].rl; bestRlTs = keep[f].rlTs || 0 }
1229
+ const days = keep[f].days || {}
1230
+ for (const d of Object.keys(days)) {
1231
+ const day0 = byDay[d] || (byDay[d] = { tokens: 0, turns: 0, models: {} })
1232
+ for (const m of Object.keys(days[d])) {
1233
+ const v = days[d][m] || {}
1234
+ day0.tokens += Number(v.total) || 0
1235
+ day0.turns += Number(v.turns) || 0
1236
+ day0.models[m] = (day0.models[m] || 0) + (Number(v.total) || 0)
1237
+ const bm = byModel[m] || (byModel[m] = { tokens: 0, out: 0, reason: 0, cached: 0, turns: 0 })
1238
+ bm.tokens += Number(v.total) || 0
1239
+ bm.out += Number(v.out) || 0
1240
+ bm.reason += Number(v.reason) || 0
1241
+ bm.cached += Number(v.cached) || 0
1242
+ bm.turns += Number(v.turns) || 0
1243
+ totalTokens += Number(v.total) || 0
1244
+ outTokens += Number(v.out) || 0
1245
+ reasonTokens += Number(v.reason) || 0
1246
+ cachedTokens += Number(v.cached) || 0
1247
+ if (d === today) todayTokens += Number(v.total) || 0
1248
+ if (d.slice(0, 7) === month) monthTokens += Number(v.total) || 0
1249
+ }
1250
+ }
1251
+ }
1252
+ const days7 = []
1253
+ for (let i = 0; i < 7; i++) {
1254
+ const d = dayAdd(today, -i)
1255
+ days7.push({ date: d, tokens: (byDay[d] && byDay[d].tokens) || 0, turns: (byDay[d] && byDay[d].turns) || 0 })
1256
+ }
1257
+ return {
1258
+ ok: true, home, sessions: files.length, changed,
1259
+ todayTokens, monthTokens, totalTokens,
1260
+ outTokens, reasonTokens, cachedTokens,
1261
+ days7, byModel,
1262
+ // 第二期:订阅窗口(5h / 周)。仅当日志里带着带值的 rate_limits(ChatGPT 订阅 provider)时才有内容
1263
+ rateLimits: bestRl,
1264
+ windows: normalizeCodexRateLimits(bestRl),
1265
+ rateLimitsTs: bestRlTs > 0 ? bestRlTs : 0,
1266
+ }
1267
+ }
1268
+ // codexSummary 的短 TTL 记忆:payload 循环 + 额度计算 + 探活会在同一次请求里反复问同一份数据,
1269
+ // 每次都同步 stat/读文件会白白阻塞事件循环(会话文件可能有几十 MB)
1270
+ let codexMemo = null, codexMemoAt = 0
1271
+ function codexSummaryCached() {
1272
+ const now = Date.now()
1273
+ if (codexMemo && now - codexMemoAt < 5000) return codexMemo
1274
+ codexMemo = codexSummary()
1275
+ codexMemoAt = now
1276
+ return codexMemo
1277
+ }
1278
+ // 后台预热:codexSummary 首次要同步读全部 rollout 日志(可能几十 MB),
1279
+ // 放在请求路径里会阻塞事件循环。这里在插件启动后预热一次,并每 5 分钟刷新一次缓存,
1280
+ // 请求进来时通常直接命中 codexSummaryCached 的 5 秒记忆。
1281
+ try {
1282
+ setTimeout(function () { try { codexSummaryCached() } catch (err) {} }, 1500)
1283
+ setInterval(function () { try { codexSummaryCached() } catch (err) {} }, 5 * 60 * 1000)
1284
+ } catch (err) {}
1285
+ // ===== 自定义 API 模型注册表(v655)=====
1286
+ // 文件:$DSH_HOME/.dshw-api.json → { version, models:[…], usage:{ [id]:{day,dayStart,lastBalance,delta,eventCost} } }
1287
+ // 密钥不落此文件:写入 DSH 官方凭据(ctx.credentials.set),读取走 credentials.resolve(keyRef)。
1288
+ function defaultApiRegistry() { return { version: 1, models: [], usage: {} } }
1289
+ function readApiRegistry() {
1290
+ for (const p of API_FILE_CANDIDATES) {
1291
+ try {
1292
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf8'))
1293
+ if (parsed && Array.isArray(parsed.models)) {
1294
+ if (!parsed.usage || typeof parsed.usage !== 'object') parsed.usage = {}
1295
+ return parsed
1296
+ }
1297
+ } catch (err) {}
1298
+ }
1299
+ return defaultApiRegistry()
1300
+ }
1301
+ function writeApiRegistry(reg) {
1302
+ const body = JSON.stringify(reg, null, 2)
1303
+ for (const p of API_FILE_CANDIDATES) {
1304
+ try { fs.writeFileSync(p, body, 'utf8'); return true } catch (err) {}
1305
+ }
1306
+ return false
1307
+ }
1308
+ function apiTemplateOf(provider) {
1309
+ return API_TEMPLATES[String(provider || '')] || null
1310
+ }
1311
+ // 内置 DeepSeek 永远存在(不进注册表文件),其余来自注册表
1312
+ function apiBuiltinModel() {
1313
+ const t = API_TEMPLATES.deepseek
1314
+ return { id: API_BUILTIN_ID, name: t.name, provider: 'deepseek', currency: t.currency, keyRef: t.keyRef, builtin: true, matchIds: ['deepseek'] }
1315
+ }
1316
+ function apiAllModels() {
1317
+ const custom = readApiRegistry().models.filter((m) => m && m.id && m.id !== API_BUILTIN_ID)
1318
+ return [apiBuiltinModel()].concat(custom)
1319
+ }
1320
+ function apiModelById(id) {
1321
+ if (String(id || '') === API_BUILTIN_ID) return apiBuiltinModel()
1322
+ return readApiRegistry().models.find((m) => m && m.id === id) || null
1323
+ }
1324
+ // JSON 取值:支持 a.b[0].c
1325
+ function pickJsonPath(obj, pathStr) {
1326
+ try {
1327
+ const parts = String(pathStr || '').replace(/\[(\d+)\]/g, '.$1').split('.').filter((x) => x.length > 0)
1328
+ let cur = obj
1329
+ for (const p of parts) {
1330
+ if (cur === null || cur === undefined) return undefined
1331
+ cur = cur[p]
1332
+ }
1333
+ return cur
1334
+ } catch (err) { return undefined }
1335
+ }
1336
+ function apiNum(v, scale) {
1337
+ const n = Number(v)
1338
+ if (!isFinite(n)) return null
1339
+ const s = isFinite(Number(scale)) && Number(scale) > 0 ? Number(scale) : 1
1340
+ return n * s
1341
+ }
1342
+ // v724 非空合并:over 里的空串 / undefined / null **不覆盖** base。
1343
+ // 为什么需要:新建模型时表单里没填的字段会存成空串,若直接 Object.assign,
1344
+ // 空串会把模板自带的接口地址 / 字段路径"盖掉",余额就永远查不到(会报「未配置余额接口地址」)。
1345
+ function mergeNonEmpty(base, over) {
1346
+ const out = Object.assign({}, base || {})
1347
+ const o = over && typeof over === 'object' ? over : {}
1348
+ for (const k of Object.keys(o)) {
1349
+ const v = o[k]
1350
+ if (v === undefined || v === null || v === '') continue
1351
+ if (v && typeof v === 'object' && !Array.isArray(v)) { out[k] = mergeNonEmpty(out[k], v); continue }
1352
+ out[k] = v
1353
+ }
1354
+ return out
1355
+ }
1356
+ // v724 递归去空:写注册表时用。让"没填的字段"根本不存在 → 模板默认值继续生效(配合 mergeNonEmpty)
1357
+ function stripEmptyDeep(o) {
1358
+ if (Array.isArray(o)) return o
1359
+ if (!o || typeof o !== 'object') return o
1360
+ const out = {}
1361
+ for (const k of Object.keys(o)) {
1362
+ const v = stripEmptyDeep(o[k])
1363
+ if (v === undefined || v === null || v === '') continue
1364
+ if (v && typeof v === 'object' && !Array.isArray(v) && !Object.keys(v).length) continue
1365
+ out[k] = v
1366
+ }
1367
+ return out
1368
+ }
1369
+ async function apiFetchJson(url, auth, key) {
1370
+ const headers = {}
1371
+ const a = String(auth == null ? 'Bearer {key}' : auth)
1372
+ if (a) headers.Authorization = a.replace('{key}', key)
1373
+ // v724:URL 里也支持 {key}(如 Gemini 的 ?key=… 形式;auth 传空串时不带 Authorization 头)
1374
+ const u = String(url).replace('{key}', key)
1375
+ const res = await fetch(u, { headers, signal: AbortSignal.timeout(15000) })
1376
+ if (!res.ok) throw new Error('HTTP ' + res.status)
1377
+ return await res.json()
1378
+ }
1379
+ // 测试连通性:优先模板的 probeUrl(如方舟 /api/v3/models),否则用余额接口/Base URL
1380
+ // 从响应体里提炼「业务层错误」:有些接口(如智谱额度)key 不对也返回 HTTP 200,
1381
+ // 真正的问题在 body 的 code/msg(例如「当前用户不存在coding plan」),必须报出来
1382
+ function apiBusinessError(data) {
1383
+ if (!data || typeof data !== 'object') return ''
1384
+ if (data.success === false || data.ok === false) {
1385
+ return String(data.msg || data.message || data.error || 'success=false').slice(0, 120)
1386
+ }
1387
+ const code = Number(data.code)
1388
+ if (isFinite(code) && code !== 0 && code !== 200 && data.msg) return String(data.msg).slice(0, 120)
1389
+ if (typeof data.error === 'string' && data.error) return data.error.slice(0, 120)
1390
+ if (data.error && typeof data.error === 'object' && data.error.message) return String(data.error.message).slice(0, 120)
1391
+ return ''
1392
+ }
1393
+ async function apiProbeModel(model) {
1394
+ if (!model) return { ok: false, error: '模型不存在' }
1395
+ const tpl = apiTemplateOf(model.provider) || {}
1396
+ if (model.id === API_BUILTIN_ID) {
1397
+ const r = await fetchBalance()
1398
+ return r.ok ? { ok: true, detail: '余额 ' + r.totalBalance + ' ' + (r.currency || 'CNY') } : { ok: false, error: r.error || r.code }
1399
+ }
1400
+ // Codex 模式:本地会话统计即「探活结果」,不需要网络与密钥
1401
+ if (tpl.kind === 'codex') {
1402
+ const cs = codexSummaryCached()
1403
+ const f = (n) => (n >= 100000000 ? (n / 100000000).toFixed(2) + '亿' : (n >= 10000 ? (n / 10000).toFixed(1).replace(/\.0$/, '') + '万' : String(Math.round(Number(n) || 0))))
1404
+ if (cs && cs.ok) {
1405
+ return { ok: true, detail: 'Codex 本地会话:今日 ' + f(cs.todayTokens) + ' · 本月 ' + f(cs.monthTokens) + ' · 累计 ' + f(cs.totalTokens) + ' tokens(' + cs.sessions + ' 个会话文件)' }
1406
+ }
1407
+ return { ok: false, error: (cs && cs.error) || '未找到 Codex 会话目录' }
1408
+ }
1409
+ let key = ''
1410
+ try {
1411
+ const cred = await ctx.credentials.resolve(model.keyRef || tpl.keyRef || '')
1412
+ key = cred && cred.value ? String(cred.value) : ''
1413
+ } catch (err) { key = '' }
1414
+ if (!key) return { ok: false, error: '未配置 ' + (model.keyRef || tpl.keyRef || 'API key') }
1415
+ const b = mergeNonEmpty(tpl.balance, model.balance) // v724:模型侧空串不再覆盖模板
1416
+ const base = String(model.baseUrl || '').replace(/\/+$/, '')
1417
+ const url = String(tpl.probeUrl || b.url || (base ? base + '/v1/models' : '')).replace('{base}', base)
1418
+ if (!url) return { ok: false, error: '没有可测试的接口(请填余额接口或 Base URL)' }
1419
+ try {
1420
+ const data = await apiFetchJson(url, b.auth, key)
1421
+ const bizErr = apiBusinessError(data)
1422
+ if (bizErr) return { ok: false, error: '测试失败: ' + bizErr }
1423
+ let detail = 'HTTP 200'
1424
+ if (data && Array.isArray(data.data)) detail = '可用模型 ' + data.data.length + ' 个'
1425
+ else if (data && data.data && Array.isArray(data.data.models)) detail = '可用模型 ' + data.data.models.length + ' 个'
1426
+ return { ok: true, detail: detail }
1427
+ } catch (err) {
1428
+ return { ok: false, error: '测试失败: ' + String((err && err.message) || err).slice(0, 140) }
1429
+ }
1430
+ }
1431
+ // 订阅额度(Coding Plan)解析:各厂商字段不同,统一归一成「已用% + 重置时间 + 档位」
1432
+ // zhipu: data.limits[0].TOKENS_LIMIT.percentage(已用%)+ nextResetTime
1433
+ // kimi: usage.remaining / usage.limit(剩余/总量)
1434
+ // minimax: model_remains[0].current_interval_remaining_percent(剩余%)+ end_time(ms)
1435
+ async function fetchModelQuota(model) {
1436
+ if (!model) return { ok: false, error: '模型不存在' }
1437
+ const tpl = apiTemplateOf(model.provider) || {}
1438
+ const q = tpl.quota
1439
+ if (!q || !q.url) return { ok: false, code: 'NO_QUOTA', error: '该厂商没有订阅额度接口' }
1440
+ let key = ''
1441
+ try {
1442
+ const cred = await ctx.credentials.resolve(model.keyRef || tpl.keyRef || '')
1443
+ key = cred && cred.value ? String(cred.value) : ''
1444
+ } catch (err) { key = '' }
1445
+ if (!key) return { ok: false, code: 'NO_KEY', error: '未配置 ' + (model.keyRef || tpl.keyRef || 'API key') }
1446
+ const base = String(model.baseUrl || '').replace(/\/+$/, '')
1447
+ const url = String(q.url).replace('{base}', base)
1448
+ try {
1449
+ const data = await apiFetchJson(url, q.auth, key)
1450
+ const bizErr = apiBusinessError(data)
1451
+ if (bizErr) return { ok: false, code: 'BIZ', error: bizErr }
1452
+ const j = q.json || {}
1453
+ const num = (v) => { const n = Number(v); return isFinite(n) ? n : null }
1454
+ let usedPct = null, remainPct = null, resetAt = null, level = '', weeklyUsedPct = null
1455
+ if (j.percent) { const p = num(pickJsonPath(data, j.percent)); if (p !== null) usedPct = p }
1456
+ if (j.remainPct) {
1457
+ const p = num(pickJsonPath(data, j.remainPct))
1458
+ if (p !== null) { remainPct = p; if (usedPct === null) usedPct = Math.max(0, 100 - p) }
1459
+ }
1460
+ if (j.weeklyRemainPct) {
1461
+ const p = num(pickJsonPath(data, j.weeklyRemainPct))
1462
+ if (p !== null) weeklyUsedPct = Math.max(0, 100 - p)
1463
+ }
1464
+ if (j.remain && j.total) {
1465
+ const r0 = num(pickJsonPath(data, j.remain))
1466
+ const t0 = num(pickJsonPath(data, j.total))
1467
+ if (r0 !== null && t0) {
1468
+ remainPct = Math.max(0, Math.min(100, r0 / t0 * 100))
1469
+ usedPct = Math.max(0, 100 - remainPct)
1470
+ }
1471
+ }
1472
+ if (j.resetAt) resetAt = pickJsonPath(data, j.resetAt)
1473
+ if (j.resetAtMs) { const ms = num(pickJsonPath(data, j.resetAtMs)); if (ms !== null) resetAt = ms }
1474
+ if (j.level) level = String(pickJsonPath(data, j.level) || '')
1475
+ if (usedPct === null && remainPct === null && !resetAt) {
1476
+ return { ok: false, code: 'PARSE', error: '额度接口返回无法解析(字段路径不匹配)' }
1477
+ }
1478
+ return { ok: true, usedPct, remainPct, resetAt, level, weeklyUsedPct }
1479
+ } catch (err) {
1480
+ return { ok: false, code: 'HTTP', error: '额度接口请求失败: ' + String((err && err.message) || err).slice(0, 140) }
1481
+ }
1482
+ }
1483
+ // 厂商回报「账号没有该订阅套餐」(如智谱的「当前用户不存在coding plan」):
1484
+ // 对没订阅的用户这行没意义,标记 hide 让前端不显示(真正的网络/密钥错误不隐藏)
1485
+ function apiPlanNoPlan(msg) {
1486
+ const s = String(msg || '').toLowerCase()
1487
+ if (!s) return false
1488
+ return s.indexOf('coding plan') >= 0 || s.indexOf('不存在') >= 0 || s.indexOf('未订阅') >= 0 ||
1489
+ s.indexOf('not subscribed') >= 0 || s.indexOf('no plan') >= 0 || s.indexOf('no active') >= 0 ||
1490
+ s.indexOf('subscription') >= 0
1491
+ }
1492
+ // 取某模型余额:DeepSeek 复用既有官方链路;其余按模板/自定义描述请求并解析
1493
+ async function fetchModelBalance(model) {
1494
+ if (!model) return { ok: false, code: 'NO_MODEL', error: '模型不存在' }
1495
+ if (model.id === API_BUILTIN_ID) {
1496
+ const r = await fetchBalance()
1497
+ if (!r.ok) return r
1498
+ return { ok: true, remaining: Number(r.totalBalance), currency: r.currency || 'CNY' }
1499
+ }
1500
+ const tpl = apiTemplateOf(model.provider) || {}
1501
+ const b = mergeNonEmpty(tpl.balance, model.balance) // v724:模型侧空串不再覆盖模板
1502
+ if (!b.url) return { ok: false, code: 'NO_URL', error: '未配置余额接口地址' }
1503
+ let key = ''
1504
+ try {
1505
+ const cred = await ctx.credentials.resolve(model.keyRef || tpl.keyRef || '')
1506
+ key = cred && cred.value ? String(cred.value) : ''
1507
+ } catch (err) { key = '' }
1508
+ if (!key) return { ok: false, code: 'NO_KEY', error: '未配置 ' + (model.keyRef || tpl.keyRef || 'API key') }
1509
+ const base = String(model.baseUrl || '').replace(/\/+$/, '')
1510
+ const url = String(b.url).replace('{base}', base)
1511
+ try {
1512
+ const data = await apiFetchJson(url, b.auth, key)
1513
+ const j = b.json || {}
1514
+ let remaining = null
1515
+ let total = null
1516
+ let used = 0
1517
+ if (j.remaining) { const v = apiNum(pickJsonPath(data, j.remaining), j.scale); if (v !== null) remaining = v }
1518
+ if (j.total) { const v = apiNum(pickJsonPath(data, j.total), j.scale); if (v !== null) total = v }
1519
+ if (j.used) { const v = apiNum(pickJsonPath(data, j.used), j.scale); if (v !== null) used += v }
1520
+ if (b.usage && b.usage.url) {
1521
+ const u = b.usage
1522
+ const d2 = await apiFetchJson(String(u.url).replace('{base}', base), u.auth || b.auth, key)
1523
+ const j2 = u.json || {}
1524
+ if (j2.used) { const v = apiNum(pickJsonPath(d2, j2.used), j2.scale); if (v !== null) used += v }
1525
+ }
1526
+ if (remaining === null && total !== null) remaining = total - used
1527
+ if (remaining === null) return { ok: false, code: 'SHAPE', error: '接口返回里没找到配置的字段(检查 JSON 路径)' }
1528
+ return { ok: true, remaining, total, used, currency: model.currency || tpl.currency || 'CNY' }
1529
+ } catch (err) {
1530
+ return { ok: false, code: 'HTTP', error: '余额接口请求失败: ' + String((err && err.message) || err).slice(0, 160) }
1531
+ }
1532
+ }
1533
+ // 逐模型记录的骨架:跨天只重置「当日」字段,累计量(tokensTotal/tokensMonth)保留
1534
+ function apiUsageNewDay(cur, day, dayKey) {
1535
+ const prev = cur && typeof cur === 'object' ? cur : {}
1536
+ const mk = String(dayKey || '').slice(0, 7)
1537
+ return {
1538
+ day: day, dayStart: null, lastBalance: null, delta: 0, eventCost: 0, eventTokens: 0, currency: '',
1539
+ tokensTotal: Number(prev.tokensTotal) || 0,
1540
+ month: mk,
1541
+ tokensMonth: prev.month === mk ? (Number(prev.tokensMonth) || 0) : 0,
1542
+ }
1543
+ }
1544
+ // 逐模型余额差记账(与 DeepSeek 账本同口径:当天首次观测为基准,之后累加下降额)
1545
+ function apiRecordBalance(id, remaining) {
1546
+ try {
1547
+ const reg = readApiRegistry()
1548
+ reg.usage = reg.usage && typeof reg.usage === 'object' ? reg.usage : {}
1549
+ const day = todayKey()
1550
+ let cur = reg.usage[id]
1551
+ if (!cur || cur.day !== day) cur = apiUsageNewDay(cur, day, todayKey())
1552
+ if (typeof remaining === 'number' && isFinite(remaining)) {
1553
+ if (cur.dayStart === null || cur.dayStart === undefined) cur.dayStart = remaining
1554
+ if (typeof cur.lastBalance === 'number' && remaining < cur.lastBalance - 0.000001) cur.delta = round2(cur.delta + (cur.lastBalance - remaining))
1555
+ cur.lastBalance = remaining
1556
+ }
1557
+ reg.usage[id] = cur
1558
+ writeApiRegistry(reg)
1559
+ return cur
1560
+ } catch (err) { return null }
1561
+ }
1562
+ // 今日已用:来源可能是「余额差」(厂商币种)或「会话事件金额」(已按自定义单价折算成人民币)。
1563
+ // 两种来源币种不同 → 返回值必须带上 currency,前端显示与阈值比较都按它走,
1564
+ // 否则 USD 单价的模型会把人民币数额渲染成 $、或拿两种单位直接比大小。
1565
+ function apiTodayUsage(id, modelCurrency) {
1566
+ const mcur = String(modelCurrency || 'CNY').toUpperCase() || 'CNY'
1567
+ try {
1568
+ const reg = readApiRegistry()
1569
+ const cur = reg.usage && reg.usage[id]
1570
+ if (!cur || cur.day !== todayKey()) return { amount: 0, source: 'none', currency: mcur }
1571
+ if (typeof cur.delta === 'number' && cur.delta > 0.004) return { amount: round2(cur.delta), source: 'balance', currency: mcur }
1572
+ return { amount: round2(Number(cur.eventCost) || 0), source: 'events', currency: 'CNY' }
1573
+ } catch (err) { return { amount: 0, source: 'none', currency: mcur } }
1574
+ }
1575
+ function apiUsageRaw(id) {
1576
+ try {
1577
+ const reg = readApiRegistry()
1578
+ return (reg.usage && reg.usage[id]) || null
1579
+ } catch (err) { return null }
1580
+ }
1581
+ // 额度「已用」:自动模式用会话 token 统计(不重置=累计-基准,每日=今日,每月=本月)
1582
+ function apiQuotaAutoUsed(id, q) {
1583
+ const u = apiUsageRaw(id) || {}
1584
+ const reset = (q && q.reset) || 'none'
1585
+ // 单位是「金额(元)」时自动统计意义不成立:自动累计的是 token,不是钱。
1586
+ // 这里直接回退成手动值(q.used),避免把 token 数当金额显示;界面上也约束为只能手动填写。
1587
+ if (q && q.unit === 'money' && (q.mode === 'auto' || q.mode === 'codex')) {
1588
+ return Math.round(Number(q.used) || 0)
1589
+ }
1590
+ // Codex 模式:已用 = Codex 本地会话统计的 token(按重置口径取今日/本月/累计)
1591
+ if (q && q.mode === 'codex') {
1592
+ const cs = codexSummaryCached()
1593
+ const base = Number(q.used) || 0
1594
+ if (!cs || !cs.ok) return base
1595
+ if (reset === 'daily') return Math.round(Number(cs.todayTokens) || 0)
1596
+ if (reset === 'monthly') return Math.round(Number(cs.monthTokens) || 0)
1597
+ const baseAt = Number(q.baseAt) || 0
1598
+ return Math.round(base + Math.max(0, (Number(cs.totalTokens) || 0) - baseAt))
1599
+ }
1600
+ if (reset === 'daily') return Math.round(Number(u.eventTokens) || 0)
1601
+ if (reset === 'monthly') return Math.round(Number(u.tokensMonth) || 0)
1602
+ const total = Number(u.tokensTotal) || 0
1603
+ const baseAt = Number(q && q.baseAt) || 0
1604
+ // used 在自动模式下表示「起始已用」(装机前已经用掉的部分)
1605
+ return Math.round((Number(q && q.used) || 0) + Math.max(0, total - baseAt))
1606
+ }
1607
+ // 会话事件归属:把某模型的 token 花费累加到注册表里匹配的模型(matchIds 子串匹配)
1608
+ function apiAttributeEvent(modelName, cost, tokens) {
1609
+ try {
1610
+ const name = String(modelName || '').toLowerCase()
1611
+ if (!name) return false
1612
+ const reg = readApiRegistry()
1613
+ let hit = null
1614
+ for (const m of reg.models) {
1615
+ if (!m || !m.id) continue
1616
+ const ids = Array.isArray(m.matchIds) && m.matchIds.length ? m.matchIds : [m.name, m.id]
1617
+ for (const s of ids) {
1618
+ const k = String(s || '').toLowerCase().trim()
1619
+ if (k && name.indexOf(k) >= 0) { hit = m; break }
1620
+ }
1621
+ if (hit) break
1622
+ }
1623
+ if (!hit) return false
1624
+ reg.usage = reg.usage && typeof reg.usage === 'object' ? reg.usage : {}
1625
+ const day = todayKey()
1626
+ let cur = reg.usage[hit.id]
1627
+ if (!cur || cur.day !== day) cur = apiUsageNewDay(cur, day, todayKey())
1628
+ cur.eventCost = round2((Number(cur.eventCost) || 0) + (Number(cost) || 0))
1629
+ cur.eventTokens = (Number(cur.eventTokens) || 0) + (Number(tokens) || 0)
1630
+ // 累计量:额度(资源包/订阅)按它算已用;跨天不清零
1631
+ const tk = Number(tokens) || 0
1632
+ cur.tokensTotal = (Number(cur.tokensTotal) || 0) + tk
1633
+ const mk = todayKey().slice(0, 7)
1634
+ if (cur.month !== mk) { cur.month = mk; cur.tokensMonth = 0 }
1635
+ cur.tokensMonth = (Number(cur.tokensMonth) || 0) + tk
1636
+ reg.usage[hit.id] = cur
1637
+ writeApiRegistry(reg)
1638
+ return true
1639
+ } catch (err) { return false }
1640
+ }
1641
+ // 删除模型:注册表 + 逐模型设置 + 所有泡泡里引用该模型的模块(含并列 A/B 与模块库)
1642
+ function apiDeleteModel(id) {
1643
+ const mid = String(id || '')
1644
+ if (!mid || mid === API_BUILTIN_ID) return { ok: false, error: '内置模型不可删除' }
1645
+ const reg = readApiRegistry()
1646
+ reg.models = reg.models.filter((m) => !(m && m.id === mid))
1647
+ if (reg.usage) delete reg.usage[mid]
1648
+ writeApiRegistry(reg)
1649
+ // 删除模型后同样失效单价缓存(它可能带着自定义单价)
1650
+ customPriceAt = 0
1651
+ let removed = 0
1652
+ try {
1653
+ const led = readUsageLedger()
1654
+ if (led.settings && led.settings.models && led.settings.models[mid]) {
1655
+ delete led.settings.models[mid]
1656
+ writeUsageLedger(led)
1657
+ }
1658
+ } catch (err) {}
1659
+ try {
1660
+ const cfg = loadBubbleConfig()
1661
+ let changed = false
1662
+ const stripItem = (obj) => {
1663
+ if (!obj || typeof obj !== 'object') return
1664
+ if (Array.isArray(obj.modules)) {
1665
+ const before = obj.modules.length
1666
+ obj.modules = obj.modules.filter((m) => !(m && m.modelId === mid))
1667
+ removed += before - obj.modules.length
1668
+ if (obj.modules.length !== before) changed = true
1669
+ }
1670
+ if (Array.isArray(obj.options)) {
1671
+ for (const o of obj.options) if (o && o.item) stripItem(o.item)
1672
+ }
1673
+ }
1674
+ // 泡泡步骤(含并列 A/B)
1675
+ if (Array.isArray(cfg.items)) for (const it of cfg.items) stripItem(it)
1676
+ // 模块库条目形态为 {id,name,module}
1677
+ if (Array.isArray(cfg.lib)) {
1678
+ for (let i = cfg.lib.length - 1; i >= 0; i--) {
1679
+ const lb = cfg.lib[i]
1680
+ if (lb && lb.module && lb.module.modelId === mid) { cfg.lib.splice(i, 1); changed = true; removed++; continue }
1681
+ if (lb && lb.module) stripItem(lb.module)
1682
+ }
1683
+ }
1684
+ if (changed) writeBubbleConfig(cfg)
1685
+ } catch (err) {}
1686
+ return { ok: true, removedModules: removed }
1687
+ }
1688
+ // 新增/更新模型;密钥(keyValue)若提供则写入 DSH 官方凭据
1689
+ // 自定义单价表刷新:把每个自定义 API 模型的 matchIds → 价格表映射进来,供 module 级 priceFor 使用
1690
+ // (priceFor 在模块作用域,读不到插件内的注册表,所以这里把结果写进 CUSTOM_PRICES)
1691
+ let customPriceAt = 0
1692
+ function refreshCustomPrices() {
1693
+ const now = Date.now()
1694
+ if (now - customPriceAt < 10000) return
1695
+ customPriceAt = now
1696
+ try {
1697
+ const map = {}
1698
+ const metaMap = {}
1699
+ for (const m of readApiRegistry().models) {
1700
+ // 内置模型(DeepSeek)跳过:它有自己的峰谷价表,不能被自定义单价覆盖
1701
+ if (!m || m.id === API_BUILTIN_ID) continue
1702
+ const pr = m && m.price
1703
+ if (!pr) continue
1704
+ const hit = Number(pr.hit), miss = Number(pr.miss), out = Number(pr.out)
1705
+ const ok = (v) => (isFinite(v) ? v : 0)
1706
+ if (!isFinite(hit) && !isFinite(miss) && !isFinite(out)) continue
1707
+ const table = { hit: [ok(hit), ok(hit)], miss: [ok(miss), ok(miss)], out: [ok(out), ok(out)] }
1708
+ const meta = { cur: String(pr.cur || 'CNY').toUpperCase(), rate: isFinite(Number(pr.rate)) ? Number(pr.rate) : 0 }
1709
+ const ids = Array.isArray(m.matchIds) && m.matchIds.length ? m.matchIds : [m.name, m.id]
1710
+ for (const s of ids) {
1711
+ const k = String(s || '').toLowerCase().trim()
1712
+ // 关键字过短(< 3 字符,如 pro/flash)极易误伤其它模型,直接忽略
1713
+ if (k && k.length >= 3) { map[k] = table; metaMap[k] = meta }
1714
+ }
1715
+ }
1716
+ CUSTOM_PRICES = map
1717
+ CUSTOM_PRICE_META = metaMap
1718
+ } catch (err) {}
1719
+ }
1720
+ async function apiSaveModel(input) {
1721
+ const p = input || {}
1722
+ const tpl = apiTemplateOf(p.provider)
1723
+ if (!tpl) return { ok: false, error: '未知的厂商模板' }
1724
+ const name = String(p.name || '').trim().slice(0, 30) || tpl.name
1725
+ const keyRef = String(p.keyRef || tpl.keyRef || '').trim().slice(0, 64) || tpl.keyRef
1726
+ const id = p.id ? String(p.id) : ('api_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7))
1727
+ const reg = readApiRegistry()
1728
+ let model = reg.models.find((m) => m && m.id === id)
1729
+ if (!model) {
1730
+ model = { id, createdAt: Date.now() }
1731
+ reg.models.push(model)
1732
+ }
1733
+ model.name = name
1734
+ model.provider = p.provider
1735
+ model.currency = String(p.currency || tpl.currency || 'CNY').toUpperCase().slice(0, 8)
1736
+ model.keyRef = keyRef
1737
+ if (p.baseUrl !== undefined) model.baseUrl = String(p.baseUrl || '').slice(0, 300)
1738
+ if (p.balance && typeof p.balance === 'object') {
1739
+ // v724:空字段不写入(stripEmptyDeep)→ 模板默认值继续生效;
1740
+ // 全部为空时直接删掉 model.balance,等于"完全用模板"
1741
+ const balIn = stripEmptyDeep({
1742
+ url: String(p.balance.url || '').slice(0, 500),
1743
+ auth: String(p.balance.auth || '').slice(0, 200),
1744
+ json: {
1745
+ remaining: String((p.balance.json && p.balance.json.remaining) || '').slice(0, 200),
1746
+ total: String((p.balance.json && p.balance.json.total) || '').slice(0, 200),
1747
+ used: String((p.balance.json && p.balance.json.used) || '').slice(0, 200),
1748
+ scale: isFinite(Number(p.balance.json && p.balance.json.scale)) ? Number(p.balance.json.scale) : undefined,
1749
+ },
1750
+ })
1751
+ if (balIn && Object.keys(balIn).length) model.balance = balIn
1752
+ else delete model.balance
1753
+ if (p.balance.usage && p.balance.usage.url) {
1754
+ model.balance = model.balance || {}
1755
+ model.balance.usage = stripEmptyDeep({
1756
+ url: String(p.balance.usage.url).slice(0, 500),
1757
+ auth: String(p.balance.usage.auth == null ? '' : p.balance.usage.auth).slice(0, 200),
1758
+ 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 },
1759
+ })
1760
+ }
1761
+ }
1762
+ // 会话事件归属:默认用「模型名」和「id」做子串匹配,用户可另填
1763
+ // 自定义单价(元/百万 token):缓存命中 / 未命中输入 / 输出。留空则沿用内置价目表
1764
+ if (p.price && typeof p.price === 'object') {
1765
+ const num = (v) => {
1766
+ const s = String(v === undefined || v === null ? '' : v).trim()
1767
+ if (s === '') return undefined
1768
+ return isFinite(Number(s)) ? Number(s) : undefined
1769
+ }
1770
+ const pr = { hit: num(p.price.hit), miss: num(p.price.miss), out: num(p.price.out) }
1771
+ const cur0 = String(p.price.cur || '').trim().toUpperCase().slice(0, 8)
1772
+ const rate0 = num(p.price.rate)
1773
+ const anyPrice = pr.hit !== undefined || pr.miss !== undefined || pr.out !== undefined
1774
+ // 校验(为什么:不校验的话,负数/超大值会算出荒谬金额;USD 缺汇率会被静默当人民币记账)
1775
+ const inRange = (v, max) => v === undefined || (v >= 0 && v <= max)
1776
+ if (!inRange(pr.hit, 1000000) || !inRange(pr.miss, 1000000) || !inRange(pr.out, 1000000)) {
1777
+ return { ok: false, error: '单价必须是 0–1000000 之间的数字(单位:币种/百万 token)' }
1778
+ }
1779
+ if (rate0 !== undefined && !(rate0 > 0 && rate0 <= 1000)) {
1780
+ return { ok: false, error: '汇率必须是 0–1000 之间的正数(元/USD)' }
1781
+ }
1782
+ if (anyPrice && cur0 === 'USD' && !(rate0 > 0)) {
1783
+ return { ok: false, error: '单价币种为美元时必须填写汇率(元/USD)' }
1784
+ }
1785
+ if (cur0) pr.cur = cur0
1786
+ if (rate0 !== undefined) pr.rate = rate0
1787
+ if (pr.hit === undefined && pr.miss === undefined && pr.out === undefined) delete model.price
1788
+ else model.price = pr
1789
+ } else if (p.price === null) {
1790
+ delete model.price
1791
+ }
1792
+ writeApiRegistry(reg)
1793
+ // 改完单价立即失效缓存:否则 refreshCustomPrices 的 10 秒节流会让新价最坏 10 秒后才生效
1794
+ customPriceAt = 0
1795
+ let keySaved = false
1796
+ if (p.keyValue !== undefined && String(p.keyValue).length) {
1797
+ try {
1798
+ await ctx.credentials.set(keyRef, String(p.keyValue))
1799
+ keySaved = true
1800
+ } catch (err) {}
1801
+ }
1802
+ return { ok: true, id, keySaved }
1803
+ }
1804
+ // 列表(含实时余额与各模型提醒/预算):余额并发取,失败只影响该项
1805
+ async function apiModelsPayload() {
1806
+ const models = apiAllModels()
1807
+ const settings = readUsageSettings()
1808
+ const out = []
1809
+ let codexStats = null // Codex 本地会话统计(机器级,多个 codex 模型共享,算一次)
1810
+ for (const m of models) {
1811
+ let entry = {
1812
+ id: m.id, name: m.name, provider: m.provider, currency: m.currency,
1813
+ keyRef: m.keyRef, builtin: !!m.builtin, baseUrl: m.baseUrl || '',
1814
+ matchIds: m.matchIds || [], settings: settings.models && settings.models[m.id] ? settings.models[m.id] : null,
1815
+ price: m.price || null,
1816
+ // 手动额度(订阅/资源包):总量、单位、已用、重置周期;前端据此显示额度模块
1817
+ quota: (settings.models && settings.models[m.id] && settings.models[m.id].quota)
1818
+ ? Object.assign({}, settings.models[m.id].quota) : null,
1819
+ balance: null, todayUsage: null, todayUsageCurrency: null, usageSource: 'none', error: null,
1820
+ }
1821
+ // 自动模式的「已用」由 host 按会话 token 统计(前端不自己算)
1822
+ if (entry.quota) {
1823
+ entry.quota.autoUsed = apiQuotaAutoUsed(m.id, entry.quota)
1824
+ entry.quota.autoToday = Math.round(Number((apiUsageRaw(m.id) || {}).eventTokens) || 0)
1825
+ }
1826
+ // 把「接口描述」(url/请求头/字段)单独下发,供前端面板回填;
1827
+ // entry.balance 是数值,不能当描述用(否则面板保存会用空值覆盖描述)
1828
+ const tplE = apiTemplateOf(m.provider) || {}
1829
+ entry.balanceDesc = mergeNonEmpty(tplE.balance, m.balance) // v724:空串不覆盖模板
1830
+ if (m.id === API_BUILTIN_ID) {
1831
+ entry.balanceMode = 'api'
1832
+ entry.hasBalanceApi = true
1833
+ try {
1834
+ const cred = await ctx.credentials.resolve(m.keyRef || 'DEEPSEEK_API_KEY')
1835
+ entry.hasKey = !!(cred && cred.value)
1836
+ } catch (err) {}
1837
+ try {
1838
+ const p = await getBalance()
1839
+ if (p && p.ok) {
1840
+ entry.balance = Number(p.totalBalance)
1841
+ entry.todayUsage = ledgerTodayTotal(readUsageLedger())
1842
+ entry.todayUsageCurrency = 'CNY' // 内置 DeepSeek 走账本,一律人民币
1843
+ entry.usageSource = 'ledger'
1844
+ } else if (p) entry.error = p.error || p.code || '余额获取失败'
1845
+ } catch (err) { entry.error = String((err && err.message) || err) }
1846
+ } else {
1847
+ let hasKey = false
1848
+ try {
1849
+ const cred = await ctx.credentials.resolve(m.keyRef || '')
1850
+ hasKey = !!(cred && cred.value)
1851
+ } catch (err) {}
1852
+ entry.hasKey = hasKey
1853
+ // 该模型实际生效的余额描述:模板默认 + 用户覆盖
1854
+ const tplM = apiTemplateOf(m.provider) || {}
1855
+ const balDesc = Object.assign({}, tplM.balance || {}, m.balance || {})
1856
+ const hasBalanceApi = !!String(balDesc.url || '').trim()
1857
+ entry.hasBalanceApi = hasBalanceApi
1858
+ entry.probeUrl = String(tplM.probeUrl || '')
1859
+ // 订阅额度(kind='quota' 的模板,如智谱/Kimi/MiniMax Coding):前端据此显示额度行与模块
1860
+ entry.planSupport = !!tplM.quota
1861
+ // Codex 模式:不查余额、不需要密钥,直接给本地会话统计(今日/本月/累计/近7天)
1862
+ if (tplM.kind === 'codex') {
1863
+ if (!codexStats) codexStats = codexSummaryCached()
1864
+ entry.codex = codexStats
1865
+ entry.hasKey = true
1866
+ entry.error = codexStats && codexStats.ok ? null : ((codexStats && codexStats.error) || '未找到 Codex 会话目录')
1867
+ }
1868
+ // 没有余额接口的厂商(如火山方舟):余额显示「—」,只用会话事件估算
1869
+ entry.balanceMode = hasBalanceApi ? 'api' : 'events'
1870
+ if (hasBalanceApi && hasKey) {
1871
+ const r = await fetchModelBalance(m)
1872
+ if (r && r.ok) {
1873
+ entry.balance = r.remaining
1874
+ if (r.currency) entry.currency = r.currency
1875
+ apiRecordBalance(m.id, r.remaining)
1876
+ const u = apiTodayUsage(m.id, entry.currency || m.currency)
1877
+ entry.todayUsage = u.amount
1878
+ entry.usageSource = u.source
1879
+ entry.todayUsageCurrency = u.currency
1880
+ } else if (r) {
1881
+ entry.error = r.error || r.code || '余额获取失败'
1882
+ }
1883
+ } else if (!hasBalanceApi) {
1884
+ entry.error = null // 无余额接口不算错误
1885
+ } else {
1886
+ entry.error = '未配置 ' + (m.keyRef || 'API key')
1887
+ }
1888
+ // 订阅额度:有额度接口 + 有 key 才请求;失败只影响 plan 字段
1889
+ if (tplM.quota && hasKey) {
1890
+ try {
1891
+ entry.plan = await fetchModelQuota(m)
1892
+ if (entry.plan && !entry.plan.ok && apiPlanNoPlan(entry.plan.error)) entry.plan.hide = true
1893
+ } catch (err) { entry.plan = { ok: false, error: String((err && err.message) || err) } }
1894
+ }
1895
+ if (entry.todayUsage === null) {
1896
+ const u2 = apiTodayUsage(m.id, entry.currency || m.currency)
1897
+ entry.todayUsage = u2.amount
1898
+ entry.usageSource = u2.source
1899
+ entry.todayUsageCurrency = u2.currency
1900
+ }
1901
+ }
1902
+ out.push(entry)
1903
+ }
1904
+ return {
1905
+ ok: true,
1906
+ builtinId: API_BUILTIN_ID,
1907
+ // v724:把模板的接口描述也下发(B 方案:前端选模板后直接回填这些字段)
1908
+ templates: Object.keys(API_TEMPLATES).map((k) => ({
1909
+ id: k, name: API_TEMPLATES[k].name, currency: API_TEMPLATES[k].currency,
1910
+ keyRef: API_TEMPLATES[k].keyRef, builtin: !!API_TEMPLATES[k].builtin,
1911
+ needsBaseUrl: !!API_TEMPLATES[k].needsBaseUrl,
1912
+ hasBalance: !!String((API_TEMPLATES[k].balance || {}).url || '').trim(),
1913
+ probeUrl: String(API_TEMPLATES[k].probeUrl || ''),
1914
+ kind: String(API_TEMPLATES[k].kind || 'balance'),
1915
+ balance: API_TEMPLATES[k].balance ? JSON.parse(JSON.stringify(API_TEMPLATES[k].balance)) : null,
1916
+ quota: API_TEMPLATES[k].quota ? JSON.parse(JSON.stringify(API_TEMPLATES[k].quota)) : null,
1917
+ matchIds: Array.isArray(API_TEMPLATES[k].matchIds) ? API_TEMPLATES[k].matchIds.slice(0, 12) : [],
1918
+ noBalanceApi: !!API_TEMPLATES[k].noBalanceApi,
1919
+ apiNote: String(API_TEMPLATES[k].apiNote || ''),
1920
+ sortKey: tplSortKey(API_TEMPLATES[k].name), // v726:下拉排序键(前端按它排)
1921
+ })),
1922
+ models: out,
1923
+ }
1924
+ }
1925
+
1926
+ // 记账:跨天时以当天第一次观测到的余额为“当日零点起始余额 dayStart”;
1927
+ // 当天内每次观测都滚动刷新 lastBalance,不再因“今天已有事件”而提前返回——
1928
+ // 事件明细可能因会话中断/重启漏记,真实余额差(dayStart−lastBalance)始终可算。
1929
+ // 归档以真实余额差为准(与官网一致,事件计价偏高时不被带高);余额差不可用时才用事件。
1930
+ function recordLedgerUsage(currentBalance) {
1931
+ const t = todayKey()
1932
+ let led = readUsageLedger()
1933
+ const events = Array.isArray(led.events) ? led.events : []
1934
+ function sumEv(d) { let s = 0; for (const e of events) if (e.day === d) s += Number(e.cost) || 0; return s }
1935
+ if (led.date !== t) {
1936
+ // 跨天:归档旧日真实合计(优先当日余额差;不可用/为0时退事件与历史),并重置当天基准
1937
+ const oldDay = led.date
1938
+ if (oldDay) {
1939
+ led.history = led.history || {}
1940
+ let oldTotal = 0
1941
+ if (typeof led.dayStart === 'number' && typeof led.lastBalance === 'number' && led.lastBalance < led.dayStart) {
1942
+ oldTotal = Math.max(oldTotal, led.dayStart - led.lastBalance)
1943
+ }
1944
+ oldTotal = Math.max(oldTotal, sumEv(oldDay))
1945
+ if (typeof led.todayUsage === 'number') oldTotal = Math.max(oldTotal, led.todayUsage)
1946
+ led.history[oldDay] = round2(oldTotal)
1947
+ }
1948
+ led.date = t
1949
+ led.dayStart = currentBalance
1950
+ led.todayUsage = 0
1951
+ led.lastBalance = currentBalance
1952
+ } else {
1953
+ // 兼容旧账本:当天尚无 dayStart 时,把已冻结的 lastBalance 视为当天起始余额
1954
+ if (typeof led.dayStart !== 'number' && typeof led.lastBalance === 'number') {
1955
+ led.dayStart = led.lastBalance
1956
+ }
1957
+ led.lastBalance = currentBalance
1958
+ }
1959
+ // 归档/裁剪统一由 pruneLedgerUsage 负责(history 保留 365 天,events 90 天/2 万条)
1960
+ pruneLedgerUsage(led)
1961
+ writeUsageLedger(led)
1962
+ return led
1963
+ }
1964
+ function normalizeUsageMode() {
1965
+ return 'ledger' // 小鲸鱼记账为唯一记账方式
1966
+ }
1967
+ // 今日已用:以“当日真实余额差(零点起始余额−当前余额)”为准——
1968
+ // 官方账单按真实扣款计,事件计价可能出现系统性偏高(14.49 vs 余额差 13.91 vs 官网 13.88),
1969
+ // 余额差与官网几乎一致;事件合计仅在余额差不可用(充值/退款/无基准)时作兜底。
1970
+ function ledgerTodayTotal(led) {
1971
+ const events = Array.isArray(led.events) ? led.events : []
1972
+ const t = dayKeyFromTs(Date.now())
1973
+ let s = 0
1974
+ for (const e of events) if (e.day === t) s += Number(e.cost) || 0
1975
+ if (typeof led.dayStart === 'number' && typeof led.lastBalance === 'number') {
1976
+ const d = led.dayStart - led.lastBalance
1977
+ if (d > 0.004) return round2(d)
1978
+ }
1979
+ return round2(s)
1980
+ }
1981
+
1982
+ async function getBalancePayload() {
1983
+ const payload = await fetchBalance()
1984
+ if (!payload.ok) {
1985
+ // 余额接口失败(含限流 429)时仍附上今日已用:事件优先,其次账本余额差
1986
+ const led = readUsageLedger()
1987
+ return { ...payload, todayUsage: ledgerTodayTotal(led), usageMode: 'ledger' }
1988
+ }
1989
+ recordLedgerUsage(Number(payload.totalBalance))
1990
+ // 必须在写入后重读账本:否则 todayUsage 会按“上一轮 lastBalance”计算,
1991
+ // 造成泡泡与用量记录(后者直接读文件)数值不一致(如 4.99 vs 5.05)
1992
+ const led = readUsageLedger()
1993
+ const full = { ...payload }
1994
+ full.isPeak = isPeakTime(Math.floor(Date.now() / 1000))
1995
+ full.todayUsage = ledgerTodayTotal(led)
1996
+ full.usageMode = 'ledger'
1997
+ return full
1998
+ }
1999
+
2000
+ function getBalance() {
1738
2001
  const now = Date.now()
1739
2002
  if (balanceCache && now - balanceCache.at < BALANCE_TTL_MS) {
1740
2003
  return Promise.resolve(balanceCache.payload)
@@ -1773,7 +2036,7 @@ function apply(ctx) {
1773
2036
  scale: parsed.scale,
1774
2037
  sound: parsed.sound !== false,
1775
2038
  vol: typeof parsed.vol === 'number' ? parsed.vol : 0.9,
1776
- soundSet: parsed.soundSet === 'fx1' ? 'fx1' : 'duck',
2039
+ soundSet: typeof parsed.soundSet === 'string' && parsed.soundSet ? parsed.soundSet : 'duck',
1777
2040
  usageMode: normalizeUsageMode(parsed.usageMode),
1778
2041
  peakMode: parsed.peakMode === 'liangwen' || parsed.peakMode === 'qiangqiang' ? parsed.peakMode : 'default',
1779
2042
  bubbleOn: parsed.bubbleOn !== false,
@@ -1781,6 +2044,7 @@ function apply(ctx) {
1781
2044
  turnCostCloseMs: typeof parsed.turnCostCloseMs === 'number' ? parsed.turnCostCloseMs : 5000,
1782
2045
  scrollGapOn: parsed.scrollGapOn === true,
1783
2046
  scrollGapPx: typeof parsed.scrollGapPx === 'number' ? Math.round(parsed.scrollGapPx) : 17,
2047
+ menuBtnHide: parsed.menuBtnHide === true,
1784
2048
  }
1785
2049
  }
1786
2050
  } catch (err) {}
@@ -1788,7 +2052,7 @@ function apply(ctx) {
1788
2052
  return null
1789
2053
  }
1790
2054
 
1791
- function writeSizeConfig(scale, sound, vol, soundSet, usageMode, peakMode, bubbleOn, turnCostOn, turnCostCloseMs, scrollGapOn, scrollGapPx) {
2055
+ function writeSizeConfig(scale, sound, vol, soundSet, usageMode, peakMode, bubbleOn, turnCostOn, turnCostCloseMs, scrollGapOn, scrollGapPx, menuBtnHide) {
1792
2056
  const um = normalizeUsageMode(usageMode)
1793
2057
  const pm = peakMode === 'liangwen' || peakMode === 'qiangqiang' ? peakMode : 'default'
1794
2058
  const bo = bubbleOn !== false
@@ -1796,11 +2060,12 @@ function apply(ctx) {
1796
2060
  const tcc = typeof turnCostCloseMs === 'number' ? (turnCostCloseMs > 0 ? turnCostCloseMs : 0) : 5000
1797
2061
  const sgo = scrollGapOn === true
1798
2062
  const sgp = typeof scrollGapPx === 'number' && scrollGapPx > 0 ? Math.round(scrollGapPx) : 0
2063
+ const mbh = menuBtnHide === true
1799
2064
  const body = JSON.stringify({
1800
2065
  scale: scale,
1801
2066
  sound: sound !== false,
1802
2067
  vol: typeof vol === 'number' ? vol : 0.9,
1803
- soundSet: soundSet === 'fx1' ? 'fx1' : 'duck',
2068
+ soundSet: typeof soundSet === 'string' && soundSet ? soundSet : 'duck',
1804
2069
  usageMode: um,
1805
2070
  peakMode: pm,
1806
2071
  bubbleOn: bo,
@@ -1808,6 +2073,7 @@ function apply(ctx) {
1808
2073
  turnCostCloseMs: tcc,
1809
2074
  scrollGapOn: sgo,
1810
2075
  scrollGapPx: sgp,
2076
+ menuBtnHide: mbh,
1811
2077
  updatedAt: new Date().toISOString(),
1812
2078
  })
1813
2079
  for (const p of SIZE_FILE_CANDIDATES) {
@@ -1818,7 +2084,7 @@ function apply(ctx) {
1818
2084
  scale: scale,
1819
2085
  sound: sound !== false,
1820
2086
  vol: typeof vol === 'number' ? vol : 0.9,
1821
- soundSet: soundSet === 'fx1' ? 'fx1' : 'duck',
2087
+ soundSet: typeof soundSet === 'string' && soundSet ? soundSet : 'duck',
1822
2088
  usageMode: um,
1823
2089
  peakMode: pm,
1824
2090
  bubbleOn: bo,
@@ -1826,6 +2092,7 @@ function apply(ctx) {
1826
2092
  turnCostCloseMs: tcc,
1827
2093
  scrollGapOn: sgo,
1828
2094
  scrollGapPx: sgp,
2095
+ menuBtnHide: mbh,
1829
2096
  }
1830
2097
  } catch (err) {}
1831
2098
  }
@@ -1850,7 +2117,282 @@ function apply(ctx) {
1850
2117
  })
1851
2118
  }
1852
2119
 
1853
- disposers.push(ctx.webServer.register({
2120
+ function readBodyMax(req, maxBytes) {
2121
+ return new Promise((resolve, reject) => {
2122
+ const chunks = []
2123
+ let size = 0
2124
+ req.on('data', (c) => {
2125
+ size += c.length
2126
+ if (size > maxBytes) {
2127
+ reject(new Error('body too large'))
2128
+ req.destroy()
2129
+ return
2130
+ }
2131
+ chunks.push(c)
2132
+ })
2133
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
2134
+ req.on('error', reject)
2135
+ })
2136
+ }
2137
+
2138
+ // —— 自定义角色存储:whale-roles/ 目录,每个角色一个 <id>.png + roles.json 索引 ——
2139
+ function pickRoleDir() {
2140
+ for (const p of ROLE_DIR_CANDIDATES) {
2141
+ try {
2142
+ fs.mkdirSync(p, { recursive: true })
2143
+ fs.accessSync(p, fs.constants.W_OK)
2144
+ return p
2145
+ } catch (err) {}
2146
+ }
2147
+ return ROLE_DIR_CANDIDATES[0]
2148
+ }
2149
+
2150
+ function defaultRolesIndex() {
2151
+ return {
2152
+ version: 1,
2153
+ roles: [
2154
+ // 默认小鲸鱼初始置顶;pinnedAt=1 作为基线,任何新置顶(Date.now())都会排在它上面
2155
+ { id: ROLE_DEFAULT_ID, name: '小鲸鱼', pinnedAt: 1, createdAt: 0 },
2156
+ ],
2157
+ }
2158
+ }
2159
+
2160
+ function readRolesIndex() {
2161
+ const dir = pickRoleDir()
2162
+ try {
2163
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, ROLE_INDEX_NAME), 'utf8'))
2164
+ if (parsed && Array.isArray(parsed.roles)) {
2165
+ if (!parsed.roles.some((r) => r && r.id === ROLE_DEFAULT_ID)) {
2166
+ parsed.roles.unshift(defaultRolesIndex().roles[0])
2167
+ }
2168
+ return parsed
2169
+ }
2170
+ } catch (err) {}
2171
+ return defaultRolesIndex()
2172
+ }
2173
+
2174
+ function writeRolesIndex(index) {
2175
+ const dir = pickRoleDir()
2176
+ try {
2177
+ fs.writeFileSync(path.join(dir, ROLE_INDEX_NAME), JSON.stringify(index, null, 2), 'utf8')
2178
+ return true
2179
+ } catch (err) {
2180
+ return false
2181
+ }
2182
+ }
2183
+
2184
+ // 排序:置顶的在前(pinnedAt 大者先,即最新置顶在最上面),未置顶按创建时间倒序
2185
+ function sortRoles(roles) {
2186
+ return roles.slice().sort((a, b) => {
2187
+ const ap = a.pinnedAt && a.pinnedAt > 0 ? a.pinnedAt : 0
2188
+ const bp = b.pinnedAt && b.pinnedAt > 0 ? b.pinnedAt : 0
2189
+ if (ap && bp) return bp - ap
2190
+ if (ap) return -1
2191
+ if (bp) return 1
2192
+ return (b.createdAt || 0) - (a.createdAt || 0)
2193
+ })
2194
+ }
2195
+
2196
+ function rolesPayload() {
2197
+ const index = readRolesIndex()
2198
+ return {
2199
+ ok: true,
2200
+ roles: sortRoles(index.roles).map((r) => ({
2201
+ id: r.id,
2202
+ name: String(r.name || r.id),
2203
+ url: r.id === ROLE_DEFAULT_ID ? '/dsh-whale/image.png' : '/dsh-whale/role-image.png?id=' + encodeURIComponent(r.id),
2204
+ pinned: !!(r.pinnedAt && r.pinnedAt > 0),
2205
+ pinnedAt: r.pinnedAt || null,
2206
+ createdAt: r.createdAt || null,
2207
+ // format: 'png' | 'gif' | 'apng'(旧角色无 format 字段 → png)
2208
+ format: r.format === 'gif' || r.format === 'apng' ? r.format : 'png',
2209
+ })),
2210
+ }
2211
+ }
2212
+
2213
+ // 角色文件按格式映射扩展名:gif→.gif,apng/png→.png(APNG 文件仍是 PNG 容器)
2214
+ function roleFileExt(format) {
2215
+ return format === 'gif' ? 'gif' : 'png'
2216
+ }
2217
+
2218
+ function roleFilePath(id, format) {
2219
+ if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(id) || id === ROLE_DEFAULT_ID) return null
2220
+ return path.join(pickRoleDir(), id + '.' + roleFileExt(format))
2221
+ }
2222
+
2223
+ function roleImagePath(id) {
2224
+ // 查找角色元数据确定扩展名;找不到默认 png
2225
+ const { role } = roleIndexFind(id)
2226
+ const format = role && (role.format === 'gif' || role.format === 'apng') ? role.format : 'png'
2227
+ const p = roleFilePath(id, format)
2228
+ return p
2229
+ }
2230
+
2231
+ function roleIdFromUrl(url) {
2232
+ try {
2233
+ const q = String(url || '').split('?')[1] || ''
2234
+ const m = /(?:^|&)id=([^&]+)/.exec(q)
2235
+ return m ? decodeURIComponent(m[1]) : ''
2236
+ } catch (err) { return '' }
2237
+ }
2238
+
2239
+ function roleIndexFind(id) {
2240
+ const index = readRolesIndex()
2241
+ const role = index.roles.find((r) => r && r.id === id)
2242
+ return { index, role }
2243
+ }
2244
+
2245
+ // —— 自定义音频:whale-audio/ 目录,片段 <id>.wav + audio.json 索引 ——
2246
+ function pickAudioDir() {
2247
+ for (const p of AUDIO_DIR_CANDIDATES) {
2248
+ try {
2249
+ fs.mkdirSync(p, { recursive: true })
2250
+ fs.accessSync(p, fs.constants.W_OK)
2251
+ return p
2252
+ } catch (err) {}
2253
+ }
2254
+ return AUDIO_DIR_CANDIDATES[0]
2255
+ }
2256
+
2257
+ function defaultAudioIndex() {
2258
+ return { version: 1, groups: [], fragments: [] }
2259
+ }
2260
+
2261
+ function readAudioIndex() {
2262
+ const dir = pickAudioDir()
2263
+ try {
2264
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, AUDIO_INDEX_NAME), 'utf8'))
2265
+ if (parsed && Array.isArray(parsed.groups) && Array.isArray(parsed.fragments)) {
2266
+ return parsed
2267
+ }
2268
+ } catch (err) {}
2269
+ return defaultAudioIndex()
2270
+ }
2271
+
2272
+ function writeAudioIndex(index) {
2273
+ const dir = pickAudioDir()
2274
+ try {
2275
+ fs.writeFileSync(path.join(dir, AUDIO_INDEX_NAME), JSON.stringify(index, null, 2), 'utf8')
2276
+ return true
2277
+ } catch (err) {
2278
+ return false
2279
+ }
2280
+ }
2281
+
2282
+ // 自定义片段 id -> 文件路径;预设片段无文件
2283
+ function audioFragmentPath(id) {
2284
+ if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(id)) return null
2285
+ if (PRESET_FRAGMENTS[id]) return null
2286
+ return path.join(pickAudioDir(), id + '.wav')
2287
+ }
2288
+
2289
+ function audioIdFromUrl(url) {
2290
+ try {
2291
+ const q = String(url || '').split('?')[1] || ''
2292
+ const m = /(?:^|&)id=([^&]+)/.exec(q)
2293
+ return m ? decodeURIComponent(m[1]) : ''
2294
+ } catch (err) { return '' }
2295
+ }
2296
+
2297
+ // 片段列表:预设片段 + 用户自定义片段
2298
+ function audioFragmentsPayload() {
2299
+ const index = readAudioIndex()
2300
+ const presets = Object.keys(PRESET_FRAGMENTS).map((k) => ({
2301
+ id: k,
2302
+ name: PRESET_FRAGMENTS[k].name,
2303
+ preset: true,
2304
+ }))
2305
+ const custom = index.fragments.map((f) => ({
2306
+ id: f.id,
2307
+ name: String(f.name || f.id),
2308
+ preset: false,
2309
+ createdAt: f.createdAt || null,
2310
+ }))
2311
+ return presets.concat(custom)
2312
+ }
2313
+
2314
+ // 音效组列表:预设组 + 用户自定义组(置顶优先,组内按创建时间倒序)
2315
+ function audioGroupsPayload() {
2316
+ const index = readAudioIndex()
2317
+ // 排序:置顶的自定义组最前,其次预设组,最后未置顶的自定义组(按创建时间倒序)
2318
+ const customs = index.groups.slice().map((g) => ({
2319
+ id: g.id,
2320
+ name: String(g.name || g.id),
2321
+ press: typeof g.press === 'string' ? g.press : null,
2322
+ release: typeof g.release === 'string' ? g.release : null,
2323
+ preset: false,
2324
+ pinned: !!(g.pinnedAt && g.pinnedAt > 0),
2325
+ pinnedAt: g.pinnedAt || null,
2326
+ createdAt: g.createdAt || 0,
2327
+ }))
2328
+ const pinned = customs.filter((g) => g.pinned).sort((a, b) => b.pinnedAt - a.pinnedAt)
2329
+ const unpinned = customs.filter((g) => !g.pinned).sort((a, b) => b.createdAt - a.createdAt)
2330
+ const presets = Object.keys(PRESET_GROUPS).map((k) => {
2331
+ const g = PRESET_GROUPS[k]
2332
+ return { id: g.id, name: g.name, press: g.press, release: g.release, preset: true, pinned: false, pinnedAt: null, createdAt: 0 }
2333
+ })
2334
+ return pinned.concat(presets, unpinned)
2335
+ }
2336
+
2337
+ function audioPayload() {
2338
+ return {
2339
+ ok: true,
2340
+ groups: audioGroupsPayload(),
2341
+ fragments: audioFragmentsPayload(),
2342
+ }
2343
+ }
2344
+
2345
+ // 取片段音频字节:内置随包片段 → 预设(SOUND_SETS) → 自定义 whale-audio/<id>.wav
2346
+ function loadAudioFragmentBytes(fragId) {
2347
+ if (BUILTIN_FRAGMENT_FILES[fragId]) {
2348
+ return loadSound(BUILTIN_FRAGMENT_FILES[fragId])
2349
+ }
2350
+ if (PRESET_FRAGMENTS[fragId]) {
2351
+ // 预设片段映射到对应音效文件:ya1->duck.press, ya2->duck.release, d1->fx1.press, d2->fx1.release
2352
+ const map = { ya1: ['duck', 'press'], ya2: ['duck', 'release'], d1: ['fx1', 'press'], d2: ['fx1', 'release'] }
2353
+ const [setName, slot] = map[fragId]
2354
+ const set = SOUND_SETS[setName]
2355
+ if (!set) return null
2356
+ return loadSound(set[slot])
2357
+ }
2358
+ const p = audioFragmentPath(fragId)
2359
+ if (!p) return null
2360
+ try {
2361
+ const bytes = fs.readFileSync(p)
2362
+ if (bytes && bytes.length > 0) return bytes
2363
+ } catch (err) {}
2364
+ return null
2365
+ }
2366
+
2367
+ // 组内实际使用的片段:若自定义组引用的片段被删,回退到预设
2368
+ // 返回 '' 表示该槽显式留空(该事件静音),调用方应据此不发音频
2369
+ function groupFragmentId(groupId, slot) {
2370
+ const custom = readAudioIndex().groups.find((g) => g && g.id === groupId)
2371
+ if (custom) {
2372
+ const fid = custom[slot]
2373
+ if (fid === '') return ''
2374
+ if (fid) {
2375
+ if (PRESET_FRAGMENTS[fid]) return fid
2376
+ if (customFragExists(fid)) return fid
2377
+ }
2378
+ // 无字段/引用失效 → 回退预设:新组无引用时用 duck
2379
+ return slot === 'press' ? PRESET_GROUPS.duck.press : PRESET_GROUPS.duck.release
2380
+ }
2381
+ const preset = PRESET_GROUPS[groupId]
2382
+ if (preset) return preset[slot]
2383
+ return slot === 'press' ? PRESET_GROUPS.duck.press : PRESET_GROUPS.duck.release
2384
+ }
2385
+
2386
+ function customFragExists(id) {
2387
+ try {
2388
+ const p = audioFragmentPath(id)
2389
+ if (!p) return false
2390
+ const st = fs.statSync(p)
2391
+ return st.isFile()
2392
+ } catch (err) { return false }
2393
+ }
2394
+
2395
+ disposers.push(registerRoute({
1854
2396
  kind: 'exact',
1855
2397
  path: '/dsh-whale/image.png',
1856
2398
  handler: (req, res) => {
@@ -1869,7 +2411,7 @@ function apply(ctx) {
1869
2411
  },
1870
2412
  }))
1871
2413
 
1872
- disposers.push(ctx.webServer.register({
2414
+ disposers.push(registerRoute({
1873
2415
  kind: 'exact',
1874
2416
  path: '/dsh-whale/rua.gif',
1875
2417
  handler: (req, res) => {
@@ -1888,7 +2430,7 @@ function apply(ctx) {
1888
2430
  },
1889
2431
  }))
1890
2432
 
1891
- disposers.push(ctx.webServer.register({
2433
+ disposers.push(registerRoute({
1892
2434
  kind: 'exact',
1893
2435
  path: '/dsh-whale/balance.json',
1894
2436
  handler: async (req, res) => {
@@ -1903,7 +2445,7 @@ function apply(ctx) {
1903
2445
  },
1904
2446
  }))
1905
2447
 
1906
- disposers.push(ctx.webServer.register({
2448
+ disposers.push(registerRoute({
1907
2449
  kind: 'exact',
1908
2450
  path: '/dsh-whale/last-turn.json',
1909
2451
  handler: (req, res) => {
@@ -1916,7 +2458,7 @@ function apply(ctx) {
1916
2458
  },
1917
2459
  }))
1918
2460
 
1919
- disposers.push(ctx.webServer.register({
2461
+ disposers.push(registerRoute({
1920
2462
  kind: 'exact',
1921
2463
  path: '/dsh-whale/size.json',
1922
2464
  handler: async (req, res) => {
@@ -1937,7 +2479,7 @@ function apply(ctx) {
1937
2479
  balanceCache = null
1938
2480
  }
1939
2481
  }
1940
- const result = writeSizeConfig(scale, parsed.sound !== false, parsed.vol, parsed.soundSet, parsed.usageMode, parsed.peakMode, parsed.bubbleOn, parsed.turnCostOn, parsed.turnCostCloseMs, parsed.scrollGapOn, parsed.scrollGapPx)
2482
+ const result = writeSizeConfig(scale, parsed.sound !== false, parsed.vol, parsed.soundSet, parsed.usageMode, parsed.peakMode, parsed.bubbleOn, parsed.turnCostOn, parsed.turnCostCloseMs, parsed.scrollGapOn, parsed.scrollGapPx, parsed.menuBtnHide)
1941
2483
  res.writeHead(result.ok ? 200 : 500, JSON_HEADERS)
1942
2484
  res.end(JSON.stringify(result))
1943
2485
  } catch (err) {
@@ -1951,6 +2493,391 @@ function apply(ctx) {
1951
2493
  },
1952
2494
  }))
1953
2495
 
2496
+ disposers.push(registerRoute({
2497
+ kind: 'exact',
2498
+ path: '/dsh-whale/usage-records.json',
2499
+ handler: async (req, res) => {
2500
+ try {
2501
+ // 与余额路由同源:请求到来时若持有较新的余额缓存(≤70s,覆盖两次 60s 轮询间隙),
2502
+ // 先用它刷新账本 lastBalance 再算汇总,保证“今日合计”与泡泡“今日已用”读同一份数据;
2503
+ // 缓存太旧(挂件长时间未开)则跳过,避免把过期余额写回账本。
2504
+ try {
2505
+ if (balanceCache && balanceCache.payload && balanceCache.payload.ok &&
2506
+ isFinite(Number(balanceCache.payload.totalBalance)) &&
2507
+ Date.now() - balanceCache.at <= 70000) {
2508
+ recordLedgerUsage(Number(balanceCache.payload.totalBalance))
2509
+ }
2510
+ } catch (err) {}
2511
+ res.writeHead(200, JSON_HEADERS)
2512
+ res.end(JSON.stringify(usageRecordsPayload()))
2513
+ } catch (err) {
2514
+ res.writeHead(200, JSON_HEADERS)
2515
+ res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err) }))
2516
+ }
2517
+ },
2518
+ }))
2519
+ // 用量设置(任务结束音/余额预警/今日预算)
2520
+ disposers.push(registerRoute({
2521
+ kind: 'exact',
2522
+ path: '/dsh-whale/usage-settings.json',
2523
+ handler: async (req, res) => {
2524
+ try {
2525
+ if (req.method === 'PUT' || req.method === 'POST') {
2526
+ const body = await readBody(req)
2527
+ const parsed = JSON.parse(body || '{}')
2528
+ if (!parsed || typeof parsed !== 'object') throw new Error('bad body')
2529
+ const result = writeUsageSettings(parsed)
2530
+ res.writeHead(result.ok ? 200 : 400, JSON_HEADERS)
2531
+ res.end(JSON.stringify(result))
2532
+ return
2533
+ }
2534
+ res.writeHead(200, JSON_HEADERS)
2535
+ res.end(JSON.stringify({ ok: true, settings: readUsageSettings() }))
2536
+ } catch (err) {
2537
+ res.writeHead(400, JSON_HEADERS)
2538
+ res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err) }))
2539
+ }
2540
+ },
2541
+ }))
2542
+
2543
+ // 自定义 API 模型:GET 列表(含实时余额/今日已用/各模型提醒预算)
2544
+ // POST {action: save|delete|set-key|delete-key|model-settings}
2545
+ disposers.push(registerRoute({
2546
+ kind: 'exact',
2547
+ path: '/dsh-whale/api-models.json',
2548
+ handler: async (req, res) => {
2549
+ try {
2550
+ if (req.method === 'PUT' || req.method === 'POST') {
2551
+ const body = await readBody(req)
2552
+ const parsed = JSON.parse(body || '{}')
2553
+ const action = String((parsed && parsed.action) || 'save')
2554
+ if (action === 'delete') {
2555
+ const r = apiDeleteModel(parsed.id)
2556
+ const list = await apiModelsPayload()
2557
+ res.writeHead(r.ok ? 200 : 400, JSON_HEADERS)
2558
+ res.end(JSON.stringify(Object.assign({}, r, { models: list.models })))
2559
+ return
2560
+ }
2561
+ if (action === 'probe') {
2562
+ // 只做只读连通性测试(如方舟 /api/v3/models),不消耗 token
2563
+ const m0 = apiModelById(parsed.id)
2564
+ const pr = await apiProbeModel(m0)
2565
+ res.writeHead(200, JSON_HEADERS)
2566
+ res.end(JSON.stringify(pr))
2567
+ return
2568
+ }
2569
+ if (action === 'set-key' || action === 'delete-key') {
2570
+ const ref = String(parsed.keyRef || '').trim()
2571
+ if (!ref) throw new Error('bad keyRef')
2572
+ const svc = ctx.credentials
2573
+ if (action === 'set-key') {
2574
+ await svc.set(ref, String(parsed.keyValue || ''))
2575
+ } else if (typeof svc.unset === 'function') {
2576
+ // 普通密钥存在凭据文档的 refs 段:必须用 unset 才能真正删除
2577
+ // (deleteRecord 只处理 records 段的结构化记录,对 refs 会静默返回)
2578
+ await svc.unset(ref)
2579
+ } else {
2580
+ await svc.deleteRecord(ref)
2581
+ }
2582
+ res.writeHead(200, JSON_HEADERS)
2583
+ res.end(JSON.stringify({ ok: true }))
2584
+ return
2585
+ }
2586
+ if (action === 'model-settings') {
2587
+ const r = writeUsageSettings({ modelSettings: { id: String(parsed.id || ''), alert: parsed.alert, budget: parsed.budget, quota: parsed.quota } })
2588
+ res.writeHead(r.ok ? 200 : 400, JSON_HEADERS)
2589
+ res.end(JSON.stringify(r))
2590
+ return
2591
+ }
2592
+ // 注意:keyValue 在提交体上(不在 model 里),必须合并进去,
2593
+ // 否则前端面板里填的 API key 会被丢弃(密钥永远存不进去)
2594
+ const r = await apiSaveModel(Object.assign({}, parsed.model || parsed, { keyValue: parsed.keyValue }))
2595
+ const list2 = await apiModelsPayload()
2596
+ res.writeHead(r.ok ? 200 : 400, JSON_HEADERS)
2597
+ res.end(JSON.stringify(Object.assign({}, r, { models: list2.models })))
2598
+ return
2599
+ }
2600
+ const payload = await apiModelsPayload()
2601
+ res.writeHead(200, JSON_HEADERS)
2602
+ res.end(JSON.stringify(payload))
2603
+ } catch (err) {
2604
+ res.writeHead(400, JSON_HEADERS)
2605
+ res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err).slice(0, 200) }))
2606
+ }
2607
+ },
2608
+ }))
2609
+
2610
+ disposers.push(registerRoute({
2611
+ kind: 'exact',
2612
+ path: '/dsh-whale/roles.json',
2613
+ handler: async (req, res) => {
2614
+ if (req.method === 'POST' || req.method === 'PUT') {
2615
+ try {
2616
+ // 放宽到 30MB:支持最大 20MB 的 GIF(base64 膨胀约 1.33 倍)
2617
+ const body = await readBodyMax(req, 30 * 1024 * 1024)
2618
+ const parsed = JSON.parse(body)
2619
+ const name = String(parsed.name || '').trim().slice(0, 20) || '新角色'
2620
+ const image = String(parsed.image || '')
2621
+ const m = /^data:image\/(png|jpeg|webp|gif);base64,([A-Za-z0-9+/=]+)$/.exec(image)
2622
+ if (!m) {
2623
+ res.writeHead(400, JSON_HEADERS)
2624
+ res.end(JSON.stringify({ ok: false, error: 'invalid image data' }))
2625
+ return
2626
+ }
2627
+ const buf = Buffer.from(m[2], 'base64')
2628
+ if (buf.length < 8 || buf.length > 20 * 1024 * 1024) {
2629
+ res.writeHead(400, JSON_HEADERS)
2630
+ res.end(JSON.stringify({ ok: false, error: 'image too large' }))
2631
+ return
2632
+ }
2633
+ // format:客户端显式告知 gif/apng(APNG 的 dataURL 是 image/png,需客户端区分);
2634
+ // 未显式给时按 dataURL 的 MIME 推断(gif→gif,其余 png)
2635
+ const declared = parsed.format === 'gif' ? 'gif' : (parsed.format === 'apng' ? 'apng' : null)
2636
+ const fmt = declared || (m[1] === 'gif' ? 'gif' : 'png')
2637
+ const id = 'role_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8)
2638
+ const dir = pickRoleDir()
2639
+ fs.writeFileSync(path.join(dir, id + '.' + roleFileExt(fmt)), buf)
2640
+ const index = readRolesIndex()
2641
+ index.roles.push({ id, name, format: fmt, pinnedAt: null, createdAt: Date.now() })
2642
+ writeRolesIndex(index)
2643
+ res.writeHead(200, JSON_HEADERS)
2644
+ res.end(JSON.stringify(rolesPayload()))
2645
+ } catch (err) {
2646
+ res.writeHead(400, JSON_HEADERS)
2647
+ res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err).slice(0, 200) }))
2648
+ }
2649
+ return
2650
+ }
2651
+ res.writeHead(200, JSON_HEADERS)
2652
+ res.end(JSON.stringify(rolesPayload()))
2653
+ },
2654
+ }))
2655
+
2656
+ disposers.push(registerRoute({
2657
+ kind: 'exact',
2658
+ path: '/dsh-whale/role-pin.json',
2659
+ handler: async (req, res) => {
2660
+ try {
2661
+ const body = await readBodyMax(req, 8192)
2662
+ const parsed = JSON.parse(body)
2663
+ const id = String(parsed.id || '')
2664
+ const { index, role } = roleIndexFind(id)
2665
+ if (!role) {
2666
+ res.writeHead(404, JSON_HEADERS)
2667
+ res.end(JSON.stringify({ ok: false, error: 'role not found' }))
2668
+ return
2669
+ }
2670
+ role.pinnedAt = parsed.pinned === true ? Date.now() : null
2671
+ writeRolesIndex(index)
2672
+ res.writeHead(200, JSON_HEADERS)
2673
+ res.end(JSON.stringify(rolesPayload()))
2674
+ } catch (err) {
2675
+ res.writeHead(400, JSON_HEADERS)
2676
+ res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err).slice(0, 200) }))
2677
+ }
2678
+ },
2679
+ }))
2680
+
2681
+ disposers.push(registerRoute({
2682
+ kind: 'exact',
2683
+ path: '/dsh-whale/role-delete.json',
2684
+ handler: async (req, res) => {
2685
+ try {
2686
+ const body = await readBodyMax(req, 8192)
2687
+ const parsed = JSON.parse(body)
2688
+ const id = String(parsed.id || '')
2689
+ if (id === ROLE_DEFAULT_ID) {
2690
+ res.writeHead(400, JSON_HEADERS)
2691
+ res.end(JSON.stringify({ ok: false, error: 'cannot delete default role' }))
2692
+ return
2693
+ }
2694
+ const { index, role } = roleIndexFind(id)
2695
+ if (!role) {
2696
+ res.writeHead(404, JSON_HEADERS)
2697
+ res.end(JSON.stringify({ ok: false, error: 'role not found' }))
2698
+ return
2699
+ }
2700
+ // 先按角色 format 确定文件路径(必须在从索引移除之前,否则查不到 format 默认成 png,删不掉 .gif)
2701
+ const fmt = role.format === 'gif' || role.format === 'apng' ? role.format : 'png'
2702
+ const p = roleFilePath(id, fmt)
2703
+ index.roles = index.roles.filter((r) => r.id !== id)
2704
+ writeRolesIndex(index)
2705
+ if (p) { try { fs.unlinkSync(p) } catch (err) {} }
2706
+ res.writeHead(200, JSON_HEADERS)
2707
+ res.end(JSON.stringify(rolesPayload()))
2708
+ } catch (err) {
2709
+ res.writeHead(400, JSON_HEADERS)
2710
+ res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err).slice(0, 200) }))
2711
+ }
2712
+ },
2713
+ }))
2714
+
2715
+ disposers.push(registerRoute({
2716
+ kind: 'exact',
2717
+ path: '/dsh-whale/role-image.png',
2718
+ handler: (req, res) => {
2719
+ try {
2720
+ const id = roleIdFromUrl(req.url)
2721
+ const p = roleImagePath(id)
2722
+ if (!p) throw new Error('bad role id')
2723
+ const bytes = fs.readFileSync(p)
2724
+ // 按角色格式返回对应 MIME:gif 动图 → image/gif;png/apng 都是 PNG 容器 → image/png
2725
+ const { role } = roleIndexFind(id)
2726
+ const mime = role && role.format === 'gif' ? 'image/gif' : 'image/png'
2727
+ res.writeHead(200, {
2728
+ 'Content-Type': mime,
2729
+ 'Cache-Control': 'no-store',
2730
+ 'Content-Length': String(bytes.length),
2731
+ })
2732
+ res.end(bytes)
2733
+ } catch (err) {
2734
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
2735
+ res.end('role image unavailable')
2736
+ }
2737
+ },
2738
+ }))
2739
+
2740
+ disposers.push(registerRoute({
2741
+ kind: 'exact',
2742
+ path: '/dsh-whale/audio.json',
2743
+ handler: async (req, res) => {
2744
+ if (req.method === 'POST' || req.method === 'PUT') {
2745
+ try {
2746
+ const body = await readBodyMax(req, 8 * 1024 * 1024)
2747
+ const parsed = JSON.parse(body)
2748
+ const action = parsed.action
2749
+ const index = readAudioIndex()
2750
+ if (action === 'upload-fragment') {
2751
+ const name = String(parsed.name || '').trim().slice(0, 40) || '未命名音频'
2752
+ const audio = String(parsed.audio || '')
2753
+ const m = /^data:audio\/wav;base64,([A-Za-z0-9+/=]+)$/.exec(audio)
2754
+ if (!m) {
2755
+ res.writeHead(400, JSON_HEADERS)
2756
+ res.end(JSON.stringify({ ok: false, error: 'invalid wav data' }))
2757
+ return
2758
+ }
2759
+ const buf = Buffer.from(m[1], 'base64')
2760
+ if (buf.length < 44 || buf.length > 8 * 1024 * 1024) {
2761
+ res.writeHead(400, JSON_HEADERS)
2762
+ res.end(JSON.stringify({ ok: false, error: 'audio too large' }))
2763
+ return
2764
+ }
2765
+ const id = 'audio_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8)
2766
+ fs.writeFileSync(path.join(pickAudioDir(), id + '.wav'), buf)
2767
+ index.fragments.push({ id, name, createdAt: Date.now() })
2768
+ writeAudioIndex(index)
2769
+ res.writeHead(200, JSON_HEADERS)
2770
+ res.end(JSON.stringify({ ok: true, id, fragments: audioFragmentsPayload() }))
2771
+ return
2772
+ }
2773
+ if (action === 'save-group') {
2774
+ const id = String(parsed.id || '')
2775
+ const name = String(parsed.name || '').trim().slice(0, 20) || '未命名音效组'
2776
+ const press = String(parsed.press ?? '')
2777
+ const release = String(parsed.release ?? '')
2778
+ // 校验引用片段存在(预设或自定义);空字符串=该槽留空(该事件静音),允许保存
2779
+ const frags = audioFragmentsPayload().map((f) => f.id)
2780
+ const validPress = press === '' ? '' : (frags.includes(press) ? press : PRESET_GROUPS.duck.press)
2781
+ const validRelease = release === '' ? '' : (frags.includes(release) ? release : PRESET_GROUPS.duck.release)
2782
+ if (id && index.groups.some((g) => g.id === id)) {
2783
+ const g = index.groups.find((x) => x.id === id)
2784
+ g.name = name
2785
+ g.press = validPress
2786
+ g.release = validRelease
2787
+ } else {
2788
+ const gid = 'group_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8)
2789
+ index.groups.push({ id: gid, name, press: validPress, release: validRelease, pinnedAt: null, createdAt: Date.now() })
2790
+ }
2791
+ writeAudioIndex(index)
2792
+ res.writeHead(200, JSON_HEADERS)
2793
+ res.end(JSON.stringify({ ok: true, groups: audioGroupsPayload() }))
2794
+ return
2795
+ }
2796
+ if (action === 'delete-group') {
2797
+ const id = String(parsed.id || '')
2798
+ if (PRESET_GROUPS[id]) {
2799
+ res.writeHead(400, JSON_HEADERS)
2800
+ res.end(JSON.stringify({ ok: false, error: 'cannot delete preset group' }))
2801
+ return
2802
+ }
2803
+ index.groups = index.groups.filter((g) => g.id !== id)
2804
+ writeAudioIndex(index)
2805
+ res.writeHead(200, JSON_HEADERS)
2806
+ res.end(JSON.stringify({ ok: true, groups: audioGroupsPayload() }))
2807
+ return
2808
+ }
2809
+ if (action === 'delete-fragment') {
2810
+ const id = String(parsed.id || '')
2811
+ if (PRESET_FRAGMENTS[id]) {
2812
+ res.writeHead(400, JSON_HEADERS)
2813
+ res.end(JSON.stringify({ ok: false, error: 'cannot delete preset fragment' }))
2814
+ return
2815
+ }
2816
+ index.fragments = index.fragments.filter((f) => f.id !== id)
2817
+ writeAudioIndex(index)
2818
+ const p = audioFragmentPath(id)
2819
+ if (p) { try { fs.unlinkSync(p) } catch (err) {} }
2820
+ res.writeHead(200, JSON_HEADERS)
2821
+ res.end(JSON.stringify({ ok: true, fragments: audioFragmentsPayload(), groups: audioGroupsPayload() }))
2822
+ return
2823
+ }
2824
+ if (action === 'pin-group') {
2825
+ const id = String(parsed.id || '')
2826
+ const g = index.groups.find((x) => x.id === id)
2827
+ if (!g) {
2828
+ res.writeHead(404, JSON_HEADERS)
2829
+ res.end(JSON.stringify({ ok: false, error: 'group not found' }))
2830
+ return
2831
+ }
2832
+ g.pinnedAt = parsed.pinned === true ? Date.now() : null
2833
+ writeAudioIndex(index)
2834
+ res.writeHead(200, JSON_HEADERS)
2835
+ res.end(JSON.stringify({ ok: true, groups: audioGroupsPayload() }))
2836
+ return
2837
+ }
2838
+ res.writeHead(400, JSON_HEADERS)
2839
+ res.end(JSON.stringify({ ok: false, error: 'unknown action' }))
2840
+ } catch (err) {
2841
+ res.writeHead(400, JSON_HEADERS)
2842
+ res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err).slice(0, 200) }))
2843
+ }
2844
+ return
2845
+ }
2846
+ res.writeHead(200, JSON_HEADERS)
2847
+ res.end(JSON.stringify(audioPayload()))
2848
+ },
2849
+ }))
2850
+
2851
+ disposers.push(registerRoute({
2852
+ kind: 'exact',
2853
+ path: '/dsh-whale/audio-fragment.wav',
2854
+ handler: (req, res) => {
2855
+ try {
2856
+ const id = audioIdFromUrl(req.url)
2857
+ const bytes = loadAudioFragmentBytes(id)
2858
+ if (!bytes) throw new Error('bad fragment id')
2859
+ // 预设/内置片段可能是 mp3 也可能是 wav —— Content-Type 必须与字节匹配,
2860
+ // 否则浏览器解码路径/加载行为异常(MIME 与字节不符会导致听感差异)
2861
+ const mime = fragmentMime(id)
2862
+ res.writeHead(200, {
2863
+ 'Content-Type': mime,
2864
+ 'Cache-Control': 'no-store',
2865
+ 'Content-Length': String(bytes.length),
2866
+ })
2867
+ res.end(bytes)
2868
+ } catch (err) {
2869
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
2870
+ res.end('audio fragment unavailable')
2871
+ }
2872
+ },
2873
+ }))
2874
+
2875
+ // 片段字节对应的 MIME:内置/预设片段按声明,自定义片段一律 wav
2876
+ function fragmentMime(fragId) {
2877
+ const f = PRESET_FRAGMENTS[fragId]
2878
+ return (f && f.mime) || 'audio/wav'
2879
+ }
2880
+
1954
2881
  function loadSound(candidates) {
1955
2882
  for (const p of candidates) {
1956
2883
  try {
@@ -1976,25 +2903,271 @@ function apply(ctx) {
1976
2903
  res.end(bytes)
1977
2904
  }
1978
2905
 
1979
- disposers.push(ctx.webServer.register({
2906
+ disposers.push(registerRoute({
1980
2907
  kind: 'exact',
1981
2908
  path: '/dsh-whale/sound/press.mp3',
1982
2909
  handler: (req, res) => {
1983
2910
  const set = SOUND_SETS[soundSetFromUrl(req.url)] || SOUND_SETS.duck
2911
+ // 自定义音效组:set 为组 id 时按组内 press 片段取音频
2912
+ const setName = soundSetFromUrl(req.url)
2913
+ if (setName && !SOUND_SETS[setName]) {
2914
+ const fragId = groupFragmentId(setName, 'press')
2915
+ if (fragId === '') { res.writeHead(204); res.end(); return } // 留空槽:该事件静音
2916
+ const bytes = loadAudioFragmentBytes(fragId)
2917
+ if (bytes) {
2918
+ res.writeHead(200, {
2919
+ 'Content-Type': fragmentMime(fragId),
2920
+ 'Cache-Control': 'no-store',
2921
+ 'Content-Length': String(bytes.length),
2922
+ })
2923
+ res.end(bytes)
2924
+ return
2925
+ }
2926
+ }
1984
2927
  serveSound(req, res, set.press)
1985
2928
  },
1986
2929
  }))
1987
2930
 
1988
- disposers.push(ctx.webServer.register({
2931
+ disposers.push(registerRoute({
1989
2932
  kind: 'exact',
1990
2933
  path: '/dsh-whale/sound/release.mp3',
1991
2934
  handler: (req, res) => {
1992
2935
  const set = SOUND_SETS[soundSetFromUrl(req.url)] || SOUND_SETS.duck
2936
+ const setName = soundSetFromUrl(req.url)
2937
+ if (setName && !SOUND_SETS[setName]) {
2938
+ const fragId = groupFragmentId(setName, 'release')
2939
+ if (fragId === '') { res.writeHead(204); res.end(); return } // 留空槽:该事件静音
2940
+ const bytes = loadAudioFragmentBytes(fragId)
2941
+ if (bytes) {
2942
+ res.writeHead(200, {
2943
+ 'Content-Type': fragmentMime(fragId),
2944
+ 'Cache-Control': 'no-store',
2945
+ 'Content-Length': String(bytes.length),
2946
+ })
2947
+ res.end(bytes)
2948
+ return
2949
+ }
2950
+ }
1993
2951
  serveSound(req, res, set.release)
1994
2952
  },
1995
2953
  }))
1996
2954
 
1997
- disposers.push(ctx.webServer.register({
2955
+ function pickBubbleImgDir() {
2956
+ for (const p of BUBBLE_IMG_DIR_CANDIDATES) {
2957
+ try {
2958
+ fs.mkdirSync(p, { recursive: true })
2959
+ fs.accessSync(p, fs.constants.W_OK)
2960
+ return p
2961
+ } catch (err) {}
2962
+ }
2963
+ return BUBBLE_IMG_DIR_CANDIDATES[0]
2964
+ }
2965
+ function defaultBubbleImgIndex() {
2966
+ return { version: 1, images: [] }
2967
+ }
2968
+ function readBubbleImgIndex() {
2969
+ try {
2970
+ const parsed = JSON.parse(fs.readFileSync(path.join(pickBubbleImgDir(), BUBBLE_IMG_INDEX_NAME), 'utf8'))
2971
+ if (parsed && Array.isArray(parsed.images)) return parsed
2972
+ } catch (err) {}
2973
+ return defaultBubbleImgIndex()
2974
+ }
2975
+ function writeBubbleImgIndex(index) {
2976
+ try {
2977
+ fs.writeFileSync(path.join(pickBubbleImgDir(), BUBBLE_IMG_INDEX_NAME), JSON.stringify(index, null, 2), 'utf8')
2978
+ return true
2979
+ } catch (err) {
2980
+ return false
2981
+ }
2982
+ }
2983
+ function bubbleImgPayload() {
2984
+ const index = readBubbleImgIndex()
2985
+ // 内置默认图常驻(先列出,不占 createdAt 排序;用户图库含同 id 时不重复)
2986
+ const seen = {}
2987
+ index.images.forEach((im) => { seen[im.id] = 1 })
2988
+ const builtins = DEFAULT_BUBBLE_IMGS.filter((d) => !seen[d.id]).map((d) => ({
2989
+ id: d.id,
2990
+ name: String(d.name || d.id),
2991
+ format: d.format === 'gif' ? 'gif' : 'png',
2992
+ url: '/dsh-whale/bubble-img.png?id=' + encodeURIComponent(d.id),
2993
+ createdAt: null,
2994
+ builtin: true,
2995
+ }))
2996
+ return {
2997
+ ok: true,
2998
+ images: builtins.concat(index.images.slice().sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)).map((im) => ({
2999
+ id: im.id,
3000
+ name: String(im.name || im.id),
3001
+ format: im.format === 'gif' ? 'gif' : 'png',
3002
+ url: '/dsh-whale/bubble-img.png?id=' + encodeURIComponent(im.id),
3003
+ createdAt: im.createdAt || null,
3004
+ }))),
3005
+ }
3006
+ }
3007
+ function bubbleImgIdFromUrl(url) {
3008
+ try {
3009
+ const q = String(url || '').split('?')[1] || ''
3010
+ const m = /(?:^|&)id=([^&]+)/.exec(q)
3011
+ return m ? decodeURIComponent(m[1]) : ''
3012
+ } catch (err) { return '' }
3013
+ }
3014
+ function loadBubbleConfig() {
3015
+ for (const p of BUBBLE_FILE_CANDIDATES) {
3016
+ try {
3017
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf8'))
3018
+ if (parsed && parsed.v === 1) return parsed
3019
+ } catch (err) {}
3020
+ }
3021
+ return null
3022
+ }
3023
+ function writeBubbleConfig(cfg) {
3024
+ const body = JSON.stringify(cfg, null, 2)
3025
+ for (const p of BUBBLE_FILE_CANDIDATES) {
3026
+ try { fs.writeFileSync(p, body, 'utf8'); return true } catch (err) {}
3027
+ }
3028
+ return false
3029
+ }
3030
+
3031
+ disposers.push(registerRoute({
3032
+ kind: 'exact',
3033
+ path: '/dsh-whale/bubble.json',
3034
+ handler: async (req, res) => {
3035
+ try {
3036
+ if (req.method === 'POST' || req.method === 'PUT') {
3037
+ const body = await readBodyMax(req, 512 * 1024)
3038
+ const parsed = JSON.parse(body)
3039
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.items) || !Array.isArray(parsed.lib)) {
3040
+ res.writeHead(400, JSON_HEADERS)
3041
+ res.end(JSON.stringify({ ok: false, error: 'invalid bubble config' }))
3042
+ return
3043
+ }
3044
+ // v727:tapAdvance =「点按角色推进泡泡队列」(只存布尔值,不放行其它字段)
3045
+ const cfg = { v: 1, items: parsed.items, lib: parsed.lib, tapAdvance: parsed.tapAdvance === true }
3046
+ writeBubbleConfig(cfg)
3047
+ res.writeHead(200, JSON_HEADERS)
3048
+ res.end(JSON.stringify({ ok: true, config: cfg }))
3049
+ return
3050
+ }
3051
+ const cfg = loadBubbleConfig()
3052
+ res.writeHead(200, JSON_HEADERS)
3053
+ res.end(JSON.stringify({ ok: true, config: cfg }))
3054
+ } catch (err) {
3055
+ res.writeHead(400, JSON_HEADERS)
3056
+ res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err).slice(0, 200) }))
3057
+ }
3058
+ },
3059
+ }))
3060
+
3061
+ disposers.push(registerRoute({
3062
+ kind: 'exact',
3063
+ path: '/dsh-whale/bubble-imgs.json',
3064
+ handler: (req, res) => {
3065
+ res.writeHead(200, JSON_HEADERS)
3066
+ res.end(JSON.stringify(bubbleImgPayload()))
3067
+ },
3068
+ }))
3069
+
3070
+ disposers.push(registerRoute({
3071
+ kind: 'exact',
3072
+ path: '/dsh-whale/bubble-img-upload.json',
3073
+ handler: async (req, res) => {
3074
+ try {
3075
+ const body = await readBodyMax(req, 10 * 1024 * 1024)
3076
+ const parsed = JSON.parse(body)
3077
+ const action = parsed && parsed.action
3078
+ const index = readBubbleImgIndex()
3079
+ if (action === 'upload') {
3080
+ const name = String(parsed.name || '').trim().slice(0, 40) || ''
3081
+ const data = String(parsed.data || '')
3082
+ const m = /^data:image\/(png|gif);base64,([A-Za-z0-9+/=]+)$/.exec(data)
3083
+ if (!m) {
3084
+ res.writeHead(400, JSON_HEADERS)
3085
+ res.end(JSON.stringify({ ok: false, error: 'invalid image data' }))
3086
+ return
3087
+ }
3088
+ const format = m[1] === 'gif' ? 'gif' : 'png'
3089
+ const buf = Buffer.from(m[2], 'base64')
3090
+ if (buf.length < 64 || buf.length > 8 * 1024 * 1024) {
3091
+ res.writeHead(400, JSON_HEADERS)
3092
+ res.end(JSON.stringify({ ok: false, error: 'image too large' }))
3093
+ return
3094
+ }
3095
+ const id = 'bimg_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8)
3096
+ fs.writeFileSync(path.join(pickBubbleImgDir(), id + '.' + format), buf)
3097
+ index.images.push({ id, name, format, createdAt: Date.now() })
3098
+ writeBubbleImgIndex(index)
3099
+ res.writeHead(200, JSON_HEADERS)
3100
+ res.end(JSON.stringify(bubbleImgPayload()))
3101
+ return
3102
+ }
3103
+ if (action === 'delete') {
3104
+ const id = String(parsed.id || '')
3105
+ const img = index.images.find((x) => x.id === id)
3106
+ if (!img) {
3107
+ res.writeHead(404, JSON_HEADERS)
3108
+ res.end(JSON.stringify({ ok: false, error: 'image not found' }))
3109
+ return
3110
+ }
3111
+ index.images = index.images.filter((x) => x.id !== id)
3112
+ writeBubbleImgIndex(index)
3113
+ const ext = img.format === 'gif' ? 'gif' : 'png'
3114
+ try { fs.unlinkSync(path.join(pickBubbleImgDir(), id + '.' + ext)) } catch (err) {}
3115
+ res.writeHead(200, JSON_HEADERS)
3116
+ res.end(JSON.stringify(bubbleImgPayload()))
3117
+ return
3118
+ }
3119
+ res.writeHead(400, JSON_HEADERS)
3120
+ res.end(JSON.stringify({ ok: false, error: 'unknown action' }))
3121
+ } catch (err) {
3122
+ res.writeHead(400, JSON_HEADERS)
3123
+ res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err).slice(0, 200) }))
3124
+ }
3125
+ },
3126
+ }))
3127
+
3128
+ disposers.push(registerRoute({
3129
+ kind: 'exact',
3130
+ path: '/dsh-whale/bubble-img.png',
3131
+ handler: (req, res) => {
3132
+ try {
3133
+ const id = bubbleImgIdFromUrl(req.url)
3134
+ // 1) 用户图库优先
3135
+ const index = readBubbleImgIndex()
3136
+ const img = index.images.find((x) => x.id === id)
3137
+ if (img) {
3138
+ const ext = img.format === 'gif' ? 'gif' : 'png'
3139
+ const bytes = fs.readFileSync(path.join(pickBubbleImgDir(), id + '.' + ext))
3140
+ res.writeHead(200, {
3141
+ 'Content-Type': img.format === 'gif' ? 'image/gif' : 'image/png',
3142
+ 'Cache-Control': 'no-store',
3143
+ 'Content-Length': String(bytes.length),
3144
+ })
3145
+ res.end(bytes)
3146
+ return
3147
+ }
3148
+ // 2) 内置默认图回退(全新安装无用户图库时,泡泡序列引用的语义 id 也能出图)
3149
+ const def = DEFAULT_BUBBLE_IMGS.find((x) => x.id === id)
3150
+ if (def) {
3151
+ const bytes = loadBuiltinBubbleImgBytes(def)
3152
+ if (bytes) {
3153
+ res.writeHead(200, {
3154
+ 'Content-Type': def.format === 'gif' ? 'image/gif' : 'image/png',
3155
+ 'Cache-Control': 'no-store',
3156
+ 'Content-Length': String(bytes.length),
3157
+ })
3158
+ res.end(bytes)
3159
+ return
3160
+ }
3161
+ }
3162
+ throw new Error('bad image id')
3163
+ } catch (err) {
3164
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
3165
+ res.end('bubble image unavailable')
3166
+ }
3167
+ },
3168
+ }))
3169
+
3170
+ disposers.push(registerRoute({
1998
3171
  kind: 'exact',
1999
3172
  path: '/dsh-whale/widget.js',
2000
3173
  handler: (req, res) => {
@@ -2002,7 +3175,7 @@ function apply(ctx) {
2002
3175
  'Content-Type': 'application/javascript; charset=utf-8',
2003
3176
  'Cache-Control': 'no-store',
2004
3177
  })
2005
- res.end(WIDGET_JS)
3178
+ res.end(loadWidgetJs())
2006
3179
  },
2007
3180
  }))
2008
3181
 
@@ -2018,6 +3191,5 @@ function apply(ctx) {
2018
3191
  try { d() } catch (err) {}
2019
3192
  }
2020
3193
  })
3194
+ },
2021
3195
  }
2022
-
2023
- export { name, inject, apply }