@goodandready/dsh-voice 0.9.30 → 0.9.32

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
@@ -132,6 +132,14 @@ window.__ModuleLoader__.load({
132
132
  'bargeIn': 'Barge-in',
133
133
  'polishSend': 'Polish whole draft before sending',
134
134
  'polishSendHint': 'Run the composed draft through the model right before sending',
135
+ 'editablePreview': 'Editable preview before send',
136
+ 'editablePreviewHint': 'Show recognized text before sending with editing, comparison and abort options.',
137
+ 'editPreview': 'Edit',
138
+ 'sendNow': 'Send',
139
+ 'cancelPreview': 'Cancel',
140
+ 'rawText': 'Raw',
141
+ 'polishedText': 'Polished',
142
+ 'sessionMismatch': 'Different chat active; canceled to prevent leak.',
135
143
  'sessionCommands': 'Voice session commands',
136
144
  'sessionCommandsHint': '"send", "cancel", "stop", "continue" act on the session instead of becoming text',
137
145
  'polishBaseUrl': 'Offline polish endpoint',
@@ -240,6 +248,27 @@ window.__ModuleLoader__.load({
240
248
  'voiceActionsLabel': 'Voice action commands ("send", "cancel", "clear", "new line")',
241
249
  'liveInterimPreview': 'Live speech preview (ghost text preview)',
242
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',
243
272
  'jargonTitle': 'Developer Lexicon & IT Jargon',
244
273
  'techJargonCorrection': 'Auto-correct technical jargon (GitHub, Docker, pnpm, Kubernetes, etc.)',
245
274
  'jargonAdd': 'Add replacement',
@@ -350,6 +379,14 @@ window.__ModuleLoader__.load({
350
379
  'bargeIn': '语音插话打断 (Barge-in)',
351
380
  'polishSend': '发送前润色整篇草稿',
352
381
  'polishSendHint': '发送给智能体前对整个输入框草稿进行一次整体润色',
382
+ 'editablePreview': '发送前可编辑预览',
383
+ 'editablePreviewHint': '发送前显示识别文本,支持编辑、润色对比和撤销。',
384
+ 'editPreview': '编辑',
385
+ 'sendNow': '发送',
386
+ 'cancelPreview': '取消',
387
+ 'rawText': '原文',
388
+ 'polishedText': '润色',
389
+ 'sessionMismatch': '当前会话已切换,已拦截发送以防止串话。',
353
390
  'sessionCommands': '会话语音控制指令',
354
391
  'sessionCommandsHint': '"send"、"cancel"、"stop"、"continue" 执行操作而非转为文本',
355
392
  'polishBaseUrl': '离线润色服务端点',
@@ -458,6 +495,27 @@ window.__ModuleLoader__.load({
458
495
  'voiceActionsLabel': '语音动作指令 ("发送", "取消", "清空", "换行")',
459
496
  'liveInterimPreview': '实时语音文本预览 (幽灵文本)',
460
497
  'structuredPromptVoice': '语音应答交互式选择提示',
498
+ 'modeVerbatim': '逐字直录',
499
+ 'modeClean': '清爽文本',
500
+ 'modeTask': '智能体任务',
501
+ 'modeNote': '便签备忘',
502
+ 'dictationModeTitle': '听写后处理模式',
503
+ 'dictationModeDesc': '选择听写文本后处理方式:逐字直录(零LLM调用)、清爽文本(整理标点去除语气词)、智能体任务(结构化提示词)或便签(要点与行动项)。',
504
+ 'dictationPromptsTitle': '听写提示词模板',
505
+ 'dictationPromptsDesc': '自定义听写后处理提示词模板。使用 {text} 作为识别语音的占位符。',
506
+ 'promptClean': '清爽文本提示词',
507
+ 'promptTask': '智能体任务提示词',
508
+ 'promptNote': '便签提示词',
509
+ 'resetPrompts': '恢复默认模板',
510
+ 'voiceMacrosTitle': '语音动作宏',
511
+ 'voiceMacrosDesc': '语音触发词自动展开为结构化提示词模板。',
512
+ 'addMacro': '添加动作宏',
513
+ 'macroTrigger': '语音触发短语',
514
+ 'macroTemplate': '展开提示词模板',
515
+ 'macroDelete': '删除',
516
+ 'rawTab': '原始识别',
517
+ 'resultTab': '处理结果',
518
+ 'llmUnavailableFallback': 'LLM不可用,已保留原始识别文本',
461
519
  'jargonTitle': '开发者词汇与 IT 术语',
462
520
  'techJargonCorrection': '自动校正开发术语 (GitHub, Docker, pnpm, Kubernetes 等)',
463
521
  'jargonAdd': '添加替换项',
@@ -518,7 +576,20 @@ window.__ModuleLoader__.load({
518
576
  '.dvo-progress-bar{height:6px;width:100%;background:var(--dsw-alias-bg-layer-3);border-radius:3px;overflow:hidden}' +
519
577
  '.dvo-progress-fill{height:100%;background:var(--dsw-alias-state-info-primary);transition:width 150ms ease}' +
520
578
  '.dvo-jargon-table{width:100%;margin-top:6px;border-collapse:collapse;font-size:12px}' +
521
- '.dvo-jargon-table td{padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l1)}'
579
+ '.dvo-jargon-table td{padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l1)}' +
580
+ '.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}' +
581
+ '.dvo-preview-header{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%}' +
582
+ '.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)}' +
583
+ '.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}' +
584
+ '.dvo-tab-btn:hover{color:var(--dsw-alias-label-primary)}' +
585
+ '.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)}' +
586
+ '.dvo-preview-body{display:flex;align-items:center;gap:8px;width:100%}' +
587
+ '.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}' +
588
+ '.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%}' +
589
+ '.dvo-preview-input:focus{border-color:var(--dsw-alias-state-info-primary)}' +
590
+ '.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}' +
591
+ '.dvo-btn-send:hover{opacity:0.9}' +
592
+ '.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)}'
522
593
  const cssId = 'dsh-voice/client.module.css'
523
594
  if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {
524
595
  const tag = document.createElement('style')
@@ -543,7 +614,7 @@ window.__ModuleLoader__.load({
543
614
  inputActions: null,
544
615
  input: null,
545
616
  settings: {
546
- vadSilenceMs: 700, autoSendMs: 4000, stream: false, streamChunkMs: 1200, vadAdapt: 0,
617
+ vadSilenceMs: 700, autoSendMs: 4000, editablePreview: true, stream: false, streamChunkMs: 1200, vadAdapt: 0,
547
618
  noiseSuppression: true, contextGlossary: true,
548
619
  gatedTurnTaking: true, autoSendVisualRing: true, voiceActions: true,
549
620
  techJargonCorrection: true, jargonDictionary: [], liveInterimPreview: true,
@@ -714,7 +785,26 @@ window.__ModuleLoader__.load({
714
785
 
715
786
  function extractContextKeywords() {
716
787
  if (!voice.settings || voice.settings.contextGlossary === false) return []
717
- const text = (voice.input && typeof voice.input.draft === 'string') ? voice.input.draft : ''
788
+ const chunks = []
789
+ const draft = (voice.input && typeof voice.input.draft === 'string') ? voice.input.draft : ''
790
+ if (draft && draft.length >= 3) chunks.push(draft)
791
+ if (voice.session && Array.isArray(voice.session.messages)) {
792
+ const msgs = voice.session.messages.slice(-5)
793
+ for (const m of msgs) {
794
+ const t = (m && typeof m.content === 'string') ? m.content : ((m && typeof m.text === 'string') ? m.text : '')
795
+ if (t && t.length >= 3) chunks.push(t)
796
+ }
797
+ }
798
+ if (typeof document !== 'undefined') {
799
+ try {
800
+ const nodes = document.querySelectorAll('.message-content code, .dsh-message-body code, pre code')
801
+ for (let i = 0; i < Math.min(nodes.length, 10); i++) {
802
+ const ct = nodes[i].textContent
803
+ if (ct && ct.length < 100) chunks.push(ct)
804
+ }
805
+ } catch (_) {}
806
+ }
807
+ const text = chunks.join(' ')
718
808
  if (!text || text.length < 3) return []
719
809
  const matches = text.match(/[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{2,29}/g) || []
720
810
  // Linguistic filter: functional stop-word dictionary for vocabulary keyword extraction.
@@ -746,7 +836,11 @@ window.__ModuleLoader__.load({
746
836
 
747
837
  const tryHost = async (chainOverride) => {
748
838
  const dataBase64 = await blobToBase64(blob)
749
- const payload = { dataBase64, mimeType: mime, mode }
839
+ const subMode = (voice._activeTarget && voice._activeTarget.dictationMode)
840
+ || voice.selectedDictationMode
841
+ || (voice.settings && voice.settings.dictationMode)
842
+ || 'clean'
843
+ const payload = { dataBase64, mimeType: mime, mode, subMode }
750
844
  if (Array.isArray(chainOverride)) {
751
845
  payload.chain = chainOverride.map((p) => (typeof p === 'string' ? { provider: p, model: modelMap[p] } : p))
752
846
  }
@@ -1458,7 +1552,14 @@ voice._chainsPromise = null
1458
1552
  if (targetActions && typeof targetActions.captureInsertion === 'function') {
1459
1553
  try { span = targetActions.captureInsertion() } catch (_) {}
1460
1554
  }
1461
- const target = { actions: targetActions, input: targetInput, span }
1555
+ const initialDraft = (targetInput && typeof targetInput.draft === 'string') ? targetInput.draft : ''
1556
+ const targetSession = voice.session || (targetInput && targetInput.session)
1557
+ const sessionId = (targetSession && (targetSession.sessionId || targetSession.id))
1558
+ || voice.currentSessionId || ''
1559
+ const snapshotSubMode = voice.selectedDictationMode
1560
+ || (voice.settings && voice.settings.dictationMode)
1561
+ || 'clean'
1562
+ const target = { actions: targetActions, input: targetInput, session: targetSession, span, initialDraft, sessionId, dictationMode: snapshotSubMode }
1462
1563
  voice._activeTarget = target
1463
1564
 
1464
1565
  if (voice.isTtsSpeaking) {
@@ -1539,7 +1640,12 @@ voice._chainsPromise = null
1539
1640
  if (mode === 'message') {
1540
1641
  const ok = appendDraft(said, target)
1541
1642
  if (ok !== false) {
1542
- voice.set({ phase: 'pending', pending: { text: said, target, totalMs: voice.settings.autoSendMs, leftMs: voice.settings.autoSendMs } })
1643
+ const usePreview = voice.settings.editablePreview !== false
1644
+ const autoMs = Number(voice.settings.autoSendMs) || 4000
1645
+ const pendingState = usePreview
1646
+ ? initPendingPreview(said, target, { autoSendMs: autoMs })
1647
+ : { text: said, target, totalMs: autoMs, leftMs: autoMs }
1648
+ voice.set({ phase: 'pending', pending: pendingState })
1543
1649
  } else {
1544
1650
  voice.set({ phase: 'idle' })
1545
1651
  }
@@ -1610,7 +1716,24 @@ voice._chainsPromise = null
1610
1716
  if (voice.lastNote) voice.lastNote.text = text
1611
1717
  if (mode === 'message') {
1612
1718
  if (ok !== false) {
1613
- voice.set({ phase: 'pending', pending: { text, target, totalMs: voice.settings.autoSendMs, leftMs: voice.settings.autoSendMs, audioUrl: noteUrl } })
1719
+ const usePreview = voice.settings.editablePreview !== false
1720
+ let polishedText = text
1721
+ if (usePreview && voice.settings.polishSend) {
1722
+ try {
1723
+ const pRes = await fetch('/dsh-voice/polish', {
1724
+ method: 'POST',
1725
+ headers: { 'content-type': 'application/json' },
1726
+ body: JSON.stringify({ text }),
1727
+ })
1728
+ const pData = await pRes.json()
1729
+ if (pData && pData.text) polishedText = pData.text
1730
+ } catch (_) {}
1731
+ }
1732
+ const autoMs = Number(voice.settings.autoSendMs) || 4000
1733
+ const pendingState = usePreview
1734
+ ? initPendingPreview(text, target, { autoSendMs: autoMs, polishedText, audioUrl: noteUrl, polishSend: voice.settings.polishSend })
1735
+ : { text, target, totalMs: autoMs, leftMs: autoMs, audioUrl: noteUrl }
1736
+ voice.set({ phase: 'pending', pending: pendingState })
1614
1737
  } else {
1615
1738
  voice.set({ phase: 'idle' })
1616
1739
  }
@@ -1658,38 +1781,11 @@ voice._chainsPromise = null
1658
1781
  }
1659
1782
 
1660
1783
  function submitPending() {
1661
- const pending = voice.pending
1662
- if (!pending) return
1663
- const text = pending.text
1664
- const target = pending.target || voice._activeTarget
1665
- const actions = (target && target.actions) || voice.inputActions
1666
- voice.pending = null
1667
- voice.set({ phase: 'idle' })
1784
+ submitPendingPreview(voice)
1785
+ }
1668
1786
 
1669
- if (voice.settings.polishSend) {
1670
- fetch('/dsh-voice/polish', {
1671
- method: 'POST',
1672
- headers: { 'content-type': 'application/json' },
1673
- body: JSON.stringify({ text }),
1674
- })
1675
- .then((res) => res.json())
1676
- .then((data) => {
1677
- const polished = (data && data.text) || text
1678
- if (actions && typeof actions.setDraft === 'function') {
1679
- actions.setDraft(polished)
1680
- }
1681
- if (actions && typeof actions.submit === 'function') {
1682
- actions.submit()
1683
- }
1684
- })
1685
- .catch(() => {
1686
- if (actions && typeof actions.submit === 'function') {
1687
- actions.submit()
1688
- }
1689
- })
1690
- } else if (actions && typeof actions.submit === 'function') {
1691
- actions.submit()
1692
- }
1787
+ function cancelPending() {
1788
+ cancelPendingPreview(voice)
1693
1789
  }
1694
1790
 
1695
1791
  function runSessionCommand(cmd) {
@@ -1700,8 +1796,7 @@ voice._chainsPromise = null
1700
1796
  }
1701
1797
 
1702
1798
  function keepPending() {
1703
- voice.pending = null
1704
- voice.set({ phase: 'idle' })
1799
+ keepPendingPreview(voice)
1705
1800
  }
1706
1801
  // ----------------------------------------------------------- components
1707
1802
  function VoiceButtons(props) {
@@ -1711,7 +1806,15 @@ voice._chainsPromise = null
1711
1806
  if (v.phase !== 'idle' && v.phase !== 'error' && !hold.armed) return null
1712
1807
  const err = v.phase === 'error'
1713
1808
  const hk = voice.hotkey ? ` (${voice.hotkey})` : ''
1809
+ const curMode = voice.selectedDictationMode || (voice.settings && voice.settings.dictationMode) || 'clean'
1714
1810
  return React.createElement(React.Fragment, null,
1811
+ React.createElement('button', {
1812
+ type: 'button',
1813
+ className: 'dvo-mode-pill-btn',
1814
+ title: t('dictationModeTitle') + ': ' + getDictationModeLabel(curMode, t),
1815
+ 'aria-label': t('dictationModeTitle') + ': ' + getDictationModeLabel(curMode, t),
1816
+ onClick: cycleDictationMode,
1817
+ }, getDictationModeLabel(curMode, t)),
1715
1818
  React.createElement('button', {
1716
1819
  type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
1717
1820
  title: err ? v.error : t('dictationBtn'),
@@ -1930,6 +2033,277 @@ voice._chainsPromise = null
1930
2033
  }
1931
2034
  return cleanup
1932
2035
  }
2036
+ // ------------------------------------------------------------------ 43-preview
2037
+ // Editable preview state machine, session pinning & UI component (#170).
2038
+
2039
+ function initPendingPreview(text, target, options = {}) {
2040
+ const autoMs = Number(options.autoSendMs) || 4000
2041
+ const polishedText = options.polishedText || text
2042
+ const isPolished = options.polishSend && polishedText && polishedText !== text
2043
+ const initialDraft = (target && typeof target.initialDraft === 'string')
2044
+ ? target.initialDraft
2045
+ : ((target && target.input && typeof target.input.draft === 'string') ? target.input.draft : '')
2046
+ const sessionId = (target && target.sessionId) || ''
2047
+
2048
+ return {
2049
+ text,
2050
+ rawText: text,
2051
+ polishedText,
2052
+ activeText: isPolished ? polishedText : text,
2053
+ selectedTab: isPolished ? 'polished' : 'raw',
2054
+ isEditing: false,
2055
+ target,
2056
+ sessionId,
2057
+ initialDraft,
2058
+ totalMs: autoMs,
2059
+ leftMs: autoMs,
2060
+ paused: false,
2061
+ audioUrl: options.audioUrl || '',
2062
+ sessionMismatch: false,
2063
+ }
2064
+ }
2065
+
2066
+ function cancelPendingPreview(voice) {
2067
+ const pending = voice.pending
2068
+ if (!pending) {
2069
+ voice.set({ phase: 'idle' })
2070
+ return
2071
+ }
2072
+ const target = pending.target || voice._activeTarget
2073
+ const actions = (target && target.actions) || voice.inputActions
2074
+
2075
+ // Restore untouched composer draft if preview was active
2076
+ if (pending.rawText !== undefined && pending.initialDraft !== undefined) {
2077
+ if (actions && typeof actions.setDraft === 'function') {
2078
+ try { actions.setDraft(pending.initialDraft) } catch (_) {}
2079
+ }
2080
+ }
2081
+ voice.pending = null
2082
+ voice.set({ phase: 'idle' })
2083
+ }
2084
+
2085
+ function keepPendingPreview(voice) {
2086
+ const pending = voice.pending
2087
+ if (!pending) {
2088
+ voice.set({ phase: 'idle' })
2089
+ return
2090
+ }
2091
+ const target = pending.target || voice._activeTarget
2092
+ const actions = (target && target.actions) || voice.inputActions
2093
+
2094
+ if (pending.rawText !== undefined) {
2095
+ // Insert final edited text into composer draft without auto-sending
2096
+ const initial = pending.initialDraft || ''
2097
+ const textToInsert = pending.activeText || ''
2098
+ const full = initial
2099
+ ? (initial.endsWith(' ') || initial.endsWith('\n') ? (initial + textToInsert) : (initial + ' ' + textToInsert))
2100
+ : textToInsert
2101
+ if (actions && typeof actions.setDraft === 'function') {
2102
+ try { actions.setDraft(full) } catch (_) {}
2103
+ }
2104
+ }
2105
+ voice.pending = null
2106
+ voice.set({ phase: 'idle' })
2107
+ }
2108
+
2109
+ function submitPendingPreview(voice) {
2110
+ const pending = voice.pending
2111
+ if (!pending) return
2112
+ const target = pending.target || voice._activeTarget
2113
+ const actions = (target && target.actions) || voice.inputActions
2114
+
2115
+ // Session guard (#170): verify that current session matches initiating session
2116
+ const currentSessionId = (target && target.session && (target.session.sessionId || target.session.id))
2117
+ || (voice.session && (voice.session.sessionId || voice.session.id))
2118
+ || voice.currentSessionId || ''
2119
+
2120
+ if (pending.sessionId && currentSessionId && pending.sessionId !== currentSessionId) {
2121
+ pending.sessionMismatch = true
2122
+ pending.paused = true
2123
+ voice.notify()
2124
+ return
2125
+ }
2126
+
2127
+ const textToSend = (pending.rawText !== undefined ? pending.activeText : pending.text) || ''
2128
+ voice.pending = null
2129
+ voice.set({ phase: 'idle' })
2130
+
2131
+ if (pending.rawText !== undefined) {
2132
+ const initial = pending.initialDraft || ''
2133
+ const full = initial
2134
+ ? (initial.endsWith(' ') || initial.endsWith('\n') ? (initial + textToSend) : (initial + ' ' + textToSend))
2135
+ : textToSend
2136
+ if (actions && typeof actions.setDraft === 'function') {
2137
+ try { actions.setDraft(full) } catch (_) {}
2138
+ }
2139
+ if (actions && typeof actions.submit === 'function') {
2140
+ try { actions.submit() } catch (_) {}
2141
+ }
2142
+ } else {
2143
+ // Legacy path without rich preview
2144
+ if (voice.settings.polishSend) {
2145
+ fetch('/dsh-voice/polish', {
2146
+ method: 'POST',
2147
+ headers: { 'content-type': 'application/json' },
2148
+ body: JSON.stringify({ text: textToSend }),
2149
+ })
2150
+ .then((res) => res.json())
2151
+ .then((data) => {
2152
+ const polished = (data && data.text) || textToSend
2153
+ if (actions && typeof actions.setDraft === 'function') actions.setDraft(polished)
2154
+ if (actions && typeof actions.submit === 'function') actions.submit()
2155
+ })
2156
+ .catch(() => {
2157
+ if (actions && typeof actions.submit === 'function') actions.submit()
2158
+ })
2159
+ } else if (actions && typeof actions.submit === 'function') {
2160
+ actions.submit()
2161
+ }
2162
+ }
2163
+ }
2164
+
2165
+ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef) {
2166
+ const p = voice.pending
2167
+ const total = p.totalMs || Number(voice.settings.autoSendMs) || 4000
2168
+ const leftMs = Math.max(0, p.leftMs || 0)
2169
+ const left = Math.ceil(leftMs / 1000)
2170
+ const frac = total > 0 ? Math.max(0, Math.min(1, leftMs / total)) : 0
2171
+ const circ = 2 * Math.PI * 14
2172
+ const off = circ * (1 - frac)
2173
+ const hasPolish = p.polishedText && p.polishedText !== p.rawText
2174
+
2175
+ return React.createElement('div', { className: 'dvo-pill-preview', role: 'region', 'aria-label': t('editablePreview') },
2176
+ // Header row: tabs, audio playback, status/countdown, cancel button
2177
+ React.createElement('div', { className: 'dvo-preview-header' },
2178
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
2179
+ hasPolish
2180
+ ? React.createElement('div', { className: 'dvo-preview-tabs' },
2181
+ React.createElement('button', {
2182
+ type: 'button',
2183
+ className: 'dvo-tab-btn' + (p.selectedTab === 'raw' ? ' dvo-tab-btn-active' : ''),
2184
+ onClick: () => {
2185
+ p.selectedTab = 'raw'
2186
+ p.activeText = p.rawText
2187
+ voice.notify()
2188
+ },
2189
+ }, t('rawText')),
2190
+ React.createElement('button', {
2191
+ type: 'button',
2192
+ className: 'dvo-tab-btn' + (p.selectedTab === 'polished' ? ' dvo-tab-btn-active' : ''),
2193
+ onClick: () => {
2194
+ p.selectedTab = 'polished'
2195
+ p.activeText = p.polishedText
2196
+ voice.notify()
2197
+ },
2198
+ }, t('polishedText')),
2199
+ )
2200
+ : React.createElement('span', { className: 'dvo-count' }, t('rawText')),
2201
+ p.audioUrl
2202
+ ? React.createElement('div', { className: 'dvo-audio-wrap' },
2203
+ React.createElement('button', {
2204
+ type: 'button', className: 'dvo-audio-play',
2205
+ title: isPlaying ? t('pause') : t('play'),
2206
+ 'aria-label': isPlaying ? t('pause') : t('play'),
2207
+ onClick: () => {
2208
+ const el = audioRef.current
2209
+ if (!el) return
2210
+ if (el.paused) { el.play().catch(() => {}); setIsPlaying(true) }
2211
+ else { el.pause(); setIsPlaying(false) }
2212
+ },
2213
+ }, isPlaying ? pauseIcon() : playIcon()),
2214
+ React.createElement('audio', {
2215
+ ref: audioRef, src: p.audioUrl,
2216
+ onEnded: () => setIsPlaying(false),
2217
+ onPause: () => setIsPlaying(false),
2218
+ onPlay: () => setIsPlaying(true),
2219
+ }),
2220
+ React.createElement('span', { className: 'dvo-audio-time' }, t('listenBack')),
2221
+ )
2222
+ : null,
2223
+ ),
2224
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
2225
+ (!p.paused && !p.isEditing && v.settings.autoSendVisualRing !== false)
2226
+ ? React.createElement('svg', { className: 'dvo-silence-ring', viewBox: '0 0 34 34' },
2227
+ React.createElement('circle', { cx: 17, cy: 17, r: 14, className: 'dvo-ring-bg' }),
2228
+ React.createElement('circle', {
2229
+ cx: 17, cy: 17, r: 14, className: 'dvo-ring-fg',
2230
+ strokeDasharray: `${circ} ${circ}`,
2231
+ strokeDashoffset: off,
2232
+ }),
2233
+ )
2234
+ : null,
2235
+ React.createElement('span', { className: 'dvo-count' },
2236
+ (p.paused || p.isEditing) ? '' : (left + t('secondsShort'))
2237
+ ),
2238
+ React.createElement('button', {
2239
+ type: 'button', className: 'dvo-pbtn', title: t('cancelPreview'),
2240
+ 'aria-label': t('cancelPreview'),
2241
+ onClick: () => { cancelPending(); focusComposer() },
2242
+ }, xIcon()),
2243
+ ),
2244
+ ),
2245
+ // Body row: editable input or preview text, Edit button, Send button
2246
+ React.createElement('div', { className: 'dvo-preview-body' },
2247
+ p.isEditing
2248
+ ? React.createElement('input', {
2249
+ type: 'text',
2250
+ className: 'dvo-preview-input',
2251
+ value: p.activeText,
2252
+ autoFocus: true,
2253
+ onChange: (e) => {
2254
+ p.activeText = e.target.value
2255
+ voice.notify()
2256
+ },
2257
+ onKeyDown: (e) => {
2258
+ if (e.key === 'Enter' && !e.shiftKey) {
2259
+ e.preventDefault()
2260
+ submitPending()
2261
+ }
2262
+ },
2263
+ })
2264
+ : React.createElement('span', { className: 'dvo-preview-text' }, p.activeText),
2265
+ !p.isEditing
2266
+ ? React.createElement('button', {
2267
+ type: 'button', className: 'dvo-pbtn', title: t('editPreview'),
2268
+ 'aria-label': t('editPreview'),
2269
+ onClick: () => {
2270
+ p.isEditing = true
2271
+ p.paused = true
2272
+ voice.notify()
2273
+ },
2274
+ }, editIcon())
2275
+ : null,
2276
+ React.createElement('button', {
2277
+ type: 'button', className: 'dvo-btn-send', title: t('sendNow'),
2278
+ 'aria-label': t('sendNow'),
2279
+ onClick: () => { submitPending(); focusComposer() },
2280
+ }, sendIcon()),
2281
+ ),
2282
+ // Mismatch warning banner
2283
+ p.sessionMismatch
2284
+ ? React.createElement('div', { className: 'dvo-warn-banner' }, t('sessionMismatch'))
2285
+ : null,
2286
+ )
2287
+ }
2288
+ // ------------------------------------------------------------------ 45-dictation
2289
+ // Dictation modes UI helper, mode cycler and labels (#264)
2290
+
2291
+ const DICTATION_MODES = ['clean', 'task', 'note', 'verbatim']
2292
+
2293
+ function getDictationModeLabel(m, t) {
2294
+ const trans = t || moduleT
2295
+ if (m === 'verbatim') return trans('modeVerbatim')
2296
+ if (m === 'task') return trans('modeTask')
2297
+ if (m === 'note') return trans('modeNote')
2298
+ return trans('modeClean')
2299
+ }
2300
+
2301
+ function cycleDictationMode() {
2302
+ const cur = voice.selectedDictationMode || (voice.settings && voice.settings.dictationMode) || 'clean'
2303
+ const nextIdx = (DICTATION_MODES.indexOf(cur) + 1) % DICTATION_MODES.length
2304
+ voice.selectedDictationMode = DICTATION_MODES[nextIdx]
2305
+ voice.notify()
2306
+ }
1933
2307
  // ----------------------------------------------------------- visualizers
1934
2308
  let _accentColorCache = { color: '', expires: 0 }
1935
2309
  let _softColorCache = { color: '', expires: 0 }
@@ -2071,6 +2445,8 @@ voice._chainsPromise = null
2071
2445
  const [isPlaying, setIsPlaying] = React.useState(false)
2072
2446
  voice.inputActions = props.inputActions
2073
2447
  voice.input = props.input
2448
+ voice.session = props.session
2449
+ voice.currentSessionId = (props.session && (props.session.sessionId || props.session.id)) || voice.currentSessionId || ''
2074
2450
  const ctx = props.ctx
2075
2451
 
2076
2452
  // VAD: accumulate silence and cut the phrase when the pause exceeds the threshold.
@@ -2194,6 +2570,10 @@ voice._chainsPromise = null
2194
2570
  else voice.notify()
2195
2571
  return
2196
2572
  }
2573
+ if (p.paused || p.isEditing) {
2574
+ // Confirmation or editing mode freezes auto-send countdown (#170)
2575
+ return
2576
+ }
2197
2577
  p.leftMs -= tick
2198
2578
  if (p.leftMs <= 0) { submitPending(); return }
2199
2579
  voice.notify()
@@ -2245,7 +2625,12 @@ voice._chainsPromise = null
2245
2625
  // Live caption of what is heard right now. Until the browser emits a
2246
2626
  // final chunk the text is interim and changes on screen.
2247
2627
  const caption = voice.caption || (inBrowser ? '' : null)
2628
+ const activeSubMode = (voice._activeTarget && voice._activeTarget.dictationMode)
2629
+ || voice.selectedDictationMode
2630
+ || (voice.settings && voice.settings.dictationMode)
2631
+ || 'clean'
2248
2632
  return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
2633
+ React.createElement('span', { className: 'dvo-mode-badge' }, getDictationModeLabel(activeSubMode, t)),
2249
2634
  React.createElement('button', {
2250
2635
  type: 'button', className: 'dvo-pbtn', title: t('cancel'),
2251
2636
  'aria-label': t('cancel'),
@@ -2274,11 +2659,22 @@ voice._chainsPromise = null
2274
2659
  }
2275
2660
 
2276
2661
  if (v.phase === 'processing') {
2662
+ const activeSubMode = (voice._activeTarget && voice._activeTarget.dictationMode)
2663
+ || voice.selectedDictationMode
2664
+ || (voice.settings && voice.settings.dictationMode)
2665
+ || 'clean'
2277
2666
  return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
2278
- React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, spinIcon(), t('transcribing')))
2667
+ React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' },
2668
+ spinIcon(),
2669
+ React.createElement('span', { className: 'dvo-mode-badge' }, getDictationModeLabel(activeSubMode, t)),
2670
+ t('transcribing'),
2671
+ ))
2279
2672
  }
2280
2673
 
2281
2674
  if (v.phase === 'pending') {
2675
+ if (voice.pending && voice.pending.rawText !== undefined) {
2676
+ return renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
2677
+ }
2282
2678
  const left = Math.max(0, Math.ceil((voice.pending ? voice.pending.leftMs : 0) / 1000))
2283
2679
  if (voice.pending && voice.pending.undoOnly) {
2284
2680
  // Cancel window for delayed dictation insert (#29-5).
@@ -2389,6 +2785,9 @@ voice._chainsPromise = null
2389
2785
  voiceCommands: !!(data && data.voiceCommands),
2390
2786
  vadSilenceMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.vadSilenceMs) || 700,
2391
2787
  autoSendMs: Number(data && data.modes && data.modes.message && data.modes.message.autoSendMs) || 4000,
2788
+ dictationMode: String((data && data.dictationMode) || 'clean'),
2789
+ dictationPrompts: (data && data.dictationPrompts) || {},
2790
+ voiceMacros: Array.isArray(data && data.voiceMacros) ? data.voiceMacros : [],
2392
2791
  sendDelayMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.sendDelayMs) || 0,
2393
2792
  stream: !!(data && data.modes && data.modes.dictation && data.modes.dictation.stream),
2394
2793
  streamChunkMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.streamChunkMs) || 1200,
@@ -2433,7 +2832,7 @@ voice._chainsPromise = null
2433
2832
  ))
2434
2833
  const offDock = ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
2435
2834
  { name: 'conversation.input.dock', id: 'dsh-voice-rec', order: 0, locale: NS, label: () => t('recordSlot') },
2436
- (props) => React.createElement(RecordPill, { input: props.input, inputActions: props.inputActions, ctx: ctx }),
2835
+ (props) => React.createElement(RecordPill, { input: props.input, inputActions: props.inputActions, session: props.session, ctx: ctx }),
2437
2836
  ))
2438
2837
  return () => {
2439
2838
  if (typeof offRight === 'function') offRight()
@@ -2728,6 +3127,93 @@ voice._chainsPromise = null
2728
3127
  ),
2729
3128
  )
2730
3129
  }
3130
+ // ------------------------------------------------------------------ 72-dictation-modes
3131
+ // Dictation modes configuration card and prompt template editors (#264)
3132
+
3133
+ function DictationModesSection(props) {
3134
+ const { t, draft, setTop, writable } = props
3135
+ const prompts = (draft && draft.dictationPrompts) || {}
3136
+ const currentMode = (draft && draft.dictationMode) || 'clean'
3137
+
3138
+ const setPrompt = (key, val) => {
3139
+ const updated = { ...prompts, [key]: val }
3140
+ setTop('dictationPrompts', updated)
3141
+ }
3142
+
3143
+ const resetPrompts = () => {
3144
+ setTop('dictationPrompts', { clean: '', task: '', note: '' })
3145
+ }
3146
+
3147
+ return React.createElement('div', { className: 'cb-section-card' },
3148
+ React.createElement('div', { className: 'cb-section-title' },
3149
+ React.createElement('span', null, '🎙️ ' + t('dictationModeTitle')),
3150
+ ),
3151
+ React.createElement('div', { className: 'cb-field-desc' }, t('dictationModeDesc')),
3152
+ React.createElement('div', { className: 'cb-grid-2', style: { marginTop: '8px' } },
3153
+ ['clean', 'task', 'note', 'verbatim'].map((m) =>
3154
+ React.createElement('label', { key: m, className: 'cb-check-label' },
3155
+ React.createElement('input', {
3156
+ type: 'radio',
3157
+ name: 'dictationMode',
3158
+ value: m,
3159
+ checked: currentMode === m,
3160
+ disabled: !writable,
3161
+ onChange: () => setTop('dictationMode', m),
3162
+ }),
3163
+ getDictationModeLabel(m, t),
3164
+ ),
3165
+ ),
3166
+ ),
3167
+ React.createElement('div', { style: { marginTop: '12px', borderTop: '1px solid var(--dsw-alias-border-l1)', paddingTop: '10px' } },
3168
+ React.createElement('div', { className: 'cb-section-title', style: { fontSize: '13px' } },
3169
+ React.createElement('span', null, t('dictationPromptsTitle')),
3170
+ React.createElement('button', {
3171
+ type: 'button',
3172
+ className: 'cb-btn cb-btn-secondary',
3173
+ style: { fontSize: '11px', padding: '2px 8px' },
3174
+ disabled: !writable,
3175
+ onClick: resetPrompts,
3176
+ }, t('resetPrompts')),
3177
+ ),
3178
+ React.createElement('div', { className: 'cb-field-desc' }, t('dictationPromptsDesc')),
3179
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '8px' } },
3180
+ React.createElement('div', null,
3181
+ React.createElement('label', { className: 'cb-label' }, t('promptClean')),
3182
+ React.createElement('textarea', {
3183
+ className: 'cb-input',
3184
+ style: { minHeight: '48px', resize: 'vertical' },
3185
+ value: prompts.clean || '',
3186
+ placeholder: 'Default: punctuation & filler-word cleanup',
3187
+ disabled: !writable,
3188
+ onChange: (e) => setPrompt('clean', e.target.value),
3189
+ }),
3190
+ ),
3191
+ React.createElement('div', null,
3192
+ React.createElement('label', { className: 'cb-label' }, t('promptTask')),
3193
+ React.createElement('textarea', {
3194
+ className: 'cb-input',
3195
+ style: { minHeight: '48px', resize: 'vertical' },
3196
+ value: prompts.task || '',
3197
+ placeholder: 'Default: structured problem/outcome/constraints/criteria',
3198
+ disabled: !writable,
3199
+ onChange: (e) => setPrompt('task', e.target.value),
3200
+ }),
3201
+ ),
3202
+ React.createElement('div', null,
3203
+ React.createElement('label', { className: 'cb-label' }, t('promptNote')),
3204
+ React.createElement('textarea', {
3205
+ className: 'cb-input',
3206
+ style: { minHeight: '48px', resize: 'vertical' },
3207
+ value: prompts.note || '',
3208
+ placeholder: 'Default: key points & action items summary',
3209
+ disabled: !writable,
3210
+ onChange: (e) => setPrompt('note', e.target.value),
3211
+ }),
3212
+ ),
3213
+ ),
3214
+ ),
3215
+ )
3216
+ }
2731
3217
  // 5. Hardware & Audio Engines Card
2732
3218
  function HardwareOptionsCard(props) {
2733
3219
  const t = props.t || moduleT
@@ -3211,6 +3697,78 @@ voice._chainsPromise = null
3211
3697
  ),
3212
3698
  )
3213
3699
  }
3700
+ // ------------------------------------------------------------------ 72-macros
3701
+ // Voice action macros configuration card (#167)
3702
+
3703
+ function MacrosSection(props) {
3704
+ const { t, draft, setTop, writable } = props
3705
+ const macros = Array.isArray(draft && draft.voiceMacros) ? draft.voiceMacros : []
3706
+
3707
+ const addMacro = () => {
3708
+ const next = [...macros, { trigger: '', template: '' }]
3709
+ setTop('voiceMacros', next)
3710
+ }
3711
+
3712
+ const updateMacro = (idx, field, val) => {
3713
+ const next = macros.map((item, i) => (i === idx ? { ...item, [field]: val } : item))
3714
+ setTop('voiceMacros', next)
3715
+ }
3716
+
3717
+ const removeMacro = (idx) => {
3718
+ const next = macros.filter((_, i) => i !== idx)
3719
+ setTop('voiceMacros', next)
3720
+ }
3721
+
3722
+ return React.createElement('div', { className: 'cb-section-card' },
3723
+ React.createElement('div', { className: 'cb-section-title' },
3724
+ React.createElement('span', null, '⚡ ' + t('voiceMacrosTitle')),
3725
+ React.createElement('button', {
3726
+ type: 'button',
3727
+ className: 'cb-btn cb-btn-secondary',
3728
+ style: { fontSize: '11px', padding: '2px 8px' },
3729
+ disabled: !writable,
3730
+ onClick: addMacro,
3731
+ }, '+ ' + t('addMacro')),
3732
+ ),
3733
+ React.createElement('div', { className: 'cb-field-desc' }, t('voiceMacrosDesc')),
3734
+ macros.length === 0
3735
+ ? React.createElement('div', { style: { fontStyle: 'italic', color: 'var(--dsw-alias-label-tertiary)', fontSize: '12px', marginTop: '6px' } },
3736
+ 'No macros configured.',
3737
+ )
3738
+ : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '6px' } },
3739
+ macros.map((m, idx) =>
3740
+ React.createElement('div', { key: idx, className: 'dvo-macro-item' },
3741
+ React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
3742
+ React.createElement('input', {
3743
+ type: 'text',
3744
+ className: 'cb-input',
3745
+ style: { flex: '1', height: '28px', fontSize: '12px' },
3746
+ placeholder: t('macroTrigger'),
3747
+ value: m.trigger || '',
3748
+ disabled: !writable,
3749
+ onChange: (e) => updateMacro(idx, 'trigger', e.target.value),
3750
+ }),
3751
+ React.createElement('button', {
3752
+ type: 'button',
3753
+ className: 'cb-btn cb-btn-secondary',
3754
+ style: { padding: '2px 6px', fontSize: '11px', color: 'var(--dsw-alias-state-error-primary)' },
3755
+ disabled: !writable,
3756
+ onClick: () => removeMacro(idx),
3757
+ }, t('macroDelete')),
3758
+ ),
3759
+ React.createElement('textarea', {
3760
+ className: 'cb-input',
3761
+ style: { minHeight: '38px', resize: 'vertical', fontSize: '12px' },
3762
+ placeholder: t('macroTemplate') + ' (use {text} for argument)',
3763
+ value: m.template || '',
3764
+ disabled: !writable,
3765
+ onChange: (e) => updateMacro(idx, 'template', e.target.value),
3766
+ }),
3767
+ ),
3768
+ ),
3769
+ ),
3770
+ )
3771
+ }
3214
3772
  // SenseVoice Local ASR (1-Click Setup)
3215
3773
  function SensevoiceSection(props) {
3216
3774
  const t = props.t || moduleT
@@ -3364,7 +3922,6 @@ voice._chainsPromise = null
3364
3922
  const [serverConfig, setServerConfig] = React.useState(null)
3365
3923
  const [configLoading, setConfigLoading] = React.useState(true)
3366
3924
  const [revision, setRevision] = React.useState(null)
3367
-
3368
3925
  React.useEffect(() => {
3369
3926
  let alive = true
3370
3927
  fetch('/dsh-voice/config', { cache: 'no-store' })
@@ -3382,7 +3939,6 @@ voice._chainsPromise = null
3382
3939
  })
3383
3940
  return () => { alive = false }
3384
3941
  }, [])
3385
-
3386
3942
  // Hotkey picker: not a name field — press the key you want.
3387
3943
  const [catching, setCatching] = React.useState(false)
3388
3944
  const [jargonInput, setJargonInput] = React.useState(null)
@@ -3405,7 +3961,6 @@ voice._chainsPromise = null
3405
3961
  document.addEventListener('keydown', onKey, true)
3406
3962
  return () => document.removeEventListener('keydown', onKey, true)
3407
3963
  }, [catching, ctx])
