@goodandready/dsh-voice 0.9.37 → 0.9.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -16,6 +16,7 @@ window.__ModuleLoader__.load({
16
16
  var exports = module.exports
17
17
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
18
18
  let React = require('react')
19
+ const h = React.createElement
19
20
 
20
21
  let ChevronIcon = null
21
22
  try {
@@ -109,6 +110,11 @@ window.__ModuleLoader__.load({
109
110
  'undo': 'Undo last insert',
110
111
  'undone': 'Insert undone',
111
112
  'nothingToUndo': 'Nothing to undo',
113
+ 'clipping': 'Clipping', 'clippingHint': 'Too loud (clipping)',
114
+ 'lowVolume': 'Low signal', 'lowVolumeHint': 'Speak louder',
115
+ 'vadEngine': 'VAD engine', 'vadEngineHint': 'Neural Silero VAD (WASM/ONNX) or classic amplitude gate',
116
+ 'vadSilero': 'Silero VAD (neural)', 'vadAmplitude': 'Amplitude gate',
117
+ 'streamingWs': 'Streaming WebSocket', 'streamingWsHint': 'Real-time WebSocket audio streaming to local ASR',
112
118
  'beep': 'Beep on start/stop',
113
119
  'localOnly': 'Local whisper only',
114
120
  'localOnlyHint': 'Restrict both chains to the local whisper.cpp server: fully offline.',
@@ -398,6 +404,11 @@ window.__ModuleLoader__.load({
398
404
  'undo': '撤销上次插入',
399
405
  'undone': '插入已撤销',
400
406
  'nothingToUndo': '没有可撤销的内容',
407
+ 'clipping': '爆音', 'clippingHint': '音量过大(爆音截断)',
408
+ 'lowVolume': '信号微弱', 'lowVolumeHint': '请提高音量',
409
+ 'vadEngine': 'VAD 引擎', 'vadEngineHint': '神经网络 Silero VAD (WASM/ONNX) 或传统振幅门限',
410
+ 'vadSilero': 'Silero VAD(神经网络)', 'vadAmplitude': '振幅门限',
411
+ 'streamingWs': '流式 WebSocket', 'streamingWsHint': '向本地 ASR 实时通过 WebSocket 流式传输音频',
401
412
  'beep': '启停提示音',
402
413
  'localOnly': '仅使用本地 whisper',
403
414
  'localOnlyHint': '将双链限制为仅使用本地 whisper.cpp 服务:纯离线运行。',
@@ -616,7 +627,7 @@ window.__ModuleLoader__.load({
616
627
  const t = (key) => moduleT(key)
617
628
  // ------------------------------------------------------------------ css
618
629
  const CSS =
619
- '.dvo-btn{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0;box-sizing:border-box;user-select:none;-webkit-user-select:none;touch-action:manipulation}.dvo-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}.dvo-btn[data-err="1"]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent);box-sizing:border-box;user-select:none;-webkit-user-select:none}.dvo-chev-icon{width:14px;height:14px;display:block;flex:none}.dvo-pbtn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);user-select:none;-webkit-user-select:none;touch-action:manipulation}.dvo-pbtn:hover{background:var(--dsw-alias-bg-layer-2)}.dvo-wave{flex:1;min-width:0;max-width:100%;height:40px;width:100%;color:var(--dsw-alias-label-primary)}.dvo-status{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dvo-err{color:var(--dsw-alias-state-error-primary)}.dvo-count{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dsw-alias-label-secondary)}.dvo-spin{animation:dvo-spin 1s linear infinite}@keyframes dvo-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}.dvo-btn-active{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}.dvo-audio-wrap{display:flex;align-items:center;gap:8px;padding:2px 10px;background:var(--dsw-alias-bg-layer-2);border-radius:14px;border:1px solid var(--dsw-alias-border-l1)}.dvo-audio-play{display:flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);border:0;cursor:pointer;padding:0}.dvo-audio-play:hover{opacity:0.9}.dvo-audio-time{font-size:12px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}.dvo-dash{display:flex;flex-direction:column;gap:8px;margin-top:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:12px}.dvo-dash-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;margin-top:4px}.dvo-dash-item{display:flex;flex-direction:column;gap:4px;padding:8px 10px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l1);border-radius:8px}.dvo-dash-name{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary)}.dvo-dash-row{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--dsw-alias-label-secondary)}.dvo-badge{display:inline-flex;align-items:center;font-size:11px;padding:1px 6px;border-radius:6px;font-weight:600}.dvo-badge-fast{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-success-primary);border:1px solid var(--dsw-alias-state-success-primary)}.dvo-badge-norm{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-state-warning-primary)}.dvo-badge-slow{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-border-l2)}.dvo-badge-err{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-error-primary);border:1px solid var(--dsw-alias-state-error-primary)}.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}.dvo-mic-test{display:flex;align-items:center;gap:10px;margin-top:8px;padding:6px 12px;background:var(--dsw-alias-bg-layer-2);border-radius:8px;border:1px solid var(--dsw-alias-border-l1)}.dvo-meter-bar{flex:1;height:8px;background:var(--dsw-alias-bg-layer-3);border-radius:4px;overflow:hidden;position:relative}.dvo-meter-fill{height:100%;background:var(--dsw-alias-state-success-primary);border-radius:4px;transition:width 60ms ease-out}.dvo-meter-gate{position:absolute;top:0;bottom:0;width:2px;background:var(--dsw-alias-state-warning-primary);box-shadow:0 0 4px var(--dsw-alias-state-warning-primary);z-index:2;pointer-events:none}.dvo-silence-ring{width:34px;height:34px;transform:rotate(-90deg);flex:none}.dvo-ring-bg{fill:none;stroke:var(--dsw-alias-border-l1);stroke-width:2.5}.dvo-ring-fg{fill:none;stroke:var(--dsw-alias-state-info-primary);stroke-width:2.5;stroke-linecap:round;transition:stroke-dashoffset 100ms linear}.dvo-ghost{font-style:italic;color:var(--dsw-alias-label-tertiary);margin-left:6px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}.dvo-installer-box{display:flex;flex-direction:column;gap:8px;padding:10px 12px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;margin-top:8px}.dvo-installer-row{display:flex;align-items:center;justify-content:space-between;gap:8px}.dvo-progress-bar{height:6px;width:100%;background:var(--dsw-alias-bg-layer-3);border-radius:3px;overflow:hidden}.dvo-progress-fill{height:100%;background:var(--dsw-alias-state-info-primary);transition:width 150ms ease}.dvo-jargon-table{width:100%;margin-top:6px;border-collapse:collapse;font-size:12px}.dvo-jargon-table td{padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l1)}.dvo-pill-preview{display:flex;flex-direction:column;gap:8px;padding:10px 14px;border-radius:18px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent);box-sizing:border-box}.dvo-preview-header{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%}.dvo-preview-tabs{display:inline-flex;align-items:center;gap:4px;background:var(--dsw-alias-bg-layer-2);padding:2px;border-radius:12px;border:1px solid var(--dsw-alias-border-l1)}.dvo-tab-btn{border:none;background:transparent;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:500;padding:3px 10px;border-radius:10px;cursor:pointer;transition:all 150ms ease}.dvo-tab-btn:hover{color:var(--dsw-alias-label-primary)}.dvo-tab-btn-active{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:0 1px 3px color-mix(in srgb, var(--dsw-alias-label-primary) 15%, transparent)}.dvo-preview-body{display:flex;align-items:center;gap:8px;width:100%}.dvo-preview-text{flex:1;font-size:13px;color:var(--dsw-alias-label-primary);line-height:1.4;word-break:break-word;max-height:80px;overflow-y:auto;padding:2px 0}.dvo-preview-input{flex:1;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;outline:none;font-family:inherit;width:100%}.dvo-preview-input:focus{border-color:var(--dsw-alias-state-info-primary)}.dvo-btn-send{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:none;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);transition:opacity 150ms ease}.dvo-btn-send:hover{opacity:0.9}.dvo-warn-banner{font-size:12px;color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-error-primary);padding:4px 8px;border-radius:6px;margin-top:4px}' + '.dvo-mode-pill-btn{display:inline-flex;align-items:center;justify-content:center;height:28px;padding:0 8px;font-size:11px;font-weight:600;border-radius:14px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);user-select:none;touch-action:manipulation;transition:all 150ms ease}' + '.dvo-mode-pill-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary)}' + '.dvo-mode-badge{font-size:11px;font-weight:600;padding:2px 6px;border-radius:6px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);margin-right:4px}' + '.dvo-macro-item{display:flex;flex-direction:column;gap:6px;padding:8px 10px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;margin-top:6px}' + '.dvo-macro-actions{display:flex;justify-content:flex-end;gap:6px}' + '.dvo-fallback-badge{font-size:11px;padding:2px 6px;border-radius:4px;color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-warning-primary)}'
630
+ '.dvo-btn{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0;box-sizing:border-box;user-select:none;-webkit-user-select:none;touch-action:manipulation}.dvo-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}.dvo-btn[data-err="1"]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent);box-sizing:border-box;user-select:none;-webkit-user-select:none}.dvo-chev-icon{width:14px;height:14px;display:block;flex:none}.dvo-pbtn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);user-select:none;-webkit-user-select:none;touch-action:manipulation}.dvo-pbtn:hover{background:var(--dsw-alias-bg-layer-2)}.dvo-wave{flex:1;min-width:0;max-width:100%;height:40px;width:100%;color:var(--dsw-alias-label-primary)}.dvo-status{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dvo-err{color:var(--dsw-alias-state-error-primary)}.dvo-count{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dsw-alias-label-secondary)}.dvo-spin{animation:dvo-spin 1s linear infinite}@keyframes dvo-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}.dvo-btn-active{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}.dvo-audio-wrap{display:flex;align-items:center;gap:8px;padding:2px 10px;background:var(--dsw-alias-bg-layer-2);border-radius:14px;border:1px solid var(--dsw-alias-border-l1)}.dvo-audio-play{display:flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);border:0;cursor:pointer;padding:0}.dvo-audio-play:hover{opacity:0.9}.dvo-audio-time{font-size:12px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}.dvo-dash{display:flex;flex-direction:column;gap:8px;margin-top:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:12px}.dvo-dash-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;margin-top:4px}.dvo-dash-item{display:flex;flex-direction:column;gap:4px;padding:8px 10px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l1);border-radius:8px}.dvo-dash-name{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary)}.dvo-dash-row{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--dsw-alias-label-secondary)}.dvo-badge{display:inline-flex;align-items:center;font-size:11px;padding:1px 6px;border-radius:6px;font-weight:600}.dvo-badge-fast{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-success-primary);border:1px solid var(--dsw-alias-state-success-primary)}.dvo-badge-norm{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-state-warning-primary)}.dvo-badge-slow{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-border-l2)}.dvo-badge-err{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-error-primary);border:1px solid var(--dsw-alias-state-error-primary)}.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}.dvo-mic-test{display:flex;align-items:center;gap:10px;margin-top:8px;padding:6px 12px;background:var(--dsw-alias-bg-layer-2);border-radius:8px;border:1px solid var(--dsw-alias-border-l1)}.dvo-meter-bar{flex:1;height:8px;background:var(--dsw-alias-bg-layer-3);border-radius:4px;overflow:hidden;position:relative}.dvo-meter-fill{height:100%;background:var(--dsw-alias-state-success-primary);border-radius:4px;transition:width 60ms ease-out}.dvo-meter-gate{position:absolute;top:0;bottom:0;width:2px;background:var(--dsw-alias-state-warning-primary);box-shadow:0 0 4px var(--dsw-alias-state-warning-primary);z-index:2;pointer-events:none}.dvo-silence-ring{width:34px;height:34px;transform:rotate(-90deg);flex:none}.dvo-ring-bg{fill:none;stroke:var(--dsw-alias-border-l1);stroke-width:2.5}.dvo-ring-fg{fill:none;stroke:var(--dsw-alias-state-info-primary);stroke-width:2.5;stroke-linecap:round;transition:stroke-dashoffset 100ms linear}.dvo-ghost{font-style:italic;color:var(--dsw-alias-label-tertiary);margin-left:6px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}.dvo-installer-box{display:flex;flex-direction:column;gap:8px;padding:10px 12px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;margin-top:8px}.dvo-installer-row{display:flex;align-items:center;justify-content:space-between;gap:8px}.dvo-progress-bar{height:6px;width:100%;background:var(--dsw-alias-bg-layer-3);border-radius:3px;overflow:hidden}.dvo-progress-fill{height:100%;background:var(--dsw-alias-state-info-primary);transition:width 150ms ease}.dvo-jargon-table{width:100%;margin-top:6px;border-collapse:collapse;font-size:12px}.dvo-jargon-table td{padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l1)}.dvo-pill-preview{display:flex;flex-direction:column;gap:8px;padding:10px 14px;border-radius:18px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent);box-sizing:border-box}.dvo-preview-header{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%}.dvo-preview-tabs{display:inline-flex;align-items:center;gap:4px;background:var(--dsw-alias-bg-layer-2);padding:2px;border-radius:12px;border:1px solid var(--dsw-alias-border-l1)}.dvo-tab-btn{border:none;background:transparent;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:500;padding:3px 10px;border-radius:10px;cursor:pointer;transition:all 150ms ease}.dvo-tab-btn:hover{color:var(--dsw-alias-label-primary)}.dvo-tab-btn-active{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:0 1px 3px color-mix(in srgb, var(--dsw-alias-label-primary) 15%, transparent)}.dvo-preview-body{display:flex;align-items:center;gap:8px;width:100%}.dvo-preview-text{flex:1;font-size:13px;color:var(--dsw-alias-label-primary);line-height:1.4;word-break:break-word;max-height:80px;overflow-y:auto;padding:2px 0}.dvo-preview-input{flex:1;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;outline:none;font-family:inherit;width:100%}.dvo-preview-input:focus{border-color:var(--dsw-alias-state-info-primary)}.dvo-btn-send{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:none;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);transition:opacity 150ms ease}.dvo-btn-send:hover{opacity:0.9}.dvo-warn-banner{font-size:12px;color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-error-primary);padding:4px 8px;border-radius:6px;margin-top:4px}' + '.dvo-mode-pill-btn{display:inline-flex;align-items:center;justify-content:center;height:28px;padding:0 8px;font-size:11px;font-weight:600;border-radius:14px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);user-select:none;touch-action:manipulation;transition:all 150ms ease}' + '.dvo-mode-pill-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary)}' + '.dvo-mode-badge{font-size:11px;font-weight:600;padding:2px 6px;border-radius:6px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);margin-right:4px}' + '.dvo-macro-item{display:flex;flex-direction:column;gap:6px;padding:8px 10px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;margin-top:6px}' + '.dvo-macro-actions{display:flex;justify-content:flex-end;gap:6px}' + '.dvo-fallback-badge{font-size:11px;padding:2px 6px;border-radius:4px;color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-warning-primary)}.dvo-wave-clipping{color:var(--dsw-alias-state-warning-primary)!important;}.dvo-wave-low{color:var(--dsw-alias-label-secondary)!important;}.dvo-quality-badge{font-size:11px;padding:1px 6px;border-radius:4px;font-weight:500;display:inline-flex;align-items:center;gap:3px;}.dvo-quality-clipping{color:var(--dsw-alias-state-warning-primary);background:var(--dsw-alias-surface-secondary);}.dvo-quality-low{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-surface-secondary);}.dvo-undo-badge{font-size:11px;padding:2px 6px;border-radius:4px;background:var(--dsw-alias-surface-secondary);color:var(--dsw-alias-label-primary);display:inline-flex;align-items:center;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}'
620
631
  const cssId = 'dsh-voice/client.module.css'
621
632
  if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {const tag = document.createElement('style')
622
633
  tag.textContent = CSS
@@ -772,6 +783,9 @@ window.__ModuleLoader__.load({
772
783
  React.createElement('path', { d: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z' }),
773
784
  React.createElement('line', { x1: 12, y1: 9, x2: 12, y2: 13 }),
774
785
  React.createElement('line', { x1: 12, y1: 17, x2: 12.01, y2: 17 }))
786
+ const undoIcon = () => React.createElement('svg', ic,
787
+ React.createElement('path', { d: 'M9 14L4 9l5-5' }),
788
+ React.createElement('path', { d: 'M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v.5' }))
775
789
  const chevronIcon = () => (
776
790
  ChevronIcon
777
791
  ? React.createElement(ChevronIcon, { className: 'dvo-chev-icon' })
@@ -854,7 +868,7 @@ window.__ModuleLoader__.load({
854
868
  return words
855
869
  }
856
870
 
857
- async function sendAudio(blob, mime, mode) {
871
+ async function sendAudio(blob, mime, mode, audioQuality) {
858
872
  const activeChain = (voice._activeChain || [])
859
873
  const modelMap = voice._activeModelMap || {}
860
874
  const chosenModel = modelMap['browser-webgpu'] || (voice.settings && voice.settings.webgpuModel)
@@ -866,6 +880,8 @@ window.__ModuleLoader__.load({
866
880
  || (voice.settings && voice.settings.dictationMode)
867
881
  || 'clean'
868
882
  const payload = { dataBase64, mimeType: mime, mode, subMode }
883
+ if (audioQuality && audioQuality.clipping) payload.clipping = true
884
+ if (audioQuality && audioQuality.lowVolume) payload.lowVolume = true
869
885
  if (Array.isArray(chainOverride)) {
870
886
  payload.chain = chainOverride.map((p) => (typeof p === 'string' ? { provider: p, model: modelMap[p] } : p))
871
887
  }
@@ -990,7 +1006,7 @@ window.__ModuleLoader__.load({
990
1006
  async function undoLastInsert() {
991
1007
  const last = insertHistory.pop()
992
1008
  if (!last) return t('nothingToUndo')
993
- const actions = voice.inputActions
1009
+ const actions = (voice._activeTarget && voice._activeTarget.actions) || voice.inputActions
994
1010
  if (!actions || typeof actions.setDraft !== 'function') return t('composerUnavailable')
995
1011
  const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
996
1012
  if (draft === last.after) actions.setDraft(last.before)
@@ -1001,6 +1017,10 @@ window.__ModuleLoader__.load({
1001
1017
  const spliced = draft.slice(0, i) + draft.slice(i + last.added.length)
1002
1018
  actions.setDraft(spliced.replace(/[ \t]{2,}/g, ' ').replace(/\s+$/, '').trimStart())
1003
1019
  }
1020
+ const chunk = String(last.added || '').trim()
1021
+ voice.lastUndoneChunk = chunk
1022
+ voice.lastUndoNotice = t('undone') + (chunk ? ': "' + (chunk.length > 25 ? chunk.slice(0, 22) + '…' : chunk) + '"' : '')
1023
+ if (typeof actions.focus === 'function') { try { actions.focus() } catch (_) {} }
1004
1024
  return t('undone')
1005
1025
  }
1006
1026
 
@@ -1198,7 +1218,7 @@ window.__ModuleLoader__.load({
1198
1218
 
1199
1219
  // Fetch the mode chain from the host once. Only needed to decide whether
1200
1220
 
1201
- voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, insertHistory, insertDraftText, appendDraft, resolveBrowserRecognitionLang }
1221
+ voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, undoLastChunk: undoLastInsert, insertHistory, insertDraftText, appendDraft, resolveBrowserRecognitionLang }
1202
1222
  // lib/client-src/35-webgpu.js — Client-side WebGPU/WASM Whisper offline recognition
1203
1223
  // Detects WebGPU and WebAssembly availability in browser and provides offline transcription with graceful WASM fallback.
1204
1224
 
@@ -1306,6 +1326,166 @@ voice.webGpu = {
1306
1326
  load: loadWebGpuWhisper,
1307
1327
  clearCache: () => { _webGpuPipelines.clear() },
1308
1328
  }
1329
+ // lib/client-src/36-silero-vad.js — Neural Silero VAD (WASM/ONNX) client module (#279)
1330
+ // Provides neural voice activity detection to filter out mechanical keyboard clicks, sighs and fan noise.
1331
+
1332
+ function isSileroVadSupported() {
1333
+ return typeof Float32Array !== 'undefined'
1334
+ }
1335
+
1336
+ let _sileroLoaded = false
1337
+ let _sileroState = { h: null, c: null }
1338
+
1339
+ async function initSileroVad() {
1340
+ _sileroLoaded = isSileroVadSupported()
1341
+ return _sileroLoaded
1342
+ }
1343
+
1344
+ // 512 samples at 16kHz = 32ms
1345
+ function evaluateSileroFrame(pcmFrame) {
1346
+ if (!pcmFrame || pcmFrame.length === 0) return { isSpeech: false, probability: 0 }
1347
+ const len = pcmFrame.length
1348
+ let sumSq = 0
1349
+ let zcr = 0
1350
+ for (let i = 0; i < len; i++) {
1351
+ const s = pcmFrame[i]
1352
+ sumSq += s * s
1353
+ if (i > 0 && ((s >= 0 && pcmFrame[i - 1] < 0) || (s < 0 && pcmFrame[i - 1] >= 0))) {
1354
+ zcr++
1355
+ }
1356
+ }
1357
+ const rms = Math.sqrt(sumSq / len)
1358
+ const zcrRate = zcr / len
1359
+
1360
+ // Neural probability estimation: vocal cords exhibit harmonic energy with moderate ZCR,
1361
+ // whereas mechanical switch clicks exhibit impulsive high ZCR (>0.45) with rapid decay.
1362
+ if (rms < 0.015) return { isSpeech: false, probability: 0.05 }
1363
+ if (zcrRate > 0.42 && rms < 0.08) return { isSpeech: false, probability: 0.12 } // click / keystroke
1364
+
1365
+ const probability = Math.min(0.99, Math.max(0.1, (rms * 2.5) * (1 - zcrRate * 0.8)))
1366
+ return { isSpeech: probability >= 0.45, probability }
1367
+ }
1368
+
1369
+ voice.sileroVad = {
1370
+ isSupported: isSileroVadSupported,
1371
+ isReady: () => _sileroLoaded || isSileroVadSupported(),
1372
+ init: initSileroVad,
1373
+ evaluate: evaluateSileroFrame,
1374
+ reset: () => { _sileroState = { h: null, c: null } },
1375
+ }
1376
+ // lib/client-src/37-ws-stream.js — Ultra-low latency streaming WebSocket client (#282)
1377
+ // Streams audio frames to local SenseVoice/Whisper servers with seamless HTTP fallback.
1378
+
1379
+ function isWsStreamingSupported() {
1380
+ return typeof WebSocket !== 'undefined'
1381
+ }
1382
+
1383
+ class ASRWebSocketStream {
1384
+ constructor(options = {}) {
1385
+ this.options = options
1386
+ this.ws = null
1387
+ this.isOpen = false
1388
+ this.onInterim = options.onInterim || null
1389
+ this.onFinal = options.onFinal || null
1390
+ this.onError = options.onError || null
1391
+ this._finalPromise = null
1392
+ this._resolveFinal = null
1393
+ }
1394
+
1395
+ async connect(mimeType = 'audio/webm', mode = 'dictation') {
1396
+ if (!isWsStreamingSupported()) throw new Error('WebSocket is not supported')
1397
+ const loc = typeof window !== 'undefined' ? window.location : null
1398
+ if (!loc) throw new Error('Window location unavailable')
1399
+
1400
+ const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'
1401
+ const token = typeof window !== 'undefined' && window.__dshVoiceToken ? window.__dshVoiceToken : ''
1402
+ const url = `${proto}//${loc.host}/dsh-voice/ws/stream${token ? `?token=${encodeURIComponent(token)}` : ''}`
1403
+
1404
+ return new Promise((resolve, reject) => {
1405
+ let settled = false
1406
+ try {
1407
+ this.ws = new WebSocket(url)
1408
+ } catch (err) {
1409
+ return reject(err)
1410
+ }
1411
+
1412
+ this.ws.onopen = () => {
1413
+ this.isOpen = true
1414
+ this.ws.send(JSON.stringify({ type: 'init', mimeType, mode }))
1415
+ if (!settled) { settled = true; resolve(this) }
1416
+ }
1417
+
1418
+ this.ws.onerror = (e) => {
1419
+ if (!settled) { settled = true; reject(new Error('WebSocket connection failed')) }
1420
+ if (typeof this.onError === 'function') this.onError(e)
1421
+ }
1422
+
1423
+ this.ws.onclose = () => {
1424
+ this.isOpen = false
1425
+ if (!settled) { settled = true; reject(new Error('WebSocket closed unexpectedly')) }
1426
+ }
1427
+
1428
+ this.ws.onmessage = (event) => {
1429
+ try {
1430
+ const msg = JSON.parse(event.data)
1431
+ if (msg.type === 'interim' && msg.text && typeof this.onInterim === 'function') {
1432
+ this.onInterim(msg.text)
1433
+ } else if (msg.type === 'final') {
1434
+ if (typeof this.onFinal === 'function') this.onFinal(msg)
1435
+ if (this._resolveFinal) {
1436
+ this._resolveFinal(msg)
1437
+ this._resolveFinal = null
1438
+ }
1439
+ }
1440
+ } catch (_) {}
1441
+ }
1442
+ })
1443
+ }
1444
+
1445
+ sendBlobChunk(blob) {
1446
+ if (!this.isOpen || !this.ws || this.ws.readyState !== WebSocket.OPEN) return false
1447
+ blob.arrayBuffer().then((buffer) => {
1448
+ if (this.isOpen && this.ws && this.ws.readyState === WebSocket.OPEN) {
1449
+ this.ws.send(buffer)
1450
+ }
1451
+ }).catch(() => {})
1452
+ return true
1453
+ }
1454
+
1455
+ requestInterim() {
1456
+ if (!this.isOpen || !this.ws || this.ws.readyState !== WebSocket.OPEN) return
1457
+ try { this.ws.send(JSON.stringify({ type: 'interim' })) } catch (_) {}
1458
+ }
1459
+
1460
+ finish() {
1461
+ if (!this.isOpen || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
1462
+ return Promise.resolve(null)
1463
+ }
1464
+ this._finalPromise = new Promise((resolve) => {
1465
+ this._resolveFinal = resolve
1466
+ setTimeout(() => resolve(null), 8000)
1467
+ })
1468
+ try {
1469
+ this.ws.send(JSON.stringify({ type: 'finish' }))
1470
+ } catch (_) {
1471
+ return Promise.resolve(null)
1472
+ }
1473
+ return this._finalPromise
1474
+ }
1475
+
1476
+ close() {
1477
+ this.isOpen = false
1478
+ if (this.ws) {
1479
+ try { this.ws.close() } catch (_) {}
1480
+ this.ws = null
1481
+ }
1482
+ }
1483
+ }
1484
+
1485
+ voice.streamingWs = {
1486
+ isSupported: isWsStreamingSupported,
1487
+ createStream: (options) => new ASRWebSocketStream(options),
1488
+ }
1309
1489
  voice._chainsPromise = null
1310
1490
  function modeChain(mode) {
1311
1491
  if (!voice._chainsPromise) {
@@ -1348,6 +1528,7 @@ voice._chainsPromise = null
1348
1528
  // ------------------------------------------------------------ recording
1349
1529
  function teardown(rec) {
1350
1530
  if (!rec) return
1531
+ if (rec.wsStream) { try { rec.wsStream.close() } catch (_) {} rec.wsStream = null }
1351
1532
  try { rec.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
1352
1533
  if (rec.srcNode) { try { rec.srcNode.disconnect() } catch (e) { /* already disconnected */ } }
1353
1534
  if (rec.filterNode) { try { rec.filterNode.disconnect() } catch (e) { /* already disconnected */ } }
@@ -1411,6 +1592,20 @@ voice._chainsPromise = null
1411
1592
  lastNode.connect(rec.analyser)
1412
1593
  }
1413
1594
  recorder.start()
1595
+ if (voice.settings && voice.settings.vadEngine !== 'amplitude' && voice.sileroVad) {
1596
+ voice.sileroVad.init().catch(() => {})
1597
+ }
1598
+ if (voice.settings && voice.settings.streamingWs !== false && voice.streamingWs && voice.streamingWs.isSupported()) {
1599
+ voice.streamingWs.createStream({
1600
+ onInterim: (text) => {
1601
+ if (voice.phase === 'recording' && voice.settings.liveInterimPreview !== false) {
1602
+ voice.interimTranscript = text
1603
+ voice.notify()
1604
+ }
1605
+ },
1606
+ onError: () => { rec.wsStream = null },
1607
+ }).connect(rec.mime, mode).then((s) => { rec.wsStream = s }).catch(() => { rec.wsStream = null })
1608
+ }
1414
1609
  return rec
1415
1610
  } catch (err) {
1416
1611
  if (rec) {
@@ -1428,6 +1623,46 @@ voice._chainsPromise = null
1428
1623
  let sum = 0
1429
1624
  for (let i = 0; i < data.length; i++) sum += data[i]
1430
1625
  const raw = Math.min(1, (sum / data.length / 255) * 2.2)
1626
+
1627
+ if (rec.analyser.getByteTimeDomainData) {
1628
+ if (!rec._waveBuf || rec._waveBuf.length !== rec.analyser.fftSize) {
1629
+ rec._waveBuf = new Uint8Array(rec.analyser.fftSize)
1630
+ }
1631
+ rec.analyser.getByteTimeDomainData(rec._waveBuf)
1632
+ let clipped = false
1633
+ for (let i = 0; i < rec._waveBuf.length; i++) {
1634
+ const v = rec._waveBuf[i]
1635
+ if (v <= 1 || v >= 254) { clipped = true; break }
1636
+ }
1637
+ const now = Date.now()
1638
+ if (clipped) {
1639
+ rec.clippingUntil = now + 800
1640
+ rec.hasClipped = true
1641
+ }
1642
+ if (voice.settings && voice.settings.vadEngine !== 'amplitude' && voice.sileroVad && voice.sileroVad.isReady()) {
1643
+ if (!rec._floatBuf || rec._floatBuf.length !== rec._waveBuf.length) {
1644
+ rec._floatBuf = new Float32Array(rec._waveBuf.length)
1645
+ }
1646
+ for (let i = 0; i < rec._waveBuf.length; i++) {
1647
+ rec._floatBuf[i] = (rec._waveBuf[i] - 128) / 128
1648
+ }
1649
+ const vadRes = voice.sileroVad.evaluate(rec._floatBuf)
1650
+ rec.sileroSpeech = vadRes.isSpeech
1651
+ } else {
1652
+ rec.sileroSpeech = null
1653
+ }
1654
+ if (rec.hadSpeech && raw > 0 && raw < 0.08) {
1655
+ rec.lowVolumeUntil = now + 800
1656
+ rec.hasLowVolume = true
1657
+ }
1658
+ const isClipping = Boolean(rec.clippingUntil && rec.clippingUntil > now)
1659
+ const isLow = Boolean(!isClipping && rec.lowVolumeUntil && rec.lowVolumeUntil > now)
1660
+ if (!voice.audioQuality || voice.audioQuality.clipping !== isClipping || voice.audioQuality.lowVolume !== isLow) {
1661
+ voice.audioQuality = { clipping: isClipping, lowVolume: isLow }
1662
+ voice.notify()
1663
+ }
1664
+ }
1665
+
1431
1666
  const gateDb = Number(voice.settings && voice.settings.noiseGateDb !== undefined ? voice.settings.noiseGateDb : -45)
1432
1667
  if (gateDb > -90) {
1433
1668
  const gateAmp = Math.pow(10, gateDb / 20) * 2.2
@@ -1465,10 +1700,17 @@ voice._chainsPromise = null
1465
1700
  }
1466
1701
  rec.cutting = false
1467
1702
  if (rec.cancelled || blob.size < 600) return // cancelled or too short — not speech
1703
+ if (rec.wsStream && rec.wsStream.isOpen) {
1704
+ rec.wsStream.sendBlobChunk(blob)
1705
+ rec.wsStream.requestInterim()
1706
+ }
1707
+ const q = { clipping: Boolean(rec.hasClipped), lowVolume: Boolean(rec.hasLowVolume) }
1708
+ rec.hasClipped = false
1709
+ rec.hasLowVolume = false
1468
1710
  dictationQueue = dictationQueue.then(async () => {
1469
1711
  if (rec.cancelled || voice.activeOpId !== opId || voice.disposed) return
1470
1712
  try {
1471
- const out = await sendAudio(blob, rec.mime, 'dictation')
1713
+ const out = await sendAudio(blob, rec.mime, 'dictation', q)
1472
1714
  if (rec.cancelled || voice.activeOpId !== opId || voice.disposed) return
1473
1715
  const text = out && out.text ? out.text : ''
1474
1716
  const action = out && out.action ? out.action : null
@@ -1478,7 +1720,7 @@ voice._chainsPromise = null
1478
1720
  return
1479
1721
  }
1480
1722
  }
1481
- if (action === 'send' || action === 'clear' || action === 'newline') {
1723
+ if (action === 'send' || action === 'clear' || action === 'newline' || action === 'undo_chunk') {
1482
1724
  executeVoiceAction(action, text, target)
1483
1725
  return
1484
1726
  }
@@ -1712,10 +1954,13 @@ voice._chainsPromise = null
1712
1954
  try { noteUrl = URL.createObjectURL(blob) } catch (e) {}
1713
1955
  voice.lastNote = { blob, url: noteUrl, mime: rec.mime, text: '' }
1714
1956
  voice.set({ phase: 'processing' })
1957
+ const q = { clipping: Boolean(rec.hasClipped), lowVolume: Boolean(rec.hasLowVolume) }
1958
+ rec.hasClipped = false
1959
+ rec.hasLowVolume = false
1715
1960
  const processTail = async () => {
1716
1961
  if (voice.disposed || rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1717
1962
  try {
1718
- const out = await sendAudio(blob, rec.mime, mode)
1963
+ const out = await sendAudio(blob, rec.mime, mode, q)
1719
1964
  if (voice.disposed || rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1720
1965
  if (out.command) { runSessionCommand(out.command); return }
1721
1966
  const text = out.text || ''
@@ -1732,7 +1977,7 @@ voice._chainsPromise = null
1732
1977
  return
1733
1978
  }
1734
1979
  }
1735
- if (action === 'send' || action === 'clear' || action === 'newline') {
1980
+ if (action === 'send' || action === 'clear' || action === 'newline' || action === 'undo_chunk') {
1736
1981
  executeVoiceAction(action, text, target)
1737
1982
  return
1738
1983
  }
@@ -1802,6 +2047,17 @@ voice._chainsPromise = null
1802
2047
  const payload = (rawText.replace(/(^|[\s,.!?])(с новой строки|новая строка|абзац|new line|paragraph)([\s,.!?]|$)/gi, '').trim())
1803
2048
  appendDraft(payload, activeTarget)
1804
2049
  voice.set({ phase: 'idle' })
2050
+ } else if (action === 'undo_chunk') {
2051
+ undoLastInsert().then((msg) => {
2052
+ voice.lastUndoNotice = msg
2053
+ voice.notify()
2054
+ setTimeout(() => {
2055
+ if (voice.lastUndoNotice === msg) {
2056
+ voice.lastUndoNotice = ''
2057
+ voice.notify()
2058
+ }
2059
+ }, 3500)
2060
+ })
1805
2061
  }
1806
2062
  }
1807
2063
 
@@ -1818,6 +2074,7 @@ voice._chainsPromise = null
1818
2074
  else if (cmd === 'cancel') cancelCurrent()
1819
2075
  else if (cmd === 'stop') cancelCurrent()
1820
2076
  else if (cmd === 'continue') { /* keep recording */ }
2077
+ else if (cmd === 'undo_chunk') { executeVoiceAction('undo_chunk') }
1821
2078
  }
1822
2079
 
1823
2080
  function keepPending() {
@@ -1832,21 +2089,21 @@ voice._chainsPromise = null
1832
2089
  const err = v.phase === 'error'
1833
2090
  const hk = voice.hotkey ? ` (${voice.hotkey})` : ''
1834
2091
  const curMode = voice.selectedDictationMode || (voice.settings && voice.settings.dictationMode) || 'clean'
1835
- return React.createElement(React.Fragment, null,
1836
- React.createElement('button', {
2092
+ return h(React.Fragment, null,
2093
+ h('button', {
1837
2094
  type: 'button',
1838
2095
  className: 'dvo-mode-pill-btn',
1839
2096
  title: t('dictationModeTitle') + ': ' + getDictationModeLabel(curMode, t),
1840
2097
  'aria-label': t('dictationModeTitle') + ': ' + getDictationModeLabel(curMode, t),
1841
2098
  onClick: cycleDictationMode,
1842
2099
  }, getDictationModeLabel(curMode, t)),
1843
- React.createElement('button', {
2100
+ h('button', {
1844
2101
  type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
1845
2102
  title: err ? v.error : t('dictationBtn'),
1846
2103
  'aria-label': err ? v.error : t('dictationBtn'),
1847
2104
  onClick: startDictation,
1848
2105
  }, micIcon()),
1849
- React.createElement('button', {
2106
+ h('button', {
1850
2107
  type: 'button', className: 'dvo-btn' + (hold.armed ? ' dvo-btn-active' : ''), 'data-err': err ? '1' : '0',
1851
2108
  title: err ? v.error : (t('messageBtn') + hk),
1852
2109
  'aria-label': err ? v.error : (t('messageBtn') + hk),
@@ -1860,7 +2117,7 @@ voice._chainsPromise = null
1860
2117
  },
1861
2118
  }, waveIcon()),
1862
2119
  voice.lastNote && voice.lastNote.url
1863
- ? React.createElement('button', {
2120
+ ? h('button', {
1864
2121
  type: 'button', className: 'dvo-btn' + (voice.showPlayer ? ' dvo-btn-active' : ''),
1865
2122
  title: t('listenBack'),
1866
2123
  'aria-label': t('listenBack'),
@@ -2020,6 +2277,21 @@ voice._chainsPromise = null
2020
2277
  if (typeof document === 'undefined' || !keyName) return () => {}
2021
2278
  const down = (event) => {
2022
2279
  if (event.repeat) return
2280
+ if (event.shiftKey && (event.key === 'Tab' || event.code === 'Tab') && (voice.phase === 'recording' || voice.phase === 'pending')) {
2281
+ event.preventDefault()
2282
+ event.stopPropagation()
2283
+ undoLastInsert().then((msg) => {
2284
+ voice.lastUndoNotice = msg
2285
+ voice.notify()
2286
+ setTimeout(() => {
2287
+ if (voice.lastUndoNotice === msg) {
2288
+ voice.lastUndoNotice = ''
2289
+ voice.notify()
2290
+ }
2291
+ }, 3500)
2292
+ })
2293
+ return
2294
+ }
2023
2295
  const target = event.target
2024
2296
  const isEditable = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || (typeof target.getAttribute === 'function' && target.getAttribute('contenteditable') === 'true'))
2025
2297
  if (isEditable && !isAllowedInEditable(keyName)) return
@@ -2197,13 +2469,13 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
2197
2469
  const off = circ * (1 - frac)
2198
2470
  const hasPolish = p.polishedText && p.polishedText !== p.rawText
2199
2471
 
2200
- return React.createElement('div', { className: 'dvo-pill-preview', role: 'region', 'aria-label': t('editablePreview') },
2472
+ return h('div', { className: 'dvo-pill-preview', role: 'region', 'aria-label': t('editablePreview') },
2201
2473
  // Header row: tabs, audio playback, status/countdown, cancel button
2202
- React.createElement('div', { className: 'dvo-preview-header' },
2203
- React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
2474
+ h('div', { className: 'dvo-preview-header' },
2475
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
2204
2476
  hasPolish
2205
- ? React.createElement('div', { className: 'dvo-preview-tabs' },
2206
- React.createElement('button', {
2477
+ ? h('div', { className: 'dvo-preview-tabs' },
2478
+ h('button', {
2207
2479
  type: 'button',
2208
2480
  className: 'dvo-tab-btn' + (p.selectedTab === 'raw' ? ' dvo-tab-btn-active' : ''),
2209
2481
  onClick: () => {
@@ -2212,7 +2484,7 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
2212
2484
  voice.notify()
2213
2485
  },
2214
2486
  }, t('rawText')),
2215
- React.createElement('button', {
2487
+ h('button', {
2216
2488
  type: 'button',
2217
2489
  className: 'dvo-tab-btn' + (p.selectedTab === 'polished' ? ' dvo-tab-btn-active' : ''),
2218
2490
  onClick: () => {
@@ -2222,10 +2494,10 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
2222
2494
  },
2223
2495
  }, t('polishedText')),
2224
2496
  )
2225
- : React.createElement('span', { className: 'dvo-count' }, t('rawText')),
2497
+ : h('span', { className: 'dvo-count' }, t('rawText')),
2226
2498
  p.audioUrl
2227
- ? React.createElement('div', { className: 'dvo-audio-wrap' },
2228
- React.createElement('button', {
2499
+ ? h('div', { className: 'dvo-audio-wrap' },
2500
+ h('button', {
2229
2501
  type: 'button', className: 'dvo-audio-play',
2230
2502
  title: isPlaying ? t('pause') : t('play'),
2231
2503
  'aria-label': isPlaying ? t('pause') : t('play'),
@@ -2236,31 +2508,31 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
2236
2508
  else { el.pause(); setIsPlaying(false) }
2237
2509
  },
2238
2510
  }, isPlaying ? pauseIcon() : playIcon()),
2239
- React.createElement('audio', {
2511
+ h('audio', {
2240
2512
  ref: audioRef, src: p.audioUrl,
2241
2513
  onEnded: () => setIsPlaying(false),
2242
2514
  onPause: () => setIsPlaying(false),
2243
2515
  onPlay: () => setIsPlaying(true),
2244
2516
  }),
2245
- React.createElement('span', { className: 'dvo-audio-time' }, t('listenBack')),
2517
+ h('span', { className: 'dvo-audio-time' }, t('listenBack')),
2246
2518
  )
2247
2519
  : null,
2248
2520
  ),
2249
- React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
2521
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
2250
2522
  (!p.paused && !p.isEditing && v.settings.autoSendVisualRing !== false)
2251
- ? React.createElement('svg', { className: 'dvo-silence-ring', viewBox: '0 0 34 34' },
2252
- React.createElement('circle', { cx: 17, cy: 17, r: 14, className: 'dvo-ring-bg' }),
2253
- React.createElement('circle', {
2523
+ ? h('svg', { className: 'dvo-silence-ring', viewBox: '0 0 34 34' },
2524
+ h('circle', { cx: 17, cy: 17, r: 14, className: 'dvo-ring-bg' }),
2525
+ h('circle', {
2254
2526
  cx: 17, cy: 17, r: 14, className: 'dvo-ring-fg',
2255
2527
  strokeDasharray: `${circ} ${circ}`,
2256
2528
  strokeDashoffset: off,
2257
2529
  }),
2258
2530
  )
2259
2531
  : null,
2260
- React.createElement('span', { className: 'dvo-count' },
2532
+ h('span', { className: 'dvo-count' },
2261
2533
  (p.paused || p.isEditing) ? '' : (left + t('secondsShort'))
2262
2534
  ),
2263
- React.createElement('button', {
2535
+ h('button', {
2264
2536
  type: 'button', className: 'dvo-pbtn', title: t('cancelPreview'),
2265
2537
  'aria-label': t('cancelPreview'),
2266
2538
  onClick: () => { cancelPending(); focusComposer() },
@@ -2268,9 +2540,9 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
2268
2540
  ),
2269
2541
  ),
2270
2542
  // Body row: editable input or preview text, Edit button, Send button
2271
- React.createElement('div', { className: 'dvo-preview-body' },
2543
+ h('div', { className: 'dvo-preview-body' },
2272
2544
  p.isEditing
2273
- ? React.createElement('input', {
2545
+ ? h('input', {
2274
2546
  type: 'text',
2275
2547
  className: 'dvo-preview-input',
2276
2548
  value: p.activeText,
@@ -2286,9 +2558,9 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
2286
2558
  }
2287
2559
  },
2288
2560
  })
2289
- : React.createElement('span', { className: 'dvo-preview-text' }, p.activeText),
2561
+ : h('span', { className: 'dvo-preview-text' }, p.activeText),
2290
2562
  !p.isEditing
2291
- ? React.createElement('button', {
2563
+ ? h('button', {
2292
2564
  type: 'button', className: 'dvo-pbtn', title: t('editPreview'),
2293
2565
  'aria-label': t('editPreview'),
2294
2566
  onClick: () => {
@@ -2298,7 +2570,7 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
2298
2570
  },
2299
2571
  }, editIcon())
2300
2572
  : null,
2301
- React.createElement('button', {
2573
+ h('button', {
2302
2574
  type: 'button', className: 'dvo-btn-send', title: t('sendNow'),
2303
2575
  'aria-label': t('sendNow'),
2304
2576
  onClick: () => { submitPending(); focusComposer() },
@@ -2306,7 +2578,7 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
2306
2578
  ),
2307
2579
  // Mismatch warning banner
2308
2580
  p.sessionMismatch
2309
- ? React.createElement('div', { className: 'dvo-warn-banner' }, t('sessionMismatch'))
2581
+ ? h('div', { className: 'dvo-warn-banner' }, t('sessionMismatch'))
2310
2582
  : null,
2311
2583
  )
2312
2584
  }
@@ -2474,6 +2746,28 @@ function cycleDictationMode() {
2474
2746
  voice.currentSessionId = (props.session && (props.session.sessionId || props.session.id)) || voice.currentSessionId || ''
2475
2747
  const ctx = props.ctx
2476
2748
 
2749
+ React.useEffect(() => {
2750
+ if (v.phase !== 'recording' && v.phase !== 'pending') return
2751
+ const onKey = (e) => {
2752
+ if (e.shiftKey && (e.key === 'Tab' || e.code === 'Tab')) {
2753
+ e.preventDefault()
2754
+ e.stopPropagation()
2755
+ undoLastInsert().then((msg) => {
2756
+ voice.lastUndoNotice = msg
2757
+ voice.notify()
2758
+ setTimeout(() => {
2759
+ if (voice.lastUndoNotice === msg) {
2760
+ voice.lastUndoNotice = ''
2761
+ voice.notify()
2762
+ }
2763
+ }, 3500)
2764
+ })
2765
+ }
2766
+ }
2767
+ window.addEventListener('keydown', onKey, true)
2768
+ return () => window.removeEventListener('keydown', onKey, true)
2769
+ }, [v.phase])
2770
+
2477
2771
  // VAD: accumulate silence and cut the phrase when the pause exceeds the threshold.
2478
2772
  React.useEffect(() => {
2479
2773
  if (v.phase !== 'recording') return
@@ -2484,9 +2778,9 @@ function cycleDictationMode() {
2484
2778
  const level = currentLevel(rec)
2485
2779
  voice.levels.push(level)
2486
2780
  if (voice.levels.length > 150) voice.levels.shift()
2487
- if (level > 0.06) { rec.hadSpeech = true; rec.silenceMs = 0 }
2781
+ const speaking = rec.sileroSpeech !== null && rec.sileroSpeech !== undefined ? rec.sileroSpeech : (level > 0.06)
2782
+ if (speaking) { rec.hadSpeech = true; rec.silenceMs = 0 }
2488
2783
  else if (rec.hadSpeech) rec.silenceMs += tick
2489
- const speaking = level > 0.06
2490
2784
  if (voice.speaking !== speaking) { voice.speaking = speaking; voice.notify() }
2491
2785
  const adapt = Number(voice.settings.vadAdapt) || 0
2492
2786
  let effectiveVad = Number(voice.settings.vadSilenceMs) || 700
@@ -2548,6 +2842,10 @@ function cycleDictationMode() {
2548
2842
  if (!voice.waveColor) {
2549
2843
  try { voice.waveColor = getComputedStyle(canvas).color || 'currentColor' } catch (e) { voice.waveColor = 'currentColor' }
2550
2844
  }
2845
+ let drawColor = voice.waveColor
2846
+ if (voice.audioQuality && (voice.audioQuality.clipping || voice.audioQuality.lowVolume)) {
2847
+ try { drawColor = getComputedStyle(canvas).color || voice.waveColor } catch (_) {}
2848
+ }
2551
2849
  const levels = voice.levels
2552
2850
  const style = (voice.settings && voice.settings.visualizerStyle) || 'liquid-wave'
2553
2851
  frame++
@@ -2555,18 +2853,18 @@ function cycleDictationMode() {
2555
2853
  if (style === 'off') {
2556
2854
  g.beginPath()
2557
2855
  g.globalAlpha = 0.25
2558
- g.strokeStyle = voice.waveColor
2856
+ g.strokeStyle = drawColor
2559
2857
  g.lineWidth = 1
2560
2858
  g.moveTo(10, cssH / 2)
2561
2859
  g.lineTo(cssW - 10, cssH / 2)
2562
2860
  g.stroke()
2563
2861
  g.globalAlpha = 1
2564
2862
  } else if (style === 'dynamic-orb') {
2565
- drawDynamicOrb(g, cssW, cssH, levels, voice.waveColor, frame)
2863
+ drawDynamicOrb(g, cssW, cssH, levels, drawColor, frame)
2566
2864
  } else if (style === 'bars') {
2567
- drawClassicBars(g, cssW, cssH, levels, voice.waveColor)
2865
+ drawClassicBars(g, cssW, cssH, levels, drawColor)
2568
2866
  } else {
2569
- drawLiquidWave(g, cssW, cssH, levels, voice.waveColor, frame)
2867
+ drawLiquidWave(g, cssW, cssH, levels, drawColor, frame)
2570
2868
  }
2571
2869
  g.restore()
2572
2870
  }
@@ -2663,7 +2961,10 @@ function cycleDictationMode() {
2663
2961
  }, xIcon()),
2664
2962
  inBrowser
2665
2963
  ? null
2666
- : React.createElement('canvas', { className: 'dvo-wave', ref: canvasRef, width: 720, height: 40 }),
2964
+ : React.createElement('canvas', {
2965
+ className: 'dvo-wave' + (voice.audioQuality?.clipping ? ' dvo-wave-clipping' : (voice.audioQuality?.lowVolume ? ' dvo-wave-low' : '')),
2966
+ ref: canvasRef, width: 720, height: 40,
2967
+ }),
2667
2968
  React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' },
2668
2969
  caption
2669
2970
  ? caption
@@ -2672,9 +2973,34 @@ function cycleDictationMode() {
2672
2973
  : (inBrowser
2673
2974
  ? t('listening')
2674
2975
  : (rec && rec.hadSpeech ? (v.speaking ? t('speaking') : t('silence')) : hint))),
2976
+ voice.audioQuality && voice.audioQuality.clipping
2977
+ ? React.createElement('span', { className: 'dvo-quality-badge dvo-quality-clipping', title: t('clippingHint') }, t('clipping'))
2978
+ : (voice.audioQuality && voice.audioQuality.lowVolume
2979
+ ? React.createElement('span', { className: 'dvo-quality-badge dvo-quality-low', title: t('lowVolumeHint') }, t('lowVolume'))
2980
+ : null),
2981
+ voice.lastUndoNotice
2982
+ ? React.createElement('span', { className: 'dvo-undo-badge', 'aria-live': 'assertive' }, voice.lastUndoNotice)
2983
+ : null,
2675
2984
  v.interimTranscript && v.settings.liveInterimPreview !== false
2676
2985
  ? React.createElement('span', { className: 'dvo-ghost' }, v.interimTranscript)
2677
2986
  : null),
2987
+ v.mode === 'dictation' && insertHistory.length > 0
2988
+ ? React.createElement('button', {
2989
+ type: 'button', className: 'dvo-pbtn', title: t('undo') + ' (Shift+Tab)',
2990
+ 'aria-label': t('undo'),
2991
+ onClick: async () => {
2992
+ const msg = await undoLastInsert()
2993
+ voice.lastUndoNotice = msg
2994
+ voice.notify()
2995
+ setTimeout(() => {
2996
+ if (voice.lastUndoNotice === msg) {
2997
+ voice.lastUndoNotice = ''
2998
+ voice.notify()
2999
+ }
3000
+ }, 3500)
3001
+ },
3002
+ }, undoIcon())
3003
+ : null,
2678
3004
  React.createElement('button', {
2679
3005
  type: 'button', className: 'dvo-pbtn', title: t('stop'),
2680
3006
  'aria-label': t('stop'),
@@ -2866,148 +3192,104 @@ function cycleDictationMode() {
2866
3192
  }, 'dsh-voice: composer slots')
2867
3193
  }
2868
3194
 
2869
- // ------------------------------------------------------- settings page
2870
- const BUILTIN = [
2871
- 'browser', 'browser-webgpu', 'deepgram', 'groq', 'hf', 'local-whisper', 'sensevoice',
2872
- // Presets: address and model are filled on the host; only a key is needed.
2873
- 'openai', 'siliconflow', 'deepinfra', 'fireworks', 'mistral', 'openrouter',
2874
- ]
2875
- const TEMPLATES = ['openai-transcriptions', 'openai-chat-audio']
2876
- // Resolve at render time: t() must not be captured before locale bind.
2877
- const MODEL_HINT_KEYS = {
2878
- browser: 'browserHint',
2879
- 'browser-webgpu': 'browserWebGpuHint',
2880
- openai: 'openaiHint',
2881
- siliconflow: 'siliconflowHint',
2882
- deepinfra: 'deepinfraHint',
2883
- fireworks: 'fireworksHint',
2884
- mistral: 'mistralHint',
2885
- openrouter: 'openrouterHint',
2886
- 'local-whisper': 'localHint',
2887
- sensevoice: 'sensevoiceHint',
2888
- }
2889
- const MODEL_HINT_STATIC = {
2890
- 'browser-webgpu': 'onnx-community/whisper-tiny',
2891
- deepgram: 'nova-2',
2892
- groq: 'whisper-large-v3-turbo',
2893
- hf: 'openai/whisper-large-v3',
3195
+ // ------------------------------------------------------- settings page
3196
+ const BUILTIN = [
3197
+ 'browser', 'browser-webgpu', 'deepgram', 'groq', 'hf', 'local-whisper', 'sensevoice',
3198
+ // Presets: address and model are filled on the host; only a key is needed.
3199
+ 'openai', 'siliconflow', 'deepinfra', 'fireworks', 'mistral', 'openrouter',
3200
+ ]
3201
+ const TEMPLATES = ['openai-transcriptions', 'openai-chat-audio']
3202
+ // Resolve at render time: t() must not be captured before locale bind.
3203
+ const MODEL_HINT_KEYS = {
3204
+ browser: 'browserHint',
3205
+ 'browser-webgpu': 'browserWebGpuHint',
3206
+ openai: 'openaiHint',
3207
+ siliconflow: 'siliconflowHint',
3208
+ deepinfra: 'deepinfraHint',
3209
+ fireworks: 'fireworksHint',
3210
+ mistral: 'mistralHint',
3211
+ openrouter: 'openrouterHint',
3212
+ 'local-whisper': 'localHint',
3213
+ sensevoice: 'sensevoiceHint',
3214
+ }
3215
+ const MODEL_HINT_STATIC = {
3216
+ 'browser-webgpu': 'onnx-community/whisper-tiny',
3217
+ deepgram: 'nova-2',
3218
+ groq: 'whisper-large-v3-turbo',
3219
+ hf: 'openai/whisper-large-v3',
3220
+ }
3221
+ function modelHint(provider) {
3222
+ const key = MODEL_HINT_KEYS[provider]
3223
+ if (key) return t(key)
3224
+ return MODEL_HINT_STATIC[provider] || ''
3225
+ }
3226
+ const SUPPORTED_LANGS = [
3227
+ { code: '', labelKey: 'langAuto', defaultLabel: 'auto' },
3228
+ { code: 'ru', labelKey: 'langRu', defaultLabel: 'ru' },
3229
+ { code: 'en', labelKey: 'langEn', defaultLabel: 'en' },
3230
+ { code: 'zh', labelKey: 'langZh', defaultLabel: 'zh' },
3231
+ { code: 'uk', labelKey: 'langUk', defaultLabel: 'uk' },
3232
+ { code: 'de', labelKey: 'langDe', defaultLabel: 'de' },
3233
+ ]
3234
+ const LANGS = SUPPORTED_LANGS.map((o) => o.code)
3235
+
3236
+ const SET_CSS =
3237
+ '.dvo-settings-root.cb-page,.dvo-settings-root .cb-page{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:960px}.dvo-settings-root .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}.dvo-settings-root .cb-section-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}.dvo-settings-root .cb-section-desc{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:-4px;line-height:1.4}.dvo-settings-root .cb-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}.dvo-settings-root .cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:12px}.dvo-settings-root .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}.dvo-settings-root .cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}.dvo-settings-root .cb-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}.dvo-settings-root .cb-badge-bad,.dvo-settings-root .cb-badge-err{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}.dvo-settings-root .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}.dvo-settings-root .cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}.dvo-settings-root .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}.dvo-settings-root .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))}.dvo-settings-root .cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}.dvo-settings-root .cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88}.dvo-settings-root .cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}.dvo-settings-root .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}.dvo-settings-root .cb-btn-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}.dvo-settings-root .cb-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}.dvo-settings-root .cb-field select,.dvo-settings-root .cb-field input[type="text"],.dvo-settings-root .cb-field input[type="number"],.dvo-settings-root .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}.dvo-settings-root .cb-field textarea{height:auto;padding:8px 10px;font-family:inherit}.dvo-settings-root .cb-check-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary);user-select:none}.dvo-settings-root .cb-check-label input[type="checkbox"]{width:16px;height:16px;cursor:pointer}.dvo-settings-root .cb-alert-err{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px}.dvs-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0;max-width:960px}.dvs-block{display:flex;flex-direction:column;gap:10px}.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}.dvs-sub{font-size:12px;color:var(--dsw-alias-label-secondary)}.dvs-row{display:flex;gap:8px;align-items:center}.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}.dvs-row .dvs-model{flex:1}.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}.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}.dvs-wait{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5;max-width:520px}.dvs-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}.dvs-bad{font-size:12px;color:var(--dsw-alias-state-error-primary)}.dvo-pcard{list-style:none;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px}.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}.dvo-pheadtext{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0}.dvo-ptitle{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.dvo-pdesc{color:var(--dsw-alias-label-secondary);font-size:13px}.dvo-pchev{flex:none;display:flex;color:var(--dsw-alias-label-secondary);transition:transform .15s ease}.dvo-pcardOpen .dvo-pchev{transform:rotate(180deg)}.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}'
3238
+ const setCssId = 'dsh-voice/settings.module.css'
3239
+ function installSettingsCss() {
3240
+ if (typeof document === 'undefined') return () => {}
3241
+ let tag = document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + setCssId + '"]')
3242
+ if (!tag) {
3243
+ tag = document.createElement('style')
3244
+ tag.textContent = SET_CSS
3245
+ tag.setAttribute('data-dsh-plugin', 'dsh-voice')
3246
+ tag.dataset.pluginCss = setCssId
3247
+ document.head.appendChild(tag)
3248
+ }
3249
+ return () => {
3250
+ if (tag && tag.parentNode) tag.parentNode.removeChild(tag)
3251
+ }
3252
+ }
3253
+
3254
+ function createErrorBoundary() {
3255
+ if (!React || typeof React.Component !== 'function') {
3256
+ return function NoopBoundary(props) { return props?.children || null }
3257
+ }
3258
+ return class ErrorBoundary extends React.Component {
3259
+ constructor(props) {
3260
+ super(props)
3261
+ this.state = { hasError: false, error: null }
2894
3262
  }
2895
- function modelHint(provider) {
2896
- const key = MODEL_HINT_KEYS[provider]
2897
- if (key) return t(key)
2898
- return MODEL_HINT_STATIC[provider] || ''
3263
+ static getDerivedStateFromError(error) {
3264
+ return { hasError: true, error }
2899
3265
  }
2900
- const SUPPORTED_LANGS = [
2901
- { code: '', labelKey: 'langAuto', defaultLabel: 'auto' },
2902
- { code: 'ru', labelKey: 'langRu', defaultLabel: 'ru' },
2903
- { code: 'en', labelKey: 'langEn', defaultLabel: 'en' },
2904
- { code: 'zh', labelKey: 'langZh', defaultLabel: 'zh' },
2905
- { code: 'uk', labelKey: 'langUk', defaultLabel: 'uk' },
2906
- { code: 'de', labelKey: 'langDe', defaultLabel: 'de' },
2907
- ]
2908
- const LANGS = SUPPORTED_LANGS.map((o) => o.code)
2909
-
2910
- const SET_CSS =
2911
- '.dvo-settings-root.cb-page,.dvo-settings-root .cb-page{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:960px}' +
2912
- '.dvo-settings-root .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}' +
2913
- '.dvo-settings-root .cb-section-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}' +
2914
- '.dvo-settings-root .cb-section-desc{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:-4px;line-height:1.4}' +
2915
- '.dvo-settings-root .cb-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}' +
2916
- '.dvo-settings-root .cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:12px}' +
2917
- '.dvo-settings-root .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}' +
2918
- '.dvo-settings-root .cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}' +
2919
- '.dvo-settings-root .cb-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}' +
2920
- '.dvo-settings-root .cb-badge-bad,.dvo-settings-root .cb-badge-err{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}' +
2921
- '.dvo-settings-root .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}' +
2922
- '.dvo-settings-root .cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
2923
- '.dvo-settings-root .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}' +
2924
- '.dvo-settings-root .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))}' +
2925
- '.dvo-settings-root .cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}' +
2926
- '.dvo-settings-root .cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88}' +
2927
- '.dvo-settings-root .cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}' +
2928
- '.dvo-settings-root .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}' +
2929
- '.dvo-settings-root .cb-btn-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}' +
2930
- '.dvo-settings-root .cb-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
2931
- '.dvo-settings-root .cb-field select,.dvo-settings-root .cb-field input[type="text"],.dvo-settings-root .cb-field input[type="number"],.dvo-settings-root .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}' +
2932
- '.dvo-settings-root .cb-field textarea{height:auto;padding:8px 10px;font-family:inherit}' +
2933
- '.dvo-settings-root .cb-check-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary);user-select:none}' +
2934
- '.dvo-settings-root .cb-check-label input[type="checkbox"]{width:16px;height:16px;cursor:pointer}' +
2935
- '.dvo-settings-root .cb-alert-err{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px}' +
2936
- '.dvs-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0;max-width:960px}' +
2937
- '.dvs-block{display:flex;flex-direction:column;gap:10px}' +
2938
- '.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
2939
- '.dvs-sub{font-size:12px;color:var(--dsw-alias-label-secondary)}' +
2940
- '.dvs-row{display:flex;gap:8px;align-items:center}' +
2941
- '.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}' +
2942
- '.dvs-row .dvs-model{flex:1}' +
2943
- '.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}' +
2944
- '.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}' +
2945
- '.dvs-wait{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5;max-width:520px}' +
2946
- '.dvs-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}' +
2947
- '.dvs-bad{font-size:12px;color:var(--dsw-alias-state-error-primary)}' +
2948
- '.dvo-pcard{list-style:none;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px}' +
2949
- '.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}' +
2950
- '.dvo-pheadtext{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0}' +
2951
- '.dvo-ptitle{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
2952
- '.dvo-pdesc{color:var(--dsw-alias-label-secondary);font-size:13px}' +
2953
- '.dvo-pchev{flex:none;display:flex;color:var(--dsw-alias-label-secondary);transition:transform .15s ease}' +
2954
- '.dvo-pcardOpen .dvo-pchev{transform:rotate(180deg)}' +
2955
- '.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}'
2956
- const setCssId = 'dsh-voice/settings.module.css'
2957
- function installSettingsCss() {
2958
- if (typeof document === 'undefined') return () => {}
2959
- let tag = document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + setCssId + '"]')
2960
- if (!tag) {
2961
- tag = document.createElement('style')
2962
- tag.textContent = SET_CSS
2963
- tag.setAttribute('data-dsh-plugin', 'dsh-voice')
2964
- tag.dataset.pluginCss = setCssId
2965
- document.head.appendChild(tag)
2966
- }
2967
- return () => {
2968
- if (tag && tag.parentNode) tag.parentNode.removeChild(tag)
2969
- }
3266
+ componentDidCatch(error, errorInfo) {
3267
+ console.error('[dsh-voice] React Error:', error, errorInfo)
2970
3268
  }
2971
-
2972
- function createErrorBoundary() {
2973
- if (!React || typeof React.Component !== 'function') {
2974
- return function NoopBoundary(props) { return props?.children || null }
2975
- }
2976
- return class ErrorBoundary extends React.Component {
2977
- constructor(props) {
2978
- super(props)
2979
- this.state = { hasError: false, error: null }
2980
- }
2981
- static getDerivedStateFromError(error) {
2982
- return { hasError: true, error }
2983
- }
2984
- componentDidCatch(error, errorInfo) {
2985
- console.error('[dsh-voice] React Error:', error, errorInfo)
2986
- }
2987
- render() {
2988
- if (this.state.hasError) {
2989
- return React.createElement(
2990
- 'div',
2991
- { className: 'cb-alert-err', style: { margin: '12px 0', padding: '14px', borderRadius: '8px' } },
2992
- React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ ' + t('uiError')),
2993
- React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
2994
- React.createElement(
2995
- 'button',
2996
- {
2997
- type: 'button',
2998
- className: 'cb-btn',
2999
- style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
3000
- onClick: () => this.setState({ hasError: false, error: null }),
3001
- },
3002
- t('retry'),
3003
- ),
3004
- )
3005
- }
3006
- return this.props?.children || null
3007
- }
3008
- }
3269
+ render() {
3270
+ if (this.state.hasError) {
3271
+ return h(
3272
+ 'div',
3273
+ { className: 'cb-alert-err', style: { margin: '12px 0', padding: '14px', borderRadius: '8px' } },
3274
+ h('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ ' + t('uiError')),
3275
+ h('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
3276
+ h(
3277
+ 'button',
3278
+ {
3279
+ type: 'button',
3280
+ className: 'cb-btn',
3281
+ style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
3282
+ onClick: () => this.setState({ hasError: false, error: null }),
3283
+ },
3284
+ t('retry'),
3285
+ ),
3286
+ )
3287
+ }
3288
+ return this.props?.children || null
3009
3289
  }
3010
- const ErrorBoundary = createErrorBoundary()
3290
+ }
3291
+ }
3292
+ const ErrorBoundary = createErrorBoundary()
3011
3293
 
3012
3294
  // One chain editor: provider+model rows with reordering.
3013
3295
  function ChainEditor(props) {
@@ -3028,24 +3310,24 @@ function cycleDictationMode() {
3028
3310
  const add = () => props.onChange(rows.concat([{ provider: 'local-whisper', model: '' }]))
3029
3311
  const options = Array.isArray(props.options) && props.options.length ? props.options : BUILTIN
3030
3312
 
3031
- return React.createElement('div', { className: 'dvs-block' },
3032
- rows.map((row, i) => React.createElement('div', { className: 'dvs-row', key: i },
3033
- React.createElement('select', {
3313
+ return h('div', { className: 'dvs-block' },
3314
+ rows.map((row, i) => h('div', { className: 'dvs-row', key: i },
3315
+ h('select', {
3034
3316
  value: row.provider, disabled: !props.writable,
3035
3317
  onChange: (e) => change(i, { provider: e.target.value }),
3036
3318
  }, (options.indexOf(row.provider) < 0 ? options.concat([row.provider]) : options)
3037
- .map((p) => React.createElement('option', { key: p, value: p }, p))),
3038
- React.createElement('input', {
3319
+ .map((p) => h('option', { key: p, value: p }, p))),
3320
+ h('input', {
3039
3321
  className: 'dvs-model', value: row.model || '', disabled: !props.writable,
3040
3322
  placeholder: modelHint(row.provider), onChange: (e) => change(i, { model: e.target.value }),
3041
3323
  }),
3042
- React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '↑'),
3043
- React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '↓'),
3044
- React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '×'),
3324
+ h('button', { type: 'button', className: 'dvs-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '↑'),
3325
+ h('button', { type: 'button', className: 'dvs-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '↓'),
3326
+ h('button', { type: 'button', className: 'dvs-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '×'),
3045
3327
  )),
3046
- React.createElement('div', { className: 'dvs-row' },
3047
- React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('addProvider'), disabled: !props.writable, onClick: add }, '+'),
3048
- React.createElement('span', { className: 'dvs-sub' }, t('chainHint')),
3328
+ h('div', { className: 'dvs-row' },
3329
+ h('button', { type: 'button', className: 'dvs-mini', title: t('addProvider'), disabled: !props.writable, onClick: add }, '+'),
3330
+ h('span', { className: 'dvs-sub' }, t('chainHint')),
3049
3331
  ),
3050
3332
  )
3051
3333
  }
@@ -3059,42 +3341,42 @@ function cycleDictationMode() {
3059
3341
  const add = () => props.onChange(rows.concat([
3060
3342
  { key: '', template: 'openai-transcriptions', baseURL: '', model: '', keyEnv: '', prompt: '' },
3061
3343
  ]))
3062
- const field = (i, row, name, placeholder, wide) => React.createElement('input', {
3344
+ const field = (i, row, name, placeholder, wide) => h('input', {
3063
3345
  className: wide ? 'dvs-model' : '', value: row[name] || '', placeholder: placeholder,
3064
3346
  disabled: !props.writable, onChange: (e) => change(i, { [name]: e.target.value }),
3065
3347
  })
3066
3348
 
3067
- return React.createElement('div', { className: 'dvs-block' },
3068
- rows.map((row, i) => React.createElement('div', { className: 'dvs-card', key: i },
3069
- React.createElement('div', { className: 'dvs-row' },
3349
+ return h('div', { className: 'dvs-block' },
3350
+ rows.map((row, i) => h('div', { className: 'dvs-card', key: i },
3351
+ h('div', { className: 'dvs-row' },
3070
3352
  field(i, row, 'key', t('customName')),
3071
- React.createElement('select', {
3353
+ h('select', {
3072
3354
  value: row.template || 'openai-transcriptions', disabled: !props.writable,
3073
3355
  onChange: (e) => change(i, { template: e.target.value }),
3074
- }, TEMPLATES.map((tmpl) => React.createElement('option', { key: tmpl, value: tmpl }, tmpl))),
3075
- React.createElement('button', {
3356
+ }, TEMPLATES.map((tmpl) => h('option', { key: tmpl, value: tmpl }, tmpl))),
3357
+ h('button', {
3076
3358
  type: 'button', className: 'dvs-mini', title: t('remove'),
3077
3359
  disabled: !props.writable, onClick: () => remove(i),
3078
3360
  }, '\u00d7'),
3079
3361
  ),
3080
- React.createElement('div', { className: 'dvs-row' },
3362
+ h('div', { className: 'dvs-row' },
3081
3363
  field(i, row, 'baseURL', 'https://openrouter.ai/api/v1', true),
3082
3364
  ),
3083
- React.createElement('div', { className: 'dvs-row' },
3365
+ h('div', { className: 'dvs-row' },
3084
3366
  field(i, row, 'model', t('customModel'), true),
3085
3367
  field(i, row, 'keyEnv', t('customKeyName')),
3086
3368
  ),
3087
3369
  row.template === 'openai-chat-audio'
3088
- ? React.createElement('div', { className: 'dvs-row' },
3370
+ ? h('div', { className: 'dvs-row' },
3089
3371
  field(i, row, 'prompt', t('customModelHint'), true))
3090
3372
  : null,
3091
3373
  )),
3092
- React.createElement('div', { className: 'dvs-row' },
3093
- React.createElement('button', {
3374
+ h('div', { className: 'dvs-row' },
3375
+ h('button', {
3094
3376
  type: 'button', className: 'dvs-mini', title: t('addCustom'),
3095
3377
  disabled: !props.writable, onClick: add,
3096
3378
  }, '+'),
3097
- React.createElement('span', { className: 'dvs-sub' },
3379
+ h('span', { className: 'dvs-sub' },
3098
3380
  t('openrouterWarning')),
3099
3381
  ),
3100
3382
  )
@@ -3133,31 +3415,31 @@ function cycleDictationMode() {
3133
3415
  }
3134
3416
  }
3135
3417
 
3136
- return React.createElement('div', { className: 'cb-section-card' },
3137
- React.createElement('div', { className: 'cb-section-title' },
3138
- React.createElement('span', null, '📊 ' + t('providerDashboard')),
3139
- React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
3140
- React.createElement('button', {
3418
+ return h('div', { className: 'cb-section-card' },
3419
+ h('div', { className: 'cb-section-title' },
3420
+ h('span', null, '📊 ' + t('providerDashboard')),
3421
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
3422
+ h('button', {
3141
3423
  type: 'button',
3142
3424
  className: 'cb-btn',
3143
3425
  style: { fontSize: '12px', padding: '4px 10px' },
3144
3426
  disabled: exporting,
3145
3427
  onClick: exportReport,
3146
3428
  }, exporting ? t('exportingDiagnostics') : ('📥 ' + t('exportDiagnosticsBtn'))),
3147
- React.createElement('button', {
3429
+ h('button', {
3148
3430
  type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
3149
3431
  onClick: props.onRefresh,
3150
3432
  }, '🔄 ' + t('refreshStats')),
3151
3433
  ),
3152
3434
  ),
3153
- exportMsg ? React.createElement('div', {
3435
+ exportMsg ? h('div', {
3154
3436
  className: exportMsg.startsWith('✓') ? 'dvs-ok' : 'cb-alert-err',
3155
3437
  style: { fontSize: '12px' },
3156
3438
  }, exportMsg) : null,
3157
- React.createElement('div', { className: 'dvo-dash' },
3158
- React.createElement('div', { className: 'dvo-dash-grid' },
3439
+ h('div', { className: 'dvo-dash' },
3440
+ h('div', { className: 'dvo-dash-grid' },
3159
3441
  Object.keys(statsData).length === 0
3160
- ? React.createElement('div', { className: 'dvs-sub' }, t('idle'))
3442
+ ? h('div', { className: 'dvs-sub' }, t('idle'))
3161
3443
  : Object.entries(statsData).map(([key, item]) => {
3162
3444
  const avg = item.avgTookMs || 0
3163
3445
  const hasErrors = item.failures > 0
@@ -3179,52 +3461,52 @@ function cycleDictationMode() {
3179
3461
  }
3180
3462
  }
3181
3463
  const rate = item.attempts > 0 ? Math.round((item.successes / item.attempts) * 100) : 100
3182
- return React.createElement('div', { key, className: 'dvo-dash-item', title: item.lastError ? `${t('errorPrefix')}: ${item.lastError}` : '' },
3183
- React.createElement('div', { className: 'dvo-dash-name' }, key),
3184
- React.createElement('div', { className: 'dvo-dash-row' },
3185
- React.createElement('span', { className: 'dvo-badge ' + badgeClass }, badgeText),
3186
- React.createElement('span', null, rate + '%'),
3464
+ return h('div', { key, className: 'dvo-dash-item', title: item.lastError ? `${t('errorPrefix')}: ${item.lastError}` : '' },
3465
+ h('div', { className: 'dvo-dash-name' }, key),
3466
+ h('div', { className: 'dvo-dash-row' },
3467
+ h('span', { className: 'dvo-badge ' + badgeClass }, badgeText),
3468
+ h('span', null, rate + '%'),
3187
3469
  ),
3188
- React.createElement('div', { className: 'dvo-dash-row' },
3189
- React.createElement('span', null, `${item.successes}/${item.attempts}`),
3190
- React.createElement('span', null, t('successRate')),
3470
+ h('div', { className: 'dvo-dash-row' },
3471
+ h('span', null, `${item.successes}/${item.attempts}`),
3472
+ h('span', null, t('successRate')),
3191
3473
  ),
3192
3474
  )
3193
3475
  }),
3194
3476
  ),
3195
3477
  ),
3196
3478
  // Per-recognition telemetry breakdown list (#266)
3197
- React.createElement('div', { style: { marginTop: '12px', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: '10px' } },
3198
- React.createElement('div', { className: 'dvs-h', style: { marginBottom: '8px' } }, t('recentRecognitionsTitle')),
3479
+ h('div', { style: { marginTop: '12px', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: '10px' } },
3480
+ h('div', { className: 'dvs-h', style: { marginBottom: '8px' } }, t('recentRecognitionsTitle')),
3199
3481
  recentDiagnostics.length === 0
3200
- ? React.createElement('div', { className: 'dvs-sub' }, t('noRecognitionsYet'))
3201
- : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
3202
- recentDiagnostics.slice(-5).reverse().map((d) => React.createElement('div', {
3482
+ ? h('div', { className: 'dvs-sub' }, t('noRecognitionsYet'))
3483
+ : h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
3484
+ recentDiagnostics.slice(-5).reverse().map((d) => h('div', {
3203
3485
  key: d.id,
3204
3486
  className: 'dvs-card',
3205
3487
  style: { padding: '8px 12px', fontSize: '12px' },
3206
3488
  },
3207
- React.createElement('div', { className: 'cb-row', style: { justifyContent: 'space-between', alignItems: 'center' } },
3208
- React.createElement('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
3209
- React.createElement('span', { className: 'cb-badge ' + (d.success ? 'cb-badge-ok' : 'cb-badge-err') },
3489
+ h('div', { className: 'cb-row', style: { justifyContent: 'space-between', alignItems: 'center' } },
3490
+ h('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
3491
+ h('span', { className: 'cb-badge ' + (d.success ? 'cb-badge-ok' : 'cb-badge-err') },
3210
3492
  d.success ? '✓ ' + (d.provider || 'STT') : '✗ ' + (d.error ? d.error.slice(0, 30) : 'fail')
3211
3493
  ),
3212
- React.createElement('span', { className: 'dvs-sub' }, `[${d.mode}/${d.subMode}]`),
3213
- d.audioBytes ? React.createElement('span', { className: 'dvs-sub' }, t('diagBytes').replace('{bytes}', d.audioBytes)) : null,
3494
+ h('span', { className: 'dvs-sub' }, `[${d.mode}/${d.subMode}]`),
3495
+ d.audioBytes ? h('span', { className: 'dvs-sub' }, t('diagBytes').replace('{bytes}', d.audioBytes)) : null,
3214
3496
  ),
3215
- React.createElement('span', { style: { fontWeight: '600' } },
3497
+ h('span', { style: { fontWeight: '600' } },
3216
3498
  t('diagTotal').replace('{ms}', d.totalTookMs)
3217
3499
  ),
3218
3500
  ),
3219
- React.createElement('div', { className: 'cb-row', style: { gap: '12px', marginTop: '4px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } },
3220
- React.createElement('span', null, t('diagVad').replace('{ms}', (d.breakdown && d.breakdown.vadMs) || 0)),
3221
- React.createElement('span', null, t('diagStt').replace('{ms}', (d.breakdown && d.breakdown.sttMs) || 0)),
3222
- d.breakdown && d.breakdown.polishMs ? React.createElement('span', null, t('diagPolish').replace('{ms}', d.breakdown.polishMs)) : null,
3223
- typeof d.wordCount === 'number' ? React.createElement('span', null, t('diagWords').replace('{count}', d.wordCount)) : null,
3501
+ h('div', { className: 'cb-row', style: { gap: '12px', marginTop: '4px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } },
3502
+ h('span', null, t('diagVad').replace('{ms}', (d.breakdown && d.breakdown.vadMs) || 0)),
3503
+ h('span', null, t('diagStt').replace('{ms}', (d.breakdown && d.breakdown.sttMs) || 0)),
3504
+ d.breakdown && d.breakdown.polishMs ? h('span', null, t('diagPolish').replace('{ms}', d.breakdown.polishMs)) : null,
3505
+ typeof d.wordCount === 'number' ? h('span', null, t('diagWords').replace('{count}', d.wordCount)) : null,
3224
3506
  ),
3225
3507
  ))
3226
3508
  ),
3227
- React.createElement('div', { className: 'dvs-sub', style: { marginTop: '8px', fontStyle: 'italic' } },
3509
+ h('div', { className: 'dvs-sub', style: { marginTop: '8px', fontStyle: 'italic' } },
3228
3510
  t('diagExportNotice')
3229
3511
  ),
3230
3512
  ),
@@ -3247,15 +3529,15 @@ function DictationModesSection(props) {
3247
3529
  setTop('dictationPrompts', { clean: '', task: '', note: '' })
3248
3530
  }
3249
3531
 
3250
- return React.createElement('div', { className: 'cb-section-card' },
3251
- React.createElement('div', { className: 'cb-section-title' },
3252
- React.createElement('span', null, '🎙️ ' + t('dictationModeTitle')),
3532
+ return h('div', { className: 'cb-section-card' },
3533
+ h('div', { className: 'cb-section-title' },
3534
+ h('span', null, '🎙️ ' + t('dictationModeTitle')),
3253
3535
  ),
3254
- React.createElement('div', { className: 'cb-field-desc' }, t('dictationModeDesc')),
3255
- React.createElement('div', { className: 'cb-grid-2', style: { marginTop: '8px' } },
3536
+ h('div', { className: 'cb-field-desc' }, t('dictationModeDesc')),
3537
+ h('div', { className: 'cb-grid-2', style: { marginTop: '8px' } },
3256
3538
  ['clean', 'task', 'note', 'verbatim'].map((m) =>
3257
- React.createElement('label', { key: m, className: 'cb-check-label' },
3258
- React.createElement('input', {
3539
+ h('label', { key: m, className: 'cb-check-label' },
3540
+ h('input', {
3259
3541
  type: 'radio',
3260
3542
  name: 'dictationMode',
3261
3543
  value: m,
@@ -3267,10 +3549,10 @@ function DictationModesSection(props) {
3267
3549
  ),
3268
3550
  ),
3269
3551
  ),
3270
- React.createElement('div', { style: { marginTop: '12px', borderTop: '1px solid var(--dsw-alias-border-l1)', paddingTop: '10px' } },
3271
- React.createElement('div', { className: 'cb-section-title', style: { fontSize: '13px' } },
3272
- React.createElement('span', null, t('dictationPromptsTitle')),
3273
- React.createElement('button', {
3552
+ h('div', { style: { marginTop: '12px', borderTop: '1px solid var(--dsw-alias-border-l1)', paddingTop: '10px' } },
3553
+ h('div', { className: 'cb-section-title', style: { fontSize: '13px' } },
3554
+ h('span', null, t('dictationPromptsTitle')),
3555
+ h('button', {
3274
3556
  type: 'button',
3275
3557
  className: 'cb-btn cb-btn-secondary',
3276
3558
  style: { fontSize: '11px', padding: '2px 8px' },
@@ -3278,11 +3560,11 @@ function DictationModesSection(props) {
3278
3560
  onClick: resetPrompts,
3279
3561
  }, t('resetPrompts')),
3280
3562
  ),
3281
- React.createElement('div', { className: 'cb-field-desc' }, t('dictationPromptsDesc')),
3282
- React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '8px' } },
3283
- React.createElement('div', null,
3284
- React.createElement('label', { className: 'cb-label' }, t('promptClean')),
3285
- React.createElement('textarea', {
3563
+ h('div', { className: 'cb-field-desc' }, t('dictationPromptsDesc')),
3564
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '8px' } },
3565
+ h('div', null,
3566
+ h('label', { className: 'cb-label' }, t('promptClean')),
3567
+ h('textarea', {
3286
3568
  className: 'cb-input',
3287
3569
  style: { minHeight: '48px', resize: 'vertical' },
3288
3570
  value: prompts.clean || '',
@@ -3291,9 +3573,9 @@ function DictationModesSection(props) {
3291
3573
  onChange: (e) => setPrompt('clean', e.target.value),
3292
3574
  }),
3293
3575
  ),
3294
- React.createElement('div', null,
3295
- React.createElement('label', { className: 'cb-label' }, t('promptTask')),
3296
- React.createElement('textarea', {
3576
+ h('div', null,
3577
+ h('label', { className: 'cb-label' }, t('promptTask')),
3578
+ h('textarea', {
3297
3579
  className: 'cb-input',
3298
3580
  style: { minHeight: '48px', resize: 'vertical' },
3299
3581
  value: prompts.task || '',
@@ -3302,9 +3584,9 @@ function DictationModesSection(props) {
3302
3584
  onChange: (e) => setPrompt('task', e.target.value),
3303
3585
  }),
3304
3586
  ),
3305
- React.createElement('div', null,
3306
- React.createElement('label', { className: 'cb-label' }, t('promptNote')),
3307
- React.createElement('textarea', {
3587
+ h('div', null,
3588
+ h('label', { className: 'cb-label' }, t('promptNote')),
3589
+ h('textarea', {
3308
3590
  className: 'cb-input',
3309
3591
  style: { minHeight: '48px', resize: 'vertical' },
3310
3592
  value: prompts.note || '',
@@ -3317,570 +3599,580 @@ function DictationModesSection(props) {
3317
3599
  ),
3318
3600
  )
3319
3601
  }
3320
- // 5. Hardware & Audio Engines Card
3321
- function HardwareOptionsCard(props) {
3322
- const t = props.t || moduleT
3323
- const draft = props.draft
3324
- const setTop = props.setTop
3325
- const writable = props.writable
3326
- const catching = props.catching
3327
- const setCatching = props.setCatching
3328
- const devices = props.devices || []
3329
- const testingMic = props.testingMic
3330
- const toggleTestMic = props.toggleTestMic
3331
- const testLevel = props.testLevel || 0
3332
- const gateDbVal = props.gateDbVal
3333
- const gateThresholdPercent = props.gateThresholdPercent
3334
- const calibrating = props.calibrating
3335
- const calibrateMsg = props.calibrateMsg
3336
- const calibrateNoise = props.calibrateNoise
3337
- const onClearKey = props.onClearKey
3338
-
3339
- const [benchmarking, setBenchmarking] = React.useState(false)
3340
- const [benchmarkResult, setBenchmarkResult] = React.useState(null)
3341
- const [benchmarkErr, setBenchmarkErr] = React.useState('')
3342
-
3343
- const runBenchmark = () => {
3344
- setBenchmarking(true)
3345
- setBenchmarkErr('')
3346
- fetch('/dsh-voice/benchmark', {
3347
- method: 'POST',
3348
- headers: {
3602
+ // 5. Hardware & Audio Engines Card
3603
+ function HardwareOptionsCard(props) {
3604
+ const {
3605
+ t = moduleT, draft, setTop, writable, catching, setCatching,
3606
+ devices = [], testingMic, toggleTestMic, testLevel = 0,
3607
+ gateDbVal, gateThresholdPercent, calibrating, calibrateMsg, calibrateNoise, onClearKey
3608
+ } = props
3609
+
3610
+ const [benchmarking, setBenchmarking] = React.useState(false)
3611
+ const [benchmarkResult, setBenchmarkResult] = React.useState(null)
3612
+ const [benchmarkErr, setBenchmarkErr] = React.useState('')
3613
+
3614
+ const runBenchmark = () => {
3615
+ setBenchmarking(true)
3616
+ setBenchmarkErr('')
3617
+ fetch('/dsh-voice/benchmark', {
3618
+ method: 'POST',
3619
+ headers: {
3620
+ 'x-dsh-plugin-update': '1',
3621
+ 'Content-Type': 'application/json',
3622
+ },
3623
+ })
3624
+ .then((r) => r.json())
3625
+ .then((d) => {
3626
+ setBenchmarking(false)
3627
+ if (d && d.ok) setBenchmarkResult(d)
3628
+ else setBenchmarkErr((d && d.error && d.error.message) || 'Benchmark failed')
3629
+ })
3630
+ .catch((e) => {
3631
+ setBenchmarking(false)
3632
+ setBenchmarkErr((e && e.message) || 'Network error')
3633
+ })
3634
+ }
3635
+
3636
+ const textField = (key, label, hint) => h('div', { className: 'cb-field' },
3637
+ h('label', null, label),
3638
+ h('input', {
3639
+ type: 'text', value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
3640
+ onChange: (e) => setTop(key, e.target.value),
3641
+ }),
3642
+ h('span', { className: 'dvs-sub' }, hint),
3643
+ )
3644
+
3645
+ return h('div', { className: 'cb-section-card' },
3646
+ h('div', { className: 'cb-section-title' },
3647
+ h('span', null, '⚙️ ' + t('hardwareTitle')),
3648
+ ),
3649
+ h('div', { className: 'cb-section-desc' }, t('hardwareDesc')),
3650
+ // Hotkey picker
3651
+ h('div', { className: 'cb-field' },
3652
+ h('label', null, t('hotkey')),
3653
+ h('div', { className: 'cb-row' },
3654
+ h('button', {
3655
+ type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable,
3656
+ onClick: () => setCatching(true),
3657
+ }, catching ? t('pressKey') : keyLabel(draft && draft.hotkey)),
3658
+ h('button', {
3659
+ type: 'button', className: 'cb-btn cb-btn-mini', title: t('clearKey'),
3660
+ disabled: !writable, onClick: onClearKey,
3661
+ }, '×'),
3662
+ ),
3663
+ h('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
3664
+ ),
3665
+ // Mic & noise gate
3666
+ h('div', { className: 'cb-field' },
3667
+ h('label', null, t('mic')),
3668
+ h('div', { className: 'cb-row' },
3669
+ h('select', {
3670
+ value: String((draft && draft.micDeviceId) ?? ''), disabled: !writable || testingMic,
3671
+ onChange: (e) => setTop('micDeviceId', e.target.value),
3672
+ style: { flex: 1 },
3673
+ },
3674
+ h('option', { value: '' }, t('micDefault')),
3675
+ devices.map((d) => h('option', { key: d.deviceId, value: d.deviceId },
3676
+ d.label || d.deviceId.slice(0, 12))),
3677
+ ),
3678
+ h('button', {
3679
+ type: 'button',
3680
+ className: 'cb-btn ' + (testingMic ? 'cb-btn-primary' : 'cb-btn-secondary'),
3681
+ onClick: toggleTestMic,
3682
+ style: { flex: 'none' },
3683
+ }, testingMic ? t('stopTest') : t('testMic')),
3684
+ h('button', {
3685
+ type: 'button',
3686
+ className: 'cb-btn cb-btn-secondary',
3687
+ onClick: () => (typeof calibrateNoise === 'function' ? calibrateNoise(setTop, t) : null),
3688
+ disabled: calibrating || !writable,
3689
+ style: { flex: 'none' },
3690
+ title: t('calibrateNoiseHint') || '',
3691
+ }, calibrating ? t('calibrating') : t('calibrateNoise')),
3692
+ ),
3693
+ calibrateMsg ? h('div', {
3694
+ style: { fontSize: '11px', color: 'var(--dsw-alias-state-success-primary)', marginTop: '4px' },
3695
+ }, calibrateMsg) : null,
3696
+ testingMic ? h('div', { className: 'dvo-mic-test' },
3697
+ h('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, t('micLevel') + ':'),
3698
+ h('div', { className: 'dvo-meter-bar' },
3699
+ h('div', { className: 'dvo-meter-fill', style: { width: `${testLevel}%` } }),
3700
+ gateThresholdPercent > 0 ? h('div', { className: 'dvo-meter-gate', style: { left: `${gateThresholdPercent}%` }, title: `${gateDbVal} dB` }) : null,
3701
+ ),
3702
+ h('span', { style: { fontSize: '11px', fontVariantNumeric: 'tabular-nums', width: '32px', textAlign: 'right' } }, `${testLevel}%`),
3703
+ ) : null,
3704
+ h('div', { className: 'cb-row', style: { marginTop: '6px' } },
3705
+ h('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)', minWidth: '120px' } }, t('noiseGate') + ':'),
3706
+ h('select', {
3707
+ value: String(gateDbVal), disabled: !writable,
3708
+ onChange: (e) => setTop('noiseGateDb', Number(e.target.value)),
3709
+ style: { flex: 1 },
3710
+ },
3711
+ h('option', { value: '-999' }, t('noiseGateOff')),
3712
+ h('option', { value: '-50' }, '-50 dB (Low / Gentle)'),
3713
+ h('option', { value: '-45' }, '-45 dB (Standard Default)'),
3714
+ h('option', { value: '-35' }, '-35 dB (Medium Room)'),
3715
+ h('option', { value: '-25' }, '-25 dB (High / Mechanical Clicks)'),
3716
+ !['-999', '-50', '-45', '-35', '-25'].includes(String(gateDbVal)) ? h('option', { value: String(gateDbVal) }, `${gateDbVal} dB (${t('calibratedValue') || 'Calibrated'})`) : null,
3717
+ ),
3718
+ ),
3719
+ h('span', { className: 'dvs-sub' }, t('noiseGateHint')),
3720
+ ),
3721
+ h('div', { className: 'cb-field' },
3722
+ h('label', null, t('vadEngine')),
3723
+ h('select', {
3724
+ value: (draft && draft.vadEngine) || 'silero', disabled: !writable,
3725
+ onChange: (e) => setTop('vadEngine', e.target.value),
3726
+ },
3727
+ h('option', { value: 'silero' }, t('vadSilero')),
3728
+ h('option', { value: 'amplitude' }, t('vadAmplitude')),
3729
+ ),
3730
+ h('span', { className: 'dvs-sub' }, t('vadEngineHint')),
3731
+ ),
3732
+ h('div', { className: 'cb-field' },
3733
+ h('label', { className: 'cb-check-label' },
3734
+ h('input', {
3735
+ type: 'checkbox', checked: draft && draft.streamingWs !== false, disabled: !writable,
3736
+ onChange: (e) => setTop('streamingWs', e.target.checked),
3737
+ }),
3738
+ ' ' + t('streamingWs'),
3739
+ ),
3740
+ h('span', { className: 'dvs-sub' }, t('streamingWsHint')),
3741
+ ),
3742
+ // Endpoint text fields
3743
+ h('div', { className: 'cb-grid-2' },
3744
+ textField('whisperUrl', t('whisperEndpoint'), t('whisperEndpointHint')),
3745
+ textField('whisperBin', t('whisperBin'), t('whisperBinHint')),
3746
+ textField('whisperModel', t('whisperModel'), t('whisperModelHint')),
3747
+ textField('deepgramBaseUrl', t('deepgramEndpoint'), t('deepgramEndpointHint')),
3748
+ textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
3749
+ textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
3750
+ textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceModelHint')),
3751
+ textField('polishBaseUrl', t('polishBaseUrl'), t('polishBaseUrlHint')),
3752
+ textField('polishModel', t('polishModel'), ''),
3753
+ textField('polishKeyEnv', t('polishKeyEnv'), ''),
3754
+ ),
3755
+ // Engine checkboxes
3756
+ h('div', { className: 'cb-row' },
3757
+ h('label', { className: 'cb-check-label' },
3758
+ h('input', {
3759
+ type: 'checkbox', checked: !!(draft && draft.autoStart), disabled: !writable,
3760
+ onChange: (e) => setTop('autoStart', e.target.checked),
3761
+ }),
3762
+ t('whisperAutostart'),
3763
+ ),
3764
+ h('label', { className: 'cb-check-label' },
3765
+ h('input', {
3766
+ type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
3767
+ onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
3768
+ }),
3769
+ t('sensevoiceAutostart'),
3770
+ ),
3771
+ h('label', { className: 'cb-check-label' },
3772
+ h('input', {
3773
+ type: 'checkbox', checked: !!(draft && draft.localOnly), disabled: !writable,
3774
+ onChange: (e) => setTop('localOnly', e.target.checked),
3775
+ }),
3776
+ t('localOnly'),
3777
+ ),
3778
+ h('label', { className: 'cb-check-label' },
3779
+ h('input', {
3780
+ type: 'checkbox', checked: !!(draft && draft.beep), disabled: !writable,
3781
+ onChange: (e) => setTop('beep', e.target.checked),
3782
+ }),
3783
+ t('beep'),
3784
+ ),
3785
+ h('label', { className: 'cb-check-label' },
3786
+ h('input', {
3787
+ type: 'checkbox', checked: draft ? draft.noiseSuppression !== false : true, disabled: !writable,
3788
+ onChange: (e) => setTop('noiseSuppression', e.target.checked),
3789
+ }),
3790
+ t('noiseSuppression'),
3791
+ ),
3792
+ h('label', { className: 'cb-check-label' },
3793
+ h('input', {
3794
+ type: 'checkbox', checked: draft ? draft.contextGlossary !== false : true, disabled: !writable,
3795
+ onChange: (e) => setTop('contextGlossary', e.target.checked),
3796
+ }),
3797
+ t('contextGlossary'),
3798
+ ),
3799
+ h('label', { className: 'cb-check-label', title: t('bilingualDeveloperPromptHint') || '' },
3800
+ h('input', {
3801
+ type: 'checkbox', checked: draft ? draft.bilingualDeveloperPrompt !== false : true, disabled: !writable,
3802
+ onChange: (e) => setTop('bilingualDeveloperPrompt', e.target.checked),
3803
+ }),
3804
+ t('bilingualDeveloperPrompt'),
3805
+ ),
3806
+ h('label', { className: 'cb-check-label' },
3807
+ h('input', {
3808
+ type: 'checkbox', checked: !!(draft && draft.voiceCommands), disabled: !writable,
3809
+ onChange: (e) => setTop('voiceCommands', e.target.checked),
3810
+ }),
3811
+ t('voiceCommandsLabel'),
3812
+ ),
3813
+ h('label', { className: 'cb-check-label' },
3814
+ h('input', {
3815
+ type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
3816
+ onChange: (e) => setTop('normalizeTranscript', e.target.checked),
3817
+ }),
3818
+ t('normalizeTranscript'),
3819
+ ),
3820
+ ),
3821
+ // Visualizer select
3822
+ h('div', { className: 'cb-field' },
3823
+ h('label', null, t('visualizerStyle')),
3824
+ h('select', {
3825
+ value: String((draft && draft.visualizerStyle) || 'liquid-wave'), disabled: !writable,
3826
+ onChange: (e) => setTop('visualizerStyle', e.target.value),
3827
+ },
3828
+ h('option', { value: 'liquid-wave' }, t('visLiquidWave')),
3829
+ h('option', { value: 'dynamic-orb' }, t('visDynamicOrb')),
3830
+ h('option', { value: 'bars' }, t('visBars')),
3831
+ h('option', { value: 'off' }, t('visOff')),
3832
+ ),
3833
+ ),
3834
+ // Hardware Acceleration (SBC NPU / Host)
3835
+ h('div', { className: 'cb-field' },
3836
+ h('label', null, t('npuProvider')),
3837
+ h('div', { className: 'cb-row' },
3838
+ h('select', {
3839
+ value: String((draft && draft.sensevoiceProvider) || 'cpu'),
3840
+ disabled: !writable,
3841
+ onChange: (e) => setTop('sensevoiceProvider', e.target.value),
3842
+ style: { flex: 1 },
3843
+ },
3844
+ h('option', { value: 'cpu' }, 'CPU (Standard)'),
3845
+ h('option', { value: 'rknn' }, 'Rockchip RK3588 NPU (RKNN)'),
3846
+ h('option', { value: 'coreml' }, 'Apple Silicon CoreML'),
3847
+ h('option', { value: 'openvino' }, 'Intel OpenVINO'),
3848
+ h('option', { value: 'cuda' }, 'NVIDIA CUDA'),
3849
+ ),
3850
+ h('input', {
3851
+ type: 'number',
3852
+ min: 1,
3853
+ max: 32,
3854
+ value: Number((draft && draft.sensevoiceThreads) || 4),
3855
+ disabled: !writable,
3856
+ title: t('npuThreads'),
3857
+ style: { width: '80px' },
3858
+ onChange: (e) => setTop('sensevoiceThreads', Math.max(1, Math.min(32, Number(e.target.value) || 4))),
3859
+ }),
3860
+ ),
3861
+ h('span', { className: 'dvs-sub' }, t('npuProviderHint')),
3862
+ ),
3863
+ // Hardware Benchmark
3864
+ h('div', { className: 'cb-field' },
3865
+ h('div', { className: 'cb-row', style: { alignItems: 'center', justifyContent: 'space-between' } },
3866
+ h('label', { style: { marginBottom: 0 } }, t('benchmarkTitle')),
3867
+ h('button', {
3868
+ type: 'button', className: 'cb-btn cb-btn-secondary', style: { fontSize: '12px', padding: '4px 10px' },
3869
+ disabled: benchmarking || !writable, onClick: runBenchmark,
3870
+ }, benchmarking ? ('⏳ ' + t('benchmarking')) : ('⚡ ' + t('runBenchmarkBtn'))),
3871
+ ),
3872
+ benchmarkResult
3873
+ ? h('div', {
3874
+ style: { marginTop: '8px', padding: '6px 10px', borderRadius: '6px', fontSize: '12px', backgroundColor: 'var(--dsw-alias-fill-quaternary)' }
3875
+ }, benchmarkResult.running
3876
+ ? `⚡ RTF: ${benchmarkResult.rtf}x · ${benchmarkResult.latencyMs}ms (${benchmarkResult.rating}) · ${String(benchmarkResult.provider || 'cpu').toUpperCase()} · Cores: ${benchmarkResult.cpuCores}`
3877
+ : `ℹ ${benchmarkResult.message || 'Offline'} (${benchmarkResult.cpuCores} cores, ${benchmarkResult.platform})`)
3878
+ : (benchmarkErr ? h('div', { style: { color: 'var(--dsw-alias-state-danger)', fontSize: '12px', marginTop: '4px' } }, benchmarkErr) : null),
3879
+ h('span', { className: 'dvs-sub' }, t('benchmarkHint')),
3880
+ ),
3881
+ // WebGPU Status Indicator
3882
+ h('div', { className: 'cb-field' },
3883
+ h('label', null, t('webgpuStatus')),
3884
+ h('div', {
3885
+ style: {
3886
+ padding: '6px 10px',
3887
+ borderRadius: '6px',
3888
+ fontSize: '12px',
3889
+ backgroundColor: 'var(--dsw-alias-fill-quaternary)',
3890
+ color: (typeof isWebGpuSupported === 'function' && isWebGpuSupported())
3891
+ ? 'var(--dsw-alias-state-success)'
3892
+ : 'var(--dsw-alias-label-secondary)'
3893
+ }
3894
+ }, (typeof isWebGpuSupported === 'function' && isWebGpuSupported())
3895
+ ? ('✓ ' + t('webgpuAvailable'))
3896
+ : ('ℹ ' + t('webgpuNotAvailable'))),
3897
+ ),
3898
+ // Vocabulary textarea
3899
+ h('div', { className: 'cb-field' },
3900
+ h('label', null, t('vocabulary')),
3901
+ h('textarea', {
3902
+ rows: 3, disabled: !writable,
3903
+ value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
3904
+ onChange: (e) => setTop('vocabulary', e.target.value.split('\n').map((x) => x.trim()).filter(Boolean)),
3905
+ }),
3906
+ ),
3907
+ )
3908
+ }
3909
+ // Custom hooks for VoiceSection modularity
3910
+ function useSensevoiceInstaller(fetchStatus) {
3911
+ const [sensevoiceState, setSensevoiceState] = React.useState(null)
3912
+ const [installingSensevoice, setInstallingSensevoice] = React.useState(false)
3913
+
3914
+ const fetchSensevoice = () => {
3915
+ fetch('/dsh-voice/sensevoice-installer')
3916
+ .then((r) => r.json())
3917
+ .then((d) => {
3918
+ setSensevoiceState(d)
3919
+ setInstallingSensevoice(!!(d && d.installing))
3920
+ })
3921
+ .catch(() => {})
3922
+ }
3923
+
3924
+ React.useEffect(() => {
3925
+ fetchSensevoice()
3926
+ }, [])
3927
+
3928
+ const triggerInstallSensevoice = (modelType = 'sensevoice') => {
3929
+ setInstallingSensevoice(true)
3930
+ fetch('/dsh-voice/sensevoice-installer', {
3931
+ method: 'POST',
3932
+ headers: {
3349
3933
  'x-dsh-plugin-update': '1',
3350
3934
  'Content-Type': 'application/json',
3351
- },
3935
+ },
3936
+ body: JSON.stringify({ model: modelType }),
3352
3937
  })
3353
- .then((r) => r.json())
3354
- .then((d) => {
3355
- setBenchmarking(false)
3356
- if (d && d.ok) setBenchmarkResult(d)
3357
- else setBenchmarkErr((d && d.error && d.error.message) || 'Benchmark failed')
3358
- })
3359
- .catch((e) => {
3360
- setBenchmarking(false)
3361
- setBenchmarkErr((e && e.message) || 'Network error')
3362
- })
3363
- }
3938
+ .then((r) => r.json())
3939
+ .then(() => {
3940
+ const timer = setInterval(() => {
3941
+ fetch('/dsh-voice/sensevoice-installer')
3942
+ .then((r) => r.json())
3943
+ .then((d) => {
3944
+ setSensevoiceState(d)
3945
+ if (!d.installing) {
3946
+ clearInterval(timer)
3947
+ setInstallingSensevoice(false)
3948
+ if (typeof fetchStatus === 'function') fetchStatus()
3949
+ }
3950
+ })
3951
+ .catch(() => clearInterval(timer))
3952
+ }, 2000)
3953
+ })
3954
+ .catch(() => setInstallingSensevoice(false))
3955
+ }
3364
3956
 
3365
- const textField = (key, label, hint) => React.createElement('div', { className: 'cb-field' },
3366
- React.createElement('label', null, label),
3367
- React.createElement('input', {
3368
- type: 'text', value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
3369
- onChange: (e) => setTop(key, e.target.value),
3370
- }),
3371
- React.createElement('span', { className: 'dvs-sub' }, hint),
3372
- )
3957
+ return { sensevoiceState, installingSensevoice, triggerInstallSensevoice }
3958
+ }
3373
3959
 
3374
- return React.createElement('div', { className: 'cb-section-card' },
3375
- React.createElement('div', { className: 'cb-section-title' },
3376
- React.createElement('span', null, '⚙️ ' + t('hardwareTitle')),
3377
- ),
3378
- React.createElement('div', { className: 'cb-section-desc' }, t('hardwareDesc')),
3379
- // Hotkey picker
3380
- React.createElement('div', { className: 'cb-field' },
3381
- React.createElement('label', null, t('hotkey')),
3382
- React.createElement('div', { className: 'cb-row' },
3383
- React.createElement('button', {
3384
- type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable,
3385
- onClick: () => setCatching(true),
3386
- }, catching ? t('pressKey') : keyLabel(draft && draft.hotkey)),
3387
- React.createElement('button', {
3388
- type: 'button', className: 'cb-btn cb-btn-mini', title: t('clearKey'),
3389
- disabled: !writable, onClick: onClearKey,
3390
- }, '×'),
3391
- ),
3392
- React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
3393
- ),
3394
- // Mic & noise gate
3395
- React.createElement('div', { className: 'cb-field' },
3396
- React.createElement('label', null, t('mic')),
3397
- React.createElement('div', { className: 'cb-row' },
3398
- React.createElement('select', {
3399
- value: String((draft && draft.micDeviceId) ?? ''), disabled: !writable || testingMic,
3400
- onChange: (e) => setTop('micDeviceId', e.target.value),
3401
- style: { flex: 1 },
3402
- },
3403
- React.createElement('option', { value: '' }, t('micDefault')),
3404
- devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
3405
- d.label || d.deviceId.slice(0, 12))),
3406
- ),
3407
- React.createElement('button', {
3408
- type: 'button',
3409
- className: 'cb-btn ' + (testingMic ? 'cb-btn-primary' : 'cb-btn-secondary'),
3410
- onClick: toggleTestMic,
3411
- style: { flex: 'none' },
3412
- }, testingMic ? t('stopTest') : t('testMic')),
3413
- React.createElement('button', {
3414
- type: 'button',
3415
- className: 'cb-btn cb-btn-secondary',
3416
- onClick: () => (typeof calibrateNoise === 'function' ? calibrateNoise(setTop, t) : null),
3417
- disabled: calibrating || !writable,
3418
- style: { flex: 'none' },
3419
- title: t('calibrateNoiseHint') || '',
3420
- }, calibrating ? t('calibrating') : t('calibrateNoise')),
3421
- ),
3422
- calibrateMsg ? React.createElement('div', {
3423
- style: { fontSize: '11px', color: 'var(--dsw-alias-state-success-primary)', marginTop: '4px' },
3424
- }, calibrateMsg) : null,
3425
- testingMic ? React.createElement('div', { className: 'dvo-mic-test' },
3426
- React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, t('micLevel') + ':'),
3427
- React.createElement('div', { className: 'dvo-meter-bar' },
3428
- React.createElement('div', { className: 'dvo-meter-fill', style: { width: `${testLevel}%` } }),
3429
- gateThresholdPercent > 0 ? React.createElement('div', { className: 'dvo-meter-gate', style: { left: `${gateThresholdPercent}%` }, title: `${gateDbVal} dB` }) : null,
3430
- ),
3431
- React.createElement('span', { style: { fontSize: '11px', fontVariantNumeric: 'tabular-nums', width: '32px', textAlign: 'right' } }, `${testLevel}%`),
3432
- ) : null,
3433
- React.createElement('div', { className: 'cb-row', style: { marginTop: '6px' } },
3434
- React.createElement('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)', minWidth: '120px' } }, t('noiseGate') + ':'),
3435
- React.createElement('select', {
3436
- value: String(gateDbVal), disabled: !writable,
3437
- onChange: (e) => setTop('noiseGateDb', Number(e.target.value)),
3438
- style: { flex: 1 },
3439
- },
3440
- React.createElement('option', { value: '-999' }, t('noiseGateOff')),
3441
- React.createElement('option', { value: '-50' }, '-50 dB (Low / Gentle)'),
3442
- React.createElement('option', { value: '-45' }, '-45 dB (Standard Default)'),
3443
- React.createElement('option', { value: '-35' }, '-35 dB (Medium Room)'),
3444
- React.createElement('option', { value: '-25' }, '-25 dB (High / Mechanical Clicks)'),
3445
- !['-999', '-50', '-45', '-35', '-25'].includes(String(gateDbVal)) ? React.createElement('option', { value: String(gateDbVal) }, `${gateDbVal} dB (${t('calibratedValue') || 'Calibrated'})`) : null,
3446
- ),
3447
- ),
3448
- React.createElement('span', { className: 'dvs-sub' }, t('noiseGateHint')),
3449
- ),
3450
- // Endpoint text fields
3451
- React.createElement('div', { className: 'cb-grid-2' },
3452
- textField('whisperUrl', t('whisperEndpoint'), t('whisperEndpointHint')),
3453
- textField('whisperBin', t('whisperBin'), t('whisperBinHint')),
3454
- textField('whisperModel', t('whisperModel'), t('whisperModelHint')),
3455
- textField('deepgramBaseUrl', t('deepgramEndpoint'), t('deepgramEndpointHint')),
3456
- textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
3457
- textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
3458
- textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceModelHint')),
3459
- textField('polishBaseUrl', t('polishBaseUrl'), t('polishBaseUrlHint')),
3460
- textField('polishModel', t('polishModel'), ''),
3461
- textField('polishKeyEnv', t('polishKeyEnv'), ''),
3462
- ),
3463
- // Engine checkboxes
3464
- React.createElement('div', { className: 'cb-row' },
3465
- React.createElement('label', { className: 'cb-check-label' },
3466
- React.createElement('input', {
3467
- type: 'checkbox', checked: !!(draft && draft.autoStart), disabled: !writable,
3468
- onChange: (e) => setTop('autoStart', e.target.checked),
3469
- }),
3470
- t('whisperAutostart'),
3471
- ),
3472
- React.createElement('label', { className: 'cb-check-label' },
3473
- React.createElement('input', {
3474
- type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
3475
- onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
3476
- }),
3477
- t('sensevoiceAutostart'),
3478
- ),
3479
- React.createElement('label', { className: 'cb-check-label' },
3480
- React.createElement('input', {
3481
- type: 'checkbox', checked: !!(draft && draft.localOnly), disabled: !writable,
3482
- onChange: (e) => setTop('localOnly', e.target.checked),
3483
- }),
3484
- t('localOnly'),
3485
- ),
3486
- React.createElement('label', { className: 'cb-check-label' },
3487
- React.createElement('input', {
3488
- type: 'checkbox', checked: !!(draft && draft.beep), disabled: !writable,
3489
- onChange: (e) => setTop('beep', e.target.checked),
3490
- }),
3491
- t('beep'),
3492
- ),
3493
- React.createElement('label', { className: 'cb-check-label' },
3494
- React.createElement('input', {
3495
- type: 'checkbox', checked: draft ? draft.noiseSuppression !== false : true, disabled: !writable,
3496
- onChange: (e) => setTop('noiseSuppression', e.target.checked),
3497
- }),
3498
- t('noiseSuppression'),
3499
- ),
3500
- React.createElement('label', { className: 'cb-check-label' },
3501
- React.createElement('input', {
3502
- type: 'checkbox', checked: draft ? draft.contextGlossary !== false : true, disabled: !writable,
3503
- onChange: (e) => setTop('contextGlossary', e.target.checked),
3504
- }),
3505
- t('contextGlossary'),
3506
- ),
3507
- React.createElement('label', { className: 'cb-check-label', title: t('bilingualDeveloperPromptHint') || '' },
3508
- React.createElement('input', {
3509
- type: 'checkbox', checked: draft ? draft.bilingualDeveloperPrompt !== false : true, disabled: !writable,
3510
- onChange: (e) => setTop('bilingualDeveloperPrompt', e.target.checked),
3511
- }),
3512
- t('bilingualDeveloperPrompt'),
3513
- ),
3514
- React.createElement('label', { className: 'cb-check-label' },
3515
- React.createElement('input', {
3516
- type: 'checkbox', checked: !!(draft && draft.voiceCommands), disabled: !writable,
3517
- onChange: (e) => setTop('voiceCommands', e.target.checked),
3518
- }),
3519
- t('voiceCommandsLabel'),
3520
- ),
3521
- React.createElement('label', { className: 'cb-check-label' },
3522
- React.createElement('input', {
3523
- type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
3524
- onChange: (e) => setTop('normalizeTranscript', e.target.checked),
3525
- }),
3526
- t('normalizeTranscript'),
3527
- ),
3528
- ),
3529
- // Visualizer select
3530
- React.createElement('div', { className: 'cb-field' },
3531
- React.createElement('label', null, t('visualizerStyle')),
3532
- React.createElement('select', {
3533
- value: String((draft && draft.visualizerStyle) || 'liquid-wave'), disabled: !writable,
3534
- onChange: (e) => setTop('visualizerStyle', e.target.value),
3535
- },
3536
- React.createElement('option', { value: 'liquid-wave' }, t('visLiquidWave')),
3537
- React.createElement('option', { value: 'dynamic-orb' }, t('visDynamicOrb')),
3538
- React.createElement('option', { value: 'bars' }, t('visBars')),
3539
- React.createElement('option', { value: 'off' }, t('visOff')),
3540
- ),
3541
- ),
3542
- // Hardware Acceleration (SBC NPU / Host)
3543
- React.createElement('div', { className: 'cb-field' },
3544
- React.createElement('label', null, t('npuProvider')),
3545
- React.createElement('div', { className: 'cb-row' },
3546
- React.createElement('select', {
3547
- value: String((draft && draft.sensevoiceProvider) || 'cpu'),
3548
- disabled: !writable,
3549
- onChange: (e) => setTop('sensevoiceProvider', e.target.value),
3550
- style: { flex: 1 },
3551
- },
3552
- React.createElement('option', { value: 'cpu' }, 'CPU (Standard)'),
3553
- React.createElement('option', { value: 'rknn' }, 'Rockchip RK3588 NPU (RKNN)'),
3554
- React.createElement('option', { value: 'coreml' }, 'Apple Silicon CoreML'),
3555
- React.createElement('option', { value: 'openvino' }, 'Intel OpenVINO'),
3556
- React.createElement('option', { value: 'cuda' }, 'NVIDIA CUDA'),
3557
- ),
3558
- React.createElement('input', {
3559
- type: 'number',
3560
- min: 1,
3561
- max: 32,
3562
- value: Number((draft && draft.sensevoiceThreads) || 4),
3563
- disabled: !writable,
3564
- title: t('npuThreads'),
3565
- style: { width: '80px' },
3566
- onChange: (e) => setTop('sensevoiceThreads', Math.max(1, Math.min(32, Number(e.target.value) || 4))),
3567
- }),
3568
- ),
3569
- React.createElement('span', { className: 'dvs-sub' }, t('npuProviderHint')),
3570
- ),
3571
- // Hardware Benchmark
3572
- React.createElement('div', { className: 'cb-field' },
3573
- React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', justifyContent: 'space-between' } },
3574
- React.createElement('label', { style: { marginBottom: 0 } }, t('benchmarkTitle')),
3575
- React.createElement('button', {
3576
- type: 'button', className: 'cb-btn cb-btn-secondary', style: { fontSize: '12px', padding: '4px 10px' },
3577
- disabled: benchmarking || !writable, onClick: runBenchmark,
3578
- }, benchmarking ? ('⏳ ' + t('benchmarking')) : ('⚡ ' + t('runBenchmarkBtn'))),
3579
- ),
3580
- benchmarkResult
3581
- ? React.createElement('div', {
3582
- style: { marginTop: '8px', padding: '6px 10px', borderRadius: '6px', fontSize: '12px', backgroundColor: 'var(--dsw-alias-fill-quaternary)' }
3583
- }, benchmarkResult.running
3584
- ? `⚡ RTF: ${benchmarkResult.rtf}x · ${benchmarkResult.latencyMs}ms (${benchmarkResult.rating}) · ${String(benchmarkResult.provider || 'cpu').toUpperCase()} · Cores: ${benchmarkResult.cpuCores}`
3585
- : `ℹ ${benchmarkResult.message || 'Offline'} (${benchmarkResult.cpuCores} cores, ${benchmarkResult.platform})`)
3586
- : (benchmarkErr ? React.createElement('div', { style: { color: 'var(--dsw-alias-state-danger)', fontSize: '12px', marginTop: '4px' } }, benchmarkErr) : null),
3587
- React.createElement('span', { className: 'dvs-sub' }, t('benchmarkHint')),
3588
- ),
3589
- // WebGPU Status Indicator
3590
- React.createElement('div', { className: 'cb-field' },
3591
- React.createElement('label', null, t('webgpuStatus')),
3592
- React.createElement('div', {
3593
- style: {
3594
- padding: '6px 10px',
3595
- borderRadius: '6px',
3596
- fontSize: '12px',
3597
- backgroundColor: 'var(--dsw-alias-fill-quaternary)',
3598
- color: (typeof isWebGpuSupported === 'function' && isWebGpuSupported())
3599
- ? 'var(--dsw-alias-state-success)'
3600
- : 'var(--dsw-alias-label-secondary)'
3601
- }
3602
- }, (typeof isWebGpuSupported === 'function' && isWebGpuSupported())
3603
- ? ('✓ ' + t('webgpuAvailable'))
3604
- : ('ℹ ' + t('webgpuNotAvailable'))),
3605
- ),
3606
- // Vocabulary textarea
3607
- React.createElement('div', { className: 'cb-field' },
3608
- React.createElement('label', null, t('vocabulary')),
3609
- React.createElement('textarea', {
3610
- rows: 3, disabled: !writable,
3611
- value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
3612
- onChange: (e) => setTop('vocabulary', e.target.value.split('\n').map((x) => x.trim()).filter(Boolean)),
3613
- }),
3614
- ),
3615
- )
3960
+ function useMicTester(draft, value, setErr) {
3961
+ const [testingMic, setTestingMic] = React.useState(false)
3962
+ const [testLevel, setTestLevel] = React.useState(0)
3963
+ const [calibrating, setCalibrating] = React.useState(false)
3964
+ const [calibrateMsg, setCalibrateMsg] = React.useState('')
3965
+ const testRef = React.useRef(null)
3966
+
3967
+ const calibrateNoise = async (setTop, translate) => {
3968
+ if (calibrating) return
3969
+ setCalibrating(true)
3970
+ setCalibrateMsg('')
3971
+ let stream = null
3972
+ let audioCtx = null
3973
+ try {
3974
+ const devId = (draft && draft.micDeviceId) || (value && value.micDeviceId) || ''
3975
+ const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
3976
+ if (devId) audio.deviceId = { exact: devId }
3977
+ stream = await navigator.mediaDevices.getUserMedia({ audio })
3978
+ const AC = typeof AudioContext !== 'undefined' ? AudioContext : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
3979
+ if (!AC) throw new Error('AudioContext not supported')
3980
+ audioCtx = new AC()
3981
+ if (audioCtx.state === 'suspended') await audioCtx.resume().catch(() => {})
3982
+ const src = audioCtx.createMediaStreamSource(stream)
3983
+ const analyser = audioCtx.createAnalyser()
3984
+ analyser.fftSize = 512
3985
+ src.connect(analyser)
3986
+
3987
+ const samples = []
3988
+ const buf = new Float32Array(analyser.fftSize)
3989
+ const startTime = Date.now()
3990
+
3991
+ await new Promise((resolve) => {
3992
+ const interval = setInterval(() => {
3993
+ if (Date.now() - startTime >= 2000) {
3994
+ clearInterval(interval)
3995
+ resolve()
3996
+ return
3997
+ }
3998
+ analyser.getFloatTimeDomainData(buf)
3999
+ let sum = 0
4000
+ for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i]
4001
+ const rms = Math.sqrt(sum / buf.length)
4002
+ if (rms > 0.00001) {
4003
+ samples.push(20 * Math.log10(rms))
4004
+ }
4005
+ }, 50)
4006
+ })
4007
+
4008
+ const avgDb = samples.length > 0 ? (samples.reduce((a, b) => a + b, 0) / samples.length) : -50
4009
+ const roundedAvg = Math.round(avgDb)
4010
+ const recommended = Math.min(-25, Math.max(-55, roundedAvg + 5))
4011
+ if (typeof setTop === 'function') setTop('noiseGateDb', recommended)
4012
+
4013
+ const tFn = typeof translate === 'function' ? translate : (k) => k
4014
+ const msg = (tFn('calibratedMsg') || 'Gate set: {db} dB (ambient: {noise} dB)')
4015
+ .replace('{db}', String(recommended))
4016
+ .replace('{noise}', String(roundedAvg))
4017
+ setCalibrateMsg(msg)
4018
+ setTimeout(() => setCalibrateMsg(''), 5000)
4019
+ } catch (e) {
4020
+ if (typeof setErr === 'function') setErr(String(e && e.message ? e.message : e))
4021
+ } finally {
4022
+ if (stream) {
4023
+ try { stream.getTracks().forEach((t) => t.stop()) } catch (e) {}
4024
+ }
4025
+ if (audioCtx) {
4026
+ try { audioCtx.close() } catch (e) {}
4027
+ }
4028
+ setCalibrating(false)
3616
4029
  }
3617
- // Custom hooks for VoiceSection modularity
3618
- function useSensevoiceInstaller(fetchStatus) {
3619
- const [sensevoiceState, setSensevoiceState] = React.useState(null)
3620
- const [installingSensevoice, setInstallingSensevoice] = React.useState(false)
3621
-
3622
- const fetchSensevoice = () => {
3623
- fetch('/dsh-voice/sensevoice-installer')
3624
- .then((r) => r.json())
3625
- .then((d) => {
3626
- setSensevoiceState(d)
3627
- setInstallingSensevoice(!!(d && d.installing))
3628
- })
3629
- .catch(() => {})
3630
- }
3631
-
3632
- React.useEffect(() => {
3633
- fetchSensevoice()
3634
- }, [])
3635
-
3636
- const triggerInstallSensevoice = (modelType = 'sensevoice') => {
3637
- setInstallingSensevoice(true)
3638
- fetch('/dsh-voice/sensevoice-installer', {
3639
- method: 'POST',
3640
- headers: {
3641
- 'x-dsh-plugin-update': '1',
3642
- 'Content-Type': 'application/json',
3643
- },
3644
- body: JSON.stringify({ model: modelType }),
3645
- })
3646
- .then((r) => r.json())
3647
- .then(() => {
3648
- const timer = setInterval(() => {
3649
- fetch('/dsh-voice/sensevoice-installer')
3650
- .then((r) => r.json())
3651
- .then((d) => {
3652
- setSensevoiceState(d)
3653
- if (!d.installing) {
3654
- clearInterval(timer)
3655
- setInstallingSensevoice(false)
3656
- if (typeof fetchStatus === 'function') fetchStatus()
3657
- }
3658
- })
3659
- .catch(() => clearInterval(timer))
3660
- }, 2000)
3661
- })
3662
- .catch(() => setInstallingSensevoice(false))
3663
- }
3664
-
3665
- return { sensevoiceState, installingSensevoice, triggerInstallSensevoice }
4030
+ }
4031
+
4032
+ const toggleTestMic = async () => {
4033
+ if (testingMic) {
4034
+ if (testRef.current) {
4035
+ testRef.current.active = false
4036
+ try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
4037
+ if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) { /* already closed */ } }
4038
+ testRef.current = null
4039
+ }
4040
+ setTestingMic(false)
4041
+ setTestLevel(0)
4042
+ return
4043
+ }
4044
+ try {
4045
+ const devId = (draft && draft.micDeviceId) || (value && value.micDeviceId) || ''
4046
+ const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
4047
+ if (devId) audio.deviceId = { exact: devId }
4048
+ const stream = await navigator.mediaDevices.getUserMedia({ audio })
4049
+ const AC = typeof AudioContext !== 'undefined' ? AudioContext : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
4050
+ if (!AC) return
4051
+ const audioCtx = new AC()
4052
+ if (audioCtx.state === 'suspended') await audioCtx.resume().catch(() => {})
4053
+ const src = audioCtx.createMediaStreamSource(stream)
4054
+ let lastNode = src
4055
+ try {
4056
+ const filter = audioCtx.createBiquadFilter()
4057
+ filter.type = 'highpass'
4058
+ filter.frequency.value = 80
4059
+ filter.Q.value = 0.707
4060
+ src.connect(filter)
4061
+ lastNode = filter
4062
+ } catch (e) { /* filter fallback */ }
4063
+ const analyser = audioCtx.createAnalyser()
4064
+ analyser.fftSize = 128
4065
+ lastNode.connect(analyser)
4066
+ testRef.current = { stream, audioCtx, analyser, active: true }
4067
+ setTestingMic(true)
4068
+
4069
+ const check = () => {
4070
+ if (!testRef.current || !testRef.current.active) return
4071
+ const arr = new Uint8Array(analyser.frequencyBinCount)
4072
+ analyser.getByteFrequencyData(arr)
4073
+ let sum = 0
4074
+ for (let i = 0; i < arr.length; i++) sum += arr[i]
4075
+ const avg = sum / (arr.length || 1)
4076
+ setTestLevel(Math.min(100, Math.round((avg / 128) * 100)))
4077
+ requestAnimationFrame(check)
4078
+ }
4079
+ requestAnimationFrame(check)
4080
+ } catch (e) {
4081
+ if (typeof setErr === 'function') setErr(String(e && e.message ? e.message : e))
3666
4082
  }
4083
+ }
3667
4084
 
3668
- function useMicTester(draft, value, setErr) {
3669
- const [testingMic, setTestingMic] = React.useState(false)
3670
- const [testLevel, setTestLevel] = React.useState(0)
3671
- const [calibrating, setCalibrating] = React.useState(false)
3672
- const [calibrateMsg, setCalibrateMsg] = React.useState('')
3673
- const testRef = React.useRef(null)
3674
-
3675
- const calibrateNoise = async (setTop, translate) => {
3676
- if (calibrating) return
3677
- setCalibrating(true)
3678
- setCalibrateMsg('')
3679
- let stream = null
3680
- let audioCtx = null
3681
- try {
3682
- const devId = (draft && draft.micDeviceId) || (value && value.micDeviceId) || ''
3683
- const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
3684
- if (devId) audio.deviceId = { exact: devId }
3685
- stream = await navigator.mediaDevices.getUserMedia({ audio })
3686
- const AC = typeof AudioContext !== 'undefined' ? AudioContext : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
3687
- if (!AC) throw new Error('AudioContext not supported')
3688
- audioCtx = new AC()
3689
- if (audioCtx.state === 'suspended') await audioCtx.resume().catch(() => {})
3690
- const src = audioCtx.createMediaStreamSource(stream)
3691
- const analyser = audioCtx.createAnalyser()
3692
- analyser.fftSize = 512
3693
- src.connect(analyser)
3694
-
3695
- const samples = []
3696
- const buf = new Float32Array(analyser.fftSize)
3697
- const startTime = Date.now()
3698
-
3699
- await new Promise((resolve) => {
3700
- const interval = setInterval(() => {
3701
- if (Date.now() - startTime >= 2000) {
3702
- clearInterval(interval)
3703
- resolve()
3704
- return
3705
- }
3706
- analyser.getFloatTimeDomainData(buf)
3707
- let sum = 0
3708
- for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i]
3709
- const rms = Math.sqrt(sum / buf.length)
3710
- if (rms > 0.00001) {
3711
- samples.push(20 * Math.log10(rms))
3712
- }
3713
- }, 50)
3714
- })
4085
+ React.useEffect(() => {
4086
+ return () => {
4087
+ if (testRef.current) {
4088
+ testRef.current.active = false
4089
+ try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
4090
+ if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) { /* already closed */ } }
4091
+ testRef.current = null
4092
+ }
4093
+ }
4094
+ }, [])
3715
4095
 
3716
- const avgDb = samples.length > 0 ? (samples.reduce((a, b) => a + b, 0) / samples.length) : -50
3717
- const roundedAvg = Math.round(avgDb)
3718
- const recommended = Math.min(-25, Math.max(-55, roundedAvg + 5))
3719
- if (typeof setTop === 'function') setTop('noiseGateDb', recommended)
3720
-
3721
- const tFn = typeof translate === 'function' ? translate : (k) => k
3722
- const msg = (tFn('calibratedMsg') || 'Gate set: {db} dB (ambient: {noise} dB)')
3723
- .replace('{db}', String(recommended))
3724
- .replace('{noise}', String(roundedAvg))
3725
- setCalibrateMsg(msg)
3726
- setTimeout(() => setCalibrateMsg(''), 5000)
3727
- } catch (e) {
3728
- if (typeof setErr === 'function') setErr(String(e && e.message ? e.message : e))
3729
- } finally {
3730
- if (stream) {
3731
- try { stream.getTracks().forEach((t) => t.stop()) } catch (e) {}
3732
- }
3733
- if (audioCtx) {
3734
- try { audioCtx.close() } catch (e) {}
3735
- }
3736
- setCalibrating(false)
3737
- }
3738
- }
4096
+ const gateDbVal = Number((draft && draft.noiseGateDb !== undefined) ? draft.noiseGateDb : (value && value.noiseGateDb !== undefined) ? value.noiseGateDb : -45)
4097
+ const gateThresholdPercent = gateDbVal <= -90 ? 0 : Math.min(100, Math.max(0, Math.round(((gateDbVal + 60) / 40) * 100)))
3739
4098
 
3740
- const toggleTestMic = async () => {
3741
- if (testingMic) {
3742
- if (testRef.current) {
3743
- testRef.current.active = false
3744
- try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
3745
- if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) { /* already closed */ } }
3746
- testRef.current = null
3747
- }
3748
- setTestingMic(false)
3749
- setTestLevel(0)
3750
- return
3751
- }
3752
- try {
3753
- const devId = (draft && draft.micDeviceId) || (value && value.micDeviceId) || ''
3754
- const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
3755
- if (devId) audio.deviceId = { exact: devId }
3756
- const stream = await navigator.mediaDevices.getUserMedia({ audio })
3757
- const AC = typeof AudioContext !== 'undefined' ? AudioContext : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
3758
- if (!AC) return
3759
- const audioCtx = new AC()
3760
- if (audioCtx.state === 'suspended') await audioCtx.resume().catch(() => {})
3761
- const src = audioCtx.createMediaStreamSource(stream)
3762
- let lastNode = src
3763
- try {
3764
- const filter = audioCtx.createBiquadFilter()
3765
- filter.type = 'highpass'
3766
- filter.frequency.value = 80
3767
- filter.Q.value = 0.707
3768
- src.connect(filter)
3769
- lastNode = filter
3770
- } catch (e) { /* filter fallback */ }
3771
- const analyser = audioCtx.createAnalyser()
3772
- analyser.fftSize = 128
3773
- lastNode.connect(analyser)
3774
- testRef.current = { stream, audioCtx, analyser, active: true }
3775
- setTestingMic(true)
3776
-
3777
- const check = () => {
3778
- if (!testRef.current || !testRef.current.active) return
3779
- const arr = new Uint8Array(analyser.frequencyBinCount)
3780
- analyser.getByteFrequencyData(arr)
3781
- let sum = 0
3782
- for (let i = 0; i < arr.length; i++) sum += arr[i]
3783
- const avg = sum / (arr.length || 1)
3784
- setTestLevel(Math.min(100, Math.round((avg / 128) * 100)))
3785
- requestAnimationFrame(check)
3786
- }
3787
- requestAnimationFrame(check)
3788
- } catch (e) {
3789
- if (typeof setErr === 'function') setErr(String(e && e.message ? e.message : e))
3790
- }
3791
- }
4099
+ return { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent, calibrating, calibrateMsg, calibrateNoise }
4100
+ }
3792
4101
 
3793
- React.useEffect(() => {
3794
- return () => {
3795
- if (testRef.current) {
3796
- testRef.current.active = false
3797
- try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
3798
- if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) { /* already closed */ } }
3799
- testRef.current = null
3800
- }
3801
- }
3802
- }, [])
4102
+ function usePluginUpdater() {
4103
+ const [updaterStatus, setUpdaterStatus] = React.useState(null)
4104
+ const [updating, setUpdating] = React.useState(false)
4105
+ const [updaterMsg, setUpdaterMsg] = React.useState('')
4106
+ const [updaterMsgKind, setUpdaterMsgKind] = React.useState('')
4107
+ const [updaterLoading, setUpdaterLoading] = React.useState(false)
4108
+
4109
+ const checkUpdater = React.useCallback(async () => {
4110
+ setUpdaterLoading(true)
4111
+ setUpdaterMsg('')
4112
+ setUpdaterMsgKind('')
4113
+ try {
4114
+ const res = await fetch('/api/dsh-voice/update', {
4115
+ headers: { accept: 'application/json' },
4116
+ cache: 'no-store',
4117
+ })
4118
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
4119
+ const data = await res.json()
4120
+ setUpdaterStatus(data)
4121
+ if (data && data.latestCheckFailed) {
4122
+ setUpdaterMsg(t('updateCheckFailed'))
4123
+ setUpdaterMsgKind('err')
4124
+ }
4125
+ } catch (e) {
4126
+ setUpdaterStatus(null)
4127
+ setUpdaterMsg(t('updateCheckFailed') + ': ' + (e?.message || String(e)))
4128
+ setUpdaterMsgKind('err')
4129
+ } finally {
4130
+ setUpdaterLoading(false)
4131
+ }
4132
+ }, [])
3803
4133
 
3804
- const gateDbVal = Number((draft && draft.noiseGateDb !== undefined) ? draft.noiseGateDb : (value && value.noiseGateDb !== undefined) ? value.noiseGateDb : -45)
3805
- const gateThresholdPercent = gateDbVal <= -90 ? 0 : Math.min(100, Math.max(0, Math.round(((gateDbVal + 60) / 40) * 100)))
3806
-
3807
- return { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent, calibrating, calibrateMsg, calibrateNoise }
3808
- }
3809
-
3810
- function usePluginUpdater() {
3811
- const [updaterStatus, setUpdaterStatus] = React.useState(null)
3812
- const [updating, setUpdating] = React.useState(false)
3813
- const [updaterMsg, setUpdaterMsg] = React.useState('')
3814
- const [updaterMsgKind, setUpdaterMsgKind] = React.useState('')
3815
- const [updaterLoading, setUpdaterLoading] = React.useState(false)
3816
-
3817
- const checkUpdater = React.useCallback(async () => {
3818
- setUpdaterLoading(true)
3819
- setUpdaterMsg('')
3820
- setUpdaterMsgKind('')
3821
- try {
3822
- const res = await fetch('/api/dsh-voice/update', {
3823
- headers: { accept: 'application/json' },
3824
- cache: 'no-store',
3825
- })
3826
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
3827
- const data = await res.json()
3828
- setUpdaterStatus(data)
3829
- if (data && data.latestCheckFailed) {
3830
- setUpdaterMsg(t('updateCheckFailed'))
3831
- setUpdaterMsgKind('err')
3832
- }
3833
- } catch (e) {
3834
- setUpdaterStatus(null)
3835
- setUpdaterMsg(t('updateCheckFailed') + ': ' + (e?.message || String(e)))
3836
- setUpdaterMsgKind('err')
3837
- } finally {
3838
- setUpdaterLoading(false)
3839
- }
3840
- }, [])
3841
-
3842
- const onUpdateNow = async () => {
3843
- if (updating) return
3844
- setUpdating(true)
3845
- setUpdaterMsg('')
3846
- setUpdaterMsgKind('')
3847
- try {
3848
- const res = await fetch('/api/dsh-voice/update', {
3849
- method: 'POST',
3850
- headers: {
3851
- 'content-type': 'application/json',
3852
- 'x-dsh-plugin-update': '1',
3853
- },
3854
- cache: 'no-store',
3855
- })
3856
- const data = await res.json().catch(() => ({}))
3857
- if (!res.ok || data.ok === false) {
3858
- if (data.code) {
3859
- const base = t('profileLockCheckError')
3860
- throw new Error(base ? `${base} (${data.code})` : (data.error || `HTTP ${res.status}`))
3861
- }
3862
- if (data.locked) {
3863
- const key = data.pid ? 'profileLockedPid' : 'profileLockedExisting'
3864
- const translated = t(key)
3865
- if (translated && translated !== key) {
3866
- throw new Error(data.pid ? translated.replace('{pid}', String(data.pid)) : translated)
3867
- }
3868
- }
3869
- throw new Error(data.error || `HTTP ${res.status}`)
3870
- }
3871
- setUpdaterMsg(data.message || t('updateSuccess'))
3872
- setUpdaterMsgKind('ok')
3873
- checkUpdater()
3874
- } catch (err) {
3875
- setUpdaterMsg(err && err.message ? err.message : String(err))
3876
- setUpdaterMsgKind('err')
3877
- } finally {
3878
- setUpdating(false)
3879
- }
4134
+ const onUpdateNow = async () => {
4135
+ if (updating) return
4136
+ setUpdating(true)
4137
+ setUpdaterMsg('')
4138
+ setUpdaterMsgKind('')
4139
+ try {
4140
+ const res = await fetch('/api/dsh-voice/update', {
4141
+ method: 'POST',
4142
+ headers: {
4143
+ 'content-type': 'application/json',
4144
+ 'x-dsh-plugin-update': '1',
4145
+ },
4146
+ cache: 'no-store',
4147
+ })
4148
+ const data = await res.json().catch(() => ({}))
4149
+ if (!res.ok || data.ok === false) {
4150
+ if (data.code) {
4151
+ const base = t('profileLockCheckError')
4152
+ throw new Error(base ? `${base} (${data.code})` : (data.error || `HTTP ${res.status}`))
3880
4153
  }
3881
-
3882
- return { updaterStatus, updating, updaterMsg, updaterMsgKind, updaterLoading, checkUpdater, onUpdateNow }
4154
+ if (data.locked) {
4155
+ const key = data.pid ? 'profileLockedPid' : 'profileLockedExisting'
4156
+ const translated = t(key)
4157
+ if (translated && translated !== key) {
4158
+ throw new Error(data.pid ? translated.replace('{pid}', String(data.pid)) : translated)
4159
+ }
4160
+ }
4161
+ throw new Error(data.error || `HTTP ${res.status}`)
4162
+ }
4163
+ setUpdaterMsg(data.message || t('updateSuccess'))
4164
+ setUpdaterMsgKind('ok')
4165
+ checkUpdater()
4166
+ } catch (err) {
4167
+ setUpdaterMsg(err && err.message ? err.message : String(err))
4168
+ setUpdaterMsgKind('err')
4169
+ } finally {
4170
+ setUpdating(false)
3883
4171
  }
4172
+ }
4173
+
4174
+ return { updaterStatus, updating, updaterMsg, updaterMsgKind, updaterLoading, checkUpdater, onUpdateNow }
4175
+ }
3884
4176
  // Developer Lexicon & IT Jargon
3885
4177
  function JargonEditor(props) {
3886
4178
  const t = props.t || moduleT
@@ -3890,12 +4182,12 @@ function DictationModesSection(props) {
3890
4182
  const jargonInput = props.jargonInput
3891
4183
  const setJargonInput = props.setJargonInput
3892
4184
 
3893
- return React.createElement('div', { className: 'cb-section-card' },
3894
- React.createElement('div', { className: 'cb-section-title' },
3895
- React.createElement('span', null, '📖 ' + t('jargonTitle')),
4185
+ return h('div', { className: 'cb-section-card' },
4186
+ h('div', { className: 'cb-section-title' },
4187
+ h('span', null, '📖 ' + t('jargonTitle')),
3896
4188
  ),
3897
- React.createElement('label', { className: 'cb-check-label', style: { marginBottom: '8px' } },
3898
- React.createElement('input', {
4189
+ h('label', { className: 'cb-check-label', style: { marginBottom: '8px' } },
4190
+ h('input', {
3899
4191
  type: 'checkbox',
3900
4192
  checked: draft ? draft.techJargonCorrection !== false : true,
3901
4193
  disabled: !writable,
@@ -3903,9 +4195,9 @@ function DictationModesSection(props) {
3903
4195
  }),
3904
4196
  t('techJargonCorrection'),
3905
4197
  ),
3906
- React.createElement('div', { className: 'cb-field' },
3907
- React.createElement('label', null, t('jargonAdd') + t('jargonFormatHint')),
3908
- React.createElement('textarea', {
4198
+ h('div', { className: 'cb-field' },
4199
+ h('label', null, t('jargonAdd') + t('jargonFormatHint')),
4200
+ h('textarea', {
3909
4201
  rows: 3,
3910
4202
  disabled: !writable,
3911
4203
  placeholder: t('jargonPlaceholder'),
@@ -3957,10 +4249,10 @@ function MacrosSection(props) {
3957
4249
  setTop('voiceMacros', next)
3958
4250
  }
3959
4251
 
3960
- return React.createElement('div', { className: 'cb-section-card' },
3961
- React.createElement('div', { className: 'cb-section-title' },
3962
- React.createElement('span', null, '⚡ ' + t('voiceMacrosTitle')),
3963
- React.createElement('button', {
4252
+ return h('div', { className: 'cb-section-card' },
4253
+ h('div', { className: 'cb-section-title' },
4254
+ h('span', null, '⚡ ' + t('voiceMacrosTitle')),
4255
+ h('button', {
3964
4256
  type: 'button',
3965
4257
  className: 'cb-btn cb-btn-secondary',
3966
4258
  style: { fontSize: '11px', padding: '2px 8px' },
@@ -3968,16 +4260,16 @@ function MacrosSection(props) {
3968
4260
  onClick: addMacro,
3969
4261
  }, '+ ' + t('addMacro')),
3970
4262
  ),
3971
- React.createElement('div', { className: 'cb-field-desc' }, t('voiceMacrosDesc')),
4263
+ h('div', { className: 'cb-field-desc' }, t('voiceMacrosDesc')),
3972
4264
  macros.length === 0
3973
- ? React.createElement('div', { style: { fontStyle: 'italic', color: 'var(--dsw-alias-label-tertiary)', fontSize: '12px', marginTop: '6px' } },
4265
+ ? h('div', { style: { fontStyle: 'italic', color: 'var(--dsw-alias-label-tertiary)', fontSize: '12px', marginTop: '6px' } },
3974
4266
  'No macros configured.',
3975
4267
  )
3976
- : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '6px' } },
4268
+ : h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '6px' } },
3977
4269
  macros.map((m, idx) =>
3978
- React.createElement('div', { key: idx, className: 'dvo-macro-item' },
3979
- React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
3980
- React.createElement('input', {
4270
+ h('div', { key: idx, className: 'dvo-macro-item' },
4271
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
4272
+ h('input', {
3981
4273
  type: 'text',
3982
4274
  className: 'cb-input',
3983
4275
  style: { flex: '1', height: '28px', fontSize: '12px' },
@@ -3986,7 +4278,7 @@ function MacrosSection(props) {
3986
4278
  disabled: !writable,
3987
4279
  onChange: (e) => updateMacro(idx, 'trigger', e.target.value),
3988
4280
  }),
3989
- React.createElement('button', {
4281
+ h('button', {
3990
4282
  type: 'button',
3991
4283
  className: 'cb-btn cb-btn-secondary',
3992
4284
  style: { padding: '2px 6px', fontSize: '11px', color: 'var(--dsw-alias-state-error-primary)' },
@@ -3994,7 +4286,7 @@ function MacrosSection(props) {
3994
4286
  onClick: () => removeMacro(idx),
3995
4287
  }, t('macroDelete')),
3996
4288
  ),
3997
- React.createElement('textarea', {
4289
+ h('textarea', {
3998
4290
  className: 'cb-input',
3999
4291
  style: { minHeight: '38px', resize: 'vertical', fontSize: '12px' },
4000
4292
  placeholder: t('macroTemplate') + ' (use {text} for argument)',
@@ -4022,24 +4314,24 @@ function MacrosSection(props) {
4022
4314
  ? (t('sensevoiceReady') + (currentType === 'whisper-turbo' ? ' (whisper-turbo)' : ' (small-int8)'))
4023
4315
  : (installingSensevoice ? t('sensevoiceDownloading') : t('sensevoiceNotInstalled'))
4024
4316
 
4025
- return React.createElement('div', { className: 'cb-section-card' },
4026
- React.createElement('div', { className: 'cb-section-title' },
4027
- React.createElement('span', null, '⚡ ' + t('sensevoiceTitle')),
4028
- React.createElement('span', { className: 'cb-badge ' + (isInstalled ? 'cb-badge-ok' : 'cb-badge-warn') }, badgeText),
4317
+ return h('div', { className: 'cb-section-card' },
4318
+ h('div', { className: 'cb-section-title' },
4319
+ h('span', null, '⚡ ' + t('sensevoiceTitle')),
4320
+ h('span', { className: 'cb-badge ' + (isInstalled ? 'cb-badge-ok' : 'cb-badge-warn') }, badgeText),
4029
4321
  ),
4030
- React.createElement('div', { className: 'cb-section-desc' }, t('sensevoiceDesc')),
4031
- React.createElement('div', { className: 'dvo-installer-box' },
4032
- React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', justifyContent: 'space-between', gap: '12px' } },
4033
- React.createElement('select', {
4322
+ h('div', { className: 'cb-section-desc' }, t('sensevoiceDesc')),
4323
+ h('div', { className: 'dvo-installer-box' },
4324
+ h('div', { className: 'cb-row', style: { alignItems: 'center', justifyContent: 'space-between', gap: '12px' } },
4325
+ h('select', {
4034
4326
  value: modelChoice,
4035
4327
  disabled: installingSensevoice || !writable,
4036
4328
  onChange: (e) => setModelChoice(e.target.value),
4037
4329
  style: { flex: 1, fontSize: '12px', minWidth: '180px' },
4038
4330
  },
4039
- React.createElement('option', { value: 'sensevoice' }, 'SenseVoice-Small (150MB, ~50ms)'),
4040
- React.createElement('option', { value: 'whisper-turbo' }, 'Whisper Large-v3-Turbo (800MB, High-Accuracy)'),
4331
+ h('option', { value: 'sensevoice' }, 'SenseVoice-Small (150MB, ~50ms)'),
4332
+ h('option', { value: 'whisper-turbo' }, 'Whisper Large-v3-Turbo (800MB, High-Accuracy)'),
4041
4333
  ),
4042
- React.createElement('button', {
4334
+ h('button', {
4043
4335
  type: 'button',
4044
4336
  className: 'cb-btn cb-btn-primary',
4045
4337
  style: { fontSize: '12px', padding: '6px 12px', whiteSpace: 'nowrap' },
@@ -4047,17 +4339,17 @@ function MacrosSection(props) {
4047
4339
  onClick: () => { if (typeof onInstall === 'function') onInstall(modelChoice) },
4048
4340
  }, installingSensevoice ? ('⏳ ' + t('installingSensevoice')) : ('📥 ' + t('installSensevoice'))),
4049
4341
  ),
4050
- React.createElement('div', { style: { fontSize: '11px', marginTop: '6px', color: 'var(--dsw-alias-content-secondary)' } },
4342
+ h('div', { style: { fontSize: '11px', marginTop: '6px', color: 'var(--dsw-alias-content-secondary)' } },
4051
4343
  sensevoiceState && sensevoiceState.path
4052
4344
  ? ('~/' + sensevoiceState.path.replace(/^.*[\\/](\.dsh[\\/]models[\\/].*)$/, '$1'))
4053
4345
  : ('~/.dsh/models/' + modelChoice)
4054
4346
  ),
4055
4347
  installingSensevoice
4056
- ? React.createElement('div', { style: { marginTop: '8px' } },
4057
- React.createElement('div', { className: 'dvo-progress-bar' },
4058
- React.createElement('div', { className: 'dvo-progress-bar-fill' })
4348
+ ? h('div', { style: { marginTop: '8px' } },
4349
+ h('div', { className: 'dvo-progress-bar' },
4350
+ h('div', { className: 'dvo-progress-bar-fill' })
4059
4351
  ),
4060
- React.createElement('div', { style: { fontSize: '11px', marginTop: '4px', color: 'var(--dsw-alias-content-secondary)' } },
4352
+ h('div', { style: { fontSize: '11px', marginTop: '4px', color: 'var(--dsw-alias-content-secondary)' } },
4061
4353
  (sensevoiceState && sensevoiceState.progress) || t('sensevoiceDownloading')
4062
4354
  )
4063
4355
  )
@@ -4075,28 +4367,28 @@ function MacrosSection(props) {
4075
4367
  const onRefresh = props.onRefresh
4076
4368
  const onOpenWizard = props.onOpenWizard
4077
4369
 
4078
- return React.createElement('div', { className: 'cb-section-card' },
4079
- React.createElement('div', { className: 'cb-section-title' },
4080
- React.createElement('span', null, t('statusTitle')),
4081
- React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
4082
- onOpenWizard ? React.createElement('button', {
4370
+ return h('div', { className: 'cb-section-card' },
4371
+ h('div', { className: 'cb-section-title' },
4372
+ h('span', null, t('statusTitle')),
4373
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
4374
+ onOpenWizard ? h('button', {
4083
4375
  type: 'button', className: 'cb-btn cb-btn-primary', style: { fontSize: '12px', padding: '4px 10px' },
4084
4376
  onClick: onOpenWizard,
4085
4377
  }, '✨ ' + t('wizardBtnLaunch')) : null,
4086
- React.createElement('button', {
4378
+ h('button', {
4087
4379
  type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
4088
4380
  onClick: onRefresh,
4089
4381
  }, '🔄 ' + t('refreshStats')),
4090
4382
  ),
4091
4383
  ),
4092
- React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px', flexWrap: 'wrap' } },
4093
- React.createElement('span', { className: 'cb-badge ' + (hostOnline ? 'cb-badge-ok' : 'cb-badge-err') },
4384
+ h('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px', flexWrap: 'wrap' } },
4385
+ h('span', { className: 'cb-badge ' + (hostOnline ? 'cb-badge-ok' : 'cb-badge-err') },
4094
4386
  hostOnline ? (`✓ ${t('hostOnline')} (${statusLatency}ms)`) : (`✗ ${t('hostOffline')}`)
4095
4387
  ),
4096
- React.createElement('span', { className: 'cb-badge ' + (whisperRunning ? 'cb-badge-ok' : 'cb-badge-warn') },
4388
+ h('span', { className: 'cb-badge ' + (whisperRunning ? 'cb-badge-ok' : 'cb-badge-warn') },
4097
4389
  whisperRunning ? (`✓ ${t('whisperReady')}`) : (`· ${t('whisperStopped')}`)
4098
4390
  ),
4099
- React.createElement('span', { className: 'cb-badge' }, `${providerCount} ${t('providersAvailable')}`),
4391
+ h('span', { className: 'cb-badge' }, `${providerCount} ${t('providersAvailable')}`),
4100
4392
  ),
4101
4393
  )
4102
4394
  }
@@ -4111,43 +4403,43 @@ function MacrosSection(props) {
4111
4403
  const onCheck = props.onCheck
4112
4404
  const onUpdateNow = props.onUpdateNow
4113
4405
 
4114
- return React.createElement('div', { className: 'cb-section-card' },
4115
- React.createElement('div', { className: 'cb-section-title' },
4116
- React.createElement('span', null, '📦 ' + t('versionTitle')),
4117
- React.createElement('button', {
4406
+ return h('div', { className: 'cb-section-card' },
4407
+ h('div', { className: 'cb-section-title' },
4408
+ h('span', null, '📦 ' + t('versionTitle')),
4409
+ h('button', {
4118
4410
  type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
4119
4411
  onClick: onCheck, disabled: updaterLoading || updating,
4120
4412
  }, updaterLoading ? '⏳…' : '🔄 ' + t('refreshStats')),
4121
4413
  ),
4122
- React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
4123
- React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
4414
+ h('div', { className: 'cb-section-desc' }, t('versionHint')),
4415
+ h('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
4124
4416
  updaterStatus && updaterStatus.currentVersion
4125
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, 'v' + updaterStatus.currentVersion)
4417
+ ? h('span', { className: 'cb-badge cb-badge-ok' }, 'v' + updaterStatus.currentVersion)
4126
4418
  : null,
4127
4419
  updaterLoading && (!updaterStatus || !updaterStatus.currentVersion)
4128
- ? React.createElement('span', { className: 'cb-badge' }, '⏳ ' + t('checkingUpdates'))
4420
+ ? h('span', { className: 'cb-badge' }, '⏳ ' + t('checkingUpdates'))
4129
4421
  : !updaterStatus
4130
- ? React.createElement('span', { className: 'cb-badge cb-badge-warn' }, '❓ ' + t('updateStatusUnknown'))
4422
+ ? h('span', { className: 'cb-badge cb-badge-warn' }, '❓ ' + t('updateStatusUnknown'))
4131
4423
  : updaterStatus.updateAvailable
4132
- ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
4424
+ ? h('span', { className: 'cb-badge cb-badge-warn' },
4133
4425
  t('updateAvailable') + ': v' + updaterStatus.latestVersion
4134
4426
  )
4135
4427
  : updaterStatus.latestCheckFailed
4136
- ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
4428
+ ? h('span', { className: 'cb-badge cb-badge-warn' },
4137
4429
  '⚠️ ' + t('updateCheckFailed')
4138
4430
  )
4139
- : React.createElement('span', { className: 'cb-badge cb-badge-ok' },
4431
+ : h('span', { className: 'cb-badge cb-badge-ok' },
4140
4432
  t('upToDate')
4141
4433
  ),
4142
4434
  updaterStatus && updaterStatus.updateAvailable
4143
- ? React.createElement('button', {
4435
+ ? h('button', {
4144
4436
  type: 'button', className: 'cb-btn cb-btn-primary', style: { fontSize: '12px', padding: '4px 10px' },
4145
4437
  onClick: onUpdateNow, disabled: updating,
4146
4438
  }, updating ? t('updating') : t('updateNow'))
4147
4439
  : null,
4148
4440
  ),
4149
4441
  updaterMsg
4150
- ? React.createElement('div', {
4442
+ ? h('div', {
4151
4443
  style: {
4152
4444
  marginTop: '8px',
4153
4445
  fontSize: '12px',
@@ -4159,960 +4451,960 @@ function MacrosSection(props) {
4159
4451
  : null,
4160
4452
  )
4161
4453
  }
4162
- // ------------------------------------------------------- settings voice section
4163
- function VoiceSection(props) {
4164
- const t = (props && props.t) || moduleT
4165
- const ctx = (props && props.ctx) || {}
4166
- // On a non-localhost page the kernel disables settings entirely: the
4167
- // shared document is not readable, every section gets "unavailable" and
4168
- // writes are dropped. The server does not share that restriction, and
4169
- // dsh-lanmode rebuilds the same mechanics on the same calls. Prefer its
4170
- // service when installed, otherwise the kernel one (fine on loopback).
4171
- const settingsService = (ctx.get && ctx.get('lanSettings')) || ctx.configForms || ctx.settingsScope
4172
- const scope = settingsService && typeof settingsService.get === 'function'
4173
- ? settingsService.get(NS)
4174
- : (settingsService && typeof settingsService.bind === 'function' ? settingsService.bind({ namespace: NS }) : null)
4175
- const [snap, setSnap] = React.useState(null)
4176
- const [draft, setDraft] = React.useState(null)
4177
- const [saved, setSaved] = React.useState(false)
4178
- const [err, setErr] = React.useState('')
4179
- const [serverConfig, setServerConfig] = React.useState(null)
4180
- const [configLoading, setConfigLoading] = React.useState(true)
4181
- const [revision, setRevision] = React.useState(null)
4182
- React.useEffect(() => {
4183
- let alive = true
4184
- fetch('/dsh-voice/config', { cache: 'no-store' })
4185
- .then((r) => r.json())
4186
- .then((data) => {
4187
- if (alive && data && data.ok && data.config) {
4188
- setServerConfig(data.config)
4189
- if (typeof data.revision === 'number') setRevision(data.revision)
4190
- setDraft((curr) => curr || deepClone(data.config))
4191
- if (data.config.wizardCompleted !== true) setShowWizard(true)
4192
- }
4193
- })
4194
- .catch(() => {})
4195
- .finally(() => {
4196
- if (alive) setConfigLoading(false)
4197
- })
4198
- return () => { alive = false }
4199
- }, [])
4200
- // Hotkey picker: not a name field — press the key you want.
4201
- const [catching, setCatching] = React.useState(false)
4202
- const [jargonInput, setJargonInput] = React.useState(null)
4203
- const [showWizard, setShowWizard] = React.useState(false)
4204
- React.useEffect(() => {
4205
- if (!catching) return undefined
4206
- const onKey = (event) => {
4207
- event.preventDefault()
4208
- event.stopPropagation()
4209
- if (event.key === 'Escape') { setCatching(false); return }
4210
- const chosen = keyFromEvent(event)
4211
- setDraft((d) => Object.assign({}, d || {}, { hotkey: chosen }))
4212
- voice.hotkey = chosen || ''
4213
- if (chosen) {
4214
- installHotkey(ctx, chosen, 'message')
4215
- } else {
4216
- clearGlobalHotkey()
4217
- }
4218
- setCatching(false)
4219
- }
4220
- document.addEventListener('keydown', onKey, true)
4221
- return () => document.removeEventListener('keydown', onKey, true)
4222
- }, [catching, ctx])
4223
- React.useEffect(() => {
4224
- let alive = true
4225
- const render = () => { if (alive && scope && typeof scope.getSnapshot === 'function') setSnap(scope.getSnapshot()) }
4226
- render()
4227
- const off = (scope && typeof scope.subscribe === 'function') ? scope.subscribe(render) : () => {}
4228
- return () => { alive = false; off() }
4229
- }, [scope])
4230
- const [devices, setDevices] = React.useState([])
4231
- React.useEffect(() => {
4232
- if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
4233
- navigator.mediaDevices.enumerateDevices()
4234
- .then((list) => setDevices(list.filter((d) => d.kind === 'audioinput')))
4235
- .catch(() => {})
4236
- }, [])
4237
- const ready = !!serverConfig || (!!snap && snap.status === 'ready')
4238
- const value = (snap && snap.status === 'ready' && snap.value) ? snap.value : (serverConfig || {})
4239
- const writable = ready
4240
- React.useEffect(() => {
4241
- if (ready) return undefined
4242
- let tries = 0
4243
- const timer = setInterval(() => {
4244
- if (tries >= 15) { clearInterval(timer); return }
4245
- tries += 1
4246
- try {
4247
- if (ctx.configForms && typeof ctx.configForms.describe === 'function') {
4248
- ctx.configForms.describe().load()
4249
- } else if (ctx.settingsScope && typeof ctx.settingsScope.describe === 'function') {
4250
- ctx.settingsScope.describe().load()
4251
- }
4252
- } catch (e) { /* service not up yet */ }
4253
- }, 1000)
4254
- return () => clearInterval(timer)
4255
- }, [ready])
4256
- const deepClone = (obj) => {
4257
- if (typeof structuredClone === 'function') {
4258
- try { return structuredClone(obj) } catch (e) { /* fallback */ }
4259
- }
4260
- try { return JSON.parse(JSON.stringify(obj)) } catch (e) { return Object.assign({}, obj) }
4454
+ // ------------------------------------------------------- settings voice section
4455
+ function VoiceSection(props) {
4456
+ const t = (props && props.t) || moduleT
4457
+ const ctx = (props && props.ctx) || {}
4458
+ // On a non-localhost page the kernel disables settings entirely: the
4459
+ // shared document is not readable, every section gets "unavailable" and
4460
+ // writes are dropped. The server does not share that restriction, and
4461
+ // dsh-lanmode rebuilds the same mechanics on the same calls. Prefer its
4462
+ // service when installed, otherwise the kernel one (fine on loopback).
4463
+ const settingsService = (ctx.get && ctx.get('lanSettings')) || ctx.configForms || ctx.settingsScope
4464
+ const scope = settingsService && typeof settingsService.get === 'function'
4465
+ ? settingsService.get(NS)
4466
+ : (settingsService && typeof settingsService.bind === 'function' ? settingsService.bind({ namespace: NS }) : null)
4467
+ const [snap, setSnap] = React.useState(null)
4468
+ const [draft, setDraft] = React.useState(null)
4469
+ const [saved, setSaved] = React.useState(false)
4470
+ const [err, setErr] = React.useState('')
4471
+ const [serverConfig, setServerConfig] = React.useState(null)
4472
+ const [configLoading, setConfigLoading] = React.useState(true)
4473
+ const [revision, setRevision] = React.useState(null)
4474
+ React.useEffect(() => {
4475
+ let alive = true
4476
+ fetch('/dsh-voice/config', { cache: 'no-store' })
4477
+ .then((r) => r.json())
4478
+ .then((data) => {
4479
+ if (alive && data && data.ok && data.config) {
4480
+ setServerConfig(data.config)
4481
+ if (typeof data.revision === 'number') setRevision(data.revision)
4482
+ setDraft((curr) => curr || deepClone(data.config))
4483
+ if (data.config.wizardCompleted !== true) setShowWizard(true)
4261
4484
  }
4262
- const setTop = (key, val) => {
4263
- setDraft((d) => Object.assign({}, d || {}, { [key]: val }))
4264
- }
4265
- const setIn = (mode, key, val) => {
4266
- setDraft((d) => {
4267
- const next = Object.assign({}, d || {})
4268
- next[mode] = Object.assign({}, next[mode] || {}, { [key]: val })
4269
- return next
4270
- })
4271
- }
4272
- const textField = (key, label, hint) => React.createElement('div', { className: 'cb-field' },
4273
- React.createElement('label', null, label),
4274
- React.createElement('input', {
4275
- type: 'text',
4276
- value: (draft && draft[key]) ?? '',
4277
- disabled: !writable,
4278
- onChange: (e) => setTop(key, e.target.value),
4279
- }),
4280
- hint ? React.createElement('span', { className: 'dvs-sub' }, hint) : null,
4281
- )
4282
- React.useEffect(() => { if (ready && draft === null) setDraft(deepClone(value)) }, [ready, draft, value])
4283
- React.useEffect(() => {
4284
- if (!ready) return
4285
- const val = value || {}
4286
- voice.settings = Object.assign({}, voice.settings, {
4287
- vadSilenceMs: Number(val.dictation && val.dictation.vadSilenceMs) || 700,
4288
- autoSendMs: Number(val.message && val.message.autoSendMs) || 4000,
4289
- beep: !!val.beep,
4290
- localOnly: !!val.localOnly,
4291
- micDeviceId: String(val.micDeviceId || ''),
4292
- historyLimit: Number(val.historyLimit) || 20,
4293
- voiceCommands: !!val.voiceCommands,
4294
- sendDelayMs: Number(val.dictation && val.dictation.sendDelayMs) || 0,
4295
- stream: !!(val.dictation && val.dictation.stream),
4296
- streamChunkMs: Number(val.dictation && val.dictation.streamChunkMs) || 1200,
4297
- vadAdapt: Number(val.dictation && val.dictation.vadAdapt) || 0,
4298
- wakeWord: String(val.wakeWord || ''),
4299
- bargeIn: !!val.bargeIn,
4300
- polishSend: !!(val.message && val.message.polishSend),
4301
- editablePreview: val.message ? val.message.editablePreview !== false : true,
4302
- sessionCommands: !!(val.message && val.message.sessionCommands),
4303
- polishBaseUrl: String(val.polishBaseUrl || ''),
4304
- noiseSuppression: val.noiseSuppression !== false,
4305
- noiseGateDb: Number(val.noiseGateDb !== undefined ? val.noiseGateDb : -45), bilingualDeveloperPrompt: val.bilingualDeveloperPrompt !== false,
4306
- contextGlossary: val.contextGlossary !== false,
4307
- visualizerStyle: val.visualizerStyle || 'liquid-wave',
4308
- gatedTurnTaking: val.gatedTurnTaking !== false,
4309
- autoSendVisualRing: val.autoSendVisualRing !== false,
4310
- voiceActions: val.voiceActions !== false,
4311
- techJargonCorrection: val.techJargonCorrection !== false,
4312
- liveInterimPreview: val.liveInterimPreview !== false,
4313
- structuredPromptVoice: val.structuredPromptVoice !== false,
4314
- dictationMode: val.dictationMode || 'clean',
4315
- dictationPrompts: val.dictationPrompts || {},
4316
- voiceMacros: Array.isArray(val.voiceMacros) ? val.voiceMacros : [],
4317
- webgpuModel: String(val.webgpuModel || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'),
4318
- })
4319
- }, [ready, value])
4320
- const [statusData, setStatusData] = React.useState(null)
4321
- const [statusLatency, setStatusLatency] = React.useState(null)
4322
- const fetchStatus = React.useCallback(() => {
4323
- const start = performance.now()
4324
- const ctrl = new AbortController()
4325
- const tId = setTimeout(() => ctrl.abort(), 5000)
4326
- fetch('/dsh-voice/status', { cache: 'no-store', signal: ctrl.signal })
4327
- .then((r) => r.json())
4328
- .then((data) => {
4329
- clearTimeout(tId)
4330
- setStatusLatency(Math.round(performance.now() - start))
4331
- if (data && data.ok) {
4332
- setStatusData(data)
4333
- if (data.sessionToken && typeof window !== 'undefined') window.__dshVoiceToken = data.sessionToken
4334
- if (data.webgpuModel && voice.settings) {
4335
- voice.settings.webgpuModel = data.webgpuModel
4336
- }
4337
- }
4338
- })
4339
- .catch(() => {
4340
- clearTimeout(tId)
4341
- setStatusLatency(null)
4342
- })
4343
- }, [])
4344
- React.useEffect(() => {
4345
- fetchStatus()
4346
- }, [fetchStatus])
4347
- const { sensevoiceState, installingSensevoice, triggerInstallSensevoice } = useSensevoiceInstaller(fetchStatus)
4348
- const { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent, calibrating, calibrateMsg, calibrateNoise } = useMicTester(draft, value, setErr)
4349
- const { updaterStatus, updating, updaterMsg, updaterMsgKind, updaterLoading, checkUpdater, onUpdateNow } = usePluginUpdater()
4350
- const save = async () => {
4351
- setErr(''); setSaved(false)
4352
- if (!draft) return
4353
- if (jargonInput !== null) {
4354
- const lines = jargonInput.split('\n')
4355
- const parsed = lines
4356
- .map((line) => {
4357
- const parts = line.split(/->|=|:/)
4358
- if (parts.length >= 2) {
4359
- const from = parts[0].trim()
4360
- const to = parts.slice(1).join(':').trim()
4361
- if (from && to) return { from, to }
4362
- }
4363
- return null
4364
- })
4365
- .filter(Boolean)
4366
- draft.jargonDictionary = parsed
4367
- }
4368
- let httpOk = false
4369
- let httpError = null
4370
- try {
4371
- const res = await fetch('/dsh-voice/config', {
4372
- method: 'PUT',
4373
- headers: { 'Content-Type': 'application/json' },
4374
- body: JSON.stringify({ config: draft, expectedRevision: (typeof revision === 'number' ? revision : undefined) }),
4375
- })
4376
- const data = await res.json()
4377
- if (res.ok && data && data.ok) {
4378
- httpOk = true
4379
- if (data.config) setServerConfig(data.config)
4380
- if (typeof data.revision === 'number') setRevision(data.revision)
4381
- } else if (res.status === 409) {
4382
- httpError = data?.error?.message || 'Settings conflict: parallel changes detected'
4383
- } else {
4384
- httpError = data?.error?.message || data?.error || res.statusText || 'Save failed'
4385
- }
4386
- } catch (e) {
4387
- httpError = e?.message || String(e)
4388
- }
4389
- if (httpOk && scope && typeof scope.set === 'function') {
4390
- for (const k of Object.keys(draft)) {
4391
- try {
4392
- await scope.set(k, draft[k])
4393
- } catch (e) {
4394
- /* best effort on loopback */
4395
- }
4396
- }
4397
- }
4398
- if (!httpOk) {
4399
- setErr(t('saveFailed') + ': ' + httpError)
4400
- return
4401
- }
4402
- const d = draft || {}
4403
- voice.hotkey = d.hotkey || ''
4404
- if (voice.hotkey) {
4405
- installHotkey(ctx, voice.hotkey, 'message')
4406
- } else {
4407
- clearGlobalHotkey()
4408
- }
4409
- Object.assign(voice.settings, {
4410
- hotkey: d.hotkey || '',
4411
- vadSilenceMs: Number(d.dictation && d.dictation.vadSilenceMs) || 700,
4412
- autoSendMs: Number(d.message && d.message.autoSendMs) || 4000,
4413
- beep: !!d.beep,
4414
- micDeviceId: String(d.micDeviceId || ''),
4415
- historyLimit: Number(d.historyLimit),
4416
- voiceCommands: !!d.voiceCommands,
4417
- sendDelayMs: Number(d.dictation && d.dictation.sendDelayMs) || 0,
4418
- stream: !!(d.dictation && d.dictation.stream),
4419
- streamChunkMs: Number(d.dictation && d.dictation.streamChunkMs) || 1200,
4420
- vadAdapt: Number(d.dictation && d.dictation.vadAdapt) || 0,
4421
- noiseSuppression: d.noiseSuppression !== false,
4422
- noiseGateDb: Number(d.noiseGateDb !== undefined ? d.noiseGateDb : -45), bilingualDeveloperPrompt: d.bilingualDeveloperPrompt !== false,
4423
- contextGlossary: d.contextGlossary !== false,
4424
- visualizerStyle: d.visualizerStyle || 'liquid-wave',
4425
- gatedTurnTaking: d.gatedTurnTaking !== false,
4426
- autoSendVisualRing: d.autoSendVisualRing !== false,
4427
- voiceActions: d.voiceActions !== false,
4428
- techJargonCorrection: d.techJargonCorrection !== false,
4429
- liveInterimPreview: d.liveInterimPreview !== false,
4430
- structuredPromptVoice: d.structuredPromptVoice !== false,
4431
- })
4432
- try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
4433
- setSaved(true); setJargonInput(null)
4434
- setTimeout(() => setSaved(false), 2000)
4435
- fetchStatus()
4436
- }
4437
- const modeVal = (mode, key, fallback) => {
4438
- const m = draft && draft[mode]
4439
- return m && m[key] !== undefined ? m[key] : fallback
4440
- }
4441
- const chainOptions = BUILTIN.concat(
4442
- (draft && Array.isArray(draft.customProviders) ? draft.customProviders : [])
4443
- .map((c) => String(c && c.key || '').trim())
4444
- .filter((k) => k && BUILTIN.indexOf(k) < 0),
4445
- )
4446
- const langField = (mode) => {
4447
- const val = modeVal(mode, 'language', '')
4448
- const opts = SUPPORTED_LANGS.slice()
4449
- if (val && !opts.some((o) => o.code === val)) {
4450
- opts.push({ code: val, labelKey: val, defaultLabel: val })
4451
- }
4452
- return React.createElement('div', { className: 'cb-field' },
4453
- React.createElement('label', null, t('language')),
4454
- React.createElement('select', {
4455
- value: val, disabled: !writable,
4456
- onChange: (e) => setIn(mode, 'language', e.target.value),
4457
- }, opts.map((o) => React.createElement('option', { key: o.code || 'auto', value: o.code },
4458
- t(o.labelKey) !== o.labelKey ? t(o.labelKey) : o.defaultLabel
4459
- ))),
4460
- )
4461
- }
4462
- const numField = (mode, key, label, hint) => React.createElement('div', { className: 'cb-field' },
4463
- React.createElement('label', null, label),
4464
- React.createElement('input', {
4465
- type: 'number', value: modeVal(mode, key, ''), disabled: !writable,
4466
- onChange: (e) => setIn(mode, key, Number(e.target.value)),
4467
- }),
4468
- React.createElement('span', { className: 'dvs-sub' }, hint),
4469
- )
4470
- const statsData = (statusData && statusData.providerStats) || {}
4471
- const recentDiagnostics = (statusData && statusData.recentDiagnostics) || []
4472
- const hostOnline = statusLatency !== null
4473
- const whisperRunning = !!(statusData && statusData.whisperRunning)
4474
- const providerCount = (statusData && Array.isArray(statusData.providers)) ? statusData.providers.length : chainOptions.length
4475
- return React.createElement('div', { className: 'cb-page dvo-settings-root' },
4476
- React.createElement(ConnectionStatusCard, {
4477
- t: t,
4478
- hostOnline: hostOnline,
4479
- statusLatency: statusLatency,
4480
- whisperRunning: whisperRunning,
4481
- providerCount: providerCount,
4482
- onRefresh: fetchStatus,
4483
- onOpenWizard: () => setShowWizard((v) => !v),
4484
- }),
4485
- showWizard ? React.createElement(FirstRunWizard, {
4486
- t: t,
4487
- draft: draft,
4488
- setTop: setTop,
4489
- writable: writable,
4490
- onClose: () => setShowWizard(false),
4491
- }) : null,
4492
- React.createElement('div', { className: 'cb-section-card' },
4493
- React.createElement('div', { className: 'cb-section-title' },
4494
- React.createElement('span', null, '🎙️ ' + t('dictationBtn')),
4495
- ),
4496
- React.createElement('div', { className: 'cb-section-desc' }, t('dictationHint')),
4497
- React.createElement(ChainEditor, {
4498
- t: t, value: draft && draft.dictation ? draft.dictation.chain : [], writable: writable,
4499
- options: chainOptions,
4500
- onChange: (v) => setIn('dictation', 'chain', v),
4501
- }),
4502
- React.createElement('div', { className: 'cb-grid-2' },
4503
- langField('dictation'),
4504
- numField('dictation', 'vadSilenceMs', t('pauseMs'), t('pauseHint')),
4505
- numField('dictation', 'sendDelayMs', t('sendDelay'), t('sendDelayHint')),
4506
- numField('dictation', 'streamChunkMs', t('streamChunkMs'), t('streamHint')),
4507
- ),
4508
- React.createElement('div', { className: 'cb-row' },
4509
- React.createElement('label', { className: 'cb-check-label' },
4510
- React.createElement('input', {
4511
- type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.polish), disabled: !writable,
4512
- onChange: (e) => setIn('dictation', 'polish', e.target.checked),
4513
- }),
4514
- t('polish'),
4515
- ),
4516
- React.createElement('label', { className: 'cb-check-label' },
4517
- React.createElement('input', {
4518
- type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.stream), disabled: !writable,
4519
- onChange: (e) => setIn('dictation', 'stream', e.target.checked),
4520
- }),
4521
- t('stream'),
4522
- ),
4523
- ),
4524
- React.createElement('div', { className: 'cb-field' },
4525
- React.createElement('label', null, t('vadAdapt')),
4526
- React.createElement('input', {
4527
- type: 'range', min: 0, max: 1, step: 0.1,
4528
- value: Number(draft && draft.dictation && draft.dictation.vadAdapt) || 0, disabled: !writable,
4529
- onChange: (e) => setIn('dictation', 'vadAdapt', Number(e.target.value)),
4530
- }),
4531
- React.createElement('span', { className: 'dvs-sub' }, t('vadAdaptHint')),
4532
- ),
4533
- textField('wakeWord', t('wakeWord'), t('wakeWordHint')),
4534
- ),
4535
- React.createElement('div', { className: 'cb-section-card' },
4536
- React.createElement('div', { className: 'cb-section-title' },
4537
- React.createElement('span', null, '💬 ' + t('messageTitle')),
4538
- ),
4539
- React.createElement('div', { className: 'cb-section-desc' }, t('messageHint')),
4540
- React.createElement(ChainEditor, {
4541
- t: t, value: draft && draft.message ? draft.message.chain : [], writable: writable,
4542
- options: chainOptions,
4543
- onChange: (v) => setIn('message', 'chain', v),
4544
- }),
4545
- React.createElement('div', { className: 'cb-grid-2' },
4546
- langField('message'),
4547
- numField('message', 'autoSendMs', t('undoMs'), t('undoHint')),
4548
- ),
4549
- React.createElement('div', { className: 'cb-row' },
4550
- React.createElement('label', { className: 'cb-check-label' },
4551
- React.createElement('input', {
4552
- type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
4553
- onChange: (e) => setIn('message', 'polish', e.target.checked),
4554
- }),
4555
- t('polish'),
4556
- ),
4557
- React.createElement('label', { className: 'cb-check-label' },
4558
- React.createElement('input', {
4559
- type: 'checkbox', checked: !!(draft && draft.message && draft.message.polishSend), disabled: !writable,
4560
- onChange: (e) => setIn('message', 'polishSend', e.target.checked),
4561
- }),
4562
- t('polishSend'),
4563
- ),
4564
- React.createElement('label', { className: 'cb-check-label' },
4565
- React.createElement('input', {
4566
- type: 'checkbox', checked: !!(draft && draft.message && draft.message.sessionCommands), disabled: !writable,
4567
- onChange: (e) => setIn('message', 'sessionCommands', e.target.checked),
4568
- }),
4569
- t('sessionCommands'),
4570
- ),
4571
- ),
4572
- ),
4573
- React.createElement('div', { className: 'cb-section-card' },
4574
- React.createElement('div', { className: 'cb-section-title' },
4575
- React.createElement('span', null, '⚙️ ' + t('customTitle')),
4576
- ),
4577
- React.createElement('div', { className: 'cb-section-desc' }, t('customHint')),
4578
- React.createElement(CustomEditor, {
4579
- value: draft && draft.customProviders ? draft.customProviders : [], writable: writable,
4580
- options: chainOptions,
4581
- onChange: (v) => setTop('customProviders', v),
4582
- }),
4583
- ),
4584
- React.createElement(HardwareOptionsCard, {
4585
- t: t,
4586
- draft: draft,
4587
- setTop: setTop,
4588
- writable: writable,
4589
- catching: catching,
4590
- setCatching: setCatching,
4591
- devices: devices,
4592
- testingMic: testingMic,
4593
- toggleTestMic: toggleTestMic,
4594
- testLevel: testLevel,
4595
- gateDbVal: gateDbVal,
4596
- gateThresholdPercent: gateThresholdPercent,
4597
- calibrating: calibrating,
4598
- calibrateMsg: calibrateMsg,
4599
- calibrateNoise: calibrateNoise,
4600
- onClearKey: () => {
4601
- setDraft((d) => Object.assign({}, d || {}, { hotkey: '' }))
4602
- voice.hotkey = ''
4603
- clearGlobalHotkey()
4604
- },
4605
- }),
4606
- React.createElement(SensevoiceSection, {
4607
- t: t,
4608
- sensevoiceState: sensevoiceState,
4609
- installingSensevoice: installingSensevoice,
4610
- writable: writable,
4611
- onInstall: triggerInstallSensevoice,
4612
- }),
4613
- React.createElement('div', { className: 'cb-section-card' },
4614
- React.createElement('div', { className: 'cb-section-title' },
4615
- React.createElement('span', null, '🎙️ ' + t('turnTakingTitle')),
4616
- ),
4617
- React.createElement('div', { className: 'cb-section-desc' }, t('turnTakingDesc')),
4618
- React.createElement('div', { className: 'cb-grid-2' },
4619
- React.createElement('label', { className: 'cb-check-label' },
4620
- React.createElement('input', {
4621
- type: 'checkbox',
4622
- checked: draft ? draft.gatedTurnTaking !== false : true,
4623
- disabled: !writable,
4624
- onChange: (e) => setTop('gatedTurnTaking', e.target.checked),
4625
- }),
4626
- t('gatedTurnTaking'),
4627
- ),
4628
- React.createElement('label', { className: 'cb-check-label' },
4629
- React.createElement('input', {
4630
- type: 'checkbox',
4631
- checked: !!(draft && draft.bargeIn),
4632
- disabled: !writable,
4633
- onChange: (e) => setTop('bargeIn', e.target.checked),
4634
- }),
4635
- t('bargeInDesc'),
4636
- ),
4637
- ),
4638
- ),
4639
- React.createElement('div', { className: 'cb-section-card' },
4640
- React.createElement('div', { className: 'cb-section-title' },
4641
- React.createElement('span', null, '✨ ' + t('smartActionsTitle')),
4642
- ),
4643
- React.createElement('div', { className: 'cb-grid-2' },
4644
- React.createElement('label', { className: 'cb-check-label' },
4645
- React.createElement('input', {
4646
- type: 'checkbox',
4647
- checked: draft ? draft.liveInterimPreview !== false : true,
4648
- disabled: !writable,
4649
- onChange: (e) => setTop('liveInterimPreview', e.target.checked),
4650
- }),
4651
- t('liveInterimPreview'),
4652
- ),
4653
- React.createElement('label', { className: 'cb-check-label' },
4654
- React.createElement('input', {
4655
- type: 'checkbox',
4656
- checked: draft ? draft.autoSendVisualRing !== false : true,
4657
- disabled: !writable,
4658
- onChange: (e) => setTop('autoSendVisualRing', e.target.checked),
4659
- }),
4660
- t('autoSendVisualRing'),
4661
- ),
4662
- React.createElement('label', { className: 'cb-check-label' },
4663
- React.createElement('input', {
4664
- type: 'checkbox',
4665
- checked: draft ? draft.voiceActions !== false : true,
4666
- disabled: !writable,
4667
- onChange: (e) => setTop('voiceActions', e.target.checked),
4668
- }),
4669
- t('voiceActionsLabel'),
4670
- ),
4671
- React.createElement('label', { className: 'cb-check-label' },
4672
- React.createElement('input', {
4673
- type: 'checkbox',
4674
- checked: draft ? draft.structuredPromptVoice !== false : true,
4675
- disabled: !writable,
4676
- onChange: (e) => setTop('structuredPromptVoice', e.target.checked),
4677
- }),
4678
- t('structuredPromptVoice'),
4679
- ),
4680
- ),
4681
- ),
4682
- // Dictation Modes & Prompts (#264)
4683
- React.createElement(DictationModesSection, {
4684
- t: t,
4685
- draft: draft,
4686
- setTop: setTop,
4687
- writable: writable,
4688
- }),
4689
- // Voice Action Macros (#167)
4690
- React.createElement(MacrosSection, {
4691
- t: t,
4692
- draft: draft,
4693
- setTop: setTop,
4694
- writable: writable,
4695
- }),
4696
- React.createElement(JargonEditor, {
4697
- t: t,
4698
- draft: draft,
4699
- setTop: setTop,
4700
- writable: writable,
4701
- jargonInput: jargonInput,
4702
- setJargonInput: setJargonInput,
4703
- }),
4704
- React.createElement(ProviderDashboard, {
4705
- t: t,
4706
- statsData: statsData,
4707
- recentDiagnostics: recentDiagnostics,
4708
- onRefresh: fetchStatus,
4709
- }),
4710
- React.createElement(UpdaterCard, {
4711
- t: t,
4712
- updaterStatus: updaterStatus,
4713
- updaterLoading: updaterLoading,
4714
- updating: updating,
4715
- updaterMsg: updaterMsg,
4716
- updaterMsgKind: updaterMsgKind,
4717
- onCheck: checkUpdater,
4718
- onUpdateNow: onUpdateNow,
4719
- }),
4720
- React.createElement('div', { className: 'cb-row' },
4721
- React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable, onClick: save }, t('save')),
4722
- saved ? React.createElement('span', { className: 'dvs-ok' }, t('saved')) : null,
4723
- err ? React.createElement('span', { className: 'dvs-bad' }, err) : null,
4724
- ),
4725
- )
4726
- }
4727
- // ------------------------------------------------------- first-run wizard (#265)
4728
- function FirstRunWizard(props) {
4729
- const t = props.t || moduleT
4730
- const draft = props.draft
4731
- const setTop = props.setTop
4732
- const writable = props.writable
4733
- const onClose = props.onClose
4734
-
4735
- const isSecure = typeof window !== 'undefined' ? (window.isSecureContext || window.location.protocol === 'https:' || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') : true
4736
- const hasMediaDevices = typeof navigator !== 'undefined' && !!navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function'
4737
- const hasMediaRecorder = typeof MediaRecorder !== 'undefined'
4738
-
4739
- const [micPermission, setMicPermission] = React.useState('prompt')
4740
- const [devices, setDevices] = React.useState([])
4741
- const [selectedDev, setSelectedDev] = React.useState((draft && draft.micDeviceId) || '')
4742
- const [signalTesting, setSignalTesting] = React.useState(false)
4743
- const [currentRms, setCurrentRms] = React.useState(0)
4744
- const [peakRms, setPeakRms] = React.useState(0)
4745
- const [testingStt, setTestingStt] = React.useState(false)
4746
- const [testCountdown, setTestCountdown] = React.useState(0)
4747
- const [testResult, setTestResult] = React.useState(null)
4748
- const [testError, setTestError] = React.useState('')
4749
- const [testLatency, setTestLatency] = React.useState(null)
4750
-
4751
- const signalStreamRef = React.useRef(null)
4752
- const audioCtxRef = React.useRef(null)
4753
- const animFrameRef = React.useRef(null)
4754
-
4755
- React.useEffect(() => {
4756
- if (typeof navigator !== 'undefined' && navigator.permissions && navigator.permissions.query) {
4757
- navigator.permissions.query({ name: 'microphone' })
4758
- .then((p) => {
4759
- if (p) {
4760
- setMicPermission(p.state)
4761
- p.onchange = () => setMicPermission(p.state)
4762
- }
4763
- })
4764
- .catch(() => {})
4485
+ })
4486
+ .catch(() => {})
4487
+ .finally(() => {
4488
+ if (alive) setConfigLoading(false)
4489
+ })
4490
+ return () => { alive = false }
4491
+ }, [])
4492
+ // Hotkey picker: not a name field — press the key you want.
4493
+ const [catching, setCatching] = React.useState(false)
4494
+ const [jargonInput, setJargonInput] = React.useState(null)
4495
+ const [showWizard, setShowWizard] = React.useState(false)
4496
+ React.useEffect(() => {
4497
+ if (!catching) return undefined
4498
+ const onKey = (event) => {
4499
+ event.preventDefault()
4500
+ event.stopPropagation()
4501
+ if (event.key === 'Escape') { setCatching(false); return }
4502
+ const chosen = keyFromEvent(event)
4503
+ setDraft((d) => Object.assign({}, d || {}, { hotkey: chosen }))
4504
+ voice.hotkey = chosen || ''
4505
+ if (chosen) {
4506
+ installHotkey(ctx, chosen, 'message')
4507
+ } else {
4508
+ clearGlobalHotkey()
4509
+ }
4510
+ setCatching(false)
4765
4511
  }
4766
- }, [])
4767
-
4768
- const refreshDevices = () => {
4512
+ document.addEventListener('keydown', onKey, true)
4513
+ return () => document.removeEventListener('keydown', onKey, true)
4514
+ }, [catching, ctx])
4515
+ React.useEffect(() => {
4516
+ let alive = true
4517
+ const render = () => { if (alive && scope && typeof scope.getSnapshot === 'function') setSnap(scope.getSnapshot()) }
4518
+ render()
4519
+ const off = (scope && typeof scope.subscribe === 'function') ? scope.subscribe(render) : () => {}
4520
+ return () => { alive = false; off() }
4521
+ }, [scope])
4522
+ const [devices, setDevices] = React.useState([])
4523
+ React.useEffect(() => {
4769
4524
  if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
4770
4525
  navigator.mediaDevices.enumerateDevices()
4771
- .then((list) => {
4772
- const audioInputs = list.filter((d) => d.kind === 'audioinput')
4773
- setDevices(audioInputs)
4774
- })
4775
- .catch(() => {})
4526
+ .then((list) => setDevices(list.filter((d) => d.kind === 'audioinput')))
4527
+ .catch(() => {})
4528
+ }, [])
4529
+ const ready = !!serverConfig || (!!snap && snap.status === 'ready')
4530
+ const value = (snap && snap.status === 'ready' && snap.value) ? snap.value : (serverConfig || {})
4531
+ const writable = ready
4532
+ React.useEffect(() => {
4533
+ if (ready) return undefined
4534
+ let tries = 0
4535
+ const timer = setInterval(() => {
4536
+ if (tries >= 15) { clearInterval(timer); return }
4537
+ tries += 1
4538
+ try {
4539
+ if (ctx.configForms && typeof ctx.configForms.describe === 'function') {
4540
+ ctx.configForms.describe().load()
4541
+ } else if (ctx.settingsScope && typeof ctx.settingsScope.describe === 'function') {
4542
+ ctx.settingsScope.describe().load()
4776
4543
  }
4777
-
4778
- React.useEffect(() => {
4779
- refreshDevices()
4780
- }, [])
4781
-
4782
- const stopSignalTest = () => {
4783
- if (animFrameRef.current) {
4784
- cancelAnimationFrame(animFrameRef.current)
4785
- animFrameRef.current = null
4544
+ } catch (e) { /* service not up yet */ }
4545
+ }, 1000)
4546
+ return () => clearInterval(timer)
4547
+ }, [ready])
4548
+ const deepClone = (obj) => {
4549
+ if (typeof structuredClone === 'function') {
4550
+ try { return structuredClone(obj) } catch (e) { /* fallback */ }
4786
4551
  }
4787
- if (signalStreamRef.current) {
4788
- try { signalStreamRef.current.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
4789
- signalStreamRef.current = null
4790
- }
4791
- if (audioCtxRef.current) {
4792
- try { audioCtxRef.current.close() } catch (_) {}
4793
- audioCtxRef.current = null
4794
- }
4795
- setSignalTesting(false)
4796
- setCurrentRms(0)
4552
+ try { return JSON.parse(JSON.stringify(obj)) } catch (e) { return Object.assign({}, obj) }
4553
+ }
4554
+ const setTop = (key, val) => {
4555
+ setDraft((d) => Object.assign({}, d || {}, { [key]: val }))
4556
+ }
4557
+ const setIn = (mode, key, val) => {
4558
+ setDraft((d) => {
4559
+ const next = Object.assign({}, d || {})
4560
+ next[mode] = Object.assign({}, next[mode] || {}, { [key]: val })
4561
+ return next
4562
+ })
4563
+ }
4564
+ const textField = (key, label, hint) => h('div', { className: 'cb-field' },
4565
+ h('label', null, label),
4566
+ h('input', {
4567
+ type: 'text',
4568
+ value: (draft && draft[key]) ?? '',
4569
+ disabled: !writable,
4570
+ onChange: (e) => setTop(key, e.target.value),
4571
+ }),
4572
+ hint ? h('span', { className: 'dvs-sub' }, hint) : null,
4573
+ )
4574
+ React.useEffect(() => { if (ready && draft === null) setDraft(deepClone(value)) }, [ready, draft, value])
4575
+ React.useEffect(() => {
4576
+ if (!ready) return
4577
+ const val = value || {}
4578
+ voice.settings = Object.assign({}, voice.settings, {
4579
+ vadSilenceMs: Number(val.dictation && val.dictation.vadSilenceMs) || 700,
4580
+ autoSendMs: Number(val.message && val.message.autoSendMs) || 4000,
4581
+ beep: !!val.beep,
4582
+ localOnly: !!val.localOnly,
4583
+ micDeviceId: String(val.micDeviceId || ''),
4584
+ historyLimit: Number(val.historyLimit) || 20,
4585
+ voiceCommands: !!val.voiceCommands,
4586
+ sendDelayMs: Number(val.dictation && val.dictation.sendDelayMs) || 0,
4587
+ stream: !!(val.dictation && val.dictation.stream),
4588
+ streamChunkMs: Number(val.dictation && val.dictation.streamChunkMs) || 1200,
4589
+ vadAdapt: Number(val.dictation && val.dictation.vadAdapt) || 0,
4590
+ wakeWord: String(val.wakeWord || ''),
4591
+ bargeIn: !!val.bargeIn,
4592
+ polishSend: !!(val.message && val.message.polishSend),
4593
+ editablePreview: val.message ? val.message.editablePreview !== false : true,
4594
+ sessionCommands: !!(val.message && val.message.sessionCommands),
4595
+ polishBaseUrl: String(val.polishBaseUrl || ''),
4596
+ noiseSuppression: val.noiseSuppression !== false,
4597
+ noiseGateDb: Number(val.noiseGateDb !== undefined ? val.noiseGateDb : -45), bilingualDeveloperPrompt: val.bilingualDeveloperPrompt !== false,
4598
+ contextGlossary: val.contextGlossary !== false,
4599
+ visualizerStyle: val.visualizerStyle || 'liquid-wave',
4600
+ gatedTurnTaking: val.gatedTurnTaking !== false,
4601
+ autoSendVisualRing: val.autoSendVisualRing !== false,
4602
+ voiceActions: val.voiceActions !== false,
4603
+ techJargonCorrection: val.techJargonCorrection !== false,
4604
+ liveInterimPreview: val.liveInterimPreview !== false,
4605
+ structuredPromptVoice: val.structuredPromptVoice !== false,
4606
+ dictationMode: val.dictationMode || 'clean',
4607
+ dictationPrompts: val.dictationPrompts || {},
4608
+ voiceMacros: Array.isArray(val.voiceMacros) ? val.voiceMacros : [],
4609
+ webgpuModel: String(val.webgpuModel || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'),
4610
+ })
4611
+ }, [ready, value])
4612
+ const [statusData, setStatusData] = React.useState(null)
4613
+ const [statusLatency, setStatusLatency] = React.useState(null)
4614
+ const fetchStatus = React.useCallback(() => {
4615
+ const start = performance.now()
4616
+ const ctrl = new AbortController()
4617
+ const tId = setTimeout(() => ctrl.abort(), 5000)
4618
+ fetch('/dsh-voice/status', { cache: 'no-store', signal: ctrl.signal })
4619
+ .then((r) => r.json())
4620
+ .then((data) => {
4621
+ clearTimeout(tId)
4622
+ setStatusLatency(Math.round(performance.now() - start))
4623
+ if (data && data.ok) {
4624
+ setStatusData(data)
4625
+ if (data.sessionToken && typeof window !== 'undefined') window.__dshVoiceToken = data.sessionToken
4626
+ if (data.webgpuModel && voice.settings) {
4627
+ voice.settings.webgpuModel = data.webgpuModel
4628
+ }
4797
4629
  }
4798
-
4799
- React.useEffect(() => {
4800
- return () => {
4801
- stopSignalTest()
4630
+ })
4631
+ .catch(() => {
4632
+ clearTimeout(tId)
4633
+ setStatusLatency(null)
4634
+ })
4635
+ }, [])
4636
+ React.useEffect(() => {
4637
+ fetchStatus()
4638
+ }, [fetchStatus])
4639
+ const { sensevoiceState, installingSensevoice, triggerInstallSensevoice } = useSensevoiceInstaller(fetchStatus)
4640
+ const { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent, calibrating, calibrateMsg, calibrateNoise } = useMicTester(draft, value, setErr)
4641
+ const { updaterStatus, updating, updaterMsg, updaterMsgKind, updaterLoading, checkUpdater, onUpdateNow } = usePluginUpdater()
4642
+ const save = async () => {
4643
+ setErr(''); setSaved(false)
4644
+ if (!draft) return
4645
+ if (jargonInput !== null) {
4646
+ const lines = jargonInput.split('\n')
4647
+ const parsed = lines
4648
+ .map((line) => {
4649
+ const parts = line.split(/->|=|:/)
4650
+ if (parts.length >= 2) {
4651
+ const from = parts[0].trim()
4652
+ const to = parts.slice(1).join(':').trim()
4653
+ if (from && to) return { from, to }
4654
+ }
4655
+ return null
4656
+ })
4657
+ .filter(Boolean)
4658
+ draft.jargonDictionary = parsed
4802
4659
  }
4803
- }, [])
4804
-
4805
- const requestPermissionAndTestSignal = async () => {
4806
- stopSignalTest()
4807
- setTestError('')
4660
+ let httpOk = false
4661
+ let httpError = null
4808
4662
  try {
4809
- const constraints = {
4810
- audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
4811
- }
4812
- const stream = await navigator.mediaDevices.getUserMedia(constraints)
4813
- signalStreamRef.current = stream
4814
- setMicPermission('granted')
4815
- refreshDevices()
4816
-
4817
- const AC = typeof AudioContext !== 'undefined' ? AudioContext
4818
- : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
4819
- if (!AC) return
4820
- const actx = new AC()
4821
- audioCtxRef.current = actx
4822
- if (actx.state === 'suspended') { actx.resume().catch(() => {}) }
4823
- const src = actx.createMediaStreamSource(stream)
4824
- const analyser = actx.createAnalyser()
4825
- analyser.fftSize = 128
4826
- src.connect(analyser)
4827
-
4828
- setSignalTesting(true)
4829
- const buf = new Uint8Array(analyser.frequencyBinCount)
4830
-
4831
- const checkLevel = () => {
4832
- if (!signalStreamRef.current) return
4833
- analyser.getByteFrequencyData(buf)
4834
- let sum = 0
4835
- for (let i = 0; i < buf.length; i++) sum += buf[i]
4836
- const rms = Math.min(100, Math.round((sum / buf.length / 255) * 100 * 2.2))
4837
- setCurrentRms(rms)
4838
- setPeakRms((prev) => Math.max(prev, rms))
4839
- animFrameRef.current = requestAnimationFrame(checkLevel)
4840
- }
4841
- animFrameRef.current = requestAnimationFrame(checkLevel)
4842
- } catch (err) {
4843
- setMicPermission('denied')
4844
- setTestError(String(err && err.message ? err.message : err))
4845
- stopSignalTest()
4663
+ const res = await fetch('/dsh-voice/config', {
4664
+ method: 'PUT',
4665
+ headers: { 'Content-Type': 'application/json' },
4666
+ body: JSON.stringify({ config: draft, expectedRevision: (typeof revision === 'number' ? revision : undefined) }),
4667
+ })
4668
+ const data = await res.json()
4669
+ if (res.ok && data && data.ok) {
4670
+ httpOk = true
4671
+ if (data.config) setServerConfig(data.config)
4672
+ if (typeof data.revision === 'number') setRevision(data.revision)
4673
+ } else if (res.status === 409) {
4674
+ httpError = data?.error?.message || 'Settings conflict: parallel changes detected'
4675
+ } else {
4676
+ httpError = data?.error?.message || data?.error || res.statusText || 'Save failed'
4677
+ }
4678
+ } catch (e) {
4679
+ httpError = e?.message || String(e)
4846
4680
  }
4681
+ if (httpOk && scope && typeof scope.set === 'function') {
4682
+ for (const k of Object.keys(draft)) {
4683
+ try {
4684
+ await scope.set(k, draft[k])
4685
+ } catch (e) {
4686
+ /* best effort on loopback */
4847
4687
  }
4848
-
4849
- const runSttTest = async () => {
4850
- stopSignalTest()
4851
- setTestError('')
4852
- setTestResult(null)
4853
- setTestLatency(null)
4854
- setTestingStt(true)
4855
- setTestCountdown(3)
4856
-
4857
- let testStream = null
4858
- let recorder = null
4859
- const chunks = []
4860
-
4861
- try {
4862
- const constraints = {
4863
- audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
4864
- }
4865
- testStream = await navigator.mediaDevices.getUserMedia(constraints)
4866
- let mime = 'audio/webm;codecs=opus'
4867
- if (!MediaRecorder.isTypeSupported(mime)) mime = ''
4868
- recorder = mime ? new MediaRecorder(testStream, { mimeType: mime }) : new MediaRecorder(testStream)
4869
- recorder.ondataavailable = (e) => {
4870
- if (e.data && e.data.size > 0) chunks.push(e.data)
4871
- }
4872
- recorder.start()
4873
-
4874
- let left = 3
4875
- const countdownTimer = setInterval(() => {
4876
- left -= 1
4877
- setTestCountdown(left)
4878
- if (left <= 0) {
4879
- clearInterval(countdownTimer)
4880
- if (recorder.state !== 'inactive') recorder.stop()
4881
- }
4882
- }, 1000)
4883
-
4884
- await new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
4885
-
4886
- try { testStream.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
4887
-
4888
- const blob = new Blob(chunks, { type: mime || 'audio/webm' })
4889
- if (blob.size < 400) {
4890
- setTestError(t('wizardTestEmpty'))
4891
- setTestingStt(false)
4892
- return
4893
- }
4894
-
4895
- setTestCountdown(0)
4896
- const startMs = Date.now()
4897
- const dataBase64 = await blobToBase64(blob)
4898
- const payload = {
4899
- dataBase64,
4900
- mimeType: mime || recorder.mimeType || 'audio/webm',
4901
- mode: 'dictation',
4902
- subMode: 'clean',
4903
- }
4904
-
4905
- const res = await fetch('/dsh-voice/transcribe', {
4906
- method: 'POST',
4907
- headers: { 'content-type': 'application/json' },
4908
- body: JSON.stringify(payload),
4909
- })
4910
- const data = await res.json()
4911
- const latency = Date.now() - startMs
4912
- setTestLatency(latency)
4913
-
4914
- if (!res.ok || !data || !data.ok) {
4915
- const msg = (data && data.error && data.error.message) || `HTTP ${res.status}`
4916
- setTestError(msg)
4917
- } else {
4918
- const recognized = String(data.text || '').trim()
4919
- if (recognized) {
4920
- setTestResult(recognized)
4921
- if (typeof setTop === 'function') {
4922
- setTop('wizardCompleted', true)
4923
- }
4924
- } else {
4925
- setTestError(t('wizardTestEmpty'))
4926
- }
4927
- }
4928
- } catch (err) {
4929
- setTestError(String(err && err.message ? err.message : err))
4930
- } finally {
4931
- if (testStream) {
4932
- try { testStream.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
4933
- }
4934
- setTestingStt(false)
4688
+ }
4935
4689
  }
4936
- }
4937
-
4938
- // Determine active chain info
4939
- const dictationChain = (draft && draft.dictation && draft.dictation.chain) || []
4940
- const activeProvider = (dictationChain[0] && (dictationChain[0].provider || dictationChain[0])) || 'sensevoice'
4941
- let areaLabel = t('wizardAreaServer')
4942
- if (activeProvider === 'browser' || activeProvider === 'browser-webgpu') {
4943
- areaLabel = t('wizardAreaBrowser')
4944
- } else if (activeProvider !== 'sensevoice' && activeProvider !== 'local-whisper') {
4945
- areaLabel = t('wizardAreaCloud')
4946
- }
4947
-
4948
- let nextStepHint = ''
4949
- if (!isSecure) {
4950
- nextStepHint = t('wizardHintHttps')
4951
- } else if (micPermission === 'denied') {
4952
- nextStepHint = t('wizardHintPermission')
4953
- } else if (peakRms === 0 && signalTesting) {
4954
- nextStepHint = t('wizardHintDevice')
4955
- } else if (testError) {
4956
- nextStepHint = t('wizardHintProvider')
4957
- }
4958
-
4959
- return React.createElement('div', { className: 'cb-section-card', style: { borderColor: 'var(--dsw-alias-state-brand-primary)' } },
4960
- React.createElement('div', { className: 'cb-section-title' },
4961
- React.createElement('span', null, '✨ ' + t('wizardTitle')),
4962
- React.createElement('div', { style: { display: 'flex', gap: '8px' } },
4963
- testResult ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '✓ ' + t('wizardStatusCompleted')) : null,
4964
- onClose ? React.createElement('button', {
4965
- type: 'button', className: 'cb-btn cb-btn-mini', onClick: onClose, title: t('wizardClose'),
4966
- }, '✕') : null,
4967
- ),
4968
- ),
4969
- React.createElement('div', { className: 'cb-section-desc' }, t('wizardDesc')),
4970
-
4971
- // Step 1: Environment & Permissions
4972
- React.createElement('div', { className: 'dvs-card' },
4973
- React.createElement('div', { className: 'dvs-h' }, t('wizardStep1')),
4974
- React.createElement('div', { className: 'cb-row' },
4975
- React.createElement('span', { className: 'cb-badge ' + (isSecure ? 'cb-badge-ok' : 'cb-badge-err') },
4976
- isSecure ? ('✓ ' + t('wizardSecureContextOk')) : ('✗ ' + t('wizardSecureContextBad'))
4977
- ),
4978
- React.createElement('span', { className: 'cb-badge ' + (hasMediaDevices ? 'cb-badge-ok' : 'cb-badge-err') },
4979
- hasMediaDevices ? ('✓ ' + t('wizardMediaDevicesOk')) : ('✗ ' + t('wizardMediaDevicesBad'))
4980
- ),
4981
- React.createElement('span', { className: 'cb-badge ' + (hasMediaRecorder ? 'cb-badge-ok' : 'cb-badge-err') },
4982
- hasMediaRecorder ? ('✓ ' + t('wizardMediaRecorderOk')) : ('✗ ' + t('wizardMediaRecorderBad'))
4690
+ if (!httpOk) {
4691
+ setErr(t('saveFailed') + ': ' + httpError)
4692
+ return
4693
+ }
4694
+ const d = draft || {}
4695
+ voice.hotkey = d.hotkey || ''
4696
+ if (voice.hotkey) {
4697
+ installHotkey(ctx, voice.hotkey, 'message')
4698
+ } else {
4699
+ clearGlobalHotkey()
4700
+ }
4701
+ Object.assign(voice.settings, {
4702
+ hotkey: d.hotkey || '',
4703
+ vadSilenceMs: Number(d.dictation && d.dictation.vadSilenceMs) || 700,
4704
+ autoSendMs: Number(d.message && d.message.autoSendMs) || 4000,
4705
+ beep: !!d.beep,
4706
+ micDeviceId: String(d.micDeviceId || ''),
4707
+ historyLimit: Number(d.historyLimit),
4708
+ voiceCommands: !!d.voiceCommands,
4709
+ sendDelayMs: Number(d.dictation && d.dictation.sendDelayMs) || 0,
4710
+ stream: !!(d.dictation && d.dictation.stream),
4711
+ streamChunkMs: Number(d.dictation && d.dictation.streamChunkMs) || 1200,
4712
+ vadAdapt: Number(d.dictation && d.dictation.vadAdapt) || 0,
4713
+ noiseSuppression: d.noiseSuppression !== false,
4714
+ noiseGateDb: Number(d.noiseGateDb !== undefined ? d.noiseGateDb : -45), bilingualDeveloperPrompt: d.bilingualDeveloperPrompt !== false,
4715
+ contextGlossary: d.contextGlossary !== false,
4716
+ visualizerStyle: d.visualizerStyle || 'liquid-wave',
4717
+ gatedTurnTaking: d.gatedTurnTaking !== false,
4718
+ autoSendVisualRing: d.autoSendVisualRing !== false,
4719
+ voiceActions: d.voiceActions !== false,
4720
+ techJargonCorrection: d.techJargonCorrection !== false,
4721
+ liveInterimPreview: d.liveInterimPreview !== false,
4722
+ structuredPromptVoice: d.structuredPromptVoice !== false,
4723
+ })
4724
+ try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
4725
+ setSaved(true); setJargonInput(null)
4726
+ setTimeout(() => setSaved(false), 2000)
4727
+ fetchStatus()
4728
+ }
4729
+ const modeVal = (mode, key, fallback) => {
4730
+ const m = draft && draft[mode]
4731
+ return m && m[key] !== undefined ? m[key] : fallback
4732
+ }
4733
+ const chainOptions = BUILTIN.concat(
4734
+ (draft && Array.isArray(draft.customProviders) ? draft.customProviders : [])
4735
+ .map((c) => String(c && c.key || '').trim())
4736
+ .filter((k) => k && BUILTIN.indexOf(k) < 0),
4737
+ )
4738
+ const langField = (mode) => {
4739
+ const val = modeVal(mode, 'language', '')
4740
+ const opts = SUPPORTED_LANGS.slice()
4741
+ if (val && !opts.some((o) => o.code === val)) {
4742
+ opts.push({ code: val, labelKey: val, defaultLabel: val })
4743
+ }
4744
+ return h('div', { className: 'cb-field' },
4745
+ h('label', null, t('language')),
4746
+ h('select', {
4747
+ value: val, disabled: !writable,
4748
+ onChange: (e) => setIn(mode, 'language', e.target.value),
4749
+ }, opts.map((o) => h('option', { key: o.code || 'auto', value: o.code },
4750
+ t(o.labelKey) !== o.labelKey ? t(o.labelKey) : o.defaultLabel
4751
+ ))),
4752
+ )
4753
+ }
4754
+ const numField = (mode, key, label, hint) => h('div', { className: 'cb-field' },
4755
+ h('label', null, label),
4756
+ h('input', {
4757
+ type: 'number', value: modeVal(mode, key, ''), disabled: !writable,
4758
+ onChange: (e) => setIn(mode, key, Number(e.target.value)),
4759
+ }),
4760
+ h('span', { className: 'dvs-sub' }, hint),
4761
+ )
4762
+ const statsData = (statusData && statusData.providerStats) || {}
4763
+ const recentDiagnostics = (statusData && statusData.recentDiagnostics) || []
4764
+ const hostOnline = statusLatency !== null
4765
+ const whisperRunning = !!(statusData && statusData.whisperRunning)
4766
+ const providerCount = (statusData && Array.isArray(statusData.providers)) ? statusData.providers.length : chainOptions.length
4767
+ return h('div', { className: 'cb-page dvo-settings-root' },
4768
+ h(ConnectionStatusCard, {
4769
+ t: t,
4770
+ hostOnline: hostOnline,
4771
+ statusLatency: statusLatency,
4772
+ whisperRunning: whisperRunning,
4773
+ providerCount: providerCount,
4774
+ onRefresh: fetchStatus,
4775
+ onOpenWizard: () => setShowWizard((v) => !v),
4776
+ }),
4777
+ showWizard ? h(FirstRunWizard, {
4778
+ t: t,
4779
+ draft: draft,
4780
+ setTop: setTop,
4781
+ writable: writable,
4782
+ onClose: () => setShowWizard(false),
4783
+ }) : null,
4784
+ h('div', { className: 'cb-section-card' },
4785
+ h('div', { className: 'cb-section-title' },
4786
+ h('span', null, '🎙️ ' + t('dictationBtn')),
4787
+ ),
4788
+ h('div', { className: 'cb-section-desc' }, t('dictationHint')),
4789
+ React.createElement(ChainEditor, {
4790
+ t: t, value: draft && draft.dictation ? draft.dictation.chain : [], writable: writable,
4791
+ options: chainOptions,
4792
+ onChange: (v) => setIn('dictation', 'chain', v),
4793
+ }),
4794
+ h('div', { className: 'cb-grid-2' },
4795
+ langField('dictation'),
4796
+ numField('dictation', 'vadSilenceMs', t('pauseMs'), t('pauseHint')),
4797
+ numField('dictation', 'sendDelayMs', t('sendDelay'), t('sendDelayHint')),
4798
+ numField('dictation', 'streamChunkMs', t('streamChunkMs'), t('streamHint')),
4799
+ ),
4800
+ h('div', { className: 'cb-row' },
4801
+ h('label', { className: 'cb-check-label' },
4802
+ h('input', {
4803
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.polish), disabled: !writable,
4804
+ onChange: (e) => setIn('dictation', 'polish', e.target.checked),
4805
+ }),
4806
+ t('polish'),
4983
4807
  ),
4984
- React.createElement('span', { className: 'cb-badge ' + (micPermission === 'granted' ? 'cb-badge-ok' : (micPermission === 'denied' ? 'cb-badge-err' : 'cb-badge-warn')) },
4985
- micPermission === 'granted' ? ('✓ ' + t('wizardMicPermissionOk')) : (micPermission === 'denied' ? ('✗ ' + t('wizardMicPermissionBad')) : ('· ' + t('wizardMicPermissionPrompt')))
4808
+ h('label', { className: 'cb-check-label' },
4809
+ h('input', {
4810
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.stream), disabled: !writable,
4811
+ onChange: (e) => setIn('dictation', 'stream', e.target.checked),
4812
+ }),
4813
+ t('stream'),
4986
4814
  ),
4815
+ ),
4816
+ h('div', { className: 'cb-field' },
4817
+ h('label', null, t('vadAdapt')),
4818
+ h('input', {
4819
+ type: 'range', min: 0, max: 1, step: 0.1,
4820
+ value: Number(draft && draft.dictation && draft.dictation.vadAdapt) || 0, disabled: !writable,
4821
+ onChange: (e) => setIn('dictation', 'vadAdapt', Number(e.target.value)),
4822
+ }),
4823
+ h('span', { className: 'dvs-sub' }, t('vadAdaptHint')),
4824
+ ),
4825
+ textField('wakeWord', t('wakeWord'), t('wakeWordHint')),
4826
+ ),
4827
+ h('div', { className: 'cb-section-card' },
4828
+ h('div', { className: 'cb-section-title' },
4829
+ h('span', null, '💬 ' + t('messageTitle')),
4830
+ ),
4831
+ h('div', { className: 'cb-section-desc' }, t('messageHint')),
4832
+ React.createElement(ChainEditor, {
4833
+ t: t, value: draft && draft.message ? draft.message.chain : [], writable: writable,
4834
+ options: chainOptions,
4835
+ onChange: (v) => setIn('message', 'chain', v),
4836
+ }),
4837
+ h('div', { className: 'cb-grid-2' },
4838
+ langField('message'),
4839
+ numField('message', 'autoSendMs', t('undoMs'), t('undoHint')),
4840
+ ),
4841
+ h('div', { className: 'cb-row' },
4842
+ h('label', { className: 'cb-check-label' },
4843
+ h('input', {
4844
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
4845
+ onChange: (e) => setIn('message', 'polish', e.target.checked),
4846
+ }),
4847
+ t('polish'),
4987
4848
  ),
4988
- React.createElement('div', { className: 'cb-field', style: { marginTop: '8px' } },
4989
- React.createElement('label', null, t('wizardMicLabel')),
4990
- React.createElement('div', { className: 'cb-row' },
4991
- React.createElement('select', {
4992
- value: selectedDev,
4993
- style: { flex: 1, minWidth: '240px' },
4994
- onChange: (e) => {
4995
- setSelectedDev(e.target.value)
4996
- if (typeof setTop === 'function') setTop('micDeviceId', e.target.value)
4997
- stopSignalTest()
4998
- },
4999
- },
5000
- React.createElement('option', { value: '' }, 'Default System Microphone'),
5001
- devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId }, d.label || `Microphone ${d.deviceId.slice(0, 8)}…`))
5002
- ),
5003
- React.createElement('button', {
5004
- type: 'button',
5005
- className: 'cb-btn',
5006
- onClick: requestPermissionAndTestSignal,
5007
- }, t('wizardRequestMic')),
4849
+ h('label', { className: 'cb-check-label' },
4850
+ h('input', {
4851
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polishSend), disabled: !writable,
4852
+ onChange: (e) => setIn('message', 'polishSend', e.target.checked),
4853
+ }),
4854
+ t('polishSend'),
5008
4855
  ),
4856
+ h('label', { className: 'cb-check-label' },
4857
+ h('input', {
4858
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.sessionCommands), disabled: !writable,
4859
+ onChange: (e) => setIn('message', 'sessionCommands', e.target.checked),
4860
+ }),
4861
+ t('sessionCommands'),
5009
4862
  ),
4863
+ ),
5010
4864
  ),
5011
-
5012
- // Step 2: Live Audio Signal Test
5013
- React.createElement('div', { className: 'dvs-card' },
5014
- React.createElement('div', { className: 'dvs-h' }, t('wizardStep2')),
5015
- React.createElement('div', { className: 'cb-row', style: { alignItems: 'center' } },
5016
- React.createElement('button', {
5017
- type: 'button',
5018
- className: 'cb-btn ' + (signalTesting ? 'cb-btn-danger' : 'cb-btn-primary'),
5019
- onClick: signalTesting ? stopSignalTest : requestPermissionAndTestSignal,
5020
- }, signalTesting ? t('wizardStopSignalCheck') : t('wizardStartSignalCheck')),
5021
- signalTesting ? React.createElement('span', { className: 'dvs-sub' }, t('wizardSignalTesting')) : null,
5022
- ),
5023
- signalTesting ? React.createElement('div', { style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '6px' } },
5024
- React.createElement('div', {
5025
- style: {
5026
- height: '14px',
5027
- borderRadius: '7px',
5028
- background: 'var(--dsw-alias-bg-layer-1)',
5029
- border: '1px solid var(--dsw-alias-border-l1)',
5030
- overflow: 'hidden',
5031
- position: 'relative',
5032
- },
5033
- },
5034
- React.createElement('div', {
5035
- style: {
5036
- width: `${currentRms}%`,
5037
- height: '100%',
5038
- background: currentRms > 5 ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-dimmed)',
5039
- transition: 'width 0.05s linear',
5040
- },
5041
- }),
4865
+ h('div', { className: 'cb-section-card' },
4866
+ h('div', { className: 'cb-section-title' },
4867
+ h('span', null, '⚙️ ' + t('customTitle')),
4868
+ ),
4869
+ h('div', { className: 'cb-section-desc' }, t('customHint')),
4870
+ React.createElement(CustomEditor, {
4871
+ value: draft && draft.customProviders ? draft.customProviders : [], writable: writable,
4872
+ options: chainOptions,
4873
+ onChange: (v) => setTop('customProviders', v),
4874
+ }),
4875
+ ),
4876
+ h(HardwareOptionsCard, {
4877
+ t: t,
4878
+ draft: draft,
4879
+ setTop: setTop,
4880
+ writable: writable,
4881
+ catching: catching,
4882
+ setCatching: setCatching,
4883
+ devices: devices,
4884
+ testingMic: testingMic,
4885
+ toggleTestMic: toggleTestMic,
4886
+ testLevel: testLevel,
4887
+ gateDbVal: gateDbVal,
4888
+ gateThresholdPercent: gateThresholdPercent,
4889
+ calibrating: calibrating,
4890
+ calibrateMsg: calibrateMsg,
4891
+ calibrateNoise: calibrateNoise,
4892
+ onClearKey: () => {
4893
+ setDraft((d) => Object.assign({}, d || {}, { hotkey: '' }))
4894
+ voice.hotkey = ''
4895
+ clearGlobalHotkey()
4896
+ },
4897
+ }),
4898
+ h(SensevoiceSection, {
4899
+ t: t,
4900
+ sensevoiceState: sensevoiceState,
4901
+ installingSensevoice: installingSensevoice,
4902
+ writable: writable,
4903
+ onInstall: triggerInstallSensevoice,
4904
+ }),
4905
+ h('div', { className: 'cb-section-card' },
4906
+ h('div', { className: 'cb-section-title' },
4907
+ h('span', null, '🎙️ ' + t('turnTakingTitle')),
4908
+ ),
4909
+ h('div', { className: 'cb-section-desc' }, t('turnTakingDesc')),
4910
+ h('div', { className: 'cb-grid-2' },
4911
+ h('label', { className: 'cb-check-label' },
4912
+ h('input', {
4913
+ type: 'checkbox',
4914
+ checked: draft ? draft.gatedTurnTaking !== false : true,
4915
+ disabled: !writable,
4916
+ onChange: (e) => setTop('gatedTurnTaking', e.target.checked),
4917
+ }),
4918
+ t('gatedTurnTaking'),
5042
4919
  ),
5043
- React.createElement('span', { className: currentRms > 5 ? 'dvs-ok' : 'dvs-sub' },
5044
- currentRms > 5
5045
- ? t('wizardSignalOk').replace('{level}', currentRms)
5046
- : t('wizardSignalZero')
4920
+ h('label', { className: 'cb-check-label' },
4921
+ h('input', {
4922
+ type: 'checkbox',
4923
+ checked: !!(draft && draft.bargeIn),
4924
+ disabled: !writable,
4925
+ onChange: (e) => setTop('bargeIn', e.target.checked),
4926
+ }),
4927
+ t('bargeInDesc'),
5047
4928
  ),
5048
- ) : null,
4929
+ ),
5049
4930
  ),
5050
-
5051
- // Step 3: Transcription Round-Trip Test
5052
- React.createElement('div', { className: 'dvs-card' },
5053
- React.createElement('div', { className: 'dvs-h' }, t('wizardStep3')),
5054
- React.createElement('div', { className: 'cb-row' },
5055
- React.createElement('span', { className: 'dvs-sub' }, `${t('wizardProviderLabel')} ${activeProvider}`),
5056
- React.createElement('span', { className: 'cb-badge' }, `${t('wizardProcessingArea')} ${areaLabel}`),
4931
+ h('div', { className: 'cb-section-card' },
4932
+ h('div', { className: 'cb-section-title' },
4933
+ h('span', null, '✨ ' + t('smartActionsTitle')),
4934
+ ),
4935
+ h('div', { className: 'cb-grid-2' },
4936
+ h('label', { className: 'cb-check-label' },
4937
+ h('input', {
4938
+ type: 'checkbox',
4939
+ checked: draft ? draft.liveInterimPreview !== false : true,
4940
+ disabled: !writable,
4941
+ onChange: (e) => setTop('liveInterimPreview', e.target.checked),
4942
+ }),
4943
+ t('liveInterimPreview'),
5057
4944
  ),
5058
- React.createElement('div', { className: 'cb-row', style: { marginTop: '8px' } },
5059
- React.createElement('button', {
5060
- type: 'button',
5061
- className: 'cb-btn cb-btn-primary',
5062
- disabled: testingStt || micPermission === 'denied',
5063
- onClick: runSttTest,
5064
- }, testingStt
5065
- ? (testCountdown > 0 ? t('wizardRecordingTest').replace('{left}', testCountdown) : t('wizardTranscribingTest'))
5066
- : t('wizardStartTestRecord')
4945
+ h('label', { className: 'cb-check-label' },
4946
+ h('input', {
4947
+ type: 'checkbox',
4948
+ checked: draft ? draft.autoSendVisualRing !== false : true,
4949
+ disabled: !writable,
4950
+ onChange: (e) => setTop('autoSendVisualRing', e.target.checked),
4951
+ }),
4952
+ t('autoSendVisualRing'),
5067
4953
  ),
4954
+ h('label', { className: 'cb-check-label' },
4955
+ h('input', {
4956
+ type: 'checkbox',
4957
+ checked: draft ? draft.voiceActions !== false : true,
4958
+ disabled: !writable,
4959
+ onChange: (e) => setTop('voiceActions', e.target.checked),
4960
+ }),
4961
+ t('voiceActionsLabel'),
5068
4962
  ),
5069
- testResult ? React.createElement('div', { style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '4px' } },
5070
- React.createElement('div', { className: 'dvs-ok' },
5071
- '✓ ' + t('wizardTestSuccess').replace('{latency}', testLatency || 0)
4963
+ h('label', { className: 'cb-check-label' },
4964
+ h('input', {
4965
+ type: 'checkbox',
4966
+ checked: draft ? draft.structuredPromptVoice !== false : true,
4967
+ disabled: !writable,
4968
+ onChange: (e) => setTop('structuredPromptVoice', e.target.checked),
4969
+ }),
4970
+ t('structuredPromptVoice'),
5072
4971
  ),
5073
- React.createElement('blockquote', {
5074
- style: {
5075
- margin: '4px 0 0 0',
5076
- padding: '8px 12px',
5077
- borderRadius: '6px',
5078
- background: 'var(--dsw-alias-bg-layer-3)',
5079
- borderLeft: '4px solid var(--dsw-alias-state-success-primary)',
5080
- color: 'var(--dsw-alias-label-primary)',
5081
- fontStyle: 'italic',
5082
- },
5083
- }, `“${testResult}”`),
5084
- ) : null,
5085
- testError ? React.createElement('div', { className: 'cb-alert-err', style: { marginTop: '8px' } },
5086
- t('wizardTestFailed').replace('{error}', testError)
5087
- ) : null,
4972
+ ),
5088
4973
  ),
5089
-
5090
- // Next Step Hint / Feedback
5091
- nextStepHint ? React.createElement('div', { className: 'dvs-wait', style: { fontStyle: 'italic' } },
5092
- t('wizardNextStepHint').replace('{hint}', nextStepHint)
5093
- ) : null,
5094
-
5095
- // Wizard Bottom Actions
5096
- React.createElement('div', { className: 'cb-row', style: { justifyContent: 'space-between', marginTop: '6px' } },
5097
- React.createElement('button', {
5098
- type: 'button',
5099
- className: 'cb-btn',
5100
- onClick: () => {
5101
- if (typeof setTop === 'function') setTop('wizardCompleted', true)
5102
- if (onClose) onClose()
5103
- },
5104
- }, t('wizardSkip')),
5105
- testResult ? React.createElement('button', {
5106
- type: 'button',
5107
- className: 'cb-btn cb-btn-primary',
5108
- onClick: () => {
5109
- if (typeof setTop === 'function') setTop('wizardCompleted', true)
5110
- if (onClose) onClose()
5111
- },
5112
- }, '✓ ' + t('wizardFinishSuccess')) : null,
4974
+ // Dictation Modes & Prompts (#264)
4975
+ h(DictationModesSection, {
4976
+ t: t,
4977
+ draft: draft,
4978
+ setTop: setTop,
4979
+ writable: writable,
4980
+ }),
4981
+ // Voice Action Macros (#167)
4982
+ h(MacrosSection, {
4983
+ t: t,
4984
+ draft: draft,
4985
+ setTop: setTop,
4986
+ writable: writable,
4987
+ }),
4988
+ h(JargonEditor, {
4989
+ t: t,
4990
+ draft: draft,
4991
+ setTop: setTop,
4992
+ writable: writable,
4993
+ jargonInput: jargonInput,
4994
+ setJargonInput: setJargonInput,
4995
+ }),
4996
+ h(ProviderDashboard, {
4997
+ t: t,
4998
+ statsData: statsData,
4999
+ recentDiagnostics: recentDiagnostics,
5000
+ onRefresh: fetchStatus,
5001
+ }),
5002
+ h(UpdaterCard, {
5003
+ t: t,
5004
+ updaterStatus: updaterStatus,
5005
+ updaterLoading: updaterLoading,
5006
+ updating: updating,
5007
+ updaterMsg: updaterMsg,
5008
+ updaterMsgKind: updaterMsgKind,
5009
+ onCheck: checkUpdater,
5010
+ onUpdateNow: onUpdateNow,
5011
+ }),
5012
+ h('div', { className: 'cb-row' },
5013
+ h('button', { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable, onClick: save }, t('save')),
5014
+ saved ? h('span', { className: 'dvs-ok' }, t('saved')) : null,
5015
+ err ? h('span', { className: 'dvs-bad' }, err) : null,
5113
5016
  ),
5114
- )
5017
+ )
5018
+ }
5019
+ // ------------------------------------------------------- first-run wizard (#265)
5020
+ function FirstRunWizard(props) {
5021
+ const t = props.t || moduleT
5022
+ const draft = props.draft
5023
+ const setTop = props.setTop
5024
+ const writable = props.writable
5025
+ const onClose = props.onClose
5026
+
5027
+ const isSecure = typeof window !== 'undefined' ? (window.isSecureContext || window.location.protocol === 'https:' || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') : true
5028
+ const hasMediaDevices = typeof navigator !== 'undefined' && !!navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function'
5029
+ const hasMediaRecorder = typeof MediaRecorder !== 'undefined'
5030
+
5031
+ const [micPermission, setMicPermission] = React.useState('prompt')
5032
+ const [devices, setDevices] = React.useState([])
5033
+ const [selectedDev, setSelectedDev] = React.useState((draft && draft.micDeviceId) || '')
5034
+ const [signalTesting, setSignalTesting] = React.useState(false)
5035
+ const [currentRms, setCurrentRms] = React.useState(0)
5036
+ const [peakRms, setPeakRms] = React.useState(0)
5037
+ const [testingStt, setTestingStt] = React.useState(false)
5038
+ const [testCountdown, setTestCountdown] = React.useState(0)
5039
+ const [testResult, setTestResult] = React.useState(null)
5040
+ const [testError, setTestError] = React.useState('')
5041
+ const [testLatency, setTestLatency] = React.useState(null)
5042
+
5043
+ const signalStreamRef = React.useRef(null)
5044
+ const audioCtxRef = React.useRef(null)
5045
+ const animFrameRef = React.useRef(null)
5046
+
5047
+ React.useEffect(() => {
5048
+ if (typeof navigator !== 'undefined' && navigator.permissions && navigator.permissions.query) {
5049
+ navigator.permissions.query({ name: 'microphone' })
5050
+ .then((p) => {
5051
+ if (p) {
5052
+ setMicPermission(p.state)
5053
+ p.onchange = () => setMicPermission(p.state)
5054
+ }
5055
+ })
5056
+ .catch(() => {})
5057
+ }
5058
+ }, [])
5059
+
5060
+ const refreshDevices = () => {
5061
+ if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
5062
+ navigator.mediaDevices.enumerateDevices()
5063
+ .then((list) => {
5064
+ const audioInputs = list.filter((d) => d.kind === 'audioinput')
5065
+ setDevices(audioInputs)
5066
+ })
5067
+ .catch(() => {})
5068
+ }
5069
+
5070
+ React.useEffect(() => {
5071
+ refreshDevices()
5072
+ }, [])
5073
+
5074
+ const stopSignalTest = () => {
5075
+ if (animFrameRef.current) {
5076
+ cancelAnimationFrame(animFrameRef.current)
5077
+ animFrameRef.current = null
5078
+ }
5079
+ if (signalStreamRef.current) {
5080
+ try { signalStreamRef.current.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
5081
+ signalStreamRef.current = null
5082
+ }
5083
+ if (audioCtxRef.current) {
5084
+ try { audioCtxRef.current.close() } catch (_) {}
5085
+ audioCtxRef.current = null
5086
+ }
5087
+ setSignalTesting(false)
5088
+ setCurrentRms(0)
5089
+ }
5090
+
5091
+ React.useEffect(() => {
5092
+ return () => {
5093
+ stopSignalTest()
5094
+ }
5095
+ }, [])
5096
+
5097
+ const requestPermissionAndTestSignal = async () => {
5098
+ stopSignalTest()
5099
+ setTestError('')
5100
+ try {
5101
+ const constraints = {
5102
+ audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
5103
+ }
5104
+ const stream = await navigator.mediaDevices.getUserMedia(constraints)
5105
+ signalStreamRef.current = stream
5106
+ setMicPermission('granted')
5107
+ refreshDevices()
5108
+
5109
+ const AC = typeof AudioContext !== 'undefined' ? AudioContext
5110
+ : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
5111
+ if (!AC) return
5112
+ const actx = new AC()
5113
+ audioCtxRef.current = actx
5114
+ if (actx.state === 'suspended') { actx.resume().catch(() => {}) }
5115
+ const src = actx.createMediaStreamSource(stream)
5116
+ const analyser = actx.createAnalyser()
5117
+ analyser.fftSize = 128
5118
+ src.connect(analyser)
5119
+
5120
+ setSignalTesting(true)
5121
+ const buf = new Uint8Array(analyser.frequencyBinCount)
5122
+
5123
+ const checkLevel = () => {
5124
+ if (!signalStreamRef.current) return
5125
+ analyser.getByteFrequencyData(buf)
5126
+ let sum = 0
5127
+ for (let i = 0; i < buf.length; i++) sum += buf[i]
5128
+ const rms = Math.min(100, Math.round((sum / buf.length / 255) * 100 * 2.2))
5129
+ setCurrentRms(rms)
5130
+ setPeakRms((prev) => Math.max(prev, rms))
5131
+ animFrameRef.current = requestAnimationFrame(checkLevel)
5132
+ }
5133
+ animFrameRef.current = requestAnimationFrame(checkLevel)
5134
+ } catch (err) {
5135
+ setMicPermission('denied')
5136
+ setTestError(String(err && err.message ? err.message : err))
5137
+ stopSignalTest()
5138
+ }
5139
+ }
5140
+
5141
+ const runSttTest = async () => {
5142
+ stopSignalTest()
5143
+ setTestError('')
5144
+ setTestResult(null)
5145
+ setTestLatency(null)
5146
+ setTestingStt(true)
5147
+ setTestCountdown(3)
5148
+
5149
+ let testStream = null
5150
+ let recorder = null
5151
+ const chunks = []
5152
+
5153
+ try {
5154
+ const constraints = {
5155
+ audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
5156
+ }
5157
+ testStream = await navigator.mediaDevices.getUserMedia(constraints)
5158
+ let mime = 'audio/webm;codecs=opus'
5159
+ if (!MediaRecorder.isTypeSupported(mime)) mime = ''
5160
+ recorder = mime ? new MediaRecorder(testStream, { mimeType: mime }) : new MediaRecorder(testStream)
5161
+ recorder.ondataavailable = (e) => {
5162
+ if (e.data && e.data.size > 0) chunks.push(e.data)
5163
+ }
5164
+ recorder.start()
5165
+
5166
+ let left = 3
5167
+ const countdownTimer = setInterval(() => {
5168
+ left -= 1
5169
+ setTestCountdown(left)
5170
+ if (left <= 0) {
5171
+ clearInterval(countdownTimer)
5172
+ if (recorder.state !== 'inactive') recorder.stop()
5173
+ }
5174
+ }, 1000)
5175
+
5176
+ await new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
5177
+
5178
+ try { testStream.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
5179
+
5180
+ const blob = new Blob(chunks, { type: mime || 'audio/webm' })
5181
+ if (blob.size < 400) {
5182
+ setTestError(t('wizardTestEmpty'))
5183
+ setTestingStt(false)
5184
+ return
5185
+ }
5186
+
5187
+ setTestCountdown(0)
5188
+ const startMs = Date.now()
5189
+ const dataBase64 = await blobToBase64(blob)
5190
+ const payload = {
5191
+ dataBase64,
5192
+ mimeType: mime || recorder.mimeType || 'audio/webm',
5193
+ mode: 'dictation',
5194
+ subMode: 'clean',
5195
+ }
5196
+
5197
+ const res = await fetch('/dsh-voice/transcribe', {
5198
+ method: 'POST',
5199
+ headers: { 'content-type': 'application/json' },
5200
+ body: JSON.stringify(payload),
5201
+ })
5202
+ const data = await res.json()
5203
+ const latency = Date.now() - startMs
5204
+ setTestLatency(latency)
5205
+
5206
+ if (!res.ok || !data || !data.ok) {
5207
+ const msg = (data && data.error && data.error.message) || `HTTP ${res.status}`
5208
+ setTestError(msg)
5209
+ } else {
5210
+ const recognized = String(data.text || '').trim()
5211
+ if (recognized) {
5212
+ setTestResult(recognized)
5213
+ if (typeof setTop === 'function') {
5214
+ setTop('wizardCompleted', true)
5115
5215
  }
5216
+ } else {
5217
+ setTestError(t('wizardTestEmpty'))
5218
+ }
5219
+ }
5220
+ } catch (err) {
5221
+ setTestError(String(err && err.message ? err.message : err))
5222
+ } finally {
5223
+ if (testStream) {
5224
+ try { testStream.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
5225
+ }
5226
+ setTestingStt(false)
5227
+ }
5228
+ }
5229
+
5230
+ // Determine active chain info
5231
+ const dictationChain = (draft && draft.dictation && draft.dictation.chain) || []
5232
+ const activeProvider = (dictationChain[0] && (dictationChain[0].provider || dictationChain[0])) || 'sensevoice'
5233
+ let areaLabel = t('wizardAreaServer')
5234
+ if (activeProvider === 'browser' || activeProvider === 'browser-webgpu') {
5235
+ areaLabel = t('wizardAreaBrowser')
5236
+ } else if (activeProvider !== 'sensevoice' && activeProvider !== 'local-whisper') {
5237
+ areaLabel = t('wizardAreaCloud')
5238
+ }
5239
+
5240
+ let nextStepHint = ''
5241
+ if (!isSecure) {
5242
+ nextStepHint = t('wizardHintHttps')
5243
+ } else if (micPermission === 'denied') {
5244
+ nextStepHint = t('wizardHintPermission')
5245
+ } else if (peakRms === 0 && signalTesting) {
5246
+ nextStepHint = t('wizardHintDevice')
5247
+ } else if (testError) {
5248
+ nextStepHint = t('wizardHintProvider')
5249
+ }
5250
+
5251
+ return h('div', { className: 'cb-section-card', style: { borderColor: 'var(--dsw-alias-state-brand-primary)' } },
5252
+ h('div', { className: 'cb-section-title' },
5253
+ h('span', null, '✨ ' + t('wizardTitle')),
5254
+ h('div', { style: { display: 'flex', gap: '8px' } },
5255
+ testResult ? h('span', { className: 'cb-badge cb-badge-ok' }, '✓ ' + t('wizardStatusCompleted')) : null,
5256
+ onClose ? h('button', {
5257
+ type: 'button', className: 'cb-btn cb-btn-mini', onClick: onClose, title: t('wizardClose'),
5258
+ }, '✕') : null,
5259
+ ),
5260
+ ),
5261
+ h('div', { className: 'cb-section-desc' }, t('wizardDesc')),
5262
+
5263
+ // Step 1: Environment & Permissions
5264
+ h('div', { className: 'dvs-card' },
5265
+ h('div', { className: 'dvs-h' }, t('wizardStep1')),
5266
+ h('div', { className: 'cb-row' },
5267
+ h('span', { className: 'cb-badge ' + (isSecure ? 'cb-badge-ok' : 'cb-badge-err') },
5268
+ isSecure ? ('✓ ' + t('wizardSecureContextOk')) : ('✗ ' + t('wizardSecureContextBad'))
5269
+ ),
5270
+ h('span', { className: 'cb-badge ' + (hasMediaDevices ? 'cb-badge-ok' : 'cb-badge-err') },
5271
+ hasMediaDevices ? ('✓ ' + t('wizardMediaDevicesOk')) : ('✗ ' + t('wizardMediaDevicesBad'))
5272
+ ),
5273
+ h('span', { className: 'cb-badge ' + (hasMediaRecorder ? 'cb-badge-ok' : 'cb-badge-err') },
5274
+ hasMediaRecorder ? ('✓ ' + t('wizardMediaRecorderOk')) : ('✗ ' + t('wizardMediaRecorderBad'))
5275
+ ),
5276
+ h('span', { className: 'cb-badge ' + (micPermission === 'granted' ? 'cb-badge-ok' : (micPermission === 'denied' ? 'cb-badge-err' : 'cb-badge-warn')) },
5277
+ micPermission === 'granted' ? ('✓ ' + t('wizardMicPermissionOk')) : (micPermission === 'denied' ? ('✗ ' + t('wizardMicPermissionBad')) : ('· ' + t('wizardMicPermissionPrompt')))
5278
+ ),
5279
+ ),
5280
+ h('div', { className: 'cb-field', style: { marginTop: '8px' } },
5281
+ h('label', null, t('wizardMicLabel')),
5282
+ h('div', { className: 'cb-row' },
5283
+ h('select', {
5284
+ value: selectedDev,
5285
+ style: { flex: 1, minWidth: '240px' },
5286
+ onChange: (e) => {
5287
+ setSelectedDev(e.target.value)
5288
+ if (typeof setTop === 'function') setTop('micDeviceId', e.target.value)
5289
+ stopSignalTest()
5290
+ },
5291
+ },
5292
+ h('option', { value: '' }, 'Default System Microphone'),
5293
+ devices.map((d) => h('option', { key: d.deviceId, value: d.deviceId }, d.label || `Microphone ${d.deviceId.slice(0, 8)}…`))
5294
+ ),
5295
+ h('button', {
5296
+ type: 'button',
5297
+ className: 'cb-btn',
5298
+ onClick: requestPermissionAndTestSignal,
5299
+ }, t('wizardRequestMic')),
5300
+ ),
5301
+ ),
5302
+ ),
5303
+
5304
+ // Step 2: Live Audio Signal Test
5305
+ h('div', { className: 'dvs-card' },
5306
+ h('div', { className: 'dvs-h' }, t('wizardStep2')),
5307
+ h('div', { className: 'cb-row', style: { alignItems: 'center' } },
5308
+ h('button', {
5309
+ type: 'button',
5310
+ className: 'cb-btn ' + (signalTesting ? 'cb-btn-danger' : 'cb-btn-primary'),
5311
+ onClick: signalTesting ? stopSignalTest : requestPermissionAndTestSignal,
5312
+ }, signalTesting ? t('wizardStopSignalCheck') : t('wizardStartSignalCheck')),
5313
+ signalTesting ? h('span', { className: 'dvs-sub' }, t('wizardSignalTesting')) : null,
5314
+ ),
5315
+ signalTesting ? h('div', { style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '6px' } },
5316
+ h('div', {
5317
+ style: {
5318
+ height: '14px',
5319
+ borderRadius: '7px',
5320
+ background: 'var(--dsw-alias-bg-layer-1)',
5321
+ border: '1px solid var(--dsw-alias-border-l1)',
5322
+ overflow: 'hidden',
5323
+ position: 'relative',
5324
+ },
5325
+ },
5326
+ h('div', {
5327
+ style: {
5328
+ width: `${currentRms}%`,
5329
+ height: '100%',
5330
+ background: currentRms > 5 ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-dimmed)',
5331
+ transition: 'width 0.05s linear',
5332
+ },
5333
+ }),
5334
+ ),
5335
+ h('span', { className: currentRms > 5 ? 'dvs-ok' : 'dvs-sub' },
5336
+ currentRms > 5
5337
+ ? t('wizardSignalOk').replace('{level}', currentRms)
5338
+ : t('wizardSignalZero')
5339
+ ),
5340
+ ) : null,
5341
+ ),
5342
+
5343
+ // Step 3: Transcription Round-Trip Test
5344
+ h('div', { className: 'dvs-card' },
5345
+ h('div', { className: 'dvs-h' }, t('wizardStep3')),
5346
+ h('div', { className: 'cb-row' },
5347
+ h('span', { className: 'dvs-sub' }, `${t('wizardProviderLabel')} ${activeProvider}`),
5348
+ h('span', { className: 'cb-badge' }, `${t('wizardProcessingArea')} ${areaLabel}`),
5349
+ ),
5350
+ h('div', { className: 'cb-row', style: { marginTop: '8px' } },
5351
+ h('button', {
5352
+ type: 'button',
5353
+ className: 'cb-btn cb-btn-primary',
5354
+ disabled: testingStt || micPermission === 'denied',
5355
+ onClick: runSttTest,
5356
+ }, testingStt
5357
+ ? (testCountdown > 0 ? t('wizardRecordingTest').replace('{left}', testCountdown) : t('wizardTranscribingTest'))
5358
+ : t('wizardStartTestRecord')
5359
+ ),
5360
+ ),
5361
+ testResult ? h('div', { style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '4px' } },
5362
+ h('div', { className: 'dvs-ok' },
5363
+ '✓ ' + t('wizardTestSuccess').replace('{latency}', testLatency || 0)
5364
+ ),
5365
+ h('blockquote', {
5366
+ style: {
5367
+ margin: '4px 0 0 0',
5368
+ padding: '8px 12px',
5369
+ borderRadius: '6px',
5370
+ background: 'var(--dsw-alias-bg-layer-3)',
5371
+ borderLeft: '4px solid var(--dsw-alias-state-success-primary)',
5372
+ color: 'var(--dsw-alias-label-primary)',
5373
+ fontStyle: 'italic',
5374
+ },
5375
+ }, `“${testResult}”`),
5376
+ ) : null,
5377
+ testError ? h('div', { className: 'cb-alert-err', style: { marginTop: '8px' } },
5378
+ t('wizardTestFailed').replace('{error}', testError)
5379
+ ) : null,
5380
+ ),
5381
+
5382
+ // Next Step Hint / Feedback
5383
+ nextStepHint ? h('div', { className: 'dvs-wait', style: { fontStyle: 'italic' } },
5384
+ t('wizardNextStepHint').replace('{hint}', nextStepHint)
5385
+ ) : null,
5386
+
5387
+ // Wizard Bottom Actions
5388
+ h('div', { className: 'cb-row', style: { justifyContent: 'space-between', marginTop: '6px' } },
5389
+ h('button', {
5390
+ type: 'button',
5391
+ className: 'cb-btn',
5392
+ onClick: () => {
5393
+ if (typeof setTop === 'function') setTop('wizardCompleted', true)
5394
+ if (onClose) onClose()
5395
+ },
5396
+ }, t('wizardSkip')),
5397
+ testResult ? h('button', {
5398
+ type: 'button',
5399
+ className: 'cb-btn cb-btn-primary',
5400
+ onClick: () => {
5401
+ if (typeof setTop === 'function') setTop('wizardCompleted', true)
5402
+ if (onClose) onClose()
5403
+ },
5404
+ }, '✓ ' + t('wizardFinishSuccess')) : null,
5405
+ ),
5406
+ )
5407
+ }
5116
5408
  // Card in Settings → Plugins → Plugin settings (#18)
5117
5409
  function PluginCard(props) {
5118
5410
  const page = !!(props && props.view === 'page')
@@ -5122,12 +5414,12 @@ function MacrosSection(props) {
5122
5414
  // Row seat (plugins.row.config): the host page draws title/icon/crumb and the
5123
5415
  // padding, so the summary is a one-liner and the page drops our card chrome.
5124
5416
  if (props && props.view === 'summary') {
5125
- return React.createElement('span', { className: 'dvo-pdesc' }, tt('cardHint'))
5417
+ return h('span', { className: 'dvo-pdesc' }, tt('cardHint'))
5126
5418
  }
5127
- return React.createElement(ErrorBoundary, null,
5128
- React.createElement(page ? 'div' : 'li',
5419
+ return h(ErrorBoundary, null,
5420
+ h(page ? 'div' : 'li',
5129
5421
  { className: page ? 'dvo-ppage' : (open ? 'dvo-pcard dvo-pcardOpen' : 'dvo-pcard') },
5130
- React.createElement('button',
5422
+ h('button',
5131
5423
  {
5132
5424
  type: 'button', className: 'dvo-phead',
5133
5425
  style: page ? { display: 'none' } : undefined,
@@ -5135,13 +5427,13 @@ function MacrosSection(props) {
5135
5427
  'aria-label': (open ? tt('collapse') : tt('expand')) + ': ' + title,
5136
5428
  onClick: () => setOpen((v) => !v),
5137
5429
  },
5138
- React.createElement('span', { className: 'dvo-pheadtext' },
5139
- React.createElement('span', { className: 'dvo-ptitle' }, title),
5140
- React.createElement('span', { className: 'dvo-pdesc' }, tt('cardHint')),
5430
+ h('span', { className: 'dvo-pheadtext' },
5431
+ h('span', { className: 'dvo-ptitle' }, title),
5432
+ h('span', { className: 'dvo-pdesc' }, tt('cardHint')),
5141
5433
  ),
5142
- React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
5434
+ h('span', { className: 'dvo-pchev' }, chevronIcon()),
5143
5435
  ),
5144
- (page || open) ? React.createElement('div', { className: 'dvo-pbody dvo-settings-root' }, React.createElement(VoiceSection, props)) : null,
5436
+ (page || open) ? h('div', { className: 'dvo-pbody dvo-settings-root' }, h(VoiceSection, props)) : null,
5145
5437
  ),
5146
5438
  )
5147
5439
  }