dsh-dock 0.11.2 → 0.11.5

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.
Files changed (41) hide show
  1. package/LICENSE +18 -18
  2. package/README.md +255 -396
  3. package/client.js +596 -98
  4. package/cordis.patch.yml +2 -2
  5. package/features/animation/host.js +119 -119
  6. package/features/animation/view.jsx +1662 -1662
  7. package/features/balance/host.js +494 -494
  8. package/features/balance/view.js +351 -351
  9. package/features/games/games/breakout.jsx +227 -227
  10. package/features/games/games/gomoku.jsx +244 -244
  11. package/features/games/games/racer.jsx +169 -169
  12. package/features/games/games/shared.jsx +118 -118
  13. package/features/games/games/snake.jsx +144 -144
  14. package/features/games/games/sokoban.jsx +180 -180
  15. package/features/games/games/tank.jsx +128 -128
  16. package/features/games/games/tetris.jsx +308 -308
  17. package/features/games/games/xiangqi.jsx +277 -277
  18. package/features/games/tank-core.js +152 -152
  19. package/features/games/view.jsx +639 -639
  20. package/features/games/xiangqi-core.js +299 -299
  21. package/features/heartbeat/view.js +35 -35
  22. package/features/mobile-relay/gateway.js +472 -472
  23. package/features/mobile-relay/host.js +622 -570
  24. package/features/mobile-relay/view.jsx +380 -300
  25. package/features/modelconfig/host.js +406 -406
  26. package/features/modelconfig/view.js +490 -490
  27. package/features/notify/host.js +374 -374
  28. package/features/notify/view.jsx +751 -751
  29. package/features/theme/view.js +61 -61
  30. package/features/tokenlog/host.js +882 -883
  31. package/features/tokenlog/view.jsx +1369 -932
  32. package/features/visionproxy/host.js +315 -315
  33. package/index.js +195 -195
  34. package/package.json +66 -64
  35. package/screenshots/dock-tokenlog-charts-2.png +0 -0
  36. package/screenshots/dock-tokenlog-charts-3.png +0 -0
  37. package/screenshots/dock-tokenlog-charts.png +0 -0
  38. package/src/client.jsx +677 -677
  39. package/src/host-core.js +207 -207
  40. package/src/shared.js +192 -192
  41. package/src/task-track.js +354 -359
