@goodandready/dsh-voice 0.8.19 → 0.8.21

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.
@@ -30,19 +30,40 @@
30
30
  const LANGS = ['auto', 'ru', 'en', 'uk', 'de']
31
31
 
32
32
  const SET_CSS =
33
- '.dvs-wrap{display:flex;flex-direction:column;gap:22px;padding:4px 0;max-width:720px}' +
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}' +
34
59
  '.dvs-block{display:flex;flex-direction:column;gap:10px}' +
35
60
  '.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
36
61
  '.dvs-sub{font-size:12px;color:var(--dsw-alias-label-secondary)}' +
37
62
  '.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}' +
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}' +
39
64
  '.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}' +
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}' +
46
67
  '.dvs-wait{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5;max-width:520px}' +
47
68
  '.dvs-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}' +
48
69
  '.dvs-bad{font-size:12px;color:var(--dsw-alias-state-error-primary)}' +
@@ -53,7 +74,7 @@
53
74
  '.dvo-pdesc{color:var(--dsw-alias-label-secondary);font-size:13px}' +
54
75
  '.dvo-pchev{flex:none;display:flex;color:var(--dsw-alias-label-secondary);transition:transform .15s ease}' +
55
76
  '.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}'
77
+ '.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}'
57
78
  const setCssId = 'dsh-voice/settings.module.css'
58
79
  if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + setCssId + '"]')) {
59
80
  const tag = document.createElement('style')
@@ -152,6 +173,46 @@
152
173
  )
153
174
  }
154
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
+
155
216
  function VoiceSection(props) {
156
217
  const t = (props && props.t) || moduleT
157
218
  const ctx = props.ctx
@@ -166,12 +227,7 @@
166
227
  const [saved, setSaved] = React.useState(false)
167
228
  const [err, setErr] = React.useState('')
168
229
 
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.
230
+ // Hotkey picker: not a name field — press the key you want.
175
231
  const [catching, setCatching] = React.useState(false)
176
232
  React.useEffect(() => {
177
233
  if (!catching) return undefined
@@ -203,22 +259,10 @@
203
259
  .catch(() => {})
204
260
  }, [])
205
261
 
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
262
  const ready = !!snap && snap.status === 'ready'
215
263
  const value = ready && snap.value ? snap.value : {}
216
264
  const writable = ready && snap.writable !== false
217
265
 
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
266
  React.useEffect(() => {
223
267
  if (ready) return undefined
224
268
  let tries = 0
@@ -230,11 +274,8 @@
230
274
  return () => clearInterval(timer)
231
275
  }, [ready])
232
276
 
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
277
  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.
278
+
238
279
  React.useEffect(() => {
239
280
  if (!ready) return
240
281
  voice.settings = Object.assign({}, voice.settings, {
@@ -258,23 +299,34 @@
258
299
  })
259
300
  }, [ready, value, snap])
260
301
 
261
- const [statsData, setStatsData] = React.useState({})
262
- React.useEffect(() => {
263
- let alive = true
264
- fetch('/dsh-voice/status', { cache: 'no-store' })
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 })
265
309
  .then((r) => r.json())
266
310
  .then((data) => {
267
- if (alive && data && data.providerStats) {
268
- setStatsData(data.providerStats)
311
+ clearTimeout(tId)
312
+ setStatusLatency(Math.round(performance.now() - start))
313
+ if (data && data.ok) {
314
+ setStatusData(data)
269
315
  }
270
316
  })
271
- .catch(() => {})
272
- return () => { alive = false }
317
+ .catch(() => {
318
+ clearTimeout(tId)
319
+ setStatusLatency(null)
320
+ })
273
321
  }, [])
274
322
 
