@goodandready/dsh-voice 0.9.31 → 0.9.33

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
@@ -248,6 +248,27 @@ window.__ModuleLoader__.load({
248
248
  'voiceActionsLabel': 'Voice action commands ("send", "cancel", "clear", "new line")',
249
249
  'liveInterimPreview': 'Live speech preview (ghost text preview)',
250
250
  'structuredPromptVoice': 'Spoken answers for interactive choice prompts',
251
+ 'modeVerbatim': 'Verbatim',
252
+ 'modeClean': 'Clean Text',
253
+ 'modeTask': 'Agent Task',
254
+ 'modeNote': 'Note',
255
+ 'dictationModeTitle': 'Dictation Post-Processing Mode',
256
+ 'dictationModeDesc': 'Select how dictated speech is processed before insertion: Verbatim (0 LLM calls), Clean text (formatting & filler removal), Agent task (structured prompt), or Note (key points & actions).',
257
+ 'dictationPromptsTitle': 'Dictation Prompt Templates',
258
+ 'dictationPromptsDesc': 'Custom prompt templates for dictation post-processing. Use {text} as placeholder for recognized speech.',
259
+ 'promptClean': 'Clean text prompt',
260
+ 'promptTask': 'Agent task prompt',
261
+ 'promptNote': 'Note prompt',
262
+ 'resetPrompts': 'Reset to defaults',
263
+ 'voiceMacrosTitle': 'Voice Action Macros',
264
+ 'voiceMacrosDesc': 'Spoken trigger phrases automatically expanded into structured prompt templates.',
265
+ 'addMacro': 'Add macro',
266
+ 'macroTrigger': 'Spoken trigger phrase',
267
+ 'macroTemplate': 'Expanded prompt template',
268
+ 'macroDelete': 'Delete',
269
+ 'rawTab': 'Raw STT',
270
+ 'resultTab': 'Result',
271
+ 'llmUnavailableFallback': 'LLM unavailable, showing raw transcript',
251
272
  'jargonTitle': 'Developer Lexicon & IT Jargon',
252
273
  'techJargonCorrection': 'Auto-correct technical jargon (GitHub, Docker, pnpm, Kubernetes, etc.)',
253
274
  'jargonAdd': 'Add replacement',
@@ -258,6 +279,49 @@ window.__ModuleLoader__.load({
258
279
  'assistantSpeakingGated': 'Assistant speaking (gated mode)',
259
280
  'jargonFormatHint': ' (spoken -> replacement)',
260
281
  'errorPrefix': 'Error',
282
+ 'wizardTitle': 'First-Run Setup & Diagnostics Wizard',
283
+ 'wizardDesc': 'Step-by-step diagnostic test: check secure context, microphone permissions, audio level, and STT round-trip.',
284
+ 'wizardBtnLaunch': 'Launch Mic & STT Wizard',
285
+ 'wizardClose': 'Close Wizard',
286
+ 'wizardStep1': '1. Audio & Environment',
287
+ 'wizardStep2': '2. Live Signal Test',
288
+ 'wizardStep3': '3. STT Transcription Test',
289
+ 'wizardSecureContextOk': 'Secure context / HTTPS verified',
290
+ 'wizardSecureContextBad': 'Insecure context (HTTPS or localhost required)',
291
+ 'wizardMediaDevicesOk': 'Browser mediaDevices API supported',
292
+ 'wizardMediaDevicesBad': 'Browser mediaDevices API missing',
293
+ 'wizardMediaRecorderOk': 'MediaRecorder API supported',
294
+ 'wizardMediaRecorderBad': 'MediaRecorder API unsupported in this browser',
295
+ 'wizardMicPermissionOk': 'Microphone permission granted',
296
+ 'wizardMicPermissionPrompt': 'Microphone permission not yet granted',
297
+ 'wizardMicPermissionBad': 'Microphone permission denied',
298
+ 'wizardRequestMic': 'Request Microphone Access',
299
+ 'wizardMicLabel': 'Microphone input device:',
300
+ 'wizardStartSignalCheck': 'Start Audio Level Test',
301
+ 'wizardStopSignalCheck': 'Stop Audio Test',
302
+ 'wizardSignalOk': 'Audio signal detected (RMS: {level}%)',
303
+ 'wizardSignalZero': 'Zero audio level detected - speak into microphone or select correct device',
304
+ 'wizardSignalTesting': 'Listening for audio signal… speak into your microphone',
305
+ 'wizardProviderLabel': 'Active STT engine & model:',
306
+ 'wizardProcessingArea': 'Processing area:',
307
+ 'wizardAreaBrowser': 'In-browser (WebGPU / WebSpeech)',
308
+ 'wizardAreaServer': 'Local MiniPC server (SenseVoice / Whisper)',
309
+ 'wizardAreaCloud': 'Remote cloud provider',
310
+ 'wizardStartTestRecord': 'Record 3-Second Test Phrase',
311
+ 'wizardRecordingTest': 'Recording test phrase… speak now ({left}s)',
312
+ 'wizardTranscribingTest': 'Transcribing test phrase…',
313
+ 'wizardTestResult': 'STT Test Result:',
314
+ 'wizardTestSuccess': 'STT round-trip verified successfully! Latency: {latency}ms',
315
+ 'wizardTestEmpty': 'No speech detected in test recording. Please speak clearly and try again.',
316
+ 'wizardTestFailed': 'STT test failed: {error}',
317
+ 'wizardNextStepHint': 'Next step: {hint}',
318
+ 'wizardHintPermission': 'Click "Request Microphone Access" and allow browser permission prompt.',
319
+ 'wizardHintDevice': 'Check microphone connection or select a different input device.',
320
+ 'wizardHintHttps': 'Open DeepSeek Harness via HTTPS or localhost address.',
321
+ 'wizardHintProvider': 'Configure STT engine credentials or install SenseVoice model below.',
322
+ 'wizardFinishSuccess': 'Setup & Verification Complete',
323
+ 'wizardSkip': 'Skip Wizard',
324
+ 'wizardStatusCompleted': 'Microphone and STT verified',
261
325
  }
262
326
 
263
327
  const zh = {
@@ -474,6 +538,27 @@ window.__ModuleLoader__.load({
474
538
  'voiceActionsLabel': '语音动作指令 ("发送", "取消", "清空", "换行")',
475
539
  'liveInterimPreview': '实时语音文本预览 (幽灵文本)',
476
540
  'structuredPromptVoice': '语音应答交互式选择提示',
541
+ 'modeVerbatim': '逐字直录',
542
+ 'modeClean': '清爽文本',
543
+ 'modeTask': '智能体任务',
544
+ 'modeNote': '便签备忘',
545
+ 'dictationModeTitle': '听写后处理模式',
546
+ 'dictationModeDesc': '选择听写文本后处理方式:逐字直录(零LLM调用)、清爽文本(整理标点去除语气词)、智能体任务(结构化提示词)或便签(要点与行动项)。',
547
+ 'dictationPromptsTitle': '听写提示词模板',
548
+ 'dictationPromptsDesc': '自定义听写后处理提示词模板。使用 {text} 作为识别语音的占位符。',
549
+ 'promptClean': '清爽文本提示词',
550
+ 'promptTask': '智能体任务提示词',
551
+ 'promptNote': '便签提示词',
552
+ 'resetPrompts': '恢复默认模板',
553
+ 'voiceMacrosTitle': '语音动作宏',
554
+ 'voiceMacrosDesc': '语音触发词自动展开为结构化提示词模板。',
555
+ 'addMacro': '添加动作宏',
556
+ 'macroTrigger': '语音触发短语',
557
+ 'macroTemplate': '展开提示词模板',
558
+ 'macroDelete': '删除',
559
+ 'rawTab': '原始识别',
560
+ 'resultTab': '处理结果',
561
+ 'llmUnavailableFallback': 'LLM不可用,已保留原始识别文本',
477
562
  'jargonTitle': '开发者词汇与 IT 术语',
478
563
  'techJargonCorrection': '自动校正开发术语 (GitHub, Docker, pnpm, Kubernetes 等)',
479
564
  'jargonAdd': '添加替换项',
@@ -484,6 +569,49 @@ window.__ModuleLoader__.load({
484
569
  'assistantSpeakingGated': '助手正在发言(门控模式)',
485
570
  'jargonFormatHint': ' (口语 -> 替换文本)',
486
571
  'errorPrefix': '错误',
572
+ 'wizardTitle': '首次启动与麦克风诊断向导',
573
+ 'wizardDesc': '分步诊断检查:安全上下文、麦克风权限、音频信号电平及实时 STT 回环测试。',
574
+ 'wizardBtnLaunch': '启动麦克风与 STT 诊断向导',
575
+ 'wizardClose': '关闭向导',
576
+ 'wizardStep1': '1. 音频与运行环境',
577
+ 'wizardStep2': '2. 实时信号测试',
578
+ 'wizardStep3': '3. STT 识别回环测试',
579
+ 'wizardSecureContextOk': '安全上下文 / HTTPS 已验证',
580
+ 'wizardSecureContextBad': '非安全上下文(需 HTTPS 或 localhost)',
581
+ 'wizardMediaDevicesOk': '浏览器 mediaDevices API 支持正常',
582
+ 'wizardMediaDevicesBad': '浏览器缺少 mediaDevices API 支持',
583
+ 'wizardMediaRecorderOk': 'MediaRecorder API 支持正常',
584
+ 'wizardMediaRecorderBad': '当前浏览器不支持 MediaRecorder API',
585
+ 'wizardMicPermissionOk': '麦克风权限已授予',
586
+ 'wizardMicPermissionPrompt': '尚未获得麦克风授权',
587
+ 'wizardMicPermissionBad': '麦克风权限被拒绝',
588
+ 'wizardRequestMic': '请求麦克风权限',
589
+ 'wizardMicLabel': '麦克风输入设备:',
590
+ 'wizardStartSignalCheck': '开始音频电平测试',
591
+ 'wizardStopSignalCheck': '停止音频测试',
592
+ 'wizardSignalOk': '检测到音频信号 (RMS: {level}%)',
593
+ 'wizardSignalZero': '未检测到声音信号 — 请对着麦克风说话或选择正确设备',
594
+ 'wizardSignalTesting': '正在监听声音信号… 请对着麦克风发声',
595
+ 'wizardProviderLabel': '当前语音引擎与模型:',
596
+ 'wizardProcessingArea': '处理区域:',
597
+ 'wizardAreaBrowser': '浏览器端处理 (WebGPU / WebSpeech)',
598
+ 'wizardAreaServer': '本地服务器处理 (SenseVoice / Whisper)',
599
+ 'wizardAreaCloud': '远程云端服务',
600
+ 'wizardStartTestRecord': '录制 3 秒测试语句',
601
+ 'wizardRecordingTest': '正在录制测试音频… 请说话 ({left}秒)',
602
+ 'wizardTranscribingTest': '正在请求语音识别…',
603
+ 'wizardTestResult': 'STT 测试结果:',
604
+ 'wizardTestSuccess': 'STT 链路验证成功!耗时:{latency}毫秒',
605
+ 'wizardTestEmpty': '录音中未检测到有效语音,请清晰发音后重试。',
606
+ 'wizardTestFailed': 'STT 测试失败:{error}',
607
+ 'wizardNextStepHint': '建议下一步:{hint}',
608
+ 'wizardHintPermission': '点击“请求麦克风权限”并在浏览器弹窗中允许访问。',
609
+ 'wizardHintDevice': '检查麦克风硬件连接或切换选择其他输入设备。',
610
+ 'wizardHintHttps': '请通过 HTTPS 或 localhost 访问 DeepSeek Harness。',
611
+ 'wizardHintProvider': '请在下方配置 STT 密钥凭据或一键安装 SenseVoice 本地模型。',
612
+ 'wizardFinishSuccess': '设置与验证已完成',
613
+ 'wizardSkip': '跳过向导',
614
+ 'wizardStatusCompleted': '麦克风与 STT 诊断已就绪',
487
615
  }
488
616
 
489
617
  // Strings are also needed outside components — in recording handlers and
@@ -547,7 +675,7 @@ window.__ModuleLoader__.load({
547
675
  '.dvo-preview-input:focus{border-color:var(--dsw-alias-state-info-primary)}' +
548
676
  '.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}' +
549
677
  '.dvo-btn-send:hover{opacity:0.9}' +
550
- '.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}'
678
+ '.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)}'
551
679
  const cssId = 'dsh-voice/client.module.css'
552
680
  if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {
553
681
  const tag = document.createElement('style')
@@ -743,7 +871,26 @@ window.__ModuleLoader__.load({
743
871
 
744
872
  function extractContextKeywords() {
745
873
  if (!voice.settings || voice.settings.contextGlossary === false) return []
746
- const text = (voice.input && typeof voice.input.draft === 'string') ? voice.input.draft : ''
874
+ const chunks = []
875
+ const draft = (voice.input && typeof voice.input.draft === 'string') ? voice.input.draft : ''
876
+ if (draft && draft.length >= 3) chunks.push(draft)
877
+ if (voice.session && Array.isArray(voice.session.messages)) {
878
+ const msgs = voice.session.messages.slice(-5)
879
+ for (const m of msgs) {
880
+ const t = (m && typeof m.content === 'string') ? m.content : ((m && typeof m.text === 'string') ? m.text : '')
881
+ if (t && t.length >= 3) chunks.push(t)
882
+ }
883
+ }
884
+ if (typeof document !== 'undefined') {
885
+ try {
886
+ const nodes = document.querySelectorAll('.message-content code, .dsh-message-body code, pre code')
887
+ for (let i = 0; i < Math.min(nodes.length, 10); i++) {
888
+ const ct = nodes[i].textContent
889
+ if (ct && ct.length < 100) chunks.push(ct)
890
+ }
891
+ } catch (_) {}
892
+ }
893
+ const text = chunks.join(' ')
747
894
  if (!text || text.length < 3) return []
748
895
  const matches = text.match(/[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{2,29}/g) || []
749
896
  // Linguistic filter: functional stop-word dictionary for vocabulary keyword extraction.
@@ -775,7 +922,11 @@ window.__ModuleLoader__.load({
775
922
 
776
923
  const tryHost = async (chainOverride) => {
777
924
  const dataBase64 = await blobToBase64(blob)
778
- const payload = { dataBase64, mimeType: mime, mode }
925
+ const subMode = (voice._activeTarget && voice._activeTarget.dictationMode)
926
+ || voice.selectedDictationMode
927
+ || (voice.settings && voice.settings.dictationMode)
928
+ || 'clean'
929
+ const payload = { dataBase64, mimeType: mime, mode, subMode }
779
930
  if (Array.isArray(chainOverride)) {
780
931
  payload.chain = chainOverride.map((p) => (typeof p === 'string' ? { provider: p, model: modelMap[p] } : p))
781
932
  }
@@ -1491,7 +1642,10 @@ voice._chainsPromise = null
1491
1642
  const targetSession = voice.session || (targetInput && targetInput.session)
1492
1643
  const sessionId = (targetSession && (targetSession.sessionId || targetSession.id))
1493
1644
  || voice.currentSessionId || ''
1494
- const target = { actions: targetActions, input: targetInput, session: targetSession, span, initialDraft, sessionId }
1645
+ const snapshotSubMode = voice.selectedDictationMode
1646
+ || (voice.settings && voice.settings.dictationMode)
1647
+ || 'clean'
1648
+ const target = { actions: targetActions, input: targetInput, session: targetSession, span, initialDraft, sessionId, dictationMode: snapshotSubMode }
1495
1649
  voice._activeTarget = target
1496
1650
 
1497
1651
  if (voice.isTtsSpeaking) {
@@ -1738,7 +1892,15 @@ voice._chainsPromise = null
1738
1892
  if (v.phase !== 'idle' && v.phase !== 'error' && !hold.armed) return null
1739
1893
  const err = v.phase === 'error'
1740
1894
  const hk = voice.hotkey ? ` (${voice.hotkey})` : ''
1895
+ const curMode = voice.selectedDictationMode || (voice.settings && voice.settings.dictationMode) || 'clean'
1741
1896
  return React.createElement(React.Fragment, null,
1897
+ React.createElement('button', {
1898
+ type: 'button',
1899
+ className: 'dvo-mode-pill-btn',
1900
+ title: t('dictationModeTitle') + ': ' + getDictationModeLabel(curMode, t),
1901
+ 'aria-label': t('dictationModeTitle') + ': ' + getDictationModeLabel(curMode, t),
1902
+ onClick: cycleDictationMode,
1903
+ }, getDictationModeLabel(curMode, t)),
1742
1904
  React.createElement('button', {
1743
1905
  type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
1744
1906
  title: err ? v.error : t('dictationBtn'),
@@ -2208,6 +2370,25 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
2208
2370
  ? React.createElement('div', { className: 'dvo-warn-banner' }, t('sessionMismatch'))
2209
2371
  : null,
2210
2372
  )
2373
+ }
2374
+ // ------------------------------------------------------------------ 45-dictation
2375
+ // Dictation modes UI helper, mode cycler and labels (#264)
2376
+
2377
+ const DICTATION_MODES = ['clean', 'task', 'note', 'verbatim']
2378
+
2379
+ function getDictationModeLabel(m, t) {
2380
+ const trans = t || moduleT
2381
+ if (m === 'verbatim') return trans('modeVerbatim')
2382
+ if (m === 'task') return trans('modeTask')
2383
+ if (m === 'note') return trans('modeNote')
2384
+ return trans('modeClean')
2385
+ }
2386
+
2387
+ function cycleDictationMode() {
2388
+ const cur = voice.selectedDictationMode || (voice.settings && voice.settings.dictationMode) || 'clean'
2389
+ const nextIdx = (DICTATION_MODES.indexOf(cur) + 1) % DICTATION_MODES.length
2390
+ voice.selectedDictationMode = DICTATION_MODES[nextIdx]
2391
+ voice.notify()
2211
2392
  }
2212
2393
  // ----------------------------------------------------------- visualizers
2213
2394
  let _accentColorCache = { color: '', expires: 0 }
@@ -2530,7 +2711,12 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
2530
2711
  // Live caption of what is heard right now. Until the browser emits a
2531
2712
  // final chunk the text is interim and changes on screen.
2532
2713
  const caption = voice.caption || (inBrowser ? '' : null)
2714
+ const activeSubMode = (voice._activeTarget && voice._activeTarget.dictationMode)
2715
+ || voice.selectedDictationMode
2716
+ || (voice.settings && voice.settings.dictationMode)
2717
+ || 'clean'
2533
2718
  return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
2719
+ React.createElement('span', { className: 'dvo-mode-badge' }, getDictationModeLabel(activeSubMode, t)),
2534
2720
  React.createElement('button', {
2535
2721
  type: 'button', className: 'dvo-pbtn', title: t('cancel'),
2536
2722
  'aria-label': t('cancel'),
@@ -2559,8 +2745,16 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
2559
2745
  }
2560
2746
 
2561
2747
  if (v.phase === 'processing') {
2748
+ const activeSubMode = (voice._activeTarget && voice._activeTarget.dictationMode)
2749
+ || voice.selectedDictationMode
2750
+ || (voice.settings && voice.settings.dictationMode)
2751
+ || 'clean'
2562
2752
  return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
2563
- React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, spinIcon(), t('transcribing')))
2753
+ React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' },
2754
+ spinIcon(),
2755
+ React.createElement('span', { className: 'dvo-mode-badge' }, getDictationModeLabel(activeSubMode, t)),
2756
+ t('transcribing'),
2757
+ ))
2564
2758
  }
2565
2759
 
2566
2760
  if (v.phase === 'pending') {
@@ -2677,6 +2871,9 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
2677
2871
  voiceCommands: !!(data && data.voiceCommands),
2678
2872
  vadSilenceMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.vadSilenceMs) || 700,
2679
2873
  autoSendMs: Number(data && data.modes && data.modes.message && data.modes.message.autoSendMs) || 4000,
2874
+ dictationMode: String((data && data.dictationMode) || 'clean'),
2875
+ dictationPrompts: (data && data.dictationPrompts) || {},
2876
+ voiceMacros: Array.isArray(data && data.voiceMacros) ? data.voiceMacros : [],
2680
2877
  sendDelayMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.sendDelayMs) || 0,
2681
2878
  stream: !!(data && data.modes && data.modes.dictation && data.modes.dictation.stream),
2682
2879
  streamChunkMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.streamChunkMs) || 1200,
@@ -3016,6 +3213,93 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3016
3213
  ),
3017
3214
  )
3018
3215
  }
3216
+ // ------------------------------------------------------------------ 72-dictation-modes
3217
+ // Dictation modes configuration card and prompt template editors (#264)
3218
+
3219
+ function DictationModesSection(props) {
3220
+ const { t, draft, setTop, writable } = props
3221
+ const prompts = (draft && draft.dictationPrompts) || {}
3222
+ const currentMode = (draft && draft.dictationMode) || 'clean'
3223
+
3224
+ const setPrompt = (key, val) => {
3225
+ const updated = { ...prompts, [key]: val }
3226
+ setTop('dictationPrompts', updated)
3227
+ }
3228
+
3229
+ const resetPrompts = () => {
3230
+ setTop('dictationPrompts', { clean: '', task: '', note: '' })
3231
+ }
3232
+
3233
+ return React.createElement('div', { className: 'cb-section-card' },
3234
+ React.createElement('div', { className: 'cb-section-title' },
3235
+ React.createElement('span', null, '🎙️ ' + t('dictationModeTitle')),
3236
+ ),
3237
+ React.createElement('div', { className: 'cb-field-desc' }, t('dictationModeDesc')),
3238
+ React.createElement('div', { className: 'cb-grid-2', style: { marginTop: '8px' } },
3239
+ ['clean', 'task', 'note', 'verbatim'].map((m) =>
3240
+ React.createElement('label', { key: m, className: 'cb-check-label' },
3241
+ React.createElement('input', {
3242
+ type: 'radio',
3243
+ name: 'dictationMode',
3244
+ value: m,
3245
+ checked: currentMode === m,
3246
+ disabled: !writable,
3247
+ onChange: () => setTop('dictationMode', m),
3248
+ }),
3249
+ getDictationModeLabel(m, t),
3250
+ ),
3251
+ ),
3252
+ ),
3253
+ React.createElement('div', { style: { marginTop: '12px', borderTop: '1px solid var(--dsw-alias-border-l1)', paddingTop: '10px' } },
3254
+ React.createElement('div', { className: 'cb-section-title', style: { fontSize: '13px' } },
3255
+ React.createElement('span', null, t('dictationPromptsTitle')),
3256
+ React.createElement('button', {
3257
+ type: 'button',
3258
+ className: 'cb-btn cb-btn-secondary',
3259
+ style: { fontSize: '11px', padding: '2px 8px' },
3260
+ disabled: !writable,
3261
+ onClick: resetPrompts,
3262
+ }, t('resetPrompts')),
3263
+ ),
3264
+ React.createElement('div', { className: 'cb-field-desc' }, t('dictationPromptsDesc')),
3265
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '8px' } },
3266
+ React.createElement('div', null,
3267
+ React.createElement('label', { className: 'cb-label' }, t('promptClean')),
3268
+ React.createElement('textarea', {
3269
+ className: 'cb-input',
3270
+ style: { minHeight: '48px', resize: 'vertical' },
3271
+ value: prompts.clean || '',
3272
+ placeholder: 'Default: punctuation & filler-word cleanup',
3273
+ disabled: !writable,
3274
+ onChange: (e) => setPrompt('clean', e.target.value),
3275
+ }),
3276
+ ),
3277
+ React.createElement('div', null,
3278
+ React.createElement('label', { className: 'cb-label' }, t('promptTask')),
3279
+ React.createElement('textarea', {
3280
+ className: 'cb-input',
3281
+ style: { minHeight: '48px', resize: 'vertical' },
3282
+ value: prompts.task || '',
3283
+ placeholder: 'Default: structured problem/outcome/constraints/criteria',
3284
+ disabled: !writable,
3285
+ onChange: (e) => setPrompt('task', e.target.value),
3286
+ }),
3287
+ ),
3288
+ React.createElement('div', null,
3289
+ React.createElement('label', { className: 'cb-label' }, t('promptNote')),
3290
+ React.createElement('textarea', {
3291
+ className: 'cb-input',
3292
+ style: { minHeight: '48px', resize: 'vertical' },
3293
+ value: prompts.note || '',
3294
+ placeholder: 'Default: key points & action items summary',
3295
+ disabled: !writable,
3296
+ onChange: (e) => setPrompt('note', e.target.value),
3297
+ }),
3298
+ ),
3299
+ ),
3300
+ ),
3301
+ )
3302
+ }
3019
3303
  // 5. Hardware & Audio Engines Card
3020
3304
  function HardwareOptionsCard(props) {
3021
3305
  const t = props.t || moduleT
@@ -3499,6 +3783,78 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3499
3783
  ),
3500
3784
  )
3501
3785
  }
3786
+ // ------------------------------------------------------------------ 72-macros
3787
+ // Voice action macros configuration card (#167)
3788
+
3789
+ function MacrosSection(props) {
3790
+ const { t, draft, setTop, writable } = props
3791
+ const macros = Array.isArray(draft && draft.voiceMacros) ? draft.voiceMacros : []
3792
+
3793
+ const addMacro = () => {
3794
+ const next = [...macros, { trigger: '', template: '' }]
3795
+ setTop('voiceMacros', next)
3796
+ }
3797
+
3798
+ const updateMacro = (idx, field, val) => {
3799
+ const next = macros.map((item, i) => (i === idx ? { ...item, [field]: val } : item))
3800
+ setTop('voiceMacros', next)
3801
+ }
3802
+
3803
+ const removeMacro = (idx) => {
3804
+ const next = macros.filter((_, i) => i !== idx)
3805
+ setTop('voiceMacros', next)
3806
+ }
3807
+
3808
+ return React.createElement('div', { className: 'cb-section-card' },
3809
+ React.createElement('div', { className: 'cb-section-title' },
3810
+ React.createElement('span', null, '⚡ ' + t('voiceMacrosTitle')),
3811
+ React.createElement('button', {
3812
+ type: 'button',
3813
+ className: 'cb-btn cb-btn-secondary',
3814
+ style: { fontSize: '11px', padding: '2px 8px' },
3815
+ disabled: !writable,
3816
+ onClick: addMacro,
3817
+ }, '+ ' + t('addMacro')),
3818
+ ),
3819
+ React.createElement('div', { className: 'cb-field-desc' }, t('voiceMacrosDesc')),
3820
+ macros.length === 0
3821
+ ? React.createElement('div', { style: { fontStyle: 'italic', color: 'var(--dsw-alias-label-tertiary)', fontSize: '12px', marginTop: '6px' } },
3822
+ 'No macros configured.',
3823
+ )
3824
+ : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '6px' } },
3825
+ macros.map((m, idx) =>
3826
+ React.createElement('div', { key: idx, className: 'dvo-macro-item' },
3827
+ React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
3828
+ React.createElement('input', {
3829
+ type: 'text',
3830
+ className: 'cb-input',
3831
+ style: { flex: '1', height: '28px', fontSize: '12px' },
3832
+ placeholder: t('macroTrigger'),
3833
+ value: m.trigger || '',
3834
+ disabled: !writable,
3835
+ onChange: (e) => updateMacro(idx, 'trigger', e.target.value),
3836
+ }),
3837
+ React.createElement('button', {
3838
+ type: 'button',
3839
+ className: 'cb-btn cb-btn-secondary',
3840
+ style: { padding: '2px 6px', fontSize: '11px', color: 'var(--dsw-alias-state-error-primary)' },
3841
+ disabled: !writable,
3842
+ onClick: () => removeMacro(idx),
3843
+ }, t('macroDelete')),
3844
+ ),
3845
+ React.createElement('textarea', {
3846
+ className: 'cb-input',
3847
+ style: { minHeight: '38px', resize: 'vertical', fontSize: '12px' },
3848
+ placeholder: t('macroTemplate') + ' (use {text} for argument)',
3849
+ value: m.template || '',
3850
+ disabled: !writable,
3851
+ onChange: (e) => updateMacro(idx, 'template', e.target.value),
3852
+ }),
3853
+ ),
3854
+ ),
3855
+ ),
3856
+ )
3857
+ }
3502
3858
  // SenseVoice Local ASR (1-Click Setup)
3503
3859
  function SensevoiceSection(props) {
3504
3860
  const t = props.t || moduleT
@@ -3553,14 +3909,21 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3553
3909
  const whisperRunning = props.whisperRunning
3554
3910
  const providerCount = props.providerCount
3555
3911
  const onRefresh = props.onRefresh
3912
+ const onOpenWizard = props.onOpenWizard
3556
3913
 
3557
3914
  return React.createElement('div', { className: 'cb-section-card' },
3558
3915
  React.createElement('div', { className: 'cb-section-title' },
3559
3916
  React.createElement('span', null, t('statusTitle')),
3560
- React.createElement('button', {
3561
- type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
3562
- onClick: onRefresh,
3563
- }, '🔄 ' + t('refreshStats')),
3917
+ React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
3918
+ onOpenWizard ? React.createElement('button', {
3919
+ type: 'button', className: 'cb-btn cb-btn-primary', style: { fontSize: '12px', padding: '4px 10px' },
3920
+ onClick: onOpenWizard,
3921
+ }, '✨ ' + t('wizardBtnLaunch')) : null,
3922
+ React.createElement('button', {
3923
+ type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
3924
+ onClick: onRefresh,
3925
+ }, '🔄 ' + t('refreshStats')),
3926
+ ),
3564
3927
  ),
3565
3928
  React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px', flexWrap: 'wrap' } },
3566
3929
  React.createElement('span', { className: 'cb-badge ' + (hostOnline ? 'cb-badge-ok' : 'cb-badge-err') },
@@ -3652,7 +4015,6 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3652
4015
  const [serverConfig, setServerConfig] = React.useState(null)
3653
4016
  const [configLoading, setConfigLoading] = React.useState(true)
3654
4017
  const [revision, setRevision] = React.useState(null)
3655
-
3656
4018
  React.useEffect(() => {
3657
4019
  let alive = true
3658
4020
  fetch('/dsh-voice/config', { cache: 'no-store' })
@@ -3662,6 +4024,7 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3662
4024
  setServerConfig(data.config)
3663
4025
  if (typeof data.revision === 'number') setRevision(data.revision)
3664
4026
  setDraft((curr) => curr || deepClone(data.config))
4027
+ if (data.config.wizardCompleted !== true) setShowWizard(true)
3665
4028
  }
3666
4029
  })
3667
4030
  .catch(() => {})
@@ -3670,10 +4033,10 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3670
4033
  })
3671
4034
  return () => { alive = false }
3672
4035
  }, [])
