@goodandready/dsh-voice 0.9.37 → 0.9.38

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
@@ -109,6 +109,8 @@ window.__ModuleLoader__.load({
109
109
  'undo': 'Undo last insert',
110
110
  'undone': 'Insert undone',
111
111
  'nothingToUndo': 'Nothing to undo',
112
+ 'clipping': 'Clipping', 'clippingHint': 'Too loud (clipping)',
113
+ 'lowVolume': 'Low signal', 'lowVolumeHint': 'Speak louder',
112
114
  'beep': 'Beep on start/stop',
113
115
  'localOnly': 'Local whisper only',
114
116
  'localOnlyHint': 'Restrict both chains to the local whisper.cpp server: fully offline.',
@@ -398,6 +400,8 @@ window.__ModuleLoader__.load({
398
400
  'undo': '撤销上次插入',
399
401
  'undone': '插入已撤销',
400
402
  'nothingToUndo': '没有可撤销的内容',
403
+ 'clipping': '爆音', 'clippingHint': '音量过大(爆音截断)',
404
+ 'lowVolume': '信号微弱', 'lowVolumeHint': '请提高音量',
401
405
  'beep': '启停提示音',
402
406
  'localOnly': '仅使用本地 whisper',
403
407
  'localOnlyHint': '将双链限制为仅使用本地 whisper.cpp 服务:纯离线运行。',
@@ -616,7 +620,7 @@ window.__ModuleLoader__.load({
616
620
  const t = (key) => moduleT(key)
617
621
  // ------------------------------------------------------------------ css
618
622
  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)}'
623
+ '.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
624
  const cssId = 'dsh-voice/client.module.css'
621
625
  if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {const tag = document.createElement('style')
622
626
  tag.textContent = CSS
@@ -772,6 +776,9 @@ window.__ModuleLoader__.load({
772
776
  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
777
  React.createElement('line', { x1: 12, y1: 9, x2: 12, y2: 13 }),
774
778
  React.createElement('line', { x1: 12, y1: 17, x2: 12.01, y2: 17 }))
779
+ const undoIcon = () => React.createElement('svg', ic,
780
+ React.createElement('path', { d: 'M9 14L4 9l5-5' }),
781
+ React.createElement('path', { d: 'M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v.5' }))
775
782
  const chevronIcon = () => (
776
783
  ChevronIcon
777
784
  ? React.createElement(ChevronIcon, { className: 'dvo-chev-icon' })
@@ -854,7 +861,7 @@ window.__ModuleLoader__.load({
854
861
  return words
855
862
  }
856
863
 
857
- async function sendAudio(blob, mime, mode) {
864
+ async function sendAudio(blob, mime, mode, audioQuality) {
858
865
  const activeChain = (voice._activeChain || [])
859
866
  const modelMap = voice._activeModelMap || {}
860
867
  const chosenModel = modelMap['browser-webgpu'] || (voice.settings && voice.settings.webgpuModel)
@@ -866,6 +873,8 @@ window.__ModuleLoader__.load({
866
873
  || (voice.settings && voice.settings.dictationMode)
867
874
  || 'clean'
868
875
  const payload = { dataBase64, mimeType: mime, mode, subMode }
876
+ if (audioQuality && audioQuality.clipping) payload.clipping = true
877
+ if (audioQuality && audioQuality.lowVolume) payload.lowVolume = true
869
878
  if (Array.isArray(chainOverride)) {
870
879
  payload.chain = chainOverride.map((p) => (typeof p === 'string' ? { provider: p, model: modelMap[p] } : p))
871
880
  }
@@ -990,7 +999,7 @@ window.__ModuleLoader__.load({
990
999
  async function undoLastInsert() {
991
1000
  const last = insertHistory.pop()
992
1001
  if (!last) return t('nothingToUndo')
993
- const actions = voice.inputActions
1002
+ const actions = (voice._activeTarget && voice._activeTarget.actions) || voice.inputActions
994
1003
  if (!actions || typeof actions.setDraft !== 'function') return t('composerUnavailable')
995
1004
  const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
996
1005
  if (draft === last.after) actions.setDraft(last.before)
@@ -1001,6 +1010,10 @@ window.__ModuleLoader__.load({
1001
1010
  const spliced = draft.slice(0, i) + draft.slice(i + last.added.length)
1002
1011
  actions.setDraft(spliced.replace(/[ \t]{2,}/g, ' ').replace(/\s+$/, '').trimStart())
1003
1012
  }
1013
+ const chunk = String(last.added || '').trim()
1014
+ voice.lastUndoneChunk = chunk
1015
+ voice.lastUndoNotice = t('undone') + (chunk ? ': "' + (chunk.length > 25 ? chunk.slice(0, 22) + '…' : chunk) + '"' : '')
1016
+ if (typeof actions.focus === 'function') { try { actions.focus() } catch (_) {} }
1004
1017
  return t('undone')
1005
1018
  }
1006
1019
 
@@ -1198,7 +1211,7 @@ window.__ModuleLoader__.load({
1198
1211
 
1199
1212
  // Fetch the mode chain from the host once. Only needed to decide whether
1200
1213
 
1201
- voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, insertHistory, insertDraftText, appendDraft, resolveBrowserRecognitionLang }
1214
+ voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, undoLastChunk: undoLastInsert, insertHistory, insertDraftText, appendDraft, resolveBrowserRecognitionLang }
1202
1215
  // lib/client-src/35-webgpu.js — Client-side WebGPU/WASM Whisper offline recognition
1203
1216
  // Detects WebGPU and WebAssembly availability in browser and provides offline transcription with graceful WASM fallback.
1204
1217
 
@@ -1428,6 +1441,34 @@ voice._chainsPromise = null
1428
1441
  let sum = 0
1429
1442
  for (let i = 0; i < data.length; i++) sum += data[i]
1430
1443
  const raw = Math.min(1, (sum / data.length / 255) * 2.2)
1444
+
1445
+ if (rec.analyser.getByteTimeDomainData) {
1446
+ if (!rec._waveBuf || rec._waveBuf.length !== rec.analyser.fftSize) {
1447
+ rec._waveBuf = new Uint8Array(rec.analyser.fftSize)
1448
+ }
1449
+ rec.analyser.getByteTimeDomainData(rec._waveBuf)
1450
+ let clipped = false
1451
+ for (let i = 0; i < rec._waveBuf.length; i++) {
1452
+ const v = rec._waveBuf[i]
1453
+ if (v <= 1 || v >= 254) { clipped = true; break }
1454
+ }
1455
+ const now = Date.now()
1456
+ if (clipped) {
1457
+ rec.clippingUntil = now + 800
1458
+ rec.hasClipped = true
1459
+ }
1460
+ if (rec.hadSpeech && raw > 0 && raw < 0.08) {
1461
+ rec.lowVolumeUntil = now + 800
1462
+ rec.hasLowVolume = true
1463
+ }
1464
+ const isClipping = Boolean(rec.clippingUntil && rec.clippingUntil > now)
1465
+ const isLow = Boolean(!isClipping && rec.lowVolumeUntil && rec.lowVolumeUntil > now)
1466
+ if (!voice.audioQuality || voice.audioQuality.clipping !== isClipping || voice.audioQuality.lowVolume !== isLow) {
1467
+ voice.audioQuality = { clipping: isClipping, lowVolume: isLow }
1468
+ voice.notify()
1469
+ }
1470
+ }
1471
+
1431
1472
  const gateDb = Number(voice.settings && voice.settings.noiseGateDb !== undefined ? voice.settings.noiseGateDb : -45)
1432
1473
  if (gateDb > -90) {
1433
1474
  const gateAmp = Math.pow(10, gateDb / 20) * 2.2
@@ -1465,10 +1506,13 @@ voice._chainsPromise = null
1465
1506
  }
1466
1507
  rec.cutting = false
1467
1508
  if (rec.cancelled || blob.size < 600) return // cancelled or too short — not speech
1509
+ const q = { clipping: Boolean(rec.hasClipped), lowVolume: Boolean(rec.hasLowVolume) }
1510
+ rec.hasClipped = false
1511
+ rec.hasLowVolume = false
1468
1512
  dictationQueue = dictationQueue.then(async () => {
1469
1513
  if (rec.cancelled || voice.activeOpId !== opId || voice.disposed) return
1470
1514
  try {
1471
- const out = await sendAudio(blob, rec.mime, 'dictation')
1515
+ const out = await sendAudio(blob, rec.mime, 'dictation', q)
1472
1516
  if (rec.cancelled || voice.activeOpId !== opId || voice.disposed) return
1473
1517
  const text = out && out.text ? out.text : ''
1474
1518
  const action = out && out.action ? out.action : null
@@ -1478,7 +1522,7 @@ voice._chainsPromise = null
1478
1522
  return
1479
1523
  }
1480
1524
  }
1481
- if (action === 'send' || action === 'clear' || action === 'newline') {
1525
+ if (action === 'send' || action === 'clear' || action === 'newline' || action === 'undo_chunk') {
1482
1526
  executeVoiceAction(action, text, target)
1483
1527
  return
1484
1528
  }
@@ -1712,10 +1756,13 @@ voice._chainsPromise = null
1712
1756
  try { noteUrl = URL.createObjectURL(blob) } catch (e) {}
1713
1757
  voice.lastNote = { blob, url: noteUrl, mime: rec.mime, text: '' }
1714
1758
  voice.set({ phase: 'processing' })
1759
+ const q = { clipping: Boolean(rec.hasClipped), lowVolume: Boolean(rec.hasLowVolume) }
1760
+ rec.hasClipped = false
1761
+ rec.hasLowVolume = false
1715
1762
  const processTail = async () => {
1716
1763
  if (voice.disposed || rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1717
1764
  try {
1718
- const out = await sendAudio(blob, rec.mime, mode)
1765
+ const out = await sendAudio(blob, rec.mime, mode, q)
1719
1766
  if (voice.disposed || rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1720
1767
  if (out.command) { runSessionCommand(out.command); return }
1721
1768
  const text = out.text || ''
@@ -1732,7 +1779,7 @@ voice._chainsPromise = null
1732
1779
  return
1733
1780
  }
1734
1781
  }
1735
- if (action === 'send' || action === 'clear' || action === 'newline') {
1782
+ if (action === 'send' || action === 'clear' || action === 'newline' || action === 'undo_chunk') {
1736
1783
  executeVoiceAction(action, text, target)
1737
1784
  return
1738
1785
  }
@@ -1802,6 +1849,17 @@ voice._chainsPromise = null
1802
1849
  const payload = (rawText.replace(/(^|[\s,.!?])(с новой строки|новая строка|абзац|new line|paragraph)([\s,.!?]|$)/gi, '').trim())
1803
1850
  appendDraft(payload, activeTarget)
1804
1851
  voice.set({ phase: 'idle' })
1852
+ } else if (action === 'undo_chunk') {
1853
+ undoLastInsert().then((msg) => {
1854
+ voice.lastUndoNotice = msg
1855
+ voice.notify()
1856
+ setTimeout(() => {
1857
+ if (voice.lastUndoNotice === msg) {
1858
+ voice.lastUndoNotice = ''
1859
+ voice.notify()
1860
+ }
1861
+ }, 3500)
1862
+ })
1805
1863
  }
1806
1864
  }
1807
1865
 
@@ -1818,6 +1876,7 @@ voice._chainsPromise = null
1818
1876
  else if (cmd === 'cancel') cancelCurrent()
1819
1877
  else if (cmd === 'stop') cancelCurrent()
1820
1878
  else if (cmd === 'continue') { /* keep recording */ }
1879
+ else if (cmd === 'undo_chunk') { executeVoiceAction('undo_chunk') }
1821
1880
  }
1822
1881
 
1823
1882
  function keepPending() {
@@ -2020,6 +2079,21 @@ voice._chainsPromise = null
2020
2079
  if (typeof document === 'undefined' || !keyName) return () => {}
2021
2080
  const down = (event) => {
2022
2081
  if (event.repeat) return
2082
+ if (event.shiftKey && (event.key === 'Tab' || event.code === 'Tab') && (voice.phase === 'recording' || voice.phase === 'pending')) {
2083
+ event.preventDefault()
2084
+ event.stopPropagation()
2085
+ undoLastInsert().then((msg) => {
2086
+ voice.lastUndoNotice = msg
2087
+ voice.notify()
2088
+ setTimeout(() => {
2089
+ if (voice.lastUndoNotice === msg) {
2090
+ voice.lastUndoNotice = ''
2091
+ voice.notify()
2092
+ }
2093
+ }, 3500)
2094
+ })
2095
+ return
2096
+ }
2023
2097
  const target = event.target
2024
2098
  const isEditable = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || (typeof target.getAttribute === 'function' && target.getAttribute('contenteditable') === 'true'))
2025
2099
  if (isEditable && !isAllowedInEditable(keyName)) return
@@ -2474,6 +2548,28 @@ function cycleDictationMode() {
2474
2548
  voice.currentSessionId = (props.session && (props.session.sessionId || props.session.id)) || voice.currentSessionId || ''
2475
2549
  const ctx = props.ctx
2476
2550
 
2551
+ React.useEffect(() => {
2552
+ if (v.phase !== 'recording' && v.phase !== 'pending') return
2553
+ const onKey = (e) => {
2554
+ if (e.shiftKey && (e.key === 'Tab' || e.code === 'Tab')) {
2555
+ e.preventDefault()
2556
+ e.stopPropagation()
2557
+ undoLastInsert().then((msg) => {
2558
+ voice.lastUndoNotice = msg
2559
+ voice.notify()
2560
+ setTimeout(() => {
2561
+ if (voice.lastUndoNotice === msg) {
2562
+ voice.lastUndoNotice = ''
2563
+ voice.notify()
2564
+ }
2565
+ }, 3500)
2566
+ })
2567
+ }
2568
+ }
2569
+ window.addEventListener('keydown', onKey, true)
2570
+ return () => window.removeEventListener('keydown', onKey, true)
2571
+ }, [v.phase])
2572
+
2477
2573
  // VAD: accumulate silence and cut the phrase when the pause exceeds the threshold.
2478
2574
  React.useEffect(() => {
2479
2575
  if (v.phase !== 'recording') return
@@ -2548,6 +2644,10 @@ function cycleDictationMode() {
2548
2644
  if (!voice.waveColor) {
2549
2645
  try { voice.waveColor = getComputedStyle(canvas).color || 'currentColor' } catch (e) { voice.waveColor = 'currentColor' }
2550
2646
  }
2647
+ let drawColor = voice.waveColor
2648
+ if (voice.audioQuality && (voice.audioQuality.clipping || voice.audioQuality.lowVolume)) {
2649
+ try { drawColor = getComputedStyle(canvas).color || voice.waveColor } catch (_) {}
2650
+ }
2551
2651
  const levels = voice.levels
2552
2652
  const style = (voice.settings && voice.settings.visualizerStyle) || 'liquid-wave'
2553
2653
  frame++
@@ -2555,18 +2655,18 @@ function cycleDictationMode() {
2555
2655
  if (style === 'off') {
2556
2656
  g.beginPath()
2557
2657
  g.globalAlpha = 0.25
2558
- g.strokeStyle = voice.waveColor
2658
+ g.strokeStyle = drawColor
2559
2659
  g.lineWidth = 1
2560
2660
  g.moveTo(10, cssH / 2)
2561
2661
  g.lineTo(cssW - 10, cssH / 2)
2562
2662
  g.stroke()
2563
2663
  g.globalAlpha = 1
2564
2664
  } else if (style === 'dynamic-orb') {
2565
- drawDynamicOrb(g, cssW, cssH, levels, voice.waveColor, frame)
2665
+ drawDynamicOrb(g, cssW, cssH, levels, drawColor, frame)
2566
2666
  } else if (style === 'bars') {
2567
- drawClassicBars(g, cssW, cssH, levels, voice.waveColor)
2667
+ drawClassicBars(g, cssW, cssH, levels, drawColor)
2568
2668
  } else {
2569
- drawLiquidWave(g, cssW, cssH, levels, voice.waveColor, frame)
2669
+ drawLiquidWave(g, cssW, cssH, levels, drawColor, frame)
2570
2670
  }
2571
2671
  g.restore()
2572
2672
  }
@@ -2663,7 +2763,10 @@ function cycleDictationMode() {
2663
2763
  }, xIcon()),
2664
2764
  inBrowser
2665
2765
  ? null
2666
- : React.createElement('canvas', { className: 'dvo-wave', ref: canvasRef, width: 720, height: 40 }),
2766
+ : React.createElement('canvas', {
2767
+ className: 'dvo-wave' + (voice.audioQuality?.clipping ? ' dvo-wave-clipping' : (voice.audioQuality?.lowVolume ? ' dvo-wave-low' : '')),
2768
+ ref: canvasRef, width: 720, height: 40,
2769
+ }),
2667
2770
  React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' },
2668
2771
  caption
2669
2772
  ? caption
@@ -2672,9 +2775,34 @@ function cycleDictationMode() {
2672
2775
  : (inBrowser
2673
2776
  ? t('listening')
2674
2777
  : (rec && rec.hadSpeech ? (v.speaking ? t('speaking') : t('silence')) : hint))),
2778
+ voice.audioQuality && voice.audioQuality.clipping
2779
+ ? React.createElement('span', { className: 'dvo-quality-badge dvo-quality-clipping', title: t('clippingHint') }, t('clipping'))
2780
+ : (voice.audioQuality && voice.audioQuality.lowVolume
2781
+ ? React.createElement('span', { className: 'dvo-quality-badge dvo-quality-low', title: t('lowVolumeHint') }, t('lowVolume'))
2782
+ : null),
2783
+ voice.lastUndoNotice
2784
+ ? React.createElement('span', { className: 'dvo-undo-badge', 'aria-live': 'assertive' }, voice.lastUndoNotice)
2785
+ : null,
2675
2786
  v.interimTranscript && v.settings.liveInterimPreview !== false
2676
2787
  ? React.createElement('span', { className: 'dvo-ghost' }, v.interimTranscript)
2677
2788
  : null),
2789
+ v.mode === 'dictation' && insertHistory.length > 0
2790
+ ? React.createElement('button', {
2791
+ type: 'button', className: 'dvo-pbtn', title: t('undo') + ' (Shift+Tab)',
2792
+ 'aria-label': t('undo'),
2793
+ onClick: async () => {
2794
+ const msg = await undoLastInsert()
2795
+ voice.lastUndoNotice = msg
2796
+ voice.notify()
2797
+ setTimeout(() => {
2798
+ if (voice.lastUndoNotice === msg) {
2799
+ voice.lastUndoNotice = ''
2800
+ voice.notify()
2801
+ }
2802
+ }, 3500)
2803
+ },
2804
+ }, undoIcon())
2805
+ : null,
2678
2806
  React.createElement('button', {
2679
2807
  type: 'button', className: 'dvo-pbtn', title: t('stop'),
2680
2808
  'aria-label': t('stop'),
@@ -2866,148 +2994,148 @@ function cycleDictationMode() {
2866
2994
  }, 'dsh-voice: composer slots')
2867
2995
  }
2868
2996
 
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',
2997
+ // ------------------------------------------------------- settings page
2998
+ const BUILTIN = [
2999
+ 'browser', 'browser-webgpu', 'deepgram', 'groq', 'hf', 'local-whisper', 'sensevoice',
3000
+ // Presets: address and model are filled on the host; only a key is needed.
3001
+ 'openai', 'siliconflow', 'deepinfra', 'fireworks', 'mistral', 'openrouter',
3002
+ ]
3003
+ const TEMPLATES = ['openai-transcriptions', 'openai-chat-audio']
3004
+ // Resolve at render time: t() must not be captured before locale bind.
3005
+ const MODEL_HINT_KEYS = {
3006
+ browser: 'browserHint',
3007
+ 'browser-webgpu': 'browserWebGpuHint',
3008
+ openai: 'openaiHint',
3009
+ siliconflow: 'siliconflowHint',
3010
+ deepinfra: 'deepinfraHint',
3011
+ fireworks: 'fireworksHint',
3012
+ mistral: 'mistralHint',
3013
+ openrouter: 'openrouterHint',
3014
+ 'local-whisper': 'localHint',
3015
+ sensevoice: 'sensevoiceHint',
3016
+ }
3017
+ const MODEL_HINT_STATIC = {
3018
+ 'browser-webgpu': 'onnx-community/whisper-tiny',
3019
+ deepgram: 'nova-2',
3020
+ groq: 'whisper-large-v3-turbo',
3021
+ hf: 'openai/whisper-large-v3',
3022
+ }
3023
+ function modelHint(provider) {
3024
+ const key = MODEL_HINT_KEYS[provider]
3025
+ if (key) return t(key)
3026
+ return MODEL_HINT_STATIC[provider] || ''
3027
+ }
3028
+ const SUPPORTED_LANGS = [
3029
+ { code: '', labelKey: 'langAuto', defaultLabel: 'auto' },
3030
+ { code: 'ru', labelKey: 'langRu', defaultLabel: 'ru' },
3031
+ { code: 'en', labelKey: 'langEn', defaultLabel: 'en' },
3032
+ { code: 'zh', labelKey: 'langZh', defaultLabel: 'zh' },
3033
+ { code: 'uk', labelKey: 'langUk', defaultLabel: 'uk' },
3034
+ { code: 'de', labelKey: 'langDe', defaultLabel: 'de' },
3035
+ ]
3036
+ const LANGS = SUPPORTED_LANGS.map((o) => o.code)
3037
+
3038
+ const SET_CSS =
3039
+ '.dvo-settings-root.cb-page,.dvo-settings-root .cb-page{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:960px}' +
3040
+ '.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}' +
3041
+ '.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}' +
3042
+ '.dvo-settings-root .cb-section-desc{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:-4px;line-height:1.4}' +
3043
+ '.dvo-settings-root .cb-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}' +
3044
+ '.dvo-settings-root .cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:12px}' +
3045
+ '.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}' +
3046
+ '.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)}' +
3047
+ '.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)}' +
3048
+ '.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)}' +
3049
+ '.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}' +
3050
+ '.dvo-settings-root .cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
3051
+ '.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}' +
3052
+ '.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))}' +
3053
+ '.dvo-settings-root .cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}' +
3054
+ '.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}' +
3055
+ '.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)}' +
3056
+ '.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}' +
3057
+ '.dvo-settings-root .cb-btn-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}' +
3058
+ '.dvo-settings-root .cb-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
3059
+ '.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}' +
3060
+ '.dvo-settings-root .cb-field textarea{height:auto;padding:8px 10px;font-family:inherit}' +
3061
+ '.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}' +
3062
+ '.dvo-settings-root .cb-check-label input[type="checkbox"]{width:16px;height:16px;cursor:pointer}' +
3063
+ '.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}' +
3064
+ '.dvs-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0;max-width:960px}' +
3065
+ '.dvs-block{display:flex;flex-direction:column;gap:10px}' +
3066
+ '.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
3067
+ '.dvs-sub{font-size:12px;color:var(--dsw-alias-label-secondary)}' +
3068
+ '.dvs-row{display:flex;gap:8px;align-items:center}' +
3069
+ '.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}' +
3070
+ '.dvs-row .dvs-model{flex:1}' +
3071
+ '.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}' +
3072
+ '.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}' +
3073
+ '.dvs-wait{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5;max-width:520px}' +
3074
+ '.dvs-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}' +
3075
+ '.dvs-bad{font-size:12px;color:var(--dsw-alias-state-error-primary)}' +
3076
+ '.dvo-pcard{list-style:none;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px}' +
3077
+ '.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}' +
3078
+ '.dvo-pheadtext{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0}' +
3079
+ '.dvo-ptitle{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
3080
+ '.dvo-pdesc{color:var(--dsw-alias-label-secondary);font-size:13px}' +
3081
+ '.dvo-pchev{flex:none;display:flex;color:var(--dsw-alias-label-secondary);transition:transform .15s ease}' +
3082
+ '.dvo-pcardOpen .dvo-pchev{transform:rotate(180deg)}' +
3083
+ '.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}'
3084
+ const setCssId = 'dsh-voice/settings.module.css'
3085
+ function installSettingsCss() {
3086
+ if (typeof document === 'undefined') return () => {}
3087
+ let tag = document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + setCssId + '"]')
3088
+ if (!tag) {
3089
+ tag = document.createElement('style')
3090
+ tag.textContent = SET_CSS
3091
+ tag.setAttribute('data-dsh-plugin', 'dsh-voice')
3092
+ tag.dataset.pluginCss = setCssId
3093
+ document.head.appendChild(tag)
3094
+ }
3095
+ return () => {
3096
+ if (tag && tag.parentNode) tag.parentNode.removeChild(tag)
3097
+ }
3098
+ }
3099
+
3100
+ function createErrorBoundary() {
3101
+ if (!React || typeof React.Component !== 'function') {
3102
+ return function NoopBoundary(props) { return props?.children || null }
3103
+ }
3104
+ return class ErrorBoundary extends React.Component {
3105
+ constructor(props) {
3106
+ super(props)
3107
+ this.state = { hasError: false, error: null }
2894
3108
  }
2895
- function modelHint(provider) {
2896
- const key = MODEL_HINT_KEYS[provider]
2897
- if (key) return t(key)
2898
- return MODEL_HINT_STATIC[provider] || ''
3109
+ static getDerivedStateFromError(error) {
3110
+ return { hasError: true, error }
2899
3111
  }
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
- }
3112
+ componentDidCatch(error, errorInfo) {
3113
+ console.error('[dsh-voice] React Error:', error, errorInfo)
2970
3114
  }
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
- }
3115
+ render() {
3116
+ if (this.state.hasError) {
3117
+ return React.createElement(
3118
+ 'div',
3119
+ { className: 'cb-alert-err', style: { margin: '12px 0', padding: '14px', borderRadius: '8px' } },
3120
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ ' + t('uiError')),
3121
+ React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
3122
+ React.createElement(
3123
+ 'button',
3124
+ {
3125
+ type: 'button',
3126
+ className: 'cb-btn',
3127
+ style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
3128
+ onClick: () => this.setState({ hasError: false, error: null }),
3129
+ },
3130
+ t('retry'),
3131
+ ),
3132
+ )
3133
+ }
3134
+ return this.props?.children || null
3009
3135
  }
3010
- const ErrorBoundary = createErrorBoundary()
3136
+ }
3137
+ }
3138
+ const ErrorBoundary = createErrorBoundary()
3011
3139
 
3012
3140
  // One chain editor: provider+model rows with reordering.
3013
3141
  function ChainEditor(props) {
@@ -3317,570 +3445,570 @@ function DictationModesSection(props) {
3317
3445
  ),
3318
3446
  )
3319
3447
  }
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: {
3448
+ // 5. Hardware & Audio Engines Card
3449
+ function HardwareOptionsCard(props) {
3450
+ const t = props.t || moduleT
3451
+ const draft = props.draft
3452
+ const setTop = props.setTop
3453
+ const writable = props.writable
3454
+ const catching = props.catching
3455
+ const setCatching = props.setCatching
3456
+ const devices = props.devices || []
3457
+ const testingMic = props.testingMic
3458
+ const toggleTestMic = props.toggleTestMic
3459
+ const testLevel = props.testLevel || 0
3460
+ const gateDbVal = props.gateDbVal
3461
+ const gateThresholdPercent = props.gateThresholdPercent
3462
+ const calibrating = props.calibrating
3463
+ const calibrateMsg = props.calibrateMsg
3464
+ const calibrateNoise = props.calibrateNoise
3465
+ const onClearKey = props.onClearKey
3466
+
3467
+ const [benchmarking, setBenchmarking] = React.useState(false)
3468
+ const [benchmarkResult, setBenchmarkResult] = React.useState(null)
3469
+ const [benchmarkErr, setBenchmarkErr] = React.useState('')
3470
+
3471
+ const runBenchmark = () => {
3472
+ setBenchmarking(true)
3473
+ setBenchmarkErr('')
3474
+ fetch('/dsh-voice/benchmark', {
3475
+ method: 'POST',
3476
+ headers: {
3477
+ 'x-dsh-plugin-update': '1',
3478
+ 'Content-Type': 'application/json',
3479
+ },
3480
+ })
3481
+ .then((r) => r.json())
3482
+ .then((d) => {
3483
+ setBenchmarking(false)
3484
+ if (d && d.ok) setBenchmarkResult(d)
3485
+ else setBenchmarkErr((d && d.error && d.error.message) || 'Benchmark failed')
3486
+ })
3487
+ .catch((e) => {
3488
+ setBenchmarking(false)
3489
+ setBenchmarkErr((e && e.message) || 'Network error')
3490
+ })
3491
+ }
3492
+
3493
+ const textField = (key, label, hint) => React.createElement('div', { className: 'cb-field' },
3494
+ React.createElement('label', null, label),
3495
+ React.createElement('input', {
3496
+ type: 'text', value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
3497
+ onChange: (e) => setTop(key, e.target.value),
3498
+ }),
3499
+ React.createElement('span', { className: 'dvs-sub' }, hint),
3500
+ )
3501
+
3502
+ return React.createElement('div', { className: 'cb-section-card' },
3503
+ React.createElement('div', { className: 'cb-section-title' },
3504
+ React.createElement('span', null, '⚙️ ' + t('hardwareTitle')),
3505
+ ),
3506
+ React.createElement('div', { className: 'cb-section-desc' }, t('hardwareDesc')),
3507
+ // Hotkey picker
3508
+ React.createElement('div', { className: 'cb-field' },
3509
+ React.createElement('label', null, t('hotkey')),
3510
+ React.createElement('div', { className: 'cb-row' },
3511
+ React.createElement('button', {
3512
+ type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable,
3513
+ onClick: () => setCatching(true),
3514
+ }, catching ? t('pressKey') : keyLabel(draft && draft.hotkey)),
3515
+ React.createElement('button', {
3516
+ type: 'button', className: 'cb-btn cb-btn-mini', title: t('clearKey'),
3517
+ disabled: !writable, onClick: onClearKey,
3518
+ }, '×'),
3519
+ ),
3520
+ React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
3521
+ ),
3522
+ // Mic & noise gate
3523
+ React.createElement('div', { className: 'cb-field' },
3524
+ React.createElement('label', null, t('mic')),
3525
+ React.createElement('div', { className: 'cb-row' },
3526
+ React.createElement('select', {
3527
+ value: String((draft && draft.micDeviceId) ?? ''), disabled: !writable || testingMic,
3528
+ onChange: (e) => setTop('micDeviceId', e.target.value),
3529
+ style: { flex: 1 },
3530
+ },
3531
+ React.createElement('option', { value: '' }, t('micDefault')),
3532
+ devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
3533
+ d.label || d.deviceId.slice(0, 12))),
3534
+ ),
3535
+ React.createElement('button', {
3536
+ type: 'button',
3537
+ className: 'cb-btn ' + (testingMic ? 'cb-btn-primary' : 'cb-btn-secondary'),
3538
+ onClick: toggleTestMic,
3539
+ style: { flex: 'none' },
3540
+ }, testingMic ? t('stopTest') : t('testMic')),
3541
+ React.createElement('button', {
3542
+ type: 'button',
3543
+ className: 'cb-btn cb-btn-secondary',
3544
+ onClick: () => (typeof calibrateNoise === 'function' ? calibrateNoise(setTop, t) : null),
3545
+ disabled: calibrating || !writable,
3546
+ style: { flex: 'none' },
3547
+ title: t('calibrateNoiseHint') || '',
3548
+ }, calibrating ? t('calibrating') : t('calibrateNoise')),
3549
+ ),
3550
+ calibrateMsg ? React.createElement('div', {
3551
+ style: { fontSize: '11px', color: 'var(--dsw-alias-state-success-primary)', marginTop: '4px' },
3552
+ }, calibrateMsg) : null,
3553
+ testingMic ? React.createElement('div', { className: 'dvo-mic-test' },
3554
+ React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, t('micLevel') + ':'),
3555
+ React.createElement('div', { className: 'dvo-meter-bar' },
3556
+ React.createElement('div', { className: 'dvo-meter-fill', style: { width: `${testLevel}%` } }),
3557
+ gateThresholdPercent > 0 ? React.createElement('div', { className: 'dvo-meter-gate', style: { left: `${gateThresholdPercent}%` }, title: `${gateDbVal} dB` }) : null,
3558
+ ),
3559
+ React.createElement('span', { style: { fontSize: '11px', fontVariantNumeric: 'tabular-nums', width: '32px', textAlign: 'right' } }, `${testLevel}%`),
3560
+ ) : null,
3561
+ React.createElement('div', { className: 'cb-row', style: { marginTop: '6px' } },
3562
+ React.createElement('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)', minWidth: '120px' } }, t('noiseGate') + ':'),
3563
+ React.createElement('select', {
3564
+ value: String(gateDbVal), disabled: !writable,
3565
+ onChange: (e) => setTop('noiseGateDb', Number(e.target.value)),
3566
+ style: { flex: 1 },
3567
+ },
3568
+ React.createElement('option', { value: '-999' }, t('noiseGateOff')),
3569
+ React.createElement('option', { value: '-50' }, '-50 dB (Low / Gentle)'),
3570
+ React.createElement('option', { value: '-45' }, '-45 dB (Standard Default)'),
3571
+ React.createElement('option', { value: '-35' }, '-35 dB (Medium Room)'),
3572
+ React.createElement('option', { value: '-25' }, '-25 dB (High / Mechanical Clicks)'),
3573
+ !['-999', '-50', '-45', '-35', '-25'].includes(String(gateDbVal)) ? React.createElement('option', { value: String(gateDbVal) }, `${gateDbVal} dB (${t('calibratedValue') || 'Calibrated'})`) : null,
3574
+ ),
3575
+ ),
3576
+ React.createElement('span', { className: 'dvs-sub' }, t('noiseGateHint')),
3577
+ ),
3578
+ // Endpoint text fields
3579
+ React.createElement('div', { className: 'cb-grid-2' },
3580
+ textField('whisperUrl', t('whisperEndpoint'), t('whisperEndpointHint')),
3581
+ textField('whisperBin', t('whisperBin'), t('whisperBinHint')),
3582
+ textField('whisperModel', t('whisperModel'), t('whisperModelHint')),
3583
+ textField('deepgramBaseUrl', t('deepgramEndpoint'), t('deepgramEndpointHint')),
3584
+ textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
3585
+ textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
3586
+ textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceModelHint')),
3587
+ textField('polishBaseUrl', t('polishBaseUrl'), t('polishBaseUrlHint')),
3588
+ textField('polishModel', t('polishModel'), ''),
3589
+ textField('polishKeyEnv', t('polishKeyEnv'), ''),
3590
+ ),
3591
+ // Engine checkboxes
3592
+ React.createElement('div', { className: 'cb-row' },
3593
+ React.createElement('label', { className: 'cb-check-label' },
3594
+ React.createElement('input', {
3595
+ type: 'checkbox', checked: !!(draft && draft.autoStart), disabled: !writable,
3596
+ onChange: (e) => setTop('autoStart', e.target.checked),
3597
+ }),
3598
+ t('whisperAutostart'),
3599
+ ),
3600
+ React.createElement('label', { className: 'cb-check-label' },
3601
+ React.createElement('input', {
3602
+ type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
3603
+ onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
3604
+ }),
3605
+ t('sensevoiceAutostart'),
3606
+ ),
3607
+ React.createElement('label', { className: 'cb-check-label' },
3608
+ React.createElement('input', {
3609
+ type: 'checkbox', checked: !!(draft && draft.localOnly), disabled: !writable,
3610
+ onChange: (e) => setTop('localOnly', e.target.checked),
3611
+ }),
3612
+ t('localOnly'),
3613
+ ),
3614
+ React.createElement('label', { className: 'cb-check-label' },
3615
+ React.createElement('input', {
3616
+ type: 'checkbox', checked: !!(draft && draft.beep), disabled: !writable,
3617
+ onChange: (e) => setTop('beep', e.target.checked),
3618
+ }),
3619
+ t('beep'),
3620
+ ),
3621
+ React.createElement('label', { className: 'cb-check-label' },
3622
+ React.createElement('input', {
3623
+ type: 'checkbox', checked: draft ? draft.noiseSuppression !== false : true, disabled: !writable,
3624
+ onChange: (e) => setTop('noiseSuppression', e.target.checked),
3625
+ }),
3626
+ t('noiseSuppression'),
3627
+ ),
3628
+ React.createElement('label', { className: 'cb-check-label' },
3629
+ React.createElement('input', {
3630
+ type: 'checkbox', checked: draft ? draft.contextGlossary !== false : true, disabled: !writable,
3631
+ onChange: (e) => setTop('contextGlossary', e.target.checked),
3632
+ }),
3633
+ t('contextGlossary'),
3634
+ ),
3635
+ React.createElement('label', { className: 'cb-check-label', title: t('bilingualDeveloperPromptHint') || '' },
3636
+ React.createElement('input', {
3637
+ type: 'checkbox', checked: draft ? draft.bilingualDeveloperPrompt !== false : true, disabled: !writable,
3638
+ onChange: (e) => setTop('bilingualDeveloperPrompt', e.target.checked),
3639
+ }),
3640
+ t('bilingualDeveloperPrompt'),
3641
+ ),
3642
+ React.createElement('label', { className: 'cb-check-label' },
3643
+ React.createElement('input', {
3644
+ type: 'checkbox', checked: !!(draft && draft.voiceCommands), disabled: !writable,
3645
+ onChange: (e) => setTop('voiceCommands', e.target.checked),
3646
+ }),
3647
+ t('voiceCommandsLabel'),
3648
+ ),
3649
+ React.createElement('label', { className: 'cb-check-label' },
3650
+ React.createElement('input', {
3651
+ type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
3652
+ onChange: (e) => setTop('normalizeTranscript', e.target.checked),
3653
+ }),
3654
+ t('normalizeTranscript'),
3655
+ ),
3656
+ ),
3657
+ // Visualizer select
3658
+ React.createElement('div', { className: 'cb-field' },
3659
+ React.createElement('label', null, t('visualizerStyle')),
3660
+ React.createElement('select', {
3661
+ value: String((draft && draft.visualizerStyle) || 'liquid-wave'), disabled: !writable,
3662
+ onChange: (e) => setTop('visualizerStyle', e.target.value),
3663
+ },
3664
+ React.createElement('option', { value: 'liquid-wave' }, t('visLiquidWave')),
3665
+ React.createElement('option', { value: 'dynamic-orb' }, t('visDynamicOrb')),
3666
+ React.createElement('option', { value: 'bars' }, t('visBars')),
3667
+ React.createElement('option', { value: 'off' }, t('visOff')),
3668
+ ),
3669
+ ),
3670
+ // Hardware Acceleration (SBC NPU / Host)
3671
+ React.createElement('div', { className: 'cb-field' },
3672
+ React.createElement('label', null, t('npuProvider')),
3673
+ React.createElement('div', { className: 'cb-row' },
3674
+ React.createElement('select', {
3675
+ value: String((draft && draft.sensevoiceProvider) || 'cpu'),
3676
+ disabled: !writable,
3677
+ onChange: (e) => setTop('sensevoiceProvider', e.target.value),
3678
+ style: { flex: 1 },
3679
+ },
3680
+ React.createElement('option', { value: 'cpu' }, 'CPU (Standard)'),
3681
+ React.createElement('option', { value: 'rknn' }, 'Rockchip RK3588 NPU (RKNN)'),
3682
+ React.createElement('option', { value: 'coreml' }, 'Apple Silicon CoreML'),
3683
+ React.createElement('option', { value: 'openvino' }, 'Intel OpenVINO'),
3684
+ React.createElement('option', { value: 'cuda' }, 'NVIDIA CUDA'),
3685
+ ),
3686
+ React.createElement('input', {
3687
+ type: 'number',
3688
+ min: 1,
3689
+ max: 32,
3690
+ value: Number((draft && draft.sensevoiceThreads) || 4),
3691
+ disabled: !writable,
3692
+ title: t('npuThreads'),
3693
+ style: { width: '80px' },
3694
+ onChange: (e) => setTop('sensevoiceThreads', Math.max(1, Math.min(32, Number(e.target.value) || 4))),
3695
+ }),
3696
+ ),
3697
+ React.createElement('span', { className: 'dvs-sub' }, t('npuProviderHint')),
3698
+ ),
3699
+ // Hardware Benchmark
3700
+ React.createElement('div', { className: 'cb-field' },
3701
+ React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', justifyContent: 'space-between' } },
3702
+ React.createElement('label', { style: { marginBottom: 0 } }, t('benchmarkTitle')),
3703
+ React.createElement('button', {
3704
+ type: 'button', className: 'cb-btn cb-btn-secondary', style: { fontSize: '12px', padding: '4px 10px' },
3705
+ disabled: benchmarking || !writable, onClick: runBenchmark,
3706
+ }, benchmarking ? ('⏳ ' + t('benchmarking')) : ('⚡ ' + t('runBenchmarkBtn'))),
3707
+ ),
3708
+ benchmarkResult
3709
+ ? React.createElement('div', {
3710
+ style: { marginTop: '8px', padding: '6px 10px', borderRadius: '6px', fontSize: '12px', backgroundColor: 'var(--dsw-alias-fill-quaternary)' }
3711
+ }, benchmarkResult.running
3712
+ ? `⚡ RTF: ${benchmarkResult.rtf}x · ${benchmarkResult.latencyMs}ms (${benchmarkResult.rating}) · ${String(benchmarkResult.provider || 'cpu').toUpperCase()} · Cores: ${benchmarkResult.cpuCores}`
3713
+ : `ℹ ${benchmarkResult.message || 'Offline'} (${benchmarkResult.cpuCores} cores, ${benchmarkResult.platform})`)
3714
+ : (benchmarkErr ? React.createElement('div', { style: { color: 'var(--dsw-alias-state-danger)', fontSize: '12px', marginTop: '4px' } }, benchmarkErr) : null),
3715
+ React.createElement('span', { className: 'dvs-sub' }, t('benchmarkHint')),
3716
+ ),
3717
+ // WebGPU Status Indicator
3718
+ React.createElement('div', { className: 'cb-field' },
3719
+ React.createElement('label', null, t('webgpuStatus')),
3720
+ React.createElement('div', {
3721
+ style: {
3722
+ padding: '6px 10px',
3723
+ borderRadius: '6px',
3724
+ fontSize: '12px',
3725
+ backgroundColor: 'var(--dsw-alias-fill-quaternary)',
3726
+ color: (typeof isWebGpuSupported === 'function' && isWebGpuSupported())
3727
+ ? 'var(--dsw-alias-state-success)'
3728
+ : 'var(--dsw-alias-label-secondary)'
3729
+ }
3730
+ }, (typeof isWebGpuSupported === 'function' && isWebGpuSupported())
3731
+ ? ('✓ ' + t('webgpuAvailable'))
3732
+ : ('ℹ ' + t('webgpuNotAvailable'))),
3733
+ ),
3734
+ // Vocabulary textarea
3735
+ React.createElement('div', { className: 'cb-field' },
3736
+ React.createElement('label', null, t('vocabulary')),
3737
+ React.createElement('textarea', {
3738
+ rows: 3, disabled: !writable,
3739
+ value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
3740
+ onChange: (e) => setTop('vocabulary', e.target.value.split('\n').map((x) => x.trim()).filter(Boolean)),
3741
+ }),
3742
+ ),
3743
+ )
3744
+ }
3745
+ // Custom hooks for VoiceSection modularity
3746
+ function useSensevoiceInstaller(fetchStatus) {
3747
+ const [sensevoiceState, setSensevoiceState] = React.useState(null)
3748
+ const [installingSensevoice, setInstallingSensevoice] = React.useState(false)
3749
+
3750
+ const fetchSensevoice = () => {
3751
+ fetch('/dsh-voice/sensevoice-installer')
3752
+ .then((r) => r.json())
3753
+ .then((d) => {
3754
+ setSensevoiceState(d)
3755
+ setInstallingSensevoice(!!(d && d.installing))
3756
+ })
3757
+ .catch(() => {})
3758
+ }
3759
+
3760
+ React.useEffect(() => {
3761
+ fetchSensevoice()
3762
+ }, [])
3763
+
3764
+ const triggerInstallSensevoice = (modelType = 'sensevoice') => {
3765
+ setInstallingSensevoice(true)
3766
+ fetch('/dsh-voice/sensevoice-installer', {
3767
+ method: 'POST',
3768
+ headers: {
3349
3769
  'x-dsh-plugin-update': '1',
3350
3770
  'Content-Type': 'application/json',
3351
- },
3771
+ },
3772
+ body: JSON.stringify({ model: modelType }),
3352
3773
  })
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
- }
3774
+ .then((r) => r.json())
3775
+ .then(() => {
3776
+ const timer = setInterval(() => {
3777
+ fetch('/dsh-voice/sensevoice-installer')
3778
+ .then((r) => r.json())
3779
+ .then((d) => {
3780
+ setSensevoiceState(d)
3781
+ if (!d.installing) {
3782
+ clearInterval(timer)
3783
+ setInstallingSensevoice(false)
3784
+ if (typeof fetchStatus === 'function') fetchStatus()
3785
+ }
3786
+ })
3787
+ .catch(() => clearInterval(timer))
3788
+ }, 2000)
3789
+ })
3790
+ .catch(() => setInstallingSensevoice(false))
3791
+ }
3364
3792
 
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
- )
3793
+ return { sensevoiceState, installingSensevoice, triggerInstallSensevoice }
3794
+ }
3373
3795
 
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
- )
3796
+ function useMicTester(draft, value, setErr) {
3797
+ const [testingMic, setTestingMic] = React.useState(false)
3798
+ const [testLevel, setTestLevel] = React.useState(0)
3799
+ const [calibrating, setCalibrating] = React.useState(false)
3800
+ const [calibrateMsg, setCalibrateMsg] = React.useState('')
3801
+ const testRef = React.useRef(null)
3802
+
3803
+ const calibrateNoise = async (setTop, translate) => {
3804
+ if (calibrating) return
3805
+ setCalibrating(true)
3806
+ setCalibrateMsg('')
3807
+ let stream = null
3808
+ let audioCtx = null
3809
+ try {
3810
+ const devId = (draft && draft.micDeviceId) || (value && value.micDeviceId) || ''
3811
+ const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
3812
+ if (devId) audio.deviceId = { exact: devId }
3813
+ stream = await navigator.mediaDevices.getUserMedia({ audio })
3814
+ const AC = typeof AudioContext !== 'undefined' ? AudioContext : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
3815
+ if (!AC) throw new Error('AudioContext not supported')
3816
+ audioCtx = new AC()
3817
+ if (audioCtx.state === 'suspended') await audioCtx.resume().catch(() => {})
3818
+ const src = audioCtx.createMediaStreamSource(stream)
3819
+ const analyser = audioCtx.createAnalyser()
3820
+ analyser.fftSize = 512
3821
+ src.connect(analyser)
3822
+
3823
+ const samples = []
3824
+ const buf = new Float32Array(analyser.fftSize)
3825
+ const startTime = Date.now()
3826
+
3827
+ await new Promise((resolve) => {
3828
+ const interval = setInterval(() => {
3829
+ if (Date.now() - startTime >= 2000) {
3830
+ clearInterval(interval)
3831
+ resolve()
3832
+ return
3833
+ }
3834
+ analyser.getFloatTimeDomainData(buf)
3835
+ let sum = 0
3836
+ for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i]
3837
+ const rms = Math.sqrt(sum / buf.length)
3838
+ if (rms > 0.00001) {
3839
+ samples.push(20 * Math.log10(rms))
3840
+ }
3841
+ }, 50)
3842
+ })
3843
+
3844
+ const avgDb = samples.length > 0 ? (samples.reduce((a, b) => a + b, 0) / samples.length) : -50
3845
+ const roundedAvg = Math.round(avgDb)
3846
+ const recommended = Math.min(-25, Math.max(-55, roundedAvg + 5))
3847
+ if (typeof setTop === 'function') setTop('noiseGateDb', recommended)
3848
+
3849
+ const tFn = typeof translate === 'function' ? translate : (k) => k
3850
+ const msg = (tFn('calibratedMsg') || 'Gate set: {db} dB (ambient: {noise} dB)')
3851
+ .replace('{db}', String(recommended))
3852
+ .replace('{noise}', String(roundedAvg))
3853
+ setCalibrateMsg(msg)
3854
+ setTimeout(() => setCalibrateMsg(''), 5000)
3855
+ } catch (e) {
3856
+ if (typeof setErr === 'function') setErr(String(e && e.message ? e.message : e))
3857
+ } finally {
3858
+ if (stream) {
3859
+ try { stream.getTracks().forEach((t) => t.stop()) } catch (e) {}
3860
+ }
3861
+ if (audioCtx) {
3862
+ try { audioCtx.close() } catch (e) {}
3863
+ }
3864
+ setCalibrating(false)
3616
3865
  }
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 }
3866
+ }
3867
+
3868
+ const toggleTestMic = async () => {
3869
+ if (testingMic) {
3870
+ if (testRef.current) {
3871
+ testRef.current.active = false
3872
+ try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
3873
+ if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) { /* already closed */ } }
3874
+ testRef.current = null
3875
+ }
3876
+ setTestingMic(false)
3877
+ setTestLevel(0)
3878
+ return
3666
3879
  }
3880
+ try {
3881
+ const devId = (draft && draft.micDeviceId) || (value && value.micDeviceId) || ''
3882
+ const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
3883
+ if (devId) audio.deviceId = { exact: devId }
3884
+ const stream = await navigator.mediaDevices.getUserMedia({ audio })
3885
+ const AC = typeof AudioContext !== 'undefined' ? AudioContext : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
3886
+ if (!AC) return
3887
+ const audioCtx = new AC()
3888
+ if (audioCtx.state === 'suspended') await audioCtx.resume().catch(() => {})
3889
+ const src = audioCtx.createMediaStreamSource(stream)
3890
+ let lastNode = src
3891
+ try {
3892
+ const filter = audioCtx.createBiquadFilter()
3893
+ filter.type = 'highpass'
3894
+ filter.frequency.value = 80
3895
+ filter.Q.value = 0.707
3896
+ src.connect(filter)
3897
+ lastNode = filter
3898
+ } catch (e) { /* filter fallback */ }
3899
+ const analyser = audioCtx.createAnalyser()
3900
+ analyser.fftSize = 128
3901
+ lastNode.connect(analyser)
3902
+ testRef.current = { stream, audioCtx, analyser, active: true }
3903
+ setTestingMic(true)
3904
+
3905
+ const check = () => {
3906
+ if (!testRef.current || !testRef.current.active) return
3907
+ const arr = new Uint8Array(analyser.frequencyBinCount)
3908
+ analyser.getByteFrequencyData(arr)
3909
+ let sum = 0
3910
+ for (let i = 0; i < arr.length; i++) sum += arr[i]
3911
+ const avg = sum / (arr.length || 1)
3912
+ setTestLevel(Math.min(100, Math.round((avg / 128) * 100)))
3913
+ requestAnimationFrame(check)
3914
+ }
3915
+ requestAnimationFrame(check)
3916
+ } catch (e) {
3917
+ if (typeof setErr === 'function') setErr(String(e && e.message ? e.message : e))
3918
+ }
3919
+ }
3667
3920
 
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
- })
3715
-
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
- }
3739
-
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
- }
3921
+ React.useEffect(() => {
3922
+ return () => {
3923
+ if (testRef.current) {
3924
+ testRef.current.active = false
3925
+ try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
3926
+ if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) { /* already closed */ } }
3927
+ testRef.current = null
3928
+ }
3929
+ }
3930
+ }, [])
3792
3931
 
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
- }, [])
3932
+ const gateDbVal = Number((draft && draft.noiseGateDb !== undefined) ? draft.noiseGateDb : (value && value.noiseGateDb !== undefined) ? value.noiseGateDb : -45)
3933
+ const gateThresholdPercent = gateDbVal <= -90 ? 0 : Math.min(100, Math.max(0, Math.round(((gateDbVal + 60) / 40) * 100)))
3803
3934
 
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)))
3935
+ return { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent, calibrating, calibrateMsg, calibrateNoise }
3936
+ }
3806
3937
 
