dsh-dock 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,164 @@
1
+ // dsh-dock · 功能模块【语音朗读】· 宿主半部
2
+ //
3
+ // 职责:只做**配置持久化**。朗读本身发生在浏览器——Web Speech API 的
4
+ // speechSynthesis 是宿主页面(浏览器)的能力,声音从用户那台设备的扬声器出来;
5
+ // Node 侧既没有 speechSynthesis,也不该把会话正文搬到服务端绕一圈。
6
+ //
7
+ // RPC(webServer HTTP 路由,前缀 /dsh-dock/tts/):
8
+ // POST /status —— 当前朗读配置 + 会话级任务追踪快照(客户端据此触发自动朗读)
9
+ // POST /config —— 增量更新配置字段并持久化到 settings(dsh-dock 命名空间 tts 段)
10
+ //
11
+ // 配置模型(schema 见 src/host-core.js DockConfig.tts):
12
+ // autoPlay / autoPlayOnFinish / interruptOnNew / rate / pitch / volume /
13
+ // voiceURI / lang / skipCode / skipTables / maxChars / maxSentences
14
+ //
15
+ // ⚠️ voiceURI 是**设备相关**的:同一份配置在另一台机器上可能没有那个音色。
16
+ // 客户端使用前会用本机 getVoices() 校验,取不到就按语言回落(见 features/tts/view.jsx),
17
+ // 所以这里只负责原样存取,不做存在性校验(宿主也拿不到浏览器的音色表)。
18
+ import { sendJson, readBody, rejectUntrustedRequest, readDockRoot, mutateDockSection } from '../../src/host-core.js'
19
+ import { acquireTaskTracker } from '../../src/task-track.js'
20
+
21
+ /** 朗读配置字段清单(schema、默认值、增量合并三处共用同一份键名)。 */
22
+ export const TTS_FIELDS = [
23
+ 'autoPlay', 'autoPlayOnFinish', 'interruptOnNew',
24
+ 'rate', 'pitch', 'volume', 'voiceURI', 'lang',
25
+ 'skipCode', 'skipTables', 'maxChars', 'maxSentences',
26
+ ]
27
+
28
+ /** 数值字段的合法区间(越界钳制,不拒绝:面板滑块与手工 POST 都可能给到边界值)。 */
29
+ const NUMERIC_RANGE = {
30
+ rate: [0.1, 10], // 规范区间是 0.1~10,面板只开放 0.5~2
31
+ pitch: [0, 2],
32
+ volume: [0, 1],
33
+ maxChars: [0, 200000],
34
+ maxSentences: [0, 200],
35
+ }
36
+
37
+ const BOOLEAN_FIELDS = [
38
+ 'autoPlay', 'autoPlayOnFinish', 'interruptOnNew', 'skipCode', 'skipTables',
39
+ ]
40
+
41
+ const STRING_FIELDS = ['voiceURI', 'lang']
42
+
43
+ function defaultConfig() {
44
+ return {
45
+ autoPlay: false,
46
+ autoPlayOnFinish: true,
47
+ interruptOnNew: true,
48
+ rate: 1,
49
+ pitch: 1,
50
+ volume: 1,
51
+ voiceURI: '',
52
+ lang: '',
53
+ skipCode: true,
54
+ skipTables: true,
55
+ maxChars: 0,
56
+ maxSentences: 0,
57
+ }
58
+ }
59
+
60
+ /** 读本地 Config 镜像里的 tts 配置(schema 默认值已含在 resolved 里),异常回退默认。 */
61
+ export function readTtsConfig(ctx) {
62
+ const cfg = defaultConfig()
63
+ try {
64
+ const v = readDockRoot(ctx)
65
+ const t = v && typeof v === 'object' && v.tts && typeof v.tts === 'object' ? v.tts : null
66
+ if (t) {
67
+ for (const key of Object.keys(cfg)) {
68
+ if (t[key] !== undefined) cfg[key] = t[key]
69
+ }
70
+ }
71
+ } catch { /* settings 未挂载,用默认 */ }
72
+ return cfg
73
+ }
74
+
75
+ /**
76
+ * 增量合并请求体到配置:只接受白名单字段,类型不符忽略,数值越界钳制。
77
+ * 非法 JSON 由 readBody 抛错(400),未知字段静默忽略(面板与宿主版本可能不同步)。
78
+ */
79
+ export function mergeTtsConfig(cfg, patch) {
80
+ const p = patch && typeof patch === 'object' ? patch : {}
81
+ for (const key of BOOLEAN_FIELDS) {
82
+ if (typeof p[key] === 'boolean') cfg[key] = p[key]
83
+ }
84
+ for (const key of Object.keys(NUMERIC_RANGE)) {
85
+ if (typeof p[key] === 'number' && Number.isFinite(p[key])) {
86
+ const [lo, hi] = NUMERIC_RANGE[key]
87
+ cfg[key] = Math.min(hi, Math.max(lo, key === 'maxSentences' || key === 'maxChars' ? Math.round(p[key]) : p[key]))
88
+ }
89
+ }
90
+ for (const key of STRING_FIELDS) {
91
+ if (typeof p[key] === 'string') {
92
+ // voiceURI/lang 是浏览器给的标识串,限长即可;不解析、不访问网络
93
+ cfg[key] = p[key].trim().slice(0, 200)
94
+ }
95
+ }
96
+ return cfg
97
+ }
98
+
99
+ export const feature = {
100
+ id: 'tts',
101
+ name: '语音朗读',
102
+ description: '把会话回复读出来(浏览器语音合成 TTS):回合结束在会话下方点喇叭播放,也可设置自动朗读',
103
+ defaultEnabled: false,
104
+ setup(ctx) {
105
+ const disposers = []
106
+ const dispose = () => {
107
+ while (disposers.length > 0) {
108
+ const fn = disposers.pop()
109
+ try { if (typeof fn === 'function') fn() } catch { /* 停用清理失败不阻断 */ }
110
+ }
111
+ }
112
+
113
+ // 会话级任务追踪(与【任务动画】【任务通知】【运行状态】共享一份):
114
+ // 自动朗读的触发源——浏览器侧轮询 /status,看到新出现的结束记录(带 lastText)就读。
115
+ const lease = acquireTaskTracker(ctx)
116
+ disposers.push(() => lease.release())
117
+ const tracker = lease.tracker
118
+
119
+ disposers.push(ctx.inject(['webServer'], (wsCtx) => {
120
+ wsCtx.effect(() => wsCtx.webServer.register({
121
+ kind: 'prefix',
122
+ path: '/dsh-dock/tts',
123
+ async handler(req, res) {
124
+ if (rejectUntrustedRequest(ctx, req, res)) return
125
+ try {
126
+ const url = new URL(req.url || '/', 'http://dsh.internal')
127
+ const method = url.pathname.replace(/^\/dsh-dock\/tts\/?/, '').split('/')[0] || ''
128
+ const payload = await readBody(req)
129
+
130
+ if (method === 'status') {
131
+ return sendJson(res, 200, {
132
+ ok: true,
133
+ data: Object.assign({}, tracker.snapshot(Date.now()), { config: readTtsConfig(ctx) }),
134
+ })
135
+ }
136
+
137
+ if (method === 'config') {
138
+ const cfg = mergeTtsConfig(readTtsConfig(ctx), payload)
139
+ try {
140
+ await mutateDockSection(ctx, ['tts'], cfg)
141
+ } catch (e) {
142
+ const err = new Error('保存配置被拒绝:' + ((e && e.message) || String(e)))
143
+ err.statusCode = 400
144
+ throw err
145
+ }
146
+ return sendJson(res, 200, { ok: true, data: { config: cfg, savedAt: Date.now() } })
147
+ }
148
+
149
+ return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method } })
150
+ } catch (e) {
151
+ const status = e && e.statusCode ? e.statusCode : 500
152
+ console.error('[dsh-dock] tts HTTP error:', status, e && e.message)
153
+ return sendJson(res, status, {
154
+ ok: false,
155
+ error: { code: status >= 500 ? 'internal' : 'bad-request', message: (e && e.message) || String(e) },
156
+ })
157
+ }
158
+ },
159
+ }), 'dsh-dock tts: /dsh-dock/tts HTTP route')
160
+ }))
161
+
162
+ return dispose
163
+ },
164
+ }