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/README.md +257 -103
- package/assets/bubble-money1.gif +0 -0
- package/assets/bubble-petpet.gif +0 -0
- package/assets/minecraft-exp-orb.wav +0 -0
- package/assets/task-end-a.wav +0 -0
- package/assets/whale-widget.js +14031 -0
- package/lib/index.js +2648 -1476
- package/package.json +10 -2
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
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
73
|
-
|
|
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-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
for (
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
-
|
|
1319
|
-
if (
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
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
|
-
.
|
|
1411
|
-
|
|
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
|
-
|
|
1466
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
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
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
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
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
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
|
|
1701
|
-
|
|
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
|
-
|
|
1705
|
-
const
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
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
|
-
//
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
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
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
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 === '
|
|
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 === '
|
|
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 === '
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 }
|