3408
-
3409
3964
  React.useEffect(() => {
3410
3965
  let alive = true
3411
3966
  const render = () => { if (alive && scope && typeof scope.getSnapshot === 'function') setSnap(scope.getSnapshot()) }
@@ -3413,7 +3968,6 @@ voice._chainsPromise = null
3413
3968
  const off = (scope && typeof scope.subscribe === 'function') ? scope.subscribe(render) : () => {}
3414
3969
  return () => { alive = false; off() }
3415
3970
  }, [scope])
3416
-
3417
3971
  const [devices, setDevices] = React.useState([])
3418
3972
  React.useEffect(() => {
3419
3973
  if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
@@ -3421,11 +3975,9 @@ voice._chainsPromise = null
3421
3975
  .then((list) => setDevices(list.filter((d) => d.kind === 'audioinput')))
3422
3976
  .catch(() => {})
3423
3977
  }, [])
3424
-
3425
3978
  const ready = !!serverConfig || (!!snap && snap.status === 'ready')
3426
3979
  const value = (snap && snap.status === 'ready' && snap.value) ? snap.value : (serverConfig || {})
3427
3980
  const writable = ready
3428
-
3429
3981
  React.useEffect(() => {
3430
3982
  if (ready) return undefined
3431
3983
  let tries = 0
@@ -3442,14 +3994,12 @@ voice._chainsPromise = null
3442
3994
  }, 1000)
