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.
@@ -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 `dsh-speak` settings namespace registered through the settings SERVICE
23
- // (`ctx.inject(['settings'])` → `settings.register`); schema defaults →
24
- // patch config → UI user layer
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 namespace (best-effort; registered through the `settings` service)
73
+ // Settings (DSH >= 0.1.7: this module's own exported `Config` is the form)
77
74
  // ---------------------------------------------------------------------------
78
- // The schema mirrors every config key. Values resolve as:
79
- // schema default → patch `config` (base) → user settings layer (the UI).
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 as a pure function so both the initial apply and settings onChange use
111
- * the same normalization (engine re-resolution, platform maxChars default).
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: Number(value.codeBlockMaxChars != null ? value.codeBlockMaxChars : 300),
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: Number(value.throttleMs != null ? value.throttleMs : 1500) || 1500,
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: Math.max(0, Number(value.questionGapMs != null ? value.questionGapMs : 2000)) || 0,
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: Number(value.maxChars != null ? value.maxChars : DEFAULT_MAX_CHARS) || 0,
134
- volume: Number(value.volume != null ? value.volume : 50) || 50,
135
- rate: Number(value.rate != null ? value.rate : 0) || 0,
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
- * Resolve a module specifier from the plugin's own location first, then from
146
- * the booted profile tree. `@deepseek-ai/schemastery` is a peer of this package
147
- * and lives beside it after an npm/pnpm install; the profile-tree fallback
148
- * covers the file:// install used by install.ps1 and repo checkouts.
149
- * @returns the module, or null when neither base resolves it.
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 requirePeer(ctx, spec) {
152
- const bases = [__filename, ctx.baseUrl].filter(Boolean)
153
- for (const base of bases) {
154
- try { return createRequire(base)(spec) } catch (e) { /* try the next base */ }
155
- }
156
- return null
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
- * Build the settings schema + entry for the settings namespace. Best-effort:
161
- * any failure (missing peer packages) returns null and the plugin keeps the
162
- * patch config.
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 buildSettingsNamespace(ctx, patch) {
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 = requirePeer(ctx, '@deepseek-ai/schemastery')
167
- if (!z) throw new Error('@deepseek-ai/schemastery 不可解析')
168
- const schema = z.object({
169
- enabled: z.boolean().default(true),
170
- automaticSpeech: z.boolean().default(true),
171
- cleanMarkdownFormatting: z.boolean().default(true),
172
- readInlineCode: z.boolean().default(true),
173
- codeBlocks: z.union(['all', 'smart', 'replace']).default('smart'),
174
- codeBlockMaxChars: z.natural().default(300),
175
- codeBlockReplacementText: z.string().default('You can see the code in our history.'),
176
- queueAllMessages: z.boolean().default(false),
177
- throttleMs: z.natural().default(1500),
178
- replayFullRead: z.boolean().default(false),
179
- engine: z.string().default(''),
180
- announceApprovals: z.boolean().default(true),
181
- announceQuestions: z.boolean().default(true),
182
- stripApprovalPrefix: z.boolean().default(true),
183
- questionGapMs: z.natural().default(2000),
184
- longTextMode: z.union(['message', 'heading']).default('message'),
185
- longTextMessage: z.string().default('本次播报内容较长,请自行阅读。'),
186
- maxChars: z.natural().default(DEFAULT_MAX_CHARS),
187
- volume: z.natural().default(50),
188
- rate: z.number().default(0),
189
- announceTurnEnd: z.boolean().default(false),
190
- announceCommandDone: z.boolean().default(false),
191
- announceGoalChange: z.boolean().default(false),
192
- announceToolErrors: z.boolean().default(false),
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 namespace 注册:', e && e.message)
198
- return null
273
+ log('settings 依赖不可用,跳过 settings 表单(继续用 patch config):', e && e.message)
274
+ return undefined
199
275
  }
200
276
  }
201
277
 
202
278
  module.exports = {
203
- apply(ctx, config) {
204
- config = config || {}
205
- let cfg = resolveConfig(config)
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
- // ---- settings namespace -------------------------------------------------
208
- // Wire the namespace through the settings SERVICE.
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
- // dsh 0.1.2-alpha.1 deleted the `installSettingsSection` / `settingsNamespace`
211
- // convenience exports from `@deepseek-ai/dsh-settings`; what remains — and
212
- // has not changed since 0.1.0-rc.7 — is the `settings` service itself
213
- // (`ctx.settings.register(ns, schema, { base })` → `{ get, watch, update,
214
- // replace }`). Referencing the removed names is fatal: an ESM named import
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
- // `ctx.inject(['settings'])` is the graceful-degradation boundary: on a host
221
- // with no settings provider the callback never runs and the composed patch
222
- // config stands as-is.
223
- const prepared = buildSettingsNamespace(ctx, config)
224
- if (prepared) {
225
- ctx.inject(['settings'], scopedCtx => {
226
- // `scope.get()` is the live resolved value (schema default → patch
227
- // config → UI user layer), so re-deriving cfg from it on every change
228
- // is what makes a settings edit take effect without a restart.
229
- let settingsSource = () => prepared.entry
230
- const applySettings = () => {
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 namespace 'dsh-speak' (installSettingsSection)
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, IconPauseOutline16, Input } = require('@deepseek-ai/dsh-client-ui-primitives')
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
- const SETTINGS_NAMESPACE = 'dsh-speak'
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', 'settingsScope', 'locale']
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
- const settings = ctx.settingsScope.bind({ namespace: SETTINGS_NAMESPACE })
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(IconPauseOutline16) : e(IconVolume2))
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, { id, value: text, disabled, inputMode: numeric ? 'numeric' : undefined, onChange: event => { setText(event.target.value); onChange(event.target.value) } })))
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; const set = (field, next) => { void settings.set(field, next).catch(console.error) }
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
- ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'speak', order: 25, label: () => t('nav'), locale: NS }, SettingsCard))
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
- - insert:
4
- - id: speech-hook
5
- name: dsh-speak
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: {}