323
+ React.useEffect(() => {
324
+ fetchStatus()
325
+ }, [fetchStatus])
326
+
275
327
  if (!ready) {
276
328
  const waiting = !snap || snap.status === 'loading'
277
- return React.createElement('div', { className: 'dvs-wrap' },
329
+ return React.createElement('div', { className: 'cb-page' },
278
330
  React.createElement('div', { className: 'dvs-wait' }, waiting
279
331
  ? t('loadingSettings')
280
332
  : t('notReady1')
@@ -294,10 +346,6 @@
294
346
  setErr(''); setSaved(false)
295
347
  if (!draft) return
296
348
 
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
349
  const failed = []
302
350
  for (const k of Object.keys(draft)) {
303
351
  try {
@@ -322,12 +370,11 @@
322
370
  contextGlossary: draft.contextGlossary !== false,
323
371
  visualizerStyle: draft.visualizerStyle || 'liquid-wave',
324
372
  })
325
- // The composer owns the hotkey handler — tell it to reload, otherwise
326
- // a new key only works after a full page reload.
327
373
  try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
328
374
 
329
375
  if (failed.length) { setErr(t('saveFailed') + ' ' + failed.join('; ')); return }
330
376
  setSaved(true); setTimeout(() => setSaved(false), 2000)
377
+ fetchStatus()
331
378
  }
332
379
 
333
380
  const modeVal = (mode, key, fallback) => {
@@ -335,174 +382,270 @@
335
382
  return m && m[key] !== undefined ? m[key] : fallback
336
383
  }
337
384
 
338
- // Custom provider names come from the draft so a just-added entry
339
- // appears in chain selects immediately.
340
385
  const chainOptions = BUILTIN.concat(
341
386
  (draft && Array.isArray(draft.customProviders) ? draft.customProviders : [])
342
387
  .map((c) => String(c && c.key || '').trim())
343
388
  .filter((k) => k && BUILTIN.indexOf(k) < 0),
344
389
  )
345
390
 
346
- const hotkeyField = () => React.createElement('label', { className: 'dvs-field' },
347
- t('hotkey'),
348
- React.createElement('div', { className: 'dvs-row' },
391
+ const hotkeyField = () => React.createElement('div', { className: 'cb-field' },
392
+ React.createElement('label', null, t('hotkey')),
393
+ React.createElement('div', { className: 'cb-row' },
349
394
  React.createElement('button', {
350
- type: 'button', className: 'dvs-save', disabled: !writable,
395
+ type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable,
351
396
  onClick: () => setCatching(true),
352
397
  }, catching ? t('pressKey') : keyLabel(draft && draft.hotkey)),
353
398
  React.createElement('button', {
354
- type: 'button', className: 'dvs-mini', title: t('clearKey'),
399
+ type: 'button', className: 'cb-btn cb-btn-mini', title: t('clearKey'),
355
400
  disabled: !writable, onClick: () => setDraft((d) => Object.assign({}, d || {}, { hotkey: '' })),
356
401
  }, '×'),
357
402
  ),
358
- React.createElement('span', { className: 'dvs-sub' },
359
- t('hotkeyHint1')
360
- + t('hotkeyHint2')))
403
+ React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
404
+ )
361
405
 
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'),
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')),
371
420
  React.createElement('select', {
372
421
  value: modeVal(mode, 'language', 'ru'), disabled: !writable,
373
422
  onChange: (e) => setIn(mode, 'language', e.target.value),
374
- }, LANGS.map((o) => React.createElement('option', { key: o, value: o }, o))))
423
+ }, LANGS.map((o) => React.createElement('option', { key: o, value: o }, o))),
424
+ )
375
425
 
376
- const numField = (mode, key, label, hint) => React.createElement('label', { className: 'dvs-field' }, label,
426
+ const numField = (mode, key, label, hint) => React.createElement('div', { className: 'cb-field' },
427
+ React.createElement('label', null, label),
377
428
  React.createElement('input', {
378
429
  type: 'number', value: modeVal(mode, key, ''), disabled: !writable,
379
430
  onChange: (e) => setIn(mode, key, Number(e.target.value)),
380
431
  }),
381
- React.createElement('span', { className: 'dvs-sub' }, hint))
432
+ React.createElement('span', { className: 'dvs-sub' }, hint),
433
+ )
382
434
 