3443
3995
  return () => clearInterval(timer)
3444
3996
  }, [ready])
3445
-
3446
3997
  const deepClone = (obj) => {
3447
3998
  if (typeof structuredClone === 'function') {
3448
3999
  try { return structuredClone(obj) } catch (e) { /* fallback */ }
3449
4000
  }
3450
4001
  try { return JSON.parse(JSON.stringify(obj)) } catch (e) { return Object.assign({}, obj) }
3451
4002
  }
3452
-
3453
4003
  const setTop = (key, val) => {
3454
4004
  setDraft((d) => Object.assign({}, d || {}, { [key]: val }))
3455
4005
  }
@@ -3470,9 +4020,7 @@ voice._chainsPromise = null
3470
4020
  }),
3471
4021
  hint ? React.createElement('span', { className: 'dvs-sub' }, hint) : null,
3472
4022
  )
3473
-
3474
4023
  React.useEffect(() => { if (ready && draft === null) setDraft(deepClone(value)) }, [ready, draft, value])
3475
-
3476
4024
  React.useEffect(() => {
3477
4025
  if (!ready) return
3478
4026
  const val = value || {}
@@ -3491,6 +4039,7 @@ voice._chainsPromise = null
3491
4039
  wakeWord: String(val.wakeWord || ''),
3492
4040
  bargeIn: !!val.bargeIn,
3493
4041
  polishSend: !!(val.message && val.message.polishSend),
4042
+ editablePreview: val.message ? val.message.editablePreview !== false : true,
3494
4043
  sessionCommands: !!(val.message && val.message.sessionCommands),
3495
4044
  polishBaseUrl: String(val.polishBaseUrl || ''),
3496
4045
  noiseSuppression: val.noiseSuppression !== false,
@@ -3503,10 +4052,12 @@ voice._chainsPromise = null
3503
4052
  techJargonCorrection: val.techJargonCorrection !== false,
3504
4053
  liveInterimPreview: val.liveInterimPreview !== false,
3505
4054
  structuredPromptVoice: val.structuredPromptVoice !== false,
4055
+ dictationMode: val.dictationMode || 'clean',
4056
+ dictationPrompts: val.dictationPrompts || {},
4057
+ voiceMacros: Array.isArray(val.voiceMacros) ? val.voiceMacros : [],
3506
4058
  webgpuModel: String(val.webgpuModel || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'),
3507
4059
  })
3508
4060
  }, [ready, value])