3673
-
3674
4036
  // Hotkey picker: not a name field — press the key you want.
3675
4037
  const [catching, setCatching] = React.useState(false)
3676
4038
  const [jargonInput, setJargonInput] = React.useState(null)
4039
+ const [showWizard, setShowWizard] = React.useState(false)
3677
4040
  React.useEffect(() => {
3678
4041
  if (!catching) return undefined
3679
4042
  const onKey = (event) => {
@@ -3693,7 +4056,6 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3693
4056
  document.addEventListener('keydown', onKey, true)
3694
4057
  return () => document.removeEventListener('keydown', onKey, true)
3695
4058
  }, [catching, ctx])
3696
-
3697
4059
  React.useEffect(() => {
3698
4060
  let alive = true
3699
4061
  const render = () => { if (alive && scope && typeof scope.getSnapshot === 'function') setSnap(scope.getSnapshot()) }
@@ -3701,7 +4063,6 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3701
4063
  const off = (scope && typeof scope.subscribe === 'function') ? scope.subscribe(render) : () => {}
3702
4064
  return () => { alive = false; off() }
3703
4065
  }, [scope])
3704
-
3705
4066
  const [devices, setDevices] = React.useState([])
3706
4067
  React.useEffect(() => {
3707
4068
  if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
@@ -3709,11 +4070,9 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3709
4070
  .then((list) => setDevices(list.filter((d) => d.kind === 'audioinput')))
3710
4071
  .catch(() => {})
3711
4072
  }, [])
3712
-
3713
4073
  const ready = !!serverConfig || (!!snap && snap.status === 'ready')
3714
4074
  const value = (snap && snap.status === 'ready' && snap.value) ? snap.value : (serverConfig || {})
3715
4075
  const writable = ready
3716
-
3717
4076
  React.useEffect(() => {
3718
4077
  if (ready) return undefined
3719
4078
  let tries = 0
@@ -3730,14 +4089,12 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3730
4089
  }, 1000)
3731
4090
  return () => clearInterval(timer)
3732
4091
  }, [ready])
