@goodandready/dsh-voice 0.8.37 → 0.9.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.
package/README.md CHANGED
@@ -217,6 +217,8 @@ Registers `transcribe_audio(file_path, language?)` in `ctx.tools`, allowing agen
217
217
  * `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
218
218
  * `POST /dsh-voice/polish` — `{ text }` → `{ ok, text }`
219
219
  * `GET /dsh-voice/status` — Returns daemon status, active chains, SenseVoice.
220
+ * `GET /dsh-voice/config` — Returns live plugin configuration snapshot.
221
+ * `PUT /dsh-voice/config` — Updates and persists plugin configuration across network.
220
222
 
221
223
  ---
222
224
 
package/README.zh.md CHANGED
@@ -129,6 +129,16 @@ v0.8.18 质量审查后的修复批次(Gitea #79–#87,PR #88):
129
129
 
130
130
  ---
131
131
 
132
+ ## 🤖 智能体工具与 HTTP 端点
133
+
134
+ * `POST /dsh-voice/transcribe` — 音频转写:`{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
135
+ * `POST /dsh-voice/polish` — 文本模型润色:`{ text }` → `{ ok, text }`
136
+ * `GET /dsh-voice/status` — 查询后端状态、服务商健康度与 SenseVoice
137
+ * `GET /dsh-voice/config` — 获取插件运行时配置快照
138
+ * `PUT /dsh-voice/config` — 跨网络安全持久化更新插件配置
139
+
140
+ ---
141
+
132
142
  ## 📦 安装指南
133
143
 
134
144
  ```bash
package/lib/client.js CHANGED
@@ -1222,6 +1222,15 @@ window.__ModuleLoader__.load({
1222
1222
  if (event.repeat) return
1223
1223
  // A modifier hotkey does not fight the text field: modifiers do not
1224
1224
  // type. A plain letter key must not be hijacked while typing.
1225
+ const target = event.target
1226
+ const isEditable = target && (
1227
+ target.tagName === 'INPUT' ||
1228
+ target.tagName === 'TEXTAREA' ||
1229
+ target.isContentEditable ||
1230
+ (typeof target.getAttribute === 'function' && target.getAttribute('contenteditable') === 'true')
1231
+ )
1232
+ if (isEditable && !['Control', 'Alt', 'Shift'].includes(keyName)) return
1233
+
1225
1234
  if (hotkeyMatches(event, keyName)) beginHold(mode)
1226
1235
  }
1227
1236
  const up = (event) => {
@@ -2174,6 +2183,25 @@ window.__ModuleLoader__.load({
2174
2183
  const [draft, setDraft] = React.useState(null)
2175
2184
  const [saved, setSaved] = React.useState(false)
2176
2185
  const [err, setErr] = React.useState('')
2186
+ const [serverConfig, setServerConfig] = React.useState(null)
2187
+ const [configLoading, setConfigLoading] = React.useState(true)
2188
+
2189
+ React.useEffect(() => {
2190
+ let alive = true
2191
+ fetch('/dsh-voice/config', { cache: 'no-store' })
2192
+ .then((r) => r.json())
2193
+ .then((data) => {
2194
+ if (alive && data && data.ok && data.config) {
2195
+ setServerConfig(data.config)
2196
+ setDraft((curr) => curr || deepClone(data.config))
2197
+ }
2198
+ })
2199
+ .catch(() => {})
2200
+ .finally(() => {
2201
+ if (alive) setConfigLoading(false)
2202
+ })
2203
+ return () => { alive = false }
2204
+ }, [])
2177
2205
 
2178
2206
  // Hotkey picker: not a name field — press the key you want.
2179
2207
  const [catching, setCatching] = React.useState(false)
@@ -2207,9 +2235,9 @@ window.__ModuleLoader__.load({
2207
2235
  .catch(() => {})
2208
2236
  }, [])
2209
2237
 
2210
- const ready = !!snap && snap.status === 'ready'
2211
- const value = ready && snap.value ? snap.value : {}
2212
- const writable = ready && snap.writable !== false
2238
+ const ready = !!serverConfig || (!!snap && snap.status === 'ready')
2239
+ const value = (snap && snap.status === 'ready' && snap.value) ? snap.value : (serverConfig || {})
2240
+ const writable = ready
2213
2241
 
2214
2242
  React.useEffect(() => {
2215
2243
  if (ready) return undefined
@@ -2455,7 +2483,7 @@ window.__ModuleLoader__.load({
2455
2483
  }, [checkUpdater])
2456
2484
 
2457
2485
  if (!ready) {
2458
- const waiting = !snap || snap.status === 'loading'
2486
+ const waiting = configLoading || (!snap || snap.status === 'loading')
2459
2487
  return React.createElement('div', { className: 'cb-page' },
2460
2488
  React.createElement('div', { className: 'dvs-wait' }, waiting
2461
2489
  ? t('loadingSettings')
@@ -2476,13 +2504,40 @@ window.__ModuleLoader__.load({
2476
2504
  setErr(''); setSaved(false)
2477
2505
  if (!draft) return
2478
2506
 
2479
- const failed = []
2480
- for (const k of Object.keys(draft)) {
2481
- try {
2482
- await scope.set(k, draft[k])
2483
- } catch (e) {
2484
- failed.push(k + ': ' + String(e && e.message ? e.message : e))
2507
+ let httpOk = false
2508
+ let httpError = null
2509
+ try {
2510
+ const res = await fetch('/dsh-voice/config', {
2511
+ method: 'PUT',
2512
+ headers: { 'Content-Type': 'application/json' },
2513
+ body: JSON.stringify({ config: draft }),
2514
+ })
2515
+ const data = await res.json()
2516
+ if (res.ok && data && data.ok) {
2517
+ httpOk = true
2518
+ if (data.config) {
2519
+ setServerConfig(data.config)
2520
+ }
2521
+ } else {
2522
+ httpError = data?.error?.message || data?.error || res.statusText || 'Save failed'
2485
2523
  }
2524
+ } catch (e) {
2525
+ httpError = e?.message || String(e)
2526
+ }
2527
+
2528
+ if (scope && typeof scope.set === 'function') {
2529
+ for (const k of Object.keys(draft)) {
2530
+ try {
2531
+ await scope.set(k, draft[k])
2532
+ } catch (e) {
2533
+ /* best effort on loopback */
2534
+ }
2535
+ }
2536
+ }
2537
+
2538
+ if (!httpOk) {
2539
+ setErr(t('saveFailed') + ': ' + httpError)
2540
+ return
2486
2541
  }
2487
2542
 
2488
2543
  const d = draft || {}
@@ -2501,11 +2556,17 @@ window.__ModuleLoader__.load({
2501
2556
  noiseGateDb: Number(d.noiseGateDb !== undefined ? d.noiseGateDb : -45),
2502
2557
  contextGlossary: d.contextGlossary !== false,
2503
2558
  visualizerStyle: d.visualizerStyle || 'liquid-wave',
2559
+ gatedTurnTaking: d.gatedTurnTaking !== false,
2560
+ autoSendVisualRing: d.autoSendVisualRing !== false,
2561
+ voiceActions: d.voiceActions !== false,
2562
+ techJargonCorrection: d.techJargonCorrection !== false,
2563
+ liveInterimPreview: d.liveInterimPreview !== false,
2564
+ structuredPromptVoice: d.structuredPromptVoice !== false,
2504
2565
  })
2505
2566
  try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
2506
2567
 
2507
- if (failed.length) { setErr(t('saveFailed') + ' ' + failed.join('; ')); return }
2508
- setSaved(true); setTimeout(() => setSaved(false), 2000)
2568
+ setSaved(true)
2569
+ setTimeout(() => setSaved(false), 2000)
2509
2570
  fetchStatus()
2510
2571
  }
2511
2572
 
@@ -3047,7 +3108,7 @@ window.__ModuleLoader__.load({
3047
3108
  React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
3048
3109
  React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
3049
3110
  React.createElement('span', { className: 'cb-badge cb-badge-ok' },
3050
- 'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.8.32')
3111
+ 'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.0')
3051
3112
  ),
3052
3113
  updaterStatus && updaterStatus.updateAvailable
3053
3114
  ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
@@ -0,0 +1,135 @@
1
+ // lib/config-routes.js — Host route for plugin configuration persistence over network.
2
+ // Solves DSH network limitation where client-side persistence is blocked in non-loopback environments (Gitea #149).
3
+
4
+ import { writeJson, readBody, isTrustedCaller } from './http-util.js'
5
+
6
+ export function registerConfigRoutes(ctx, {
7
+ NS,
8
+ live,
9
+ getSettingsService,
10
+ getScope,
11
+ validateConfig,
12
+ triggerAutostart,
13
+ }) {
14
+ return ctx.webServer.register({
15
+ kind: 'exact',
16
+ path: '/dsh-voice/config',
17
+ handler: async (req, res) => {
18
+ // 1. Method check: only GET, PUT, POST allowed
19
+ if (req.method !== 'GET' && req.method !== 'PUT' && req.method !== 'POST') {
20
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET, PUT, or POST only' } })
21
+ return
22
+ }
23
+
24
+ // 2. Security check: loopback or same-origin
25
+ if (!isTrustedCaller(req)) {
26
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } })
27
+ return
28
+ }
29
+
30
+ // 3. GET: return live configuration snapshot
31
+ if (req.method === 'GET') {
32
+ writeJson(res, 200, { ok: true, config: live() })
33
+ return
34
+ }
35
+
36
+ // 4. PUT / POST: check settings service availability
37
+ const settingsService = getSettingsService?.()
38
+ const scope = getScope?.()
39
+ if (!settingsService && !scope) {
40
+ writeJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'settings service not ready' } })
41
+ return
42
+ }
43
+
44
+ // 5. Read request body
45
+ let raw
46
+ try {
47
+ raw = await readBody(req, 1024 * 1024)
48
+ } catch (e) {
49
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
50
+ return
51
+ }
52
+
53
+ // 6. Parse JSON payload
54
+ let payload
55
+ try {
56
+ payload = JSON.parse(raw.toString('utf8') || '{}')
57
+ } catch {
58
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
59
+ return
60
+ }
61
+
62
+ const rawConfig = payload && typeof payload.config === 'object' && payload.config !== null
63
+ ? payload.config
64
+ : payload
65
+
66
+ if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
67
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: 'body must be a JSON object' } })
68
+ return
69
+ }
70
+
71
+ // 7. Validate and merge configuration
72
+ let merged
73
+ try {
74
+ const current = live() || {}
75
+ const candidate = {
76
+ ...current,
77
+ ...rawConfig,
78
+ dictation: { ...(current.dictation || {}), ...(rawConfig.dictation || {}) },
79
+ message: { ...(current.message || {}), ...(rawConfig.message || {}) },
80
+ }
81
+ merged = validateConfig ? validateConfig(candidate) : candidate
82
+ } catch (err) {
83
+ writeJson(res, 400, { ok: false, error: { code: 'validation', message: String(err?.message || err) } })
84
+ return
85
+ }
86
+
87
+ // 8. Persist to host settings
88
+ try {
89
+ let saved = false
90
+ if (settingsService) {
91
+ let rev
92
+ try {
93
+ rev = settingsService.describe?.()?.find?.((r) => r.ns === NS)?.revision
94
+ } catch { /* ignore revision lookup errors */ }
95
+
96
+ if (typeof settingsService.replace === 'function') {
97
+ await settingsService.replace(NS, merged, rev)
98
+ saved = true
99
+ } else if (typeof settingsService.update === 'function') {
100
+ await settingsService.update(NS, merged, rev)
101
+ saved = true
102
+ }
103
+ }
104
+
105
+ if (scope) {
106
+ if (typeof scope.replace === 'function') {
107
+ await scope.replace(merged)
108
+ saved = true
109
+ } else if (typeof scope.patch === 'function') {
110
+ await scope.patch(merged)
111
+ saved = true
112
+ } else if (typeof scope.set === 'function') {
113
+ for (const [k, v] of Object.entries(merged)) {
114
+ await scope.set(k, v).catch(() => {})
115
+ }
116
+ saved = true
117
+ }
118
+ }
119
+
120
+ if (!saved) {
121
+ writeJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'failed to persist settings' } })
122
+ return
123
+ }
124
+
125
+ if (typeof triggerAutostart === 'function') {
126
+ triggerAutostart()
127
+ }
128
+
129
+ writeJson(res, 200, { ok: true, config: live() })
130
+ } catch (err) {
131
+ writeJson(res, 500, { ok: false, error: { code: 'save', message: String(err?.message || err) } })
132
+ }
133
+ },
134
+ })
135
+ }
package/lib/index.js CHANGED
@@ -29,6 +29,7 @@ import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
29
29
  import { registerPluginUpdater } from './updater.js'
30
30
  import { registerSensevoiceInstaller } from './sensevoice-installer.js'
31
31
  import { createConfigReader } from './config-compat.js'
32
+ import { registerConfigRoutes } from './config-routes.js'
32
33
 
33
34
  function isAutoLang(lang) {
34
35
  return !lang || lang === 'auto' || String(lang).includes(',')
@@ -214,19 +215,10 @@ export function apply(ctx, baseConfig) {
214
215
  // while caching unwrapped values across requests.
215
216
  const readEntryConfig = createConfigReader(baseConfig, ctx)
216
217
  let scope = null
218
+ let settingsService = null
217
219
  let getConfig = readEntryConfig
218
220
  const live = () => BaseConfig(structuredClone(getConfig() ?? {})) ?? readEntryConfig()
219
221
 
220
- ctx.inject(['settings'], (sctx) => {
221
- try {
222
- scope = sctx.settings.register(NS, Config, { base: baseConfig })
223
- if (scope && typeof scope.get === 'function') {
224
- getConfig = () => scope.get() ?? readEntryConfig()
225
- }
226
- } catch { /* modern DSH handles settings via SettingsForms */ }
227
- sctx.effect(() => () => { getConfig = readEntryConfig; scope = null })
228
- })
229
-
230
222
  async function resolveKey(ref) {
231
223
  try {
232
224
  const resolved = await ctx.credentials.resolve(credentialRef(ref))
@@ -269,12 +261,27 @@ export function apply(ctx, baseConfig) {
269
261
  stdoutMaxBytes: 4 * 1024 * 1024,
270
262
  })
271
263
  child = ctx.shell.start(spec)
272
- for (let i = 0; i < 20; i++) {
264
+ if (child && typeof child.on === 'function') {
265
+ child.on('error', (err) => {
266
+ whisperError = err?.message || String(err)
267
+ })
268
+ child.on('exit', (code) => {
269
+ if (code !== 0 && code !== null) {
270
+ whisperError = `whisper server process exited with code ${code}`
271
+ }
272
+ })
273
+ }
274
+ // Wait for it to become healthy (up to 30s for cold model loading on Metal/CPU).
275
+ for (let i = 0; i < 60; i++) {
273
276
  await new Promise((r) => setTimeout(r, 500))
274
277
  if (await whisperAlive()) {
275
278
  whisperError = null
276
279
  return true
277
280
  }
281
+ if (child && child.exitCode !== null && child.exitCode !== undefined) {
282
+ whisperError = `whisper server process exited prematurely with code ${child.exitCode}`
283
+ return false
284
+ }
278
285
  }
279
286
  whisperError = 'whisper server process started but failed health check on port 8001'
280
287
  return false
@@ -285,8 +292,6 @@ export function apply(ctx, baseConfig) {
285
292
  finally { startingWhisper = false }
286
293
  }
287
294
 
288
- startWhisper().catch(() => {})
289
-
290
295
  // SenseVoice-ONNX / Sherpa-ONNX: autostart and health check.
291
296
  let sensevoiceChild = null
292
297
  let sensevoiceError = null
@@ -325,12 +330,27 @@ export function apply(ctx, baseConfig) {
325
330
  stdoutMaxBytes: 4 * 1024 * 1024,
326
331
  })
327
332
  sensevoiceChild = ctx.shell.start(spec)
328
- for (let i = 0; i < 20; i++) {
333
+ if (sensevoiceChild && typeof sensevoiceChild.on === 'function') {
334
+ sensevoiceChild.on('error', (err) => {
335
+ sensevoiceError = err?.message || String(err)
336
+ })
337
+ sensevoiceChild.on('exit', (code) => {
338
+ if (code !== 0 && code !== null) {
339
+ sensevoiceError = `sensevoice process exited with code ${code}`
340
+ }
341
+ })
342
+ }
343
+ // Wait for it to become healthy (up to 30s).
344
+ for (let i = 0; i < 60; i++) {
329
345
  await new Promise((r) => setTimeout(r, 500))
330
346
  if (await sensevoiceAlive()) {
331
347
  sensevoiceError = null
332
348
  return true
333
349
  }
350
+ if (sensevoiceChild && sensevoiceChild.exitCode !== null && sensevoiceChild.exitCode !== undefined) {
351
+ sensevoiceError = `sensevoice process exited prematurely with code ${sensevoiceChild.exitCode}`
352
+ return false
353
+ }
334
354
  }
335
355
  sensevoiceError = 'sensevoice process started but failed health check'
336
356
  return false
@@ -341,7 +361,39 @@ export function apply(ctx, baseConfig) {
341
361
  finally { startingSensevoice = false }
342
362
  }
343
363
 
344
- startSensevoice().catch(() => {})
364
+ function triggerAutostart() {
365
+ startWhisper().catch((err) => {
366
+ ctx.logger?.debug?.('whisper autostart failed: %s', err?.message || err)
367
+ })
368
+ startSensevoice().catch((err) => {
369
+ ctx.logger?.debug?.('sensevoice autostart failed: %s', err?.message || err)
370
+ })
371
+ }
372
+
373
+ ctx.inject(['settings'], (sctx) => {
374
+ settingsService = sctx.settings
375
+ try {
376
+ scope = sctx.settings.register(NS, Config, { base: baseConfig })
377
+ if (scope && typeof scope.get === 'function') {
378
+ getConfig = () => scope.get() ?? readEntryConfig()
379
+ }
380
+ if (scope && typeof scope.watch === 'function') {
381
+ scope.watch(() => {
382
+ triggerAutostart()
383
+ })
384
+ }
385
+ } catch { /* modern DSH handles settings via SettingsForms */ }
386
+ sctx.effect(() => () => { getConfig = readEntryConfig; scope = null; settingsService = null })
387
+ triggerAutostart()
388
+ })
389
+
390
+ if (typeof ctx.on === 'function') {
391
+ ctx.on('loader/volatile-update', () => triggerAutostart())
392
+ ctx.on('settings/document-updated', () => triggerAutostart())
393
+ ctx.on('config', () => triggerAutostart())
394
+ }
395
+
396
+ triggerAutostart()
345
397
 
346
398
  // Provider health and latency stats (Latency & Health Dashboard).
347
399
  const statsTracker = createStatsTracker()
@@ -615,5 +667,14 @@ export function apply(ctx, baseConfig) {
615
667
  },
616
668
  startSensevoice,
617
669
  }), 'dsh-voice: sensevoice installer route')
670
+
671
+ ctx.effect(() => registerConfigRoutes(ctx, {
672
+ NS,
673
+ live,
674
+ getSettingsService: () => settingsService,
675
+ getScope: () => scope,
676
+ validateConfig: (val) => BaseConfig(structuredClone(val)),
677
+ triggerAutostart,
678
+ }), 'dsh-voice: config route')
618
679
  }
619
680
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.8.37",
3
+ "version": "0.9.0",
4
4
  "description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
5
5
  "license": "MIT",
6
6
  "type": "module",