383
- const textField = (key, label, hint) => React.createElement('label', { className: 'dvs-field' }, label,
435
+ const textField = (key, label, hint) => React.createElement('div', { className: 'cb-field' },
436
+ React.createElement('label', null, label),
384
437
  React.createElement('input', {
385
- value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
438
+ type: 'text', value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
386
439
  onChange: (e) => setTop(key, e.target.value),
387
440
  }),
388
- React.createElement('span', { className: 'dvs-sub' }, hint))
441
+ React.createElement('span', { className: 'dvs-sub' }, hint),
442
+ )
389
443
 
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')),
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')),
394
477
  React.createElement(ChainEditor, {
395
478
  value: draft && draft.dictation ? draft.dictation.chain : [], writable: writable,
396
479
  options: chainOptions,
397
480
  onChange: (v) => setIn('dictation', 'chain', v),
398
481
  }),
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'),
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')),
420
506
  React.createElement('input', {
421
507
  type: 'range', min: 0, max: 1, step: 0.1,
422
508
  value: Number(draft && draft.dictation && draft.dictation.vadAdapt) || 0, disabled: !writable,
423
509
  onChange: (e) => setIn('dictation', 'vadAdapt', Number(e.target.value)),
424
- })),
510
+ }),
511
+ React.createElement('span', { className: 'dvs-sub' }, t('vadAdaptHint')),
512
+ ),
513
+ textField('wakeWord', t('wakeWord'), t('wakeWordHint')),
425
514
  ),
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')),
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')),
429
522
  React.createElement(ChainEditor, {
430
523
  value: draft && draft.message ? draft.message.chain : [], writable: writable,
431
524
  options: chainOptions,
432
525
  onChange: (v) => setIn('message', 'chain', v),
433
526
  }),
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
- })),
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
+ ),
451
554
  ),
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')),
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')),
456
562
  React.createElement(CustomEditor, {
457
563
  value: draft && draft.customProviders ? draft.customProviders : [], writable: writable,
564
+ options: chainOptions,
458
565
  onChange: (v) => setTop('customProviders', v),
459
566
  }),
460
567
  ),
461
- React.createElement('div', { className: 'dvs-block' },
462
- React.createElement('div', { className: 'dvs-h' }, t('general')),
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')),
463
575
  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
576
  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'),
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')),
506
649
  React.createElement('select', {
507
650
  value: String((draft && draft.visualizerStyle) || 'liquid-wave'), disabled: !writable,
508
651
  onChange: (e) => setTop('visualizerStyle', e.target.value),
@@ -511,43 +654,27 @@
511
654
  React.createElement('option', { value: 'dynamic-orb' }, t('visDynamicOrb')),
512
655
  React.createElement('option', { value: 'bars' }, t('visBars')),
513
656
  React.createElement('option', { value: 'off' }, t('visOff')),
514
- )),
515
- React.createElement('label', { className: 'dvs-field' }, t('vocabulary'),
657
+ ),
658
+ ),
659
+ React.createElement('div', { className: 'cb-field' },
660
+ React.createElement('label', null, t('vocabulary')),
516
661
  React.createElement('textarea', {
517
662
  rows: 3, disabled: !writable,
518
663
  value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
519
664
  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
- })),
665
+ }),
666
+ ),
548
667
  ),
549
- React.createElement('div', { className: 'dvs-block' },
550
- React.createElement('div', { className: 'dvs-h' }, t('providerDashboard')),
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
+ ),
551
678
  React.createElement('div', { className: 'dvo-dash' },
552
679
  React.createElement('div', { className: 'dvo-dash-grid' },
553
680
  Object.keys(statsData).length === 0
@@ -584,53 +711,51 @@
584
711
  React.createElement('span', null, t('successRate')),
585
712
  ),
586
713
  )
587
- })
588
- )
589
- )
714
+ }),
715
+ ),
716
+ ),
590
717
  ),
591
- React.createElement('div', { className: 'dvs-row' },
592
- React.createElement('button', { type: 'button', className: 'dvs-save', disabled: !writable, onClick: save }, t('save')),
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')),
593
722
  saved ? React.createElement('span', { className: 'dvs-ok' }, t('saved')) : null,
594
723
  err ? React.createElement('span', { className: 'dvs-bad' }, err) : null,
595
724
  ),
596
725
  )
597
726
  }
598
727
 
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.
728
+ // Card in Settings → Plugins → Plugin settings (#18)
602
729
  function PluginCard(props) {
603
730
  const [open, setOpen] = React.useState(false)
604
731
  const tt = (props && props.t) || t
605
732
  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')),
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()),
618
748
  ),
619
- React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
749
+ open ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
620
750
  ),
621
- open ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
622
751
  )
623
752
  }
624
753
 
625
754
  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
755
  ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
630
756
  {
631
757
  name: 'settings.plugin.item',
632
758
  key: NS,
633
- // locale on the slot record is what makes the component receive props.t.
634
759
  locale: NS,
635
760
  inject: () => ({ ctx: ctx }),
636
761
  },