@goodandready/dsh-voice 0.8.18 → 0.8.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,640 @@
1
+ // ------------------------------------------------------- settings page
2
+ const BUILTIN = [
3
+ 'browser', 'deepgram', 'groq', 'hf', 'local-whisper', 'sensevoice',
4
+ // Presets: address and model are filled on the host; only a key is needed.
5
+ 'openai', 'siliconflow', 'deepinfra', 'fireworks', 'mistral', 'openrouter',
6
+ ]
7
+ const TEMPLATES = ['openai-transcriptions', 'openai-chat-audio']
8
+ // Resolve at render time: t() must not be captured before locale bind.
9
+ const MODEL_HINT_KEYS = {
10
+ browser: 'browserHint',
11
+ openai: 'openaiHint',
12
+ siliconflow: 'siliconflowHint',
13
+ deepinfra: 'deepinfraHint',
14
+ fireworks: 'fireworksHint',
15
+ mistral: 'mistralHint',
16
+ openrouter: 'openrouterHint',
17
+ 'local-whisper': 'localHint',
18
+ sensevoice: 'sensevoiceHint',
19
+ }
20
+ const MODEL_HINT_STATIC = {
21
+ deepgram: 'nova-2',
22
+ groq: 'whisper-large-v3-turbo',
23
+ hf: 'openai/whisper-large-v3',
24
+ }
25
+ function modelHint(provider) {
26
+ const key = MODEL_HINT_KEYS[provider]
27
+ if (key) return t(key)
28
+ return MODEL_HINT_STATIC[provider] || ''
29
+ }
30
+ const LANGS = ['auto', 'ru', 'en', 'uk', 'de']
31
+
32
+ const SET_CSS =
33
+ '.dvs-wrap{display:flex;flex-direction:column;gap:22px;padding:4px 0;max-width:720px}' +
34
+ '.dvs-block{display:flex;flex-direction:column;gap:10px}' +
35
+ '.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
36
+ '.dvs-sub{font-size:12px;color:var(--dsw-alias-label-secondary)}' +
37
+ '.dvs-row{display:flex;gap:8px;align-items:center}' +
38
+ '.dvs-row select,.dvs-row input{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);border-radius:6px;padding:6px 8px;font-size:13px}' +
39
+ '.dvs-row .dvs-model{flex:1}' +
40
+ '.dvs-field{display:flex;flex-direction:column;gap:6px;padding:12px 0;font-size:12px;color:var(--dsw-alias-label-secondary)}' +
41
+ '.dvs-field input,.dvs-field select{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px}' +
42
+ '.dvs-mini{border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);border-radius:6px;width:28px;height:28px;cursor:pointer;flex:none}' +
43
+ '.dvs-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:5px 14px;font-size:13px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}' +
44
+ '.dvs-card{display:flex;flex-direction:column;gap:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px}' +
45
+ '.dvs-card input,.dvs-card select{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);border-radius:6px;padding:6px 8px;font-size:13px}' +
46
+ '.dvs-wait{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5;max-width:520px}' +
47
+ '.dvs-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}' +
48
+ '.dvs-bad{font-size:12px;color:var(--dsw-alias-state-error-primary)}' +
49
+ '.dvo-pcard{list-style:none;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px}' +
50
+ '.dvo-phead{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}' +
51
+ '.dvo-pheadtext{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0}' +
52
+ '.dvo-ptitle{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
53
+ '.dvo-pdesc{color:var(--dsw-alias-label-secondary);font-size:13px}' +
54
+ '.dvo-pchev{flex:none;display:flex;color:var(--dsw-alias-label-secondary);transition:transform .15s ease}' +
55
+ '.dvo-pcardOpen .dvo-pchev{transform:rotate(180deg)}' +
56
+ '.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}'
57
+ const setCssId = 'dsh-voice/settings.module.css'
58
+ if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + setCssId + '"]')) {
59
+ const tag = document.createElement('style')
60
+ tag.textContent = SET_CSS
61
+ tag.setAttribute('data-dsh-plugin', 'dsh-voice')
62
+ tag.dataset.pluginCss = setCssId
63
+ document.head.appendChild(tag)
64
+ }
65
+
66
+ // One chain editor: provider+model rows with reordering.
67
+ function ChainEditor(props) {
68
+ const rows = Array.isArray(props.value) ? props.value : []
69
+ const change = (i, patch) => {
70
+ const next = rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r))
71
+ props.onChange(next)
72
+ }
73
+ const move = (i, delta) => {
74
+ const j = i + delta
75
+ if (j < 0 || j >= rows.length) return
76
+ const next = rows.slice()
77
+ const tmp = next[i]; next[i] = next[j]; next[j] = tmp
78
+ props.onChange(next)
79
+ }
80
+ const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
81
+ const add = () => props.onChange(rows.concat([{ provider: 'local-whisper', model: '' }]))
82
+ const options = Array.isArray(props.options) && props.options.length ? props.options : BUILTIN
83
+
84
+ return React.createElement('div', { className: 'dvs-block' },
85
+ rows.map((row, i) => React.createElement('div', { className: 'dvs-row', key: i },
86
+ React.createElement('select', {
87
+ value: row.provider, disabled: !props.writable,
88
+ onChange: (e) => change(i, { provider: e.target.value }),
89
+ }, (options.indexOf(row.provider) < 0 ? options.concat([row.provider]) : options)
90
+ .map((p) => React.createElement('option', { key: p, value: p }, p))),
91
+ React.createElement('input', {
92
+ className: 'dvs-model', value: row.model || '', disabled: !props.writable,
93
+ placeholder: modelHint(row.provider), onChange: (e) => change(i, { model: e.target.value }),
94
+ }),
95
+ React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '↑'),
96
+ React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '↓'),
97
+ React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '×'),
98
+ )),
99
+ React.createElement('div', { className: 'dvs-row' },
100
+ React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('addProvider'), disabled: !props.writable, onClick: add }, '+'),
101
+ React.createElement('span', { className: 'dvs-sub' }, t('chainHint')),
102
+ ),
103
+ )
104
+ }
105
+
106
+ // Custom providers: name, API template, where to call, how to authorize.
107
+ function CustomEditor(props) {
108
+ const rows = Array.isArray(props.value) ? props.value : []
109
+ const change = (i, patch) => props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
110
+ const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
111
+ const add = () => props.onChange(rows.concat([
112
+ { key: '', template: 'openai-transcriptions', baseURL: '', model: '', keyEnv: '', prompt: '' },
113
+ ]))
114
+ const field = (i, row, name, placeholder, wide) => React.createElement('input', {
115
+ className: wide ? 'dvs-model' : '', value: row[name] || '', placeholder: placeholder,
116
+ disabled: !props.writable, onChange: (e) => change(i, { [name]: e.target.value }),
117
+ })
118
+
119
+ return React.createElement('div', { className: 'dvs-block' },
120
+ rows.map((row, i) => React.createElement('div', { className: 'dvs-card', key: i },
121
+ React.createElement('div', { className: 'dvs-row' },
122
+ field(i, row, 'key', t('customName')),
123
+ React.createElement('select', {
124
+ value: row.template || 'openai-transcriptions', disabled: !props.writable,
125
+ onChange: (e) => change(i, { template: e.target.value }),
126
+ }, TEMPLATES.map((t) => React.createElement('option', { key: t, value: t }, t))),
127
+ React.createElement('button', {
128
+ type: 'button', className: 'dvs-mini', title: t('remove'),
129
+ disabled: !props.writable, onClick: () => remove(i),
130
+ }, '\u00d7'),
131
+ ),
132
+ React.createElement('div', { className: 'dvs-row' },
133
+ field(i, row, 'baseURL', 'https://openrouter.ai/api/v1', true),
134
+ ),
135
+ React.createElement('div', { className: 'dvs-row' },
136
+ field(i, row, 'model', t('customModel'), true),
137
+ field(i, row, 'keyEnv', t('customKeyName')),
138
+ ),
139
+ row.template === 'openai-chat-audio'
140
+ ? React.createElement('div', { className: 'dvs-row' },
141
+ field(i, row, 'prompt', t('customModelHint'), true))
142
+ : null,
143
+ )),
144
+ React.createElement('div', { className: 'dvs-row' },
145
+ React.createElement('button', {
146
+ type: 'button', className: 'dvs-mini', title: t('addCustom'),
147
+ disabled: !props.writable, onClick: add,
148
+ }, '+'),
149
+ React.createElement('span', { className: 'dvs-sub' },
150
+ t('openrouterWarning')),
151
+ ),
152
+ )
153
+ }
154
+
155
+ function VoiceSection(props) {
156
+ const t = (props && props.t) || moduleT
157
+ const ctx = props.ctx
158
+ // On a non-localhost page the kernel disables settings entirely: the
159
+ // shared document is not readable, every section gets "unavailable" and
160
+ // writes are dropped. The server does not share that restriction, and
161
+ // dsh-lanmode rebuilds the same mechanics on the same calls. Prefer its
162
+ // service when installed, otherwise the kernel one (fine on loopback).
163
+ const scope = ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).bind({ namespace: NS })
164
+ const [snap, setSnap] = React.useState(null)
165
+ const [draft, setDraft] = React.useState(null)
166
+ const [saved, setSaved] = React.useState(false)
167
+ const [err, setErr] = React.useState('')
168
+
169
+ // Hotkey picker: not a name field — press the key you want. Users should
170
+ // not need to know codes like KeyR.
171
+ //
172
+ // Hooks are declared here, above every return: declaring them later
173
+ // would leave fewer hooks on the not-ready branch and React would
174
+ // unmount the section with an error.
175
+ const [catching, setCatching] = React.useState(false)
176
+ React.useEffect(() => {
177
+ if (!catching) return undefined
178
+ const onKey = (event) => {
179
+ event.preventDefault()
180
+ event.stopPropagation()
181
+ if (event.key === 'Escape') { setCatching(false); return }
182
+ const chosen = keyFromEvent(event)
183
+ setDraft((d) => Object.assign({}, d || {}, { hotkey: chosen }))
184
+ setCatching(false)
185
+ }
186
+ document.addEventListener('keydown', onKey, true)
187
+ return () => document.removeEventListener('keydown', onKey, true)
188
+ }, [catching])
189
+
190
+ React.useEffect(() => {
191
+ let alive = true
192
+ const render = () => { if (alive) setSnap(scope.getSnapshot()) }
193
+ render()
194
+ const off = scope.subscribe(render)
195
+ return () => { alive = false; off() }
196
+ }, [])
197
+
198
+ const [devices, setDevices] = React.useState([])
199
+ React.useEffect(() => {
200
+ if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
201
+ navigator.mediaDevices.enumerateDevices()
202
+ .then((list) => setDevices(list.filter((d) => d.kind === 'audioinput')))
203
+ .catch(() => {})
204
+ }, [])
205
+
206
+ // The snapshot carries a status, and status matters more than value.
207
+ // loading — no host answer yet;
208
+ // unavailable — host answered but does not know this namespace yet
209
+ // (page opened in the first seconds while plugins register);
210
+ // ready — values are present.
211
+ // At unavailable, writable comes from the document and stays true, so
212
+ // without a status check the card draws an empty but seemingly working
213
+ // form — that is exactly how this bug looked.
214
+ const ready = !!snap && snap.status === 'ready'
215
+ const value = ready && snap.value ? snap.value : {}
216
+ const writable = ready && snap.writable !== false
217
+
218
+ // The browser settings mirror reloads only on value writes and
219
+ // reconnect; a new namespace is not such a signal. It will not fix
220
+ // itself — ask it to reload until ready. The mirror is shared, so this
221
+ // also repairs other settings sections.
222
+ React.useEffect(() => {
223
+ if (ready) return undefined
224
+ let tries = 0
225
+ const timer = setInterval(() => {
226
+ if (tries >= 15) { clearInterval(timer); return }
227
+ tries += 1
228
+ try { ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).describe().load() } catch (e) { /* service not up yet */ }
229
+ }, 1000)
230
+ return () => clearInterval(timer)
231
+ }, [ready])
232
+
233
+ // Seed the draft only from a ready snapshot. Previously it was filled
234
+ // from the first snapshot and an empty draft froze forever: later
235
+ // snapshots did not reseed it and the card stayed empty until reload.
236
+ React.useEffect(() => { if (ready && draft === null) setDraft(JSON.parse(JSON.stringify(value))) }, [ready, draft, value])
237
+ // The client needs the VAD threshold and cancel window from the same settings.
238
+ React.useEffect(() => {
239
+ if (!ready) return
240
+ voice.settings = Object.assign({}, voice.settings, {
241
+ vadSilenceMs: Number(value && value.dictation && value.dictation.vadSilenceMs) || 700,
242
+ autoSendMs: Number(value && value.message && value.message.autoSendMs) || 4000,
243
+ beep: !!(snap && snap.beep),
244
+ micDeviceId: String((snap && snap.micDeviceId) || ''),
245
+ historyLimit: Number(snap && snap.historyLimit),
246
+ voiceCommands: !!(snap && snap.voiceCommands),
247
+ sendDelayMs: Number(value && value.dictation && value.dictation.sendDelayMs) || 0,
248
+ stream: !!(value && value.dictation && value.dictation.stream),
249
+ streamChunkMs: Number(value && value.dictation && value.dictation.streamChunkMs) || 1200,
250
+ vadAdapt: Number(value && value.dictation && value.dictation.vadAdapt) || 0,
251
+ wakeWord: String((snap && snap.wakeWord) || ''),
252
+ bargeIn: !!(snap && snap.bargeIn),
253
+ polishSend: !!(value && value.message && value.message.polishSend),
254
+ sessionCommands: !!(value && value.message && value.message.sessionCommands),
255
+ polishBaseUrl: String((snap && snap.polishBaseUrl) || ''),
256
+ noiseSuppression: value.noiseSuppression !== false,
257
+ contextGlossary: value.contextGlossary !== false,
258
+ })
259
+ }, [ready, value, snap])
260
+
261
+ const [statsData, setStatsData] = React.useState({})
262
+ React.useEffect(() => {
263
+ let alive = true
264
+ fetch('/dsh-voice/status', { cache: 'no-store' })
265
+ .then((r) => r.json())
266
+ .then((data) => {
267
+ if (alive && data && data.providerStats) {
268
+ setStatsData(data.providerStats)
269
+ }
270
+ })
271
+ .catch(() => {})
272
+ return () => { alive = false }
273
+ }, [])
274
+
275
+ if (!ready) {
276
+ const waiting = !snap || snap.status === 'loading'
277
+ return React.createElement('div', { className: 'dvs-wrap' },
278
+ React.createElement('div', { className: 'dvs-wait' }, waiting
279
+ ? t('loadingSettings')
280
+ : t('notReady1')
281
+ + t('notReady2')),
282
+ )
283
+ }
284
+
285
+ const setIn = (mode, key, v) => setDraft((d) => {
286
+ const next = JSON.parse(JSON.stringify(d || {}))
287
+ next[mode] = next[mode] || {}
288
+ next[mode][key] = v
289
+ return next
290
+ })
291
+ const setTop = (key, v) => setDraft((d) => Object.assign({}, d || {}, { [key]: v }))
292
+
293
+ const save = async () => {
294
+ setErr(''); setSaved(false)
295
+ if (!draft) return
296
+
297
+ // Fields are written one by one. Previously the first failure broke
298
+ // the loop: later fields were never saved, the composer never got the
299
+ // hotkey reload signal, and the button looked like "nothing happens".
300
+ // Now every field is attempted and failures are collected by name.
301
+ const failed = []
302
+ for (const k of Object.keys(draft)) {
303
+ try {
304
+ await scope.set(k, draft[k])
305
+ } catch (e) {
306
+ failed.push(k + ': ' + String(e && e.message ? e.message : e))
307
+ }
308
+ }
309
+
310
+ Object.assign(voice.settings, {
311
+ vadSilenceMs: Number(draft.dictation && draft.dictation.vadSilenceMs) || 700,
312
+ autoSendMs: Number(draft.message && draft.message.autoSendMs) || 4000,
313
+ beep: !!draft.beep,
314
+ micDeviceId: String(draft.micDeviceId || ''),
315
+ historyLimit: Number(draft.historyLimit),
316
+ voiceCommands: !!draft.voiceCommands,
317
+ sendDelayMs: Number(draft.dictation && draft.dictation.sendDelayMs) || 0,
318
+ stream: !!(draft.dictation && draft.dictation.stream),
319
+ streamChunkMs: Number(draft.dictation && draft.dictation.streamChunkMs) || 1200,
320
+ vadAdapt: Number(draft.dictation && draft.dictation.vadAdapt) || 0,
321
+ noiseSuppression: draft.noiseSuppression !== false,
322
+ contextGlossary: draft.contextGlossary !== false,
323
+ visualizerStyle: draft.visualizerStyle || 'liquid-wave',
324
+ })
325
+ // The composer owns the hotkey handler — tell it to reload, otherwise
326
+ // a new key only works after a full page reload.
327
+ try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
328
+
329
+ if (failed.length) { setErr(t('saveFailed') + ' ' + failed.join('; ')); return }
330
+ setSaved(true); setTimeout(() => setSaved(false), 2000)
331
+ }
332
+
333
+ const modeVal = (mode, key, fallback) => {
334
+ const m = draft && draft[mode]
335
+ return m && m[key] !== undefined ? m[key] : fallback
336
+ }
337
+
338
+ // Custom provider names come from the draft so a just-added entry
339
+ // appears in chain selects immediately.
340
+ const chainOptions = BUILTIN.concat(
341
+ (draft && Array.isArray(draft.customProviders) ? draft.customProviders : [])
342
+ .map((c) => String(c && c.key || '').trim())
343
+ .filter((k) => k && BUILTIN.indexOf(k) < 0),
344
+ )
345
+
346
+ const hotkeyField = () => React.createElement('label', { className: 'dvs-field' },
347
+ t('hotkey'),
348
+ React.createElement('div', { className: 'dvs-row' },
349
+ React.createElement('button', {
350
+ type: 'button', className: 'dvs-save', disabled: !writable,
351
+ onClick: () => setCatching(true),
352
+ }, catching ? t('pressKey') : keyLabel(draft && draft.hotkey)),
353
+ React.createElement('button', {
354
+ type: 'button', className: 'dvs-mini', title: t('clearKey'),
355
+ disabled: !writable, onClick: () => setDraft((d) => Object.assign({}, d || {}, { hotkey: '' })),
356
+ }, '×'),
357
+ ),
358
+ React.createElement('span', { className: 'dvs-sub' },
359
+ t('hotkeyHint1')
360
+ + t('hotkeyHint2')))
361
+
362
+ const micField = () => React.createElement('label', { className: 'dvs-field' }, t('mic'),
363
+ React.createElement('select', {
364
+ value: String((snap && snap.micDeviceId) || ''), disabled: !writable,
365
+ onChange: (e) => setTop('micDeviceId', e.target.value),
366
+ },
367
+ React.createElement('option', { value: '' }, t('micDefault')),
368
+ devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
369
+ d.label || d.deviceId.slice(0, 12)))))
370
+ const langField = (mode) => React.createElement('label', { className: 'dvs-field' }, t('language'),
371
+ React.createElement('select', {
372
+ value: modeVal(mode, 'language', 'ru'), disabled: !writable,
373
+ onChange: (e) => setIn(mode, 'language', e.target.value),
374
+ }, LANGS.map((o) => React.createElement('option', { key: o, value: o }, o))))
375
+
376
+ const numField = (mode, key, label, hint) => React.createElement('label', { className: 'dvs-field' }, label,
377
+ React.createElement('input', {
378
+ type: 'number', value: modeVal(mode, key, ''), disabled: !writable,
379
+ onChange: (e) => setIn(mode, key, Number(e.target.value)),
380
+ }),
381
+ React.createElement('span', { className: 'dvs-sub' }, hint))
382
+
383
+ const textField = (key, label, hint) => React.createElement('label', { className: 'dvs-field' }, label,
384
+ React.createElement('input', {
385
+ value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
386
+ onChange: (e) => setTop(key, e.target.value),
387
+ }),
388
+ React.createElement('span', { className: 'dvs-sub' }, hint))
389
+
390
+ return React.createElement('div', { className: 'dvs-wrap' },
391
+ React.createElement('div', { className: 'dvs-block' },
392
+ React.createElement('div', { className: 'dvs-h' }, t('dictationBtn')),
393
+ React.createElement('div', { className: 'dvs-sub' }, t('dictationHint')),
394
+ React.createElement(ChainEditor, {
395
+ value: draft && draft.dictation ? draft.dictation.chain : [], writable: writable,
396
+ options: chainOptions,
397
+ onChange: (v) => setIn('dictation', 'chain', v),
398
+ }),
399
+ langField('dictation'),
400
+ numField('dictation', 'vadSilenceMs', t('pauseMs'), t('pauseHint')),
401
+ numField('dictation', 'sendDelayMs', t('sendDelay'), t('sendDelayHint')),
402
+ React.createElement('label', { className: 'dvs-field' }, t('polish'),
403
+ React.createElement('input', {
404
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.polish), disabled: !writable,
405
+ onChange: (e) => setIn('dictation', 'polish', e.target.checked),
406
+ })),
407
+ React.createElement('div', { className: 'dvs-sub' }, t('polishHint')),
408
+ React.createElement('label', { className: 'dvs-field', title: t('wakeWordHint') }, t('wakeWord'),
409
+ React.createElement('input', {
410
+ type: 'text', value: String((snap && snap.wakeWord) || ''), disabled: !writable,
411
+ onChange: (e) => setTop('wakeWord', e.target.value),
412
+ })),
413
+ React.createElement('label', { className: 'dvs-field', title: t('streamHint') }, t('stream'),
414
+ React.createElement('input', {
415
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.stream), disabled: !writable,
416
+ onChange: (e) => setIn('dictation', 'stream', e.target.checked),
417
+ })),
418
+ numField('dictation', 'streamChunkMs', t('streamChunkMs'), t('streamHint')),
419
+ React.createElement('label', { className: 'dvs-field', title: t('vadAdaptHint') }, t('vadAdapt'),
420
+ React.createElement('input', {
421
+ type: 'range', min: 0, max: 1, step: 0.1,
422
+ value: Number(draft && draft.dictation && draft.dictation.vadAdapt) || 0, disabled: !writable,
423
+ onChange: (e) => setIn('dictation', 'vadAdapt', Number(e.target.value)),
424
+ })),
425
+ ),
426
+ React.createElement('div', { className: 'dvs-block' },
427
+ React.createElement('div', { className: 'dvs-h' }, t('messageTitle')),
428
+ React.createElement('div', { className: 'dvs-sub' }, t('messageHint')),
429
+ React.createElement(ChainEditor, {
430
+ value: draft && draft.message ? draft.message.chain : [], writable: writable,
431
+ options: chainOptions,
432
+ onChange: (v) => setIn('message', 'chain', v),
433
+ }),
434
+ langField('message'),
435
+ numField('message', 'autoSendMs', t('undoMs'), t('undoHint')),
436
+ React.createElement('label', { className: 'dvs-field' }, t('polish'),
437
+ React.createElement('input', {
438
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
439
+ onChange: (e) => setIn('message', 'polish', e.target.checked),
440
+ })),
441
+ React.createElement('label', { className: 'dvs-field', title: t('polishSendHint') }, t('polishSend'),
442
+ React.createElement('input', {
443
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polishSend), disabled: !writable,
444
+ onChange: (e) => setIn('message', 'polishSend', e.target.checked),
445
+ })),
446
+ React.createElement('label', { className: 'dvs-field', title: t('sessionCommandsHint') }, t('sessionCommands'),
447
+ React.createElement('input', {
448
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.sessionCommands), disabled: !writable,
449
+ onChange: (e) => setIn('message', 'sessionCommands', e.target.checked),
450
+ })),
451
+ ),
452
+ React.createElement('div', { className: 'dvs-block' },
453
+ React.createElement('div', { className: 'dvs-h' }, t('customTitle')),
454
+ React.createElement('div', { className: 'dvs-sub' },
455
+ t('customHint')),
456
+ React.createElement(CustomEditor, {
457
+ value: draft && draft.customProviders ? draft.customProviders : [], writable: writable,
458
+ onChange: (v) => setTop('customProviders', v),
459
+ }),
460
+ ),
461
+ React.createElement('div', { className: 'dvs-block' },
462
+ React.createElement('div', { className: 'dvs-h' }, t('general')),
463
+ hotkeyField(),
464
+ textField('whisperUrl', t('whisperEndpoint'), t('whisperEndpointHint')),
465
+ textField('whisperBin', t('whisperBin'), t('whisperBinHint')),
466
+ textField('whisperModel', t('whisperModel'), t('whisperModelHint')),
467
+ textField('deepgramBaseUrl', t('deepgramEndpoint'), t('deepgramEndpointHint')),
468
+ React.createElement('label', { className: 'dvs-field' }, t('whisperAutostart'),
469
+ React.createElement('input', {
470
+ type: 'checkbox', checked: !!(draft && draft.autoStart), disabled: !writable,
471
+ onChange: (e) => setTop('autoStart', e.target.checked),
472
+ })),
473
+ React.createElement('label', { className: 'dvs-field', title: t('normalizeTranscriptHint') },
474
+ t('normalizeTranscript'),
475
+ React.createElement('input', {
476
+ type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
477
+ onChange: (e) => setTop('normalizeTranscript', e.target.checked),
478
+ })),
479
+ React.createElement('label', { className: 'dvs-field' }, t('beep'),
480
+ React.createElement('input', {
481
+ type: 'checkbox', checked: !!(draft && draft.beep), disabled: !writable,
482
+ onChange: (e) => setTop('beep', e.target.checked),
483
+ })),
484
+ React.createElement('label', { className: 'dvs-field', title: t('localOnlyHint') }, t('localOnly'),
485
+ React.createElement('input', {
486
+ type: 'checkbox', checked: !!(draft && draft.localOnly), disabled: !writable,
487
+ onChange: (e) => setTop('localOnly', e.target.checked),
488
+ })),
489
+ React.createElement('label', { className: 'dvs-field' }, t('voiceCommandsLabel'),
490
+ React.createElement('input', {
491
+ type: 'checkbox', checked: !!(draft && draft.voiceCommands), disabled: !writable,
492
+ onChange: (e) => setTop('voiceCommands', e.target.checked),
493
+ })),
494
+ micField(),
495
+ React.createElement('label', { className: 'dvs-field', title: t('noiseSuppressionHint') }, t('noiseSuppression'),
496
+ React.createElement('input', {
497
+ type: 'checkbox', checked: draft ? draft.noiseSuppression !== false : true, disabled: !writable,
498
+ onChange: (e) => setTop('noiseSuppression', e.target.checked),
499
+ })),
500
+ React.createElement('label', { className: 'dvs-field', title: t('contextGlossaryHint') }, t('contextGlossary'),
501
+ React.createElement('input', {
502
+ type: 'checkbox', checked: draft ? draft.contextGlossary !== false : true, disabled: !writable,
503
+ onChange: (e) => setTop('contextGlossary', e.target.checked),
504
+ })),
505
+ React.createElement('label', { className: 'dvs-field', title: t('visualizerStyleHint') }, t('visualizerStyle'),
506
+ React.createElement('select', {
507
+ value: String((draft && draft.visualizerStyle) || 'liquid-wave'), disabled: !writable,
508
+ onChange: (e) => setTop('visualizerStyle', e.target.value),
509
+ },
510
+ React.createElement('option', { value: 'liquid-wave' }, t('visLiquidWave')),
511
+ React.createElement('option', { value: 'dynamic-orb' }, t('visDynamicOrb')),
512
+ React.createElement('option', { value: 'bars' }, t('visBars')),
513
+ React.createElement('option', { value: 'off' }, t('visOff')),
514
+ )),
515
+ React.createElement('label', { className: 'dvs-field' }, t('vocabulary'),
516
+ React.createElement('textarea', {
517
+ rows: 3, disabled: !writable,
518
+ value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
519
+ onChange: (e) => setTop('vocabulary', e.target.value.split('\n').map((x) => x.trim()).filter(Boolean)),
520
+ })),
521
+ React.createElement('label', { className: 'dvs-field', title: t('polishBaseUrlHint') }, t('polishBaseUrl'),
522
+ React.createElement('input', {
523
+ type: 'text', value: String((snap && snap.polishBaseUrl) || ''), disabled: !writable,
524
+ onChange: (e) => setTop('polishBaseUrl', e.target.value),
525
+ })),
526
+ React.createElement('label', { className: 'dvs-field' }, t('polishModel'),
527
+ React.createElement('input', {
528
+ type: 'text', value: String((draft && draft.polishModel) || ''), disabled: !writable,
529
+ onChange: (e) => setTop('polishModel', e.target.value),
530
+ })),
531
+ React.createElement('label', { className: 'dvs-field' }, t('polishKeyEnv'),
532
+ React.createElement('input', {
533
+ type: 'text', value: String((draft && draft.polishKeyEnv) || ''), disabled: !writable,
534
+ onChange: (e) => setTop('polishKeyEnv', e.target.value),
535
+ })),
536
+ ),
537
+ React.createElement('div', { className: 'dvs-block' },
538
+ React.createElement('div', { className: 'dvs-h' }, 'SenseVoice-ONNX / Sherpa-ONNX'),
539
+ React.createElement('div', { className: 'dvs-sub' }, t('sensevoiceHint')),
540
+ textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
541
+ textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
542
+ textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceModelHint')),
543
+ React.createElement('label', { className: 'dvs-field' }, t('sensevoiceAutostart'),
544
+ React.createElement('input', {
545
+ type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
546
+ onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
547
+ })),
548
+ ),
549
+ React.createElement('div', { className: 'dvs-block' },
550
+ React.createElement('div', { className: 'dvs-h' }, t('providerDashboard')),
551
+ React.createElement('div', { className: 'dvo-dash' },
552
+ React.createElement('div', { className: 'dvo-dash-grid' },
553
+ Object.keys(statsData).length === 0
554
+ ? React.createElement('div', { className: 'dvs-sub' }, t('idle'))
555
+ : Object.entries(statsData).map(([key, item]) => {
556
+ const avg = item.avgTookMs || 0
557
+ const hasErrors = item.failures > 0
558
+ let badgeClass = 'dvo-badge-idle'
559
+ let badgeText = t('idle')
560
+ if (item.attempts > 0) {
561
+ if (hasErrors && item.successes === 0) {
562
+ badgeClass = 'dvo-badge-err'
563
+ badgeText = t('error')
564
+ } else if (avg > 0 && avg < 400) {
565
+ badgeClass = 'dvo-badge-fast'
566
+ badgeText = avg + 'ms · ' + t('fast')
567
+ } else if (avg >= 400 && avg <= 1500) {
568
+ badgeClass = 'dvo-badge-norm'
569
+ badgeText = avg + 'ms · ' + t('normal')
570
+ } else {
571
+ badgeClass = 'dvo-badge-slow'
572
+ badgeText = avg + 'ms · ' + t('slow')
573
+ }
574
+ }
575
+ const rate = item.attempts > 0 ? Math.round((item.successes / item.attempts) * 100) : 100
576
+ return React.createElement('div', { key, className: 'dvo-dash-item', title: item.lastError ? `Error: ${item.lastError}` : '' },
577
+ React.createElement('div', { className: 'dvo-dash-name' }, key),
578
+ React.createElement('div', { className: 'dvo-dash-row' },
579
+ React.createElement('span', { className: 'dvo-badge ' + badgeClass }, badgeText),
580
+ React.createElement('span', null, rate + '%'),
581
+ ),
582
+ React.createElement('div', { className: 'dvo-dash-row' },
583
+ React.createElement('span', null, `${item.successes}/${item.attempts}`),
584
+ React.createElement('span', null, t('successRate')),
585
+ ),
586
+ )
587
+ })
588
+ )
589
+ )
590
+ ),
591
+ React.createElement('div', { className: 'dvs-row' },
592
+ React.createElement('button', { type: 'button', className: 'dvs-save', disabled: !writable, onClick: save }, t('save')),
593
+ saved ? React.createElement('span', { className: 'dvs-ok' }, t('saved')) : null,
594
+ err ? React.createElement('span', { className: 'dvs-bad' }, err) : null,
595
+ ),
596
+ )
597
+ }
598
+
599
+ // Card in Settings → Plugins → Plugin settings (#18): the kernel only
600
+ // draws the list shell, so title, hint and collapse are ours.
601
+ // Body mounts on first expand; the settings snapshot arrives then.
602
+ function PluginCard(props) {
603
+ const [open, setOpen] = React.useState(false)
604
+ const tt = (props && props.t) || t
605
+ const title = tt('title')
606
+ return React.createElement('li',
607
+ { className: open ? 'dvo-pcard dvo-pcardOpen' : 'dvo-pcard' },
608
+ React.createElement('button',
609
+ {
610
+ type: 'button', className: 'dvo-phead',
611
+ 'aria-expanded': open ? 'true' : 'false',
612
+ 'aria-label': (open ? tt('collapse') : tt('expand')) + ': ' + title,
613
+ onClick: () => setOpen((v) => !v),
614
+ },
615
+ React.createElement('span', { className: 'dvo-pheadtext' },
616
+ React.createElement('span', { className: 'dvo-ptitle' }, title),
617
+ React.createElement('span', { className: 'dvo-pdesc' }, tt('cardHint')),
618
+ ),
619
+ React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
620
+ ),
621
+ open ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
622
+ )
623
+ }
624
+
625
+ function registerSettings(ctx) {
626
+ // Settings card under Settings → Plugins → Plugin settings (#18).
627
+ // The registration key must equal NS (the settings namespace), otherwise
628
+ // the tab silently skips the slot.
629
+ ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
630
+ {
631
+ name: 'settings.plugin.item',
632
+ key: NS,
633
+ // locale on the slot record is what makes the component receive props.t.
634
+ locale: NS,
635
+ inject: () => ({ ctx: ctx }),
636
+ },
637
+ PluginCard,
638
+ ))
639
+ }
640
+