3807
- return { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent, calibrating, calibrateMsg, calibrateNoise }
3938
+ function usePluginUpdater() {
3939
+ const [updaterStatus, setUpdaterStatus] = React.useState(null)
3940
+ const [updating, setUpdating] = React.useState(false)
3941
+ const [updaterMsg, setUpdaterMsg] = React.useState('')
3942
+ const [updaterMsgKind, setUpdaterMsgKind] = React.useState('')
3943
+ const [updaterLoading, setUpdaterLoading] = React.useState(false)
3944
+
3945
+ const checkUpdater = React.useCallback(async () => {
3946
+ setUpdaterLoading(true)
3947
+ setUpdaterMsg('')
3948
+ setUpdaterMsgKind('')
3949
+ try {
3950
+ const res = await fetch('/api/dsh-voice/update', {
3951
+ headers: { accept: 'application/json' },
3952
+ cache: 'no-store',
3953
+ })
3954
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
3955
+ const data = await res.json()
3956
+ setUpdaterStatus(data)
3957
+ if (data && data.latestCheckFailed) {
3958
+ setUpdaterMsg(t('updateCheckFailed'))
3959
+ setUpdaterMsgKind('err')
3960
+ }
3961
+ } catch (e) {
3962
+ setUpdaterStatus(null)
3963
+ setUpdaterMsg(t('updateCheckFailed') + ': ' + (e?.message || String(e)))
3964
+ setUpdaterMsgKind('err')
3965
+ } finally {
3966
+ setUpdaterLoading(false)
3808
3967
  }
3968
+ }, [])
3809
3969
 
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
- }
3970
+ const onUpdateNow = async () => {
3971
+ if (updating) return
3972
+ setUpdating(true)
3973
+ setUpdaterMsg('')
3974
+ setUpdaterMsgKind('')
3975
+ try {
3976
+ const res = await fetch('/api/dsh-voice/update', {
3977
+ method: 'POST',
3978
+ headers: {
3979
+ 'content-type': 'application/json',
3980
+ 'x-dsh-plugin-update': '1',
3981
+ },
3982
+ cache: 'no-store',
3983
+ })
3984
+ const data = await res.json().catch(() => ({}))
3985
+ if (!res.ok || data.ok === false) {
3986
+ if (data.code) {
3987
+ const base = t('profileLockCheckError')
3988
+ throw new Error(base ? `${base} (${data.code})` : (data.error || `HTTP ${res.status}`))
3880
3989
  }
3881
-
3882
- return { updaterStatus, updating, updaterMsg, updaterMsgKind, updaterLoading, checkUpdater, onUpdateNow }
3990
+ if (data.locked) {
3991
+ const key = data.pid ? 'profileLockedPid' : 'profileLockedExisting'
3992
+ const translated = t(key)
3993
+ if (translated && translated !== key) {
3994
+ throw new Error(data.pid ? translated.replace('{pid}', String(data.pid)) : translated)
3995
+ }
3996
+ }
3997
+ throw new Error(data.error || `HTTP ${res.status}`)
3998
+ }
3999
+ setUpdaterMsg(data.message || t('updateSuccess'))
4000
+ setUpdaterMsgKind('ok')
4001
+ checkUpdater()
4002
+ } catch (err) {
4003
+ setUpdaterMsg(err && err.message ? err.message : String(err))
4004
+ setUpdaterMsgKind('err')
4005
+ } finally {
4006
+ setUpdating(false)
3883
4007
  }
4008
+ }
4009
+
4010
+ return { updaterStatus, updating, updaterMsg, updaterMsgKind, updaterLoading, checkUpdater, onUpdateNow }
4011
+ }
3884
4012
  // Developer Lexicon & IT Jargon
