@goodandready/dsh-voice 0.8.18 โ†’ 0.8.20

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,765 @@
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
+ '.cb-page{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:960px}' +
34
+ '.cb-section-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:12px}' +
35
+ '.cb-section-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}' +
36
+ '.cb-section-desc{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:-4px;line-height:1.4}' +
37
+ '.cb-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}' +
38
+ '.cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:12px}' +
39
+ '.cb-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
40
+ '.cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:rgba(16,185,129,0.08)}' +
41
+ '.cb-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08)}' +
42
+ '.cb-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08)}' +
43
+ '.cb-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
44
+ '.cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
45
+ '.cb-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 12px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}' +
46
+ '.cb-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}' +
47
+ '.cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}' +
48
+ '.cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88}' +
49
+ '.cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:rgba(239,68,68,0.3)}' +
50
+ '.cb-btn-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;display:inline-flex;align-items:center;justify-content:center;padding:0;font-size:13px}' +
51
+ '.cb-btn-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}' +
52
+ '.cb-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
53
+ '.cb-field select,.cb-field input[type="text"],.cb-field input[type="number"],.cb-field textarea{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
54
+ '.cb-field textarea{height:auto;padding:8px 10px;font-family:inherit}' +
55
+ '.cb-check-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary);user-select:none}' +
56
+ '.cb-check-label input[type="checkbox"]{width:16px;height:16px;cursor:pointer}' +
57
+ '.cb-alert-err{padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:13px}' +
58
+ '.dvs-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0;max-width:960px}' +
59
+ '.dvs-block{display:flex;flex-direction:column;gap:10px}' +
60
+ '.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
61
+ '.dvs-sub{font-size:12px;color:var(--dsw-alias-label-secondary)}' +
62
+ '.dvs-row{display:flex;gap:8px;align-items:center}' +
63
+ '.dvs-row select,.dvs-row input{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:6px 10px;font-size:13px}' +
64
+ '.dvs-row .dvs-model{flex:1}' +
65
+ '.dvs-card{display:flex;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);border-radius:10px;padding:12px}' +
66
+ '.dvs-card input,.dvs-card select{background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);border-radius:6px;padding:6px 8px;font-size:13px}' +
67
+ '.dvs-wait{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5;max-width:520px}' +
68
+ '.dvs-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}' +
69
+ '.dvs-bad{font-size:12px;color:var(--dsw-alias-state-error-primary)}' +
70
+ '.dvo-pcard{list-style:none;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px}' +
71
+ '.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}' +
72
+ '.dvo-pheadtext{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0}' +
73
+ '.dvo-ptitle{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
74
+ '.dvo-pdesc{color:var(--dsw-alias-label-secondary);font-size:13px}' +
75
+ '.dvo-pchev{flex:none;display:flex;color:var(--dsw-alias-label-secondary);transition:transform .15s ease}' +
76
+ '.dvo-pcardOpen .dvo-pchev{transform:rotate(180deg)}' +
77
+ '.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}'
78
+ const setCssId = 'dsh-voice/settings.module.css'
79
+ if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + setCssId + '"]')) {
80
+ const tag = document.createElement('style')
81
+ tag.textContent = SET_CSS
82
+ tag.setAttribute('data-dsh-plugin', 'dsh-voice')
83
+ tag.dataset.pluginCss = setCssId
84
+ document.head.appendChild(tag)
85
+ }
86
+
87
+ // One chain editor: provider+model rows with reordering.
88
+ function ChainEditor(props) {
89
+ const rows = Array.isArray(props.value) ? props.value : []
90
+ const change = (i, patch) => {
91
+ const next = rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r))
92
+ props.onChange(next)
93
+ }
94
+ const move = (i, delta) => {
95
+ const j = i + delta
96
+ if (j < 0 || j >= rows.length) return
97
+ const next = rows.slice()
98
+ const tmp = next[i]; next[i] = next[j]; next[j] = tmp
99
+ props.onChange(next)
100
+ }
101
+ const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
102
+ const add = () => props.onChange(rows.concat([{ provider: 'local-whisper', model: '' }]))
103
+ const options = Array.isArray(props.options) && props.options.length ? props.options : BUILTIN
104
+
105
+ return React.createElement('div', { className: 'dvs-block' },
106
+ rows.map((row, i) => React.createElement('div', { className: 'dvs-row', key: i },
107
+ React.createElement('select', {
108
+ value: row.provider, disabled: !props.writable,
109
+ onChange: (e) => change(i, { provider: e.target.value }),
110
+ }, (options.indexOf(row.provider) < 0 ? options.concat([row.provider]) : options)
111
+ .map((p) => React.createElement('option', { key: p, value: p }, p))),
112
+ React.createElement('input', {
113
+ className: 'dvs-model', value: row.model || '', disabled: !props.writable,
114
+ placeholder: modelHint(row.provider), onChange: (e) => change(i, { model: e.target.value }),
115
+ }),
116
+ React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, 'โ†‘'),
117
+ React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, 'โ†“'),
118
+ React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, 'ร—'),
119
+ )),
120
+ React.createElement('div', { className: 'dvs-row' },
121
+ React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('addProvider'), disabled: !props.writable, onClick: add }, '+'),
122
+ React.createElement('span', { className: 'dvs-sub' }, t('chainHint')),
123
+ ),
124
+ )
125
+ }
126
+
127
+ // Custom providers: name, API template, where to call, how to authorize.
128
+ function CustomEditor(props) {
129
+ const rows = Array.isArray(props.value) ? props.value : []
130
+ const change = (i, patch) => props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
131
+ const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
132
+ const add = () => props.onChange(rows.concat([
133
+ { key: '', template: 'openai-transcriptions', baseURL: '', model: '', keyEnv: '', prompt: '' },
134
+ ]))
135
+ const field = (i, row, name, placeholder, wide) => React.createElement('input', {
136
+ className: wide ? 'dvs-model' : '', value: row[name] || '', placeholder: placeholder,
137
+ disabled: !props.writable, onChange: (e) => change(i, { [name]: e.target.value }),
138
+ })
139
+
140
+ return React.createElement('div', { className: 'dvs-block' },
141
+ rows.map((row, i) => React.createElement('div', { className: 'dvs-card', key: i },
142
+ React.createElement('div', { className: 'dvs-row' },
143
+ field(i, row, 'key', t('customName')),
144
+ React.createElement('select', {
145
+ value: row.template || 'openai-transcriptions', disabled: !props.writable,
146
+ onChange: (e) => change(i, { template: e.target.value }),
147
+ }, TEMPLATES.map((t) => React.createElement('option', { key: t, value: t }, t))),
148
+ React.createElement('button', {
149
+ type: 'button', className: 'dvs-mini', title: t('remove'),
150
+ disabled: !props.writable, onClick: () => remove(i),
151
+ }, '\u00d7'),
152
+ ),
153
+ React.createElement('div', { className: 'dvs-row' },
154
+ field(i, row, 'baseURL', 'https://openrouter.ai/api/v1', true),
155
+ ),
156
+ React.createElement('div', { className: 'dvs-row' },
157
+ field(i, row, 'model', t('customModel'), true),
158
+ field(i, row, 'keyEnv', t('customKeyName')),
159
+ ),
160
+ row.template === 'openai-chat-audio'
161
+ ? React.createElement('div', { className: 'dvs-row' },
162
+ field(i, row, 'prompt', t('customModelHint'), true))
163
+ : null,
164
+ )),
165
+ React.createElement('div', { className: 'dvs-row' },
166
+ React.createElement('button', {
167
+ type: 'button', className: 'dvs-mini', title: t('addCustom'),
168
+ disabled: !props.writable, onClick: add,
169
+ }, '+'),
170
+ React.createElement('span', { className: 'dvs-sub' },
171
+ t('openrouterWarning')),
172
+ ),
173
+ )
174
+ }
175
+
176
+ function createErrorBoundary() {
177
+ if (!React || typeof React.Component !== 'function') {
178
+ return function NoopBoundary(props) { return props?.children || null }
179
+ }
180
+ return class ErrorBoundary extends React.Component {
181
+ constructor(props) {
182
+ super(props)
183
+ this.state = { hasError: false, error: null }
184
+ }
185
+ static getDerivedStateFromError(error) {
186
+ return { hasError: true, error }
187
+ }
188
+ componentDidCatch(error, errorInfo) {
189
+ console.error('[dsh-voice] React Error:', error, errorInfo)
190
+ }
191
+ render() {
192
+ if (this.state.hasError) {
193
+ return React.createElement(
194
+ 'div',
195
+ { className: 'cb-alert-err', style: { margin: '12px 0', padding: '14px', borderRadius: '8px' } },
196
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, 'โš ๏ธ ' + t('uiError')),
197
+ React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
198
+ React.createElement(
199
+ 'button',
200
+ {
201
+ type: 'button',
202
+ className: 'cb-btn',
203
+ style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
204
+ onClick: () => this.setState({ hasError: false, error: null }),
205
+ },
206
+ t('retry'),
207
+ ),
208
+ )
209
+ }
210
+ return this.props?.children || null
211
+ }
212
+ }
213
+ }
214
+ const ErrorBoundary = createErrorBoundary()
215
+
216
+ function VoiceSection(props) {
217
+ const t = (props && props.t) || moduleT
218
+ const ctx = props.ctx
219
+ // On a non-localhost page the kernel disables settings entirely: the
220
+ // shared document is not readable, every section gets "unavailable" and
221
+ // writes are dropped. The server does not share that restriction, and
222
+ // dsh-lanmode rebuilds the same mechanics on the same calls. Prefer its
223
+ // service when installed, otherwise the kernel one (fine on loopback).
224
+ const scope = ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).bind({ namespace: NS })
225
+ const [snap, setSnap] = React.useState(null)
226
+ const [draft, setDraft] = React.useState(null)
227
+ const [saved, setSaved] = React.useState(false)
228
+ const [err, setErr] = React.useState('')
229
+
230
+ // Hotkey picker: not a name field โ€” press the key you want.
231
+ const [catching, setCatching] = React.useState(false)
232
+ React.useEffect(() => {
233
+ if (!catching) return undefined
234
+ const onKey = (event) => {
235
+ event.preventDefault()
236
+ event.stopPropagation()
237
+ if (event.key === 'Escape') { setCatching(false); return }
238
+ const chosen = keyFromEvent(event)
239
+ setDraft((d) => Object.assign({}, d || {}, { hotkey: chosen }))
240
+ setCatching(false)
241
+ }
242
+ document.addEventListener('keydown', onKey, true)
243
+ return () => document.removeEventListener('keydown', onKey, true)
244
+ }, [catching])
245
+
246
+ React.useEffect(() => {
247
+ let alive = true
248
+ const render = () => { if (alive) setSnap(scope.getSnapshot()) }
249
+ render()
250
+ const off = scope.subscribe(render)
251
+ return () => { alive = false; off() }
252
+ }, [])
253
+
254
+ const [devices, setDevices] = React.useState([])
255
+ React.useEffect(() => {
256
+ if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
257
+ navigator.mediaDevices.enumerateDevices()
258
+ .then((list) => setDevices(list.filter((d) => d.kind === 'audioinput')))
259
+ .catch(() => {})
260
+ }, [])
261
+
262
+ const ready = !!snap && snap.status === 'ready'
263
+ const value = ready && snap.value ? snap.value : {}
264
+ const writable = ready && snap.writable !== false
265
+
266
+ React.useEffect(() => {
267
+ if (ready) return undefined
268
+ let tries = 0
269
+ const timer = setInterval(() => {
270
+ if (tries >= 15) { clearInterval(timer); return }
271
+ tries += 1
272
+ try { ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).describe().load() } catch (e) { /* service not up yet */ }
273
+ }, 1000)
274
+ return () => clearInterval(timer)
275
+ }, [ready])
276
+
277
+ React.useEffect(() => { if (ready && draft === null) setDraft(JSON.parse(JSON.stringify(value))) }, [ready, draft, value])
278
+
279
+ React.useEffect(() => {
280
+ if (!ready) return
281
+ voice.settings = Object.assign({}, voice.settings, {
282
+ vadSilenceMs: Number(value && value.dictation && value.dictation.vadSilenceMs) || 700,
283
+ autoSendMs: Number(value && value.message && value.message.autoSendMs) || 4000,
284
+ beep: !!(snap && snap.beep),
285
+ micDeviceId: String((snap && snap.micDeviceId) || ''),
286
+ historyLimit: Number(snap && snap.historyLimit),
287
+ voiceCommands: !!(snap && snap.voiceCommands),
288
+ sendDelayMs: Number(value && value.dictation && value.dictation.sendDelayMs) || 0,
289
+ stream: !!(value && value.dictation && value.dictation.stream),
290
+ streamChunkMs: Number(value && value.dictation && value.dictation.streamChunkMs) || 1200,
291
+ vadAdapt: Number(value && value.dictation && value.dictation.vadAdapt) || 0,
292
+ wakeWord: String((snap && snap.wakeWord) || ''),
293
+ bargeIn: !!(snap && snap.bargeIn),
294
+ polishSend: !!(value && value.message && value.message.polishSend),
295
+ sessionCommands: !!(value && value.message && value.message.sessionCommands),
296
+ polishBaseUrl: String((snap && snap.polishBaseUrl) || ''),
297
+ noiseSuppression: value.noiseSuppression !== false,
298
+ contextGlossary: value.contextGlossary !== false,
299
+ })
300
+ }, [ready, value, snap])
301
+
302
+ const [statusData, setStatusData] = React.useState(null)
303
+ const [statusLatency, setStatusLatency] = React.useState(null)
304
+ const fetchStatus = React.useCallback(() => {
305
+ const start = performance.now()
306
+ const ctrl = new AbortController()
307
+ const tId = setTimeout(() => ctrl.abort(), 5000)
308
+ fetch('/dsh-voice/status', { cache: 'no-store', signal: ctrl.signal })
309
+ .then((r) => r.json())
310
+ .then((data) => {
311
+ clearTimeout(tId)
312
+ setStatusLatency(Math.round(performance.now() - start))
313
+ if (data && data.ok) {
314
+ setStatusData(data)
315
+ }
316
+ })
317
+ .catch(() => {
318
+ clearTimeout(tId)
319
+ setStatusLatency(null)
320
+ })
321
+ }, [])
322
+
323
+ React.useEffect(() => {
324
+ fetchStatus()
325
+ }, [fetchStatus])
326
+
327
+ if (!ready) {
328
+ const waiting = !snap || snap.status === 'loading'
329
+ return React.createElement('div', { className: 'cb-page' },
330
+ React.createElement('div', { className: 'dvs-wait' }, waiting
331
+ ? t('loadingSettings')
332
+ : t('notReady1')
333
+ + t('notReady2')),
334
+ )
335
+ }
336
+
337
+ const setIn = (mode, key, v) => setDraft((d) => {
338
+ const next = JSON.parse(JSON.stringify(d || {}))
339
+ next[mode] = next[mode] || {}
340
+ next[mode][key] = v
341
+ return next
342
+ })
343
+ const setTop = (key, v) => setDraft((d) => Object.assign({}, d || {}, { [key]: v }))
344
+
345
+ const save = async () => {
346
+ setErr(''); setSaved(false)
347
+ if (!draft) return
348
+
349
+ const failed = []
350
+ for (const k of Object.keys(draft)) {
351
+ try {
352
+ await scope.set(k, draft[k])
353
+ } catch (e) {
354
+ failed.push(k + ': ' + String(e && e.message ? e.message : e))
355
+ }
356
+ }
357
+
358
+ Object.assign(voice.settings, {
359
+ vadSilenceMs: Number(draft.dictation && draft.dictation.vadSilenceMs) || 700,
360
+ autoSendMs: Number(draft.message && draft.message.autoSendMs) || 4000,
361
+ beep: !!draft.beep,
362
+ micDeviceId: String(draft.micDeviceId || ''),
363
+ historyLimit: Number(draft.historyLimit),
364
+ voiceCommands: !!draft.voiceCommands,
365
+ sendDelayMs: Number(draft.dictation && draft.dictation.sendDelayMs) || 0,
366
+ stream: !!(draft.dictation && draft.dictation.stream),
367
+ streamChunkMs: Number(draft.dictation && draft.dictation.streamChunkMs) || 1200,
368
+ vadAdapt: Number(draft.dictation && draft.dictation.vadAdapt) || 0,
369
+ noiseSuppression: draft.noiseSuppression !== false,
370
+ contextGlossary: draft.contextGlossary !== false,
371
+ visualizerStyle: draft.visualizerStyle || 'liquid-wave',
372
+ })
373
+ try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
374
+
375
+ if (failed.length) { setErr(t('saveFailed') + ' ' + failed.join('; ')); return }
376
+ setSaved(true); setTimeout(() => setSaved(false), 2000)
377
+ fetchStatus()
378
+ }
379
+
380
+ const modeVal = (mode, key, fallback) => {
381
+ const m = draft && draft[mode]
382
+ return m && m[key] !== undefined ? m[key] : fallback
383
+ }
384
+
385
+ const chainOptions = BUILTIN.concat(
386
+ (draft && Array.isArray(draft.customProviders) ? draft.customProviders : [])
387
+ .map((c) => String(c && c.key || '').trim())
388
+ .filter((k) => k && BUILTIN.indexOf(k) < 0),
389
+ )
390
+
391
+ const hotkeyField = () => React.createElement('div', { className: 'cb-field' },
392
+ React.createElement('label', null, t('hotkey')),
393
+ React.createElement('div', { className: 'cb-row' },
394
+ React.createElement('button', {
395
+ type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable,
396
+ onClick: () => setCatching(true),
397
+ }, catching ? t('pressKey') : keyLabel(draft && draft.hotkey)),
398
+ React.createElement('button', {
399
+ type: 'button', className: 'cb-btn cb-btn-mini', title: t('clearKey'),
400
+ disabled: !writable, onClick: () => setDraft((d) => Object.assign({}, d || {}, { hotkey: '' })),
401
+ }, 'ร—'),
402
+ ),
403
+ React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
404
+ )
405
+
406
+ const micField = () => React.createElement('div', { className: 'cb-field' },
407
+ React.createElement('label', null, t('mic')),
408
+ React.createElement('select', {
409
+ value: String((snap && snap.micDeviceId) || ''), disabled: !writable,
410
+ onChange: (e) => setTop('micDeviceId', e.target.value),
411
+ },
412
+ React.createElement('option', { value: '' }, t('micDefault')),
413
+ devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
414
+ d.label || d.deviceId.slice(0, 12))),
415
+ ),
416
+ )
417
+
418
+ const langField = (mode) => React.createElement('div', { className: 'cb-field' },
419
+ React.createElement('label', null, t('language')),
420
+ React.createElement('select', {
421
+ value: modeVal(mode, 'language', 'ru'), disabled: !writable,
422
+ onChange: (e) => setIn(mode, 'language', e.target.value),
423
+ }, LANGS.map((o) => React.createElement('option', { key: o, value: o }, o))),
424
+ )
425
+
426
+ const numField = (mode, key, label, hint) => React.createElement('div', { className: 'cb-field' },
427
+ React.createElement('label', null, label),
428
+ React.createElement('input', {
429
+ type: 'number', value: modeVal(mode, key, ''), disabled: !writable,
430
+ onChange: (e) => setIn(mode, key, Number(e.target.value)),
431
+ }),
432
+ React.createElement('span', { className: 'dvs-sub' }, hint),
433
+ )
434
+
435
+ const textField = (key, label, hint) => React.createElement('div', { className: 'cb-field' },
436
+ React.createElement('label', null, label),
437
+ React.createElement('input', {
438
+ type: 'text', value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
439
+ onChange: (e) => setTop(key, e.target.value),
440
+ }),
441
+ React.createElement('span', { className: 'dvs-sub' }, hint),
442
+ )
443
+
444
+ const statsData = (statusData && statusData.providerStats) || {}
445
+ const hostOnline = statusLatency !== null
446
+ const whisperRunning = !!(statusData && statusData.whisperRunning)
447
+ const providerCount = (statusData && Array.isArray(statusData.providers)) ? statusData.providers.length : chainOptions.length
448
+
449
+ return React.createElement('div', { className: 'cb-page' },
450
+ // 1. Connection & Engine Status Card
451
+ React.createElement('div', { className: 'cb-section-card' },
452
+ React.createElement('div', { className: 'cb-section-title' },
453
+ React.createElement('span', null, t('statusTitle')),
454
+ React.createElement('button', {
455
+ type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
456
+ onClick: fetchStatus,
457
+ }, '๐Ÿ”„ ' + t('refreshStats')),
458
+ ),
459
+ React.createElement('div', { className: 'cb-section-desc' }, t('statusDesc')),
460
+ React.createElement('div', { className: 'cb-row' },
461
+ hostOnline
462
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, 'โ— ' + t('badgeHostOnline').replace('{ms}', statusLatency))
463
+ : React.createElement('span', { className: 'cb-badge cb-badge-bad' }, 'โ— ' + t('badgeHostOffline')),
464
+ whisperRunning
465
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, 'โ— ' + t('badgeWhisperActive'))
466
+ : React.createElement('span', { className: 'cb-badge cb-badge-warn' }, 'โ—‹ ' + t('badgeWhisperInactive')),
467
+ React.createElement('span', { className: 'cb-badge cb-badge-ok' }, 'โœ“ ' + t('badgeProviders').replace('{count}', providerCount)),
468
+ ),
469
+ ),
470
+
471
+ // 2. Dictation Mode Card
472
+ React.createElement('div', { className: 'cb-section-card' },
473
+ React.createElement('div', { className: 'cb-section-title' },
474
+ React.createElement('span', null, '๐ŸŽ™๏ธ ' + t('dictationBtn')),
475
+ ),
476
+ React.createElement('div', { className: 'cb-section-desc' }, t('dictationHint')),
477
+ React.createElement(ChainEditor, {
478
+ value: draft && draft.dictation ? draft.dictation.chain : [], writable: writable,
479
+ options: chainOptions,
480
+ onChange: (v) => setIn('dictation', 'chain', v),
481
+ }),
482
+ React.createElement('div', { className: 'cb-grid-2' },
483
+ langField('dictation'),
484
+ numField('dictation', 'vadSilenceMs', t('pauseMs'), t('pauseHint')),
485
+ numField('dictation', 'sendDelayMs', t('sendDelay'), t('sendDelayHint')),
486
+ numField('dictation', 'streamChunkMs', t('streamChunkMs'), t('streamHint')),
487
+ ),
488
+ React.createElement('div', { className: 'cb-row' },
489
+ React.createElement('label', { className: 'cb-check-label' },
490
+ React.createElement('input', {
491
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.polish), disabled: !writable,
492
+ onChange: (e) => setIn('dictation', 'polish', e.target.checked),
493
+ }),
494
+ t('polish'),
495
+ ),
496
+ React.createElement('label', { className: 'cb-check-label' },
497
+ React.createElement('input', {
498
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.stream), disabled: !writable,
499
+ onChange: (e) => setIn('dictation', 'stream', e.target.checked),
500
+ }),
501
+ t('stream'),
502
+ ),
503
+ ),
504
+ React.createElement('div', { className: 'cb-field' },
505
+ React.createElement('label', null, t('vadAdapt')),
506
+ React.createElement('input', {
507
+ type: 'range', min: 0, max: 1, step: 0.1,
508
+ value: Number(draft && draft.dictation && draft.dictation.vadAdapt) || 0, disabled: !writable,
509
+ onChange: (e) => setIn('dictation', 'vadAdapt', Number(e.target.value)),
510
+ }),
511
+ React.createElement('span', { className: 'dvs-sub' }, t('vadAdaptHint')),
512
+ ),
513
+ textField('wakeWord', t('wakeWord'), t('wakeWordHint')),
514
+ ),
515
+
516
+ // 3. Voice Message Card
517
+ React.createElement('div', { className: 'cb-section-card' },
518
+ React.createElement('div', { className: 'cb-section-title' },
519
+ React.createElement('span', null, '๐Ÿ’ฌ ' + t('messageTitle')),
520
+ ),
521
+ React.createElement('div', { className: 'cb-section-desc' }, t('messageHint')),
522
+ React.createElement(ChainEditor, {
523
+ value: draft && draft.message ? draft.message.chain : [], writable: writable,
524
+ options: chainOptions,
525
+ onChange: (v) => setIn('message', 'chain', v),
526
+ }),
527
+ React.createElement('div', { className: 'cb-grid-2' },
528
+ langField('message'),
529
+ numField('message', 'autoSendMs', t('undoMs'), t('undoHint')),
530
+ ),
531
+ React.createElement('div', { className: 'cb-row' },
532
+ React.createElement('label', { className: 'cb-check-label' },
533
+ React.createElement('input', {
534
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
535
+ onChange: (e) => setIn('message', 'polish', e.target.checked),
536
+ }),
537
+ t('polish'),
538
+ ),
539
+ React.createElement('label', { className: 'cb-check-label' },
540
+ React.createElement('input', {
541
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polishSend), disabled: !writable,
542
+ onChange: (e) => setIn('message', 'polishSend', e.target.checked),
543
+ }),
544
+ t('polishSend'),
545
+ ),
546
+ React.createElement('label', { className: 'cb-check-label' },
547
+ React.createElement('input', {
548
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.sessionCommands), disabled: !writable,
549
+ onChange: (e) => setIn('message', 'sessionCommands', e.target.checked),
550
+ }),
551
+ t('sessionCommands'),
552
+ ),
553
+ ),
554
+ ),
555
+
556
+ // 4. Custom Providers Card
557
+ React.createElement('div', { className: 'cb-section-card' },
558
+ React.createElement('div', { className: 'cb-section-title' },
559
+ React.createElement('span', null, 'โš™๏ธ ' + t('customTitle')),
560
+ ),
561
+ React.createElement('div', { className: 'cb-section-desc' }, t('customHint')),
562
+ React.createElement(CustomEditor, {
563
+ value: draft && draft.customProviders ? draft.customProviders : [], writable: writable,
564
+ options: chainOptions,
565
+ onChange: (v) => setTop('customProviders', v),
566
+ }),
567
+ ),
568
+
569
+ // 5. Hardware & Local Engines Card
570
+ React.createElement('div', { className: 'cb-section-card' },
571
+ React.createElement('div', { className: 'cb-section-title' },
572
+ React.createElement('span', null, '๐Ÿ”ง ' + t('hardwareTitle')),
573
+ ),
574
+ React.createElement('div', { className: 'cb-section-desc' }, t('hardwareDesc')),
575
+ hotkeyField(),
576
+ micField(),
577
+ React.createElement('div', { className: 'cb-grid-2' },
578
+ textField('whisperUrl', t('whisperEndpoint'), t('whisperEndpointHint')),
579
+ textField('whisperBin', t('whisperBin'), t('whisperBinHint')),
580
+ textField('whisperModel', t('whisperModel'), t('whisperModelHint')),
581
+ textField('deepgramBaseUrl', t('deepgramEndpoint'), t('deepgramEndpointHint')),
582
+ textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
583
+ textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
584
+ textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceModelHint')),
585
+ textField('polishBaseUrl', t('polishBaseUrl'), t('polishBaseUrlHint')),
586
+ textField('polishModel', t('polishModel'), ''),
587
+ textField('polishKeyEnv', t('polishKeyEnv'), ''),
588
+ ),
589
+ React.createElement('div', { className: 'cb-row' },
590
+ React.createElement('label', { className: 'cb-check-label' },
591
+ React.createElement('input', {
592
+ type: 'checkbox', checked: !!(draft && draft.autoStart), disabled: !writable,
593
+ onChange: (e) => setTop('autoStart', e.target.checked),
594
+ }),
595
+ t('whisperAutostart'),
596
+ ),
597
+ React.createElement('label', { className: 'cb-check-label' },
598
+ React.createElement('input', {
599
+ type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
600
+ onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
601
+ }),
602
+ t('sensevoiceAutostart'),
603
+ ),
604
+ React.createElement('label', { className: 'cb-check-label' },
605
+ React.createElement('input', {
606
+ type: 'checkbox', checked: !!(draft && draft.localOnly), disabled: !writable,
607
+ onChange: (e) => setTop('localOnly', e.target.checked),
608
+ }),
609
+ t('localOnly'),
610
+ ),
611
+ React.createElement('label', { className: 'cb-check-label' },
612
+ React.createElement('input', {
613
+ type: 'checkbox', checked: !!(draft && draft.beep), disabled: !writable,
614
+ onChange: (e) => setTop('beep', e.target.checked),
615
+ }),
616
+ t('beep'),
617
+ ),
618
+ React.createElement('label', { className: 'cb-check-label' },
619
+ React.createElement('input', {
620
+ type: 'checkbox', checked: draft ? draft.noiseSuppression !== false : true, disabled: !writable,
621
+ onChange: (e) => setTop('noiseSuppression', e.target.checked),
622
+ }),
623
+ t('noiseSuppression'),
624
+ ),
625
+ React.createElement('label', { className: 'cb-check-label' },
626
+ React.createElement('input', {
627
+ type: 'checkbox', checked: draft ? draft.contextGlossary !== false : true, disabled: !writable,
628
+ onChange: (e) => setTop('contextGlossary', e.target.checked),
629
+ }),
630
+ t('contextGlossary'),
631
+ ),
632
+ React.createElement('label', { className: 'cb-check-label' },
633
+ React.createElement('input', {
634
+ type: 'checkbox', checked: !!(draft && draft.voiceCommands), disabled: !writable,
635
+ onChange: (e) => setTop('voiceCommands', e.target.checked),
636
+ }),
637
+ t('voiceCommandsLabel'),
638
+ ),
639
+ React.createElement('label', { className: 'cb-check-label' },
640
+ React.createElement('input', {
641
+ type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
642
+ onChange: (e) => setTop('normalizeTranscript', e.target.checked),
643
+ }),
644
+ t('normalizeTranscript'),
645
+ ),
646
+ ),
647
+ React.createElement('div', { className: 'cb-field' },
648
+ React.createElement('label', null, t('visualizerStyle')),
649
+ React.createElement('select', {
650
+ value: String((draft && draft.visualizerStyle) || 'liquid-wave'), disabled: !writable,
651
+ onChange: (e) => setTop('visualizerStyle', e.target.value),
652
+ },
653
+ React.createElement('option', { value: 'liquid-wave' }, t('visLiquidWave')),
654
+ React.createElement('option', { value: 'dynamic-orb' }, t('visDynamicOrb')),
655
+ React.createElement('option', { value: 'bars' }, t('visBars')),
656
+ React.createElement('option', { value: 'off' }, t('visOff')),
657
+ ),
658
+ ),
659
+ React.createElement('div', { className: 'cb-field' },
660
+ React.createElement('label', null, t('vocabulary')),
661
+ React.createElement('textarea', {
662
+ rows: 3, disabled: !writable,
663
+ value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
664
+ onChange: (e) => setTop('vocabulary', e.target.value.split('\n').map((x) => x.trim()).filter(Boolean)),
665
+ }),
666
+ ),
667
+ ),
668
+
669
+ // 6. Provider Telemetry & Latency Dashboard
670
+ React.createElement('div', { className: 'cb-section-card' },
671
+ React.createElement('div', { className: 'cb-section-title' },
672
+ React.createElement('span', null, '๐Ÿ“Š ' + t('providerDashboard')),
673
+ React.createElement('button', {
674
+ type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
675
+ onClick: fetchStatus,
676
+ }, '๐Ÿ”„ ' + t('refreshStats')),
677
+ ),
678
+ React.createElement('div', { className: 'dvo-dash' },
679
+ React.createElement('div', { className: 'dvo-dash-grid' },
680
+ Object.keys(statsData).length === 0
681
+ ? React.createElement('div', { className: 'dvs-sub' }, t('idle'))
682
+ : Object.entries(statsData).map(([key, item]) => {
683
+ const avg = item.avgTookMs || 0
684
+ const hasErrors = item.failures > 0
685
+ let badgeClass = 'dvo-badge-idle'
686
+ let badgeText = t('idle')
687
+ if (item.attempts > 0) {
688
+ if (hasErrors && item.successes === 0) {
689
+ badgeClass = 'dvo-badge-err'
690
+ badgeText = t('error')
691
+ } else if (avg > 0 && avg < 400) {
692
+ badgeClass = 'dvo-badge-fast'
693
+ badgeText = avg + 'ms ยท ' + t('fast')
694
+ } else if (avg >= 400 && avg <= 1500) {
695
+ badgeClass = 'dvo-badge-norm'
696
+ badgeText = avg + 'ms ยท ' + t('normal')
697
+ } else {
698
+ badgeClass = 'dvo-badge-slow'
699
+ badgeText = avg + 'ms ยท ' + t('slow')
700
+ }
701
+ }
702
+ const rate = item.attempts > 0 ? Math.round((item.successes / item.attempts) * 100) : 100
703
+ return React.createElement('div', { key, className: 'dvo-dash-item', title: item.lastError ? `Error: ${item.lastError}` : '' },
704
+ React.createElement('div', { className: 'dvo-dash-name' }, key),
705
+ React.createElement('div', { className: 'dvo-dash-row' },
706
+ React.createElement('span', { className: 'dvo-badge ' + badgeClass }, badgeText),
707
+ React.createElement('span', null, rate + '%'),
708
+ ),
709
+ React.createElement('div', { className: 'dvo-dash-row' },
710
+ React.createElement('span', null, `${item.successes}/${item.attempts}`),
711
+ React.createElement('span', null, t('successRate')),
712
+ ),
713
+ )
714
+ }),
715
+ ),
716
+ ),
717
+ ),
718
+
719
+ // Action bar (Save)
720
+ React.createElement('div', { className: 'cb-row' },
721
+ React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable, onClick: save }, t('save')),
722
+ saved ? React.createElement('span', { className: 'dvs-ok' }, t('saved')) : null,
723
+ err ? React.createElement('span', { className: 'dvs-bad' }, err) : null,
724
+ ),
725
+ )
726
+ }
727
+
728
+ // Card in Settings โ†’ Plugins โ†’ Plugin settings (#18)
729
+ function PluginCard(props) {
730
+ const [open, setOpen] = React.useState(false)
731
+ const tt = (props && props.t) || t
732
+ const title = tt('title')
733
+ return React.createElement(ErrorBoundary, null,
734
+ React.createElement('li',
735
+ { className: open ? 'dvo-pcard dvo-pcardOpen' : 'dvo-pcard' },
736
+ React.createElement('button',
737
+ {
738
+ type: 'button', className: 'dvo-phead',
739
+ 'aria-expanded': open ? 'true' : 'false',
740
+ 'aria-label': (open ? tt('collapse') : tt('expand')) + ': ' + title,
741
+ onClick: () => setOpen((v) => !v),
742
+ },
743
+ React.createElement('span', { className: 'dvo-pheadtext' },
744
+ React.createElement('span', { className: 'dvo-ptitle' }, title),
745
+ React.createElement('span', { className: 'dvo-pdesc' }, tt('cardHint')),
746
+ ),
747
+ React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
748
+ ),
749
+ open ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
750
+ ),
751
+ )
752
+ }
753
+
754
+ function registerSettings(ctx) {
755
+ ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
756
+ {
757
+ name: 'settings.plugin.item',
758
+ key: NS,
759
+ locale: NS,
760
+ inject: () => ({ ctx: ctx }),
761
+ },
762
+ PluginCard,
763
+ ))
764
+ }
765
+