@goodandready/dsh-voice 0.9.29 → 0.9.31

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',
@@ -350,6 +358,14 @@ window.__ModuleLoader__.load({
350
358
  'bargeIn': '语音插话打断 (Barge-in)',
351
359
  'polishSend': '发送前润色整篇草稿',
352
360
  'polishSendHint': '发送给智能体前对整个输入框草稿进行一次整体润色',
361
+ 'editablePreview': '发送前可编辑预览',
362
+ 'editablePreviewHint': '发送前显示识别文本,支持编辑、润色对比和撤销。',
363
+ 'editPreview': '编辑',
364
+ 'sendNow': '发送',
365
+ 'cancelPreview': '取消',
366
+ 'rawText': '原文',
367
+ 'polishedText': '润色',
368
+ 'sessionMismatch': '当前会话已切换,已拦截发送以防止串话。',
353
369
  'sessionCommands': '会话语音控制指令',
354
370
  'sessionCommandsHint': '"send"、"cancel"、"stop"、"continue" 执行操作而非转为文本',
355
371
  'polishBaseUrl': '离线润色服务端点',
@@ -518,7 +534,20 @@ window.__ModuleLoader__.load({
518
534
  '.dvo-progress-bar{height:6px;width:100%;background:var(--dsw-alias-bg-layer-3);border-radius:3px;overflow:hidden}' +
519
535
  '.dvo-progress-fill{height:100%;background:var(--dsw-alias-state-info-primary);transition:width 150ms ease}' +
520
536
  '.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)}'
537
+ '.dvo-jargon-table td{padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l1)}' +
538
+ '.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}' +
539
+ '.dvo-preview-header{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%}' +
540
+ '.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)}' +
541
+ '.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}' +
542
+ '.dvo-tab-btn:hover{color:var(--dsw-alias-label-primary)}' +
543
+ '.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)}' +
544
+ '.dvo-preview-body{display:flex;align-items:center;gap:8px;width:100%}' +
545
+ '.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}' +
546
+ '.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%}' +
547
+ '.dvo-preview-input:focus{border-color:var(--dsw-alias-state-info-primary)}' +
548
+ '.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
+ '.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}'
522
551
  const cssId = 'dsh-voice/client.module.css'
523
552
  if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {
524
553
  const tag = document.createElement('style')
@@ -543,7 +572,7 @@ window.__ModuleLoader__.load({
543
572
  inputActions: null,
544
573
  input: null,
545
574
  settings: {
546
- vadSilenceMs: 700, autoSendMs: 4000, stream: false, streamChunkMs: 1200, vadAdapt: 0,
575
+ vadSilenceMs: 700, autoSendMs: 4000, editablePreview: true, stream: false, streamChunkMs: 1200, vadAdapt: 0,
547
576
  noiseSuppression: true, contextGlossary: true,
548
577
  gatedTurnTaking: true, autoSendVisualRing: true, voiceActions: true,
549
578
  techJargonCorrection: true, jargonDictionary: [], liveInterimPreview: true,
@@ -1458,7 +1487,11 @@ voice._chainsPromise = null
1458
1487
  if (targetActions && typeof targetActions.captureInsertion === 'function') {
1459
1488
  try { span = targetActions.captureInsertion() } catch (_) {}
1460
1489
  }
1461
- const target = { actions: targetActions, input: targetInput, span }
1490
+ const initialDraft = (targetInput && typeof targetInput.draft === 'string') ? targetInput.draft : ''
1491
+ const targetSession = voice.session || (targetInput && targetInput.session)
1492
+ const sessionId = (targetSession && (targetSession.sessionId || targetSession.id))
1493
+ || voice.currentSessionId || ''
1494
+ const target = { actions: targetActions, input: targetInput, session: targetSession, span, initialDraft, sessionId }
1462
1495
  voice._activeTarget = target
1463
1496
 
1464
1497
  if (voice.isTtsSpeaking) {
@@ -1539,7 +1572,12 @@ voice._chainsPromise = null
1539
1572
  if (mode === 'message') {
1540
1573
  const ok = appendDraft(said, target)
1541
1574
  if (ok !== false) {
1542
- voice.set({ phase: 'pending', pending: { text: said, target, totalMs: voice.settings.autoSendMs, leftMs: voice.settings.autoSendMs } })
1575
+ const usePreview = voice.settings.editablePreview !== false
1576
+ const autoMs = Number(voice.settings.autoSendMs) || 4000
1577
+ const pendingState = usePreview
1578
+ ? initPendingPreview(said, target, { autoSendMs: autoMs })
1579
+ : { text: said, target, totalMs: autoMs, leftMs: autoMs }
1580
+ voice.set({ phase: 'pending', pending: pendingState })
1543
1581
  } else {
1544
1582
  voice.set({ phase: 'idle' })
1545
1583
  }
@@ -1610,7 +1648,24 @@ voice._chainsPromise = null
1610
1648
  if (voice.lastNote) voice.lastNote.text = text
1611
1649
  if (mode === 'message') {
1612
1650
  if (ok !== false) {
1613
- voice.set({ phase: 'pending', pending: { text, target, totalMs: voice.settings.autoSendMs, leftMs: voice.settings.autoSendMs, audioUrl: noteUrl } })
1651
+ const usePreview = voice.settings.editablePreview !== false
1652
+ let polishedText = text
1653
+ if (usePreview && voice.settings.polishSend) {
1654
+ try {
1655
+ const pRes = await fetch('/dsh-voice/polish', {
1656
+ method: 'POST',
1657
+ headers: { 'content-type': 'application/json' },
1658
+ body: JSON.stringify({ text }),
1659
+ })
1660
+ const pData = await pRes.json()
1661
+ if (pData && pData.text) polishedText = pData.text
1662
+ } catch (_) {}
1663
+ }
1664
+ const autoMs = Number(voice.settings.autoSendMs) || 4000
1665
+ const pendingState = usePreview
1666
+ ? initPendingPreview(text, target, { autoSendMs: autoMs, polishedText, audioUrl: noteUrl, polishSend: voice.settings.polishSend })
1667
+ : { text, target, totalMs: autoMs, leftMs: autoMs, audioUrl: noteUrl }
1668
+ voice.set({ phase: 'pending', pending: pendingState })
1614
1669
  } else {
1615
1670
  voice.set({ phase: 'idle' })
1616
1671
  }
@@ -1658,38 +1713,11 @@ voice._chainsPromise = null
1658
1713
  }
1659
1714
 
1660
1715
  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' })
1716
+ submitPendingPreview(voice)
1717
+ }
1668
1718
 
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
- }
1719
+ function cancelPending() {
1720
+ cancelPendingPreview(voice)
1693
1721
  }
1694
1722
 
1695
1723
  function runSessionCommand(cmd) {
@@ -1700,8 +1728,7 @@ voice._chainsPromise = null
1700
1728
  }
1701
1729
 
1702
1730
  function keepPending() {
1703
- voice.pending = null
1704
- voice.set({ phase: 'idle' })
1731
+ keepPendingPreview(voice)
1705
1732
  }
1706
1733
  // ----------------------------------------------------------- components
1707
1734
  function VoiceButtons(props) {
@@ -1930,6 +1957,258 @@ voice._chainsPromise = null
1930
1957
  }
1931
1958
  return cleanup
1932
1959
  }
1960
+ // ------------------------------------------------------------------ 43-preview
1961
+ // Editable preview state machine, session pinning & UI component (#170).
1962
+
1963
+ function initPendingPreview(text, target, options = {}) {
1964
+ const autoMs = Number(options.autoSendMs) || 4000
1965
+ const polishedText = options.polishedText || text
1966
+ const isPolished = options.polishSend && polishedText && polishedText !== text
1967
+ const initialDraft = (target && typeof target.initialDraft === 'string')
1968
+ ? target.initialDraft
1969
+ : ((target && target.input && typeof target.input.draft === 'string') ? target.input.draft : '')
1970
+ const sessionId = (target && target.sessionId) || ''
1971
+
1972
+ return {
1973
+ text,
1974
+ rawText: text,
1975
+ polishedText,
1976
+ activeText: isPolished ? polishedText : text,
1977
+ selectedTab: isPolished ? 'polished' : 'raw',
1978
+ isEditing: false,
1979
+ target,
1980
+ sessionId,
1981
+ initialDraft,
1982
+ totalMs: autoMs,
1983
+ leftMs: autoMs,
1984
+ paused: false,
1985
+ audioUrl: options.audioUrl || '',
1986
+ sessionMismatch: false,
1987
+ }
1988
+ }
1989
+
1990
+ function cancelPendingPreview(voice) {
1991
+ const pending = voice.pending
1992
+ if (!pending) {
1993
+ voice.set({ phase: 'idle' })
1994
+ return
1995
+ }
1996
+ const target = pending.target || voice._activeTarget
1997
+ const actions = (target && target.actions) || voice.inputActions
1998
+
1999
+ // Restore untouched composer draft if preview was active
2000
+ if (pending.rawText !== undefined && pending.initialDraft !== undefined) {
2001
+ if (actions && typeof actions.setDraft === 'function') {
2002
+ try { actions.setDraft(pending.initialDraft) } catch (_) {}
2003
+ }
2004
+ }
2005
+ voice.pending = null
2006
+ voice.set({ phase: 'idle' })
2007
+ }
2008
+
2009
+ function keepPendingPreview(voice) {
2010
+ const pending = voice.pending
2011
+ if (!pending) {
2012
+ voice.set({ phase: 'idle' })
2013
+ return
2014
+ }
2015
+ const target = pending.target || voice._activeTarget
2016
+ const actions = (target && target.actions) || voice.inputActions
2017
+
2018
+ if (pending.rawText !== undefined) {
2019
+ // Insert final edited text into composer draft without auto-sending
2020
+ const initial = pending.initialDraft || ''
2021
+ const textToInsert = pending.activeText || ''
2022
+ const full = initial
2023
+ ? (initial.endsWith(' ') || initial.endsWith('\n') ? (initial + textToInsert) : (initial + ' ' + textToInsert))
2024
+ : textToInsert
2025
+ if (actions && typeof actions.setDraft === 'function') {
2026
+ try { actions.setDraft(full) } catch (_) {}
2027
+ }
2028
+ }
2029
+ voice.pending = null
2030
+ voice.set({ phase: 'idle' })
2031
+ }
2032
+
2033
+ function submitPendingPreview(voice) {
2034
+ const pending = voice.pending
2035
+ if (!pending) return
2036
+ const target = pending.target || voice._activeTarget
2037
+ const actions = (target && target.actions) || voice.inputActions
2038
+
2039
+ // Session guard (#170): verify that current session matches initiating session
2040
+ const currentSessionId = (target && target.session && (target.session.sessionId || target.session.id))
2041
+ || (voice.session && (voice.session.sessionId || voice.session.id))
2042
+ || voice.currentSessionId || ''
2043
+
2044
+ if (pending.sessionId && currentSessionId && pending.sessionId !== currentSessionId) {
2045
+ pending.sessionMismatch = true
2046
+ pending.paused = true
2047
+ voice.notify()
2048
+ return
2049
+ }
2050
+
2051
+ const textToSend = (pending.rawText !== undefined ? pending.activeText : pending.text) || ''
2052
+ voice.pending = null
2053
+ voice.set({ phase: 'idle' })
2054
+
2055
+ if (pending.rawText !== undefined) {
2056
+ const initial = pending.initialDraft || ''
2057
+ const full = initial
2058
+ ? (initial.endsWith(' ') || initial.endsWith('\n') ? (initial + textToSend) : (initial + ' ' + textToSend))
2059
+ : textToSend
2060
+ if (actions && typeof actions.setDraft === 'function') {
2061
+ try { actions.setDraft(full) } catch (_) {}
2062
+ }
2063
+ if (actions && typeof actions.submit === 'function') {
2064
+ try { actions.submit() } catch (_) {}
2065
+ }
2066
+ } else {
2067
+ // Legacy path without rich preview
2068
+ if (voice.settings.polishSend) {
2069
+ fetch('/dsh-voice/polish', {
2070
+ method: 'POST',
2071
+ headers: { 'content-type': 'application/json' },
2072
+ body: JSON.stringify({ text: textToSend }),
2073
+ })
2074
+ .then((res) => res.json())
2075
+ .then((data) => {
2076
+ const polished = (data && data.text) || textToSend
2077
+ if (actions && typeof actions.setDraft === 'function') actions.setDraft(polished)
2078
+ if (actions && typeof actions.submit === 'function') actions.submit()
2079
+ })
2080
+ .catch(() => {
2081
+ if (actions && typeof actions.submit === 'function') actions.submit()
2082
+ })
2083
+ } else if (actions && typeof actions.submit === 'function') {
2084
+ actions.submit()
2085
+ }
2086
+ }
2087
+ }
2088
+
2089
+ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef) {
2090
+ const p = voice.pending
2091
+ const total = p.totalMs || Number(voice.settings.autoSendMs) || 4000
2092
+ const leftMs = Math.max(0, p.leftMs || 0)
2093
+ const left = Math.ceil(leftMs / 1000)
2094
+ const frac = total > 0 ? Math.max(0, Math.min(1, leftMs / total)) : 0
2095
+ const circ = 2 * Math.PI * 14
2096
+ const off = circ * (1 - frac)
2097
+ const hasPolish = p.polishedText && p.polishedText !== p.rawText
2098
+
2099
+ return React.createElement('div', { className: 'dvo-pill-preview', role: 'region', 'aria-label': t('editablePreview') },
2100
+ // Header row: tabs, audio playback, status/countdown, cancel button
2101
+ React.createElement('div', { className: 'dvo-preview-header' },
2102
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
2103
+ hasPolish
2104
+ ? React.createElement('div', { className: 'dvo-preview-tabs' },
2105
+ React.createElement('button', {
2106
+ type: 'button',
2107
+ className: 'dvo-tab-btn' + (p.selectedTab === 'raw' ? ' dvo-tab-btn-active' : ''),
2108
+ onClick: () => {
2109
+ p.selectedTab = 'raw'
2110
+ p.activeText = p.rawText
2111
+ voice.notify()
2112
+ },
2113
+ }, t('rawText')),
2114
+ React.createElement('button', {
2115
+ type: 'button',
2116
+ className: 'dvo-tab-btn' + (p.selectedTab === 'polished' ? ' dvo-tab-btn-active' : ''),
2117
+ onClick: () => {
2118
+ p.selectedTab = 'polished'
2119
+ p.activeText = p.polishedText
2120
+ voice.notify()
2121
+ },
2122
+ }, t('polishedText')),
2123
+ )
2124
+ : React.createElement('span', { className: 'dvo-count' }, t('rawText')),
2125
+ p.audioUrl
2126
+ ? React.createElement('div', { className: 'dvo-audio-wrap' },
2127
+ React.createElement('button', {
2128
+ type: 'button', className: 'dvo-audio-play',
2129
+ title: isPlaying ? t('pause') : t('play'),
2130
+ 'aria-label': isPlaying ? t('pause') : t('play'),
2131
+ onClick: () => {
2132
+ const el = audioRef.current
2133
+ if (!el) return
2134
+ if (el.paused) { el.play().catch(() => {}); setIsPlaying(true) }
2135
+ else { el.pause(); setIsPlaying(false) }
2136
+ },
2137
+ }, isPlaying ? pauseIcon() : playIcon()),
2138
+ React.createElement('audio', {
2139
+ ref: audioRef, src: p.audioUrl,
2140
+ onEnded: () => setIsPlaying(false),
2141
+ onPause: () => setIsPlaying(false),
2142
+ onPlay: () => setIsPlaying(true),
2143
+ }),
2144
+ React.createElement('span', { className: 'dvo-audio-time' }, t('listenBack')),
2145
+ )
2146
+ : null,
2147
+ ),
2148
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
2149
+ (!p.paused && !p.isEditing && v.settings.autoSendVisualRing !== false)
2150
+ ? React.createElement('svg', { className: 'dvo-silence-ring', viewBox: '0 0 34 34' },
2151
+ React.createElement('circle', { cx: 17, cy: 17, r: 14, className: 'dvo-ring-bg' }),
2152
+ React.createElement('circle', {
2153
+ cx: 17, cy: 17, r: 14, className: 'dvo-ring-fg',
2154
+ strokeDasharray: `${circ} ${circ}`,
2155
+ strokeDashoffset: off,
2156
+ }),
2157
+ )
2158
+ : null,
2159
+ React.createElement('span', { className: 'dvo-count' },
2160
+ (p.paused || p.isEditing) ? '' : (left + t('secondsShort'))
2161
+ ),
2162
+ React.createElement('button', {
2163
+ type: 'button', className: 'dvo-pbtn', title: t('cancelPreview'),
2164
+ 'aria-label': t('cancelPreview'),
2165
+ onClick: () => { cancelPending(); focusComposer() },
2166
+ }, xIcon()),
2167
+ ),
2168
+ ),
2169
+ // Body row: editable input or preview text, Edit button, Send button
2170
+ React.createElement('div', { className: 'dvo-preview-body' },
2171
+ p.isEditing
2172
+ ? React.createElement('input', {
2173
+ type: 'text',
2174
+ className: 'dvo-preview-input',
2175
+ value: p.activeText,
2176
+ autoFocus: true,
2177
+ onChange: (e) => {
2178
+ p.activeText = e.target.value
2179
+ voice.notify()
2180
+ },
2181
+ onKeyDown: (e) => {
2182
+ if (e.key === 'Enter' && !e.shiftKey) {
2183
+ e.preventDefault()
2184
+ submitPending()
2185
+ }
2186
+ },
2187
+ })
2188
+ : React.createElement('span', { className: 'dvo-preview-text' }, p.activeText),
2189
+ !p.isEditing
2190
+ ? React.createElement('button', {
2191
+ type: 'button', className: 'dvo-pbtn', title: t('editPreview'),
2192
+ 'aria-label': t('editPreview'),
2193
+ onClick: () => {
2194
+ p.isEditing = true
2195
+ p.paused = true
2196
+ voice.notify()
2197
+ },
2198
+ }, editIcon())
2199
+ : null,
2200
+ React.createElement('button', {
2201
+ type: 'button', className: 'dvo-btn-send', title: t('sendNow'),
2202
+ 'aria-label': t('sendNow'),
2203
+ onClick: () => { submitPending(); focusComposer() },
2204
+ }, sendIcon()),
2205
+ ),
2206
+ // Mismatch warning banner
2207
+ p.sessionMismatch
2208
+ ? React.createElement('div', { className: 'dvo-warn-banner' }, t('sessionMismatch'))
2209
+ : null,
2210
+ )
2211
+ }
1933
2212
  // ----------------------------------------------------------- visualizers
1934
2213
  let _accentColorCache = { color: '', expires: 0 }
1935
2214
  let _softColorCache = { color: '', expires: 0 }
@@ -2071,6 +2350,8 @@ voice._chainsPromise = null
2071
2350
  const [isPlaying, setIsPlaying] = React.useState(false)
2072
2351
  voice.inputActions = props.inputActions
2073
2352
  voice.input = props.input
2353
+ voice.session = props.session
2354
+ voice.currentSessionId = (props.session && (props.session.sessionId || props.session.id)) || voice.currentSessionId || ''
2074
2355
  const ctx = props.ctx
2075
2356
 
2076
2357
  // VAD: accumulate silence and cut the phrase when the pause exceeds the threshold.
@@ -2194,6 +2475,10 @@ voice._chainsPromise = null
2194
2475
  else voice.notify()
2195
2476
  return
2196
2477
  }
2478
+ if (p.paused || p.isEditing) {
2479
+ // Confirmation or editing mode freezes auto-send countdown (#170)
2480
+ return
2481
+ }
2197
2482
  p.leftMs -= tick
2198
2483
  if (p.leftMs <= 0) { submitPending(); return }
2199
2484
  voice.notify()
@@ -2279,6 +2564,9 @@ voice._chainsPromise = null
2279
2564
  }
2280
2565
 
2281
2566
  if (v.phase === 'pending') {
2567
+ if (voice.pending && voice.pending.rawText !== undefined) {
2568
+ return renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
2569
+ }
2282
2570
  const left = Math.max(0, Math.ceil((voice.pending ? voice.pending.leftMs : 0) / 1000))
2283
2571
  if (voice.pending && voice.pending.undoOnly) {
2284
2572
  // Cancel window for delayed dictation insert (#29-5).
@@ -2433,7 +2721,7 @@ voice._chainsPromise = null
2433
2721
  ))
2434
2722
  const offDock = ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
2435
2723
  { 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 }),
2724
+ (props) => React.createElement(RecordPill, { input: props.input, inputActions: props.inputActions, session: props.session, ctx: ctx }),
2437
2725
  ))
