@goodandready/dsh-voice 0.9.32 → 0.9.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -279,6 +279,49 @@ window.__ModuleLoader__.load({
279
279
  'assistantSpeakingGated': 'Assistant speaking (gated mode)',
280
280
  'jargonFormatHint': ' (spoken -> replacement)',
281
281
  'errorPrefix': 'Error',
282
+ 'wizardTitle': 'First-Run Setup & Diagnostics Wizard',
283
+ 'wizardDesc': 'Step-by-step diagnostic test: check secure context, microphone permissions, audio level, and STT round-trip.',
284
+ 'wizardBtnLaunch': 'Launch Mic & STT Wizard',
285
+ 'wizardClose': 'Close Wizard',
286
+ 'wizardStep1': '1. Audio & Environment',
287
+ 'wizardStep2': '2. Live Signal Test',
288
+ 'wizardStep3': '3. STT Transcription Test',
289
+ 'wizardSecureContextOk': 'Secure context / HTTPS verified',
290
+ 'wizardSecureContextBad': 'Insecure context (HTTPS or localhost required)',
291
+ 'wizardMediaDevicesOk': 'Browser mediaDevices API supported',
292
+ 'wizardMediaDevicesBad': 'Browser mediaDevices API missing',
293
+ 'wizardMediaRecorderOk': 'MediaRecorder API supported',
294
+ 'wizardMediaRecorderBad': 'MediaRecorder API unsupported in this browser',
295
+ 'wizardMicPermissionOk': 'Microphone permission granted',
296
+ 'wizardMicPermissionPrompt': 'Microphone permission not yet granted',
297
+ 'wizardMicPermissionBad': 'Microphone permission denied',
298
+ 'wizardRequestMic': 'Request Microphone Access',
299
+ 'wizardMicLabel': 'Microphone input device:',
300
+ 'wizardStartSignalCheck': 'Start Audio Level Test',
301
+ 'wizardStopSignalCheck': 'Stop Audio Test',
302
+ 'wizardSignalOk': 'Audio signal detected (RMS: {level}%)',
303
+ 'wizardSignalZero': 'Zero audio level detected - speak into microphone or select correct device',
304
+ 'wizardSignalTesting': 'Listening for audio signal… speak into your microphone',
305
+ 'wizardProviderLabel': 'Active STT engine & model:',
306
+ 'wizardProcessingArea': 'Processing area:',
307
+ 'wizardAreaBrowser': 'In-browser (WebGPU / WebSpeech)',
308
+ 'wizardAreaServer': 'Local MiniPC server (SenseVoice / Whisper)',
309
+ 'wizardAreaCloud': 'Remote cloud provider',
310
+ 'wizardStartTestRecord': 'Record 3-Second Test Phrase',
311
+ 'wizardRecordingTest': 'Recording test phrase… speak now ({left}s)',
312
+ 'wizardTranscribingTest': 'Transcribing test phrase…',
313
+ 'wizardTestResult': 'STT Test Result:',
314
+ 'wizardTestSuccess': 'STT round-trip verified successfully! Latency: {latency}ms',
315
+ 'wizardTestEmpty': 'No speech detected in test recording. Please speak clearly and try again.',
316
+ 'wizardTestFailed': 'STT test failed: {error}',
317
+ 'wizardNextStepHint': 'Next step: {hint}',
318
+ 'wizardHintPermission': 'Click "Request Microphone Access" and allow browser permission prompt.',
319
+ 'wizardHintDevice': 'Check microphone connection or select a different input device.',
320
+ 'wizardHintHttps': 'Open DeepSeek Harness via HTTPS or localhost address.',
321
+ 'wizardHintProvider': 'Configure STT engine credentials or install SenseVoice model below.',
322
+ 'wizardFinishSuccess': 'Setup & Verification Complete',
323
+ 'wizardSkip': 'Skip Wizard',
324
+ 'wizardStatusCompleted': 'Microphone and STT verified',
282
325
  }
283
326
 
284
327
  const zh = {
@@ -526,6 +569,49 @@ window.__ModuleLoader__.load({
526
569
  'assistantSpeakingGated': '助手正在发言(门控模式)',
527
570
  'jargonFormatHint': ' (口语 -> 替换文本)',
528
571
  'errorPrefix': '错误',
572
+ 'wizardTitle': '首次启动与麦克风诊断向导',
573
+ 'wizardDesc': '分步诊断检查:安全上下文、麦克风权限、音频信号电平及实时 STT 回环测试。',
574
+ 'wizardBtnLaunch': '启动麦克风与 STT 诊断向导',
575
+ 'wizardClose': '关闭向导',
576
+ 'wizardStep1': '1. 音频与运行环境',
577
+ 'wizardStep2': '2. 实时信号测试',
578
+ 'wizardStep3': '3. STT 识别回环测试',
579
+ 'wizardSecureContextOk': '安全上下文 / HTTPS 已验证',
580
+ 'wizardSecureContextBad': '非安全上下文(需 HTTPS 或 localhost)',
581
+ 'wizardMediaDevicesOk': '浏览器 mediaDevices API 支持正常',
582
+ 'wizardMediaDevicesBad': '浏览器缺少 mediaDevices API 支持',
583
+ 'wizardMediaRecorderOk': 'MediaRecorder API 支持正常',
584
+ 'wizardMediaRecorderBad': '当前浏览器不支持 MediaRecorder API',
585
+ 'wizardMicPermissionOk': '麦克风权限已授予',
586
+ 'wizardMicPermissionPrompt': '尚未获得麦克风授权',
587
+ 'wizardMicPermissionBad': '麦克风权限被拒绝',
588
+ 'wizardRequestMic': '请求麦克风权限',
589
+ 'wizardMicLabel': '麦克风输入设备:',
590
+ 'wizardStartSignalCheck': '开始音频电平测试',
591
+ 'wizardStopSignalCheck': '停止音频测试',
592
+ 'wizardSignalOk': '检测到音频信号 (RMS: {level}%)',
593
+ 'wizardSignalZero': '未检测到声音信号 — 请对着麦克风说话或选择正确设备',
594
+ 'wizardSignalTesting': '正在监听声音信号… 请对着麦克风发声',
595
+ 'wizardProviderLabel': '当前语音引擎与模型:',
596
+ 'wizardProcessingArea': '处理区域:',
597
+ 'wizardAreaBrowser': '浏览器端处理 (WebGPU / WebSpeech)',
598
+ 'wizardAreaServer': '本地服务器处理 (SenseVoice / Whisper)',
599
+ 'wizardAreaCloud': '远程云端服务',
600
+ 'wizardStartTestRecord': '录制 3 秒测试语句',
601
+ 'wizardRecordingTest': '正在录制测试音频… 请说话 ({left}秒)',
602
+ 'wizardTranscribingTest': '正在请求语音识别…',
603
+ 'wizardTestResult': 'STT 测试结果:',
604
+ 'wizardTestSuccess': 'STT 链路验证成功!耗时:{latency}毫秒',
605
+ 'wizardTestEmpty': '录音中未检测到有效语音,请清晰发音后重试。',
606
+ 'wizardTestFailed': 'STT 测试失败:{error}',
607
+ 'wizardNextStepHint': '建议下一步:{hint}',
608
+ 'wizardHintPermission': '点击“请求麦克风权限”并在浏览器弹窗中允许访问。',
609
+ 'wizardHintDevice': '检查麦克风硬件连接或切换选择其他输入设备。',
610
+ 'wizardHintHttps': '请通过 HTTPS 或 localhost 访问 DeepSeek Harness。',
611
+ 'wizardHintProvider': '请在下方配置 STT 密钥凭据或一键安装 SenseVoice 本地模型。',
612
+ 'wizardFinishSuccess': '设置与验证已完成',
613
+ 'wizardSkip': '跳过向导',
614
+ 'wizardStatusCompleted': '麦克风与 STT 诊断已就绪',
529
615
  }
530
616
 
531
617
  // Strings are also needed outside components — in recording handlers and
@@ -3823,14 +3909,21 @@ function MacrosSection(props) {
3823
3909
  const whisperRunning = props.whisperRunning
3824
3910
  const providerCount = props.providerCount
3825
3911
  const onRefresh = props.onRefresh
3912
+ const onOpenWizard = props.onOpenWizard
3826
3913
 
3827
3914
  return React.createElement('div', { className: 'cb-section-card' },
3828
3915
  React.createElement('div', { className: 'cb-section-title' },
3829
3916
  React.createElement('span', null, t('statusTitle')),
3830
- React.createElement('button', {
3831
- type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
3832
- onClick: onRefresh,
3833
- }, '🔄 ' + t('refreshStats')),
3917
+ React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
3918
+ onOpenWizard ? React.createElement('button', {
3919
+ type: 'button', className: 'cb-btn cb-btn-primary', style: { fontSize: '12px', padding: '4px 10px' },
3920
+ onClick: onOpenWizard,
3921
+ }, '✨ ' + t('wizardBtnLaunch')) : null,
3922
+ React.createElement('button', {
3923
+ type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
3924
+ onClick: onRefresh,
3925
+ }, '🔄 ' + t('refreshStats')),
3926
+ ),
3834
3927
  ),
3835
3928
  React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px', flexWrap: 'wrap' } },
3836
3929
  React.createElement('span', { className: 'cb-badge ' + (hostOnline ? 'cb-badge-ok' : 'cb-badge-err') },
@@ -3931,6 +4024,7 @@ function MacrosSection(props) {
3931
4024
  setServerConfig(data.config)
3932
4025
  if (typeof data.revision === 'number') setRevision(data.revision)
3933
4026
  setDraft((curr) => curr || deepClone(data.config))
4027
+ if (data.config.wizardCompleted !== true) setShowWizard(true)
3934
4028
  }
3935
4029
  })
3936
4030
  .catch(() => {})
@@ -3942,6 +4036,7 @@ function MacrosSection(props) {
3942
4036
  // Hotkey picker: not a name field — press the key you want.
3943
4037
  const [catching, setCatching] = React.useState(false)
3944
4038
  const [jargonInput, setJargonInput] = React.useState(null)
4039
+ const [showWizard, setShowWizard] = React.useState(false)
3945
4040
  React.useEffect(() => {
3946
4041
  if (!catching) return undefined
3947
4042
  const onKey = (event) => {
@@ -4219,7 +4314,15 @@ function MacrosSection(props) {
4219
4314
  whisperRunning: whisperRunning,
4220
4315
  providerCount: providerCount,
4221
4316
  onRefresh: fetchStatus,
4317
+ onOpenWizard: () => setShowWizard((v) => !v),
4222
4318
  }),
4319
+ showWizard ? React.createElement(FirstRunWizard, {
4320
+ t: t,
4321
+ draft: draft,
4322
+ setTop: setTop,
4323
+ writable: writable,
4324
+ onClose: () => setShowWizard(false),
4325
+ }) : null,
4223
4326
  React.createElement('div', { className: 'cb-section-card' },
4224
4327
  React.createElement('div', { className: 'cb-section-title' },
4225
4328
  React.createElement('span', null, '🎙️ ' + t('dictationBtn')),
@@ -4451,6 +4554,395 @@ function MacrosSection(props) {
4451
4554
  ),
4452
4555
  )
4453
4556
  }
4557
+ // ------------------------------------------------------- first-run wizard (#265)
4558
+ function FirstRunWizard(props) {
4559
+ const t = props.t || moduleT
4560
+ const draft = props.draft
4561
+ const setTop = props.setTop
4562
+ const writable = props.writable
4563
+ const onClose = props.onClose
4564
+
4565
+ const isSecure = typeof window !== 'undefined' ? (window.isSecureContext || window.location.protocol === 'https:' || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') : true
4566
+ const hasMediaDevices = typeof navigator !== 'undefined' && !!navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function'
4567
+ const hasMediaRecorder = typeof MediaRecorder !== 'undefined'
4568
+
4569
+ const [micPermission, setMicPermission] = React.useState('prompt')
4570
+ const [devices, setDevices] = React.useState([])
4571
+ const [selectedDev, setSelectedDev] = React.useState((draft && draft.micDeviceId) || '')
4572
+ const [signalTesting, setSignalTesting] = React.useState(false)
4573
+ const [currentRms, setCurrentRms] = React.useState(0)
4574
+ const [peakRms, setPeakRms] = React.useState(0)
4575
+ const [testingStt, setTestingStt] = React.useState(false)
4576
+ const [testCountdown, setTestCountdown] = React.useState(0)
4577
+ const [testResult, setTestResult] = React.useState(null)
4578
+ const [testError, setTestError] = React.useState('')
4579
+ const [testLatency, setTestLatency] = React.useState(null)
4580
+
4581
+ const signalStreamRef = React.useRef(null)
4582
+ const audioCtxRef = React.useRef(null)
4583
+ const animFrameRef = React.useRef(null)
4584
+
4585
+ React.useEffect(() => {
4586
+ if (typeof navigator !== 'undefined' && navigator.permissions && navigator.permissions.query) {
4587
+ navigator.permissions.query({ name: 'microphone' })
4588
+ .then((p) => {
4589
+ if (p) {
4590
+ setMicPermission(p.state)
4591
+ p.onchange = () => setMicPermission(p.state)
4592
+ }
4593
+ })
4594
+ .catch(() => {})
4595
+ }
4596
+ }, [])
4597
+
4598
+ const refreshDevices = () => {
4599
+ if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
4600
+ navigator.mediaDevices.enumerateDevices()
4601
+ .then((list) => {
4602
+ const audioInputs = list.filter((d) => d.kind === 'audioinput')
4603
+ setDevices(audioInputs)
4604
+ })
4605
+ .catch(() => {})
4606
+ }
4607
+
4608
+ React.useEffect(() => {
4609
+ refreshDevices()
4610
+ }, [])
4611
+
4612
+ const stopSignalTest = () => {
4613
+ if (animFrameRef.current) {
4614
+ cancelAnimationFrame(animFrameRef.current)
4615
+ animFrameRef.current = null
4616
+ }
4617
+ if (signalStreamRef.current) {
4618
+ try { signalStreamRef.current.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
4619
+ signalStreamRef.current = null
4620
+ }
4621
+ if (audioCtxRef.current) {
4622
+ try { audioCtxRef.current.close() } catch (_) {}
4623
+ audioCtxRef.current = null
4624
+ }
4625
+ setSignalTesting(false)
4626
+ setCurrentRms(0)
4627
+ }
4628
+
4629
+ React.useEffect(() => {
4630
+ return () => {
4631
+ stopSignalTest()
4632
+ }
4633
+ }, [])
4634
+
4635
+ const requestPermissionAndTestSignal = async () => {
4636
+ stopSignalTest()
4637
+ setTestError('')
4638
+ try {
4639
+ const constraints = {
4640
+ audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
4641
+ }
4642
+ const stream = await navigator.mediaDevices.getUserMedia(constraints)
4643
+ signalStreamRef.current = stream
4644
+ setMicPermission('granted')
4645
+ refreshDevices()
4646
+
4647
+ const AC = typeof AudioContext !== 'undefined' ? AudioContext
4648
+ : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
4649
+ if (!AC) return
4650
+ const actx = new AC()
4651
+ audioCtxRef.current = actx
4652
+ if (actx.state === 'suspended') { actx.resume().catch(() => {}) }
4653
+ const src = actx.createMediaStreamSource(stream)
4654
+ const analyser = actx.createAnalyser()
4655
+ analyser.fftSize = 128
4656
+ src.connect(analyser)
4657
+
4658
+ setSignalTesting(true)
4659
+ const buf = new Uint8Array(analyser.frequencyBinCount)
4660
+
4661
+ const checkLevel = () => {
4662
+ if (!signalStreamRef.current) return
4663
+ analyser.getByteFrequencyData(buf)
4664
+ let sum = 0
4665
+ for (let i = 0; i < buf.length; i++) sum += buf[i]
4666
+ const rms = Math.min(100, Math.round((sum / buf.length / 255) * 100 * 2.2))
4667
+ setCurrentRms(rms)
4668
+ setPeakRms((prev) => Math.max(prev, rms))
4669
+ animFrameRef.current = requestAnimationFrame(checkLevel)
4670
+ }
4671
+ animFrameRef.current = requestAnimationFrame(checkLevel)
4672
+ } catch (err) {
4673
+ setMicPermission('denied')
4674
+ setTestError(String(err && err.message ? err.message : err))
4675
+ stopSignalTest()
4676
+ }
4677
+ }
4678
+
4679
+ const runSttTest = async () => {
4680
+ stopSignalTest()
4681
+ setTestError('')
4682
+ setTestResult(null)
4683
+ setTestLatency(null)
4684
+ setTestingStt(true)
4685
+ setTestCountdown(3)
4686
+
4687
+ let testStream = null
4688
+ let recorder = null
4689
+ const chunks = []
4690
+
4691
+ try {
4692
+ const constraints = {
4693
+ audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
4694
+ }
4695
+ testStream = await navigator.mediaDevices.getUserMedia(constraints)
4696
+ let mime = 'audio/webm;codecs=opus'
4697
+ if (!MediaRecorder.isTypeSupported(mime)) mime = ''
4698
+ recorder = mime ? new MediaRecorder(testStream, { mimeType: mime }) : new MediaRecorder(testStream)
4699
+ recorder.ondataavailable = (e) => {
4700
+ if (e.data && e.data.size > 0) chunks.push(e.data)
4701
+ }
4702
+ recorder.start()
4703
+
4704
+ let left = 3
4705
+ const countdownTimer = setInterval(() => {
4706
+ left -= 1
4707
+ setTestCountdown(left)
4708
+ if (left <= 0) {
4709
+ clearInterval(countdownTimer)
4710
+ if (recorder.state !== 'inactive') recorder.stop()
4711
+ }
4712
+ }, 1000)
4713
+
4714
+ await new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
4715
+
4716
+ try { testStream.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
4717
+
4718
+ const blob = new Blob(chunks, { type: mime || 'audio/webm' })
4719
+ if (blob.size < 400) {
4720
+ setTestError(t('wizardTestEmpty'))
4721
+ setTestingStt(false)
4722
+ return
4723
+ }
4724
+
4725
+ setTestCountdown(0)
4726
+ const startMs = Date.now()
4727
+ const dataBase64 = await blobToBase64(blob)
4728
+ const payload = {
4729
+ dataBase64,
4730
+ mimeType: mime || recorder.mimeType || 'audio/webm',
4731
+ mode: 'dictation',
4732
+ subMode: 'clean',
4733
+ }
4734
+
4735
+ const res = await fetch('/dsh-voice/transcribe', {
4736
+ method: 'POST',
4737
+ headers: { 'content-type': 'application/json' },
4738
+ body: JSON.stringify(payload),
4739
+ })
4740
+ const data = await res.json()
4741
+ const latency = Date.now() - startMs
4742
+ setTestLatency(latency)
4743
+
4744
+ if (!res.ok || !data || !data.ok) {
4745
+ const msg = (data && data.error && data.error.message) || `HTTP ${res.status}`
4746
+ setTestError(msg)
4747
+ } else {
4748
+ const recognized = String(data.text || '').trim()
4749
+ if (recognized) {
4750
+ setTestResult(recognized)
4751
+ if (typeof setTop === 'function') {
4752
+ setTop('wizardCompleted', true)
4753
+ }
4754
+ } else {
4755
+ setTestError(t('wizardTestEmpty'))
4756
+ }
4757
+ }
4758
+ } catch (err) {
4759
+ setTestError(String(err && err.message ? err.message : err))
4760
+ } finally {
4761
+ if (testStream) {
4762
+ try { testStream.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
4763
+ }
4764
+ setTestingStt(false)
4765
+ }
4766
+ }
4767
+
4768
+ // Determine active chain info
4769
+ const dictationChain = (draft && draft.dictation && draft.dictation.chain) || []
4770
+ const activeProvider = (dictationChain[0] && (dictationChain[0].provider || dictationChain[0])) || 'sensevoice'
4771
+ let areaLabel = t('wizardAreaServer')
4772
+ if (activeProvider === 'browser' || activeProvider === 'browser-webgpu') {
4773
+ areaLabel = t('wizardAreaBrowser')
4774
+ } else if (activeProvider !== 'sensevoice' && activeProvider !== 'local-whisper') {
4775
+ areaLabel = t('wizardAreaCloud')
4776
+ }
4777
+
4778
+ let nextStepHint = ''
4779
+ if (!isSecure) {
4780
+ nextStepHint = t('wizardHintHttps')
4781
+ } else if (micPermission === 'denied') {
4782
+ nextStepHint = t('wizardHintPermission')
4783
+ } else if (peakRms === 0 && signalTesting) {
4784
+ nextStepHint = t('wizardHintDevice')
4785
+ } else if (testError) {
4786
+ nextStepHint = t('wizardHintProvider')
4787
+ }
4788
+
4789
+ return React.createElement('div', { className: 'cb-section-card', style: { borderColor: 'var(--dsw-alias-state-brand-primary)' } },
4790
+ React.createElement('div', { className: 'cb-section-title' },
4791
+ React.createElement('span', null, '✨ ' + t('wizardTitle')),
4792
+ React.createElement('div', { style: { display: 'flex', gap: '8px' } },
4793
+ testResult ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '✓ ' + t('wizardStatusCompleted')) : null,
4794
+ onClose ? React.createElement('button', {
4795
+ type: 'button', className: 'cb-btn cb-btn-mini', onClick: onClose, title: t('wizardClose'),
4796
+ }, '✕') : null,
4797
+ ),
4798
+ ),
4799
+ React.createElement('div', { className: 'cb-section-desc' }, t('wizardDesc')),
4800
+
4801
+ // Step 1: Environment & Permissions
4802
+ React.createElement('div', { className: 'dvs-card' },
4803
+ React.createElement('div', { className: 'dvs-h' }, t('wizardStep1')),
4804
+ React.createElement('div', { className: 'cb-row' },
4805
+ React.createElement('span', { className: 'cb-badge ' + (isSecure ? 'cb-badge-ok' : 'cb-badge-err') },
4806
+ isSecure ? ('✓ ' + t('wizardSecureContextOk')) : ('✗ ' + t('wizardSecureContextBad'))
4807
+ ),
4808
+ React.createElement('span', { className: 'cb-badge ' + (hasMediaDevices ? 'cb-badge-ok' : 'cb-badge-err') },
4809
+ hasMediaDevices ? ('✓ ' + t('wizardMediaDevicesOk')) : ('✗ ' + t('wizardMediaDevicesBad'))
4810
+ ),
4811
+ React.createElement('span', { className: 'cb-badge ' + (hasMediaRecorder ? 'cb-badge-ok' : 'cb-badge-err') },
4812
+ hasMediaRecorder ? ('✓ ' + t('wizardMediaRecorderOk')) : ('✗ ' + t('wizardMediaRecorderBad'))
4813
+ ),
4814
+ React.createElement('span', { className: 'cb-badge ' + (micPermission === 'granted' ? 'cb-badge-ok' : (micPermission === 'denied' ? 'cb-badge-err' : 'cb-badge-warn')) },
4815
+ micPermission === 'granted' ? ('✓ ' + t('wizardMicPermissionOk')) : (micPermission === 'denied' ? ('✗ ' + t('wizardMicPermissionBad')) : ('· ' + t('wizardMicPermissionPrompt')))
4816
+ ),
4817
+ ),
4818
+ React.createElement('div', { className: 'cb-field', style: { marginTop: '8px' } },
4819
+ React.createElement('label', null, t('wizardMicLabel')),
4820
+ React.createElement('div', { className: 'cb-row' },
4821
+ React.createElement('select', {
4822
+ value: selectedDev,
4823
+ style: { flex: 1, minWidth: '240px' },
4824
+ onChange: (e) => {
4825
+ setSelectedDev(e.target.value)
4826
+ if (typeof setTop === 'function') setTop('micDeviceId', e.target.value)
4827
+ stopSignalTest()
4828
+ },
4829
+ },
4830
+ React.createElement('option', { value: '' }, 'Default System Microphone'),
4831
+ devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId }, d.label || `Microphone ${d.deviceId.slice(0, 8)}…`))
4832
+ ),
4833
+ React.createElement('button', {
4834
+ type: 'button',
4835
+ className: 'cb-btn',
4836
+ onClick: requestPermissionAndTestSignal,
4837
+ }, t('wizardRequestMic')),
4838
+ ),
4839
+ ),
4840
+ ),
4841
+
4842
+ // Step 2: Live Audio Signal Test
4843
+ React.createElement('div', { className: 'dvs-card' },
4844
+ React.createElement('div', { className: 'dvs-h' }, t('wizardStep2')),
4845
+ React.createElement('div', { className: 'cb-row', style: { alignItems: 'center' } },
4846
+ React.createElement('button', {
4847
+ type: 'button',
4848
+ className: 'cb-btn ' + (signalTesting ? 'cb-btn-danger' : 'cb-btn-primary'),
4849
+ onClick: signalTesting ? stopSignalTest : requestPermissionAndTestSignal,
4850
+ }, signalTesting ? t('wizardStopSignalCheck') : t('wizardStartSignalCheck')),
4851
+ signalTesting ? React.createElement('span', { className: 'dvs-sub' }, t('wizardSignalTesting')) : null,
4852
+ ),
4853
+ signalTesting ? React.createElement('div', { style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '6px' } },
4854
+ React.createElement('div', {
4855
+ style: {
4856
+ height: '14px',
4857
+ borderRadius: '7px',
4858
+ background: 'var(--dsw-alias-bg-layer-1)',
4859
+ border: '1px solid var(--dsw-alias-border-l1)',
4860
+ overflow: 'hidden',
4861
+ position: 'relative',
4862
+ },
4863
+ },
4864
+ React.createElement('div', {
4865
+ style: {
4866
+ width: `${currentRms}%`,
4867
+ height: '100%',
4868
+ background: currentRms > 5 ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-dimmed)',
4869
+ transition: 'width 0.05s linear',
4870
+ },
4871
+ }),
4872
+ ),
4873
+ React.createElement('span', { className: currentRms > 5 ? 'dvs-ok' : 'dvs-sub' },
4874
+ currentRms > 5
4875
+ ? t('wizardSignalOk').replace('{level}', currentRms)
4876
+ : t('wizardSignalZero')
4877
+ ),
4878
+ ) : null,
4879
+ ),
4880
+
4881
+ // Step 3: Transcription Round-Trip Test
4882
+ React.createElement('div', { className: 'dvs-card' },
4883
+ React.createElement('div', { className: 'dvs-h' }, t('wizardStep3')),
4884
+ React.createElement('div', { className: 'cb-row' },
4885
+ React.createElement('span', { className: 'dvs-sub' }, `${t('wizardProviderLabel')} ${activeProvider}`),
4886
+ React.createElement('span', { className: 'cb-badge' }, `${t('wizardProcessingArea')} ${areaLabel}`),
4887
+ ),
4888
+ React.createElement('div', { className: 'cb-row', style: { marginTop: '8px' } },
4889
+ React.createElement('button', {
4890
+ type: 'button',
4891
+ className: 'cb-btn cb-btn-primary',
4892
+ disabled: testingStt || micPermission === 'denied',
4893
+ onClick: runSttTest,
4894
+ }, testingStt
4895
+ ? (testCountdown > 0 ? t('wizardRecordingTest').replace('{left}', testCountdown) : t('wizardTranscribingTest'))
4896
+ : t('wizardStartTestRecord')
4897
+ ),
4898
+ ),
4899
+ testResult ? React.createElement('div', { style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '4px' } },
4900
+ React.createElement('div', { className: 'dvs-ok' },
4901
+ '✓ ' + t('wizardTestSuccess').replace('{latency}', testLatency || 0)
4902
+ ),
4903
+ React.createElement('blockquote', {
4904
+ style: {
4905
+ margin: '4px 0 0 0',
4906
+ padding: '8px 12px',
4907
+ borderRadius: '6px',
4908
+ background: 'var(--dsw-alias-bg-layer-3)',
4909
+ borderLeft: '4px solid var(--dsw-alias-state-success-primary)',
4910
+ color: 'var(--dsw-alias-label-primary)',
4911
+ fontStyle: 'italic',
4912
+ },
4913
+ }, `“${testResult}”`),
4914
+ ) : null,
4915
+ testError ? React.createElement('div', { className: 'cb-alert-err', style: { marginTop: '8px' } },
4916
+ t('wizardTestFailed').replace('{error}', testError)
4917
+ ) : null,
4918
+ ),
4919
+
4920
+ // Next Step Hint / Feedback
4921
+ nextStepHint ? React.createElement('div', { className: 'dvs-wait', style: { fontStyle: 'italic' } },
4922
+ t('wizardNextStepHint').replace('{hint}', nextStepHint)
4923
+ ) : null,
4924
+
4925
+ // Wizard Bottom Actions
4926
+ React.createElement('div', { className: 'cb-row', style: { justifyContent: 'space-between', marginTop: '6px' } },
4927
+ React.createElement('button', {
4928
+ type: 'button',
4929
+ className: 'cb-btn',
4930
+ onClick: () => {
4931
+ if (typeof setTop === 'function') setTop('wizardCompleted', true)
4932
+ if (onClose) onClose()
4933
+ },
4934
+ }, t('wizardSkip')),
4935
+ testResult ? React.createElement('button', {
4936
+ type: 'button',
4937
+ className: 'cb-btn cb-btn-primary',
4938
+ onClick: () => {
4939
+ if (typeof setTop === 'function') setTop('wizardCompleted', true)
4940
+ if (onClose) onClose()
4941
+ },
4942
+ }, '✓ ' + t('wizardFinishSuccess')) : null,
4943
+ ),
4944
+ )
4945
+ }
4454
4946
  // Card in Settings → Plugins → Plugin settings (#18)
4455
4947
  function PluginCard(props) {
4456
4948
  const page = !!(props && props.view === 'page')
package/lib/updater.js CHANGED
@@ -18,15 +18,82 @@ function header(request, name) {
18
18
  return Array.isArray(value) ? value[0] : value;
19
19
  }
20
20
 
21
- function isPrivateLan(value) {
22
- const address = value?.toLowerCase().replace(/^\[|\]$/g, '');
21
+ function parseIPv4(ip) {
22
+ const parts = ip.split('.');
23
+ if (parts.length !== 4) return null;
24
+ let num = 0;
25
+ for (const p of parts) {
26
+ if (!/^\d+$/.test(p)) return null;
27
+ const n = Number(p);
28
+ if (n < 0 || n > 255) return null;
29
+ num = (num << 8) | n;
30
+ }
31
+ return (num >>> 0);
32
+ }
33
+
34
+ function parseIPv6(ip) {
35
+ const str = ip.toLowerCase();
36
+ if (str.includes('.')) {
37
+ const lastColon = str.lastIndexOf(':');
38
+ if (lastColon === -1) return null;
39
+ const v4Part = str.slice(lastColon + 1);
40
+ const v4Num = parseIPv4(v4Part);
41
+ if (v4Num === null) return null;
42
+ const v6Prefix = str.slice(0, lastColon);
43
+ const hiHex = ((v4Num >>> 16) & 0xffff).toString(16);
44
+ const loHex = (v4Num & 0xffff).toString(16);
45
+ return parseIPv6(v6Prefix + ':' + hiHex + ':' + loHex);
46
+ }
47
+ const parts = str.split('::');
48
+ if (parts.length > 2) return null;
49
+ let groups = [];
50
+ if (parts.length === 2) {
51
+ const left = parts[0] ? parts[0].split(':') : [];
52
+ const right = parts[1] ? parts[1].split(':') : [];
53
+ const missing = 8 - (left.length + right.length);
54
+ if (missing < 1) return null;
55
+ groups = [...left, ...Array(missing).fill('0'), ...right];
56
+ } else {
57
+ groups = str.split(':');
58
+ }
59
+ if (groups.length !== 8) return null;
60
+ let hi = 0n;
61
+ for (let i = 0; i < 8; i++) {
62
+ if (!/^[0-9a-f]{1,4}$/.test(groups[i])) return null;
63
+ hi = (hi << 16n) | BigInt(parseInt(groups[i], 16));
64
+ }
65
+ return hi;
66
+ }
67
+
68
+ export function isPrivateLan(value) {
69
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '').trim();
23
70
  if (!address) return false;
24
- const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address;
25
- return (
26
- ipv4.startsWith('192.168.') ||
27
- ipv4.startsWith('10.') ||
28
- /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ipv4)
29
- );
71
+ const slashIdx = address.indexOf('/');
72
+ const ipPart = slashIdx !== -1 ? address.slice(0, slashIdx) : address;
73
+
74
+ const ipv4 = ipPart.startsWith('::ffff:') ? ipPart.slice(7) : ipPart;
75
+
76
+ const v4Num = parseIPv4(ipv4);
77
+ if (v4Num !== null) {
78
+ if ((v4Num >>> 24) === 10) return true;
79
+ if ((v4Num >>> 20) === ((172 << 4) | 1)) return true;
80
+ if ((v4Num >>> 16) === ((192 << 8) | 168)) return true;
81
+ if ((v4Num >>> 16) === ((169 << 8) | 254)) return true;
82
+ return false;
83
+ }
84
+
85
+ const v6Num = parseIPv6(ipPart);
86
+ if (v6Num !== null) {
87
+ // fc00::/7 ULA (covers fc00::/8 and fd00::/8)
88
+ const first7Bits = Number(v6Num >> 121n);
89
+ if (first7Bits === 0b1111110) return true;
90
+ // fe80::/10 Link-Local
91
+ const first10Bits = Number(v6Num >> 118n);
92
+ if (first10Bits === 0b1111111010) return true;
93
+ return false;
94
+ }
95
+
96
+ return false;
30
97
  }
31
98
 
32
99
  function isLoopback(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.32",
3
+ "version": "0.9.33",
4
4
  "description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
5
5
  "license": "MIT",
6
6
  "type": "module",