3509
-
3510
4061
  const [statusData, setStatusData] = React.useState(null)
3511
4062
  const [statusLatency, setStatusLatency] = React.useState(null)
3512
4063
  const fetchStatus = React.useCallback(() => {
@@ -3530,17 +4081,12 @@ voice._chainsPromise = null
3530
4081
  setStatusLatency(null)
3531
4082
  })
3532
4083
  }, [])
3533
-
3534
4084
  React.useEffect(() => {
3535
4085
  fetchStatus()
3536
4086
  }, [fetchStatus])
3537
-
3538
4087
  const { sensevoiceState, installingSensevoice, triggerInstallSensevoice } = useSensevoiceInstaller(fetchStatus)
3539
-
3540
4088
  const { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent } = useMicTester(draft, value, setErr)
3541
-
3542
4089
  const { updaterStatus, updating, updaterMsg, updaterMsgKind, updaterLoading, checkUpdater, onUpdateNow } = usePluginUpdater()
3543
-
3544
4090
  const save = async () => {
3545
4091
  setErr(''); setSaved(false)
3546
4092
  if (!draft) return
@@ -3559,7 +4105,6 @@ voice._chainsPromise = null
3559
4105
  .filter(Boolean)
3560
4106
  draft.jargonDictionary = parsed
3561
4107
  }