2438
2726
  return () => {
2439
2727
  if (typeof offRight === 'function') offRight()
@@ -3087,11 +3375,13 @@ voice._chainsPromise = null
3087
3375
  const [updaterStatus, setUpdaterStatus] = React.useState(null)
3088
3376
  const [updating, setUpdating] = React.useState(false)
3089
3377
  const [updaterMsg, setUpdaterMsg] = React.useState('')
3378
+ const [updaterMsgKind, setUpdaterMsgKind] = React.useState('')
3090
3379
  const [updaterLoading, setUpdaterLoading] = React.useState(false)
3091
3380
 
3092
3381
  const checkUpdater = React.useCallback(async () => {
3093
3382
  setUpdaterLoading(true)
3094
3383
  setUpdaterMsg('')
3384
+ setUpdaterMsgKind('')
3095
3385
  try {
3096
3386
  const res = await fetch('/api/dsh-voice/update', {
3097
3387
  headers: { accept: 'application/json' },
@@ -3102,10 +3392,12 @@ voice._chainsPromise = null
3102
3392
  setUpdaterStatus(data)
3103
3393
  if (data && data.latestCheckFailed) {
3104
3394
  setUpdaterMsg(t('updateCheckFailed'))
3395
+ setUpdaterMsgKind('err')
3105
3396
  }
3106
3397
  } catch (e) {
3107
3398
  setUpdaterStatus(null)
3108
3399
  setUpdaterMsg(t('updateCheckFailed') + ': ' + (e?.message || String(e)))
3400
+ setUpdaterMsgKind('err')
3109
3401
  } finally {
3110
3402
  setUpdaterLoading(false)
3111
3403
  }
@@ -3115,6 +3407,7 @@ voice._chainsPromise = null
3115
3407
  if (updating) return
3116
3408
  setUpdating(true)
3117
3409
  setUpdaterMsg('')
3410
+ setUpdaterMsgKind('')
3118
3411
  try {
3119
3412
  const res = await fetch('/api/dsh-voice/update', {
3120
3413
  method: 'POST',
@@ -3140,15 +3433,17 @@ voice._chainsPromise = null
3140
3433
  throw new Error(data.error || `HTTP ${res.status}`)
3141
3434
  }
3142
3435
  setUpdaterMsg(data.message || t('updateSuccess'))
3436
+ setUpdaterMsgKind('ok')
3143
3437
  checkUpdater()
3144
3438
  } catch (err) {
3145
3439
  setUpdaterMsg(err && err.message ? err.message : String(err))
3440
+ setUpdaterMsgKind('err')
3146
3441
  } finally {
3147
3442
  setUpdating(false)
3148
3443
  }
3149
3444
  }
3150
3445
 
3151
- return { updaterStatus, updating, updaterMsg, updaterLoading, checkUpdater, onUpdateNow }
3446
+ return { updaterStatus, updating, updaterMsg, updaterMsgKind, updaterLoading, checkUpdater, onUpdateNow }
3152
3447
  }
3153
3448
  // Developer Lexicon & IT Jargon
3154
3449
  function JargonEditor(props) {
@@ -3285,6 +3580,7 @@ voice._chainsPromise = null
3285
3580
  const updaterLoading = props.updaterLoading
3286
3581
  const updating = props.updating
3287
3582
  const updaterMsg = props.updaterMsg
3583
+ const updaterMsgKind = props.updaterMsgKind
3288
3584
  const onCheck = props.onCheck
3289
3585
  const onUpdateNow = props.onUpdateNow
3290
3586
 
@@ -3328,7 +3624,7 @@ voice._chainsPromise = null
3328
3624
  style: {
3329
3625
  marginTop: '8px',
3330
3626
  fontSize: '12px',
3331
- color: updaterMsg.includes('fail') || updaterMsg.includes('失败')
3627
+ color: (updaterMsgKind === 'err' || (!updaterMsgKind && (updaterMsg.includes('fail') || updaterMsg.includes('失败'))))
3332
3628
  ? 'var(--dsw-alias-state-warning-primary)'
3333
3629
  : 'var(--dsw-alias-state-success-primary)',
3334
3630
  },
@@ -3483,6 +3779,7 @@ voice._chainsPromise = null
3483
3779
  wakeWord: String(val.wakeWord || ''),
3484
3780
  bargeIn: !!val.bargeIn,
3485
3781
  polishSend: !!(val.message && val.message.polishSend),
3782
+ editablePreview: val.message ? val.message.editablePreview !== false : true,
3486
3783
  sessionCommands: !!(val.message && val.message.sessionCommands),
3487
3784
  polishBaseUrl: String(val.polishBaseUrl || ''),
3488
3785
  noiseSuppression: val.noiseSuppression !== false,
@@ -3531,7 +3828,7 @@ voice._chainsPromise = null
3531
3828
 
3532
3829
  const { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent } = useMicTester(draft, value, setErr)
3533
3830
 
3534
- const { updaterStatus, updating, updaterMsg, updaterLoading, checkUpdater, onUpdateNow } = usePluginUpdater()
3831
+ const { updaterStatus, updating, updaterMsg, updaterMsgKind, updaterLoading, checkUpdater, onUpdateNow } = usePluginUpdater()
3535
3832
 
3536
3833
  const save = async () => {
3537
3834
  setErr(''); setSaved(false)
@@ -3905,6 +4202,7 @@ voice._chainsPromise = null
3905
4202
  updaterLoading: updaterLoading,
3906
4203
  updating: updating,
3907
4204
  updaterMsg: updaterMsg,
4205
+ updaterMsgKind: updaterMsgKind,
3908
4206
  onCheck: checkUpdater,
3909
4207
  onUpdateNow: onUpdateNow,
3910
4208
  }),
@@ -4032,6 +4330,12 @@ voice._chainsPromise = null
4032
4330
  cancelCurrent,
4033
4331
  stopCurrent,
4034
4332
  submitPending,
4333
+ cancelPending,
4334
+ keepPending,
4335
+ initPendingPreview,
4336
+ cancelPendingPreview,
4337
+ keepPendingPreview,
4338
+ submitPendingPreview,
4035
4339
  loadWebGpuWhisper,
4036
4340
  isWebGpuSupported,
4037
4341
  isWasmSupported,