3885
4013
  function JargonEditor(props) {
3886
4014
  const t = props.t || moduleT
@@ -4159,960 +4287,960 @@ function MacrosSection(props) {
4159
4287
  : null,
4160
4288
  )
4161
4289
  }
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) }
4261
- }
4262
- const setTop = (key, val) => {
4263
- setDraft((d) => Object.assign({}, d || {}, { [key]: val }))
4290
+ // ------------------------------------------------------- settings voice section
4291
+ function VoiceSection(props) {
4292
+ const t = (props && props.t) || moduleT
4293
+ const ctx = (props && props.ctx) || {}
4294
+ // On a non-localhost page the kernel disables settings entirely: the
4295
+ // shared document is not readable, every section gets "unavailable" and
4296
+ // writes are dropped. The server does not share that restriction, and
4297
+ // dsh-lanmode rebuilds the same mechanics on the same calls. Prefer its
4298
+ // service when installed, otherwise the kernel one (fine on loopback).
4299
+ const settingsService = (ctx.get && ctx.get('lanSettings')) || ctx.configForms || ctx.settingsScope
4300
+ const scope = settingsService && typeof settingsService.get === 'function'
4301
+ ? settingsService.get(NS)
4302
+ : (settingsService && typeof settingsService.bind === 'function' ? settingsService.bind({ namespace: NS }) : null)
4303
+ const [snap, setSnap] = React.useState(null)
4304
+ const [draft, setDraft] = React.useState(null)
4305
+ const [saved, setSaved] = React.useState(false)
4306
+ const [err, setErr] = React.useState('')
4307
+ const [serverConfig, setServerConfig] = React.useState(null)
4308
+ const [configLoading, setConfigLoading] = React.useState(true)
4309
+ const [revision, setRevision] = React.useState(null)
4310
+ React.useEffect(() => {
4311
+ let alive = true
4312
+ fetch('/dsh-voice/config', { cache: 'no-store' })
4313
+ .then((r) => r.json())
4314
+ .then((data) => {
4315
+ if (alive && data && data.ok && data.config) {
4316
+ setServerConfig(data.config)
4317
+ if (typeof data.revision === 'number') setRevision(data.revision)
4318
+ setDraft((curr) => curr || deepClone(data.config))
4319
+ if (data.config.wizardCompleted !== true) setShowWizard(true)
4264
4320
  }
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(() => {})
4321
+ })
4322
+ .catch(() => {})
4323
+ .finally(() => {
4324
+ if (alive) setConfigLoading(false)
4325
+ })
4326
+ return () => { alive = false }
4327
+ }, [])
4328
+ // Hotkey picker: not a name field — press the key you want.
4329
+ const [catching, setCatching] = React.useState(false)
4330
+ const [jargonInput, setJargonInput] = React.useState(null)
4331
+ const [showWizard, setShowWizard] = React.useState(false)
4332
+ React.useEffect(() => {
4333
+ if (!catching) return undefined
4334
+ const onKey = (event) => {
4335
+ event.preventDefault()
4336
+ event.stopPropagation()
4337
+ if (event.key === 'Escape') { setCatching(false); return }
4338
+ const chosen = keyFromEvent(event)
4339
+ setDraft((d) => Object.assign({}, d || {}, { hotkey: chosen }))
4340
+ voice.hotkey = chosen || ''
4341
+ if (chosen) {
4342
+ installHotkey(ctx, chosen, 'message')
4343
+ } else {
4344
+ clearGlobalHotkey()
4345
+ }
4346
+ setCatching(false)
4765
4347
  }
4766
- }, [])
4767
-
4768
- const refreshDevices = () => {
4348
+ document.addEventListener('keydown', onKey, true)
4349
+ return () => document.removeEventListener('keydown', onKey, true)
4350
+ }, [catching, ctx])
4351
+ React.useEffect(() => {
4352
+ let alive = true
4353
+ const render = () => { if (alive && scope && typeof scope.getSnapshot === 'function') setSnap(scope.getSnapshot()) }
4354
+ render()
4355
+ const off = (scope && typeof scope.subscribe === 'function') ? scope.subscribe(render) : () => {}
4356
+ return () => { alive = false; off() }
4357
+ }, [scope])
4358
+ const [devices, setDevices] = React.useState([])
4359
+ React.useEffect(() => {
4769
4360
  if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
4770
4361
  navigator.mediaDevices.enumerateDevices()
4771
- .then((list) => {
4772
- const audioInputs = list.filter((d) => d.kind === 'audioinput')
4773
- setDevices(audioInputs)
4774
- })
4775
- .catch(() => {})
4362
+ .then((list) => setDevices(list.filter((d) => d.kind === 'audioinput')))
4363
+ .catch(() => {})
4364
+ }, [])
4365
+ const ready = !!serverConfig || (!!snap && snap.status === 'ready')
4366
+ const value = (snap && snap.status === 'ready' && snap.value) ? snap.value : (serverConfig || {})
4367
+ const writable = ready
4368
+ React.useEffect(() => {
4369
+ if (ready) return undefined
4370
+ let tries = 0
4371
+ const timer = setInterval(() => {
4372
+ if (tries >= 15) { clearInterval(timer); return }
4373
+ tries += 1
4374
+ try {
4375
+ if (ctx.configForms && typeof ctx.configForms.describe === 'function') {
4376
+ ctx.configForms.describe().load()
4377
+ } else if (ctx.settingsScope && typeof ctx.settingsScope.describe === 'function') {
4378
+ ctx.settingsScope.describe().load()
4776
4379
  }
4777
-
4778
- React.useEffect(() => {
4779
- refreshDevices()
4780
- }, [])
4781
-
4782
- const stopSignalTest = () => {
4783
- if (animFrameRef.current) {
4784
- cancelAnimationFrame(animFrameRef.current)
4785
- animFrameRef.current = null
4786
- }
4787
- if (signalStreamRef.current) {
4788
- try { signalStreamRef.current.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
4789
- signalStreamRef.current = null
4380
+ } catch (e) { /* service not up yet */ }
4381
+ }, 1000)
4382
+ return () => clearInterval(timer)
4383
+ }, [ready])
4384
+ const deepClone = (obj) => {
4385
+ if (typeof structuredClone === 'function') {
4386
+ try { return structuredClone(obj) } catch (e) { /* fallback */ }
4790
4387
  }
4791
- if (audioCtxRef.current) {
4792
- try { audioCtxRef.current.close() } catch (_) {}
4793
- audioCtxRef.current = null
4794
- }
4795
- setSignalTesting(false)
4796
- setCurrentRms(0)
4388
+ try { return JSON.parse(JSON.stringify(obj)) } catch (e) { return Object.assign({}, obj) }
4389
+ }
4390
+ const setTop = (key, val) => {
4391
+ setDraft((d) => Object.assign({}, d || {}, { [key]: val }))
4392
+ }
4393
+ const setIn = (mode, key, val) => {
4394
+ setDraft((d) => {
4395
+ const next = Object.assign({}, d || {})
4396
+ next[mode] = Object.assign({}, next[mode] || {}, { [key]: val })
4397
+ return next
4398
+ })
4399
+ }
4400
+ const textField = (key, label, hint) => React.createElement('div', { className: 'cb-field' },
4401
+ React.createElement('label', null, label),
4402
+ React.createElement('input', {
4403
+ type: 'text',
4404
+ value: (draft && draft[key]) ?? '',
4405
+ disabled: !writable,
4406
+ onChange: (e) => setTop(key, e.target.value),
4407
+ }),
4408
+ hint ? React.createElement('span', { className: 'dvs-sub' }, hint) : null,
4409
+ )
4410
+ React.useEffect(() => { if (ready && draft === null) setDraft(deepClone(value)) }, [ready, draft, value])
4411
+ React.useEffect(() => {
4412
+ if (!ready) return
4413
+ const val = value || {}
4414
+ voice.settings = Object.assign({}, voice.settings, {
4415
+ vadSilenceMs: Number(val.dictation && val.dictation.vadSilenceMs) || 700,
4416
+ autoSendMs: Number(val.message && val.message.autoSendMs) || 4000,
4417
+ beep: !!val.beep,
4418
+ localOnly: !!val.localOnly,
4419
+ micDeviceId: String(val.micDeviceId || ''),
4420
+ historyLimit: Number(val.historyLimit) || 20,
4421
+ voiceCommands: !!val.voiceCommands,
4422
+ sendDelayMs: Number(val.dictation && val.dictation.sendDelayMs) || 0,
4423
+ stream: !!(val.dictation && val.dictation.stream),
4424
+ streamChunkMs: Number(val.dictation && val.dictation.streamChunkMs) || 1200,
4425
+ vadAdapt: Number(val.dictation && val.dictation.vadAdapt) || 0,
4426
+ wakeWord: String(val.wakeWord || ''),
4427
+ bargeIn: !!val.bargeIn,
4428
+ polishSend: !!(val.message && val.message.polishSend),
4429
+ editablePreview: val.message ? val.message.editablePreview !== false : true,
4430
+ sessionCommands: !!(val.message && val.message.sessionCommands),
4431
+ polishBaseUrl: String(val.polishBaseUrl || ''),
4432
+ noiseSuppression: val.noiseSuppression !== false,
4433
+ noiseGateDb: Number(val.noiseGateDb !== undefined ? val.noiseGateDb : -45), bilingualDeveloperPrompt: val.bilingualDeveloperPrompt !== false,
4434
+ contextGlossary: val.contextGlossary !== false,
4435
+ visualizerStyle: val.visualizerStyle || 'liquid-wave',
4436
+ gatedTurnTaking: val.gatedTurnTaking !== false,
4437
+ autoSendVisualRing: val.autoSendVisualRing !== false,
4438
+ voiceActions: val.voiceActions !== false,
4439
+ techJargonCorrection: val.techJargonCorrection !== false,
4440
+ liveInterimPreview: val.liveInterimPreview !== false,
4441
+ structuredPromptVoice: val.structuredPromptVoice !== false,
4442
+ dictationMode: val.dictationMode || 'clean',
4443
+ dictationPrompts: val.dictationPrompts || {},
4444
+ voiceMacros: Array.isArray(val.voiceMacros) ? val.voiceMacros : [],
4445
+ webgpuModel: String(val.webgpuModel || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'),
4446
+ })
4447
+ }, [ready, value])
4448
+ const [statusData, setStatusData] = React.useState(null)
4449
+ const [statusLatency, setStatusLatency] = React.useState(null)
4450
+ const fetchStatus = React.useCallback(() => {
4451
+ const start = performance.now()
4452
+ const ctrl = new AbortController()
4453
+ const tId = setTimeout(() => ctrl.abort(), 5000)
4454
+ fetch('/dsh-voice/status', { cache: 'no-store', signal: ctrl.signal })
4455
+ .then((r) => r.json())
4456
+ .then((data) => {
4457
+ clearTimeout(tId)
4458
+ setStatusLatency(Math.round(performance.now() - start))
4459
+ if (data && data.ok) {
4460
+ setStatusData(data)
4461
+ if (data.sessionToken && typeof window !== 'undefined') window.__dshVoiceToken = data.sessionToken
4462
+ if (data.webgpuModel && voice.settings) {
4463
+ voice.settings.webgpuModel = data.webgpuModel
4464
+ }
4797
4465
  }
4798
-
4799
- React.useEffect(() => {
4800
- return () => {
4801
- stopSignalTest()
4466
+ })
4467
+ .catch(() => {
4468
+ clearTimeout(tId)
4469
+ setStatusLatency(null)
4470
+ })
4471
+ }, [])
4472
+ React.useEffect(() => {
4473
+ fetchStatus()
4474
+ }, [fetchStatus])
4475
+ const { sensevoiceState, installingSensevoice, triggerInstallSensevoice } = useSensevoiceInstaller(fetchStatus)
4476
+ const { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent, calibrating, calibrateMsg, calibrateNoise } = useMicTester(draft, value, setErr)
4477
+ const { updaterStatus, updating, updaterMsg, updaterMsgKind, updaterLoading, checkUpdater, onUpdateNow } = usePluginUpdater()
4478
+ const save = async () => {
4479
+ setErr(''); setSaved(false)
4480
+ if (!draft) return
4481
+ if (jargonInput !== null) {
4482
+ const lines = jargonInput.split('\n')
4483
+ const parsed = lines
4484
+ .map((line) => {
4485
+ const parts = line.split(/->|=|:/)
4486
+ if (parts.length >= 2) {
4487
+ const from = parts[0].trim()
4488
+ const to = parts.slice(1).join(':').trim()
4489
+ if (from && to) return { from, to }
4490
+ }
4491
+ return null
4492
+ })
4493
+ .filter(Boolean)
4494
+ draft.jargonDictionary = parsed
4802
4495
  }
4803
- }, [])
4804
-
4805
- const requestPermissionAndTestSignal = async () => {
4806
- stopSignalTest()
4807
- setTestError('')
4496
+ let httpOk = false
4497
+ let httpError = null
4808
4498
  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()
4499
+ const res = await fetch('/dsh-voice/config', {
4500
+ method: 'PUT',
4501
+ headers: { 'Content-Type': 'application/json' },
4502
+ body: JSON.stringify({ config: draft, expectedRevision: (typeof revision === 'number' ? revision : undefined) }),
4503
+ })
4504
+ const data = await res.json()
4505
+ if (res.ok && data && data.ok) {
4506
+ httpOk = true
4507
+ if (data.config) setServerConfig(data.config)
4508
+ if (typeof data.revision === 'number') setRevision(data.revision)
4509
+ } else if (res.status === 409) {
4510
+ httpError = data?.error?.message || 'Settings conflict: parallel changes detected'
4511
+ } else {
4512
+ httpError = data?.error?.message || data?.error || res.statusText || 'Save failed'
4513
+ }
4514
+ } catch (e) {
4515
+ httpError = e?.message || String(e)
4846
4516
  }
4517
+ if (httpOk && scope && typeof scope.set === 'function') {
4518
+ for (const k of Object.keys(draft)) {
4519
+ try {
4520
+ await scope.set(k, draft[k])
4521
+ } catch (e) {
4522
+ /* best effort on loopback */
4847
4523
  }
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)
4524
+ }
4935
4525
  }
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'))
4526
+ if (!httpOk) {
4527
+ setErr(t('saveFailed') + ': ' + httpError)
4528
+ return
4529
+ }
4530
+ const d = draft || {}
4531
+ voice.hotkey = d.hotkey || ''
4532
+ if (voice.hotkey) {
4533
+ installHotkey(ctx, voice.hotkey, 'message')
4534
+ } else {
4535
+ clearGlobalHotkey()
4536
+ }
4537
+ Object.assign(voice.settings, {
4538
+ hotkey: d.hotkey || '',
4539
+ vadSilenceMs: Number(d.dictation && d.dictation.vadSilenceMs) || 700,
4540
+ autoSendMs: Number(d.message && d.message.autoSendMs) || 4000,
4541
+ beep: !!d.beep,
4542
+ micDeviceId: String(d.micDeviceId || ''),
4543
+ historyLimit: Number(d.historyLimit),
4544
+ voiceCommands: !!d.voiceCommands,
4545
+ sendDelayMs: Number(d.dictation && d.dictation.sendDelayMs) || 0,
4546
+ stream: !!(d.dictation && d.dictation.stream),
4547
+ streamChunkMs: Number(d.dictation && d.dictation.streamChunkMs) || 1200,
4548
+ vadAdapt: Number(d.dictation && d.dictation.vadAdapt) || 0,
4549
+ noiseSuppression: d.noiseSuppression !== false,
4550
+ noiseGateDb: Number(d.noiseGateDb !== undefined ? d.noiseGateDb : -45), bilingualDeveloperPrompt: d.bilingualDeveloperPrompt !== false,
4551
+ contextGlossary: d.contextGlossary !== false,
4552
+ visualizerStyle: d.visualizerStyle || 'liquid-wave',
4553
+ gatedTurnTaking: d.gatedTurnTaking !== false,
4554
+ autoSendVisualRing: d.autoSendVisualRing !== false,
4555
+ voiceActions: d.voiceActions !== false,
4556
+ techJargonCorrection: d.techJargonCorrection !== false,
4557
+ liveInterimPreview: d.liveInterimPreview !== false,
4558
+ structuredPromptVoice: d.structuredPromptVoice !== false,
4559
+ })
4560
+ try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
4561
+ setSaved(true); setJargonInput(null)
4562
+ setTimeout(() => setSaved(false), 2000)
4563
+ fetchStatus()
4564
+ }
4565
+ const modeVal = (mode, key, fallback) => {
4566
+ const m = draft && draft[mode]
4567
+ return m && m[key] !== undefined ? m[key] : fallback
4568
+ }
4569
+ const chainOptions = BUILTIN.concat(
4570
+ (draft && Array.isArray(draft.customProviders) ? draft.customProviders : [])
4571
+ .map((c) => String(c && c.key || '').trim())
4572
+ .filter((k) => k && BUILTIN.indexOf(k) < 0),
4573
+ )
4574
+ const langField = (mode) => {
4575
+ const val = modeVal(mode, 'language', '')
4576
+ const opts = SUPPORTED_LANGS.slice()
4577
+ if (val && !opts.some((o) => o.code === val)) {
4578
+ opts.push({ code: val, labelKey: val, defaultLabel: val })
4579
+ }
4580
+ return React.createElement('div', { className: 'cb-field' },
4581
+ React.createElement('label', null, t('language')),
4582
+ React.createElement('select', {
4583
+ value: val, disabled: !writable,
4584
+ onChange: (e) => setIn(mode, 'language', e.target.value),
4585
+ }, opts.map((o) => React.createElement('option', { key: o.code || 'auto', value: o.code },
4586
+ t(o.labelKey) !== o.labelKey ? t(o.labelKey) : o.defaultLabel
4587
+ ))),
4588
+ )
4589
+ }
4590
+ const numField = (mode, key, label, hint) => React.createElement('div', { className: 'cb-field' },
4591
+ React.createElement('label', null, label),
4592
+ React.createElement('input', {
4593
+ type: 'number', value: modeVal(mode, key, ''), disabled: !writable,
4594
+ onChange: (e) => setIn(mode, key, Number(e.target.value)),
4595
+ }),
4596
+ React.createElement('span', { className: 'dvs-sub' }, hint),
4597
+ )
4598
+ const statsData = (statusData && statusData.providerStats) || {}
4599
+ const recentDiagnostics = (statusData && statusData.recentDiagnostics) || []
4600
+ const hostOnline = statusLatency !== null
4601
+ const whisperRunning = !!(statusData && statusData.whisperRunning)
4602
+ const providerCount = (statusData && Array.isArray(statusData.providers)) ? statusData.providers.length : chainOptions.length
4603
+ return React.createElement('div', { className: 'cb-page dvo-settings-root' },
4604
+ React.createElement(ConnectionStatusCard, {
4605
+ t: t,
4606
+ hostOnline: hostOnline,
4607
+ statusLatency: statusLatency,
4608
+ whisperRunning: whisperRunning,
4609
+ providerCount: providerCount,
4610
+ onRefresh: fetchStatus,
4611
+ onOpenWizard: () => setShowWizard((v) => !v),
4612
+ }),
4613
+ showWizard ? React.createElement(FirstRunWizard, {
4614
+ t: t,
4615
+ draft: draft,
4616
+ setTop: setTop,
4617
+ writable: writable,
4618
+ onClose: () => setShowWizard(false),
4619
+ }) : null,
4620
+ React.createElement('div', { className: 'cb-section-card' },
4621
+ React.createElement('div', { className: 'cb-section-title' },
4622
+ React.createElement('span', null, '🎙️ ' + t('dictationBtn')),
4623
+ ),
4624
+ React.createElement('div', { className: 'cb-section-desc' }, t('dictationHint')),
4625
+ React.createElement(ChainEditor, {
4626
+ t: t, value: draft && draft.dictation ? draft.dictation.chain : [], writable: writable,
4627
+ options: chainOptions,
4628
+ onChange: (v) => setIn('dictation', 'chain', v),
4629
+ }),
4630
+ React.createElement('div', { className: 'cb-grid-2' },
4631
+ langField('dictation'),
4632
+ numField('dictation', 'vadSilenceMs', t('pauseMs'), t('pauseHint')),
4633
+ numField('dictation', 'sendDelayMs', t('sendDelay'), t('sendDelayHint')),
4634
+ numField('dictation', 'streamChunkMs', t('streamChunkMs'), t('streamHint')),
4635
+ ),
4636
+ React.createElement('div', { className: 'cb-row' },
4637
+ React.createElement('label', { className: 'cb-check-label' },
4638
+ React.createElement('input', {
4639
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.polish), disabled: !writable,
4640
+ onChange: (e) => setIn('dictation', 'polish', e.target.checked),
4641
+ }),
4642
+ t('polish'),
4983
4643
  ),
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')))
4644
+ React.createElement('label', { className: 'cb-check-label' },
4645
+ React.createElement('input', {
4646
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.stream), disabled: !writable,
4647
+ onChange: (e) => setIn('dictation', 'stream', e.target.checked),
4648
+ }),
4649
+ t('stream'),
4986
4650
  ),
4651
+ ),
4652
+ React.createElement('div', { className: 'cb-field' },
4653
+ React.createElement('label', null, t('vadAdapt')),
4654
+ React.createElement('input', {
4655
+ type: 'range', min: 0, max: 1, step: 0.1,
4656
+ value: Number(draft && draft.dictation && draft.dictation.vadAdapt) || 0, disabled: !writable,
4657
+ onChange: (e) => setIn('dictation', 'vadAdapt', Number(e.target.value)),
4658
+ }),
4659
+ React.createElement('span', { className: 'dvs-sub' }, t('vadAdaptHint')),
4660
+ ),
4661
+ textField('wakeWord', t('wakeWord'), t('wakeWordHint')),
4662
+ ),
4663
+ React.createElement('div', { className: 'cb-section-card' },
4664
+ React.createElement('div', { className: 'cb-section-title' },
4665
+ React.createElement('span', null, '💬 ' + t('messageTitle')),
4666
+ ),
4667
+ React.createElement('div', { className: 'cb-section-desc' }, t('messageHint')),
4668
+ React.createElement(ChainEditor, {
4669
+ t: t, value: draft && draft.message ? draft.message.chain : [], writable: writable,
4670
+ options: chainOptions,
4671
+ onChange: (v) => setIn('message', 'chain', v),
4672
+ }),
4673
+ React.createElement('div', { className: 'cb-grid-2' },
4674
+ langField('message'),
4675
+ numField('message', 'autoSendMs', t('undoMs'), t('undoHint')),
4676
+ ),
4677
+ React.createElement('div', { className: 'cb-row' },
4678
+ React.createElement('label', { className: 'cb-check-label' },
4679
+ React.createElement('input', {
4680
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
4681
+ onChange: (e) => setIn('message', 'polish', e.target.checked),
4682
+ }),
4683
+ t('polish'),
4987
4684
  ),
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')),
4685
+ React.createElement('label', { className: 'cb-check-label' },
4686
+ React.createElement('input', {
4687
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polishSend), disabled: !writable,
4688
+ onChange: (e) => setIn('message', 'polishSend', e.target.checked),
4689
+ }),
4690
+ t('polishSend'),
5008
4691
  ),
4692
+ React.createElement('label', { className: 'cb-check-label' },
4693
+ React.createElement('input', {
4694
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.sessionCommands), disabled: !writable,
4695
+ onChange: (e) => setIn('message', 'sessionCommands', e.target.checked),
4696
+ }),
4697
+ t('sessionCommands'),
5009
4698
  ),
4699
+ ),
5010
4700
  ),
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
- }),
4701
+ React.createElement('div', { className: 'cb-section-card' },
4702
+ React.createElement('div', { className: 'cb-section-title' },
4703
+ React.createElement('span', null, '⚙️ ' + t('customTitle')),
4704
+ ),
4705
+ React.createElement('div', { className: 'cb-section-desc' }, t('customHint')),
4706
+ React.createElement(CustomEditor, {
4707
+ value: draft && draft.customProviders ? draft.customProviders : [], writable: writable,
4708
+ options: chainOptions,
4709
+ onChange: (v) => setTop('customProviders', v),
4710
+ }),
4711
+ ),
4712
+ React.createElement(HardwareOptionsCard, {
4713
+ t: t,
4714
+ draft: draft,
4715
+ setTop: setTop,
4716
+ writable: writable,
4717
+ catching: catching,
4718
+ setCatching: setCatching,
4719
+ devices: devices,
4720
+ testingMic: testingMic,
4721
+ toggleTestMic: toggleTestMic,
4722
+ testLevel: testLevel,
4723
+ gateDbVal: gateDbVal,
4724
+ gateThresholdPercent: gateThresholdPercent,
4725
+ calibrating: calibrating,
4726
+ calibrateMsg: calibrateMsg,
4727
+ calibrateNoise: calibrateNoise,
4728
+ onClearKey: () => {
4729
+ setDraft((d) => Object.assign({}, d || {}, { hotkey: '' }))
4730
+ voice.hotkey = ''
4731
+ clearGlobalHotkey()
4732
+ },
4733
+ }),
4734
+ React.createElement(SensevoiceSection, {
4735
+ t: t,
4736
+ sensevoiceState: sensevoiceState,
4737
+ installingSensevoice: installingSensevoice,
4738
+ writable: writable,
4739
+ onInstall: triggerInstallSensevoice,
4740
+ }),
4741
+ React.createElement('div', { className: 'cb-section-card' },
4742
+ React.createElement('div', { className: 'cb-section-title' },
4743
+ React.createElement('span', null, '🎙️ ' + t('turnTakingTitle')),
4744
+ ),
4745
+ React.createElement('div', { className: 'cb-section-desc' }, t('turnTakingDesc')),
4746
+ React.createElement('div', { className: 'cb-grid-2' },
4747
+ React.createElement('label', { className: 'cb-check-label' },
4748
+ React.createElement('input', {
4749
+ type: 'checkbox',
4750
+ checked: draft ? draft.gatedTurnTaking !== false : true,
4751
+ disabled: !writable,
4752
+ onChange: (e) => setTop('gatedTurnTaking', e.target.checked),
4753
+ }),
4754
+ t('gatedTurnTaking'),
5042
4755
  ),
5043
- React.createElement('span', { className: currentRms > 5 ? 'dvs-ok' : 'dvs-sub' },
5044
- currentRms > 5
5045
- ? t('wizardSignalOk').replace('{level}', currentRms)
5046
- : t('wizardSignalZero')
4756
+ React.createElement('label', { className: 'cb-check-label' },
4757
+ React.createElement('input', {
4758
+ type: 'checkbox',
4759
+ checked: !!(draft && draft.bargeIn),
4760
+ disabled: !writable,
4761
+ onChange: (e) => setTop('bargeIn', e.target.checked),
4762
+ }),
4763
+ t('bargeInDesc'),
5047
4764
  ),
5048
- ) : null,
4765
+ ),
5049
4766
  ),
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}`),
4767
+ React.createElement('div', { className: 'cb-section-card' },
4768
+ React.createElement('div', { className: 'cb-section-title' },
4769
+ React.createElement('span', null, '✨ ' + t('smartActionsTitle')),
4770
+ ),
4771
+ React.createElement('div', { className: 'cb-grid-2' },
4772
+ React.createElement('label', { className: 'cb-check-label' },
4773
+ React.createElement('input', {
4774
+ type: 'checkbox',
4775
+ checked: draft ? draft.liveInterimPreview !== false : true,
4776
+ disabled: !writable,
4777
+ onChange: (e) => setTop('liveInterimPreview', e.target.checked),
4778
+ }),
4779
+ t('liveInterimPreview'),
5057
4780
  ),
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')
4781
+ React.createElement('label', { className: 'cb-check-label' },
4782
+ React.createElement('input', {
4783
+ type: 'checkbox',
4784
+ checked: draft ? draft.autoSendVisualRing !== false : true,
4785
+ disabled: !writable,
4786
+ onChange: (e) => setTop('autoSendVisualRing', e.target.checked),
4787
+ }),
4788
+ t('autoSendVisualRing'),
5067
4789
  ),
4790
+ React.createElement('label', { className: 'cb-check-label' },
4791
+ React.createElement('input', {
4792
+ type: 'checkbox',
4793
+ checked: draft ? draft.voiceActions !== false : true,
4794
+ disabled: !writable,
4795
+ onChange: (e) => setTop('voiceActions', e.target.checked),
4796
+ }),
4797
+ t('voiceActionsLabel'),
5068
4798
  ),
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)
4799
+ React.createElement('label', { className: 'cb-check-label' },
4800
+ React.createElement('input', {
4801
+ type: 'checkbox',
4802
+ checked: draft ? draft.structuredPromptVoice !== false : true,
4803
+ disabled: !writable,
4804
+ onChange: (e) => setTop('structuredPromptVoice', e.target.checked),
4805
+ }),
4806
+ t('structuredPromptVoice'),
5072
4807
  ),
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,
4808
+ ),
4809
+ ),
4810
+ // Dictation Modes & Prompts (#264)
4811
+ React.createElement(DictationModesSection, {
4812
+ t: t,
4813
+ draft: draft,
4814
+ setTop: setTop,
4815
+ writable: writable,
4816
+ }),
4817
+ // Voice Action Macros (#167)
4818
+ React.createElement(MacrosSection, {
4819
+ t: t,
4820
+ draft: draft,
4821
+ setTop: setTop,
4822
+ writable: writable,
4823
+ }),
4824
+ React.createElement(JargonEditor, {
4825
+ t: t,
4826
+ draft: draft,
4827
+ setTop: setTop,
4828
+ writable: writable,
4829
+ jargonInput: jargonInput,
4830
+ setJargonInput: setJargonInput,
4831
+ }),
4832
+ React.createElement(ProviderDashboard, {
4833
+ t: t,
4834
+ statsData: statsData,
4835
+ recentDiagnostics: recentDiagnostics,
4836
+ onRefresh: fetchStatus,
4837
+ }),
4838
+ React.createElement(UpdaterCard, {
4839
+ t: t,
4840
+ updaterStatus: updaterStatus,
4841
+ updaterLoading: updaterLoading,
4842
+ updating: updating,
4843
+ updaterMsg: updaterMsg,
4844
+ updaterMsgKind: updaterMsgKind,
4845
+ onCheck: checkUpdater,
4846
+ onUpdateNow: onUpdateNow,
4847
+ }),
4848
+ React.createElement('div', { className: 'cb-row' },
4849
+ React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable, onClick: save }, t('save')),
4850
+ saved ? React.createElement('span', { className: 'dvs-ok' }, t('saved')) : null,
4851
+ err ? React.createElement('span', { className: 'dvs-bad' }, err) : null,
5088
4852
  ),
4853
+ )
4854
+ }
4855
+ // ------------------------------------------------------- first-run wizard (#265)
4856
+ function FirstRunWizard(props) {
4857
+ const t = props.t || moduleT
4858
+ const draft = props.draft
4859
+ const setTop = props.setTop
4860
+ const writable = props.writable
4861
+ const onClose = props.onClose
4862
+
4863
+ const isSecure = typeof window !== 'undefined' ? (window.isSecureContext || window.location.protocol === 'https:' || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') : true
4864
+ const hasMediaDevices = typeof navigator !== 'undefined' && !!navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function'
4865
+ const hasMediaRecorder = typeof MediaRecorder !== 'undefined'
4866
+
4867
+ const [micPermission, setMicPermission] = React.useState('prompt')
4868
+ const [devices, setDevices] = React.useState([])
4869
+ const [selectedDev, setSelectedDev] = React.useState((draft && draft.micDeviceId) || '')
4870
+ const [signalTesting, setSignalTesting] = React.useState(false)
4871
+ const [currentRms, setCurrentRms] = React.useState(0)
4872
+ const [peakRms, setPeakRms] = React.useState(0)
4873
+ const [testingStt, setTestingStt] = React.useState(false)
4874
+ const [testCountdown, setTestCountdown] = React.useState(0)
4875
+ const [testResult, setTestResult] = React.useState(null)
4876
+ const [testError, setTestError] = React.useState('')
4877
+ const [testLatency, setTestLatency] = React.useState(null)
4878
+
4879
+ const signalStreamRef = React.useRef(null)
4880
+ const audioCtxRef = React.useRef(null)
4881
+ const animFrameRef = React.useRef(null)
4882
+
4883
+ React.useEffect(() => {
4884
+ if (typeof navigator !== 'undefined' && navigator.permissions && navigator.permissions.query) {
4885
+ navigator.permissions.query({ name: 'microphone' })
4886
+ .then((p) => {
4887
+ if (p) {
4888
+ setMicPermission(p.state)
4889
+ p.onchange = () => setMicPermission(p.state)
4890
+ }
4891
+ })
4892
+ .catch(() => {})
4893
+ }
4894
+ }, [])
4895
+
4896
+ const refreshDevices = () => {
4897
+ if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
4898
+ navigator.mediaDevices.enumerateDevices()
4899
+ .then((list) => {
4900
+ const audioInputs = list.filter((d) => d.kind === 'audioinput')
4901
+ setDevices(audioInputs)
4902
+ })
4903
+ .catch(() => {})
4904
+ }
4905
+
4906
+ React.useEffect(() => {
4907
+ refreshDevices()
4908
+ }, [])
4909
+
4910
+ const stopSignalTest = () => {
4911
+ if (animFrameRef.current) {
4912
+ cancelAnimationFrame(animFrameRef.current)
4913
+ animFrameRef.current = null
4914
+ }
4915
+ if (signalStreamRef.current) {
4916
+ try { signalStreamRef.current.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
4917
+ signalStreamRef.current = null
4918
+ }
4919
+ if (audioCtxRef.current) {
4920
+ try { audioCtxRef.current.close() } catch (_) {}
4921
+ audioCtxRef.current = null
4922
+ }
4923
+ setSignalTesting(false)
4924
+ setCurrentRms(0)
4925
+ }
5089
4926
 
5090
- // Next Step Hint / Feedback
5091
- nextStepHint ? React.createElement('div', { className: 'dvs-wait', style: { fontStyle: 'italic' } },
5092
- t('wizardNextStepHint').replace('{hint}', nextStepHint)
5093
- ) : null,
4927
+ React.useEffect(() => {
4928
+ return () => {
4929
+ stopSignalTest()
4930
+ }
4931
+ }, [])
5094
4932
 
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,
5113
- ),
5114
- )
4933
+ const requestPermissionAndTestSignal = async () => {
4934
+ stopSignalTest()
4935
+ setTestError('')
4936
+ try {
4937
+ const constraints = {
4938
+ audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
4939
+ }
4940
+ const stream = await navigator.mediaDevices.getUserMedia(constraints)
4941
+ signalStreamRef.current = stream
4942
+ setMicPermission('granted')
4943
+ refreshDevices()
4944
+
4945
+ const AC = typeof AudioContext !== 'undefined' ? AudioContext
4946
+ : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
4947
+ if (!AC) return
4948
+ const actx = new AC()
4949
+ audioCtxRef.current = actx
4950
+ if (actx.state === 'suspended') { actx.resume().catch(() => {}) }
4951
+ const src = actx.createMediaStreamSource(stream)
4952
+ const analyser = actx.createAnalyser()
4953
+ analyser.fftSize = 128
4954
+ src.connect(analyser)
4955
+
4956
+ setSignalTesting(true)
4957
+ const buf = new Uint8Array(analyser.frequencyBinCount)
4958
+
4959
+ const checkLevel = () => {
4960
+ if (!signalStreamRef.current) return
4961
+ analyser.getByteFrequencyData(buf)
4962
+ let sum = 0
4963
+ for (let i = 0; i < buf.length; i++) sum += buf[i]
4964
+ const rms = Math.min(100, Math.round((sum / buf.length / 255) * 100 * 2.2))
4965
+ setCurrentRms(rms)
4966
+ setPeakRms((prev) => Math.max(prev, rms))
4967
+ animFrameRef.current = requestAnimationFrame(checkLevel)
4968
+ }
4969
+ animFrameRef.current = requestAnimationFrame(checkLevel)
4970
+ } catch (err) {
4971
+ setMicPermission('denied')
4972
+ setTestError(String(err && err.message ? err.message : err))
4973
+ stopSignalTest()
4974
+ }
4975
+ }
4976
+
4977
+ const runSttTest = async () => {
4978
+ stopSignalTest()
4979
+ setTestError('')
4980
+ setTestResult(null)
4981
+ setTestLatency(null)
4982
+ setTestingStt(true)
4983
+ setTestCountdown(3)
4984
+
4985
+ let testStream = null
4986
+ let recorder = null
4987
+ const chunks = []
4988
+
4989
+ try {
4990
+ const constraints = {
4991
+ audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
4992
+ }
4993
+ testStream = await navigator.mediaDevices.getUserMedia(constraints)
4994
+ let mime = 'audio/webm;codecs=opus'
4995
+ if (!MediaRecorder.isTypeSupported(mime)) mime = ''
4996
+ recorder = mime ? new MediaRecorder(testStream, { mimeType: mime }) : new MediaRecorder(testStream)
4997
+ recorder.ondataavailable = (e) => {
4998
+ if (e.data && e.data.size > 0) chunks.push(e.data)
4999
+ }
5000
+ recorder.start()
5001
+
5002
+ let left = 3
5003
+ const countdownTimer = setInterval(() => {
5004
+ left -= 1
5005
+ setTestCountdown(left)
5006
+ if (left <= 0) {
5007
+ clearInterval(countdownTimer)
5008
+ if (recorder.state !== 'inactive') recorder.stop()
5009
+ }
5010
+ }, 1000)
5011
+
5012
+ await new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
5013
+
5014
+ try { testStream.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
5015
+
5016
+ const blob = new Blob(chunks, { type: mime || 'audio/webm' })
5017
+ if (blob.size < 400) {
5018
+ setTestError(t('wizardTestEmpty'))
5019
+ setTestingStt(false)
5020
+ return
5021
+ }
5022
+
5023
+ setTestCountdown(0)
5024
+ const startMs = Date.now()
5025
+ const dataBase64 = await blobToBase64(blob)
5026
+ const payload = {
5027
+ dataBase64,
5028
+ mimeType: mime || recorder.mimeType || 'audio/webm',
5029
+ mode: 'dictation',
5030
+ subMode: 'clean',
5031
+ }
5032
+
5033
+ const res = await fetch('/dsh-voice/transcribe', {
5034
+ method: 'POST',
5035
+ headers: { 'content-type': 'application/json' },
5036
+ body: JSON.stringify(payload),
5037
+ })
5038
+ const data = await res.json()
5039
+ const latency = Date.now() - startMs
5040
+ setTestLatency(latency)
5041
+
5042
+ if (!res.ok || !data || !data.ok) {
5043
+ const msg = (data && data.error && data.error.message) || `HTTP ${res.status}`
5044
+ setTestError(msg)
5045
+ } else {
5046
+ const recognized = String(data.text || '').trim()
5047
+ if (recognized) {
5048
+ setTestResult(recognized)
5049
+ if (typeof setTop === 'function') {
5050
+ setTop('wizardCompleted', true)
5115
5051
  }
5052
+ } else {
5053
+ setTestError(t('wizardTestEmpty'))
5054
+ }
5055
+ }
5056
+ } catch (err) {
5057
+ setTestError(String(err && err.message ? err.message : err))
5058
+ } finally {
5059
+ if (testStream) {
5060
+ try { testStream.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
5061
+ }
5062
+ setTestingStt(false)
5063
+ }
5064
+ }
5065
+
5066
+ // Determine active chain info
5067
+ const dictationChain = (draft && draft.dictation && draft.dictation.chain) || []
5068
+ const activeProvider = (dictationChain[0] && (dictationChain[0].provider || dictationChain[0])) || 'sensevoice'
5069
+ let areaLabel = t('wizardAreaServer')
5070
+ if (activeProvider === 'browser' || activeProvider === 'browser-webgpu') {
5071
+ areaLabel = t('wizardAreaBrowser')
5072
+ } else if (activeProvider !== 'sensevoice' && activeProvider !== 'local-whisper') {
5073
+ areaLabel = t('wizardAreaCloud')
5074
+ }
5075
+
5076
+ let nextStepHint = ''
5077
+ if (!isSecure) {
5078
+ nextStepHint = t('wizardHintHttps')
5079
+ } else if (micPermission === 'denied') {
5080
+ nextStepHint = t('wizardHintPermission')
5081
+ } else if (peakRms === 0 && signalTesting) {
5082
+ nextStepHint = t('wizardHintDevice')
5083
+ } else if (testError) {
5084
+ nextStepHint = t('wizardHintProvider')
5085
+ }
5086
+
5087
+ return React.createElement('div', { className: 'cb-section-card', style: { borderColor: 'var(--dsw-alias-state-brand-primary)' } },
5088
+ React.createElement('div', { className: 'cb-section-title' },
5089
+ React.createElement('span', null, '✨ ' + t('wizardTitle')),
5090
+ React.createElement('div', { style: { display: 'flex', gap: '8px' } },
5091
+ testResult ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '✓ ' + t('wizardStatusCompleted')) : null,
5092
+ onClose ? React.createElement('button', {
5093
+ type: 'button', className: 'cb-btn cb-btn-mini', onClick: onClose, title: t('wizardClose'),
5094
+ }, '✕') : null,
5095
+ ),
5096
+ ),
5097
+ React.createElement('div', { className: 'cb-section-desc' }, t('wizardDesc')),
5098
+
5099
+ // Step 1: Environment & Permissions
5100
+ React.createElement('div', { className: 'dvs-card' },
5101
+ React.createElement('div', { className: 'dvs-h' }, t('wizardStep1')),
5102
+ React.createElement('div', { className: 'cb-row' },
5103
+ React.createElement('span', { className: 'cb-badge ' + (isSecure ? 'cb-badge-ok' : 'cb-badge-err') },
5104
+ isSecure ? ('✓ ' + t('wizardSecureContextOk')) : ('✗ ' + t('wizardSecureContextBad'))
5105
+ ),
5106
+ React.createElement('span', { className: 'cb-badge ' + (hasMediaDevices ? 'cb-badge-ok' : 'cb-badge-err') },
5107
+ hasMediaDevices ? ('✓ ' + t('wizardMediaDevicesOk')) : ('✗ ' + t('wizardMediaDevicesBad'))
5108
+ ),
5109
+ React.createElement('span', { className: 'cb-badge ' + (hasMediaRecorder ? 'cb-badge-ok' : 'cb-badge-err') },
5110
+ hasMediaRecorder ? ('✓ ' + t('wizardMediaRecorderOk')) : ('✗ ' + t('wizardMediaRecorderBad'))
5111
+ ),
5112
+ React.createElement('span', { className: 'cb-badge ' + (micPermission === 'granted' ? 'cb-badge-ok' : (micPermission === 'denied' ? 'cb-badge-err' : 'cb-badge-warn')) },
5113
+ micPermission === 'granted' ? ('✓ ' + t('wizardMicPermissionOk')) : (micPermission === 'denied' ? ('✗ ' + t('wizardMicPermissionBad')) : ('· ' + t('wizardMicPermissionPrompt')))
5114
+ ),
5115
+ ),
5116
+ React.createElement('div', { className: 'cb-field', style: { marginTop: '8px' } },
5117
+ React.createElement('label', null, t('wizardMicLabel')),
5118
+ React.createElement('div', { className: 'cb-row' },
5119
+ React.createElement('select', {
5120
+ value: selectedDev,
5121
+ style: { flex: 1, minWidth: '240px' },
5122
+ onChange: (e) => {
5123
+ setSelectedDev(e.target.value)
5124
+ if (typeof setTop === 'function') setTop('micDeviceId', e.target.value)
5125
+ stopSignalTest()
5126
+ },
5127
+ },
5128
+ React.createElement('option', { value: '' }, 'Default System Microphone'),
5129
+ devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId }, d.label || `Microphone ${d.deviceId.slice(0, 8)}…`))
5130
+ ),
5131
+ React.createElement('button', {
5132
+ type: 'button',
5133
+ className: 'cb-btn',
5134
+ onClick: requestPermissionAndTestSignal,
5135
+ }, t('wizardRequestMic')),
5136
+ ),
5137
+ ),
5138
+ ),
5139
+
5140
+ // Step 2: Live Audio Signal Test
5141
+ React.createElement('div', { className: 'dvs-card' },
5142
+ React.createElement('div', { className: 'dvs-h' }, t('wizardStep2')),
5143
+ React.createElement('div', { className: 'cb-row', style: { alignItems: 'center' } },
5144
+ React.createElement('button', {
5145
+ type: 'button',
5146
+ className: 'cb-btn ' + (signalTesting ? 'cb-btn-danger' : 'cb-btn-primary'),
5147
+ onClick: signalTesting ? stopSignalTest : requestPermissionAndTestSignal,
5148
+ }, signalTesting ? t('wizardStopSignalCheck') : t('wizardStartSignalCheck')),
5149
+ signalTesting ? React.createElement('span', { className: 'dvs-sub' }, t('wizardSignalTesting')) : null,
5150
+ ),
5151
+ signalTesting ? React.createElement('div', { style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '6px' } },
5152
+ React.createElement('div', {
5153
+ style: {
5154
+ height: '14px',
5155
+ borderRadius: '7px',
5156
+ background: 'var(--dsw-alias-bg-layer-1)',
5157
+ border: '1px solid var(--dsw-alias-border-l1)',
5158
+ overflow: 'hidden',
5159
+ position: 'relative',
5160
+ },
5161
+ },
5162
+ React.createElement('div', {
5163
+ style: {
5164
+ width: `${currentRms}%`,
5165
+ height: '100%',
5166
+ background: currentRms > 5 ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-dimmed)',
5167
+ transition: 'width 0.05s linear',
5168
+ },
5169
+ }),
5170
+ ),
5171
+ React.createElement('span', { className: currentRms > 5 ? 'dvs-ok' : 'dvs-sub' },
5172
+ currentRms > 5
5173
+ ? t('wizardSignalOk').replace('{level}', currentRms)
5174
+ : t('wizardSignalZero')
5175
+ ),
5176
+ ) : null,
5177
+ ),
5178
+
5179
+ // Step 3: Transcription Round-Trip Test
5180
+ React.createElement('div', { className: 'dvs-card' },
5181
+ React.createElement('div', { className: 'dvs-h' }, t('wizardStep3')),
5182
+ React.createElement('div', { className: 'cb-row' },
5183
+ React.createElement('span', { className: 'dvs-sub' }, `${t('wizardProviderLabel')} ${activeProvider}`),
5184
+ React.createElement('span', { className: 'cb-badge' }, `${t('wizardProcessingArea')} ${areaLabel}`),
5185
+ ),
5186
+ React.createElement('div', { className: 'cb-row', style: { marginTop: '8px' } },
5187
+ React.createElement('button', {
5188
+ type: 'button',
5189
+ className: 'cb-btn cb-btn-primary',
5190
+ disabled: testingStt || micPermission === 'denied',
5191
+ onClick: runSttTest,
5192
+ }, testingStt
5193
+ ? (testCountdown > 0 ? t('wizardRecordingTest').replace('{left}', testCountdown) : t('wizardTranscribingTest'))
5194
+ : t('wizardStartTestRecord')
5195
+ ),
5196
+ ),
5197
+ testResult ? React.createElement('div', { style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '4px' } },
5198
+ React.createElement('div', { className: 'dvs-ok' },
5199
+ '✓ ' + t('wizardTestSuccess').replace('{latency}', testLatency || 0)
5200
+ ),
5201
+ React.createElement('blockquote', {
5202
+ style: {
5203
+ margin: '4px 0 0 0',
5204
+ padding: '8px 12px',
5205
+ borderRadius: '6px',
5206
+ background: 'var(--dsw-alias-bg-layer-3)',
5207
+ borderLeft: '4px solid var(--dsw-alias-state-success-primary)',
5208
+ color: 'var(--dsw-alias-label-primary)',
5209
+ fontStyle: 'italic',
5210
+ },
5211
+ }, `“${testResult}”`),
5212
+ ) : null,
5213
+ testError ? React.createElement('div', { className: 'cb-alert-err', style: { marginTop: '8px' } },
5214
+ t('wizardTestFailed').replace('{error}', testError)
5215
+ ) : null,
5216
+ ),
5217
+
5218
+ // Next Step Hint / Feedback
5219
+ nextStepHint ? React.createElement('div', { className: 'dvs-wait', style: { fontStyle: 'italic' } },
5220
+ t('wizardNextStepHint').replace('{hint}', nextStepHint)
5221
+ ) : null,
5222
+
5223
+ // Wizard Bottom Actions
5224
+ React.createElement('div', { className: 'cb-row', style: { justifyContent: 'space-between', marginTop: '6px' } },
5225
+ React.createElement('button', {
5226
+ type: 'button',
5227
+ className: 'cb-btn',
5228
+ onClick: () => {
5229
+ if (typeof setTop === 'function') setTop('wizardCompleted', true)
5230
+ if (onClose) onClose()
5231
+ },
5232
+ }, t('wizardSkip')),
5233
+ testResult ? React.createElement('button', {
5234
+ type: 'button',
5235
+ className: 'cb-btn cb-btn-primary',
5236
+ onClick: () => {
5237
+ if (typeof setTop === 'function') setTop('wizardCompleted', true)
5238
+ if (onClose) onClose()
5239
+ },
5240
+ }, '✓ ' + t('wizardFinishSuccess')) : null,
5241
+ ),
5242
+ )
5243
+ }
5116
5244
  // Card in Settings → Plugins → Plugin settings (#18)
5117
5245
  function PluginCard(props) {
5118
5246
  const page = !!(props && props.view === 'page')