3562
-
3563
4108
  let httpOk = false
3564
4109
  let httpError = null
3565
4110
  try {
@@ -3581,7 +4126,6 @@ voice._chainsPromise = null
3581
4126
  } catch (e) {
3582
4127
  httpError = e?.message || String(e)
3583
4128
  }
3584
-
3585
4129
  if (httpOk && scope && typeof scope.set === 'function') {
3586
4130
  for (const k of Object.keys(draft)) {
3587
4131
  try {
@@ -3591,12 +4135,10 @@ voice._chainsPromise = null
3591
4135
  }
3592
4136
  }
3593
4137
  }
3594
-
3595
4138
  if (!httpOk) {
3596
4139
  setErr(t('saveFailed') + ': ' + httpError)
3597
4140
  return
3598
4141
  }
3599
-
3600
4142
  const d = draft || {}
3601
4143
  voice.hotkey = d.hotkey || ''
3602
4144
  if (voice.hotkey) {
@@ -3628,23 +4170,19 @@ voice._chainsPromise = null
3628
4170
  structuredPromptVoice: d.structuredPromptVoice !== false,
3629
4171
  })
3630
4172
  try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
3631
-
3632
4173
  setSaved(true); setJargonInput(null)
3633
4174
  setTimeout(() => setSaved(false), 2000)
