@goodandready/dsh-voice 0.8.38 → 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,6 +215,7 @@ 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
 
@@ -369,6 +371,7 @@ export function apply(ctx, baseConfig) {
369
371
  }
370
372
 
371
373
  ctx.inject(['settings'], (sctx) => {
374
+ settingsService = sctx.settings
372
375
  try {
373
376
  scope = sctx.settings.register(NS, Config, { base: baseConfig })
374
377
  if (scope && typeof scope.get === 'function') {
@@ -380,7 +383,7 @@ export function apply(ctx, baseConfig) {
380
383
  })
381
384
  }
382
385
  } catch { /* modern DSH handles settings via SettingsForms */ }
383
- sctx.effect(() => () => { getConfig = readEntryConfig; scope = null })
386
+ sctx.effect(() => () => { getConfig = readEntryConfig; scope = null; settingsService = null })
384
387
  triggerAutostart()
385
388
  })
386
389
 
@@ -664,5 +667,14 @@ export function apply(ctx, baseConfig) {
664
667
  },
665
668
  startSensevoice,
666
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')
667
679
  }
668
680
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.8.38",
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",