@goodandready/dsh-voice 0.8.27 → 0.8.29

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
@@ -17,6 +17,14 @@ window.__ModuleLoader__.load({
17
17
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
18
18
  let React = require('react')
19
19
 
20
+ let ChevronIcon = null
21
+ try {
22
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
23
+ ChevronIcon = primitives && (primitives.IconChevronDownOutline14 || primitives.IconChevronDownOutline)
24
+ } catch {
25
+ ChevronIcon = null
26
+ }
27
+
20
28
  const NS = 'dsh-voice'
21
29
 
22
30
  // UI strings live in the locale registry so a separate package can
@@ -183,6 +191,15 @@ window.__ModuleLoader__.load({
183
191
  'noiseGate': 'Noise Gate (dB)',
184
192
  'noiseGateHint': 'Mute background fan hum and keyboard clicks below threshold. Off = disabled',
185
193
  'noiseGateOff': 'Off',
194
+ 'versionTitle': 'Plugin Version & Updates',
195
+ 'versionHint': 'Check for new versions and update directly from npm.',
196
+ 'updateAvailable': 'Update available',
197
+ 'upToDate': 'Up to date',
198
+ 'updatePrompt': 'A new version is available on npm. Click update to install.',
199
+ 'updateNow': 'Update now',
200
+ 'updating': 'Updating…',
201
+ 'updateSuccess': 'Updated successfully! Restart DSH to apply.',
202
+ 'updateFailed': 'Update failed. Check logs.',
186
203
  }
187
204
 
188
205
  const zh = {
@@ -346,6 +363,15 @@ window.__ModuleLoader__.load({
346
363
  'noiseGate': '噪声门 (dB)',
347
364
  'noiseGateHint': '静音切除低于阈值的风扇嗡嗡声与机械键盘敲击声。Off = 关闭',
348
365
  'noiseGateOff': '关闭',
366
+ 'versionTitle': '插件版本与更新',
367
+ 'versionHint': '检查新版本并直接从 npm 更新。',
368
+ 'updateAvailable': '有可用更新',
369
+ 'upToDate': '已是最新版本',
370
+ 'updatePrompt': 'npm 上有新版本可用。点击更新进行安装。',
371
+ 'updateNow': '立即更新',
372
+ 'updating': '正在更新…',
373
+ 'updateSuccess': '更新成功!请重启 DSH 生效。',
374
+ 'updateFailed': '更新失败,请查看日志。',
349
375
  }
350
376
 
351
377
  // Strings are also needed outside components — in recording handlers and
@@ -357,7 +383,8 @@ window.__ModuleLoader__.load({
357
383
  '.dvo-btn{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0;box-sizing:border-box;user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
358
384
  '.dvo-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}' +
359
385
  '.dvo-btn[data-err="1"]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}' +
360
- '.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px rgba(0,0,0,.18);box-sizing:border-box;user-select:none;-webkit-user-select:none}' +
386
+ '.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent);box-sizing:border-box;user-select:none;-webkit-user-select:none}' +
387
+ '.dvo-chev-icon{width:14px;height:14px;display:block;flex:none}' +
361
388
  '.dvo-pbtn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
362
389
  '.dvo-pbtn:hover{background:var(--dsw-alias-bg-layer-2)}' +
363
390
  '.dvo-wave{flex:1;min-width:0;max-width:100%;height:40px;width:100%;color:var(--dsw-alias-label-primary)}' +
@@ -447,8 +474,31 @@ window.__ModuleLoader__.load({
447
474
  React.createElement('path', { d: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z' }),
448
475
  React.createElement('line', { x1: 12, y1: 9, x2: 12, y2: 13 }),
449
476
  React.createElement('line', { x1: 12, y1: 17, x2: 12.01, y2: 17 }))
450
- const chevronIcon = () => React.createElement('svg', ic,
451
- React.createElement('path', { d: 'M6 9l6 6 6-6' }))
477
+ const chevronIcon = () => (
478
+ ChevronIcon
479
+ ? React.createElement(ChevronIcon, { className: 'dvo-chev-icon' })
480
+ : React.createElement('svg', {
481
+ className: 'dvo-chev-icon',
482
+ width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none',
483
+ },
484
+ React.createElement('path', {
485
+ d: 'M4 6l4 4 4-4', stroke: 'currentColor', strokeWidth: 1.5,
486
+ strokeLinecap: 'round', strokeLinejoin: 'round',
487
+ }))
488
+ )
489
+
490
+ function focusComposer() {
491
+ if (typeof window === 'undefined') return
492
+ const actions = voice.inputActions
493
+ if (actions && typeof actions.focus === 'function') {
494
+ try { actions.focus(); return } catch { /* best effort */ }
495
+ }
496
+ try {
497
+ const el = document.querySelector('.conversation-input textarea, .cb-composer textarea, .dsh-composer textarea, [data-slate-editor="true"], textarea')
498
+ if (el && typeof el.focus === 'function') el.focus()
499
+ } catch { /* best effort */ }
500
+ }
501
+
452
502
 
453
503
  // ------------------------------------------------------------ transport
454
504
  function blobToBase64(blob) {
@@ -466,6 +516,8 @@ window.__ModuleLoader__.load({
466
516
  const text = (voice.input && typeof voice.input.draft === 'string') ? voice.input.draft : ''
467
517
  if (!text || text.length < 3) return []
468
518
  const matches = text.match(/[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{2,29}/g) || []
519
+ // Linguistic filter: functional stop-word dictionary for vocabulary keyword extraction.
520
+ // Language processing data for context analysis, distinct from user-facing UI text.
469
521
  const stop = new Set([
470
522
  'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'any', 'can', 'her', 'was',
471
523
  'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now',
@@ -491,11 +543,24 @@ window.__ModuleLoader__.load({
491
543
  const payload = { dataBase64, mimeType: mime, mode }
492
544
  const contextWords = extractContextKeywords()
493
545
  if (contextWords && contextWords.length > 0) payload.contextWords = contextWords
494
- const res = await fetch('/dsh-voice/transcribe', {
495
- method: 'POST',
496
- headers: { 'content-type': 'application/json' },
497
- body: JSON.stringify(payload),
498
- })
546
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
547
+ const timer = controller ? setTimeout(() => controller.abort(), 60000) : null
548
+ let res = null
549
+ try {
550
+ res = await fetch('/dsh-voice/transcribe', {
551
+ method: 'POST',
552
+ headers: { 'content-type': 'application/json' },
553
+ body: JSON.stringify(payload),
554
+ signal: controller ? controller.signal : undefined,
555
+ })
556
+ } catch (e) {
557
+ if (e && e.name === 'AbortError') {
558
+ throw new Error('Transcription request timed out (60s)')
559
+ }
560
+ throw e
561
+ } finally {
562
+ if (timer) clearTimeout(timer)
563
+ }
499
564
  let parsed = null
500
565
  try { parsed = await res.json() } catch (e) { /* not json */ }
501
566
  if (!res.ok || !parsed || !parsed.ok) {
@@ -513,8 +578,8 @@ window.__ModuleLoader__.load({
513
578
  return s
514
579
  }
515
580
 
516
- // Spoken edit commands (#37): "new line" / новой строки" -> \n and so on.
517
- // Applied before normalization, only when enabled in settings.
581
+ // Functional speech edit commands (#37): spoken line breaks and punctuation -> symbols.
582
+ // Linguistic data for audio transcript post-processing; not user-facing UI text.
518
583
  // Russian phrases match RU STT output; English covers EN dictation.
519
584
  const VOICE_COMMANDS = [
520
585
  [/(^|[\s,.!?])с новой строки([\s,.!?]|$)/gi, '$1\n$2'],
@@ -545,7 +610,8 @@ window.__ModuleLoader__.load({
545
610
  // Draft was edited manually — cut the last insert as a substring.
546
611
  const i = draft.lastIndexOf(last.added)
547
612
  if (i < 0) { insertHistory.push(last); return t('nothingToUndo') }
548
- actions.setDraft((draft.slice(0, i) + draft.slice(i + last.added.length)).replace(/\s+$/, ''))
613
+ const spliced = draft.slice(0, i) + draft.slice(i + last.added.length)
614
+ actions.setDraft(spliced.replace(/[ \t]{2,}/g, ' ').replace(/\s+$/, '').trimStart())
549
615
  }
550
616
  return t('undone')
551
617
  }
@@ -717,6 +783,17 @@ window.__ModuleLoader__.load({
717
783
  if (rec.audioCtx) { try { rec.audioCtx.close() } catch (e) { /* already closed */ } }
718
784
  }
719
785
 
786
+ if (typeof window !== 'undefined') {
787
+ const onPageUnload = () => {
788
+ if (voice.rec) teardown(voice.rec)
789
+ if (voice.browser) {
790
+ try { voice.browser.abort() } catch (e) { /* ignore */ }
791
+ }
792
+ }
793
+ window.addEventListener('pagehide', onPageUnload)
794
+ window.addEventListener('beforeunload', onPageUnload)
795
+ }
796
+
720
797
  function waitStop(recorder) {
721
798
  if (!recorder || recorder.state === 'inactive') return Promise.resolve()
722
799
  return new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
@@ -765,7 +842,7 @@ window.__ModuleLoader__.load({
765
842
  src.connect(filter)
766
843
  lastNode = filter
767
844
  rec.filterNode = filter
768
- } catch (e) {}
845
+ } catch (e) { /* audio filter fallback */ }
769
846
 
770
847
  rec.analyser = rec.audioCtx.createAnalyser()
771
848
  rec.analyser.fftSize = 128
@@ -815,16 +892,18 @@ window.__ModuleLoader__.load({
815
892
  rec.silenceMs = 0
816
893
  rec.hadSpeech = false
817
894
  rec.streamMs = 0
818
- if (!rec.closing) {
895
+ if (!rec.closing && !rec.cancelled) {
819
896
  try {
820
897
  if (rec.recorder && rec.recorder.state === 'inactive') rec.recorder.start()
821
898
  } catch (e) { /* stream already closed */ }
822
899
  }
823
900
  rec.cutting = false
824
- if (blob.size < 600) return // too short — not speech
901
+ if (rec.cancelled || blob.size < 600) return // cancelled or too short — not speech
825
902
  dictationQueue = dictationQueue.then(async () => {
903
+ if (rec.cancelled) return
826
904
  try {
827
905
  const out = await sendAudio(blob, rec.mime, 'dictation')
906
+ if (rec.cancelled) return
828
907
  const text = out && out.text ? out.text : ''
829
908
  const delay = Number(voice.settings.sendDelayMs) || 0
830
909
  if (text && delay > 0 && !voice.holding) {
@@ -836,7 +915,9 @@ window.__ModuleLoader__.load({
836
915
  }
837
916
  if (text) appendDraft(text)
838
917
  } catch (e) {
839
- voice.set({ error: String(e && e.message ? e.message : e) })
918
+ if (!rec.cancelled) {
919
+ voice.set({ error: String(e && e.message ? e.message : e) })
920
+ }
840
921
  }
841
922
  })
842
923
  })
@@ -987,6 +1068,7 @@ window.__ModuleLoader__.load({
987
1068
 
988
1069
  function cancelCurrent() {
989
1070
  announceVoice('end')
1071
+ focusComposer()
990
1072
  if (voice.browser) {
991
1073
  voice.browser.abort()
992
1074
  voice.browser = null
@@ -997,6 +1079,7 @@ window.__ModuleLoader__.load({
997
1079
  const rec = voice.rec
998
1080
  voice.pending = null
999
1081
  if (!rec) { voice.set({ phase: 'idle', error: '' }); return }
1082
+ rec.cancelled = true
1000
1083
  rec.closing = true
1001
1084
  const stopped = waitStop(rec.recorder)
1002
1085
  try {
@@ -1009,6 +1092,7 @@ window.__ModuleLoader__.load({
1009
1092
  // sends the whole recording and opens the cancel window.
1010
1093
  function stopCurrent() {
1011
1094
  announceVoice('end')
1095
+ focusComposer()
1012
1096
  if (voice.browser) {
1013
1097
  const mode = voice.mode
1014
1098
  const said = (voice.browserFinals || []).join(' ').trim()
@@ -1075,14 +1159,21 @@ window.__ModuleLoader__.load({
1075
1159
  try {
1076
1160
  const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
1077
1161
  if (draft.trim()) {
1078
- const res = await fetch('/dsh-voice/polish', {
1079
- method: 'POST',
1080
- headers: { 'content-type': 'application/json' },
1081
- body: JSON.stringify({ text: draft }),
1082
- })
1083
- const parsed = await res.json().catch(() => null)
1084
- if (parsed && parsed.ok && typeof parsed.text === 'string' && parsed.text.trim()) {
1085
- actions.setDraft(parsed.text.trim())
1162
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
1163
+ const timer = controller ? setTimeout(() => controller.abort(), 10000) : null
1164
+ try {
1165
+ const res = await fetch('/dsh-voice/polish', {
1166
+ method: 'POST',
1167
+ headers: { 'content-type': 'application/json' },
1168
+ body: JSON.stringify({ text: draft }),
1169
+ signal: controller ? controller.signal : undefined,
1170
+ })
1171
+ const parsed = await res.json().catch(() => null)
1172
+ if (parsed && parsed.ok && typeof parsed.text === 'string' && parsed.text.trim()) {
1173
+ actions.setDraft(parsed.text.trim())
1174
+ }
1175
+ } finally {
1176
+ if (timer) clearTimeout(timer)
1086
1177
  }
1087
1178
  }
1088
1179
  } catch (e) { /* polish is best-effort */ }
@@ -1127,11 +1218,14 @@ window.__ModuleLoader__.load({
1127
1218
  return React.createElement(React.Fragment, null,
1128
1219
  React.createElement('button', {
1129
1220
  type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
1130
- title: err ? v.error : t('dictationBtn'), onClick: startDictation,
1221
+ title: err ? v.error : t('dictationBtn'),
1222
+ 'aria-label': err ? v.error : t('dictationBtn'),
1223
+ onClick: startDictation,
1131
1224
  }, micIcon()),
1132
1225
  React.createElement('button', {
1133
1226
  type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
1134
1227
  title: err ? v.error : (t('messageBtn') + hk),
1228
+ 'aria-label': err ? v.error : (t('messageBtn') + hk),
1135
1229
  // Hold: record while pressed; leaving the control cancels.
1136
1230
  onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
1137
1231
  onPointerUp: () => endHold(false),
@@ -1141,6 +1235,7 @@ window.__ModuleLoader__.load({
1141
1235
  ? React.createElement('button', {
1142
1236
  type: 'button', className: 'dvo-btn' + (voice.showPlayer ? ' dvo-btn-active' : ''),
1143
1237
  title: t('listenBack'),
1238
+ 'aria-label': t('listenBack'),
1144
1239
  onClick: () => voice.set({ showPlayer: !voice.showPlayer }),
1145
1240
  }, playIcon())
1146
1241
  : null,
@@ -1148,22 +1243,35 @@ window.__ModuleLoader__.load({
1148
1243
  }
1149
1244
 
1150
1245
  // ----------------------------------------------------------- visualizers
1246
+ let _accentColorCache = { color: '', expires: 0 }
1247
+ let _softColorCache = { color: '', expires: 0 }
1248
+
1151
1249
  function accentColor(fallback) {
1250
+ const now = Date.now()
1251
+ if (now < _accentColorCache.expires && _accentColorCache.color) return _accentColorCache.color
1152
1252
  try {
1153
1253
  const root = document.documentElement
1154
1254
  const s = getComputedStyle(root)
1155
1255
  const pick = s.getPropertyValue('--dsw-alias-state-info-primary').trim()
1156
1256
  || s.getPropertyValue('--dsw-alias-label-primary').trim()
1157
- if (pick) return pick
1257
+ if (pick) {
1258
+ _accentColorCache = { color: pick, expires: now + 1000 }
1259
+ return pick
1260
+ }
1158
1261
  } catch (noTheme) { /* canvas-only fallback */ }
1159
1262
  return fallback || voice.waveColor || 'currentColor'
1160
1263
  }
1161
1264
  function softColor(fallback) {
1265
+ const now = Date.now()
1266
+ if (now < _softColorCache.expires && _softColorCache.color) return _softColorCache.color
1162
1267
  try {
1163
1268
  const s = getComputedStyle(document.documentElement)
1164
1269
  const pick = s.getPropertyValue('--dsw-alias-bg-layer-3').trim()
1165
1270
  || s.getPropertyValue('--dsw-alias-label-primary').trim()
1166
- if (pick) return pick
1271
+ if (pick) {
1272
+ _softColorCache = { color: pick, expires: now + 1000 }
1273
+ return pick
1274
+ }
1167
1275
  } catch (noTheme) { /* fallback */ }
1168
1276
  return fallback || voice.waveColor || 'currentColor'
1169
1277
  }
@@ -1349,7 +1457,7 @@ window.__ModuleLoader__.load({
1349
1457
  g.clearRect(0, 0, cssW, cssH)
1350
1458
 
1351
1459
  if (!voice.waveColor) {
1352
- try { voice.waveColor = getComputedStyle(canvas).color || '#fff' } catch (e) { voice.waveColor = '#fff' }
1460
+ try { voice.waveColor = getComputedStyle(canvas).color || 'currentColor' } catch (e) { voice.waveColor = 'currentColor' }
1353
1461
  }
1354
1462
  const levels = voice.levels
1355
1463
  const style = (voice.settings && voice.settings.visualizerStyle) || 'liquid-wave'
@@ -1407,11 +1515,12 @@ window.__ModuleLoader__.load({
1407
1515
 
1408
1516
  if (v.phase === 'idle') {
1409
1517
  if (!voice.showPlayer || !voice.lastNote || !voice.lastNote.url) return null
1410
- return React.createElement('div', { className: 'dvo-pill' },
1518
+ return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
1411
1519
  React.createElement('div', { className: 'dvo-audio-wrap' },
1412
1520
  React.createElement('button', {
1413
1521
  type: 'button', className: 'dvo-audio-play',
1414
1522
  title: isPlaying ? t('pause') : t('play'),
1523
+ 'aria-label': isPlaying ? t('pause') : t('play'),
1415
1524
  onClick: () => {
1416
1525
  const el = audioRef.current
1417
1526
  if (!el) return
@@ -1427,13 +1536,15 @@ window.__ModuleLoader__.load({
1427
1536
  }),
1428
1537
  React.createElement('span', { className: 'dvo-audio-time' }, t('lastRecording')),
1429
1538
  ),
1430
- React.createElement('span', { className: 'dvo-status' }, voice.lastNote.text || ''),
1539
+ React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, voice.lastNote.text || ''),
1431
1540
  React.createElement('button', {
1432
1541
  type: 'button', className: 'dvo-pbtn', title: t('hide'),
1542
+ 'aria-label': t('hide'),
1433
1543
  onClick: () => {
1434
1544
  if (audioRef.current) audioRef.current.pause()
1435
1545
  setIsPlaying(false)
1436
1546
  voice.set({ showPlayer: false })
1547
+ focusComposer()
1437
1548
  },
1438
1549
  }, xIcon()),
1439
1550
  )
@@ -1446,12 +1557,16 @@ window.__ModuleLoader__.load({
1446
1557
  // Live caption of what is heard right now. Until the browser emits a
1447
1558
  // final chunk the text is interim and changes on screen.
1448
1559
  const caption = voice.caption || (inBrowser ? '' : null)
1449
- return React.createElement('div', { className: 'dvo-pill' },
1450
- React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('cancel'), onClick: cancelCurrent }, xIcon()),
1560
+ return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
1561
+ React.createElement('button', {
1562
+ type: 'button', className: 'dvo-pbtn', title: t('cancel'),
1563
+ 'aria-label': t('cancel'),
1564
+ onClick: cancelCurrent,
1565
+ }, xIcon()),
1451
1566
  inBrowser
1452
1567
  ? null
1453
1568
  : React.createElement('canvas', { className: 'dvo-wave', ref: canvasRef, width: 720, height: 40 }),
1454
- React.createElement('span', { className: 'dvo-status' },
1569
+ React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' },
1455
1570
  caption
1456
1571
  ? caption
1457
1572
  : (voice.holding
@@ -1459,36 +1574,43 @@ window.__ModuleLoader__.load({
1459
1574
  : (inBrowser
1460
1575
  ? t('listening')
1461
1576
  : (rec && rec.hadSpeech ? (v.speaking ? t('speaking') : t('silence')) : hint)))),
1462
- React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('stop'), onClick: stopCurrent }, stopIcon()),
1577
+ React.createElement('button', {
1578
+ type: 'button', className: 'dvo-pbtn', title: t('stop'),
1579
+ 'aria-label': t('stop'),
1580
+ onClick: stopCurrent,
1581
+ }, stopIcon()),
1463
1582
  )
1464
1583
  }
1465
1584
 
1466
1585
  if (v.phase === 'processing') {
1467
- return React.createElement('div', { className: 'dvo-pill' },
1468
- React.createElement('span', { className: 'dvo-status' }, spinIcon(), t('transcribing')))
1586
+ return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
1587
+ React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, spinIcon(), t('transcribing')))
1469
1588
  }
1470
1589
 
1471
1590
  if (v.phase === 'pending') {
1472
1591
  const left = Math.max(0, Math.ceil((voice.pending ? voice.pending.leftMs : 0) / 1000))
1473
1592
  if (voice.pending && voice.pending.undoOnly) {
1474
1593
  // Cancel window for delayed dictation insert (#29-5).
1475
- return React.createElement('div', { className: 'dvo-pill' },
1476
- React.createElement('span', { className: 'dvo-status' }, t('undo'), ': ', left, t('secondsShort')),
1594
+ return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
1595
+ React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, t('undo'), ': ', left, t('secondsShort')),
1477
1596
  React.createElement('button', {
1478
1597
  type: 'button', className: 'dvo-pbtn', title: t('undo'),
1598
+ 'aria-label': t('undo'),
1479
1599
  onClick: async () => {
1480
1600
  const msg = await undoLastInsert()
1481
1601
  voice.set({ phase: 'idle', error: msg === t('undone') ? '' : msg })
1602
+ focusComposer()
1482
1603
  },
1483
1604
  }, xIcon()),
1484
1605
  )
1485
1606
  }
1486
- return React.createElement('div', { className: 'dvo-pill' },
1607
+ return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
1487
1608
  voice.lastNote && voice.lastNote.url
1488
1609
  ? React.createElement('div', { className: 'dvo-audio-wrap' },
1489
1610
  React.createElement('button', {
1490
1611
  type: 'button', className: 'dvo-audio-play',
1491
1612
  title: isPlaying ? t('pause') : t('play'),
1613
+ 'aria-label': isPlaying ? t('pause') : t('play'),
1492
1614
  onClick: () => {
1493
1615
  const el = audioRef.current
1494
1616
  if (!el) return
@@ -1505,15 +1627,29 @@ window.__ModuleLoader__.load({
1505
1627
  React.createElement('span', { className: 'dvo-audio-time' }, t('listenBack')),
1506
1628
  )
1507
1629
  : null,
1508
- React.createElement('span', { className: 'dvo-status' }, t('sendingIn')),
1630
+ React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, t('sendingIn')),
1509
1631
  React.createElement('span', { className: 'dvo-count' }, left + t('secondsShort')),
1510
- React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('keepPending'), onClick: keepPending }, xIcon()),
1632
+ React.createElement('button', {
1633
+ type: 'button', className: 'dvo-pbtn', title: t('keepPending'),
1634
+ 'aria-label': t('keepPending'),
1635
+ onClick: () => {
1636
+ keepPending()
1637
+ focusComposer()
1638
+ },
1639
+ }, xIcon()),
1511
1640
  )
1512
1641
  }
1513
1642
 
1514
- return React.createElement('div', { className: 'dvo-pill' },
1515
- React.createElement('span', { className: 'dvo-status dvo-err' }, warnIcon(), v.error),
1516
- React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('hide'), onClick: () => voice.set({ phase: 'idle', error: '' }) }, xIcon()),
1643
+ return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
1644
+ React.createElement('span', { className: 'dvo-status dvo-err', 'aria-live': 'polite', 'aria-atomic': 'true' }, warnIcon(), v.error),
1645
+ React.createElement('button', {
1646
+ type: 'button', className: 'dvo-pbtn', title: t('hide'),
1647
+ 'aria-label': t('hide'),
1648
+ onClick: () => {
1649
+ voice.set({ phase: 'idle', error: '' })
1650
+ focusComposer()
1651
+ },
1652
+ }, xIcon()),
1517
1653
  )
1518
1654
  }
1519
1655
 
@@ -1616,16 +1752,16 @@ window.__ModuleLoader__.load({
1616
1752
  '.cb-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}' +
1617
1753
  '.cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:12px}' +
1618
1754
  '.cb-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
1619
- '.cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:rgba(16,185,129,0.08)}' +
1620
- '.cb-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08)}' +
1621
- '.cb-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08)}' +
1755
+ '.cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}' +
1756
+ '.cb-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}' +
1757
+ '.cb-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}' +
1622
1758
  '.cb-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
1623
1759
  '.cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
1624
1760
  '.cb-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 12px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}' +
1625
1761
  '.cb-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}' +
1626
1762
  '.cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}' +
1627
1763
  '.cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88}' +
1628
- '.cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:rgba(239,68,68,0.3)}' +
1764
+ '.cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}' +
1629
1765
  '.cb-btn-mini{border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);border-radius:6px;width:28px;height:28px;cursor:pointer;flex:none;display:inline-flex;align-items:center;justify-content:center;padding:0;font-size:13px}' +
1630
1766
  '.cb-btn-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}' +
1631
1767
  '.cb-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
@@ -1633,7 +1769,7 @@ window.__ModuleLoader__.load({
1633
1769
  '.cb-field textarea{height:auto;padding:8px 10px;font-family:inherit}' +
1634
1770
  '.cb-check-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary);user-select:none}' +
1635
1771
  '.cb-check-label input[type="checkbox"]{width:16px;height:16px;cursor:pointer}' +
1636
- '.cb-alert-err{padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:13px}' +
1772
+ '.cb-alert-err{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px}' +
1637
1773
  '.dvs-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0;max-width:960px}' +
1638
1774
  '.dvs-block{display:flex;flex-direction:column;gap:10px}' +
1639
1775
  '.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
@@ -1864,26 +2000,27 @@ window.__ModuleLoader__.load({
1864
2000
 
1865
2001
  React.useEffect(() => {
1866
2002
  if (!ready) return
2003
+ const val = value || {}
1867
2004
  voice.settings = Object.assign({}, voice.settings, {
1868
- vadSilenceMs: Number(value && value.dictation && value.dictation.vadSilenceMs) || 700,
1869
- autoSendMs: Number(value && value.message && value.message.autoSendMs) || 4000,
1870
- beep: !!(value && value.beep),
1871
- micDeviceId: String((value && value.micDeviceId) || ''),
1872
- historyLimit: Number(value && value.historyLimit) || 20,
1873
- voiceCommands: !!(value && value.voiceCommands),
1874
- sendDelayMs: Number(value && value.dictation && value.dictation.sendDelayMs) || 0,
1875
- stream: !!(value && value.dictation && value.dictation.stream),
1876
- streamChunkMs: Number(value && value.dictation && value.dictation.streamChunkMs) || 1200,
1877
- vadAdapt: Number(value && value.dictation && value.dictation.vadAdapt) || 0,
1878
- wakeWord: String((value && value.wakeWord) || ''),
1879
- bargeIn: !!(value && value.bargeIn),
1880
- polishSend: !!(value && value.message && value.message.polishSend),
1881
- sessionCommands: !!(value && value.message && value.message.sessionCommands),
1882
- polishBaseUrl: String((value && value.polishBaseUrl) || ''),
1883
- noiseSuppression: value.noiseSuppression !== false,
1884
- noiseGateDb: Number((value && value.noiseGateDb) !== undefined ? value.noiseGateDb : -45),
1885
- contextGlossary: value.contextGlossary !== false,
1886
- visualizerStyle: (value && value.visualizerStyle) || 'liquid-wave',
2005
+ vadSilenceMs: Number(val.dictation && val.dictation.vadSilenceMs) || 700,
2006
+ autoSendMs: Number(val.message && val.message.autoSendMs) || 4000,
2007
+ beep: !!val.beep,
2008
+ micDeviceId: String(val.micDeviceId || ''),
2009
+ historyLimit: Number(val.historyLimit) || 20,
2010
+ voiceCommands: !!val.voiceCommands,
2011
+ sendDelayMs: Number(val.dictation && val.dictation.sendDelayMs) || 0,
2012
+ stream: !!(val.dictation && val.dictation.stream),
2013
+ streamChunkMs: Number(val.dictation && val.dictation.streamChunkMs) || 1200,
2014
+ vadAdapt: Number(val.dictation && val.dictation.vadAdapt) || 0,
2015
+ wakeWord: String(val.wakeWord || ''),
2016
+ bargeIn: !!val.bargeIn,
2017
+ polishSend: !!(val.message && val.message.polishSend),
2018
+ sessionCommands: !!(val.message && val.message.sessionCommands),
2019
+ polishBaseUrl: String(val.polishBaseUrl || ''),
2020
+ noiseSuppression: val.noiseSuppression !== false,
2021
+ noiseGateDb: Number(val.noiseGateDb !== undefined ? val.noiseGateDb : -45),
2022
+ contextGlossary: val.contextGlossary !== false,
2023
+ visualizerStyle: val.visualizerStyle || 'liquid-wave',
1887
2024
  })
1888
2025
  }, [ready, value])
1889
2026
 
@@ -1896,8 +2033,8 @@ window.__ModuleLoader__.load({
1896
2033
  if (testingMic) {
1897
2034
  if (testRef.current) {
1898
2035
  testRef.current.active = false
1899
- try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) {}
1900
- if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) {} }
2036
+ try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
2037
+ if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) { /* already closed */ } }
1901
2038
  testRef.current = null
1902
2039
  }
1903
2040
  setTestingMic(false)
@@ -1922,7 +2059,7 @@ window.__ModuleLoader__.load({
1922
2059
  filter.Q.value = 0.707
1923
2060
  src.connect(filter)
1924
2061
  lastNode = filter
1925
- } catch (e) {}
2062
+ } catch (e) { /* filter fallback */ }
1926
2063
  const analyser = audioCtx.createAnalyser()
1927
2064
  analyser.fftSize = 128
1928
2065
  lastNode.connect(analyser)
@@ -1931,12 +2068,12 @@ window.__ModuleLoader__.load({
1931
2068
 
1932
2069
  const check = () => {
1933
2070
  if (!testRef.current || !testRef.current.active) return
1934
- const data = new Uint8Array(analyser.frequencyBinCount)
1935
- analyser.getByteFrequencyData(data)
2071
+ const arr = new Uint8Array(analyser.frequencyBinCount)
2072
+ analyser.getByteFrequencyData(arr)
1936
2073
  let sum = 0
1937
- for (let i = 0; i < data.length; i++) sum += data[i]
1938
- const lvl = Math.min(100, Math.round((sum / data.length / 255) * 220))
1939
- setTestLevel(lvl)
2074
+ for (let i = 0; i < arr.length; i++) sum += arr[i]
2075
+ const avg = sum / (arr.length || 1)
2076
+ setTestLevel(Math.min(100, Math.round((avg / 128) * 100)))
1940
2077
  requestAnimationFrame(check)
1941
2078
  }
1942
2079
  requestAnimationFrame(check)
@@ -1949,8 +2086,8 @@ window.__ModuleLoader__.load({
1949
2086
  return () => {
1950
2087
  if (testRef.current) {
1951
2088
  testRef.current.active = false
1952
- try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) {}
1953
- if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) {} }
2089
+ try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
2090
+ if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) { /* already closed */ } }
1954
2091
  testRef.current = null
1955
2092
  }
1956
2093
  }
@@ -1981,6 +2118,55 @@ window.__ModuleLoader__.load({
1981
2118
  fetchStatus()
1982
2119
  }, [fetchStatus])
1983
2120
 
2121
+ const [updaterStatus, setUpdaterStatus] = React.useState(null)
2122
+ const [updating, setUpdating] = React.useState(false)
2123
+ const [updaterMsg, setUpdaterMsg] = React.useState('')
2124
+ const [updaterLoading, setUpdaterLoading] = React.useState(false)
2125
+
2126
+ const checkUpdater = React.useCallback(async () => {
2127
+ setUpdaterLoading(true)
2128
+ setUpdaterMsg('')
2129
+ try {
2130
+ const res = await fetch('/api/dsh-voice/update', {
2131
+ headers: { accept: 'application/json' },
2132
+ cache: 'no-store',
2133
+ })
2134
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
2135
+ const data = await res.json()
2136
+ setUpdaterStatus(data)
2137
+ } catch (e) {
2138
+ /* best effort update check */
2139
+ } finally {
2140
+ setUpdaterLoading(false)
2141
+ }
2142
+ }, [])
2143
+
2144
+ const onUpdateNow = async () => {
2145
+ setUpdating(true)
2146
+ setUpdaterMsg('')
2147
+ try {
2148
+ const res = await fetch('/api/dsh-voice/update', {
2149
+ method: 'POST',
2150
+ headers: {
2151
+ 'x-dsh-plugin-update': '1',
2152
+ 'content-type': 'application/json',
2153
+ },
2154
+ })
2155
+ const data = await res.json()
2156
+ if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`)
2157
+ setUpdaterStatus(data)
2158
+ setUpdaterMsg(t('updateSuccess'))
2159
+ } catch (e) {
2160
+ setUpdaterMsg(t('updateFailed') + ' ' + (e?.message || String(e)))
2161
+ } finally {
2162
+ setUpdating(false)
2163
+ }
2164
+ }
2165
+
2166
+ React.useEffect(() => {
2167
+ checkUpdater()
2168
+ }, [checkUpdater])
2169
+
1984
2170
  if (!ready) {
1985
2171
  const waiting = !snap || snap.status === 'loading'
1986
2172
  return React.createElement('div', { className: 'cb-page' },
@@ -2012,21 +2198,22 @@ window.__ModuleLoader__.load({
2012
2198
  }
2013
2199
  }
2014
2200
 
2201
+ const d = draft || {}
2015
2202
  Object.assign(voice.settings, {
2016
- vadSilenceMs: Number(draft.dictation && draft.dictation.vadSilenceMs) || 700,
2017
- autoSendMs: Number(draft.message && draft.message.autoSendMs) || 4000,
2018
- beep: !!draft.beep,
2019
- micDeviceId: String(draft.micDeviceId || ''),
2020
- historyLimit: Number(draft.historyLimit),
2021
- voiceCommands: !!draft.voiceCommands,
2022
- sendDelayMs: Number(draft.dictation && draft.dictation.sendDelayMs) || 0,
2023
- stream: !!(draft.dictation && draft.dictation.stream),
2024
- streamChunkMs: Number(draft.dictation && draft.dictation.streamChunkMs) || 1200,
2025
- vadAdapt: Number(draft.dictation && draft.dictation.vadAdapt) || 0,
2026
- noiseSuppression: draft.noiseSuppression !== false,
2027
- noiseGateDb: Number(draft.noiseGateDb !== undefined ? draft.noiseGateDb : -45),
2028
- contextGlossary: draft.contextGlossary !== false,
2029
- visualizerStyle: draft.visualizerStyle || 'liquid-wave',
2203
+ vadSilenceMs: Number(d.dictation && d.dictation.vadSilenceMs) || 700,
2204
+ autoSendMs: Number(d.message && d.message.autoSendMs) || 4000,
2205
+ beep: !!d.beep,
2206
+ micDeviceId: String(d.micDeviceId || ''),
2207
+ historyLimit: Number(d.historyLimit),
2208
+ voiceCommands: !!d.voiceCommands,
2209
+ sendDelayMs: Number(d.dictation && d.dictation.sendDelayMs) || 0,
2210
+ stream: !!(d.dictation && d.dictation.stream),
2211
+ streamChunkMs: Number(d.dictation && d.dictation.streamChunkMs) || 1200,
2212
+ vadAdapt: Number(d.dictation && d.dictation.vadAdapt) || 0,
2213
+ noiseSuppression: d.noiseSuppression !== false,
2214
+ noiseGateDb: Number(d.noiseGateDb !== undefined ? d.noiseGateDb : -45),
2215
+ contextGlossary: d.contextGlossary !== false,
2216
+ visualizerStyle: d.visualizerStyle || 'liquid-wave',
2030
2217
  })
2031
2218
  try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
2032
2219
 
@@ -2061,7 +2248,7 @@ window.__ModuleLoader__.load({
2061
2248
  React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
2062
2249
  )
2063
2250
 
2064
- const gateDbVal = Number((draft && draft.noiseGateDb) !== undefined ? draft.noiseGateDb : (value && value.noiseGateDb) !== undefined ? value.noiseGateDb : -45)
2251
+ const gateDbVal = Number((draft && draft.noiseGateDb !== undefined) ? draft.noiseGateDb : (value && value.noiseGateDb !== undefined) ? value.noiseGateDb : -45)
2065
2252
  const gateThresholdPercent = gateDbVal <= -90 ? 0 : Math.min(100, Math.max(0, Math.round(((gateDbVal + 60) / 40) * 100)))
2066
2253
 
2067
2254
  const micField = () => React.createElement('div', { className: 'cb-field' },
@@ -2409,6 +2596,47 @@ window.__ModuleLoader__.load({
2409
2596
  ),
2410
2597
  ),
2411
2598
 
2599
+ // 7. Plugin Version & Updates Card
2600
+ React.createElement('div', { className: 'cb-section-card' },
2601
+ React.createElement('div', { className: 'cb-section-title' },
2602
+ React.createElement('span', null, '📦 ' + t('versionTitle')),
2603
+ React.createElement('button', {
2604
+ type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
2605
+ onClick: checkUpdater, disabled: updaterLoading || updating,
2606
+ }, updaterLoading ? '⏳…' : '🔄 ' + t('refreshStats')),
2607
+ ),
2608
+ React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
2609
+ React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
2610
+ React.createElement('span', { className: 'cb-badge cb-badge-ok' },
2611
+ 'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.8.29')
2612
+ ),
2613
+ updaterStatus && updaterStatus.updateAvailable
2614
+ ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
2615
+ t('updateAvailable') + ': v' + updaterStatus.latestVersion
2616
+ )
2617
+ : React.createElement('span', { className: 'cb-badge cb-badge-ok' },
2618
+ t('upToDate')
2619
+ ),
2620
+ updaterStatus && updaterStatus.updateAvailable
2621
+ ? React.createElement('button', {
2622
+ type: 'button', className: 'cb-btn cb-btn-primary', style: { fontSize: '12px', padding: '4px 10px' },
2623
+ onClick: onUpdateNow, disabled: updating,
2624
+ }, updating ? t('updating') : t('updateNow'))
2625
+ : null,
2626
+ ),
2627
+ updaterMsg
2628
+ ? React.createElement('div', {
2629
+ style: {
2630
+ marginTop: '8px',
2631
+ fontSize: '12px',
2632
+ color: updaterMsg.includes('fail') || updaterMsg.includes('失败')
2633
+ ? 'var(--dsw-alias-state-warning-primary)'
2634
+ : 'var(--dsw-alias-state-success-primary)',
2635
+ },
2636
+ }, updaterMsg)
2637
+ : null,
2638
+ ),
2639
+
2412
2640
  // Action bar (Save)
2413
2641
  React.createElement('div', { className: 'cb-row' },
2414
2642
  React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable, onClick: save }, t('save')),