3733
-
3734
4092
  const deepClone = (obj) => {
3735
4093
  if (typeof structuredClone === 'function') {
3736
4094
  try { return structuredClone(obj) } catch (e) { /* fallback */ }
3737
4095
  }
3738
4096
  try { return JSON.parse(JSON.stringify(obj)) } catch (e) { return Object.assign({}, obj) }
3739
4097
  }
3740
-
3741
4098
  const setTop = (key, val) => {
3742
4099
  setDraft((d) => Object.assign({}, d || {}, { [key]: val }))
3743
4100
  }
@@ -3758,9 +4115,7 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3758
4115
  }),
3759
4116
  hint ? React.createElement('span', { className: 'dvs-sub' }, hint) : null,
3760
4117
  )
3761
-
3762
4118
  React.useEffect(() => { if (ready && draft === null) setDraft(deepClone(value)) }, [ready, draft, value])
3763
-
3764
4119
  React.useEffect(() => {
3765
4120
  if (!ready) return
3766
4121
  const val = value || {}
@@ -3792,10 +4147,12 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3792
4147
  techJargonCorrection: val.techJargonCorrection !== false,
3793
4148
  liveInterimPreview: val.liveInterimPreview !== false,
3794
4149
  structuredPromptVoice: val.structuredPromptVoice !== false,
4150
+ dictationMode: val.dictationMode || 'clean',
4151
+ dictationPrompts: val.dictationPrompts || {},
4152
+ voiceMacros: Array.isArray(val.voiceMacros) ? val.voiceMacros : [],
3795
4153
  webgpuModel: String(val.webgpuModel || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'),
3796
4154
  })
3797
4155
  }, [ready, value])