package/src/host-core.js CHANGED
@@ -1,207 +1,207 @@
1
- // dsh-dock · 宿主共享内核:各功能模块(features/*/host.js)共用的常量与工具。
2
- // 放在 src/ 而非 features/:不属于任何单个功能;提取功能模块独立成包时按需随包复制。
3
- import z from '@deepseek-ai/schemastery'
4
-
5
- /** dsh-dock 自有 settings 命名空间(插件级配置,如图片理解代理)。 */
6
- export const DOCK_NS = 'dsh-dock'
7
-
8
- /** 自有命名空间的 schema(功能开关 + 图片理解代理 + 任务动画/任务通知配置 + 远程访问账号)。
9
- * 运行状态模块只读共享追踪、不写配置,因此没有自己的段。 */
10
- export const DockConfig = z.object({
11
- // 宿主侧功能开关(id -> boolean):客户端面板 toggle 即时同步过来,
12
- // 重启 dsh 后按此表决定宿主半部 setup 哪些功能(路由注册、事件订阅等)。
13
- features: z.dict(z.boolean()).default({}),
14
- remoteAuth: z.object({
15
- /** 远程访问登录账号(明文用户名;密码只存加盐哈希)。 */
16
- username: z.string().default(''),
17
- passwordHash: z.string().default(''),
18
- salt: z.string().default(''),
19
- }).default({}),
20
- visionProxy: z.object({
21
- enabled: z.boolean().default(false),
22
- provider: z.string().default(''),
23
- model: z.string().default(''),
24
- }).default({}),
25
- // 【用量记录】模块的单价配置:官网抓取的刊例价常与实际计费不符(第三方/中转、
26
- // 折扣、自建端点等),这里保存用户自填单价并优先用于费用估算。
27
- // 详见 features/tokenlog/host.js 的 resolvePricing(优先级:自定义 > 官网 > 内置 > 兜底)。
28
- tokenlog: z.object({
29
- /** USD→CNY 汇率(金额内部以 USD 记录,界面按人民币展示)。 */
30
- usdCnyRate: z.number().default(7.2),
31
- /** 是否仍抓取官网刊例价,作为「自定义单价」未命中时的兜底。 */
32
- fetchOfficial: z.boolean().default(true),
33
- /** 官网价目地址(仅 http/https,且拒绝本机/内网/保留地址)。 */
34
- pricingUrl: z.string().default('https://api-docs.deepseek.com/zh-cn/quick_start/pricing'),
35
- /** 官网价目抓取间隔(小时)。 */
36
- pricingFetchIntervalHours: z.number().default(24),
37
- /** 用户自定义单价(人民币元/百万 tokens):命中即用、优先级最高,按数组顺序匹配。 */
38
- pricing: z.array(z.object({
39
- match: z.string().default(''),
40
- input: z.number().default(0),
41
- output: z.number().default(0),
42
- cacheRead: z.number().default(0),
43
- cacheWrite: z.number().default(0),
44
- /** 分时段价(可多段,如 DeepSeek 的高峰/优惠时段):命中某段用该段价,否则用上面的基准价。
45
- * start/end 为本地小时数 0~24;start>end 表示跨零点;单段等价旧的 peak。 */
46
- peaks: z.array(z.object({
47
- start: z.number().default(0),
48
- end: z.number().default(0),
49
- input: z.number().default(0),
50
- output: z.number().default(0),
51
- cacheRead: z.number().default(0),
52
- cacheWrite: z.number().default(0),
53
- })).default([]),
54
- })).default([]),
55
- /** 未匹配任何条目的模型所用兜底单价。 */
56
- fallback: z.object({
57
- input: z.number().default(2.16),
58
- output: z.number().default(6.48),
59
- cacheRead: z.number().default(0.43),
60
- cacheWrite: z.number().default(4.32),
61
- }).default({}),
62
- }).default({}),
63
- // 【任务动画】模块:只放动画本身(动效开关/模式/桌面伙伴大小)。
64
- // 通知相关的字段已全部迁往下面的 notify 段(见 migrateNotifyConfig);
65
- // 旧值不在 schema 里也会被 schemastery 原样保留,迁移读得到。
66
- animation: z.object({
67
- animationEnabled: z.boolean().default(true),
68
- effectMode: z.string().default('flow'),
69
- // 桌面伙伴场景缩放(0.85~2.2;浮层右下角也可直接拖动缩放)
70
- robotScale: z.number().default(1.35),
71
- }).default({}),
72
- // 【任务通知】模块:页内卡片 / 提示音 / 浏览器系统通知 / 群机器人推送。
73
- // 模块自身的启停由 features.notify 开关负责(独立菜单项),这里只放通知行为配置。
74
- notify: z.object({
75
- notifyOnComplete: z.boolean().default(true),
76
- notifyOnError: z.boolean().default(true),
77
- // 工具等待用户确认/批准时提醒(dsh 会话流 approval/asked → decided)
78
- notifyOnConfirm: z.boolean().default(true),
79
- // 通知停留毫秒数(0 = 常驻直到手动关闭)
80
- notifyStayMs: z.number().default(8000),
81
- // 浏览器系统通知(页面后台时推送)
82
- systemNotify: z.boolean().default(false),
83
- // 任务结束提示音(WebAudio 合成,macOS/Windows 通用,无音频文件依赖)
84
- soundNotify: z.boolean().default(true),
85
- // 提示音音效(完成场景音名;异常音由音效包内配套)
86
- soundEffect: z.string().default('chime'),
87
- // 钉钉群机器人推送(宿主侧直发,浏览器关着也能推;事件跟随 notifyOnComplete/notifyOnError)
88
- dingtalkEnabled: z.boolean().default(false),
89
- dingtalkWebhook: z.string().default(''),
90
- // 飞书群机器人推送(宿主侧直发,浏览器关着也能推;事件跟随 notifyOnComplete/notifyOnError)
91
- feishuEnabled: z.boolean().default(false),
92
- feishuWebhook: z.string().default(''),
93
- // 一次性迁移标记:animation 段的旧通知配置已搬到本段(搬完置 true,避免每次启动重复写)
94
- migratedFromAnimation: z.boolean().default(false),
95
- }).default({}),
96
- })
97
-
98
- /** 通知配置字段清单(schema、迁移、客户端默认值三处共用同一份键名)。 */
99
- export const NOTIFY_FIELDS = [
100
- 'notifyOnComplete', 'notifyOnError', 'notifyOnConfirm', 'notifyStayMs',
101
- 'systemNotify', 'soundNotify', 'soundEffect',
102
- 'dingtalkEnabled', 'dingtalkWebhook', 'feishuEnabled', 'feishuWebhook',
103
- ]
104
-
105
- /**
106
- * 一次性迁移:通知配置从 settings 的 animation 段搬到 notify 段
107
- * (【任务通知】独立成功能模块,配置也该由它自己拥有)。
108
- *
109
- * 为什么必须搬:animation 模块保存配置时整段写回,旧字段会被覆盖丢失;
110
- * 迁移在插件加载时(settings 注册回调里)先跑,早于任何面板保存动作。
111
- * 取值规则:animation 段的旧值优先(拆分前它就是用户的真实选择),notify 段保留其余键。
112
- */
113
- export async function migrateNotifyConfig(ctx) {
114
- try {
115
- const settings = ctx.get('settings')
116
- if (!settings || typeof settings.get !== 'function' || typeof settings.mutate !== 'function') return
117
- const root = settings.get(DOCK_NS)
118
- const notify = root && typeof root === 'object' && root.notify && typeof root.notify === 'object' ? root.notify : null
119
- if (notify && notify.migratedFromAnimation === true) return
120
- const animation = root && typeof root === 'object' && root.animation && typeof root.animation === 'object' ? root.animation : null
121
- const legacy = {}
122
- let copied = 0
123
- for (const key of NOTIFY_FIELDS) {
124
- if (animation && animation[key] !== undefined) { legacy[key] = animation[key]; copied++ }
125
- }
126
- // 没有可搬的旧字段(全新安装 / 已搬完)就不写盘,也不置标记——
127
- // 避免 settings 命名空间尚未生效时误把「空迁移」当完成(那会丢掉旧配置)。
128
- if (copied === 0) return
129
- const next = Object.assign({}, notify || {}, legacy, { migratedFromAnimation: true })
130
- await settings.mutate(DOCK_NS, [{ op: 'set', path: ['notify'], value: next }])
131
- console.log('[dsh-dock] notify config migrated from animation section; fields copied:', copied)
132
- } catch (e) {
133
- console.warn('[dsh-dock] notify config migration skipped:', (e && e.message) || String(e))
134
- }
135
- }
136
-
137
- /**
138
- * 一次性迁移:宿主侧【模型设置】功能 id 从 models 改为 modelconfig(与客户端视图 id 对齐)。
139
- *
140
- * 为什么必须迁:面板开关按客户端 id(modelconfig)POST /dsh-dock/features,
141
- * 旧宿主 id 叫 models → 永远「未知功能」404(客户端静默吞掉),开关表里因此
142
- * 只可能出现 API 手工写入的 models 键。新键未落盘时继承旧值并删掉旧键;
143
- * 判断条件本身幂等(迁完后 models 键不存在,直接早退),无需额外标记位。
144
- */
145
- export async function migrateModelsFeatureId(ctx) {
146
- try {
147
- const settings = ctx.get('settings')
148
- if (!settings || typeof settings.get !== 'function' || typeof settings.mutate !== 'function') return
149
- const root = settings.get(DOCK_NS)
150
- const features = root && typeof root === 'object' && root.features && typeof root.features === 'object' ? root.features : null
151
- if (!features || typeof features.models !== 'boolean' || typeof features.modelconfig === 'boolean') return
152
- const next = Object.assign({}, features, { modelconfig: features.models })
153
- delete next.models
154
- await settings.mutate(DOCK_NS, [{ op: 'set', path: ['features'], value: next }])
155
- console.log('[dsh-dock] feature id migrated: features.models -> features.modelconfig =', features.models)
156
- } catch (e) {
157
- console.warn('[dsh-dock] models feature id migration skipped:', (e && e.message) || String(e))
158
- }
159
- }
160
-
161
- /** 任务动画 effectMode 合法值(客户端动画模式)。 */
162
- export const ANIMATION_MODES = [
163
- 'flow', 'breathe', 'ring', 'orbit', 'robot', 'matrix', 'stars', 'aurora', 'space',
164
- 'nebula', 'warp', 'radar', 'constellation', 'fireflies', 'ocean', 'prism', 'circuit', 'gravity', 'lantern',
165
- ]
166
-
167
- /** 提示音 soundEffect 合法值(客户端音效库键名)。 */
168
- export const SOUND_EFFECTS = ['chime', 'ding', 'coin', 'bell', 'pulse', 'arp']
169
-
170
- /** 沿 settingsPath 走一层对象(user/base/value 都可用)。 */
171
- export function walkPath(node, path) {
172
- for (const key of path || []) {
173
- node = node && typeof node === 'object' ? node[key] : undefined
174
- }
175
- return node
176
- }
177
-
178
- /** 序列化 JSON 响应。 */
179
- export function sendJson(res, status, value) {
180
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
181
- res.end(JSON.stringify(value))
182
- }
183
-
184
- /** 解析 POST 请求体 JSON(限长 2MB)。 */
185
- export function readBody(req) {
186
- return new Promise((resolve, reject) => {
187
- let size = 0
188
- const chunks = []
189
- req.on('data', (c) => {
190
- size += c.length
191
- if (size > 2 * 1024 * 1024) {
192
- reject(new Error('请求体过大'))
193
- req.destroy()
194
- return
195
- }
196
- chunks.push(c)
197
- })
198
- req.on('end', () => {
199
- try {
200
- resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')))
201
- } catch (e) {
202
- reject(new Error('请求体不是合法 JSON'))
203
- }
204
- })
205
- req.on('error', reject)
206
- })
207
- }
1
+ // dsh-dock · 宿主共享内核:各功能模块(features/*/host.js)共用的常量与工具。
2
+ // 放在 src/ 而非 features/:不属于任何单个功能;提取功能模块独立成包时按需随包复制。
3
+ import z from '@deepseek-ai/schemastery'
4
+
5
+ /** dsh-dock 自有 settings 命名空间(插件级配置,如图片理解代理)。 */
6
+ export const DOCK_NS = 'dsh-dock'
7
+
8
+ /** 自有命名空间的 schema(功能开关 + 图片理解代理 + 任务动画/任务通知配置 + 远程访问账号)。
9
+ * 运行状态模块只读共享追踪、不写配置,因此没有自己的段。 */
10
+ export const DockConfig = z.object({
11
+ // 宿主侧功能开关(id -> boolean):客户端面板 toggle 即时同步过来,
12
+ // 重启 dsh 后按此表决定宿主半部 setup 哪些功能(路由注册、事件订阅等)。
13
+ features: z.dict(z.boolean()).default({}),
14
+ remoteAuth: z.object({
15
+ /** 远程访问登录账号(明文用户名;密码只存加盐哈希)。 */
16
+ username: z.string().default(''),
17
+ passwordHash: z.string().default(''),
18
+ salt: z.string().default(''),
19
+ }).default({}),
20
+ visionProxy: z.object({
21
+ enabled: z.boolean().default(false),
22
+ provider: z.string().default(''),
23
+ model: z.string().default(''),
24
+ }).default({}),
25
+ // 【用量记录】模块的单价配置:官网抓取的刊例价常与实际计费不符(第三方/中转、
26
+ // 折扣、自建端点等),这里保存用户自填单价并优先用于费用估算。
27
+ // 详见 features/tokenlog/host.js 的 resolvePricing(优先级:自定义 > 官网 > 内置 > 兜底)。
28
+ tokenlog: z.object({
29
+ /** USD→CNY 汇率(金额内部以 USD 记录,界面按人民币展示)。 */
30
+ usdCnyRate: z.number().default(7.2),
31
+ /** 是否仍抓取官网刊例价,作为「自定义单价」未命中时的兜底。 */
32
+ fetchOfficial: z.boolean().default(true),
33
+ /** 官网价目地址(仅 http/https,且拒绝本机/内网/保留地址)。 */
34
+ pricingUrl: z.string().default('https://api-docs.deepseek.com/zh-cn/quick_start/pricing'),
35
+ /** 官网价目抓取间隔(小时)。 */
36
+ pricingFetchIntervalHours: z.number().default(24),
37
+ /** 用户自定义单价(人民币元/百万 tokens):命中即用、优先级最高,按数组顺序匹配。 */
38
+ pricing: z.array(z.object({
39
+ match: z.string().default(''),
40
+ input: z.number().default(0),
41
+ output: z.number().default(0),
42
+ cacheRead: z.number().default(0),
43
+ cacheWrite: z.number().default(0),
44
+ /** 分时段价(可多段,如 DeepSeek 的高峰/优惠时段):命中某段用该段价,否则用上面的基准价。
45
+ * start/end 为本地小时数 0~24;start>end 表示跨零点;单段等价旧的 peak。 */
46
+ peaks: z.array(z.object({
47
+ start: z.number().default(0),
48
+ end: z.number().default(0),
49
+ input: z.number().default(0),
50
+ output: z.number().default(0),
51
+ cacheRead: z.number().default(0),
52
+ cacheWrite: z.number().default(0),
53
+ })).default([]),
54
+ })).default([]),
55
+ /** 未匹配任何条目的模型所用兜底单价。 */
56
+ fallback: z.object({
57
+ input: z.number().default(2.16),
58
+ output: z.number().default(6.48),
59
+ cacheRead: z.number().default(0.43),
60
+ cacheWrite: z.number().default(4.32),
61
+ }).default({}),
62
+ }).default({}),
63
+ // 【任务动画】模块:只放动画本身(动效开关/模式/桌面伙伴大小)。
64
+ // 通知相关的字段已全部迁往下面的 notify 段(见 migrateNotifyConfig);
65
+ // 旧值不在 schema 里也会被 schemastery 原样保留,迁移读得到。
66
+ animation: z.object({
67
+ animationEnabled: z.boolean().default(true),
68
+ effectMode: z.string().default('flow'),
69
+ // 桌面伙伴场景缩放(0.85~2.2;浮层右下角也可直接拖动缩放)
70
+ robotScale: z.number().default(1.35),
71
+ }).default({}),
72
+ // 【任务通知】模块:页内卡片 / 提示音 / 浏览器系统通知 / 群机器人推送。
73
+ // 模块自身的启停由 features.notify 开关负责(独立菜单项),这里只放通知行为配置。
74
+ notify: z.object({
75
+ notifyOnComplete: z.boolean().default(true),
76
+ notifyOnError: z.boolean().default(true),
77
+ // 工具等待用户确认/批准时提醒(dsh 会话流 approval/asked → decided)
78
+ notifyOnConfirm: z.boolean().default(true),
79
+ // 通知停留毫秒数(0 = 常驻直到手动关闭)
80
+ notifyStayMs: z.number().default(8000),
81
+ // 浏览器系统通知(页面后台时推送)
82
+ systemNotify: z.boolean().default(false),
83
+ // 任务结束提示音(WebAudio 合成,macOS/Windows 通用,无音频文件依赖)
84
+ soundNotify: z.boolean().default(true),
85
+ // 提示音音效(完成场景音名;异常音由音效包内配套)
86
+ soundEffect: z.string().default('chime'),
87
+ // 钉钉群机器人推送(宿主侧直发,浏览器关着也能推;事件跟随 notifyOnComplete/notifyOnError)
88
+ dingtalkEnabled: z.boolean().default(false),
89
+ dingtalkWebhook: z.string().default(''),
90
+ // 飞书群机器人推送(宿主侧直发,浏览器关着也能推;事件跟随 notifyOnComplete/notifyOnError)
91
+ feishuEnabled: z.boolean().default(false),
92
+ feishuWebhook: z.string().default(''),
93
+ // 一次性迁移标记:animation 段的旧通知配置已搬到本段(搬完置 true,避免每次启动重复写)
94
+ migratedFromAnimation: z.boolean().default(false),
95
+ }).default({}),
96
+ })
97
+
98
+ /** 通知配置字段清单(schema、迁移、客户端默认值三处共用同一份键名)。 */
99
+ export const NOTIFY_FIELDS = [
100
+ 'notifyOnComplete', 'notifyOnError', 'notifyOnConfirm', 'notifyStayMs',
101
+ 'systemNotify', 'soundNotify', 'soundEffect',
102
+ 'dingtalkEnabled', 'dingtalkWebhook', 'feishuEnabled', 'feishuWebhook',
103
+ ]
104
+
105
+ /**
106
+ * 一次性迁移:通知配置从 settings 的 animation 段搬到 notify 段
107
+ * (【任务通知】独立成功能模块,配置也该由它自己拥有)。
108
+ *
109
+ * 为什么必须搬:animation 模块保存配置时整段写回,旧字段会被覆盖丢失;
110
+ * 迁移在插件加载时(settings 注册回调里)先跑,早于任何面板保存动作。
111
+ * 取值规则:animation 段的旧值优先(拆分前它就是用户的真实选择),notify 段保留其余键。
112
+ */
113
+ export async function migrateNotifyConfig(ctx) {
114
+ try {
115
+ const settings = ctx.get('settings')
116
+ if (!settings || typeof settings.get !== 'function' || typeof settings.mutate !== 'function') return
117
+ const root = settings.get(DOCK_NS)
118
+ const notify = root && typeof root === 'object' && root.notify && typeof root.notify === 'object' ? root.notify : null
119
+ if (notify && notify.migratedFromAnimation === true) return
120
+ const animation = root && typeof root === 'object' && root.animation && typeof root.animation === 'object' ? root.animation : null
121
+ const legacy = {}
122
+ let copied = 0
123
+ for (const key of NOTIFY_FIELDS) {
124
+ if (animation && animation[key] !== undefined) { legacy[key] = animation[key]; copied++ }
125
+ }
126
+ // 没有可搬的旧字段(全新安装 / 已搬完)就不写盘,也不置标记——
127
+ // 避免 settings 命名空间尚未生效时误把「空迁移」当完成(那会丢掉旧配置)。
128
+ if (copied === 0) return
129
+ const next = Object.assign({}, notify || {}, legacy, { migratedFromAnimation: true })
130
+ await settings.mutate(DOCK_NS, [{ op: 'set', path: ['notify'], value: next }])
131
+ console.log('[dsh-dock] notify config migrated from animation section; fields copied:', copied)
132
+ } catch (e) {
133
+ console.warn('[dsh-dock] notify config migration skipped:', (e && e.message) || String(e))
134
+ }
135
+ }
136
+
137
+ /**
138
+ * 一次性迁移:宿主侧【模型设置】功能 id 从 models 改为 modelconfig(与客户端视图 id 对齐)。
139
+ *
140
+ * 为什么必须迁:面板开关按客户端 id(modelconfig)POST /dsh-dock/features,
141
+ * 旧宿主 id 叫 models → 永远「未知功能」404(客户端静默吞掉),开关表里因此
142
+ * 只可能出现 API 手工写入的 models 键。新键未落盘时继承旧值并删掉旧键;
143
+ * 判断条件本身幂等(迁完后 models 键不存在,直接早退),无需额外标记位。
144
+ */
145
+ export async function migrateModelsFeatureId(ctx) {
146
+ try {
147
+ const settings = ctx.get('settings')
148
+ if (!settings || typeof settings.get !== 'function' || typeof settings.mutate !== 'function') return
149
+ const root = settings.get(DOCK_NS)
150
+ const features = root && typeof root === 'object' && root.features && typeof root.features === 'object' ? root.features : null
151
+ if (!features || typeof features.models !== 'boolean' || typeof features.modelconfig === 'boolean') return
152
+ const next = Object.assign({}, features, { modelconfig: features.models })
153
+ delete next.models
154
+ await settings.mutate(DOCK_NS, [{ op: 'set', path: ['features'], value: next }])
155
+ console.log('[dsh-dock] feature id migrated: features.models -> features.modelconfig =', features.models)
156
+ } catch (e) {
157
+ console.warn('[dsh-dock] models feature id migration skipped:', (e && e.message) || String(e))
158
+ }
159
+ }
160
+
161
+ /** 任务动画 effectMode 合法值(客户端动画模式)。 */
162
+ export const ANIMATION_MODES = [
163
+ 'flow', 'breathe', 'ring', 'orbit', 'robot', 'matrix', 'stars', 'aurora', 'space',
164
+ 'nebula', 'warp', 'radar', 'constellation', 'fireflies', 'ocean', 'prism', 'circuit', 'gravity', 'lantern',
165
+ ]
166
+
167
+ /** 提示音 soundEffect 合法值(客户端音效库键名)。 */
168
+ export const SOUND_EFFECTS = ['chime', 'ding', 'coin', 'bell', 'pulse', 'arp']
169
+
170
+ /** 沿 settingsPath 走一层对象(user/base/value 都可用)。 */
171
+ export function walkPath(node, path) {
172
+ for (const key of path || []) {
173
+ node = node && typeof node === 'object' ? node[key] : undefined
174
+ }
175
+ return node
176
+ }
177
+
178
+ /** 序列化 JSON 响应。 */
179
+ export function sendJson(res, status, value) {
180
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
181
+ res.end(JSON.stringify(value))
182
+ }
183
+
184
+ /** 解析 POST 请求体 JSON(限长 2MB)。 */
185
+ export function readBody(req) {
186
+ return new Promise((resolve, reject) => {
187
+ let size = 0
188
+ const chunks = []
189
+ req.on('data', (c) => {
190
+ size += c.length
191
+ if (size > 2 * 1024 * 1024) {
192
+ reject(new Error('请求体过大'))
193
+ req.destroy()
194
+ return
195
+ }
196
+ chunks.push(c)
197
+ })
198
+ req.on('end', () => {
199
+ try {
200
+ resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')))
201
+ } catch (e) {
202
+ reject(new Error('请求体不是合法 JSON'))
203
+ }
204
+ })
205
+ req.on('error', reject)
206
+ })
207
+ }