3634
4175
  fetchStatus()
3635
4176
  }
3636
-
3637
4177
  const modeVal = (mode, key, fallback) => {
3638
4178
  const m = draft && draft[mode]
3639
4179
  return m && m[key] !== undefined ? m[key] : fallback
3640
4180
  }
3641
-
3642
4181
  const chainOptions = BUILTIN.concat(
3643
4182
  (draft && Array.isArray(draft.customProviders) ? draft.customProviders : [])
3644
4183
  .map((c) => String(c && c.key || '').trim())
3645
4184
  .filter((k) => k && BUILTIN.indexOf(k) < 0),
3646
4185
  )
3647
-
3648
4186
  const langField = (mode) => {
3649
4187
  const val = modeVal(mode, 'language', '')
3650
4188
  const opts = SUPPORTED_LANGS.slice()
@@ -3661,7 +4199,6 @@ voice._chainsPromise = null
3661
4199
  ))),
3662
4200
  )
3663
4201
  }
3664
-
3665
4202
  const numField = (mode, key, label, hint) => React.createElement('div', { className: 'cb-field' },
3666
4203
  React.createElement('label', null, label),
3667
4204
  React.createElement('input', {
@@ -3670,15 +4207,11 @@ voice._chainsPromise = null
3670
4207
  }),
3671
4208
  React.createElement('span', { className: 'dvs-sub' }, hint),
3672
4209
  )