3798
-
3799
4156
  const [statusData, setStatusData] = React.useState(null)
3800
4157
  const [statusLatency, setStatusLatency] = React.useState(null)
3801
4158
  const fetchStatus = React.useCallback(() => {
@@ -3819,17 +4176,12 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3819
4176
  setStatusLatency(null)
3820
4177
  })
3821
4178
  }, [])
3822
-
3823
4179
  React.useEffect(() => {
3824
4180
  fetchStatus()
3825
4181
  }, [fetchStatus])
3826
-
3827
4182
  const { sensevoiceState, installingSensevoice, triggerInstallSensevoice } = useSensevoiceInstaller(fetchStatus)
3828
-
3829
4183
  const { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent } = useMicTester(draft, value, setErr)
3830
-
3831
4184
  const { updaterStatus, updating, updaterMsg, updaterMsgKind, updaterLoading, checkUpdater, onUpdateNow } = usePluginUpdater()
3832
-
3833
4185
  const save = async () => {
3834
4186
  setErr(''); setSaved(false)
3835
4187
  if (!draft) return
@@ -3848,7 +4200,6 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3848
4200
  .filter(Boolean)
3849
4201
  draft.jargonDictionary = parsed
3850
4202
  }
3851
-
3852
4203
  let httpOk = false
