dsh-speak 1.8.1 → 1.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -54
- package/README.zh-CN.md +104 -52
- package/adapters/dsh/install.ps1 +32 -7
- package/adapters/dsh/speech-hook.js +195 -107
- package/client/client.js +59 -12
- package/cordis.patch.yml +29 -5
- package/docs/DESIGN.md +87 -23
- package/docs/DESIGN.zh-CN.md +71 -22
- package/package.json +2 -7
|
@@ -19,9 +19,10 @@
|
|
|
19
19
|
// - on: every assistant/message is enqueued immediately as it arrives
|
|
20
20
|
// * a `/dsh-speak/control` POST route (play/stop/status) and a
|
|
21
21
|
// `/dsh-speak/ws` WebSocket publish the authoritative speech state
|
|
22
|
-
// * a
|
|
23
|
-
//
|
|
24
|
-
//
|
|
22
|
+
// * a settings form projected from THIS module's exported `Config` (DSH >=
|
|
23
|
+
// 0.1.7 reads each Loader entry's own Config); schema defaults → patch
|
|
24
|
+
// config → UI user layer. The settings namespace is the entry id
|
|
25
|
+
// (`dsh-speak`), and a write commits into the live config references
|
|
25
26
|
// * `enabled` master switch: when off, nothing is ever enqueued (no sound)
|
|
26
27
|
//
|
|
27
28
|
// Trigger semantics:
|
|
@@ -39,7 +40,6 @@
|
|
|
39
40
|
'use strict'
|
|
40
41
|
|
|
41
42
|
const { spawn } = require('child_process')
|
|
42
|
-
const { createRequire } = require('module')
|
|
43
43
|
const { WebSocketServer, WebSocket } = require('ws')
|
|
44
44
|
const fs = require('fs')
|
|
45
45
|
const os = require('os')
|
|
@@ -51,9 +51,6 @@ function log(...args) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const ENGINE_NAME = process.platform === 'darwin' ? 'speak.sh' : 'speak.ps1'
|
|
54
|
-
// Settings namespace of this plugin (lowercase kebab-case; must match the
|
|
55
|
-
// browser card's namespace in client/client.js).
|
|
56
|
-
const SETTINGS_NS = 'dsh-speak'
|
|
57
54
|
|
|
58
55
|
/**
|
|
59
56
|
* Locate the engine script:
|
|
@@ -73,10 +70,34 @@ function resolveEngine(override) {
|
|
|
73
70
|
const DEFAULT_MAX_CHARS = process.platform === 'darwin' ? 0 : 300
|
|
74
71
|
|
|
75
72
|
// ---------------------------------------------------------------------------
|
|
76
|
-
// Settings
|
|
73
|
+
// Settings (DSH >= 0.1.7: this module's own exported `Config` is the form)
|
|
77
74
|
// ---------------------------------------------------------------------------
|
|
78
|
-
//
|
|
79
|
-
//
|
|
75
|
+
// 0.1.7 replaced the imperative `settings.register(namespace, schema, { base })`
|
|
76
|
+
// provider API with Config projection: the settings service reads every ACTIVE
|
|
77
|
+
// Loader entry's own exported `Config` schema and projects its `.volatile()`
|
|
78
|
+
// fields into the settings UI (`ctx.settings.describe()` on the host,
|
|
79
|
+
// `ctx.configForms` in the browser). There is no namespace to register any
|
|
80
|
+
// more — the namespace IS the Loader entry id (`dsh-speak`; see
|
|
81
|
+
// cordis.patch.yml), which is what client/client.js binds.
|
|
82
|
+
//
|
|
83
|
+
// What that changes here:
|
|
84
|
+
// * the schema must exist as a static export, built at module load (the
|
|
85
|
+
// Loader reads `module.exports.Config` before any context exists),
|
|
86
|
+
// * volatile fields reach apply() as stable references (`config.enabled
|
|
87
|
+
// .get()`), not plain values,
|
|
88
|
+
// * a settings write commits into those references in place and emits
|
|
89
|
+
// `loader/volatile-update` — no restart, so cfg is re-derived there.
|
|
90
|
+
//
|
|
91
|
+
// Nothing is registered from this half any more: 0.1.2-alpha.1 had deleted
|
|
92
|
+
// `installSettingsSection` / `settingsNamespace`, and 0.1.7 deleted the
|
|
93
|
+
// `settings.register` service API that had replaced them in 1.6.0. The only
|
|
94
|
+
// thing left to do is opt out of the shell's auto-generated page (it would
|
|
95
|
+
// duplicate the hand-written one in client/client.js).
|
|
96
|
+
//
|
|
97
|
+
// Values still resolve as: schema default → patch `config` → UI user layer.
|
|
98
|
+
// `SCHEMA_DEFAULTS` is the normalization fallback used when the schemastery
|
|
99
|
+
// peer is unavailable (no Config → no settings page, patch config only);
|
|
100
|
+
// test-settings-integration.js asserts it stays in sync with the real schema.
|
|
80
101
|
const SCHEMA_DEFAULTS = {
|
|
81
102
|
enabled: true,
|
|
82
103
|
automaticSpeech: true,
|
|
@@ -105,10 +126,33 @@ const SCHEMA_DEFAULTS = {
|
|
|
105
126
|
announceTodoWrite: false,
|
|
106
127
|
}
|
|
107
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Coerce one numeric config field.
|
|
131
|
+
*
|
|
132
|
+
* An explicit value wins — including 0, which is meaningful for `throttleMs` (no
|
|
133
|
+
* merging), `maxChars` (macOS: unlimited) and `volume` (silence). The fallback
|
|
134
|
+
* applies only when the field is absent or unparseable, and the result is clamped
|
|
135
|
+
* to what the engine accepts, because an out-of-range SAPI `Rate` (-10..10) or
|
|
136
|
+
* `Volume` (0..100) makes `speak.ps1` throw — i.e. silence with no explanation.
|
|
137
|
+
* `||` is deliberately NOT used here: it silently turned a user's 0 into the
|
|
138
|
+
* fallback and let negatives through.
|
|
139
|
+
* @param field - config key, for the clamp diagnostic.
|
|
140
|
+
* @returns the usable number.
|
|
141
|
+
*/
|
|
142
|
+
function configNumber(field, value, fallback, min, max) {
|
|
143
|
+
if (value === undefined || value === null || value === '') return fallback
|
|
144
|
+
const parsed = Number(value)
|
|
145
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
146
|
+
const clamped = Math.min(max, Math.max(min, Math.round(parsed)))
|
|
147
|
+
if (clamped !== parsed) log('settings 值超出范围,已钳制:', field, parsed, '->', clamped)
|
|
148
|
+
return clamped
|
|
149
|
+
}
|
|
150
|
+
|
|
108
151
|
/**
|
|
109
152
|
* Resolve the raw settings value into the mutable `cfg` the queue reads.
|
|
110
|
-
* Kept
|
|
111
|
-
* the
|
|
153
|
+
* Kept a pure mapping (only a diagnostic line when a value had to be clamped) so
|
|
154
|
+
* the initial apply and every `loader/volatile-update` normalize identically
|
|
155
|
+
* (engine re-resolution, platform defaults, SAPI-safe ranges).
|
|
112
156
|
*/
|
|
113
157
|
function resolveConfig(value) {
|
|
114
158
|
value = value || {}
|
|
@@ -118,21 +162,25 @@ function resolveConfig(value) {
|
|
|
118
162
|
cleanMarkdownFormatting: value.cleanMarkdownFormatting !== false,
|
|
119
163
|
readInlineCode: value.readInlineCode !== false,
|
|
120
164
|
codeBlocks: ['all', 'smart', 'replace'].includes(value.codeBlocks) ? value.codeBlocks : 'smart',
|
|
121
|
-
codeBlockMaxChars:
|
|
165
|
+
codeBlockMaxChars: configNumber('codeBlockMaxChars', value.codeBlockMaxChars, 300, 0, Number.MAX_SAFE_INTEGER),
|
|
122
166
|
codeBlockReplacementText: String(value.codeBlockReplacementText || 'You can see the code in our history.'),
|
|
123
167
|
queueAllMessages: value.queueAllMessages === true,
|
|
124
|
-
throttleMs:
|
|
168
|
+
throttleMs: configNumber('throttleMs', value.throttleMs, 1500, 0, Number.MAX_SAFE_INTEGER),
|
|
125
169
|
replayFullRead: value.replayFullRead === true,
|
|
126
170
|
engine: resolveEngine(value.engine || ''),
|
|
127
171
|
announceApprovals: value.announceApprovals !== false,
|
|
128
172
|
announceQuestions: value.announceQuestions !== false,
|
|
129
173
|
stripApprovalPrefix: value.stripApprovalPrefix !== false,
|
|
130
|
-
questionGapMs:
|
|
174
|
+
questionGapMs: configNumber('questionGapMs', value.questionGapMs, 2000, 0, Number.MAX_SAFE_INTEGER),
|
|
131
175
|
longTextMode: value.longTextMode === 'heading' ? 'heading' : 'message',
|
|
132
176
|
longTextMessage: String(value.longTextMessage || SCHEMA_DEFAULTS.longTextMessage),
|
|
133
|
-
maxChars:
|
|
134
|
-
volume:
|
|
135
|
-
|
|
177
|
+
maxChars: configNumber('maxChars', value.maxChars, DEFAULT_MAX_CHARS, 0, Number.MAX_SAFE_INTEGER),
|
|
178
|
+
volume: configNumber('volume', value.volume, 50, 0, 100),
|
|
179
|
+
// Windows: SAPI scale -10..10. macOS: words per minute (0 = engine default),
|
|
180
|
+
// where the engine passes -r only for a positive value anyway.
|
|
181
|
+
rate: process.platform === 'darwin'
|
|
182
|
+
? configNumber('rate', value.rate, 0, 0, Number.MAX_SAFE_INTEGER)
|
|
183
|
+
: configNumber('rate', value.rate, 0, -10, 10),
|
|
136
184
|
announceTurnEnd: value.announceTurnEnd === true,
|
|
137
185
|
announceCommandDone: value.announceCommandDone === true,
|
|
138
186
|
announceGoalChange: value.announceGoalChange === true,
|
|
@@ -142,111 +190,151 @@ function resolveConfig(value) {
|
|
|
142
190
|
}
|
|
143
191
|
|
|
144
192
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* @returns the
|
|
193
|
+
* Read one config field from whatever shape the Loader handed us: a volatile
|
|
194
|
+
* field arrives as a reference (`{ get() }`, cosmokit `createVolatile`), a
|
|
195
|
+
* plugin mounted without a Config schema receives plain values, and an omitted
|
|
196
|
+
* field is simply `undefined`.
|
|
197
|
+
* @returns the current plain value, or undefined.
|
|
150
198
|
*/
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
return
|
|
199
|
+
function configValue(config, key) {
|
|
200
|
+
if (!config) return undefined
|
|
201
|
+
const value = config[key]
|
|
202
|
+
if (value === undefined || value === null) return undefined
|
|
203
|
+
if (typeof value === 'object' && typeof value.get === 'function') return value.get()
|
|
204
|
+
return value
|
|
157
205
|
}
|
|
158
206
|
|
|
159
207
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
208
|
+
* Detach every known field into the plain object `resolveConfig` normalizes.
|
|
209
|
+
* Read fresh on each call: the references are updated in place by a settings
|
|
210
|
+
* write, so the same `config` object always yields the current values.
|
|
163
211
|
*/
|
|
164
|
-
function
|
|
212
|
+
function rawConfig(config) {
|
|
213
|
+
const raw = {}
|
|
214
|
+
for (const key of Object.keys(SCHEMA_DEFAULTS)) raw[key] = configValue(config, key)
|
|
215
|
+
return raw
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The settings namespace this plugin owns: its Loader entry id. That id is what
|
|
220
|
+
* the settings service projects the form under (`settings.describe()` →
|
|
221
|
+
* `ctx.configForms.get(id)` in the browser) and what client/client.js binds.
|
|
222
|
+
* `settingsNamespace` is also the name the plugin used for its settings before
|
|
223
|
+
* 0.1.7, so the browser half accepts either spelling.
|
|
224
|
+
* @returns the entry id, or undefined when mounted without a Loader.
|
|
225
|
+
*/
|
|
226
|
+
function settingsNamespace(ctx) {
|
|
227
|
+
try { return ctx.fiber && ctx.fiber.entry ? ctx.fiber.entry.options.id : undefined } catch (e) { return undefined }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Build the settings form schema — the module's static `Config` export.
|
|
232
|
+
*
|
|
233
|
+
* Resolved at load time because the Loader reads `module.exports.Config`
|
|
234
|
+
* immediately after importing the plugin, before any context exists (the old
|
|
235
|
+
* `ctx.baseUrl` fallback is therefore unavailable here, and `__filename` is
|
|
236
|
+
* enough: it walks the profile's own `node_modules` chain either way).
|
|
237
|
+
* Best-effort: an installation that cannot resolve the schemastery peer gets no
|
|
238
|
+
* settings page (Config stays undefined) and keeps running on the composed
|
|
239
|
+
* patch `config`.
|
|
240
|
+
* @returns the schema, or undefined when the peer is unavailable.
|
|
241
|
+
*/
|
|
242
|
+
function buildConfig() {
|
|
165
243
|
try {
|
|
166
|
-
const z =
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
announceTodoWrite: z.boolean().default(false),
|
|
244
|
+
const z = require('module').createRequire(__filename)('@deepseek-ai/schemastery')
|
|
245
|
+
return z.object({
|
|
246
|
+
enabled: z.boolean().default(true).volatile(),
|
|
247
|
+
automaticSpeech: z.boolean().default(true).volatile(),
|
|
248
|
+
cleanMarkdownFormatting: z.boolean().default(true).volatile(),
|
|
249
|
+
readInlineCode: z.boolean().default(true).volatile(),
|
|
250
|
+
codeBlocks: z.union(['all', 'smart', 'replace']).default('smart').volatile(),
|
|
251
|
+
codeBlockMaxChars: z.natural().default(300).volatile(),
|
|
252
|
+
codeBlockReplacementText: z.string().default('You can see the code in our history.').volatile(),
|
|
253
|
+
queueAllMessages: z.boolean().default(false).volatile(),
|
|
254
|
+
throttleMs: z.natural().default(1500).volatile(),
|
|
255
|
+
replayFullRead: z.boolean().default(false).volatile(),
|
|
256
|
+
engine: z.string().default('').volatile(),
|
|
257
|
+
announceApprovals: z.boolean().default(true).volatile(),
|
|
258
|
+
announceQuestions: z.boolean().default(true).volatile(),
|
|
259
|
+
stripApprovalPrefix: z.boolean().default(true).volatile(),
|
|
260
|
+
questionGapMs: z.natural().default(2000).volatile(),
|
|
261
|
+
longTextMode: z.union(['message', 'heading']).default('message').volatile(),
|
|
262
|
+
longTextMessage: z.string().default('本次播报内容较长,请自行阅读。').volatile(),
|
|
263
|
+
maxChars: z.natural().default(DEFAULT_MAX_CHARS).volatile(),
|
|
264
|
+
volume: z.natural().default(50).volatile(),
|
|
265
|
+
rate: z.number().default(0).volatile(),
|
|
266
|
+
announceTurnEnd: z.boolean().default(false).volatile(),
|
|
267
|
+
announceCommandDone: z.boolean().default(false).volatile(),
|
|
268
|
+
announceGoalChange: z.boolean().default(false).volatile(),
|
|
269
|
+
announceToolErrors: z.boolean().default(false).volatile(),
|
|
270
|
+
announceTodoWrite: z.boolean().default(false).volatile(),
|
|
194
271
|
})
|
|
195
|
-
return { schema, entry: { ...SCHEMA_DEFAULTS, ...(patch || {}) } }
|
|
196
272
|
} catch (e) {
|
|
197
|
-
log('settings 依赖不可用,跳过 settings
|
|
198
|
-
return
|
|
273
|
+
log('settings 依赖不可用,跳过 settings 表单(继续用 patch config):', e && e.message)
|
|
274
|
+
return undefined
|
|
199
275
|
}
|
|
200
276
|
}
|
|
201
277
|
|
|
202
278
|
module.exports = {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
279
|
+
// Static settings form schema projected by the settings service (DSH >=
|
|
280
|
+
// 0.1.7). Undefined when the schemastery peer cannot be resolved.
|
|
281
|
+
Config: buildConfig(),
|
|
206
282
|
|
|
207
|
-
|
|
208
|
-
//
|
|
283
|
+
apply(ctx, config) {
|
|
284
|
+
// ---- duplicate-entry guard ----------------------------------------------
|
|
285
|
+
// One profile must mount this plugin exactly ONCE. A second row with the same
|
|
286
|
+
// id (or the legacy `speech-hook` id) is easy to create by accident — adding
|
|
287
|
+
// `dsh-speak` to `dsh.profile.bundles` while the hand-written insert row is
|
|
288
|
+
// still there is the usual way. Two live instances would mean two FIFO queues
|
|
289
|
+
// announcing everything twice and two claims on the `/dsh-speak/ws` route.
|
|
209
290
|
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
// of a deleted export is a module-evaluation SyntaxError that kills the host
|
|
216
|
-
// boot, and a lazy `settingsModule.installSettingsSection(...)` call — what
|
|
217
|
-
// this plugin used to do inside a timer callback — throws
|
|
218
|
-
// `settingsNamespace is not a function` and crashed dsh before it served.
|
|
291
|
+
// The claim is keyed on `globalThis`, not on a module variable: two rows may
|
|
292
|
+
// name this file differently (a `file:///…` URL beside the bare package name)
|
|
293
|
+
// and Node would then evaluate the module twice, each copy seeing its own
|
|
294
|
+
// module-scope flag. The extra instance stays inert and says so in the log —
|
|
295
|
+
// remove the duplicate row and reload to hand ownership over.
|
|
219
296
|
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
try { cfg = resolveConfig(settingsSource()) } catch (e) { log('settings 变更应用失败:', e && e.message) }
|
|
232
|
-
}
|
|
233
|
-
try {
|
|
234
|
-
const scope = scopedCtx.settings.register(SETTINGS_NS, prepared.schema, { base: prepared.entry })
|
|
235
|
-
settingsSource = () => scope.get()
|
|
236
|
-
// Unload restores the composed entry, so a disabled plugin cannot
|
|
237
|
-
// leave the queue reading a value nobody can see or change any more.
|
|
238
|
-
scopedCtx.effect(() => () => {
|
|
239
|
-
settingsSource = () => prepared.entry
|
|
240
|
-
applySettings()
|
|
241
|
-
})
|
|
242
|
-
scope.watch(applySettings)
|
|
243
|
-
applySettings()
|
|
244
|
-
log('settings namespace 已注册:', SETTINGS_NS)
|
|
245
|
-
} catch (e) {
|
|
246
|
-
log('settings namespace 注册失败,继续使用 patch config:', e && e.message)
|
|
247
|
-
}
|
|
248
|
-
})
|
|
297
|
+
// This guard protects SPEECH only. A duplicated entry id also breaks the
|
|
298
|
+
// settings page in a way this half cannot fix: DSH's config editor keeps only
|
|
299
|
+
// uniquely-ided entries, so every write is refused with
|
|
300
|
+
// `settings/rejected: Configuration for "dsh-speak" is overridden by a home
|
|
301
|
+
// patch or command-line overlay` while speech keeps working. Hence the hint in
|
|
302
|
+
// the log line below.
|
|
303
|
+
const claim = Symbol.for('dsh-speak.active')
|
|
304
|
+
if (globalThis[claim] !== undefined) {
|
|
305
|
+
log('已有实例在运行,本行不再挂载(条目 id =', settingsNamespace(ctx),
|
|
306
|
+
'):请删掉重复的 dsh-speak 行后重载 —— 重复的 id 还会让设置页的写入被拒(overridden by a home patch)')
|
|
307
|
+
return
|
|
249
308
|
}
|
|
309
|
+
globalThis[claim] = true
|
|
310
|
+
ctx.effect(() => () => { if (globalThis[claim] === true) delete globalThis[claim] }, 'dsh-speak: single-instance claim')
|
|
311
|
+
|
|
312
|
+
// Live configuration: the references inside `config` are updated in place
|
|
313
|
+
// by a settings write, so cfg is re-derived from them (see below).
|
|
314
|
+
const readConfig = () => resolveConfig(rawConfig(config))
|
|
315
|
+
let cfg = readConfig()
|
|
316
|
+
|
|
317
|
+
// ---- settings presentation ----------------------------------------------
|
|
318
|
+
// This plugin ships a hand-written settings page (client/client.js), so the
|
|
319
|
+
// entry opts out of the shell's automatically generated form — otherwise the
|
|
320
|
+
// same fields would appear twice. Presentation-only and non-fatal: a host
|
|
321
|
+
// without the settings service simply has no pages at all.
|
|
322
|
+
ctx.inject(['settings'], scopedCtx => {
|
|
323
|
+
if (!ctx.fiber) return
|
|
324
|
+
try {
|
|
325
|
+
scopedCtx.effect(() => scopedCtx.settings.configure({ auto: false }, ctx.fiber))
|
|
326
|
+
log('settings 表单由 settings 服务投影(entry =', settingsNamespace(ctx), ',自动页面已关闭)')
|
|
327
|
+
} catch (e) {
|
|
328
|
+
log('settings.configure 失败,保留默认页面策略:', e && e.message)
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
// A settings write commits into the running fiber's config references and
|
|
333
|
+
// emits this event; re-deriving cfg is what makes the edit audible without
|
|
334
|
+
// restarting dsh.
|
|
335
|
+
ctx.on('loader/volatile-update', () => {
|
|
336
|
+
try { cfg = readConfig() } catch (e) { log('settings 变更应用失败:', e && e.message) }
|
|
337
|
+
})
|
|
250
338
|
|
|
251
339
|
// ---- host-owned FIFO speech queue + WebSocket state sync (PR #2) ----
|
|
252
340
|
let activeSpeech = null
|
package/client/client.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// Host contract (adapters/dsh/speech-hook.js):
|
|
11
11
|
// * /dsh-speak/control — POST { action: 'play'|'stop'|'status', ... }
|
|
12
12
|
// * /dsh-speak/ws — WebSocket publishing { type: 'speech-state', ... }
|
|
13
|
-
// * settings
|
|
13
|
+
// * settings form projected from the host plugin's own Config export
|
|
14
|
+
// (settings namespace = the host entry id, see SETTINGS_NAMESPACES)
|
|
14
15
|
//
|
|
15
16
|
// The bundle is deliberately hand-written (no build step) and only uses
|
|
16
17
|
// platform seed modules + official primitives (bundle-purity gate).
|
|
@@ -21,10 +22,16 @@ window.__ModuleLoader__.load({
|
|
|
21
22
|
factory: require => {
|
|
22
23
|
const module = { exports: {} }
|
|
23
24
|
const React = require('react')
|
|
24
|
-
const { Button, DisclosureRow,
|
|
25
|
+
const { Button, DisclosureRow, IconPauseOutlineRegular, Input } = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
25
26
|
const CONTROL_PATH = '/dsh-speak/control'
|
|
26
27
|
const SOCKET_PATH = '/dsh-speak/ws'
|
|
27
|
-
|
|
28
|
+
// Settings namespace(s) this card edits. On DSH >= 0.1.7 the settings form is
|
|
29
|
+
// projected from the HOST entry's own Config export, and the namespace is that
|
|
30
|
+
// entry's Loader id — there is no client-side namespace registration any more.
|
|
31
|
+
// `dsh-speak` is the id this package ships and documents; `speech-hook` is the
|
|
32
|
+
// id every 1.8.x profile patch (and this package's own bundle patch) used, so
|
|
33
|
+
// an upgraded profile keeps its settings page without an edit.
|
|
34
|
+
const SETTINGS_NAMESPACES = ['dsh-speak', 'speech-hook']
|
|
28
35
|
|
|
29
36
|
// ---- locale copy (zh / en) -------------------------------------------
|
|
30
37
|
const NS = 'dsh-speak'
|
|
@@ -165,14 +172,17 @@ window.__ModuleLoader__.load({
|
|
|
165
172
|
todoWriteHint: 'Announces when the agent updates its todos.',
|
|
166
173
|
}
|
|
167
174
|
|
|
168
|
-
module.exports.inject = ['slots', 'timer', '
|
|
175
|
+
module.exports.inject = ['slots', 'timer', 'configForms', 'locale']
|
|
169
176
|
module.exports.apply = function apply(ctx) {
|
|
170
177
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-speak: dictionaries')
|
|
171
178
|
const t = ctx.locale.bind(NS)
|
|
172
179
|
|
|
173
180
|
let speechState = { speaking: false, sessionId: null, turn: null, messageId: null, source: null, queueLength: 0 }
|
|
174
181
|
const listeners = new Set()
|
|
175
|
-
|
|
182
|
+
// The bound settings form, installed by the `whileServed` watch below: the
|
|
183
|
+
// configForms service is keyed by the HOST entry id, which is only knowable
|
|
184
|
+
// once the settings mirror reports which namespace the host actually serves.
|
|
185
|
+
let settings = null
|
|
176
186
|
const e = React.createElement
|
|
177
187
|
function IconVolume2({ size = 20, className }) {
|
|
178
188
|
return e('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', className, 'aria-hidden': 'true' },
|
|
@@ -282,11 +292,14 @@ window.__ModuleLoader__.load({
|
|
|
282
292
|
const payload = speaking ? { action } : { action, sessionId: props.sessionId, turn, messageId, text }
|
|
283
293
|
void control(payload).catch(console.error).finally(() => setPending(false))
|
|
284
294
|
},
|
|
285
|
-
}, speaking ? e(
|
|
295
|
+
}, speaking ? e(IconPauseOutlineRegular) : e(IconVolume2))
|
|
286
296
|
}
|
|
297
|
+
// The card is only mounted while the host namespace is served (see the
|
|
298
|
+
// whileServed watch at the end of apply), so `settings` is bound by then;
|
|
299
|
+
// the guards keep a mount outside that window renderable instead of fatal.
|
|
287
300
|
function useSettings() {
|
|
288
|
-
const [snapshot, setSnapshot] = React.useState(settings.getSnapshot())
|
|
289
|
-
React.useEffect(() => settings.subscribe(() => setSnapshot(settings.getSnapshot())), [])
|
|
301
|
+
const [snapshot, setSnapshot] = React.useState(settings ? settings.getSnapshot() : null)
|
|
302
|
+
React.useEffect(() => (settings ? settings.subscribe(() => setSnapshot(settings.getSnapshot())) : undefined), [])
|
|
290
303
|
return snapshot
|
|
291
304
|
}
|
|
292
305
|
function Field({ label, hint, children, inline }) {
|
|
@@ -300,7 +313,18 @@ window.__ModuleLoader__.load({
|
|
|
300
313
|
// 局部 state 缓冲,输入过程自由,onChange 里校验合法后才写配置。
|
|
301
314
|
const [text, setText] = React.useState(String(value))
|
|
302
315
|
React.useEffect(() => { setText(String(value)) }, [value])
|
|
303
|
-
return e(Field, { label: `${label}:`, hint }, e('div', { className: 'dsh-speak-input-row' }, e(Input, {
|
|
316
|
+
return e(Field, { label: `${label}:`, hint }, e('div', { className: 'dsh-speak-input-row' }, e(Input, {
|
|
317
|
+
id, value: text, disabled, inputMode: numeric ? 'numeric' : undefined,
|
|
318
|
+
onChange: event => {
|
|
319
|
+
const typed = event.target.value
|
|
320
|
+
setText(typed)
|
|
321
|
+
// A refused write (false) leaves the config untouched, so the buffer must
|
|
322
|
+
// fall back to the stored value — otherwise the input keeps showing a
|
|
323
|
+
// number that was never saved (the toggle controls self-correct because
|
|
324
|
+
// they render straight from the snapshot).
|
|
325
|
+
void Promise.resolve(onChange(typed)).then(ok => { if (ok === false) setText(String(value)) })
|
|
326
|
+
},
|
|
327
|
+
})))
|
|
304
328
|
}
|
|
305
329
|
function Options({ label, value, disabled, onChange, hint, options }) { return e(Field, { label, hint }, e('div', { className: 'dsh-speak-option-row' }, ...options.map(option => e(Button, { key: option.value, variant: value === option.value ? 'primary' : 'outline', size: 'sm', disabled, 'aria-pressed': value === option.value, onClick: () => onChange(option.value) }, option.label)))) }
|
|
306
330
|
function MarkdownCleaning({ value, clean, disabled, set }) {
|
|
@@ -315,8 +339,21 @@ window.__ModuleLoader__.load({
|
|
|
315
339
|
function SettingsCard() {
|
|
316
340
|
// hooks must run unconditionally (before the ready-guard return)
|
|
317
341
|
const [eventsOpen, setEventsOpen] = React.useState(false)
|
|
318
|
-
const snapshot = useSettings(); if (snapshot.status !== 'ready' || !snapshot.value) return null
|
|
319
|
-
const value = snapshot.value; const disabled = !snapshot.writable; const clean = value.cleanMarkdownFormatting !== false
|
|
342
|
+
const snapshot = useSettings(); if (!snapshot || snapshot.status !== 'ready' || !snapshot.value) return null
|
|
343
|
+
const value = snapshot.value; const disabled = !snapshot.writable; const clean = value.cleanMarkdownFormatting !== false
|
|
344
|
+
// `set` resolves to the Host's answer so a control can react to a refusal:
|
|
345
|
+
// `false` means the write was rejected (e.g. a duplicated entry id — see the
|
|
346
|
+
// README troubleshooting table), and the mirror then reloads the old value.
|
|
347
|
+
const set = async (field, next) => {
|
|
348
|
+
try {
|
|
349
|
+
const saved = await settings.set(field, next)
|
|
350
|
+
if (!saved) console.warn('[dsh-speak] setting not saved:', field)
|
|
351
|
+
return saved
|
|
352
|
+
} catch (error) {
|
|
353
|
+
console.error('[dsh-speak] settings write failed:', field, error)
|
|
354
|
+
return false
|
|
355
|
+
}
|
|
356
|
+
}
|
|
320
357
|
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent || navigator.platform || '')
|
|
321
358
|
return e('section', { 'aria-label': t('settingsAria') }, e('h3', null, t('settingsTitle')), e('p', null, t('settingsIntro')),
|
|
322
359
|
e(Toggle, { label: t('masterSwitch'), value: value.enabled !== false, disabled, onChange: next => set('enabled', next), hint: t('masterSwitchHint') }),
|
|
@@ -350,7 +387,17 @@ window.__ModuleLoader__.load({
|
|
|
350
387
|
document.head.appendChild(style); return () => style.remove()
|
|
351
388
|
}, 'dsh-speak message action styles')
|
|
352
389
|
ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({ name: 'conversation.chat.assistant-actions', id: 'speak', order: 5, label: 'Speak', locale: NS }, SpeakAction))
|
|
353
|
-
|
|
390
|
+
// The settings page is bound to whichever namespace the HOST actually
|
|
391
|
+
// serves, and exists only while one is served: `whileServed` runs once a
|
|
392
|
+
// listed namespace reaches the shared settings mirror (and withdraws the
|
|
393
|
+
// page when none is), so a deployment without the settings provider — or
|
|
394
|
+
// with an entry id this plugin does not know — shows no dead page.
|
|
395
|
+
ctx.effect(() => ctx.configForms.whileServed(SETTINGS_NAMESPACES, served => {
|
|
396
|
+
const namespace = SETTINGS_NAMESPACES.find(name => served.has(name))
|
|
397
|
+
if (namespace === undefined) return () => {}
|
|
398
|
+
settings = ctx.configForms.get(namespace)
|
|
399
|
+
return ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'speak', order: 25, label: () => t('nav'), locale: NS }, SettingsCard))
|
|
400
|
+
}), 'dsh-speak settings page')
|
|
354
401
|
}
|
|
355
402
|
return module.exports
|
|
356
403
|
},
|
package/cordis.patch.yml
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
|
-
# dsh-speak bundle patch: auto-register the speech-hook plugin when this package
|
|
2
|
-
# is used as a DSH bundle (declared in dsh.profile.bundles).
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
# dsh-speak bundle patch: auto-register the speech-hook plugin when this package
|
|
2
|
+
# is used as a DSH bundle (declared in dsh.profile.bundles).
|
|
3
|
+
#
|
|
4
|
+
# The entry `id` is not decoration: since DSH 0.1.7 the settings service projects
|
|
5
|
+
# each Loader entry's own exported `Config` into the settings UI, so the entry id
|
|
6
|
+
# IS the settings namespace (the key under which this plugin's options are stored
|
|
7
|
+
# in the profile patch, and what the browser half binds). `dsh-speak` is the id
|
|
8
|
+
# this package documents; rows left over from 1.8.x name the same entry
|
|
9
|
+
# `speech-hook`, and the browser half accepts either id, so an existing profile
|
|
10
|
+
# needs no edit.
|
|
11
|
+
#
|
|
12
|
+
# The TWO rows below are deliberate, and the shape matters:
|
|
13
|
+
# * `insert` provides the entry (nothing else in a composition does),
|
|
14
|
+
# * the top-level row carries the editable `config`.
|
|
15
|
+
# DSH's config editor rewrites a `config` in place only on a TOP-LEVEL row
|
|
16
|
+
# (`config-editor.edit()` looks for a non-insert row with the same id+name and
|
|
17
|
+
# `setIn`s its config there; its `inherited()` helper strips `config` from exactly
|
|
18
|
+
# those rows). A `config` nested INSIDE the `insert` row — the shape 1.8.x used and
|
|
19
|
+
# the obvious-looking one — is not addressable that way: the editor appends a new
|
|
20
|
+
# top-level row and then rolls that write back, so the settings page answers
|
|
21
|
+
# `ok: true`, the running plugin obeys the change immediately, and the value
|
|
22
|
+
# silently reverts at the next boot. Shipping the top-level row makes settings
|
|
23
|
+
# persist from the first write (verified: UI edit → profile patch in ~0.3 s).
|
|
24
|
+
- insert:
|
|
25
|
+
- id: dsh-speak
|
|
26
|
+
name: dsh-speak
|
|
27
|
+
- id: dsh-speak
|
|
28
|
+
name: dsh-speak
|
|
29
|
+
config: {}
|