3673
-
3674
-
3675
4210
  const statsData = (statusData && statusData.providerStats) || {}
3676
4211
  const hostOnline = statusLatency !== null
3677
4212
  const whisperRunning = !!(statusData && statusData.whisperRunning)
3678
4213
  const providerCount = (statusData && Array.isArray(statusData.providers)) ? statusData.providers.length : chainOptions.length
3679
-
3680
4214
  return React.createElement('div', { className: 'cb-page dvo-settings-root' },
3681
- // 1. Connection & Engine Status Card
3682
4215
  React.createElement(ConnectionStatusCard, {
3683
4216
  t: t,
3684
4217
  hostOnline: hostOnline,
@@ -3687,8 +4220,6 @@ voice._chainsPromise = null
3687
4220
  providerCount: providerCount,
3688
4221
  onRefresh: fetchStatus,
3689
4222
  }),
3690
-
3691
- // 2. Dictation Mode Card
3692
4223
  React.createElement('div', { className: 'cb-section-card' },
3693
4224
  React.createElement('div', { className: 'cb-section-title' },
3694
4225
  React.createElement('span', null, '🎙️ ' + t('dictationBtn')),
@@ -3732,8 +4263,6 @@ voice._chainsPromise = null
3732
4263
  ),
3733
4264
  textField('wakeWord', t('wakeWord'), t('wakeWordHint')),
3734
4265
  ),
3735
-
3736
- // 3. Voice Message Card
3737
4266
  React.createElement('div', { className: 'cb-section-card' },
3738
4267
  React.createElement('div', { className: 'cb-section-title' },
3739
4268
  React.createElement('span', null, '💬 ' + t('messageTitle')),
@@ -3772,8 +4301,6 @@ voice._chainsPromise = null
3772
4301
  ),
3773
4302
  ),
3774
4303
  ),