3853
4204
  let httpError = null
3854
4205
  try {
@@ -3870,7 +4221,6 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3870
4221
  } catch (e) {
3871
4222
  httpError = e?.message || String(e)
3872
4223
  }
3873
-
3874
4224
  if (httpOk && scope && typeof scope.set === 'function') {
3875
4225
  for (const k of Object.keys(draft)) {
3876
4226
  try {
@@ -3880,12 +4230,10 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3880
4230
  }
3881
4231
  }
3882
4232
  }
3883
-
3884
4233
  if (!httpOk) {
3885
4234
  setErr(t('saveFailed') + ': ' + httpError)
3886
4235
  return
3887
4236
  }
3888
-
3889
4237
  const d = draft || {}
3890
4238
  voice.hotkey = d.hotkey || ''
3891
4239
  if (voice.hotkey) {
@@ -3917,23 +4265,19 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3917
4265
  structuredPromptVoice: d.structuredPromptVoice !== false,
3918
4266
  })
3919
4267
  try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
3920
-
3921
4268
  setSaved(true); setJargonInput(null)
3922
4269
  setTimeout(() => setSaved(false), 2000)
3923
4270
  fetchStatus()
3924
4271
  }
3925
-
3926
4272
  const modeVal = (mode, key, fallback) => {
3927
4273
  const m = draft && draft[mode]
3928
4274
  return m && m[key] !== undefined ? m[key] : fallback
3929
4275
  }
3930
-
3931
4276
  const chainOptions = BUILTIN.concat(
3932
4277
  (draft && Array.isArray(draft.customProviders) ? draft.customProviders : [])
3933
4278
  .map((c) => String(c && c.key || '').trim())
3934
4279
  .filter((k) => k && BUILTIN.indexOf(k) < 0),
3935
4280
  )
3936
-
3937
4281
  const langField = (mode) => {
3938
4282
  const val = modeVal(mode, 'language', '')
3939
4283
  const opts = SUPPORTED_LANGS.slice()
@@ -3950,7 +4294,6 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3950
4294
  ))),
3951
4295
  )
3952
4296
  }
3953
-
3954
4297
  const numField = (mode, key, label, hint) => React.createElement('div', { className: 'cb-field' },
3955
4298
  React.createElement('label', null, label),
3956
4299
  React.createElement('input', {
@@ -3959,15 +4302,11 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3959
4302
  }),
3960
4303
  React.createElement('span', { className: 'dvs-sub' }, hint),
3961
4304
  )
3962
-
3963
-
3964
4305
  const statsData = (statusData && statusData.providerStats) || {}
3965
4306
  const hostOnline = statusLatency !== null
3966
4307
  const whisperRunning = !!(statusData && statusData.whisperRunning)
3967
4308
  const providerCount = (statusData && Array.isArray(statusData.providers)) ? statusData.providers.length : chainOptions.length
3968
-
3969
4309
  return React.createElement('div', { className: 'cb-page dvo-settings-root' },
3970
- // 1. Connection & Engine Status Card
3971
4310
  React.createElement(ConnectionStatusCard, {
3972
4311
  t: t,
3973
4312
  hostOnline: hostOnline,
@@ -3975,9 +4314,15 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
3975
4314
  whisperRunning: whisperRunning,
3976
4315
  providerCount: providerCount,
3977
4316
  onRefresh: fetchStatus,
4317
+ onOpenWizard: () => setShowWizard((v) => !v),
3978
4318
  }),
3979
-
3980
- // 2. Dictation Mode Card
4319
+ showWizard ? React.createElement(FirstRunWizard, {
4320
+ t: t,
4321
+ draft: draft,
4322
+ setTop: setTop,
4323
+ writable: writable,
4324
+ onClose: () => setShowWizard(false),
4325
+ }) : null,
3981
4326
  React.createElement('div', { className: 'cb-section-card' },
3982
4327
  React.createElement('div', { className: 'cb-section-title' },
3983
4328
  React.createElement('span', null, '🎙️ ' + t('dictationBtn')),
@@ -4021,8 +4366,6 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
4021
4366
  ),
4022
4367
  textField('wakeWord', t('wakeWord'), t('wakeWordHint')),
4023
4368
  ),
4024
-
4025
- // 3. Voice Message Card
4026
4369
  React.createElement('div', { className: 'cb-section-card' },
4027
4370
  React.createElement('div', { className: 'cb-section-title' },
4028
4371
  React.createElement('span', null, '💬 ' + t('messageTitle')),
@@ -4061,8 +4404,6 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
4061
4404
  ),
4062
4405
  ),
4063
4406
  ),
