dsh-notify-say 0.4.35 → 0.4.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +1885 -1738
- package/lib/dev-reload.js +647 -647
- package/lib/http-guard.js +25 -7
- package/lib/index.js +113 -10
- package/lib/voice-pack-download.js +133 -23
- package/lib/voice-pack-route.js +260 -258
- package/lib/voice-regions.js +144 -0
- package/package.json +1 -1
- package/test/client-contract.test.mjs +78 -0
- package/test/config-coverage.test.mjs +75 -0
- package/test/diagnostics.test.mjs +5 -3
- package/test/docs-consistency.test.mjs +18 -0
- package/test/fingerprint-scope.test.mjs +89 -0
- package/test/http-guard.test.mjs +63 -6
- package/test/review-fixes.test.mjs +149 -0
- package/test/voice-pack-download.test.mjs +47 -0
package/lib/client.js
CHANGED
|
@@ -1,1738 +1,1885 @@
|
|
|
1
|
-
// dsh-notify-say browser half。
|
|
2
|
-
//
|
|
3
|
-
// ## 职责(只有两件)
|
|
4
|
-
//
|
|
5
|
-
// 1. 在 DSH 设置页注册「通知」分区(slots.inject('settings.section'))
|
|
6
|
-
// —— Config schema 只生成「插件配置编辑器」里的表单,**不会**在设置侧栏加分区;
|
|
7
|
-
// 侧栏分区必须由 client 注册。本机先例:dsh-opencode-go / dsh-save-money /
|
|
8
|
-
// dsh-cost-meter / dsh-our-free-model 全都这么做。
|
|
9
|
-
// 2. 点 Toast 后跳到「那条通知对应的会话」。
|
|
10
|
-
//
|
|
11
|
-
// ## 为什么必须是这种形态
|
|
12
|
-
//
|
|
13
|
-
// 宿主契约要求 client 产物自行注册 `window.__ModuleLoader__.load({id, factory})`
|
|
14
|
-
// (实测:dsh-sidebar-footer-stack 的 3KB 手写 client.js 也是这个包裹)。
|
|
15
|
-
// 直接放 ESM 源码会被当经典脚本执行 → SyntaxError → client-modules 加载失败。
|
|
16
|
-
// 本文件**手写**这个包裹,不依赖 esbuild(本机没有 esbuild,也不需要)。
|
|
17
|
-
//
|
|
18
|
-
// ## 兼容性(本插件首要目标)
|
|
19
|
-
//
|
|
20
|
-
// client 跑在 sandbox 渲染进程里:sandbox:true / contextIsolation:true /
|
|
21
|
-
// nodeIntegration:false → **没有 process、读不了盘**。
|
|
22
|
-
// 所以一切数据都走 host 的 /api route(fetch),且**每一处都 try/catch**:
|
|
23
|
-
//
|
|
24
|
-
// - 拿不到 slots → 不注册设置分区,跳转仍工作
|
|
25
|
-
// - 拿不到 uiWorkspace → 不跳转,用户看到的是"窗口被唤到前台"(旧行为)
|
|
26
|
-
// - route 不可达 → 不跳转,不报错
|
|
27
|
-
//
|
|
28
|
-
// 任何一环失败都只是"少一个增强",绝不拖累页面。
|
|
29
|
-
window.__ModuleLoader__.load({
|
|
30
|
-
id: 'dsh-notify-say',
|
|
31
|
-
factory: (require) => {
|
|
32
|
-
var module = { exports: {} }
|
|
33
|
-
var exports = module.exports
|
|
34
|
-
|
|
35
|
-
var name = 'dsh-notify-say'
|
|
36
|
-
// 声明对 slots 的**硬依赖**,而不是在 apply 里"试一次拿不到就算了"。
|
|
37
|
-
//
|
|
38
|
-
// 为什么(独立审查 2026-10-17 指出):`slots` 由 dsh-client-ui-renderer 提供,
|
|
39
|
-
// 若本插件激活早于该服务生效,`ctx.get('slots', false)` 会拿到 undefined,
|
|
40
|
-
// 而原实现没有重试 → 设置页分区**永久不出现**,且不留任何日志。
|
|
41
|
-
// 写成 inject 后,宿主会等依赖就绪再激活,从根上避免这个竞态。
|
|
42
|
-
// 先例:dsh-our-free-model/client.js 的 `const inject = ['slots','locale']`。
|
|
43
|
-
var inject = ['slots']
|
|
44
|
-
|
|
45
|
-
/** host 注册的 route 前缀(与 lib/session-bridge.js 的 ROUTE_PREFIX 一致)。 */
|
|
46
|
-
var API_PREFIX = 'dsh-notify-say'
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* 用页面 base 解析 route,而不是 origin-absolute 的 `/api/...`。
|
|
50
|
-
* DSH 可能挂在反向代理的子路径下,写死 `/api/...` 会 404
|
|
51
|
-
* (本机先例 dsh-our-free-model 的注释明确记载了这个坑)。
|
|
52
|
-
*/
|
|
53
|
-
function apiUrl(path) {
|
|
54
|
-
try {
|
|
55
|
-
return new URL('api/' + API_PREFIX + '/' + path, document.baseURI).toString()
|
|
56
|
-
} catch (e) {
|
|
57
|
-
return '/api/' + API_PREFIX + '/' + path
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* 取「最近一条通知对应的会话」;消费即清,避免重复跳转。
|
|
63
|
-
*
|
|
64
|
-
* ⚠️ 返回的是**对象** `{ sessionId, at }`,不是裸字符串。
|
|
65
|
-
*
|
|
66
|
-
* 2026-10-18 外部审阅发现一个 P0 bug:这里原本 `return doc.sessionId`
|
|
67
|
-
* (字符串),但调用方写的是 `if (!doc || !doc.sessionId) return`。
|
|
68
|
-
* 字符串没有 `.sessionId` 属性 → 恒为 undefined → **直接 return**。
|
|
69
|
-
* 结果是会话跳转功能**从未生效过**,而且服务端信号已被 consume 清除,
|
|
70
|
-
* 连重试的机会都没有。
|
|
71
|
-
*
|
|
72
|
-
* 教训:这类"返回类型与调用方期望不符"的 bug,静态审阅一眼可见,
|
|
73
|
-
* 但没有任何单元测试覆盖到(因为两端各自都"看起来对")。
|
|
74
|
-
* 已加 C27 测试锁住返回形状。
|
|
75
|
-
*/
|
|
76
|
-
function consumeLastSession() {
|
|
77
|
-
// ⚠️ 必须是 POST + JSON:服务端把它当写操作(会清除信号),
|
|
78
|
-
// 而 http-guard 对写操作要求 JSON content-type —— 用 GET 会收到 403,
|
|
79
|
-
// 会话跳转随之失效(2026-10-18 实测回归,v0.4.25 修)。
|
|
80
|
-
return fetch(apiUrl('consume'), {
|
|
81
|
-
method: 'POST',
|
|
82
|
-
cache: 'no-store',
|
|
83
|
-
credentials: 'same-origin',
|
|
84
|
-
headers: { 'content-type': 'application/json' },
|
|
85
|
-
body: '{}',
|
|
86
|
-
})
|
|
87
|
-
.then(function (res) {
|
|
88
|
-
if (!res || !res.ok) return null
|
|
89
|
-
if (res.status === 204) return null
|
|
90
|
-
return res.json()
|
|
91
|
-
})
|
|
92
|
-
.then(function (doc) {
|
|
93
|
-
if (!doc || typeof doc.sessionId !== 'string' || !doc.sessionId) return null
|
|
94
|
-
return {
|
|
95
|
-
sessionId: doc.sessionId,
|
|
96
|
-
at: Number(doc.at) || 0,
|
|
97
|
-
}
|
|
98
|
-
})
|
|
99
|
-
.catch(function () { return null })
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* 跳到指定会话。**未文档化 API**,拿不到就静默不动(退回"只唤窗")。
|
|
104
|
-
*/
|
|
105
|
-
// ── 语音包管理 API(v0.4.32)──────────────────────────────────────
|
|
106
|
-
//
|
|
107
|
-
// **只传音色 key,不传 URL** —— 下载地址由 host 从固定源算,
|
|
108
|
-
// 前端传 URL 就等于让本机访问任意地址(SSRF)。
|
|
109
|
-
function fetchVoicePacks() {
|
|
110
|
-
return fetch(apiUrl('voicepacks/index'), { cache: 'no-store', credentials: 'same-origin' })
|
|
111
|
-
.then(function (r) { return r && r.ok ? r.json() : null })
|
|
112
|
-
.catch(function () { return null })
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function installVoicePack(key) {
|
|
116
|
-
return fetch(apiUrl('voicepacks/install'), {
|
|
117
|
-
method: 'POST', cache: 'no-store', credentials: 'same-origin',
|
|
118
|
-
headers: { 'content-type': 'application/json' },
|
|
119
|
-
body: JSON.stringify({ key: key }),
|
|
120
|
-
}).then(function (r) { return r ? r.json() : null }).catch(function () { return null })
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function uninstallVoicePack(key) {
|
|
124
|
-
return fetch(apiUrl('voicepacks/uninstall'), {
|
|
125
|
-
method: 'POST', cache: 'no-store', credentials: 'same-origin',
|
|
126
|
-
headers: { 'content-type': 'application/json' },
|
|
127
|
-
body: JSON.stringify({ key: key }),
|
|
128
|
-
}).then(function (r) { return r ? r.json() : null }).catch(function () { return null })
|
|
129
|
-
}
|
|
130
|
-
// 导出诊断(v0.4.35):宿主读日志 → 脱敏 → 写文件,返回路径。
|
|
131
|
-
// 用 POST:它会写文件,GET 可被任意网页用 <img> 触发。
|
|
132
|
-
function exportDiagnostics() {
|
|
133
|
-
return fetch(apiUrl('diagnostics'), {
|
|
134
|
-
method: 'POST', cache: 'no-store', credentials: 'same-origin',
|
|
135
|
-
headers: { 'content-type': 'application/json' },
|
|
136
|
-
body: '{}',
|
|
137
|
-
}).then(function (r) { return r ? r.json() : null }).catch(function () { return null })
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function openSession(ctx, sessionId) {
|
|
141
|
-
try {
|
|
142
|
-
if (!sessionId) return false
|
|
143
|
-
var ui = null
|
|
144
|
-
try { ui = ctx.get('uiWorkspace', false) } catch (e) { ui = null }
|
|
145
|
-
if (!ui || typeof ui.openSession !== 'function') return false
|
|
146
|
-
ui.openSession(sessionId)
|
|
147
|
-
return true
|
|
148
|
-
} catch (e) {
|
|
149
|
-
return false
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ── 1) 设置页分区 ──────────────────────────────────────────────────────
|
|
154
|
-
//
|
|
155
|
-
// ⚠️ 必须返回 **React 元素**,不能返回原生 DOM。
|
|
156
|
-
// v0.4.1 首版用 document.createElement 造节点,结果设置页点进去**一片空白**
|
|
157
|
-
// —— React 不认识原生 DOM 节点,不会挂载它(实测现象)。
|
|
158
|
-
// React 从 factory 的 require 取(本机先例 dsh-save-money:16 即 require('react'))。
|
|
159
|
-
//
|
|
160
|
-
// 内容:官方 Config 表单负责**编辑**;本卡片只做只读说明 + 常用命令提示。
|
|
161
|
-
// 这样即使 slots 渲染接口变化,损失也仅是一张说明卡。
|
|
162
|
-
var React = null
|
|
163
|
-
try { React = require('react') } catch (e) { React = null }
|
|
164
|
-
|
|
165
|
-
/** 极简样式(内联):不依赖任何 CSS 模块(哈希会随构建漂)。 */
|
|
166
|
-
var S = {
|
|
167
|
-
box: { padding: '12px', fontSize: '13px', lineHeight: '1.8' },
|
|
168
|
-
title: { fontWeight: 600, marginBottom: '8px' },
|
|
169
|
-
dim: { opacity: 0.75 },
|
|
170
|
-
gap: { opacity: 0.75, marginTop: '6px' },
|
|
171
|
-
code: { fontFamily: 'ui-monospace,Menlo,Consolas,monospace', fontSize: '12px' },
|
|
172
|
-
row: { display: 'flex', alignItems: 'center', gap: '8px', margin: '6px 0' },
|
|
173
|
-
label: { minWidth: '86px' },
|
|
174
|
-
hint: { opacity: 0.6, fontSize: '12px', marginLeft: '6px' },
|
|
175
|
-
err: { color: '#c0392b', fontSize: '12px', marginTop: '6px' },
|
|
176
|
-
ok: { color: '#1e8449', fontSize: '12px', marginTop: '6px' },
|
|
177
|
-
foot: { opacity: 0.6, marginTop: '10px', fontSize: '12px' },
|
|
178
|
-
// 事件开关:勾选框 + 可换行的说明文字
|
|
179
|
-
toggleRow: { display: 'flex', alignItems: 'flex-start', gap: '8px', margin: '8px 0' },
|
|
180
|
-
toggleBox: { marginTop: '3px', flex: '0 0 auto' },
|
|
181
|
-
toggleText: { flex: '1', lineHeight: '1.5' },
|
|
182
|
-
toggleLabel: { fontWeight: 500 },
|
|
183
|
-
toggleDesc: { opacity: 0.62, fontSize: '12px' },
|
|
184
|
-
// 音源说明(弱化、单独一行)
|
|
185
|
-
subHint: { opacity: 0.62, fontSize: '12px', margin: '2px 0 8px' },
|
|
186
|
-
btn: {
|
|
187
|
-
padding: '4px 10px', fontSize: '12px', cursor: 'pointer',
|
|
188
|
-
border: '1px solid rgba(128,128,128,.45)', borderRadius: '4px',
|
|
189
|
-
background: 'transparent', color: 'inherit',
|
|
190
|
-
},
|
|
191
|
-
// 高级折叠区
|
|
192
|
-
advHead: { margin: '14px 0 4px' },
|
|
193
|
-
advBtn: {
|
|
194
|
-
padding: '2px 0', fontSize: '13px', cursor: 'pointer',
|
|
195
|
-
border: 'none', background: 'transparent', color: 'inherit', fontWeight: 600,
|
|
196
|
-
},
|
|
197
|
-
// 台词输入框:与下拉同样的边框风格,占满剩余宽度
|
|
198
|
-
textInput: {
|
|
199
|
-
flex: '1', minWidth: '120px', padding: '3px 8px', fontSize: '13px',
|
|
200
|
-
border: '1px solid rgba(128,128,128,.45)', borderRadius: '4px',
|
|
201
|
-
background: 'transparent', color: 'inherit',
|
|
202
|
-
},
|
|
203
|
-
// 高级区里每行的「子设置」容器:左侧留出缩进,表明从属于上一行
|
|
204
|
-
subBox: {
|
|
205
|
-
margin: '0 0 8px 94px', padding: '6px 10px',
|
|
206
|
-
borderLeft: '2px solid rgba(128,128,128,.28)',
|
|
207
|
-
},
|
|
208
|
-
subRow: { display: 'flex', alignItems: 'center', gap: '8px', margin: '4px 0' },
|
|
209
|
-
subLabel: { minWidth: '64px', fontSize: '12px', opacity: 0.82 },
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** 音源 id → 显示名("跟随默认(xxx)"里要用)。 */
|
|
213
|
-
var SRC_NAME = {
|
|
214
|
-
tts: '系统语音合成',
|
|
215
|
-
bundled: '语音包',
|
|
216
|
-
system: '系统提示音',
|
|
217
|
-
custom: '我的音频',
|
|
218
|
-
none: '不出声',
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// ── 配置读写(走 host route)───────────────────────────────────────────
|
|
222
|
-
//
|
|
223
|
-
// 为什么不走官方 Config 表单:本插件以 bundle 形态挂载时,
|
|
224
|
-
// `volatileForm` 生成的表单出现在「插件配置编辑器」里,而不是设置页分区
|
|
225
|
-
// (实测:分区里只有我们自己画的卡)。所以卡里自带控件,经 host route 落盘到
|
|
226
|
-
// `$DSH_HOME/storages/dsh-notify-say/config.json`,插件每轮播报前重读 →
|
|
227
|
-
// **改完立即生效,不用重启**。
|
|
228
|
-
function cfgUrl(path) {
|
|
229
|
-
try { return new URL('api/' + API_PREFIX + '/' + path, document.baseURI).toString() }
|
|
230
|
-
catch (e) { return '/api/' + API_PREFIX + '/' + path }
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* 弹出系统文件选择器,拿到**真实绝对路径**。
|
|
235
|
-
*
|
|
236
|
-
* ## 为什么不能只用 `<input type=file>`
|
|
237
|
-
*
|
|
238
|
-
* 浏览器安全限制下,`<input type=file>` 的 File 对象**拿不到绝对路径**
|
|
239
|
-
* (只有 name/size/内容)。而我们的播放进程是按路径读文件的,必须拿真路径。
|
|
240
|
-
*
|
|
241
|
-
* ## 官方给的解法
|
|
242
|
-
*
|
|
243
|
-
* 宿主通过 contextBridge 暴露了 `__DSH_HOST_PATHS__.pathFor(file)`,
|
|
244
|
-
* 底层是 Electron 的 `webUtils.getPathForFile`(app.asar 实证)。
|
|
245
|
-
* 这是**正路**,本机桌面壳就是 `dsh-app://app`,能访问到。
|
|
246
|
-
*
|
|
247
|
-
* ## 降级
|
|
248
|
-
*
|
|
249
|
-
* 拿不到 `__DSH_HOST_PATHS__` 时(例如非桌面壳环境),退回只显示文件名并提示
|
|
250
|
-
* 手动填路径 —— 绝不让按钮"点了没反应"。
|
|
251
|
-
*
|
|
252
|
-
* @returns {Promise<string>} 绝对路径;取消或失败返回空串
|
|
253
|
-
*/
|
|
254
|
-
function pickCustomFile() {
|
|
255
|
-
return new Promise(function (resolve) {
|
|
256
|
-
try {
|
|
257
|
-
var input = document.createElement('input')
|
|
258
|
-
input.type = 'file'
|
|
259
|
-
input.accept = 'audio/*,.wav,.mp3,.m4a,.flac,.ogg'
|
|
260
|
-
input.style.display = 'none'
|
|
261
|
-
document.body.appendChild(input)
|
|
262
|
-
|
|
263
|
-
var done = function (val) {
|
|
264
|
-
try { input.remove() } catch (e) { /* ignore */ }
|
|
265
|
-
resolve(val || '')
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
input.addEventListener('change', function () {
|
|
269
|
-
try {
|
|
270
|
-
var f = input.files && input.files[0]
|
|
271
|
-
if (!f) return done('')
|
|
272
|
-
var host = globalThis.__DSH_HOST_PATHS__
|
|
273
|
-
if (host && typeof host.pathFor === 'function') {
|
|
274
|
-
var p = host.pathFor(f)
|
|
275
|
-
if (typeof p === 'string' && p) return done(p)
|
|
276
|
-
}
|
|
277
|
-
// 拿不到真路径:不静默失败,明确告诉用户
|
|
278
|
-
try {
|
|
279
|
-
console.warn('[dsh-notify-say] 无法取得文件绝对路径,请手动在配置里填 customFile')
|
|
280
|
-
} catch (e) { /* ignore */ }
|
|
281
|
-
return done('')
|
|
282
|
-
} catch (e) { return done('') }
|
|
283
|
-
})
|
|
284
|
-
input.click()
|
|
285
|
-
} catch (e) {
|
|
286
|
-
resolve('')
|
|
287
|
-
}
|
|
288
|
-
})
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function loadConfig() {
|
|
292
|
-
return fetch(cfgUrl('config'), { cache: 'no-store', credentials: 'same-origin' })
|
|
293
|
-
.then(function (r) { return r && r.ok ? r.json() : null })
|
|
294
|
-
.then(function (d) { return d && d.values ? d.values : {} })
|
|
295
|
-
.catch(function () { return {} })
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function saveConfig(patch) {
|
|
299
|
-
return fetch(cfgUrl('config/set'), {
|
|
300
|
-
method: 'POST',
|
|
301
|
-
cache: 'no-store',
|
|
302
|
-
credentials: 'same-origin',
|
|
303
|
-
headers: { 'content-type': 'application/json' },
|
|
304
|
-
body: JSON.stringify({ values: patch }),
|
|
305
|
-
})
|
|
306
|
-
.then(function (r) { return r ? r.json().catch(function () { return null }) : null })
|
|
307
|
-
.catch(function () { return null })
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* 试听:**只出声,不弹窗**(v0.4.17)。
|
|
312
|
-
*
|
|
313
|
-
* 为什么需要:音量增益 / 发音人 / 音色 / 自定义音频这些设置,
|
|
314
|
-
* 光看数字没法判断效果,只能盲调。有了试听就能边调边听。
|
|
315
|
-
*
|
|
316
|
-
* 与「测试通知」的区别:
|
|
317
|
-
* · 试听 → 只听声音(ps1 的 -Preview,无 Toast)
|
|
318
|
-
* · 测试通知 → 完整走一遍 notify(),含 Toast 弹窗
|
|
319
|
-
*
|
|
320
|
-
* @param {string} kind done|fail|question|approval|blocked|chime
|
|
321
|
-
* @returns {Promise<object|null>} host 返回 { ok, kind, source, gain }
|
|
322
|
-
*/
|
|
323
|
-
function previewSound(kind) {
|
|
324
|
-
var k = kind || 'done'
|
|
325
|
-
return fetch(cfgUrl('preview?kind=' + encodeURIComponent(k)), {
|
|
326
|
-
cache: 'no-store',
|
|
327
|
-
credentials: 'same-origin',
|
|
328
|
-
})
|
|
329
|
-
.then(function (r) { return r ? r.json().catch(function () { return null }) : null })
|
|
330
|
-
.catch(function () { return null })
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/** 音色候选(与 sounds/voice-lines 下的基础音色一致;可手输其他 key)。 */
|
|
334
|
-
// 可下载/可选的音色清单(**只列审核通过的**)。
|
|
335
|
-
//
|
|
336
|
-
// ⚠️ 2026-10-18:移除 kasukabe-tsumugi 与 mochiko ——
|
|
337
|
-
// · 春日部つむぎ:官方规约第 6 条禁止「二次配布」
|
|
338
|
-
// · もち子さん:官方规约禁止「音声素材を無償配布」
|
|
339
|
-
// 审核结论见 docs/audio-audit/README.md。
|
|
340
|
-
//
|
|
341
|
-
// 注意:这个列表只影响 GUI 下拉里的候选项。真正的分发拦截在
|
|
342
|
-
// release-build.ps1 的 $NeverShipVoicePrefixes(构建期硬阻断),
|
|
343
|
-
// 两处都要维护,但**构建期那道是安全网**。
|
|
344
|
-
var VOICE_KEYS = [
|
|
345
|
-
'zundamon-normal', 'zundamon-amama', 'ankomon', 'goki-nuigurumi',
|
|
346
|
-
'kyushu-sora', 'manbetsu-hanamaru-genki', 'meimei-meria',
|
|
347
|
-
'shikoku-metang', 'ameharu-hau', 'namiine-ritsu', 'shirakami-kotaro',
|
|
348
|
-
]
|
|
349
|
-
|
|
350
|
-
/** 事件开关定义(键 → 中文名)。 */
|
|
351
|
-
// 事件开关:[键, 名称, 一句话说明]。
|
|
352
|
-
//
|
|
353
|
-
// 说明文案是给**不了解内部术语**的人看的:
|
|
354
|
-
// - "空闲门" 是内部叫法,必须在界面上解释清楚它在防什么;
|
|
355
|
-
// - 其余各项也用"什么时候会响"的口语描述,而不是复述配置键的含义。
|
|
356
|
-
var TOGGLES = [
|
|
357
|
-
['notifyOnComplete', '回合完成',
|
|
358
|
-
'这一轮正常跑完时提醒你'],
|
|
359
|
-
['notifyOnError', '回合出错',
|
|
360
|
-
'这一轮以错误结束时提醒(会播日语道歉语音)'],
|
|
361
|
-
['notifyOnBlocked', '被拦截',
|
|
362
|
-
'这一轮还没开始就被拦下时提醒(例如钩子拒绝了指令)'],
|
|
363
|
-
['notifyOnAborted', '已中止',
|
|
364
|
-
'你手动按停止、或超出长度限制时提醒'],
|
|
365
|
-
['notifyOnApproval', '待批准',
|
|
366
|
-
'AI 要做敏感操作、等你点同意时提醒'],
|
|
367
|
-
['notifyOnQuestion', '要提问',
|
|
368
|
-
'AI 有问题要问你、停下来等你回答时提醒'],
|
|
369
|
-
['subagents', '子代理也提醒',
|
|
370
|
-
'连子代理自己的每一轮结束也提醒;默认只提醒你直接对话的那个会话'],
|
|
371
|
-
['idleGate', '等真正干完再提醒',
|
|
372
|
-
'别被半路打断的"完成"骗了:AI 派出去的子任务还在跑时先不响,'
|
|
373
|
-
+ '等全部结束再响一次。关掉的话,一结束就响(即使还有子任务在跑)'],
|
|
374
|
-
]
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* 设置卡片:**真控件**。
|
|
378
|
-
*
|
|
379
|
-
* 状态全部来自 host route;每次改动立刻 POST,成功后用返回的合并值刷新本地态。
|
|
380
|
-
* 任何一步失败只显示一行错误,不让整卡崩掉。
|
|
381
|
-
*/
|
|
382
|
-
function SettingsCard() {
|
|
383
|
-
if (!React) return null
|
|
384
|
-
var h = React.createElement
|
|
385
|
-
// 初值只是"占位",让控件首屏就有内容、可交互(避免闪一屏"读取中")。
|
|
386
|
-
// ⚠️ 真实值必须以 host 返回的**生效值**为准(见下方 useEffect 的 merge)。
|
|
387
|
-
// 这些占位数字**不代表实际配置**:例如 patch 里 volume 可能是 100,
|
|
388
|
-
// 这里写 80 只是防 undefined;生效值到达后会被覆盖。
|
|
389
|
-
var st = React.useState({
|
|
390
|
-
loaded: false,
|
|
391
|
-
values: {
|
|
392
|
-
voice: true, voiceMode: 'fixed', voiceName: 'zundamon-normal', volume: 80,
|
|
393
|
-
notifyOnComplete: true, notifyOnError: true, notifyOnBlocked: true,
|
|
394
|
-
notifyOnAborted: false, notifyOnApproval: true, notifyOnQuestion: true,
|
|
395
|
-
subagents: false, idleGate: true,
|
|
396
|
-
},
|
|
397
|
-
busy: false, msg: '', err: '',
|
|
398
|
-
// 语音包清单:null = 还没加载过(用户点按钮才加载)
|
|
399
|
-
packs: { loading: false, list: null, error: null },
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
busy: false,
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
})
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
h('
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
))
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
))
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
h('
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
h('
|
|
958
|
-
h('
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
//
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
},
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
var
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
h('
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
})
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
}
|
|
1680
|
-
if (
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1
|
+
// dsh-notify-say browser half。
|
|
2
|
+
//
|
|
3
|
+
// ## 职责(只有两件)
|
|
4
|
+
//
|
|
5
|
+
// 1. 在 DSH 设置页注册「通知」分区(slots.inject('settings.section'))
|
|
6
|
+
// —— Config schema 只生成「插件配置编辑器」里的表单,**不会**在设置侧栏加分区;
|
|
7
|
+
// 侧栏分区必须由 client 注册。本机先例:dsh-opencode-go / dsh-save-money /
|
|
8
|
+
// dsh-cost-meter / dsh-our-free-model 全都这么做。
|
|
9
|
+
// 2. 点 Toast 后跳到「那条通知对应的会话」。
|
|
10
|
+
//
|
|
11
|
+
// ## 为什么必须是这种形态
|
|
12
|
+
//
|
|
13
|
+
// 宿主契约要求 client 产物自行注册 `window.__ModuleLoader__.load({id, factory})`
|
|
14
|
+
// (实测:dsh-sidebar-footer-stack 的 3KB 手写 client.js 也是这个包裹)。
|
|
15
|
+
// 直接放 ESM 源码会被当经典脚本执行 → SyntaxError → client-modules 加载失败。
|
|
16
|
+
// 本文件**手写**这个包裹,不依赖 esbuild(本机没有 esbuild,也不需要)。
|
|
17
|
+
//
|
|
18
|
+
// ## 兼容性(本插件首要目标)
|
|
19
|
+
//
|
|
20
|
+
// client 跑在 sandbox 渲染进程里:sandbox:true / contextIsolation:true /
|
|
21
|
+
// nodeIntegration:false → **没有 process、读不了盘**。
|
|
22
|
+
// 所以一切数据都走 host 的 /api route(fetch),且**每一处都 try/catch**:
|
|
23
|
+
//
|
|
24
|
+
// - 拿不到 slots → 不注册设置分区,跳转仍工作
|
|
25
|
+
// - 拿不到 uiWorkspace → 不跳转,用户看到的是"窗口被唤到前台"(旧行为)
|
|
26
|
+
// - route 不可达 → 不跳转,不报错
|
|
27
|
+
//
|
|
28
|
+
// 任何一环失败都只是"少一个增强",绝不拖累页面。
|
|
29
|
+
window.__ModuleLoader__.load({
|
|
30
|
+
id: 'dsh-notify-say',
|
|
31
|
+
factory: (require) => {
|
|
32
|
+
var module = { exports: {} }
|
|
33
|
+
var exports = module.exports
|
|
34
|
+
|
|
35
|
+
var name = 'dsh-notify-say'
|
|
36
|
+
// 声明对 slots 的**硬依赖**,而不是在 apply 里"试一次拿不到就算了"。
|
|
37
|
+
//
|
|
38
|
+
// 为什么(独立审查 2026-10-17 指出):`slots` 由 dsh-client-ui-renderer 提供,
|
|
39
|
+
// 若本插件激活早于该服务生效,`ctx.get('slots', false)` 会拿到 undefined,
|
|
40
|
+
// 而原实现没有重试 → 设置页分区**永久不出现**,且不留任何日志。
|
|
41
|
+
// 写成 inject 后,宿主会等依赖就绪再激活,从根上避免这个竞态。
|
|
42
|
+
// 先例:dsh-our-free-model/client.js 的 `const inject = ['slots','locale']`。
|
|
43
|
+
var inject = ['slots']
|
|
44
|
+
|
|
45
|
+
/** host 注册的 route 前缀(与 lib/session-bridge.js 的 ROUTE_PREFIX 一致)。 */
|
|
46
|
+
var API_PREFIX = 'dsh-notify-say'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 用页面 base 解析 route,而不是 origin-absolute 的 `/api/...`。
|
|
50
|
+
* DSH 可能挂在反向代理的子路径下,写死 `/api/...` 会 404
|
|
51
|
+
* (本机先例 dsh-our-free-model 的注释明确记载了这个坑)。
|
|
52
|
+
*/
|
|
53
|
+
function apiUrl(path) {
|
|
54
|
+
try {
|
|
55
|
+
return new URL('api/' + API_PREFIX + '/' + path, document.baseURI).toString()
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return '/api/' + API_PREFIX + '/' + path
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 取「最近一条通知对应的会话」;消费即清,避免重复跳转。
|
|
63
|
+
*
|
|
64
|
+
* ⚠️ 返回的是**对象** `{ sessionId, at }`,不是裸字符串。
|
|
65
|
+
*
|
|
66
|
+
* 2026-10-18 外部审阅发现一个 P0 bug:这里原本 `return doc.sessionId`
|
|
67
|
+
* (字符串),但调用方写的是 `if (!doc || !doc.sessionId) return`。
|
|
68
|
+
* 字符串没有 `.sessionId` 属性 → 恒为 undefined → **直接 return**。
|
|
69
|
+
* 结果是会话跳转功能**从未生效过**,而且服务端信号已被 consume 清除,
|
|
70
|
+
* 连重试的机会都没有。
|
|
71
|
+
*
|
|
72
|
+
* 教训:这类"返回类型与调用方期望不符"的 bug,静态审阅一眼可见,
|
|
73
|
+
* 但没有任何单元测试覆盖到(因为两端各自都"看起来对")。
|
|
74
|
+
* 已加 C27 测试锁住返回形状。
|
|
75
|
+
*/
|
|
76
|
+
function consumeLastSession() {
|
|
77
|
+
// ⚠️ 必须是 POST + JSON:服务端把它当写操作(会清除信号),
|
|
78
|
+
// 而 http-guard 对写操作要求 JSON content-type —— 用 GET 会收到 403,
|
|
79
|
+
// 会话跳转随之失效(2026-10-18 实测回归,v0.4.25 修)。
|
|
80
|
+
return fetch(apiUrl('consume'), {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
cache: 'no-store',
|
|
83
|
+
credentials: 'same-origin',
|
|
84
|
+
headers: { 'content-type': 'application/json' },
|
|
85
|
+
body: '{}',
|
|
86
|
+
})
|
|
87
|
+
.then(function (res) {
|
|
88
|
+
if (!res || !res.ok) return null
|
|
89
|
+
if (res.status === 204) return null
|
|
90
|
+
return res.json()
|
|
91
|
+
})
|
|
92
|
+
.then(function (doc) {
|
|
93
|
+
if (!doc || typeof doc.sessionId !== 'string' || !doc.sessionId) return null
|
|
94
|
+
return {
|
|
95
|
+
sessionId: doc.sessionId,
|
|
96
|
+
at: Number(doc.at) || 0,
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
.catch(function () { return null })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 跳到指定会话。**未文档化 API**,拿不到就静默不动(退回"只唤窗")。
|
|
104
|
+
*/
|
|
105
|
+
// ── 语音包管理 API(v0.4.32)──────────────────────────────────────
|
|
106
|
+
//
|
|
107
|
+
// **只传音色 key,不传 URL** —— 下载地址由 host 从固定源算,
|
|
108
|
+
// 前端传 URL 就等于让本机访问任意地址(SSRF)。
|
|
109
|
+
function fetchVoicePacks() {
|
|
110
|
+
return fetch(apiUrl('voicepacks/index'), { cache: 'no-store', credentials: 'same-origin' })
|
|
111
|
+
.then(function (r) { return r && r.ok ? r.json() : null })
|
|
112
|
+
.catch(function () { return null })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function installVoicePack(key) {
|
|
116
|
+
return fetch(apiUrl('voicepacks/install'), {
|
|
117
|
+
method: 'POST', cache: 'no-store', credentials: 'same-origin',
|
|
118
|
+
headers: { 'content-type': 'application/json' },
|
|
119
|
+
body: JSON.stringify({ key: key }),
|
|
120
|
+
}).then(function (r) { return r ? r.json() : null }).catch(function () { return null })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function uninstallVoicePack(key) {
|
|
124
|
+
return fetch(apiUrl('voicepacks/uninstall'), {
|
|
125
|
+
method: 'POST', cache: 'no-store', credentials: 'same-origin',
|
|
126
|
+
headers: { 'content-type': 'application/json' },
|
|
127
|
+
body: JSON.stringify({ key: key }),
|
|
128
|
+
}).then(function (r) { return r ? r.json() : null }).catch(function () { return null })
|
|
129
|
+
}
|
|
130
|
+
// 导出诊断(v0.4.35):宿主读日志 → 脱敏 → 写文件,返回路径。
|
|
131
|
+
// 用 POST:它会写文件,GET 可被任意网页用 <img> 触发。
|
|
132
|
+
function exportDiagnostics() {
|
|
133
|
+
return fetch(apiUrl('diagnostics'), {
|
|
134
|
+
method: 'POST', cache: 'no-store', credentials: 'same-origin',
|
|
135
|
+
headers: { 'content-type': 'application/json' },
|
|
136
|
+
body: '{}',
|
|
137
|
+
}).then(function (r) { return r ? r.json() : null }).catch(function () { return null })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function openSession(ctx, sessionId) {
|
|
141
|
+
try {
|
|
142
|
+
if (!sessionId) return false
|
|
143
|
+
var ui = null
|
|
144
|
+
try { ui = ctx.get('uiWorkspace', false) } catch (e) { ui = null }
|
|
145
|
+
if (!ui || typeof ui.openSession !== 'function') return false
|
|
146
|
+
ui.openSession(sessionId)
|
|
147
|
+
return true
|
|
148
|
+
} catch (e) {
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── 1) 设置页分区 ──────────────────────────────────────────────────────
|
|
154
|
+
//
|
|
155
|
+
// ⚠️ 必须返回 **React 元素**,不能返回原生 DOM。
|
|
156
|
+
// v0.4.1 首版用 document.createElement 造节点,结果设置页点进去**一片空白**
|
|
157
|
+
// —— React 不认识原生 DOM 节点,不会挂载它(实测现象)。
|
|
158
|
+
// React 从 factory 的 require 取(本机先例 dsh-save-money:16 即 require('react'))。
|
|
159
|
+
//
|
|
160
|
+
// 内容:官方 Config 表单负责**编辑**;本卡片只做只读说明 + 常用命令提示。
|
|
161
|
+
// 这样即使 slots 渲染接口变化,损失也仅是一张说明卡。
|
|
162
|
+
var React = null
|
|
163
|
+
try { React = require('react') } catch (e) { React = null }
|
|
164
|
+
|
|
165
|
+
/** 极简样式(内联):不依赖任何 CSS 模块(哈希会随构建漂)。 */
|
|
166
|
+
var S = {
|
|
167
|
+
box: { padding: '12px', fontSize: '13px', lineHeight: '1.8' },
|
|
168
|
+
title: { fontWeight: 600, marginBottom: '8px' },
|
|
169
|
+
dim: { opacity: 0.75 },
|
|
170
|
+
gap: { opacity: 0.75, marginTop: '6px' },
|
|
171
|
+
code: { fontFamily: 'ui-monospace,Menlo,Consolas,monospace', fontSize: '12px' },
|
|
172
|
+
row: { display: 'flex', alignItems: 'center', gap: '8px', margin: '6px 0' },
|
|
173
|
+
label: { minWidth: '86px' },
|
|
174
|
+
hint: { opacity: 0.6, fontSize: '12px', marginLeft: '6px' },
|
|
175
|
+
err: { color: '#c0392b', fontSize: '12px', marginTop: '6px' },
|
|
176
|
+
ok: { color: '#1e8449', fontSize: '12px', marginTop: '6px' },
|
|
177
|
+
foot: { opacity: 0.6, marginTop: '10px', fontSize: '12px' },
|
|
178
|
+
// 事件开关:勾选框 + 可换行的说明文字
|
|
179
|
+
toggleRow: { display: 'flex', alignItems: 'flex-start', gap: '8px', margin: '8px 0' },
|
|
180
|
+
toggleBox: { marginTop: '3px', flex: '0 0 auto' },
|
|
181
|
+
toggleText: { flex: '1', lineHeight: '1.5' },
|
|
182
|
+
toggleLabel: { fontWeight: 500 },
|
|
183
|
+
toggleDesc: { opacity: 0.62, fontSize: '12px' },
|
|
184
|
+
// 音源说明(弱化、单独一行)
|
|
185
|
+
subHint: { opacity: 0.62, fontSize: '12px', margin: '2px 0 8px' },
|
|
186
|
+
btn: {
|
|
187
|
+
padding: '4px 10px', fontSize: '12px', cursor: 'pointer',
|
|
188
|
+
border: '1px solid rgba(128,128,128,.45)', borderRadius: '4px',
|
|
189
|
+
background: 'transparent', color: 'inherit',
|
|
190
|
+
},
|
|
191
|
+
// 高级折叠区
|
|
192
|
+
advHead: { margin: '14px 0 4px' },
|
|
193
|
+
advBtn: {
|
|
194
|
+
padding: '2px 0', fontSize: '13px', cursor: 'pointer',
|
|
195
|
+
border: 'none', background: 'transparent', color: 'inherit', fontWeight: 600,
|
|
196
|
+
},
|
|
197
|
+
// 台词输入框:与下拉同样的边框风格,占满剩余宽度
|
|
198
|
+
textInput: {
|
|
199
|
+
flex: '1', minWidth: '120px', padding: '3px 8px', fontSize: '13px',
|
|
200
|
+
border: '1px solid rgba(128,128,128,.45)', borderRadius: '4px',
|
|
201
|
+
background: 'transparent', color: 'inherit',
|
|
202
|
+
},
|
|
203
|
+
// 高级区里每行的「子设置」容器:左侧留出缩进,表明从属于上一行
|
|
204
|
+
subBox: {
|
|
205
|
+
margin: '0 0 8px 94px', padding: '6px 10px',
|
|
206
|
+
borderLeft: '2px solid rgba(128,128,128,.28)',
|
|
207
|
+
},
|
|
208
|
+
subRow: { display: 'flex', alignItems: 'center', gap: '8px', margin: '4px 0' },
|
|
209
|
+
subLabel: { minWidth: '64px', fontSize: '12px', opacity: 0.82 },
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 音源 id → 显示名("跟随默认(xxx)"里要用)。 */
|
|
213
|
+
var SRC_NAME = {
|
|
214
|
+
tts: '系统语音合成',
|
|
215
|
+
bundled: '语音包',
|
|
216
|
+
system: '系统提示音',
|
|
217
|
+
custom: '我的音频',
|
|
218
|
+
none: '不出声',
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── 配置读写(走 host route)───────────────────────────────────────────
|
|
222
|
+
//
|
|
223
|
+
// 为什么不走官方 Config 表单:本插件以 bundle 形态挂载时,
|
|
224
|
+
// `volatileForm` 生成的表单出现在「插件配置编辑器」里,而不是设置页分区
|
|
225
|
+
// (实测:分区里只有我们自己画的卡)。所以卡里自带控件,经 host route 落盘到
|
|
226
|
+
// `$DSH_HOME/storages/dsh-notify-say/config.json`,插件每轮播报前重读 →
|
|
227
|
+
// **改完立即生效,不用重启**。
|
|
228
|
+
function cfgUrl(path) {
|
|
229
|
+
try { return new URL('api/' + API_PREFIX + '/' + path, document.baseURI).toString() }
|
|
230
|
+
catch (e) { return '/api/' + API_PREFIX + '/' + path }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 弹出系统文件选择器,拿到**真实绝对路径**。
|
|
235
|
+
*
|
|
236
|
+
* ## 为什么不能只用 `<input type=file>`
|
|
237
|
+
*
|
|
238
|
+
* 浏览器安全限制下,`<input type=file>` 的 File 对象**拿不到绝对路径**
|
|
239
|
+
* (只有 name/size/内容)。而我们的播放进程是按路径读文件的,必须拿真路径。
|
|
240
|
+
*
|
|
241
|
+
* ## 官方给的解法
|
|
242
|
+
*
|
|
243
|
+
* 宿主通过 contextBridge 暴露了 `__DSH_HOST_PATHS__.pathFor(file)`,
|
|
244
|
+
* 底层是 Electron 的 `webUtils.getPathForFile`(app.asar 实证)。
|
|
245
|
+
* 这是**正路**,本机桌面壳就是 `dsh-app://app`,能访问到。
|
|
246
|
+
*
|
|
247
|
+
* ## 降级
|
|
248
|
+
*
|
|
249
|
+
* 拿不到 `__DSH_HOST_PATHS__` 时(例如非桌面壳环境),退回只显示文件名并提示
|
|
250
|
+
* 手动填路径 —— 绝不让按钮"点了没反应"。
|
|
251
|
+
*
|
|
252
|
+
* @returns {Promise<string>} 绝对路径;取消或失败返回空串
|
|
253
|
+
*/
|
|
254
|
+
function pickCustomFile() {
|
|
255
|
+
return new Promise(function (resolve) {
|
|
256
|
+
try {
|
|
257
|
+
var input = document.createElement('input')
|
|
258
|
+
input.type = 'file'
|
|
259
|
+
input.accept = 'audio/*,.wav,.mp3,.m4a,.flac,.ogg'
|
|
260
|
+
input.style.display = 'none'
|
|
261
|
+
document.body.appendChild(input)
|
|
262
|
+
|
|
263
|
+
var done = function (val) {
|
|
264
|
+
try { input.remove() } catch (e) { /* ignore */ }
|
|
265
|
+
resolve(val || '')
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
input.addEventListener('change', function () {
|
|
269
|
+
try {
|
|
270
|
+
var f = input.files && input.files[0]
|
|
271
|
+
if (!f) return done('')
|
|
272
|
+
var host = globalThis.__DSH_HOST_PATHS__
|
|
273
|
+
if (host && typeof host.pathFor === 'function') {
|
|
274
|
+
var p = host.pathFor(f)
|
|
275
|
+
if (typeof p === 'string' && p) return done(p)
|
|
276
|
+
}
|
|
277
|
+
// 拿不到真路径:不静默失败,明确告诉用户
|
|
278
|
+
try {
|
|
279
|
+
console.warn('[dsh-notify-say] 无法取得文件绝对路径,请手动在配置里填 customFile')
|
|
280
|
+
} catch (e) { /* ignore */ }
|
|
281
|
+
return done('')
|
|
282
|
+
} catch (e) { return done('') }
|
|
283
|
+
})
|
|
284
|
+
input.click()
|
|
285
|
+
} catch (e) {
|
|
286
|
+
resolve('')
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function loadConfig() {
|
|
292
|
+
return fetch(cfgUrl('config'), { cache: 'no-store', credentials: 'same-origin' })
|
|
293
|
+
.then(function (r) { return r && r.ok ? r.json() : null })
|
|
294
|
+
.then(function (d) { return d && d.values ? d.values : {} })
|
|
295
|
+
.catch(function () { return {} })
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function saveConfig(patch) {
|
|
299
|
+
return fetch(cfgUrl('config/set'), {
|
|
300
|
+
method: 'POST',
|
|
301
|
+
cache: 'no-store',
|
|
302
|
+
credentials: 'same-origin',
|
|
303
|
+
headers: { 'content-type': 'application/json' },
|
|
304
|
+
body: JSON.stringify({ values: patch }),
|
|
305
|
+
})
|
|
306
|
+
.then(function (r) { return r ? r.json().catch(function () { return null }) : null })
|
|
307
|
+
.catch(function () { return null })
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 试听:**只出声,不弹窗**(v0.4.17)。
|
|
312
|
+
*
|
|
313
|
+
* 为什么需要:音量增益 / 发音人 / 音色 / 自定义音频这些设置,
|
|
314
|
+
* 光看数字没法判断效果,只能盲调。有了试听就能边调边听。
|
|
315
|
+
*
|
|
316
|
+
* 与「测试通知」的区别:
|
|
317
|
+
* · 试听 → 只听声音(ps1 的 -Preview,无 Toast)
|
|
318
|
+
* · 测试通知 → 完整走一遍 notify(),含 Toast 弹窗
|
|
319
|
+
*
|
|
320
|
+
* @param {string} kind done|fail|question|approval|blocked|chime
|
|
321
|
+
* @returns {Promise<object|null>} host 返回 { ok, kind, source, gain }
|
|
322
|
+
*/
|
|
323
|
+
function previewSound(kind) {
|
|
324
|
+
var k = kind || 'done'
|
|
325
|
+
return fetch(cfgUrl('preview?kind=' + encodeURIComponent(k)), {
|
|
326
|
+
cache: 'no-store',
|
|
327
|
+
credentials: 'same-origin',
|
|
328
|
+
})
|
|
329
|
+
.then(function (r) { return r ? r.json().catch(function () { return null }) : null })
|
|
330
|
+
.catch(function () { return null })
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 音色候选(与 sounds/voice-lines 下的基础音色一致;可手输其他 key)。 */
|
|
334
|
+
// 可下载/可选的音色清单(**只列审核通过的**)。
|
|
335
|
+
//
|
|
336
|
+
// ⚠️ 2026-10-18:移除 kasukabe-tsumugi 与 mochiko ——
|
|
337
|
+
// · 春日部つむぎ:官方规约第 6 条禁止「二次配布」
|
|
338
|
+
// · もち子さん:官方规约禁止「音声素材を無償配布」
|
|
339
|
+
// 审核结论见 docs/audio-audit/README.md。
|
|
340
|
+
//
|
|
341
|
+
// 注意:这个列表只影响 GUI 下拉里的候选项。真正的分发拦截在
|
|
342
|
+
// release-build.ps1 的 $NeverShipVoicePrefixes(构建期硬阻断),
|
|
343
|
+
// 两处都要维护,但**构建期那道是安全网**。
|
|
344
|
+
var VOICE_KEYS = [
|
|
345
|
+
'zundamon-normal', 'zundamon-amama', 'ankomon', 'goki-nuigurumi',
|
|
346
|
+
'kyushu-sora', 'manbetsu-hanamaru-genki', 'meimei-meria',
|
|
347
|
+
'shikoku-metang', 'ameharu-hau', 'namiine-ritsu', 'shirakami-kotaro',
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
/** 事件开关定义(键 → 中文名)。 */
|
|
351
|
+
// 事件开关:[键, 名称, 一句话说明]。
|
|
352
|
+
//
|
|
353
|
+
// 说明文案是给**不了解内部术语**的人看的:
|
|
354
|
+
// - "空闲门" 是内部叫法,必须在界面上解释清楚它在防什么;
|
|
355
|
+
// - 其余各项也用"什么时候会响"的口语描述,而不是复述配置键的含义。
|
|
356
|
+
var TOGGLES = [
|
|
357
|
+
['notifyOnComplete', '回合完成',
|
|
358
|
+
'这一轮正常跑完时提醒你'],
|
|
359
|
+
['notifyOnError', '回合出错',
|
|
360
|
+
'这一轮以错误结束时提醒(会播日语道歉语音)'],
|
|
361
|
+
['notifyOnBlocked', '被拦截',
|
|
362
|
+
'这一轮还没开始就被拦下时提醒(例如钩子拒绝了指令)'],
|
|
363
|
+
['notifyOnAborted', '已中止',
|
|
364
|
+
'你手动按停止、或超出长度限制时提醒'],
|
|
365
|
+
['notifyOnApproval', '待批准',
|
|
366
|
+
'AI 要做敏感操作、等你点同意时提醒'],
|
|
367
|
+
['notifyOnQuestion', '要提问',
|
|
368
|
+
'AI 有问题要问你、停下来等你回答时提醒'],
|
|
369
|
+
['subagents', '子代理也提醒',
|
|
370
|
+
'连子代理自己的每一轮结束也提醒;默认只提醒你直接对话的那个会话'],
|
|
371
|
+
['idleGate', '等真正干完再提醒',
|
|
372
|
+
'别被半路打断的"完成"骗了:AI 派出去的子任务还在跑时先不响,'
|
|
373
|
+
+ '等全部结束再响一次。关掉的话,一结束就响(即使还有子任务在跑)'],
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 设置卡片:**真控件**。
|
|
378
|
+
*
|
|
379
|
+
* 状态全部来自 host route;每次改动立刻 POST,成功后用返回的合并值刷新本地态。
|
|
380
|
+
* 任何一步失败只显示一行错误,不让整卡崩掉。
|
|
381
|
+
*/
|
|
382
|
+
function SettingsCard() {
|
|
383
|
+
if (!React) return null
|
|
384
|
+
var h = React.createElement
|
|
385
|
+
// 初值只是"占位",让控件首屏就有内容、可交互(避免闪一屏"读取中")。
|
|
386
|
+
// ⚠️ 真实值必须以 host 返回的**生效值**为准(见下方 useEffect 的 merge)。
|
|
387
|
+
// 这些占位数字**不代表实际配置**:例如 patch 里 volume 可能是 100,
|
|
388
|
+
// 这里写 80 只是防 undefined;生效值到达后会被覆盖。
|
|
389
|
+
var st = React.useState({
|
|
390
|
+
loaded: false,
|
|
391
|
+
values: {
|
|
392
|
+
voice: true, voiceMode: 'fixed', voiceName: 'zundamon-normal', volume: 80,
|
|
393
|
+
notifyOnComplete: true, notifyOnError: true, notifyOnBlocked: true,
|
|
394
|
+
notifyOnAborted: false, notifyOnApproval: true, notifyOnQuestion: true,
|
|
395
|
+
subagents: false, idleGate: true,
|
|
396
|
+
},
|
|
397
|
+
busy: false, msg: '', err: '',
|
|
398
|
+
// 语音包清单:null = 还没加载过(用户点按钮才加载)
|
|
399
|
+
packs: { loading: false, list: null, error: null },
|
|
400
|
+
// 已确认过条款的音色 key(v0.4.40)。
|
|
401
|
+
//
|
|
402
|
+
// 只存在内存里,不落盘 —— 理由见 renderTermsPrompt 上方注释。
|
|
403
|
+
termsAck: {},
|
|
404
|
+
// 正在等待用户确认的音色(null = 没在问)
|
|
405
|
+
termsPending: null,
|
|
406
|
+
// 高级折叠区默认收起:多数人不需要,铺开只会劝退
|
|
407
|
+
advOpen: false,
|
|
408
|
+
})
|
|
409
|
+
var s = st[0]
|
|
410
|
+
var set = st[1]
|
|
411
|
+
|
|
412
|
+
React.useEffect(function () {
|
|
413
|
+
var alive = true
|
|
414
|
+
loadConfig().then(function (v) {
|
|
415
|
+
if (!alive) return
|
|
416
|
+
set(function (p) {
|
|
417
|
+
// 生效值覆盖占位值。注意这里**整体覆盖**而非只填缺失键:
|
|
418
|
+
// host 返回的 values 已经是四层合并后的完整配置,
|
|
419
|
+
// 它才是真的;本地占位值一律作废。
|
|
420
|
+
var merged = {}
|
|
421
|
+
var k
|
|
422
|
+
for (k in p.values) merged[k] = p.values[k]
|
|
423
|
+
if (v) for (k in v) merged[k] = v[k]
|
|
424
|
+
return { loaded: true, values: merged, packs: p.packs, termsAck: p.termsAck, termsPending: p.termsPending, busy: false, msg: '', err: '' }
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
return function () { alive = false }
|
|
428
|
+
}, [])
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* 保存一个配置变更。
|
|
432
|
+
*
|
|
433
|
+
* ⚠️ 每次 set 都**必须带上 advOpen**:状态是一个整体对象,
|
|
434
|
+
* 漏掉某个字段就等于把它重置成 undefined。第一版漏了 advOpen,
|
|
435
|
+
* 表现是"点一下任意控件,高级折叠区就自己收起来"——很恼人且难查。
|
|
436
|
+
*/
|
|
437
|
+
function apply(patch, label) {
|
|
438
|
+
set(function (p) {
|
|
439
|
+
return {
|
|
440
|
+
loaded: p.loaded, values: p.values, packs: p.packs, advOpen: p.advOpen,
|
|
441
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
442
|
+
busy: true, msg: '', err: '',
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
saveConfig(patch).then(function (r) {
|
|
446
|
+
if (r && r.ok) {
|
|
447
|
+
set(function (p) {
|
|
448
|
+
return {
|
|
449
|
+
loaded: true,
|
|
450
|
+
values: r.values || p.values,
|
|
451
|
+
packs: p.packs,
|
|
452
|
+
advOpen: p.advOpen,
|
|
453
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
454
|
+
busy: false,
|
|
455
|
+
msg: (label || '已保存') + '(立即生效)',
|
|
456
|
+
err: '',
|
|
457
|
+
}
|
|
458
|
+
})
|
|
459
|
+
} else {
|
|
460
|
+
set(function (p) {
|
|
461
|
+
return {
|
|
462
|
+
loaded: p.loaded, values: p.values, packs: p.packs, advOpen: p.advOpen,
|
|
463
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
464
|
+
busy: false, msg: '',
|
|
465
|
+
err: '保存失败:' + ((r && r.reason) || '未知原因'),
|
|
466
|
+
}
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
// ── 语音包动作(v0.4.32)──────────────────────────────────────
|
|
472
|
+
//
|
|
473
|
+
// 下载要几秒(1~3 MB),所以:进 busy 挡重复点击;
|
|
474
|
+
// 完成后重拉清单,让"已安装"标记更新。
|
|
475
|
+
function loadPacks() {
|
|
476
|
+
set(function (p) {
|
|
477
|
+
return {
|
|
478
|
+
loaded: p.loaded, values: p.values, packs: p.packs, advOpen: p.advOpen, busy: p.busy, msg: p.msg, err: p.err,
|
|
479
|
+
packs: { loading: true, list: null, error: null },
|
|
480
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
481
|
+
}
|
|
482
|
+
})
|
|
483
|
+
fetchVoicePacks().then(function (doc) {
|
|
484
|
+
var ok = doc && doc.ok && Array.isArray(doc.packs)
|
|
485
|
+
set(function (p) {
|
|
486
|
+
return {
|
|
487
|
+
loaded: p.loaded, values: p.values, packs: p.packs, advOpen: p.advOpen, busy: p.busy, msg: p.msg, err: p.err,
|
|
488
|
+
packs: {
|
|
489
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
490
|
+
loading: false,
|
|
491
|
+
list: ok ? doc.packs : null,
|
|
492
|
+
error: ok ? null : ((doc && (doc.indexError || doc.reason)) || "无法获取"),
|
|
493
|
+
},
|
|
494
|
+
}
|
|
495
|
+
})
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function addPack(key, label) {
|
|
500
|
+
set(function (p) {
|
|
501
|
+
return {
|
|
502
|
+
loaded: p.loaded, values: p.values, advOpen: p.advOpen, packs: p.packs, busy: true,
|
|
503
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
504
|
+
msg: "正在下载 " + label + "…", err: "",
|
|
505
|
+
}
|
|
506
|
+
})
|
|
507
|
+
installVoicePack(key).then(function (r) {
|
|
508
|
+
var ok = r && r.ok
|
|
509
|
+
set(function (p) {
|
|
510
|
+
return {
|
|
511
|
+
loaded: p.loaded, values: p.values, advOpen: p.advOpen, packs: p.packs, busy: false,
|
|
512
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
513
|
+
msg: ok ? ("已安装 " + label + "(" + (r.files || 0) + " 个文件)") : "",
|
|
514
|
+
err: ok ? "" : ("安装失败:" + ((r && (r.error || r.reason)) || "未知原因")),
|
|
515
|
+
}
|
|
516
|
+
})
|
|
517
|
+
if (ok) loadPacks()
|
|
518
|
+
})
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function removePack(key, label) {
|
|
522
|
+
set(function (p) {
|
|
523
|
+
return {
|
|
524
|
+
loaded: p.loaded, values: p.values, advOpen: p.advOpen, packs: p.packs, busy: true,
|
|
525
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
526
|
+
msg: "正在卸载 " + label + "…", err: "",
|
|
527
|
+
}
|
|
528
|
+
})
|
|
529
|
+
uninstallVoicePack(key).then(function (r) {
|
|
530
|
+
var ok = r && r.ok
|
|
531
|
+
set(function (p) {
|
|
532
|
+
return {
|
|
533
|
+
loaded: p.loaded, values: p.values, advOpen: p.advOpen, packs: p.packs, busy: false,
|
|
534
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
535
|
+
msg: ok ? ("已卸载 " + label) : "",
|
|
536
|
+
err: ok ? "" : ("卸载失败:" + ((r && (r.error || r.reason)) || "未知原因")),
|
|
537
|
+
}
|
|
538
|
+
})
|
|
539
|
+
if (ok) loadPacks()
|
|
540
|
+
})
|
|
541
|
+
}
|
|
542
|
+
// ── 下载前条款确认(v0.4.40)─────────────────────────────────────
|
|
543
|
+
//
|
|
544
|
+
// ## 为什么需要
|
|
545
|
+
//
|
|
546
|
+
// 这些语音由不同作者创作,各有各的**再分发与使用限制**:
|
|
547
|
+
// · 有的要求必须署名(VOICEVOX:ずんだもん 这类 credit)
|
|
548
|
+
// · 有的禁止二次配布(我们因此下架了两个音色)
|
|
549
|
+
// · 有的对商用/改作有额外条件
|
|
550
|
+
//
|
|
551
|
+
// 条款必须**下载前**可见。只靠 zip 里的 TERMS.md 不够 ——
|
|
552
|
+
// 那时文件已经落到用户机器上了,"先告知再下载"就变成了"先下载再说"。
|
|
553
|
+
//
|
|
554
|
+
// ## 这不是把审核责任推给用户
|
|
555
|
+
//
|
|
556
|
+
// 我们仍然自己审核(docs/audio-audit/ 有 13 份官方原文快照)。
|
|
557
|
+
// 这里的确认只是把「你用它时也要守这些条款」传达给用户,
|
|
558
|
+
// 不能替代开发者的合规判断。
|
|
559
|
+
//
|
|
560
|
+
// ## 为什么确认状态不落盘
|
|
561
|
+
//
|
|
562
|
+
// 存配置里意味着"确认过一次就永久静默"。但用户可能:
|
|
563
|
+
// · 换人用这台机器
|
|
564
|
+
// · 过很久后忘了条款内容
|
|
565
|
+
// 每次都问又很烦。折中:**只在本次会话内记住**。
|
|
566
|
+
// 浏览器刷新后重新问一次 —— 成本很低(点一下),收益是条款不会被遗忘。
|
|
567
|
+
function askTerms(pack) {
|
|
568
|
+
set(function (p) {
|
|
569
|
+
return {
|
|
570
|
+
loaded: p.loaded, values: p.values, advOpen: p.advOpen, packs: p.packs,
|
|
571
|
+
termsAck: p.termsAck, busy: p.busy, msg: "", err: "",
|
|
572
|
+
termsPending: pack,
|
|
573
|
+
}
|
|
574
|
+
})
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function ackTerms() {
|
|
578
|
+
var pack = st[0].termsPending
|
|
579
|
+
if (!pack) return
|
|
580
|
+
set(function (p) {
|
|
581
|
+
var ack = {}
|
|
582
|
+
for (var k in p.termsAck) ack[k] = p.termsAck[k]
|
|
583
|
+
ack[pack.key] = true
|
|
584
|
+
return {
|
|
585
|
+
loaded: p.loaded, values: p.values, advOpen: p.advOpen, packs: p.packs,
|
|
586
|
+
termsAck: ack, busy: p.busy, msg: "", err: "",
|
|
587
|
+
termsPending: null,
|
|
588
|
+
}
|
|
589
|
+
})
|
|
590
|
+
// 确认后直接开始下载(少一次点击)
|
|
591
|
+
addPack(pack.key, pack.name || pack.key)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function cancelTerms() {
|
|
595
|
+
set(function (p) {
|
|
596
|
+
return {
|
|
597
|
+
loaded: p.loaded, values: p.values, advOpen: p.advOpen, packs: p.packs,
|
|
598
|
+
termsAck: p.termsAck, busy: p.busy, msg: "", err: "",
|
|
599
|
+
termsPending: null,
|
|
600
|
+
}
|
|
601
|
+
})
|
|
602
|
+
}
|
|
603
|
+
// 导出诊断:成功后把路径显示出来(用户要能直接找到文件)。
|
|
604
|
+
function doExportDiagnostics() {
|
|
605
|
+
set(function (p) {
|
|
606
|
+
return { loaded: p.loaded, values: p.values, advOpen: p.advOpen, packs: p.packs, termsAck: p.termsAck, termsPending: p.termsPending, busy: true, msg: '正在导出诊断…', err: '' }
|
|
607
|
+
})
|
|
608
|
+
exportDiagnostics().then(function (r) {
|
|
609
|
+
var ok = r && r.ok
|
|
610
|
+
set(function (p) {
|
|
611
|
+
return {
|
|
612
|
+
loaded: p.loaded, values: p.values, advOpen: p.advOpen, packs: p.packs, busy: false,
|
|
613
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
614
|
+
msg: ok ? ("已导出:" + r.path + "(" + r.lines + " 行)") : "",
|
|
615
|
+
err: ok ? "" : ("导出失败:" + ((r && (r.error || r.reason)) || "未知原因")),
|
|
616
|
+
}
|
|
617
|
+
})
|
|
618
|
+
})
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
var v = s.values || {}
|
|
622
|
+
var rows = []
|
|
623
|
+
|
|
624
|
+
rows.push(h('div', { key: 't', style: S.title }, '桌面通知'))
|
|
625
|
+
// 注意:**不因"未加载完"就提前 return**。初值已按默认配置填好,
|
|
626
|
+
// 控件立即可用;真实值到达后自动覆盖(见 useEffect 的 merge)。
|
|
627
|
+
// 早期版本在这里 return 了一行"读取配置中…",导致首屏只有文字没有控件。
|
|
628
|
+
|
|
629
|
+
// ── 音源选择(v0.4.4)─────────────────────────────────────────────────
|
|
630
|
+
//
|
|
631
|
+
// 这是本插件最核心的可配置项:用户想听系统语音、想听语音包、想要系统音效、
|
|
632
|
+
// 还是干脆不出声 —— 四种人都存在,所以必须能选。
|
|
633
|
+
// 「none」单独作为一项,比"关掉语音开关"更明确(后者语义是"不要声音但可能还有别的")。
|
|
634
|
+
var SOURCES = [
|
|
635
|
+
['tts', '系统语音合成(不用下载)'],
|
|
636
|
+
['bundled', '语音包(二次元音色)'],
|
|
637
|
+
['system', '系统提示音(短促音效)'],
|
|
638
|
+
['custom', '我自己的音频文件'],
|
|
639
|
+
['none', '不出声,只显示通知'],
|
|
640
|
+
]
|
|
641
|
+
var curSource = v.audioSource || 'tts'
|
|
642
|
+
rows.push(h('div', { key: 'src', style: S.row },
|
|
643
|
+
h('span', { style: S.label }, '声音来源'),
|
|
644
|
+
h('select', {
|
|
645
|
+
value: curSource,
|
|
646
|
+
disabled: s.busy,
|
|
647
|
+
onChange: function (e) { apply({ audioSource: e.target.value }, '声音来源已更新') },
|
|
648
|
+
}, SOURCES.map(function (p) { return h('option', { key: p[0], value: p[0] }, p[1]) })),
|
|
649
|
+
))
|
|
650
|
+
// 说明:让用户知道当前选项意味着什么,不用去猜
|
|
651
|
+
var SRC_HINT = {
|
|
652
|
+
tts: '用 Windows 自带语音念一句中文,不占空间、装了就能用',
|
|
653
|
+
bundled: '播放下载的 VOICEVOX 语音包;没下载过音色时会退回系统提示音',
|
|
654
|
+
system: '播放 Windows 的系统提示音,完成/失败/提问各用不同的音',
|
|
655
|
+
custom: '播放你自己指定的音频文件(在下方设置)',
|
|
656
|
+
none: '完全不发声,只弹通知',
|
|
657
|
+
}
|
|
658
|
+
rows.push(h('div', { key: 'srchint', style: S.subHint }, SRC_HINT[curSource] || ''))
|
|
659
|
+
|
|
660
|
+
// ── 弹窗开关(v0.4.4)────────────────────────────────────────────────
|
|
661
|
+
// 有人只想听声音、嫌弹窗打断;也有人只要弹窗、不要声音。两者都要能关。
|
|
662
|
+
rows.push(h('div', { key: 'toast', style: S.row },
|
|
663
|
+
h('span', { style: S.label }, '显示通知弹窗'),
|
|
664
|
+
h('input', {
|
|
665
|
+
type: 'checkbox',
|
|
666
|
+
checked: v.showToast !== false,
|
|
667
|
+
disabled: s.busy,
|
|
668
|
+
onChange: function (e) { apply({ showToast: !!e.target.checked }, '弹窗开关已更新') },
|
|
669
|
+
}),
|
|
670
|
+
h('span', { style: S.hint }, v.showToast === false ? '关闭后只有声音' : '关掉后只听声音、不弹窗'),
|
|
671
|
+
))
|
|
672
|
+
|
|
673
|
+
// 语音开关
|
|
674
|
+
rows.push(h('div', { key: 'voice', style: S.row },
|
|
675
|
+
h('span', { style: S.label }, '语音播报'),
|
|
676
|
+
h('input', {
|
|
677
|
+
type: 'checkbox',
|
|
678
|
+
checked: v.voice !== false,
|
|
679
|
+
disabled: s.busy,
|
|
680
|
+
onChange: function (e) { apply({ voice: !!e.target.checked }, '语音开关已更新') },
|
|
681
|
+
}),
|
|
682
|
+
h('span', { style: S.hint }, '关掉只弹 Toast'),
|
|
683
|
+
))
|
|
684
|
+
|
|
685
|
+
// 音量
|
|
686
|
+
rows.push(h('div', { key: 'vol', style: S.row },
|
|
687
|
+
h('span', { style: S.label }, '音量'),
|
|
688
|
+
h('input', {
|
|
689
|
+
type: 'range', min: 0, max: 100, step: 1,
|
|
690
|
+
value: typeof v.volume === 'number' ? v.volume : 80,
|
|
691
|
+
disabled: s.busy,
|
|
692
|
+
style: { flex: '1' },
|
|
693
|
+
// onChange 必须有:React 对「有 value 无 onChange」的受控表单元素会
|
|
694
|
+
// 降级成只读并告警(本机是 React 19,受控组件语义更严格)。
|
|
695
|
+
// 拖拽过程只改本地显示,不写盘 —— 避免每像素一次请求。
|
|
696
|
+
onChange: function (e) {
|
|
697
|
+
var n = Number(e.target.value)
|
|
698
|
+
set(function (p) {
|
|
699
|
+
var nv = {}; for (var k in p.values) nv[k] = p.values[k]
|
|
700
|
+
nv.volume = n
|
|
701
|
+
return {
|
|
702
|
+
loaded: p.loaded, values: nv, advOpen: p.advOpen, packs: p.packs,
|
|
703
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
704
|
+
busy: p.busy, msg: '', err: p.err,
|
|
705
|
+
}
|
|
706
|
+
})
|
|
707
|
+
},
|
|
708
|
+
// 松手才写盘。用 pointerup 覆盖鼠标/触摸/触控笔;
|
|
709
|
+
// 再补 onBlur 兜底(指针在元素外抬起时元素收不到 pointerup)。
|
|
710
|
+
onPointerUp: function (e) { apply({ volume: Number(e.currentTarget.value) }, '音量已更新') },
|
|
711
|
+
onBlur: function (e) { apply({ volume: Number(e.currentTarget.value) }, '音量已更新') },
|
|
712
|
+
onKeyUp: function (e) { apply({ volume: Number(e.currentTarget.value) }, '音量已更新') },
|
|
713
|
+
}),
|
|
714
|
+
h('span', { style: S.hint }, String(typeof v.volume === 'number' ? v.volume : 80)),
|
|
715
|
+
))
|
|
716
|
+
|
|
717
|
+
// ── 语音包增益(v0.4.16)─────────────────────────────────────────────
|
|
718
|
+
//
|
|
719
|
+
// 为什么单独一个滑杆:Windows 的播放音量上限是"原始幅度 1.0 倍",
|
|
720
|
+
// 而本包语音包峰值中位只有 40.4%(实测 247 个文件),
|
|
721
|
+
// 所以上面「音量」拉满也只到原始电平的 35%~40%,听感偏轻。
|
|
722
|
+
// 这个滑杆做**数字增益**:把样本乘一个系数,绕开 1.0 倍上限。
|
|
723
|
+
//
|
|
724
|
+
// ps1 侧会按峰值自动限幅(不超过 95%),所以不用担心调大破音。
|
|
725
|
+
rows.push(h('div', { key: 'vogain', style: S.row },
|
|
726
|
+
h('span', { style: S.label }, '语音包增益'),
|
|
727
|
+
h('input', {
|
|
728
|
+
type: 'range', min: 1.0, max: 4.0, step: 0.1,
|
|
729
|
+
value: typeof v.voiceGain === 'number' ? v.voiceGain : 2.0,
|
|
730
|
+
disabled: s.busy,
|
|
731
|
+
style: { flex: '1' },
|
|
732
|
+
onChange: function (e) {
|
|
733
|
+
var n = Number(e.target.value)
|
|
734
|
+
set(function (p) {
|
|
735
|
+
var nv = {}; for (var k in p.values) nv[k] = p.values[k]
|
|
736
|
+
nv.voiceGain = n
|
|
737
|
+
return {
|
|
738
|
+
loaded: p.loaded, values: nv, advOpen: p.advOpen, packs: p.packs,
|
|
739
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
740
|
+
busy: p.busy, msg: '', err: p.err,
|
|
741
|
+
}
|
|
742
|
+
})
|
|
743
|
+
},
|
|
744
|
+
onPointerUp: function (e) { apply({ voiceGain: Number(e.currentTarget.value) }, '增益已更新') },
|
|
745
|
+
onBlur: function (e) { apply({ voiceGain: Number(e.currentTarget.value) }, '增益已更新') },
|
|
746
|
+
onKeyUp: function (e) { apply({ voiceGain: Number(e.currentTarget.value) }, '增益已更新') },
|
|
747
|
+
}),
|
|
748
|
+
h('span', { style: S.hint }, 'x' + (typeof v.voiceGain === 'number' ? v.voiceGain.toFixed(1) : '2.0')),
|
|
749
|
+
// 试听:只听声音、不弹窗。调增益/换音色这类"要听才知道"的设置,
|
|
750
|
+
// 没有试听就只能盲调(用户 2026-10-18 指出)。
|
|
751
|
+
h('button', {
|
|
752
|
+
type: 'button',
|
|
753
|
+
key: 'pv-gain',
|
|
754
|
+
disabled: s.busy,
|
|
755
|
+
style: S.btn,
|
|
756
|
+
onClick: function () {
|
|
757
|
+
previewSound('done').then(function (r) {
|
|
758
|
+
set(function (p) {
|
|
759
|
+
return {
|
|
760
|
+
loaded: p.loaded, values: p.values, packs: p.packs, advOpen: p.advOpen,
|
|
761
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
762
|
+
busy: false,
|
|
763
|
+
msg: r && r.ok ? ('试听中(' + (r.source || 'bundled') + ')') : '试听失败',
|
|
764
|
+
err: p.err,
|
|
765
|
+
}
|
|
766
|
+
})
|
|
767
|
+
})
|
|
768
|
+
},
|
|
769
|
+
}, '试听'),
|
|
770
|
+
))
|
|
771
|
+
rows.push(h('div', { key: 'vogainhint', style: S.subHint },
|
|
772
|
+
'语音包素材本身录得偏轻,这里补上。自动限幅,调大不会破音。'))
|
|
773
|
+
|
|
774
|
+
// ── 自定义音频标准化(v0.4.16)──────────────────────────────────────
|
|
775
|
+
//
|
|
776
|
+
// 默认关:那是用户自己的素材,可能特意做得轻或响,不该擅自改。
|
|
777
|
+
// 打开后才按统一响度处理(只对 16-bit PCM wav 生效)。
|
|
778
|
+
rows.push(h('div', { key: 'normcust', style: S.row },
|
|
779
|
+
h('span', { style: S.label }, '自定义音频标准化'),
|
|
780
|
+
h('input', {
|
|
781
|
+
type: 'checkbox',
|
|
782
|
+
checked: v.normalizeCustomAudio === true,
|
|
783
|
+
disabled: s.busy,
|
|
784
|
+
onChange: function (e) {
|
|
785
|
+
apply({ normalizeCustomAudio: !!e.target.checked }, '已更新')
|
|
786
|
+
},
|
|
787
|
+
}),
|
|
788
|
+
h('span', { style: S.hint }, v.normalizeCustomAudio === true
|
|
789
|
+
? '开:统一响度(仅 wav 有效)'
|
|
790
|
+
: '关:尊重你原文件的响度'),
|
|
791
|
+
))
|
|
792
|
+
|
|
793
|
+
// ── 试听(v0.4.17)──────────────────────────────────────────────────
|
|
794
|
+
//
|
|
795
|
+
// 为什么单独一行放五种事件:用户常给不同事件配不同音源
|
|
796
|
+
// (例如"完成听日语、失败听系统音"),必须能分别试听确认。
|
|
797
|
+
//
|
|
798
|
+
// 试听**只出声、不弹窗**(ps1 的 -Preview)——
|
|
799
|
+
// 它的用途是"听效果",不是验证通知通路(那是「测试通知」的事)。
|
|
800
|
+
rows.push(h('div', { key: 'pv-row', style: S.row },
|
|
801
|
+
h('span', { style: S.label }, '试听'),
|
|
802
|
+
(function () {
|
|
803
|
+
var KINDS = [
|
|
804
|
+
['done', '完成'], ['fail', '失败'], ['question', '提问'],
|
|
805
|
+
['approval', '批准'], ['blocked', '拦截'],
|
|
806
|
+
]
|
|
807
|
+
return h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap', flex: '1' } },
|
|
808
|
+
KINDS.map(function (pair) {
|
|
809
|
+
return h('button', {
|
|
810
|
+
type: 'button',
|
|
811
|
+
key: 'pv-' + pair[0],
|
|
812
|
+
disabled: s.busy,
|
|
813
|
+
style: S.btn,
|
|
814
|
+
onClick: function () {
|
|
815
|
+
// 先写盘再试听:让试听用的就是刚才调的值
|
|
816
|
+
previewSound(pair[0]).then(function (r) {
|
|
817
|
+
set(function (p) {
|
|
818
|
+
return {
|
|
819
|
+
loaded: p.loaded, values: p.values, packs: p.packs, advOpen: p.advOpen,
|
|
820
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
821
|
+
busy: false,
|
|
822
|
+
msg: r && r.ok
|
|
823
|
+
? ('试听「' + pair[1] + '」(' + (r.source || 'bundled') + ')')
|
|
824
|
+
: '试听失败',
|
|
825
|
+
err: p.err,
|
|
826
|
+
}
|
|
827
|
+
})
|
|
828
|
+
})
|
|
829
|
+
},
|
|
830
|
+
}, pair[1])
|
|
831
|
+
}),
|
|
832
|
+
)
|
|
833
|
+
})(),
|
|
834
|
+
))
|
|
835
|
+
rows.push(h('div', { key: 'pv-hint', style: S.subHint },
|
|
836
|
+
'试听只出声、不弹窗。调完增益或换了音色,点一下就能听效果。'))
|
|
837
|
+
|
|
838
|
+
// ── 语音包管理(v0.4.32)──────────────────────────────────────
|
|
839
|
+
//
|
|
840
|
+
// 核心包只带 1 个音色,其余按需下载。
|
|
841
|
+
//
|
|
842
|
+
// ⚠️ 刻意**不在挂载时自动加载**:set 会触发重渲染,
|
|
843
|
+
// 而任何同步执行的 useEffect 都会让「加载 → set → 重渲染 → 加载」
|
|
844
|
+
// 变成死循环(实测把 4GB 堆打满)。改为用户点按钮触发。
|
|
845
|
+
if (curSource === 'bundled') {
|
|
846
|
+
var vp = s.packs || { loading: false, list: null, error: null }
|
|
847
|
+
rows.push(h('div', { key: 'vp-title', style: S.label }, '语音包'))
|
|
848
|
+
|
|
849
|
+
if (vp.loading) {
|
|
850
|
+
rows.push(h('div', { key: 'vp-loading', style: S.subHint }, '正在获取可下载音色…'))
|
|
851
|
+
} else if (!vp.list) {
|
|
852
|
+
rows.push(h('div', { key: 'vp-idle', style: S.subHint },
|
|
853
|
+
vp.error
|
|
854
|
+
? '未能获取可下载列表:' + vp.error + '。已装的音色仍可正常使用。'
|
|
855
|
+
: '核心包只带 1 个音色。点右边按钮看看还有哪些可以下载。'))
|
|
856
|
+
rows.push(h('div', { key: 'vp-load-row', style: S.row },
|
|
857
|
+
h('button', {
|
|
858
|
+
type: 'button',
|
|
859
|
+
disabled: s.busy,
|
|
860
|
+
style: S.btn,
|
|
861
|
+
onClick: function () { loadPacks() },
|
|
862
|
+
}, vp.error ? '重试' : '加载列表'),
|
|
863
|
+
))
|
|
864
|
+
} else {
|
|
865
|
+
var items = vp.list.filter(function (p) { return p.downloadable || p.installed })
|
|
866
|
+
rows.push(h('div', { key: 'vp-list', style: { display: 'flex', flexDirection: 'column', gap: '4px', flex: '1' } },
|
|
867
|
+
items.map(function (p) {
|
|
868
|
+
var mb = p.bytes ? (p.bytes / 1048576).toFixed(1) + ' MB' : ''
|
|
869
|
+
// 每个音色显示:名称 + 体积 + 署名 + 官方条款链接(v0.4.40)
|
|
870
|
+
//
|
|
871
|
+
// 为什么必须在这里显示:这些语音各有各的再分发与使用限制,
|
|
872
|
+
// 条款要**下载前**可见 —— 不能只靠 zip 里的 TERMS.md(那要下载后才看得到)。
|
|
873
|
+
return h('div', { key: 'vp-item-' + p.key, style: { display: 'flex', flexDirection: 'column', gap: '2px', padding: '3px 0' } },
|
|
874
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
|
|
875
|
+
h('span', { style: { flex: '1' } }, (p.name || p.key) + (mb ? '(' + mb + ')' : '')),
|
|
876
|
+
p.installed ? h('span', { style: { color: '#2a7' } }, '已安装') : null,
|
|
877
|
+
h('button', {
|
|
878
|
+
type: 'button',
|
|
879
|
+
disabled: s.busy || !p.downloadable,
|
|
880
|
+
style: S.btn,
|
|
881
|
+
onClick: function () {
|
|
882
|
+
if (p.installed) { removePack(p.key, p.name || p.key); return }
|
|
883
|
+
// 没确认过条款 → 先问(见 askTerms)
|
|
884
|
+
if (!(s.termsAck && s.termsAck[p.key])) { askTerms(p); return }
|
|
885
|
+
addPack(p.key, p.name || p.key)
|
|
886
|
+
},
|
|
887
|
+
}, p.installed ? '卸载' : (p.downloadable ? '下载' : '不可用')),
|
|
888
|
+
),
|
|
889
|
+
// 署名与条款:小字,不抢视觉但随时可查
|
|
890
|
+
h('div', { style: S.subHint },
|
|
891
|
+
(p.credit ? '署名:' + p.credit : '')
|
|
892
|
+
+ (p.termsUrl ? '' : ' (条款链接待补)'),
|
|
893
|
+
p.termsUrl
|
|
894
|
+
? h('a', {
|
|
895
|
+
href: p.termsUrl,
|
|
896
|
+
target: '_blank',
|
|
897
|
+
rel: 'noreferrer noopener',
|
|
898
|
+
style: { color: '#4a9', marginLeft: '6px' },
|
|
899
|
+
}, '官方条款 ↗')
|
|
900
|
+
: null,
|
|
901
|
+
),
|
|
902
|
+
)
|
|
903
|
+
}),
|
|
904
|
+
))
|
|
905
|
+
rows.push(h('div', { key: 'vp-hint', style: S.subHint },
|
|
906
|
+
'这些语音由 VOICEVOX 生成,各有各的署名与用途限制;下载后会附 TERMS.md。'))
|
|
907
|
+
// ── 下载前条款确认面板(v0.4.40)─────────────────────────────
|
|
908
|
+
//
|
|
909
|
+
// 只在用户点了某个未确认音色的「下载」时出现。
|
|
910
|
+
// 内容刻意只放:署名 + 官方条款链接 + 明确的确认动作。
|
|
911
|
+
// 不放我们自己转述的条款摘要 —— 转述会失真,用户该看原文。
|
|
912
|
+
if (s.termsPending) {
|
|
913
|
+
var tp = s.termsPending
|
|
914
|
+
rows.push(h('div', {
|
|
915
|
+
key: 'vp-terms-box',
|
|
916
|
+
style: {
|
|
917
|
+
marginTop: '6px', padding: '8px', borderRadius: '4px',
|
|
918
|
+
background: 'rgba(255,200,80,.10)',
|
|
919
|
+
border: '1px solid rgba(255,200,80,.35)',
|
|
920
|
+
display: 'flex', flexDirection: 'column', gap: '4px',
|
|
921
|
+
},
|
|
922
|
+
},
|
|
923
|
+
h('div', { style: { fontWeight: 600 } }, '下载前请先看条款:' + (tp.name || tp.key)),
|
|
924
|
+
h('div', { style: S.subHint },
|
|
925
|
+
tp.credit ? ('署名要求:' + tp.credit) : '该音色未标注署名要求'),
|
|
926
|
+
tp.termsUrl
|
|
927
|
+
? h('div', { style: S.subHint },
|
|
928
|
+
'官方条款:',
|
|
929
|
+
h('a', {
|
|
930
|
+
href: tp.termsUrl,
|
|
931
|
+
target: '_blank',
|
|
932
|
+
rel: 'noreferrer noopener',
|
|
933
|
+
style: { color: '#4a9' },
|
|
934
|
+
}, tp.termsUrl),
|
|
935
|
+
)
|
|
936
|
+
: h('div', { style: S.subHint }, '该音色没有登记官方条款链接 —— 请谨慎使用。'),
|
|
937
|
+
h('div', { style: S.subHint },
|
|
938
|
+
'下载后 zip 内也带 TERMS.md。你用它做的内容(视频、直播等)仍需遵守上述条款。'),
|
|
939
|
+
h('div', { style: S.row },
|
|
940
|
+
h('button', { type: 'button', style: S.btn, onClick: function () { ackTerms() } }, '我已阅读,下载'),
|
|
941
|
+
h('button', { type: 'button', style: S.btn, onClick: function () { cancelTerms() } }, '取消'),
|
|
942
|
+
),
|
|
943
|
+
))
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
// ── 语音包专属设置(仅 bundled 时显示)──────────────────────────────
|
|
948
|
+
if (curSource === 'bundled') {
|
|
949
|
+
// 音色模式
|
|
950
|
+
rows.push(h('div', { key: 'mode', style: S.row },
|
|
951
|
+
h('span', { style: S.label }, '音色模式'),
|
|
952
|
+
h('select', {
|
|
953
|
+
value: v.voiceMode || 'fixed',
|
|
954
|
+
disabled: s.busy,
|
|
955
|
+
onChange: function (e) { apply({ voiceMode: e.target.value }, '音色模式已更新') },
|
|
956
|
+
},
|
|
957
|
+
h('option', { value: 'fixed' }, '固定音色'),
|
|
958
|
+
h('option', { value: 'random' }, '每次随机'),
|
|
959
|
+
),
|
|
960
|
+
))
|
|
961
|
+
|
|
962
|
+
// 音色名(随机模式下置灰,避免误以为它仍生效)
|
|
963
|
+
var fixedMode = (v.voiceMode || 'fixed') === 'fixed'
|
|
964
|
+
var keyOpts = VOICE_KEYS.slice()
|
|
965
|
+
if (v.voiceName && keyOpts.indexOf(v.voiceName) < 0) keyOpts.unshift(v.voiceName)
|
|
966
|
+
rows.push(h('div', { key: 'name', style: S.row },
|
|
967
|
+
h('span', { style: S.label }, '固定音色'),
|
|
968
|
+
h('select', {
|
|
969
|
+
value: v.voiceName || 'zundamon-normal',
|
|
970
|
+
disabled: s.busy || !fixedMode,
|
|
971
|
+
onChange: function (e) { apply({ voiceName: e.target.value }, '音色已更新') },
|
|
972
|
+
}, keyOpts.map(function (k) { return h('option', { key: k, value: k }, k) })),
|
|
973
|
+
fixedMode ? null : h('span', { style: S.hint }, '随机模式下不生效'),
|
|
974
|
+
))
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// ── TTS 专属设置(仅 tts 时显示)─────────────────────────────────────
|
|
978
|
+
if (curSource === 'tts') {
|
|
979
|
+
var TTS_VOICES = [['', '系统默认'], ['Microsoft Huihui Desktop', 'Huihui(中文女声)'],
|
|
980
|
+
['Microsoft Zira Desktop', 'Zira(英文女声)']]
|
|
981
|
+
rows.push(h('div', { key: 'ttsv', style: S.row },
|
|
982
|
+
h('span', { style: S.label }, '朗读声音'),
|
|
983
|
+
h('select', {
|
|
984
|
+
value: v.ttsVoice || '',
|
|
985
|
+
disabled: s.busy,
|
|
986
|
+
onChange: function (e) { apply({ ttsVoice: e.target.value }, '朗读声音已更新') },
|
|
987
|
+
}, TTS_VOICES.map(function (p) { return h('option', { key: p[0], value: p[0] }, p[1]) })),
|
|
988
|
+
))
|
|
989
|
+
rows.push(h('div', { key: 'ttsr', style: S.row },
|
|
990
|
+
h('span', { style: S.label }, '语速'),
|
|
991
|
+
h('input', {
|
|
992
|
+
type: 'range', min: -10, max: 10, step: 1,
|
|
993
|
+
value: typeof v.ttsRate === 'number' ? v.ttsRate : 0,
|
|
994
|
+
disabled: s.busy,
|
|
995
|
+
style: { flex: '1' },
|
|
996
|
+
onChange: function (e) {
|
|
997
|
+
var n = Number(e.target.value)
|
|
998
|
+
set(function (p) {
|
|
999
|
+
var nv = {}; for (var k in p.values) nv[k] = p.values[k]
|
|
1000
|
+
nv.ttsRate = n
|
|
1001
|
+
return {
|
|
1002
|
+
loaded: p.loaded, values: nv, advOpen: p.advOpen, packs: p.packs,
|
|
1003
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
1004
|
+
busy: p.busy, msg: '', err: p.err,
|
|
1005
|
+
}
|
|
1006
|
+
})
|
|
1007
|
+
},
|
|
1008
|
+
onPointerUp: function (e) { apply({ ttsRate: Number(e.currentTarget.value) }, '语速已更新') },
|
|
1009
|
+
onBlur: function (e) { apply({ ttsRate: Number(e.currentTarget.value) }, '语速已更新') },
|
|
1010
|
+
onKeyUp: function (e) { apply({ ttsRate: Number(e.currentTarget.value) }, '语速已更新') },
|
|
1011
|
+
}),
|
|
1012
|
+
h('span', { style: S.hint }, (function () {
|
|
1013
|
+
var n = typeof v.ttsRate === 'number' ? v.ttsRate : 0
|
|
1014
|
+
return n === 0 ? '正常' : (n < 0 ? '慢 ' + Math.abs(n) : '快 ' + n)
|
|
1015
|
+
})()),
|
|
1016
|
+
))
|
|
1017
|
+
|
|
1018
|
+
// ── 自定义台词(v0.4.8)─────────────────────────────────────────────
|
|
1019
|
+
//
|
|
1020
|
+
// 用户 2026-10-17 要求:"既然是这个 TTS 台词,是不是可以用户去输一段话呀?"
|
|
1021
|
+
// 留空 = 用内置台词库(每种事件 2~4 句随机,避免听腻)。
|
|
1022
|
+
//
|
|
1023
|
+
// 用受控 input + onBlur 保存(不是每次按键都写盘):
|
|
1024
|
+
// 打字过程频繁 POST 没有意义,还会让光标位置闪。
|
|
1025
|
+
rows.push(h('div', { key: 'ttsTextHint', style: S.subHint },
|
|
1026
|
+
'想让它说别的话就填在这里(最多 60 字)。留空则每次从内置的几句里随机。'))
|
|
1027
|
+
var TTS_TEXT_ROWS = [
|
|
1028
|
+
['Done', '任务完成'],
|
|
1029
|
+
['Fail', '任务失败'],
|
|
1030
|
+
['Question', '向你提问'],
|
|
1031
|
+
['Approval', '待你批准'],
|
|
1032
|
+
['Blocked', '被拦截'],
|
|
1033
|
+
]
|
|
1034
|
+
TTS_TEXT_ROWS.forEach(function (pair) {
|
|
1035
|
+
var suffix = pair[0]
|
|
1036
|
+
var label = pair[1]
|
|
1037
|
+
var key = 'ttsText' + suffix
|
|
1038
|
+
rows.push(h('div', { key: 'tt-' + suffix, style: S.row },
|
|
1039
|
+
h('span', { style: S.label }, label),
|
|
1040
|
+
h('input', {
|
|
1041
|
+
type: 'text',
|
|
1042
|
+
maxLength: 60,
|
|
1043
|
+
placeholder: '留空 = 内置随机',
|
|
1044
|
+
value: typeof v[key] === 'string' ? v[key] : '',
|
|
1045
|
+
disabled: s.busy,
|
|
1046
|
+
style: S.textInput,
|
|
1047
|
+
// 打字时只更新本地显示,不发请求
|
|
1048
|
+
onChange: (function (k) {
|
|
1049
|
+
return function (e) {
|
|
1050
|
+
var val = e.target.value
|
|
1051
|
+
set(function (p) {
|
|
1052
|
+
var nv = {}; for (var kk in p.values) nv[kk] = p.values[kk]
|
|
1053
|
+
nv[k] = val
|
|
1054
|
+
return {
|
|
1055
|
+
loaded: p.loaded, values: nv, advOpen: p.advOpen, packs: p.packs,
|
|
1056
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
1057
|
+
busy: p.busy, msg: '', err: p.err,
|
|
1058
|
+
}
|
|
1059
|
+
})
|
|
1060
|
+
}
|
|
1061
|
+
})(key),
|
|
1062
|
+
// 失去焦点 / 回车才写盘
|
|
1063
|
+
onBlur: (function (k) {
|
|
1064
|
+
return function (e) {
|
|
1065
|
+
var o = {}
|
|
1066
|
+
o[k] = e.target.value
|
|
1067
|
+
apply(o, '台词已更新')
|
|
1068
|
+
}
|
|
1069
|
+
})(key),
|
|
1070
|
+
onKeyDown: function (e) {
|
|
1071
|
+
if (e.key === 'Enter') { try { e.target.blur() } catch (x) { /* ignore */ } }
|
|
1072
|
+
},
|
|
1073
|
+
}),
|
|
1074
|
+
))
|
|
1075
|
+
})
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// ── 自定义音频(仅 custom 时显示)────────────────────────────────────
|
|
1079
|
+
//
|
|
1080
|
+
// 按事件分别选文件:用户可能想让"完成"放自己的音乐、"失败"放另一段。
|
|
1081
|
+
// 保存的是扁平键(customFileDone / customFileFail …),因为 config-store
|
|
1082
|
+
// 的白名单只认标量;host 侧会拼成 audio-source 需要的 customFiles 字典。
|
|
1083
|
+
//
|
|
1084
|
+
// ⚠️ 只记录**路径**,不复制文件:用户改文件立即生效,也不占额外空间。
|
|
1085
|
+
if (curSource === 'custom') {
|
|
1086
|
+
rows.push(h('div', { key: 'custhint', style: S.subHint },
|
|
1087
|
+
'给每种提醒各挑一个 wav / mp3。文件不会被复制,插件只记住位置。'))
|
|
1088
|
+
var CUSTOM_KINDS = [
|
|
1089
|
+
['Done', '任务完成'],
|
|
1090
|
+
['Fail', '任务失败'],
|
|
1091
|
+
['Question', '向你提问'],
|
|
1092
|
+
['Approval', '待你批准'],
|
|
1093
|
+
]
|
|
1094
|
+
CUSTOM_KINDS.forEach(function (pair) {
|
|
1095
|
+
var suffix = pair[0]
|
|
1096
|
+
var label = pair[1]
|
|
1097
|
+
var key = 'customFile' + suffix
|
|
1098
|
+
var cur = v[key] || ''
|
|
1099
|
+
rows.push(h('div', { key: 'cf-' + suffix, style: S.row },
|
|
1100
|
+
h('span', { style: S.label }, label),
|
|
1101
|
+
h('button', {
|
|
1102
|
+
type: 'button',
|
|
1103
|
+
disabled: s.busy,
|
|
1104
|
+
style: S.btn,
|
|
1105
|
+
onClick: (function (k) {
|
|
1106
|
+
return function () {
|
|
1107
|
+
pickCustomFile().then(function (p) {
|
|
1108
|
+
if (p) {
|
|
1109
|
+
var patch = {}
|
|
1110
|
+
patch[k] = p
|
|
1111
|
+
patch.audioSource = 'custom'
|
|
1112
|
+
apply(patch, '已选择音频文件')
|
|
1113
|
+
} else {
|
|
1114
|
+
set(function (prev) {
|
|
1115
|
+
return {
|
|
1116
|
+
loaded: prev.loaded, values: prev.values, advOpen: prev.advOpen, packs: p.packs,
|
|
1117
|
+
termsAck: prev.termsAck, termsPending: prev.termsPending,
|
|
1118
|
+
busy: false, msg: '',
|
|
1119
|
+
err: '没有取到文件路径。若点了没反应,可手动在配置里填该事件的 customFile。',
|
|
1120
|
+
}
|
|
1121
|
+
})
|
|
1122
|
+
}
|
|
1123
|
+
}).catch(function () { /* pickCustomFile 内部已兜底 */ })
|
|
1124
|
+
}
|
|
1125
|
+
})(key),
|
|
1126
|
+
}, cur ? '更换…' : '选择文件…'),
|
|
1127
|
+
h('span', { style: S.hint }, cur || '(未选择,将用系统提示音)'),
|
|
1128
|
+
cur ? h('button', {
|
|
1129
|
+
type: 'button',
|
|
1130
|
+
disabled: s.busy,
|
|
1131
|
+
style: S.btn,
|
|
1132
|
+
onClick: (function (k) {
|
|
1133
|
+
return function () {
|
|
1134
|
+
var patch = {}
|
|
1135
|
+
patch[k] = ''
|
|
1136
|
+
apply(patch, '已清除')
|
|
1137
|
+
}
|
|
1138
|
+
})(key),
|
|
1139
|
+
}, '清除') : null,
|
|
1140
|
+
))
|
|
1141
|
+
})
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// 事件开关(未设置时用默认:aborted / subagents 默认关,其余默认开)
|
|
1145
|
+
var DEFAULTS = {
|
|
1146
|
+
notifyOnComplete: true, notifyOnError: true, notifyOnBlocked: true,
|
|
1147
|
+
notifyOnAborted: false, notifyOnApproval: true, notifyOnQuestion: true,
|
|
1148
|
+
subagents: false, idleGate: true,
|
|
1149
|
+
}
|
|
1150
|
+
rows.push(h('div', { key: 'sep', style: { margin: '10px 0 4px', fontWeight: 600 } }, '提醒哪些事件'))
|
|
1151
|
+
TOGGLES.forEach(function (pair) {
|
|
1152
|
+
var key = pair[0]
|
|
1153
|
+
var label = pair[1]
|
|
1154
|
+
var desc = pair[2] || ''
|
|
1155
|
+
var cur = (typeof v[key] === 'boolean') ? v[key] : DEFAULTS[key]
|
|
1156
|
+
// 一行 = [勾选框] [名称] [说明]。说明用弱化色、允许换行,
|
|
1157
|
+
// 这样"等真正干完再提醒"这种较长的解释也能完整读到。
|
|
1158
|
+
rows.push(h('div', { key: 'tg-' + key, style: S.toggleRow },
|
|
1159
|
+
h('input', {
|
|
1160
|
+
type: 'checkbox',
|
|
1161
|
+
checked: cur === true,
|
|
1162
|
+
disabled: s.busy,
|
|
1163
|
+
style: S.toggleBox,
|
|
1164
|
+
onChange: (function (k) {
|
|
1165
|
+
return function (e) {
|
|
1166
|
+
var o = {}
|
|
1167
|
+
o[k] = !!e.target.checked
|
|
1168
|
+
apply(o, '已更新')
|
|
1169
|
+
}
|
|
1170
|
+
})(key),
|
|
1171
|
+
}),
|
|
1172
|
+
h('div', { style: S.toggleText },
|
|
1173
|
+
h('span', { style: S.toggleLabel }, label),
|
|
1174
|
+
desc ? h('span', { style: S.toggleDesc }, ' — ' + desc) : null,
|
|
1175
|
+
),
|
|
1176
|
+
))
|
|
1177
|
+
})
|
|
1178
|
+
|
|
1179
|
+
// ── 会话跳转(v0.4.6)──────────────────────────────────────────────
|
|
1180
|
+
//
|
|
1181
|
+
// 为什么这里是**两个**开关而不是一个:
|
|
1182
|
+
// 切回窗口有两种情形,用户诉求正好相反 ——
|
|
1183
|
+
// · 后台完成 → 回来自动带到那条会话(省得自己翻侧栏)
|
|
1184
|
+
// · 前台完成 → 有人想跳过去看一眼,有人**故意不想跳**
|
|
1185
|
+
// (想自己按绿点逐个确认哪些完成了)
|
|
1186
|
+
// 所以不替用户选,两两组合出四种行为。
|
|
1187
|
+
//
|
|
1188
|
+
// 实测限制(写进说明,别让用户以为是 bug):
|
|
1189
|
+
// Toast 的点击只能唤起窗口、带不了会话 id,所以"点通知才跳"做不到;
|
|
1190
|
+
// 只能用「窗口从后台回来」作为信号。因此直接点任务栏图标也会跳。
|
|
1191
|
+
rows.push(h('div', { key: 'sepHr2', style: { margin: '10px 0 4px', borderTop: '1px solid rgba(128,128,128,.25)' } }))
|
|
1192
|
+
rows.push(h('div', { key: 'jumpTitle', style: { fontWeight: 600, margin: '6px 0 2px' } }, '跳回对应会话'))
|
|
1193
|
+
rows.push(h('div', { key: 'jumpHint', style: S.subHint },
|
|
1194
|
+
'收到完成提醒后,自动带你回到那条会话。'))
|
|
1195
|
+
|
|
1196
|
+
rows.push(h('div', { key: 'jumpBg', style: S.toggleRow },
|
|
1197
|
+
h('input', {
|
|
1198
|
+
type: 'checkbox',
|
|
1199
|
+
checked: v.jumpWhenBackground !== false,
|
|
1200
|
+
disabled: s.busy,
|
|
1201
|
+
style: S.toggleBox,
|
|
1202
|
+
onChange: function (e) { apply({ jumpWhenBackground: !!e.target.checked }, '跳转设置已更新') },
|
|
1203
|
+
}),
|
|
1204
|
+
h('div', { style: S.toggleText },
|
|
1205
|
+
h('span', { style: S.toggleLabel }, '在后台完成时跳转'),
|
|
1206
|
+
h('span', { style: S.toggleDesc },
|
|
1207
|
+
' — 你在别的窗口忙、任务完成了,回来时直接落到那条会话。'),
|
|
1208
|
+
),
|
|
1209
|
+
))
|
|
1210
|
+
|
|
1211
|
+
rows.push(h('div', { key: 'jumpFg', style: S.toggleRow },
|
|
1212
|
+
h('input', {
|
|
1213
|
+
type: 'checkbox',
|
|
1214
|
+
checked: v.jumpWhenForeground === true,
|
|
1215
|
+
disabled: s.busy,
|
|
1216
|
+
style: S.toggleBox,
|
|
1217
|
+
onChange: function (e) { apply({ jumpWhenForeground: !!e.target.checked }, '跳转设置已更新') },
|
|
1218
|
+
}),
|
|
1219
|
+
h('div', { style: S.toggleText },
|
|
1220
|
+
h('span', { style: S.toggleLabel }, '窗口就在眼前时也跳转'),
|
|
1221
|
+
h('span', { style: S.toggleDesc },
|
|
1222
|
+
' — 默认关。关掉时你正看着屏幕,它不会动你的位置;'
|
|
1223
|
+
+ '打开则每次完成都带你过去看一眼。'),
|
|
1224
|
+
),
|
|
1225
|
+
))
|
|
1226
|
+
|
|
1227
|
+
// 两个都关 = 完全关闭。明确说出来,免得用户以为坏了。
|
|
1228
|
+
if (v.jumpWhenBackground === false && v.jumpWhenForeground !== true) {
|
|
1229
|
+
rows.push(h('div', { key: 'jumpOff', style: S.subHint }, '当前:两个都关,不会自动跳转。'))
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// ── 冷却(v0.4.7)─────────────────────────────────────────────────────
|
|
1233
|
+
//
|
|
1234
|
+
// 用户 2026-10-17 明确要求:"同一会话的冷却我们可以给它关了,
|
|
1235
|
+
// 甚至可以把不同会话的冷却给它加上。"
|
|
1236
|
+
//
|
|
1237
|
+
// 两类人诉求相反,所以两级都做成可调:
|
|
1238
|
+
// 同一会话:连续完成多次只提醒最后一次(防噪音)
|
|
1239
|
+
// 所有会话:任意会话响过之后,一段时间内其余会话都不出声
|
|
1240
|
+
// ("我已经听到一个、过来了,别再叫")
|
|
1241
|
+
//
|
|
1242
|
+
// 跨会话默认 0(关闭),保持"不同会话都会响" —— 想每个结果都听见的人不会漏。
|
|
1243
|
+
rows.push(h('div', { key: 'sepHr3', style: { margin: '10px 0 4px', borderTop: '1px solid rgba(128,128,128,.25)' } }))
|
|
1244
|
+
rows.push(h('div', { key: 'coolTitle', style: { fontWeight: 600, margin: '6px 0 2px' } }, '提醒频率'))
|
|
1245
|
+
rows.push(h('div', { key: 'coolHint', style: S.subHint },
|
|
1246
|
+
'嫌吵就拉开间隔;想每个结果都听见就把「同一会话」调成关闭。'))
|
|
1247
|
+
|
|
1248
|
+
var COOLDOWN_CHOICES = [
|
|
1249
|
+
[0, '关闭(每次都提醒)'],
|
|
1250
|
+
[15, '15 秒'],
|
|
1251
|
+
[30, '30 秒'],
|
|
1252
|
+
[60, '1 分钟'],
|
|
1253
|
+
[120, '2 分钟'],
|
|
1254
|
+
[300, '5 分钟'],
|
|
1255
|
+
]
|
|
1256
|
+
var curDeb = Math.round(typeof v.debounceSeconds === 'number' ? v.debounceSeconds : 60)
|
|
1257
|
+
var curGlb = Math.round(typeof v.globalCooldownSeconds === 'number' ? v.globalCooldownSeconds : 0)
|
|
1258
|
+
|
|
1259
|
+
/** 造一个秒数下拉(当前值不在预设里时补一项,避免显示错乱)。 */
|
|
1260
|
+
var mkCooldownSelect = function (cur, key) {
|
|
1261
|
+
var opts = COOLDOWN_CHOICES.map(function (p) {
|
|
1262
|
+
return h('option', { key: String(p[0]), value: String(p[0]) }, p[1])
|
|
1263
|
+
})
|
|
1264
|
+
if (!COOLDOWN_CHOICES.some(function (p) { return p[0] === cur })) {
|
|
1265
|
+
opts = opts.concat([h('option', { key: 'custom', value: String(cur) }, cur + ' 秒')])
|
|
1266
|
+
}
|
|
1267
|
+
return h('select', {
|
|
1268
|
+
value: String(cur),
|
|
1269
|
+
disabled: s.busy,
|
|
1270
|
+
onChange: (function (k) {
|
|
1271
|
+
return function (e) {
|
|
1272
|
+
var patch = {}
|
|
1273
|
+
patch[k] = Number(e.target.value)
|
|
1274
|
+
apply(patch, '提醒频率已更新')
|
|
1275
|
+
}
|
|
1276
|
+
})(key),
|
|
1277
|
+
}, opts)
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
rows.push(h('div', { key: 'deb', style: S.row },
|
|
1281
|
+
h('span', { style: S.label }, '同一会话'),
|
|
1282
|
+
mkCooldownSelect(curDeb, 'debounceSeconds'),
|
|
1283
|
+
h('span', { style: S.hint }, curDeb === 0 ? '每次都响' : '这段时间内不重复'),
|
|
1284
|
+
))
|
|
1285
|
+
|
|
1286
|
+
rows.push(h('div', { key: 'glb', style: S.row },
|
|
1287
|
+
h('span', { style: S.label }, '所有会话'),
|
|
1288
|
+
mkCooldownSelect(curGlb, 'globalCooldownSeconds'),
|
|
1289
|
+
h('span', { style: S.hint }, curGlb === 0 ? '每个会话都会响' : '响一次后安静一会儿'),
|
|
1290
|
+
))
|
|
1291
|
+
|
|
1292
|
+
// ── 高级(折叠,v0.4.5)──────────────────────────────────────────────
|
|
1293
|
+
//
|
|
1294
|
+
// 放在折叠区而不是主界面,理由:
|
|
1295
|
+
// - 大多数人永远不需要"给某一种提醒单独换音源",铺开只会劝退;
|
|
1296
|
+
// - 但真正需要的人(比如"完成听日语、失败听系统警告音")又必须能用。
|
|
1297
|
+
//
|
|
1298
|
+
// 用法:每一行的下拉默认「跟随上面的声音来源」,改过才覆盖。
|
|
1299
|
+
// 只把**偏离默认的项**存进配置(kindSourceFail='system'),
|
|
1300
|
+
// 所以以后改全局默认,没单独改过的事件会跟着变 —— 符合直觉。
|
|
1301
|
+
var advOpen = st[0].advOpen === true
|
|
1302
|
+
rows.push(h('div', { key: 'adv', style: S.advHead },
|
|
1303
|
+
h('button', {
|
|
1304
|
+
type: 'button',
|
|
1305
|
+
style: S.advBtn,
|
|
1306
|
+
onClick: function () {
|
|
1307
|
+
set(function (p) {
|
|
1308
|
+
return {
|
|
1309
|
+
loaded: p.loaded, values: p.values, packs: p.packs, busy: p.busy, msg: p.msg, err: p.err,
|
|
1310
|
+
advOpen: p.advOpen !== true,
|
|
1311
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
1312
|
+
}
|
|
1313
|
+
})
|
|
1314
|
+
},
|
|
1315
|
+
}, (advOpen ? '▾ ' : '▸ ') + '高级:给每种提醒单独设置'),
|
|
1316
|
+
))
|
|
1317
|
+
|
|
1318
|
+
if (advOpen) {
|
|
1319
|
+
var KIND_ROWS = [['Done', '任务完成'], ['Fail', '任务失败'],
|
|
1320
|
+
['Question', '向你提问'], ['Approval', '待你批准'], ['Blocked', '被拦截']]
|
|
1321
|
+
var FOLLOW = '__follow__'
|
|
1322
|
+
rows.push(h('div', { key: 'advhint', style: S.subHint },
|
|
1323
|
+
'默认跟随上面的「声音来源」。只有你改过的那几行才会单独生效。'))
|
|
1324
|
+
KIND_ROWS.forEach(function (pair) {
|
|
1325
|
+
var suffix = pair[0]
|
|
1326
|
+
var label = pair[1]
|
|
1327
|
+
// ⚠️ 读与写**用的字段名不同**,这是 v0.4.10 修的一个真 bug:
|
|
1328
|
+
//
|
|
1329
|
+
// 写入:扁平键 kindSourceFail = 'system'(config-store 白名单只认标量)
|
|
1330
|
+
// 读回:backend 的 normalizeConfig 会把它聚合成
|
|
1331
|
+
// audioSourceByKind = { fail: 'system' }
|
|
1332
|
+
// —— 生效值里**没有** kindSourceFail 这个键
|
|
1333
|
+
//
|
|
1334
|
+
// 原先前端读写都用扁平键,于是 GET 回来的 v 里取不到值,
|
|
1335
|
+
// 每行永远显示「跟随默认」,用户以为设置没生效(实际存进去了)。
|
|
1336
|
+
var writeKey = 'kindSource' + suffix
|
|
1337
|
+
var kindLower = suffix.toLowerCase()
|
|
1338
|
+
var byKind = (v.audioSourceByKind && typeof v.audioSourceByKind === 'object')
|
|
1339
|
+
? v.audioSourceByKind : {}
|
|
1340
|
+
var cur = byKind[kindLower] || FOLLOW
|
|
1341
|
+
rows.push(h('div', { key: 'ks-' + suffix, style: S.row },
|
|
1342
|
+
h('span', { style: S.label }, label),
|
|
1343
|
+
h('select', {
|
|
1344
|
+
value: cur,
|
|
1345
|
+
disabled: s.busy,
|
|
1346
|
+
onChange: (function (k) {
|
|
1347
|
+
return function (e) {
|
|
1348
|
+
var patch = {}
|
|
1349
|
+
// 选「跟随默认」就把键清空,让配置回到最小状态
|
|
1350
|
+
patch[k] = e.target.value === FOLLOW ? '' : e.target.value
|
|
1351
|
+
apply(patch, '已更新')
|
|
1352
|
+
}
|
|
1353
|
+
})(writeKey),
|
|
1354
|
+
}, [
|
|
1355
|
+
h('option', { key: FOLLOW, value: FOLLOW }, '跟随默认(' + (SRC_NAME[curSource] || curSource) + ')'),
|
|
1356
|
+
h('option', { key: 'tts', value: 'tts' }, '系统语音合成'),
|
|
1357
|
+
h('option', { key: 'bundled', value: 'bundled' }, '语音包'),
|
|
1358
|
+
h('option', { key: 'system', value: 'system' }, '系统提示音'),
|
|
1359
|
+
h('option', { key: 'custom', value: 'custom' }, '我的音频文件'),
|
|
1360
|
+
h('option', { key: 'none', value: 'none' }, '不出声'),
|
|
1361
|
+
]),
|
|
1362
|
+
cur === FOLLOW ? null : h('span', { style: S.hint }, '(已单独设置)'),
|
|
1363
|
+
))
|
|
1364
|
+
|
|
1365
|
+
// ── 该事件的**子设置**(v0.4.9)──────────────────────────────────
|
|
1366
|
+
//
|
|
1367
|
+
// 用户 2026-10-17 指出:只选了音源类型,但"念什么话、什么声音、
|
|
1368
|
+
// 什么音效"还得回上面用全局的,不合逻辑。所以每行在**覆盖了音源**时
|
|
1369
|
+
// 展开该音源自己的参数;「跟随默认」时不展开(避免界面爆炸)。
|
|
1370
|
+
//
|
|
1371
|
+
// 参数矩阵:
|
|
1372
|
+
// tts → 发音人 + 语速 + 台词
|
|
1373
|
+
// bundled → 音色模式 + 音色名
|
|
1374
|
+
// system → 系统音效名
|
|
1375
|
+
// custom → 选择文件
|
|
1376
|
+
var effSrc = (cur === FOLLOW) ? curSource : cur
|
|
1377
|
+
if (cur === FOLLOW) return // 跟随默认 → 不展开
|
|
1378
|
+
|
|
1379
|
+
var sub = []
|
|
1380
|
+
var kKey = function (base) { return base + suffix }
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* 读按事件覆盖的值。
|
|
1384
|
+
*
|
|
1385
|
+
* ⚠️ 写入用**扁平键**(kindTtsVoiceDone),读回用**字典**
|
|
1386
|
+
* (values.kindTtsVoice.done)—— 因为 backend 的 normalizeConfig
|
|
1387
|
+
* 会把扁平键聚合成字典,生效值里没有扁平键。
|
|
1388
|
+
* 第一版读写都用扁平键,导致界面永远显示"跟随默认"(实际已存)。
|
|
1389
|
+
*
|
|
1390
|
+
* @param {string} dictName 字典名(如 'kindTtsVoice')
|
|
1391
|
+
* @param {*} dflt 取不到时的默认值
|
|
1392
|
+
*/
|
|
1393
|
+
var readKind = function (dictName, dflt) {
|
|
1394
|
+
var m = v[dictName]
|
|
1395
|
+
if (m && typeof m === 'object') {
|
|
1396
|
+
var got = m[kindLower]
|
|
1397
|
+
if (got !== undefined && got !== null && got !== '') return got
|
|
1398
|
+
}
|
|
1399
|
+
return dflt
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
if (effSrc === 'tts') {
|
|
1403
|
+
// 发音人
|
|
1404
|
+
var vNameKey = kKey('kindTtsVoice')
|
|
1405
|
+
var vNameCur = readKind('kindTtsVoice', '')
|
|
1406
|
+
sub.push(h('div', { key: 'ttsv-' + suffix, style: S.subRow },
|
|
1407
|
+
h('span', { style: S.subLabel }, '朗读声音'),
|
|
1408
|
+
h('select', {
|
|
1409
|
+
value: vNameCur,
|
|
1410
|
+
disabled: s.busy,
|
|
1411
|
+
onChange: (function (k) {
|
|
1412
|
+
return function (e) {
|
|
1413
|
+
var o = {}; o[k] = e.target.value; apply(o, '已更新')
|
|
1414
|
+
}
|
|
1415
|
+
})(vNameKey),
|
|
1416
|
+
}, [
|
|
1417
|
+
h('option', { key: '', value: '' }, '跟随全局'),
|
|
1418
|
+
h('option', { key: 'hui', value: 'Microsoft Huihui Desktop' }, 'Huihui(中文女声)'),
|
|
1419
|
+
h('option', { key: 'zira', value: 'Microsoft Zira Desktop' }, 'Zira(英文女声)'),
|
|
1420
|
+
]),
|
|
1421
|
+
))
|
|
1422
|
+
// 语速
|
|
1423
|
+
var vRateKey = kKey('kindTtsRate')
|
|
1424
|
+
var vRateCur = Number(readKind('kindTtsRate', 0)) || 0
|
|
1425
|
+
sub.push(h('div', { key: 'ttsr-' + suffix, style: S.subRow },
|
|
1426
|
+
h('span', { style: S.subLabel }, '语速'),
|
|
1427
|
+
h('input', {
|
|
1428
|
+
type: 'range', min: -10, max: 10, step: 1,
|
|
1429
|
+
value: vRateCur,
|
|
1430
|
+
disabled: s.busy,
|
|
1431
|
+
style: { flex: '1' },
|
|
1432
|
+
onChange: (function (k) {
|
|
1433
|
+
return function (e) {
|
|
1434
|
+
var n = Number(e.target.value)
|
|
1435
|
+
set(function (p) {
|
|
1436
|
+
var nv = {}; for (var kk in p.values) nv[kk] = p.values[kk]
|
|
1437
|
+
nv[k] = n
|
|
1438
|
+
return {
|
|
1439
|
+
loaded: p.loaded, values: nv, advOpen: p.advOpen, packs: p.packs,
|
|
1440
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
1441
|
+
busy: p.busy, msg: '', err: p.err,
|
|
1442
|
+
}
|
|
1443
|
+
})
|
|
1444
|
+
}
|
|
1445
|
+
})(vRateKey),
|
|
1446
|
+
onPointerUp: (function (k) {
|
|
1447
|
+
return function (e) {
|
|
1448
|
+
var o = {}; o[k] = Number(e.currentTarget.value); apply(o, '已更新')
|
|
1449
|
+
}
|
|
1450
|
+
})(vRateKey),
|
|
1451
|
+
onBlur: (function (k) {
|
|
1452
|
+
return function (e) {
|
|
1453
|
+
var o = {}; o[k] = Number(e.currentTarget.value); apply(o, '已更新')
|
|
1454
|
+
}
|
|
1455
|
+
})(vRateKey),
|
|
1456
|
+
}),
|
|
1457
|
+
h('span', { style: S.hint }, vRateCur === 0 ? '跟随全局' : (vRateCur < 0 ? '慢' : '快')),
|
|
1458
|
+
))
|
|
1459
|
+
// 台词(这个字段本来就按事件,直接复用)
|
|
1460
|
+
var txtKey = 'ttsText' + suffix
|
|
1461
|
+
var txtCur = String(readKind('ttsTexts', '') || '')
|
|
1462
|
+
sub.push(h('div', { key: 'ttst-' + suffix, style: S.subRow },
|
|
1463
|
+
h('span', { style: S.subLabel }, '台词'),
|
|
1464
|
+
h('input', {
|
|
1465
|
+
type: 'text',
|
|
1466
|
+
maxLength: 60,
|
|
1467
|
+
placeholder: '留空 = 内置随机',
|
|
1468
|
+
value: txtCur,
|
|
1469
|
+
disabled: s.busy,
|
|
1470
|
+
style: S.textInput,
|
|
1471
|
+
onChange: (function (k) {
|
|
1472
|
+
return function (e) {
|
|
1473
|
+
var val = e.target.value
|
|
1474
|
+
set(function (p) {
|
|
1475
|
+
var nv = {}; for (var kk in p.values) nv[kk] = p.values[kk]
|
|
1476
|
+
nv[k] = val
|
|
1477
|
+
return {
|
|
1478
|
+
loaded: p.loaded, values: nv, advOpen: p.advOpen, packs: p.packs,
|
|
1479
|
+
termsAck: p.termsAck, termsPending: p.termsPending,
|
|
1480
|
+
busy: p.busy, msg: '', err: p.err,
|
|
1481
|
+
}
|
|
1482
|
+
})
|
|
1483
|
+
}
|
|
1484
|
+
})(txtKey),
|
|
1485
|
+
onBlur: (function (k) {
|
|
1486
|
+
return function (e) {
|
|
1487
|
+
var o = {}; o[k] = e.target.value; apply(o, '已更新')
|
|
1488
|
+
}
|
|
1489
|
+
})(txtKey),
|
|
1490
|
+
onKeyDown: function (e) { if (e.key === 'Enter') { try { e.target.blur() } catch (x) { /* ignore */ } } },
|
|
1491
|
+
}),
|
|
1492
|
+
))
|
|
1493
|
+
} else if (effSrc === 'bundled') {
|
|
1494
|
+
// 音色模式
|
|
1495
|
+
var mKey = kKey('kindVoiceMode')
|
|
1496
|
+
var mCur = readKind('kindVoiceMode', '')
|
|
1497
|
+
sub.push(h('div', { key: 'km-' + suffix, style: S.subRow },
|
|
1498
|
+
h('span', { style: S.subLabel }, '音色模式'),
|
|
1499
|
+
h('select', {
|
|
1500
|
+
value: mCur,
|
|
1501
|
+
disabled: s.busy,
|
|
1502
|
+
onChange: (function (k) {
|
|
1503
|
+
return function (e) { var o = {}; o[k] = e.target.value; apply(o, '已更新') }
|
|
1504
|
+
})(mKey),
|
|
1505
|
+
}, [
|
|
1506
|
+
h('option', { key: '', value: '' }, '跟随全局'),
|
|
1507
|
+
h('option', { key: 'fixed', value: 'fixed' }, '固定音色'),
|
|
1508
|
+
h('option', { key: 'random', value: 'random' }, '每次随机'),
|
|
1509
|
+
]),
|
|
1510
|
+
))
|
|
1511
|
+
// 音色名
|
|
1512
|
+
var nKey = kKey('kindVoiceName')
|
|
1513
|
+
var nCur = readKind('kindVoiceName', '')
|
|
1514
|
+
var kOpts2 = VOICE_KEYS.slice()
|
|
1515
|
+
if (nCur && kOpts2.indexOf(nCur) < 0) kOpts2.unshift(nCur)
|
|
1516
|
+
sub.push(h('div', { key: 'kn-' + suffix, style: S.subRow },
|
|
1517
|
+
h('span', { style: S.subLabel }, '音色'),
|
|
1518
|
+
h('select', {
|
|
1519
|
+
value: nCur,
|
|
1520
|
+
disabled: s.busy || mCur === 'random',
|
|
1521
|
+
onChange: (function (k) {
|
|
1522
|
+
return function (e) { var o = {}; o[k] = e.target.value; apply(o, '已更新') }
|
|
1523
|
+
})(nKey),
|
|
1524
|
+
}, [h('option', { key: '', value: '' }, '跟随全局')].concat(
|
|
1525
|
+
kOpts2.map(function (x) { return h('option', { key: x, value: x }, x) }),
|
|
1526
|
+
)),
|
|
1527
|
+
mCur === 'random' ? h('span', { style: S.hint }, '随机模式下不生效') : null,
|
|
1528
|
+
))
|
|
1529
|
+
} else if (effSrc === 'system') {
|
|
1530
|
+
// 系统音效名
|
|
1531
|
+
var sKey = kKey('kindSystemSound')
|
|
1532
|
+
var sCur = readKind('kindSystemSound', '')
|
|
1533
|
+
sub.push(h('div', { key: 'kss-' + suffix, style: S.subRow },
|
|
1534
|
+
h('span', { style: S.subLabel }, '音效'),
|
|
1535
|
+
h('select', {
|
|
1536
|
+
value: sCur,
|
|
1537
|
+
disabled: s.busy,
|
|
1538
|
+
onChange: (function (k) {
|
|
1539
|
+
return function (e) { var o = {}; o[k] = e.target.value; apply(o, '已更新') }
|
|
1540
|
+
})(sKey),
|
|
1541
|
+
}, [
|
|
1542
|
+
h('option', { key: '', value: '' }, '按事件自动选'),
|
|
1543
|
+
h('option', { key: 'Asterisk', value: 'Asterisk' }, '提示音(轻快)'),
|
|
1544
|
+
h('option', { key: 'Question', value: 'Question' }, '询问音'),
|
|
1545
|
+
h('option', { key: 'Exclamation', value: 'Exclamation' }, '感叹音'),
|
|
1546
|
+
h('option', { key: 'Hand', value: 'Hand' }, '警告音(低沉)'),
|
|
1547
|
+
h('option', { key: 'Beep', value: 'Beep' }, '哔声'),
|
|
1548
|
+
]),
|
|
1549
|
+
))
|
|
1550
|
+
} else if (effSrc === 'custom') {
|
|
1551
|
+
// 选择文件(复用已有的 customFile* 键)
|
|
1552
|
+
var cKey = 'customFile' + suffix
|
|
1553
|
+
var cCur = readKind('customFiles', '')
|
|
1554
|
+
sub.push(h('div', { key: 'kc-' + suffix, style: S.subRow },
|
|
1555
|
+
h('span', { style: S.subLabel }, '音频'),
|
|
1556
|
+
h('button', {
|
|
1557
|
+
type: 'button',
|
|
1558
|
+
disabled: s.busy,
|
|
1559
|
+
style: S.btn,
|
|
1560
|
+
onClick: (function (k) {
|
|
1561
|
+
return function () {
|
|
1562
|
+
pickCustomFile().then(function (p) {
|
|
1563
|
+
if (p) { var o = {}; o[k] = p; apply(o, '已选择音频') }
|
|
1564
|
+
}).catch(function () { /* 内部已兜底 */ })
|
|
1565
|
+
}
|
|
1566
|
+
})(cKey),
|
|
1567
|
+
}, cCur ? '更换…' : '选择文件…'),
|
|
1568
|
+
h('span', { style: S.hint }, cCur || '(未选择,将用系统提示音)'),
|
|
1569
|
+
))
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
if (sub.length) {
|
|
1573
|
+
rows.push(h('div', { key: 'sub-' + suffix, style: S.subBox }, sub))
|
|
1574
|
+
}
|
|
1575
|
+
})
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
// ── 开发选项:热重载 ────────────────────────────────────────────────
|
|
1579
|
+
//
|
|
1580
|
+
// 让用户自己选,而不是我替他决定。默认关(生产安装不需要)。
|
|
1581
|
+
// 开启后:改插件源码保存 → 自动换成新代码,不用重启 DSH。
|
|
1582
|
+
rows.push(h('div', { key: 'sepHr', style: { margin: '10px 0 4px', borderTop: '1px solid rgba(128,128,128,.25)' } }))
|
|
1583
|
+
rows.push(h('div', { key: 'dr', style: S.toggleRow },
|
|
1584
|
+
h('input', {
|
|
1585
|
+
type: 'checkbox',
|
|
1586
|
+
checked: v.devReload === true,
|
|
1587
|
+
disabled: s.busy,
|
|
1588
|
+
style: S.toggleBox,
|
|
1589
|
+
onChange: function (e) { apply({ devReload: !!e.target.checked }, '热重载设置已更新') },
|
|
1590
|
+
}),
|
|
1591
|
+
h('div', { style: S.toggleText },
|
|
1592
|
+
h('span', { style: S.toggleLabel }, '开发:改代码后自动热重载'),
|
|
1593
|
+
h('span', { style: S.toggleDesc },
|
|
1594
|
+
' — 保存插件源码后自动生效,不必重启 DSH。默认关:'
|
|
1595
|
+
+ '它依赖宿主内部结构,升级后可能失效;失效时只报错,不影响正常播报。'),
|
|
1596
|
+
),
|
|
1597
|
+
))
|
|
1598
|
+
|
|
1599
|
+
// ── 日志级别(v0.4.34)──────────────────────────────────────────────
|
|
1600
|
+
//
|
|
1601
|
+
// 默认 info:只记元数据(事件、音源、成败、数量)。
|
|
1602
|
+
// debug:额外记台词正文与 Toast 标题正文 —— **可能含敏感信息**。
|
|
1603
|
+
//
|
|
1604
|
+
// 为什么放在高级区:多数人不需要改,但排查问题时要能开。
|
|
1605
|
+
rows.push(h('div', { key: 'll-row', style: S.row },
|
|
1606
|
+
h('span', { style: S.label }, '日志详细度'),
|
|
1607
|
+
h('select', {
|
|
1608
|
+
value: v.logLevel || 'info',
|
|
1609
|
+
disabled: s.busy,
|
|
1610
|
+
onChange: function (e) { apply({ logLevel: e.target.value }, '日志级别已更新') },
|
|
1611
|
+
},
|
|
1612
|
+
h('option', { value: 'info' }, '只记元数据(默认)'),
|
|
1613
|
+
h('option', { value: 'debug' }, '含台词正文(可能敏感)'),
|
|
1614
|
+
),
|
|
1615
|
+
))
|
|
1616
|
+
rows.push(h('div', { key: 'll-hint', style: S.subHint },
|
|
1617
|
+
v.logLevel === 'debug'
|
|
1618
|
+
? '⚠️ debug 档会把台词和通知正文写进日志。日志在 %TEMP%\\dsh-notify-say\\notify.log,'
|
|
1619
|
+
+ '提 Issue 前请先切回「只记元数据」或自行删改。'
|
|
1620
|
+
: '日志只记事件类型、音源、成败与数量,不含台词正文。排查问题时可切到 debug。'))
|
|
1621
|
+
|
|
1622
|
+
// ── 导出诊断(v0.4.35)───────────────────────────────────────────
|
|
1623
|
+
//
|
|
1624
|
+
// 用户提 Issue 时最需要的东西:一份**已脱敏**的日志。
|
|
1625
|
+
// 让他自己去 %TEMP% 找、手动删用户名,多数人不会做。
|
|
1626
|
+
rows.push(h('div', { key: 'dg-row', style: S.row },
|
|
1627
|
+
h('span', { style: S.label }, '诊断'),
|
|
1628
|
+
h('button', {
|
|
1629
|
+
type: 'button',
|
|
1630
|
+
disabled: s.busy,
|
|
1631
|
+
style: S.btn,
|
|
1632
|
+
onClick: function () { doExportDiagnostics() },
|
|
1633
|
+
}, '导出诊断文件'),
|
|
1634
|
+
))
|
|
1635
|
+
rows.push(h('div', { key: 'dg-hint', style: S.subHint },
|
|
1636
|
+
'导出到日志同目录(%TEMP%\\dsh-notify-say\\)。会自动把用户名替换为 <user>、'
|
|
1637
|
+
+ '绝对路径替换为 <path>,但**仍请自己再看一遍**再上传。'))
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
if (s.msg) rows.push(h('div', { key: 'msg', style: S.ok }, s.msg))
|
|
1641
|
+
if (s.err) rows.push(h('div', { key: 'err', style: S.err }, s.err))
|
|
1642
|
+
rows.push(h('div', { key: 'foot', style: S.foot },
|
|
1643
|
+
'改动立即生效,无需重启。以上为运行时覆盖,优先级高于 cordis.patch.yml。'))
|
|
1644
|
+
|
|
1645
|
+
return h('div', { style: S.box }, rows)
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* 注册设置页分区。
|
|
1650
|
+
*
|
|
1651
|
+
* 已通过 `inject = ['slots']` 声明硬依赖,理论上此刻 slots 一定就绪;
|
|
1652
|
+
* 但仍保留软判断:**宁可少一个分区,也不要让整个 client 激活失败**
|
|
1653
|
+
* (激活失败会连跳转一起丢掉)。
|
|
1654
|
+
*
|
|
1655
|
+
* @returns {Function|null} 清理函数(解除注册),没有注册则 null
|
|
1656
|
+
*/
|
|
1657
|
+
function registerSettingsSection(ctx) {
|
|
1658
|
+
try {
|
|
1659
|
+
var slots = ctx.get('slots', false)
|
|
1660
|
+
if (!slots || typeof slots.inject !== 'function' || typeof slots.register !== 'function') {
|
|
1661
|
+
try {
|
|
1662
|
+
console.warn('[dsh-notify-say] slots 不可用,设置页分区未注册(跳转仍可用)')
|
|
1663
|
+
} catch (e) { /* ignore */ }
|
|
1664
|
+
return null
|
|
1665
|
+
}
|
|
1666
|
+
var dispose = null
|
|
1667
|
+
slots.inject('settings.section', function () {
|
|
1668
|
+
try {
|
|
1669
|
+
// register 会返回一个 disposer —— 收好它,否则热重载会重复注册
|
|
1670
|
+
// (独立审查 2026-10-17 指出:原实现丢弃了返回值)
|
|
1671
|
+
var d = slots.register({
|
|
1672
|
+
name: 'settings.section',
|
|
1673
|
+
id: 'dsh-notify-say',
|
|
1674
|
+
order: 60,
|
|
1675
|
+
label: '通知',
|
|
1676
|
+
}, SettingsCard)
|
|
1677
|
+
if (typeof d === 'function') dispose = d
|
|
1678
|
+
} catch (e) { /* 注册失败不影响跳转 */ }
|
|
1679
|
+
})
|
|
1680
|
+
return function () { try { if (typeof dispose === 'function') dispose() } catch (e) { /* ignore */ } }
|
|
1681
|
+
} catch (e) { return null }
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// ── 2) 完成播报后跳回对应会话 ──────────────────────────────────────────
|
|
1685
|
+
//
|
|
1686
|
+
// ## 为什么不是"点 Toast 才跳"
|
|
1687
|
+
//
|
|
1688
|
+
// Windows Toast 的 `launch` 是固定串 `dsh://open`(ps1 里 activationType="protocol"),
|
|
1689
|
+
// 官方只支持唤窗,且 main.ts 是整串精确匹配 —— **拿不到"点的是哪条通知"**。
|
|
1690
|
+
// 所以只能折中:用「窗口从不可见变为可见」作为信号。
|
|
1691
|
+
//
|
|
1692
|
+
// ## 判定规则
|
|
1693
|
+
//
|
|
1694
|
+
// 通知到达时窗口**不可见** → 用户回来时**跳** (结果还没看,带他过去)
|
|
1695
|
+
// 通知到达时窗口**可见** → **不跳** (他正看着,别动他的位置,让他自己看绿点)
|
|
1696
|
+
//
|
|
1697
|
+
// 判定的实现形式:记录"用户最后一次离开的时刻",
|
|
1698
|
+
// 若通知时间 >= 离开时刻,说明通知是在用户离开期间到达的。
|
|
1699
|
+
//
|
|
1700
|
+
// **不设时效**:10 分钟后才回来看,结果仍然没看过,跳过去依然合理。
|
|
1701
|
+
//
|
|
1702
|
+
// ## 已知边界(已写进 GUI 的折叠说明)
|
|
1703
|
+
//
|
|
1704
|
+
// 用户直接点任务栏图标而非点 Toast 时,信号完全相同,无法区分 —— 也会跳。
|
|
1705
|
+
// 不接受就关掉这个开关。
|
|
1706
|
+
//
|
|
1707
|
+
// ⚠️ client 是**经典脚本**(__ModuleLoader__ 包裹),不能 import ESM。
|
|
1708
|
+
// 所以这里内联了 lib/session-jump.js 的两个纯函数。**两处必须保持一致**,
|
|
1709
|
+
// session-jump.test.mjs 测的是 lib 那份,client 这份由 client-bundle.test.mjs 校验一致性。
|
|
1710
|
+
|
|
1711
|
+
/** 与 lib/session-jump.js 的 isHidden 等价。 */
|
|
1712
|
+
function isHiddenDoc(doc) {
|
|
1713
|
+
try {
|
|
1714
|
+
if (!doc) return true
|
|
1715
|
+
if (typeof doc.visibilityState === 'string') return doc.visibilityState === 'hidden'
|
|
1716
|
+
if (typeof doc.hidden === 'boolean') return doc.hidden
|
|
1717
|
+
return false
|
|
1718
|
+
} catch (e) { return true }
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/**
|
|
1722
|
+
* 与 lib/session-jump.js 的 decideJump 等价(只保留判定,不带决策常量)。
|
|
1723
|
+
*
|
|
1724
|
+
* 两个独立开关组合出四种行为(用户 2026-10-17 要求的):
|
|
1725
|
+
* bg=true, fg=false → 后台跳、前台不跳(默认)
|
|
1726
|
+
* bg=true, fg=true → 都跳
|
|
1727
|
+
* bg=false, fg=true → 只前台跳
|
|
1728
|
+
* bg=false, fg=false → 都不跳(自己按绿点确认)
|
|
1729
|
+
*
|
|
1730
|
+
* ⚠️ 判定用**区间** [leftAt, returnedAt),不能只比 leftAt ——
|
|
1731
|
+
* 只比 leftAt 会导致"用户离开过一次之后,前台判定永久失效"
|
|
1732
|
+
* (独立审查 2026-10-17 抓到的严重缺陷)。
|
|
1733
|
+
*/
|
|
1734
|
+
function shouldJump(inp) {
|
|
1735
|
+
var i = inp || {}
|
|
1736
|
+
var bg = i.jumpWhenBackground === undefined ? true : i.jumpWhenBackground !== false
|
|
1737
|
+
var fg = i.jumpWhenForeground === true
|
|
1738
|
+
if (!bg && !fg) return false // 两个都关 = 功能关闭
|
|
1739
|
+
if (!i.sessionId) return false
|
|
1740
|
+
if (i.consumed === true) return false
|
|
1741
|
+
var left = Number(i.leftAt)
|
|
1742
|
+
var at = Number(i.notifiedAt)
|
|
1743
|
+
// Number(undefined) 是 NaN,而 `NaN <= 0` 为 false —— 必须显式归一成 0
|
|
1744
|
+
var retRaw = Number(i.returnedAt)
|
|
1745
|
+
var ret = isFinite(retRaw) ? retRaw : 0
|
|
1746
|
+
if (!isFinite(left) || left <= 0) return false // 不知何时离开 → 保守不跳
|
|
1747
|
+
if (!isFinite(at) || at <= 0) return false
|
|
1748
|
+
// 通知落在「用户不在的那段时间」内 → 后台完成
|
|
1749
|
+
var inAway = at >= left && (ret <= 0 || at < ret)
|
|
1750
|
+
if (inAway) return bg
|
|
1751
|
+
return fg // 前台完成
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
/** 与 lib/session-jump.js 的 createLeaveTracker 等价。 */
|
|
1755
|
+
/**
|
|
1756
|
+
* 与 lib/session-jump.js 的 createLeaveTracker 等价。
|
|
1757
|
+
*
|
|
1758
|
+
* 同时记 `leftAt`(离开)与 `returnedAt`(回来)—— 只记离开会让
|
|
1759
|
+
* "前台不跳"永久失效(见 shouldJump 的注释)。
|
|
1760
|
+
*/
|
|
1761
|
+
function makeLeaveTracker() {
|
|
1762
|
+
var leftAt = 0
|
|
1763
|
+
var returnedAt = 0
|
|
1764
|
+
var wasHidden = false
|
|
1765
|
+
var seen = false
|
|
1766
|
+
return {
|
|
1767
|
+
onVisibility: function (doc, now) {
|
|
1768
|
+
var hidden = isHiddenDoc(doc)
|
|
1769
|
+
var t = isFinite(now) ? now : Date.now()
|
|
1770
|
+
if (!seen) {
|
|
1771
|
+
seen = true
|
|
1772
|
+
wasHidden = hidden
|
|
1773
|
+
if (hidden) leftAt = t
|
|
1774
|
+
return
|
|
1775
|
+
}
|
|
1776
|
+
if (hidden && !wasHidden) {
|
|
1777
|
+
leftAt = t
|
|
1778
|
+
returnedAt = 0 // 新一轮离开,旧的回来说明作废
|
|
1779
|
+
} else if (!hidden && wasHidden) {
|
|
1780
|
+
returnedAt = t // 用户回来了
|
|
1781
|
+
}
|
|
1782
|
+
wasHidden = hidden
|
|
1783
|
+
},
|
|
1784
|
+
leftAt: function () { return leftAt },
|
|
1785
|
+
returnedAt: function () { return returnedAt },
|
|
1786
|
+
reset: function () { leftAt = 0; returnedAt = 0 },
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
function armSessionJump(ctx) {
|
|
1791
|
+
var leave = makeLeaveTracker()
|
|
1792
|
+
var busy = false
|
|
1793
|
+
|
|
1794
|
+
/** 从 host 取配置里的开关(拿不到就当关闭,绝不擅自跳)。 */
|
|
1795
|
+
/**
|
|
1796
|
+
* 从 host 取两个跳转开关。
|
|
1797
|
+
*
|
|
1798
|
+
* 拿不到配置时:**两个都返回 false**(不跳)。
|
|
1799
|
+
* 宁可少跳一次,也不要在用户没同意的情况下动他的界面。
|
|
1800
|
+
*/
|
|
1801
|
+
function jumpEnabled() {
|
|
1802
|
+
return loadConfig().then(function (v) {
|
|
1803
|
+
// ⚠️ 2026-10-18 外部审阅指出逻辑矛盾:loadConfig 失败返回 {},
|
|
1804
|
+
// 而 {}.jumpWhenBackground !== false 求值为 true —— 与"拿不到就不跳"正好相反。
|
|
1805
|
+
// 现在显式区分"读到了"与"没读到"。
|
|
1806
|
+
if (!v || typeof v !== "object" || Object.keys(v).length === 0) {
|
|
1807
|
+
return { jumpWhenBackground: false, jumpWhenForeground: false }
|
|
1808
|
+
}
|
|
1809
|
+
return {
|
|
1810
|
+
// 只有明确读到 true 才跳;缺失或 null 都不跳
|
|
1811
|
+
jumpWhenBackground: v.jumpWhenBackground === true,
|
|
1812
|
+
jumpWhenForeground: v.jumpWhenForeground === true,
|
|
1813
|
+
}
|
|
1814
|
+
}).catch(function () {
|
|
1815
|
+
return { jumpWhenBackground: false, jumpWhenForeground: false }
|
|
1816
|
+
})
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
var onVisibility = function () {
|
|
1820
|
+
try {
|
|
1821
|
+
var now = Date.now()
|
|
1822
|
+
leave.onVisibility(typeof document !== 'undefined' ? document : null, now)
|
|
1823
|
+
|
|
1824
|
+
// 只在"变为可见"时才考虑跳转
|
|
1825
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
|
|
1826
|
+
} catch (e) { return }
|
|
1827
|
+
if (busy) return
|
|
1828
|
+
busy = true
|
|
1829
|
+
|
|
1830
|
+
jumpEnabled().then(function (en) {
|
|
1831
|
+
if (!en) { busy = false; return null }
|
|
1832
|
+
return consumeLastSession().then(function (doc) {
|
|
1833
|
+
busy = false
|
|
1834
|
+
if (!doc || !doc.sessionId) return
|
|
1835
|
+
if (shouldJump({
|
|
1836
|
+
jumpWhenBackground: en.jumpWhenBackground,
|
|
1837
|
+
jumpWhenForeground: en.jumpWhenForeground,
|
|
1838
|
+
consumed: false,
|
|
1839
|
+
sessionId: doc.sessionId,
|
|
1840
|
+
notifiedAt: Number(doc.at),
|
|
1841
|
+
leftAt: leave.leftAt(),
|
|
1842
|
+
returnedAt: leave.returnedAt(),
|
|
1843
|
+
})) {
|
|
1844
|
+
leave.reset()
|
|
1845
|
+
openSession(ctx, doc.sessionId)
|
|
1846
|
+
}
|
|
1847
|
+
})
|
|
1848
|
+
}).catch(function () { busy = false })
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
try {
|
|
1852
|
+
if (typeof document !== 'undefined' && document.addEventListener) {
|
|
1853
|
+
document.addEventListener('visibilitychange', onVisibility)
|
|
1854
|
+
}
|
|
1855
|
+
} catch (e) { /* 拿不到 document 就算了 */ }
|
|
1856
|
+
|
|
1857
|
+
return function () {
|
|
1858
|
+
try {
|
|
1859
|
+
if (typeof document !== 'undefined' && document.removeEventListener) {
|
|
1860
|
+
document.removeEventListener('visibilitychange', onVisibility)
|
|
1861
|
+
}
|
|
1862
|
+
} catch (e) { /* ignore */ }
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
function apply(ctx) {
|
|
1867
|
+
// 两件事分别 try/catch:任一失败都不该拖累另一件。
|
|
1868
|
+
var disposeSection = null
|
|
1869
|
+
try { disposeSection = registerSettingsSection(ctx) } catch (e) { disposeSection = null }
|
|
1870
|
+
|
|
1871
|
+
var disposeJump = null
|
|
1872
|
+
try { disposeJump = armSessionJump(ctx) } catch (e) { disposeJump = null }
|
|
1873
|
+
|
|
1874
|
+
return function () {
|
|
1875
|
+
try { if (typeof disposeJump === 'function') disposeJump() } catch (e) { /* ignore */ }
|
|
1876
|
+
try { if (typeof disposeSection === 'function') disposeSection() } catch (e) { /* ignore */ }
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
exports.apply = apply
|
|
1881
|
+
exports.inject = inject
|
|
1882
|
+
exports.name = name
|
|
1883
|
+
return module.exports
|
|
1884
|
+
},
|
|
1885
|
+
})
|