3775
-
3776
- // 4. Custom Providers Card
3777
4304
  React.createElement('div', { className: 'cb-section-card' },
3778
4305
  React.createElement('div', { className: 'cb-section-title' },
3779
4306
  React.createElement('span', null, '⚙️ ' + t('customTitle')),
@@ -3785,8 +4312,6 @@ voice._chainsPromise = null
3785
4312
  onChange: (v) => setTop('customProviders', v),
3786
4313
  }),
3787
4314
  ),
3788
-
3789
- // 5. Hardware & Audio Engines Card
3790
4315
  React.createElement(HardwareOptionsCard, {
3791
4316
  t: t,
3792
4317
  draft: draft,
@@ -3806,8 +4331,6 @@ voice._chainsPromise = null
3806
4331
  clearGlobalHotkey()
3807
4332
  },
3808
4333
  }),
3809
-
3810
- // 6. SenseVoice Local ASR (1-Click Setup)
3811
4334
  React.createElement(SensevoiceSection, {
3812
4335
  t: t,
3813
4336
  sensevoiceState: sensevoiceState,
@@ -3815,8 +4338,6 @@ voice._chainsPromise = null
3815
4338
  writable: writable,
3816
4339
  onInstall: triggerInstallSensevoice,
3817
4340
  }),
3818
-
3819
- // 7. Turn-Taking & Audio Coordination
3820
4341
  React.createElement('div', { className: 'cb-section-card' },
3821
4342
  React.createElement('div', { className: 'cb-section-title' },
3822
4343
  React.createElement('span', null, '🎙️ ' + t('turnTakingTitle')),
@@ -3843,8 +4364,6 @@ voice._chainsPromise = null
3843
4364
  ),
3844
4365
  ),
3845
4366
  ),
3846
-
3847
- // 8. Hands-free & Smart Actions
3848
4367
  React.createElement('div', { className: 'cb-section-card' },
3849
4368
  React.createElement('div', { className: 'cb-section-title' },
3850
4369
  React.createElement('span', null, '✨ ' + t('smartActionsTitle')),
@@ -3888,8 +4407,20 @@ voice._chainsPromise = null
3888
4407
  ),
3889
4408
  ),
3890
4409
  ),
3891
-
3892
- // 9. Developer Lexicon & IT Jargon
4410
+ // Dictation Modes & Prompts (#264)
4411
+ React.createElement(DictationModesSection, {
4412
+ t: t,
4413
+ draft: draft,
4414
+ setTop: setTop,
4415
+ writable: writable,
4416
+ }),
4417
+ // Voice Action Macros (#167)
4418
+ React.createElement(MacrosSection, {
4419
+ t: t,
4420
+ draft: draft,
4421
+ setTop: setTop,
4422
+ writable: writable,
4423
+ }),
3893
4424
  React.createElement(JargonEditor, {
3894
4425
  t: t,
3895
4426
  draft: draft,
@@ -3898,15 +4429,11 @@ voice._chainsPromise = null
3898
4429
  jargonInput: jargonInput,
3899
4430
  setJargonInput: setJargonInput,
3900
4431
  }),
3901
-
3902
- // 10. Provider Telemetry & Latency Dashboard
3903
4432
  React.createElement(ProviderDashboard, {
3904
4433
  t: t,
3905
4434
  statsData: statsData,
3906
4435
  onRefresh: fetchStatus,
3907
4436
  }),
3908
-
3909
- // 7. Plugin Version & Updates Card
3910
4437
  React.createElement(UpdaterCard, {
3911
4438
  t: t,
3912
4439
  updaterStatus: updaterStatus,
@@ -3917,8 +4444,6 @@ voice._chainsPromise = null
3917
4444
  onCheck: checkUpdater,
3918
4445
  onUpdateNow: onUpdateNow,
3919
4446
  }),
3920
-
3921
- // Action bar (Save)
3922
4447
  React.createElement('div', { className: 'cb-row' },
3923
4448
  React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable, onClick: save }, t('save')),
3924
4449
  saved ? React.createElement('span', { className: 'dvs-ok' }, t('saved')) : null,
@@ -4031,6 +4556,9 @@ voice._chainsPromise = null
4031
4556
  isAllowedInEditable,
4032
4557
  isHotkeyRelease,
4033
4558
  extractContextKeywords,
4559
+ DICTATION_MODES,
4560
+ getDictationModeLabel,
4561
+ cycleDictationMode,
4034
4562
  resolveBrowserRecognitionLang,
4035
4563
  VoiceSection,
4036
4564
  cutPhrase,
@@ -4041,6 +4569,12 @@ voice._chainsPromise = null
4041
4569
  cancelCurrent,
4042
4570
  stopCurrent,
4043
4571
  submitPending,
4572
+ cancelPending,
4573
+ keepPending,
4574
+ initPendingPreview,
4575
+ cancelPendingPreview,
4576
+ keepPendingPreview,
4577
+ submitPendingPreview,
4044
4578
  loadWebGpuWhisper,
4045
4579
  isWebGpuSupported,
4046
4580
  isWasmSupported,