4064
-
4065
- // 4. Custom Providers Card
4066
4407
  React.createElement('div', { className: 'cb-section-card' },
4067
4408
  React.createElement('div', { className: 'cb-section-title' },
4068
4409
  React.createElement('span', null, '⚙️ ' + t('customTitle')),
@@ -4074,8 +4415,6 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
4074
4415
  onChange: (v) => setTop('customProviders', v),
4075
4416
  }),
4076
4417
  ),
4077
-
4078
- // 5. Hardware & Audio Engines Card
4079
4418
  React.createElement(HardwareOptionsCard, {
4080
4419
  t: t,
4081
4420
  draft: draft,
@@ -4095,8 +4434,6 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
4095
4434
  clearGlobalHotkey()
4096
4435
  },
4097
4436
  }),
4098
-
4099
- // 6. SenseVoice Local ASR (1-Click Setup)
4100
4437
  React.createElement(SensevoiceSection, {
4101
4438
  t: t,
4102
4439
  sensevoiceState: sensevoiceState,
@@ -4104,8 +4441,6 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
4104
4441
  writable: writable,
4105
4442
  onInstall: triggerInstallSensevoice,
4106
4443
  }),
4107
-
4108
- // 7. Turn-Taking & Audio Coordination
4109
4444
  React.createElement('div', { className: 'cb-section-card' },
4110
4445
  React.createElement('div', { className: 'cb-section-title' },
4111
4446
  React.createElement('span', null, '🎙️ ' + t('turnTakingTitle')),
@@ -4132,8 +4467,6 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
4132
4467
  ),
4133
4468
  ),
4134
4469
  ),
4135
-
4136
- // 8. Hands-free & Smart Actions
4137
4470
  React.createElement('div', { className: 'cb-section-card' },
4138
4471
  React.createElement('div', { className: 'cb-section-title' },
4139
4472
  React.createElement('span', null, '✨ ' + t('smartActionsTitle')),
@@ -4177,8 +4510,20 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
4177
4510
  ),
4178
4511
  ),
4179
4512
  ),
4180
-
4181
- // 9. Developer Lexicon & IT Jargon
4513
+ // Dictation Modes & Prompts (#264)
4514
+ React.createElement(DictationModesSection, {
4515
+ t: t,
4516
+ draft: draft,
4517
+ setTop: setTop,
4518
+ writable: writable,
4519
+ }),
4520
+ // Voice Action Macros (#167)
4521
+ React.createElement(MacrosSection, {
4522
+ t: t,
4523
+ draft: draft,
4524
+ setTop: setTop,
4525
+ writable: writable,
4526
+ }),
4182
4527
  React.createElement(JargonEditor, {
4183
4528
  t: t,
4184
4529
  draft: draft,
@@ -4187,15 +4532,11 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
4187
4532
  jargonInput: jargonInput,
4188
4533
  setJargonInput: setJargonInput,
4189
4534
  }),
4190
-
4191
- // 10. Provider Telemetry & Latency Dashboard
4192
4535
  React.createElement(ProviderDashboard, {
4193
4536
  t: t,
4194
4537
  statsData: statsData,
4195
4538
  onRefresh: fetchStatus,
4196
4539
  }),
4197
-
4198
- // 7. Plugin Version & Updates Card
4199
4540
  React.createElement(UpdaterCard, {
4200
4541
  t: t,
4201
4542
  updaterStatus: updaterStatus,
@@ -4206,8 +4547,6 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
4206
4547
  onCheck: checkUpdater,
4207
4548
  onUpdateNow: onUpdateNow,
4208
4549
  }),
4209
-
4210
- // Action bar (Save)
4211
4550
  React.createElement('div', { className: 'cb-row' },
4212
4551
  React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable, onClick: save }, t('save')),
4213
4552
  saved ? React.createElement('span', { className: 'dvs-ok' }, t('saved')) : null,
@@ -4215,6 +4554,395 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
4215
4554
  ),
4216
4555
  )
4217
4556
  }
4557
+ // ------------------------------------------------------- first-run wizard (#265)
4558
+ function FirstRunWizard(props) {
4559
+ const t = props.t || moduleT
4560
+ const draft = props.draft
4561
+ const setTop = props.setTop
4562
+ const writable = props.writable
4563
+ const onClose = props.onClose
4564
+
4565
+ const isSecure = typeof window !== 'undefined' ? (window.isSecureContext || window.location.protocol === 'https:' || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') : true
4566
+ const hasMediaDevices = typeof navigator !== 'undefined' && !!navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function'
4567
+ const hasMediaRecorder = typeof MediaRecorder !== 'undefined'
4568
+
4569
+ const [micPermission, setMicPermission] = React.useState('prompt')
4570
+ const [devices, setDevices] = React.useState([])
4571
+ const [selectedDev, setSelectedDev] = React.useState((draft && draft.micDeviceId) || '')
4572
+ const [signalTesting, setSignalTesting] = React.useState(false)
4573
+ const [currentRms, setCurrentRms] = React.useState(0)
4574
+ const [peakRms, setPeakRms] = React.useState(0)
4575
+ const [testingStt, setTestingStt] = React.useState(false)
4576
+ const [testCountdown, setTestCountdown] = React.useState(0)
4577
+ const [testResult, setTestResult] = React.useState(null)
4578
+ const [testError, setTestError] = React.useState('')
4579
+ const [testLatency, setTestLatency] = React.useState(null)
4580
+
4581
+ const signalStreamRef = React.useRef(null)
4582
+ const audioCtxRef = React.useRef(null)
4583
+ const animFrameRef = React.useRef(null)
4584
+
4585
+ React.useEffect(() => {
4586
+ if (typeof navigator !== 'undefined' && navigator.permissions && navigator.permissions.query) {
4587
+ navigator.permissions.query({ name: 'microphone' })
4588
+ .then((p) => {
4589
+ if (p) {
4590
+ setMicPermission(p.state)
4591
+ p.onchange = () => setMicPermission(p.state)
4592
+ }
4593
+ })
4594
+ .catch(() => {})
4595
+ }
4596
+ }, [])
4597
+
4598
+ const refreshDevices = () => {
4599
+ if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
4600
+ navigator.mediaDevices.enumerateDevices()
4601
+ .then((list) => {
4602
+ const audioInputs = list.filter((d) => d.kind === 'audioinput')
4603
+ setDevices(audioInputs)
4604
+ })
4605
+ .catch(() => {})
4606
+ }
4607
+
4608
+ React.useEffect(() => {
4609
+ refreshDevices()
4610
+ }, [])
4611
+
4612
+ const stopSignalTest = () => {
4613
+ if (animFrameRef.current) {
4614
+ cancelAnimationFrame(animFrameRef.current)
4615
+ animFrameRef.current = null
4616
+ }
4617
+ if (signalStreamRef.current) {
4618
+ try { signalStreamRef.current.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
4619
+ signalStreamRef.current = null
4620
+ }
4621
+ if (audioCtxRef.current) {
4622
+ try { audioCtxRef.current.close() } catch (_) {}
4623
+ audioCtxRef.current = null
4624
+ }
4625
+ setSignalTesting(false)
4626
+ setCurrentRms(0)
4627
+ }
4628
+
4629
+ React.useEffect(() => {
4630
+ return () => {
4631
+ stopSignalTest()
4632
+ }
4633
+ }, [])
4634
+
4635
+ const requestPermissionAndTestSignal = async () => {
4636
+ stopSignalTest()
4637
+ setTestError('')
4638
+ try {
4639
+ const constraints = {
4640
+ audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
4641
+ }
4642
+ const stream = await navigator.mediaDevices.getUserMedia(constraints)
4643
+ signalStreamRef.current = stream
4644
+ setMicPermission('granted')
4645
+ refreshDevices()
4646
+
4647
+ const AC = typeof AudioContext !== 'undefined' ? AudioContext
4648
+ : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
4649
+ if (!AC) return
4650
+ const actx = new AC()
4651
+ audioCtxRef.current = actx
4652
+ if (actx.state === 'suspended') { actx.resume().catch(() => {}) }
4653
+ const src = actx.createMediaStreamSource(stream)
4654
+ const analyser = actx.createAnalyser()
4655
+ analyser.fftSize = 128
4656
+ src.connect(analyser)
4657
+
4658
+ setSignalTesting(true)
4659
+ const buf = new Uint8Array(analyser.frequencyBinCount)
4660
+
4661
+ const checkLevel = () => {
4662
+ if (!signalStreamRef.current) return
4663
+ analyser.getByteFrequencyData(buf)
4664
+ let sum = 0
4665
+ for (let i = 0; i < buf.length; i++) sum += buf[i]
4666
+ const rms = Math.min(100, Math.round((sum / buf.length / 255) * 100 * 2.2))
4667
+ setCurrentRms(rms)
4668
+ setPeakRms((prev) => Math.max(prev, rms))
4669
+ animFrameRef.current = requestAnimationFrame(checkLevel)
4670
+ }
4671
+ animFrameRef.current = requestAnimationFrame(checkLevel)
4672
+ } catch (err) {
4673
+ setMicPermission('denied')
4674
+ setTestError(String(err && err.message ? err.message : err))
4675
+ stopSignalTest()
4676
+ }
4677
+ }
4678
+
4679
+ const runSttTest = async () => {
4680
+ stopSignalTest()
4681
+ setTestError('')
4682
+ setTestResult(null)
4683
+ setTestLatency(null)
4684
+ setTestingStt(true)
4685
+ setTestCountdown(3)
4686
+
4687
+ let testStream = null
4688
+ let recorder = null
4689
+ const chunks = []
4690
+
4691
+ try {
4692
+ const constraints = {
4693
+ audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
4694
+ }
4695
+ testStream = await navigator.mediaDevices.getUserMedia(constraints)
4696
+ let mime = 'audio/webm;codecs=opus'
4697
+ if (!MediaRecorder.isTypeSupported(mime)) mime = ''
4698
+ recorder = mime ? new MediaRecorder(testStream, { mimeType: mime }) : new MediaRecorder(testStream)
4699
+ recorder.ondataavailable = (e) => {
4700
+ if (e.data && e.data.size > 0) chunks.push(e.data)
4701
+ }
4702
+ recorder.start()
4703
+
4704
+ let left = 3
4705
+ const countdownTimer = setInterval(() => {
4706
+ left -= 1
4707
+ setTestCountdown(left)
4708
+ if (left <= 0) {
4709
+ clearInterval(countdownTimer)
4710
+ if (recorder.state !== 'inactive') recorder.stop()
4711
+ }
4712
+ }, 1000)
4713
+
4714
+ await new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
4715
+
4716
+ try { testStream.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
4717
+
4718
+ const blob = new Blob(chunks, { type: mime || 'audio/webm' })
4719
+ if (blob.size < 400) {
4720
+ setTestError(t('wizardTestEmpty'))
4721
+ setTestingStt(false)
4722
+ return
4723
+ }
4724
+
4725
+ setTestCountdown(0)
4726
+ const startMs = Date.now()
4727
+ const dataBase64 = await blobToBase64(blob)
4728
+ const payload = {
4729
+ dataBase64,
4730
+ mimeType: mime || recorder.mimeType || 'audio/webm',
4731
+ mode: 'dictation',
4732
+ subMode: 'clean',
4733
+ }
4734
+
4735
+ const res = await fetch('/dsh-voice/transcribe', {
4736
+ method: 'POST',
4737
+ headers: { 'content-type': 'application/json' },
4738
+ body: JSON.stringify(payload),
4739
+ })
4740
+ const data = await res.json()
4741
+ const latency = Date.now() - startMs
4742
+ setTestLatency(latency)
4743
+
4744
+ if (!res.ok || !data || !data.ok) {
4745
+ const msg = (data && data.error && data.error.message) || `HTTP ${res.status}`
4746
+ setTestError(msg)
4747
+ } else {
4748
+ const recognized = String(data.text || '').trim()
4749
+ if (recognized) {
4750
+ setTestResult(recognized)
4751
+ if (typeof setTop === 'function') {
4752
+ setTop('wizardCompleted', true)
4753
+ }
4754
+ } else {
4755
+ setTestError(t('wizardTestEmpty'))
4756
+ }
4757
+ }
4758
+ } catch (err) {
4759
+ setTestError(String(err && err.message ? err.message : err))
4760
+ } finally {
4761
+ if (testStream) {
4762
+ try { testStream.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
4763
+ }
4764
+ setTestingStt(false)
4765
+ }
4766
+ }
4767
+
4768
+ // Determine active chain info
4769
+ const dictationChain = (draft && draft.dictation && draft.dictation.chain) || []
4770
+ const activeProvider = (dictationChain[0] && (dictationChain[0].provider || dictationChain[0])) || 'sensevoice'
4771
+ let areaLabel = t('wizardAreaServer')
4772
+ if (activeProvider === 'browser' || activeProvider === 'browser-webgpu') {
4773
+ areaLabel = t('wizardAreaBrowser')
4774
+ } else if (activeProvider !== 'sensevoice' && activeProvider !== 'local-whisper') {
4775
+ areaLabel = t('wizardAreaCloud')
4776
+ }
4777
+
4778
+ let nextStepHint = ''
4779
+ if (!isSecure) {
4780
+ nextStepHint = t('wizardHintHttps')
4781
+ } else if (micPermission === 'denied') {
4782
+ nextStepHint = t('wizardHintPermission')
4783
+ } else if (peakRms === 0 && signalTesting) {
4784
+ nextStepHint = t('wizardHintDevice')
4785
+ } else if (testError) {
4786
+ nextStepHint = t('wizardHintProvider')
4787
+ }
4788
+
4789
+ return React.createElement('div', { className: 'cb-section-card', style: { borderColor: 'var(--dsw-alias-state-brand-primary)' } },
4790
+ React.createElement('div', { className: 'cb-section-title' },
4791
+ React.createElement('span', null, '✨ ' + t('wizardTitle')),
4792
+ React.createElement('div', { style: { display: 'flex', gap: '8px' } },
4793
+ testResult ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '✓ ' + t('wizardStatusCompleted')) : null,
4794
+ onClose ? React.createElement('button', {
4795
+ type: 'button', className: 'cb-btn cb-btn-mini', onClick: onClose, title: t('wizardClose'),
4796
+ }, '✕') : null,
4797
+ ),
4798
+ ),
4799
+ React.createElement('div', { className: 'cb-section-desc' }, t('wizardDesc')),
4800
+
4801
+ // Step 1: Environment & Permissions
4802
+ React.createElement('div', { className: 'dvs-card' },
4803
+ React.createElement('div', { className: 'dvs-h' }, t('wizardStep1')),
4804
+ React.createElement('div', { className: 'cb-row' },
4805
+ React.createElement('span', { className: 'cb-badge ' + (isSecure ? 'cb-badge-ok' : 'cb-badge-err') },
4806
+ isSecure ? ('✓ ' + t('wizardSecureContextOk')) : ('✗ ' + t('wizardSecureContextBad'))
4807
+ ),
4808
+ React.createElement('span', { className: 'cb-badge ' + (hasMediaDevices ? 'cb-badge-ok' : 'cb-badge-err') },
4809
+ hasMediaDevices ? ('✓ ' + t('wizardMediaDevicesOk')) : ('✗ ' + t('wizardMediaDevicesBad'))
4810
+ ),
4811
+ React.createElement('span', { className: 'cb-badge ' + (hasMediaRecorder ? 'cb-badge-ok' : 'cb-badge-err') },
4812
+ hasMediaRecorder ? ('✓ ' + t('wizardMediaRecorderOk')) : ('✗ ' + t('wizardMediaRecorderBad'))
4813
+ ),
4814
+ React.createElement('span', { className: 'cb-badge ' + (micPermission === 'granted' ? 'cb-badge-ok' : (micPermission === 'denied' ? 'cb-badge-err' : 'cb-badge-warn')) },
4815
+ micPermission === 'granted' ? ('✓ ' + t('wizardMicPermissionOk')) : (micPermission === 'denied' ? ('✗ ' + t('wizardMicPermissionBad')) : ('· ' + t('wizardMicPermissionPrompt')))
4816
+ ),
4817
+ ),
4818
+ React.createElement('div', { className: 'cb-field', style: { marginTop: '8px' } },
4819
+ React.createElement('label', null, t('wizardMicLabel')),
4820
+ React.createElement('div', { className: 'cb-row' },
4821
+ React.createElement('select', {
4822
+ value: selectedDev,
4823
+ style: { flex: 1, minWidth: '240px' },
4824
+ onChange: (e) => {
4825
+ setSelectedDev(e.target.value)
4826
+ if (typeof setTop === 'function') setTop('micDeviceId', e.target.value)
4827
+ stopSignalTest()
4828
+ },
4829
+ },
4830
+ React.createElement('option', { value: '' }, 'Default System Microphone'),
4831
+ devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId }, d.label || `Microphone ${d.deviceId.slice(0, 8)}…`))
4832
+ ),
4833
+ React.createElement('button', {
4834
+ type: 'button',
4835
+ className: 'cb-btn',
4836
+ onClick: requestPermissionAndTestSignal,
4837
+ }, t('wizardRequestMic')),
4838
+ ),
4839
+ ),
4840
+ ),
4841
+
4842
+ // Step 2: Live Audio Signal Test
4843
+ React.createElement('div', { className: 'dvs-card' },
4844
+ React.createElement('div', { className: 'dvs-h' }, t('wizardStep2')),
4845
+ React.createElement('div', { className: 'cb-row', style: { alignItems: 'center' } },
4846
+ React.createElement('button', {
4847
+ type: 'button',
4848
+ className: 'cb-btn ' + (signalTesting ? 'cb-btn-danger' : 'cb-btn-primary'),
4849
+ onClick: signalTesting ? stopSignalTest : requestPermissionAndTestSignal,
4850
+ }, signalTesting ? t('wizardStopSignalCheck') : t('wizardStartSignalCheck')),
4851
+ signalTesting ? React.createElement('span', { className: 'dvs-sub' }, t('wizardSignalTesting')) : null,
4852
+ ),
4853
+ signalTesting ? React.createElement('div', { style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '6px' } },
4854
+ React.createElement('div', {
4855
+ style: {
4856
+ height: '14px',
4857
+ borderRadius: '7px',
4858
+ background: 'var(--dsw-alias-bg-layer-1)',
4859
+ border: '1px solid var(--dsw-alias-border-l1)',
4860
+ overflow: 'hidden',
4861
+ position: 'relative',
4862
+ },
4863
+ },
4864
+ React.createElement('div', {
4865
+ style: {
4866
+ width: `${currentRms}%`,
4867
+ height: '100%',
4868
+ background: currentRms > 5 ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-dimmed)',
4869
+ transition: 'width 0.05s linear',
4870
+ },
4871
+ }),
4872
+ ),
4873
+ React.createElement('span', { className: currentRms > 5 ? 'dvs-ok' : 'dvs-sub' },
4874
+ currentRms > 5
4875
+ ? t('wizardSignalOk').replace('{level}', currentRms)
4876
+ : t('wizardSignalZero')
4877
+ ),
4878
+ ) : null,
4879
+ ),
4880
+
4881
+ // Step 3: Transcription Round-Trip Test
4882
+ React.createElement('div', { className: 'dvs-card' },
4883
+ React.createElement('div', { className: 'dvs-h' }, t('wizardStep3')),
4884
+ React.createElement('div', { className: 'cb-row' },
4885
+ React.createElement('span', { className: 'dvs-sub' }, `${t('wizardProviderLabel')} ${activeProvider}`),
4886
+ React.createElement('span', { className: 'cb-badge' }, `${t('wizardProcessingArea')} ${areaLabel}`),
4887
+ ),
4888
+ React.createElement('div', { className: 'cb-row', style: { marginTop: '8px' } },
4889
+ React.createElement('button', {
4890
+ type: 'button',
4891
+ className: 'cb-btn cb-btn-primary',
4892
+ disabled: testingStt || micPermission === 'denied',
4893
+ onClick: runSttTest,
4894
+ }, testingStt
4895
+ ? (testCountdown > 0 ? t('wizardRecordingTest').replace('{left}', testCountdown) : t('wizardTranscribingTest'))
4896
+ : t('wizardStartTestRecord')
4897
+ ),
4898
+ ),
4899
+ testResult ? React.createElement('div', { style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '4px' } },
4900
+ React.createElement('div', { className: 'dvs-ok' },
4901
+ '✓ ' + t('wizardTestSuccess').replace('{latency}', testLatency || 0)
4902
+ ),
4903
+ React.createElement('blockquote', {
4904
+ style: {
4905
+ margin: '4px 0 0 0',
4906
+ padding: '8px 12px',
4907
+ borderRadius: '6px',
4908
+ background: 'var(--dsw-alias-bg-layer-3)',
4909
+ borderLeft: '4px solid var(--dsw-alias-state-success-primary)',
4910
+ color: 'var(--dsw-alias-label-primary)',
4911
+ fontStyle: 'italic',
4912
+ },
4913
+ }, `“${testResult}”`),
4914
+ ) : null,
4915
+ testError ? React.createElement('div', { className: 'cb-alert-err', style: { marginTop: '8px' } },
4916
+ t('wizardTestFailed').replace('{error}', testError)
4917
+ ) : null,
4918
+ ),
4919
+
4920
+ // Next Step Hint / Feedback
4921
+ nextStepHint ? React.createElement('div', { className: 'dvs-wait', style: { fontStyle: 'italic' } },
4922
+ t('wizardNextStepHint').replace('{hint}', nextStepHint)
4923
+ ) : null,
4924
+
4925
+ // Wizard Bottom Actions
4926
+ React.createElement('div', { className: 'cb-row', style: { justifyContent: 'space-between', marginTop: '6px' } },
4927
+ React.createElement('button', {
4928
+ type: 'button',
4929
+ className: 'cb-btn',
4930
+ onClick: () => {
4931
+ if (typeof setTop === 'function') setTop('wizardCompleted', true)
4932
+ if (onClose) onClose()
4933
+ },
4934
+ }, t('wizardSkip')),
4935
+ testResult ? React.createElement('button', {
4936
+ type: 'button',
4937
+ className: 'cb-btn cb-btn-primary',
4938
+ onClick: () => {
4939
+ if (typeof setTop === 'function') setTop('wizardCompleted', true)
4940
+ if (onClose) onClose()
4941
+ },
4942
+ }, '✓ ' + t('wizardFinishSuccess')) : null,
4943
+ ),
4944
+ )
4945
+ }
4218
4946
  // Card in Settings → Plugins → Plugin settings (#18)
4219
4947
  function PluginCard(props) {
4220
4948
  const page = !!(props && props.view === 'page')
@@ -4320,6 +5048,9 @@ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
4320
5048
  isAllowedInEditable,
4321
5049
  isHotkeyRelease,
4322
5050
  extractContextKeywords,
5051
+ DICTATION_MODES,
5052
+ getDictationModeLabel,
5053
+ cycleDictationMode,
4323
5054
  resolveBrowserRecognitionLang,
4324
5055
  VoiceSection,
4325
5056
  cutPhrase,