@goodandready/dsh-voice 0.9.32 → 0.9.34
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 +601 -29
- package/lib/host-routes.js +66 -3
- package/lib/stats.js +51 -1
- package/lib/updater.js +75 -8
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -71,16 +71,10 @@ window.__ModuleLoader__.load({
|
|
|
71
71
|
'webgpuStatus': 'Browser WebGPU Status',
|
|
72
72
|
'webgpuAvailable': 'WebGPU Available (hardware acceleration ready)',
|
|
73
73
|
'webgpuNotAvailable': 'WebGPU Not Detected (will fallback to server)',
|
|
74
|
-
'openaiHint': 'whisper-1 · key OPENAI_API_KEY',
|
|
75
|
-
'
|
|
76
|
-
'
|
|
77
|
-
'
|
|
78
|
-
'mistralHint': 'voxtral-mini-latest · key MISTRAL_API_KEY',
|
|
79
|
-
'openrouterHint': 'google/gemini-2.5-flash · key OPENROUTER_API_KEY',
|
|
80
|
-
'localHint': 'set when the server is started',
|
|
81
|
-
'up': 'Up',
|
|
82
|
-
'down': 'Down',
|
|
83
|
-
'remove': 'Remove',
|
|
74
|
+
'openaiHint': 'whisper-1 · key OPENAI_API_KEY', 'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · key SILICONFLOW_API_KEY',
|
|
75
|
+
'deepinfraHint': 'openai/whisper-large-v3-turbo · key DEEPINFRA_API_KEY', 'fireworksHint': 'whisper-v3-turbo · key FIREWORKS_API_KEY',
|
|
76
|
+
'mistralHint': 'voxtral-mini-latest · key MISTRAL_API_KEY', 'openrouterHint': 'google/gemini-2.5-flash · key OPENROUTER_API_KEY',
|
|
77
|
+
'localHint': 'set when the server is started', 'up': 'Up', 'down': 'Down', 'remove': 'Remove',
|
|
84
78
|
'addProvider': 'Add provider',
|
|
85
79
|
'chainHint': 'Top to bottom is the order they are tried in',
|
|
86
80
|
'customName': 'name used in the chain',
|
|
@@ -279,6 +273,55 @@ window.__ModuleLoader__.load({
|
|
|
279
273
|
'assistantSpeakingGated': 'Assistant speaking (gated mode)',
|
|
280
274
|
'jargonFormatHint': ' (spoken -> replacement)',
|
|
281
275
|
'errorPrefix': 'Error',
|
|
276
|
+
'wizardTitle': 'First-Run Setup & Diagnostics Wizard',
|
|
277
|
+
'wizardDesc': 'Step-by-step diagnostic test: check secure context, microphone permissions, audio level, and STT round-trip.',
|
|
278
|
+
'wizardBtnLaunch': 'Launch Mic & STT Wizard',
|
|
279
|
+
'wizardClose': 'Close Wizard',
|
|
280
|
+
'wizardStep1': '1. Audio & Environment',
|
|
281
|
+
'wizardStep2': '2. Live Signal Test',
|
|
282
|
+
'wizardStep3': '3. STT Transcription Test',
|
|
283
|
+
'wizardSecureContextOk': 'Secure context / HTTPS verified',
|
|
284
|
+
'wizardSecureContextBad': 'Insecure context (HTTPS or localhost required)',
|
|
285
|
+
'wizardMediaDevicesOk': 'Browser mediaDevices API supported',
|
|
286
|
+
'wizardMediaDevicesBad': 'Browser mediaDevices API missing',
|
|
287
|
+
'wizardMediaRecorderOk': 'MediaRecorder API supported',
|
|
288
|
+
'wizardMediaRecorderBad': 'MediaRecorder API unsupported in this browser',
|
|
289
|
+
'wizardMicPermissionOk': 'Microphone permission granted',
|
|
290
|
+
'wizardMicPermissionPrompt': 'Microphone permission not yet granted',
|
|
291
|
+
'wizardMicPermissionBad': 'Microphone permission denied',
|
|
292
|
+
'wizardRequestMic': 'Request Microphone Access',
|
|
293
|
+
'wizardMicLabel': 'Microphone input device:',
|
|
294
|
+
'wizardStartSignalCheck': 'Start Audio Level Test',
|
|
295
|
+
'wizardStopSignalCheck': 'Stop Audio Test',
|
|
296
|
+
'wizardSignalOk': 'Audio signal detected (RMS: {level}%)',
|
|
297
|
+
'wizardSignalZero': 'Zero audio level detected - speak into microphone or select correct device',
|
|
298
|
+
'wizardSignalTesting': 'Listening for audio signal… speak into your microphone',
|
|
299
|
+
'wizardProviderLabel': 'Active STT engine & model:',
|
|
300
|
+
'wizardProcessingArea': 'Processing area:',
|
|
301
|
+
'wizardAreaBrowser': 'In-browser (WebGPU / WebSpeech)',
|
|
302
|
+
'wizardAreaServer': 'Local MiniPC server (SenseVoice / Whisper)',
|
|
303
|
+
'wizardAreaCloud': 'Remote cloud provider',
|
|
304
|
+
'wizardStartTestRecord': 'Record 3-Second Test Phrase',
|
|
305
|
+
'wizardRecordingTest': 'Recording test phrase… speak now ({left}s)',
|
|
306
|
+
'wizardTranscribingTest': 'Transcribing test phrase…',
|
|
307
|
+
'wizardTestResult': 'STT Test Result:',
|
|
308
|
+
'wizardTestSuccess': 'STT round-trip verified successfully! Latency: {latency}ms',
|
|
309
|
+
'wizardTestEmpty': 'No speech detected in test recording. Please speak clearly and try again.',
|
|
310
|
+
'wizardTestFailed': 'STT test failed: {error}',
|
|
311
|
+
'wizardNextStepHint': 'Next step: {hint}',
|
|
312
|
+
'wizardHintPermission': 'Click "Request Microphone Access" and allow browser permission prompt.',
|
|
313
|
+
'wizardHintDevice': 'Check microphone connection or select a different input device.',
|
|
314
|
+
'wizardHintHttps': 'Open DeepSeek Harness via HTTPS or localhost address.',
|
|
315
|
+
'wizardHintProvider': 'Configure STT engine credentials or install SenseVoice model below.',
|
|
316
|
+
'wizardFinishSuccess': 'Setup & Verification Complete',
|
|
317
|
+
'wizardSkip': 'Skip Wizard',
|
|
318
|
+
'wizardStatusCompleted': 'Microphone and STT verified',
|
|
319
|
+
'exportDiagnosticsBtn': 'Export Diagnostics Report', 'exportingDiagnostics': 'Exporting report…',
|
|
320
|
+
'diagnosticsExportSuccess': 'Report downloaded', 'recentRecognitionsTitle': 'Recent Recognition Telemetry',
|
|
321
|
+
'noRecognitionsYet': 'No recent recognition records yet.', 'diagLatencyBreakdown': 'Latency breakdown:',
|
|
322
|
+
'diagVad': 'VAD: {ms}ms', 'diagStt': 'STT: {ms}ms', 'diagPolish': 'Polish: {ms}ms',
|
|
323
|
+
'diagTotal': 'Total: {ms}ms', 'diagBytes': 'Audio: {bytes} B', 'diagWords': 'Words: {count}',
|
|
324
|
+
'diagExportNotice': 'Exported JSON contains anonymized timings, provider chain, MIME format, and error codes without raw audio or speech transcripts.',
|
|
282
325
|
}
|
|
283
326
|
|
|
284
327
|
const zh = {
|
|
@@ -318,16 +361,10 @@ window.__ModuleLoader__.load({
|
|
|
318
361
|
'webgpuStatus': '浏览器 WebGPU 状态',
|
|
319
362
|
'webgpuAvailable': 'WebGPU 可用 (硬件加速就绪)',
|
|
320
363
|
'webgpuNotAvailable': '未检测到 WebGPU (将自动回退到服务端)',
|
|
321
|
-
'openaiHint': 'whisper-1 · 密钥 OPENAI_API_KEY',
|
|
322
|
-
'
|
|
323
|
-
'
|
|
324
|
-
'
|
|
325
|
-
'mistralHint': 'voxtral-mini-latest · 密钥 MISTRAL_API_KEY',
|
|
326
|
-
'openrouterHint': 'google/gemini-2.5-flash · 密钥 OPENROUTER_API_KEY',
|
|
327
|
-
'localHint': '服务器启动时配置',
|
|
328
|
-
'up': '上移',
|
|
329
|
-
'down': '下移',
|
|
330
|
-
'remove': '移除',
|
|
364
|
+
'openaiHint': 'whisper-1 · 密钥 OPENAI_API_KEY', 'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · 密钥 SILICONFLOW_API_KEY',
|
|
365
|
+
'deepinfraHint': 'openai/whisper-large-v3-turbo · 密钥 DEEPINFRA_API_KEY', 'fireworksHint': 'whisper-v3-turbo · 密钥 FIREWORKS_API_KEY',
|
|
366
|
+
'mistralHint': 'voxtral-mini-latest · 密钥 MISTRAL_API_KEY', 'openrouterHint': 'google/gemini-2.5-flash · 密钥 OPENROUTER_API_KEY',
|
|
367
|
+
'localHint': '服务器启动时配置', 'up': '上移', 'down': '下移', 'remove': '移除',
|
|
331
368
|
'addProvider': '添加服务商',
|
|
332
369
|
'chainHint': '自上而下为故障转移尝试顺序',
|
|
333
370
|
'customName': '备用链中使用的名称',
|
|
@@ -526,6 +563,55 @@ window.__ModuleLoader__.load({
|
|
|
526
563
|
'assistantSpeakingGated': '助手正在发言(门控模式)',
|
|
527
564
|
'jargonFormatHint': ' (口语 -> 替换文本)',
|
|
528
565
|
'errorPrefix': '错误',
|
|
566
|
+
'wizardTitle': '首次启动与麦克风诊断向导',
|
|
567
|
+
'wizardDesc': '分步诊断检查:安全上下文、麦克风权限、音频信号电平及实时 STT 回环测试。',
|
|
568
|
+
'wizardBtnLaunch': '启动麦克风与 STT 诊断向导',
|
|
569
|
+
'wizardClose': '关闭向导',
|
|
570
|
+
'wizardStep1': '1. 音频与运行环境',
|
|
571
|
+
'wizardStep2': '2. 实时信号测试',
|
|
572
|
+
'wizardStep3': '3. STT 识别回环测试',
|
|
573
|
+
'wizardSecureContextOk': '安全上下文 / HTTPS 已验证',
|
|
574
|
+
'wizardSecureContextBad': '非安全上下文(需 HTTPS 或 localhost)',
|
|
575
|
+
'wizardMediaDevicesOk': '浏览器 mediaDevices API 支持正常',
|
|
576
|
+
'wizardMediaDevicesBad': '浏览器缺少 mediaDevices API 支持',
|
|
577
|
+
'wizardMediaRecorderOk': 'MediaRecorder API 支持正常',
|
|
578
|
+
'wizardMediaRecorderBad': '当前浏览器不支持 MediaRecorder API',
|
|
579
|
+
'wizardMicPermissionOk': '麦克风权限已授予',
|
|
580
|
+
'wizardMicPermissionPrompt': '尚未获得麦克风授权',
|
|
581
|
+
'wizardMicPermissionBad': '麦克风权限被拒绝',
|
|
582
|
+
'wizardRequestMic': '请求麦克风权限',
|
|
583
|
+
'wizardMicLabel': '麦克风输入设备:',
|
|
584
|
+
'wizardStartSignalCheck': '开始音频电平测试',
|
|
585
|
+
'wizardStopSignalCheck': '停止音频测试',
|
|
586
|
+
'wizardSignalOk': '检测到音频信号 (RMS: {level}%)',
|
|
587
|
+
'wizardSignalZero': '未检测到声音信号 — 请对着麦克风说话或选择正确设备',
|
|
588
|
+
'wizardSignalTesting': '正在监听声音信号… 请对着麦克风发声',
|
|
589
|
+
'wizardProviderLabel': '当前语音引擎与模型:',
|
|
590
|
+
'wizardProcessingArea': '处理区域:',
|
|
591
|
+
'wizardAreaBrowser': '浏览器端处理 (WebGPU / WebSpeech)',
|
|
592
|
+
'wizardAreaServer': '本地服务器处理 (SenseVoice / Whisper)',
|
|
593
|
+
'wizardAreaCloud': '远程云端服务',
|
|
594
|
+
'wizardStartTestRecord': '录制 3 秒测试语句',
|
|
595
|
+
'wizardRecordingTest': '正在录制测试音频… 请说话 ({left}秒)',
|
|
596
|
+
'wizardTranscribingTest': '正在请求语音识别…',
|
|
597
|
+
'wizardTestResult': 'STT 测试结果:',
|
|
598
|
+
'wizardTestSuccess': 'STT 链路验证成功!耗时:{latency}毫秒',
|
|
599
|
+
'wizardTestEmpty': '录音中未检测到有效语音,请清晰发音后重试。',
|
|
600
|
+
'wizardTestFailed': 'STT 测试失败:{error}',
|
|
601
|
+
'wizardNextStepHint': '建议下一步:{hint}',
|
|
602
|
+
'wizardHintPermission': '点击“请求麦克风权限”并在浏览器弹窗中允许访问。',
|
|
603
|
+
'wizardHintDevice': '检查麦克风硬件连接或切换选择其他输入设备。',
|
|
604
|
+
'wizardHintHttps': '请通过 HTTPS 或 localhost 访问 DeepSeek Harness。',
|
|
605
|
+
'wizardHintProvider': '请在下方配置 STT 密钥凭据或一键安装 SenseVoice 本地模型。',
|
|
606
|
+
'wizardFinishSuccess': '设置与验证已完成',
|
|
607
|
+
'wizardSkip': '跳过向导',
|
|
608
|
+
'wizardStatusCompleted': '麦克风与 STT 诊断已就绪',
|
|
609
|
+
'exportDiagnosticsBtn': '导出诊断报告', 'exportingDiagnostics': '正在导出报告…',
|
|
610
|
+
'diagnosticsExportSuccess': '诊断报告已下载', 'recentRecognitionsTitle': '近期识别遥测详情',
|
|
611
|
+
'noRecognitionsYet': '暂无近期识别记录。', 'diagLatencyBreakdown': '耗时分布:',
|
|
612
|
+
'diagVad': 'VAD:{ms}毫秒', 'diagStt': 'STT:{ms}毫秒', 'diagPolish': '后处理:{ms}毫秒',
|
|
613
|
+
'diagTotal': '总耗时:{ms}毫秒', 'diagBytes': '音频大小:{bytes} 字节', 'diagWords': '词数:{count}',
|
|
614
|
+
'diagExportNotice': '导出的 JSON 报告包含脱敏的耗时统计、备用链尝试、MIME 格式与错误码,不含任何原始音频或语音文本。',
|
|
529
615
|
}
|
|
530
616
|
|
|
531
617
|
// Strings are also needed outside components — in recording handlers and
|
|
@@ -3074,18 +3160,61 @@ function cycleDictationMode() {
|
|
|
3074
3160
|
),
|
|
3075
3161
|
)
|
|
3076
3162
|
}
|
|
3077
|
-
// Provider Telemetry & Latency Dashboard
|
|
3163
|
+
// Provider Telemetry & Latency Dashboard (#266)
|
|
3078
3164
|
function ProviderDashboard(props) {
|
|
3079
3165
|
const t = props.t || moduleT
|
|
3080
3166
|
const statsData = props.statsData || {}
|
|
3167
|
+
const recentDiagnostics = props.recentDiagnostics || []
|
|
3168
|
+
const [exporting, setExporting] = React.useState(false)
|
|
3169
|
+
const [exportMsg, setExportMsg] = React.useState('')
|
|
3170
|
+
|
|
3171
|
+
const exportReport = async () => {
|
|
3172
|
+
setExporting(true)
|
|
3173
|
+
setExportMsg('')
|
|
3174
|
+
try {
|
|
3175
|
+
const res = await fetch('/dsh-voice/diagnostics/export')
|
|
3176
|
+
const data = await res.json()
|
|
3177
|
+
if (!res.ok || !data || !data.ok) {
|
|
3178
|
+
throw new Error((data && data.error && data.error.message) || `HTTP ${res.status}`)
|
|
3179
|
+
}
|
|
3180
|
+
const blob = new Blob([JSON.stringify(data.report || {}, null, 2)], { type: 'application/json' })
|
|
3181
|
+
const url = URL.createObjectURL(blob)
|
|
3182
|
+
const a = document.createElement('a')
|
|
3183
|
+
a.href = url
|
|
3184
|
+
a.download = `dsh-voice-diagnostics-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
|
|
3185
|
+
document.body.appendChild(a)
|
|
3186
|
+
a.click()
|
|
3187
|
+
document.body.removeChild(a)
|
|
3188
|
+
URL.revokeObjectURL(url)
|
|
3189
|
+
setExportMsg('✓ ' + t('diagnosticsExportSuccess'))
|
|
3190
|
+
} catch (err) {
|
|
3191
|
+
setExportMsg(String(err && err.message ? err.message : err))
|
|
3192
|
+
} finally {
|
|
3193
|
+
setExporting(false)
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3081
3197
|
return React.createElement('div', { className: 'cb-section-card' },
|
|
3082
3198
|
React.createElement('div', { className: 'cb-section-title' },
|
|
3083
3199
|
React.createElement('span', null, '📊 ' + t('providerDashboard')),
|
|
3084
|
-
React.createElement('
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3200
|
+
React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
|
|
3201
|
+
React.createElement('button', {
|
|
3202
|
+
type: 'button',
|
|
3203
|
+
className: 'cb-btn',
|
|
3204
|
+
style: { fontSize: '12px', padding: '4px 10px' },
|
|
3205
|
+
disabled: exporting,
|
|
3206
|
+
onClick: exportReport,
|
|
3207
|
+
}, exporting ? t('exportingDiagnostics') : ('📥 ' + t('exportDiagnosticsBtn'))),
|
|
3208
|
+
React.createElement('button', {
|
|
3209
|
+
type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
|
|
3210
|
+
onClick: props.onRefresh,
|
|
3211
|
+
}, '🔄 ' + t('refreshStats')),
|
|
3212
|
+
),
|
|
3088
3213
|
),
|
|
3214
|
+
exportMsg ? React.createElement('div', {
|
|
3215
|
+
className: exportMsg.startsWith('✓') ? 'dvs-ok' : 'cb-alert-err',
|
|
3216
|
+
style: { fontSize: '12px' },
|
|
3217
|
+
}, exportMsg) : null,
|
|
3089
3218
|
React.createElement('div', { className: 'dvo-dash' },
|
|
3090
3219
|
React.createElement('div', { className: 'dvo-dash-grid' },
|
|
3091
3220
|
Object.keys(statsData).length === 0
|
|
@@ -3125,6 +3254,41 @@ function cycleDictationMode() {
|
|
|
3125
3254
|
}),
|
|
3126
3255
|
),
|
|
3127
3256
|
),
|
|
3257
|
+
// Per-recognition telemetry breakdown list (#266)
|
|
3258
|
+
React.createElement('div', { style: { marginTop: '12px', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: '10px' } },
|
|
3259
|
+
React.createElement('div', { className: 'dvs-h', style: { marginBottom: '8px' } }, t('recentRecognitionsTitle')),
|
|
3260
|
+
recentDiagnostics.length === 0
|
|
3261
|
+
? React.createElement('div', { className: 'dvs-sub' }, t('noRecognitionsYet'))
|
|
3262
|
+
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
|
|
3263
|
+
recentDiagnostics.slice(-5).reverse().map((d) => React.createElement('div', {
|
|
3264
|
+
key: d.id,
|
|
3265
|
+
className: 'dvs-card',
|
|
3266
|
+
style: { padding: '8px 12px', fontSize: '12px' },
|
|
3267
|
+
},
|
|
3268
|
+
React.createElement('div', { className: 'cb-row', style: { justifyContent: 'space-between', alignItems: 'center' } },
|
|
3269
|
+
React.createElement('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
|
|
3270
|
+
React.createElement('span', { className: 'cb-badge ' + (d.success ? 'cb-badge-ok' : 'cb-badge-err') },
|
|
3271
|
+
d.success ? '✓ ' + (d.provider || 'STT') : '✗ ' + (d.error ? d.error.slice(0, 30) : 'fail')
|
|
3272
|
+
),
|
|
3273
|
+
React.createElement('span', { className: 'dvs-sub' }, `[${d.mode}/${d.subMode}]`),
|
|
3274
|
+
d.audioBytes ? React.createElement('span', { className: 'dvs-sub' }, t('diagBytes').replace('{bytes}', d.audioBytes)) : null,
|
|
3275
|
+
),
|
|
3276
|
+
React.createElement('span', { style: { fontWeight: '600' } },
|
|
3277
|
+
t('diagTotal').replace('{ms}', d.totalTookMs)
|
|
3278
|
+
),
|
|
3279
|
+
),
|
|
3280
|
+
React.createElement('div', { className: 'cb-row', style: { gap: '12px', marginTop: '4px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } },
|
|
3281
|
+
React.createElement('span', null, t('diagVad').replace('{ms}', (d.breakdown && d.breakdown.vadMs) || 0)),
|
|
3282
|
+
React.createElement('span', null, t('diagStt').replace('{ms}', (d.breakdown && d.breakdown.sttMs) || 0)),
|
|
3283
|
+
d.breakdown && d.breakdown.polishMs ? React.createElement('span', null, t('diagPolish').replace('{ms}', d.breakdown.polishMs)) : null,
|
|
3284
|
+
typeof d.wordCount === 'number' ? React.createElement('span', null, t('diagWords').replace('{count}', d.wordCount)) : null,
|
|
3285
|
+
),
|
|
3286
|
+
))
|
|
3287
|
+
),
|
|
3288
|
+
React.createElement('div', { className: 'dvs-sub', style: { marginTop: '8px', fontStyle: 'italic' } },
|
|
3289
|
+
t('diagExportNotice')
|
|
3290
|
+
),
|
|
3291
|
+
),
|
|
3128
3292
|
)
|
|
3129
3293
|
}
|
|
3130
3294
|
// ------------------------------------------------------------------ 72-dictation-modes
|
|
@@ -3823,14 +3987,21 @@ function MacrosSection(props) {
|
|
|
3823
3987
|
const whisperRunning = props.whisperRunning
|
|
3824
3988
|
const providerCount = props.providerCount
|
|
3825
3989
|
const onRefresh = props.onRefresh
|
|
3990
|
+
const onOpenWizard = props.onOpenWizard
|
|
3826
3991
|
|
|
3827
3992
|
return React.createElement('div', { className: 'cb-section-card' },
|
|
3828
3993
|
React.createElement('div', { className: 'cb-section-title' },
|
|
3829
3994
|
React.createElement('span', null, t('statusTitle')),
|
|
3830
|
-
React.createElement('
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3995
|
+
React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
|
|
3996
|
+
onOpenWizard ? React.createElement('button', {
|
|
3997
|
+
type: 'button', className: 'cb-btn cb-btn-primary', style: { fontSize: '12px', padding: '4px 10px' },
|
|
3998
|
+
onClick: onOpenWizard,
|
|
3999
|
+
}, '✨ ' + t('wizardBtnLaunch')) : null,
|
|
4000
|
+
React.createElement('button', {
|
|
4001
|
+
type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
|
|
4002
|
+
onClick: onRefresh,
|
|
4003
|
+
}, '🔄 ' + t('refreshStats')),
|
|
4004
|
+
),
|
|
3834
4005
|
),
|
|
3835
4006
|
React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px', flexWrap: 'wrap' } },
|
|
3836
4007
|
React.createElement('span', { className: 'cb-badge ' + (hostOnline ? 'cb-badge-ok' : 'cb-badge-err') },
|
|
@@ -3931,6 +4102,7 @@ function MacrosSection(props) {
|
|
|
3931
4102
|
setServerConfig(data.config)
|
|
3932
4103
|
if (typeof data.revision === 'number') setRevision(data.revision)
|
|
3933
4104
|
setDraft((curr) => curr || deepClone(data.config))
|
|
4105
|
+
if (data.config.wizardCompleted !== true) setShowWizard(true)
|
|
3934
4106
|
}
|
|
3935
4107
|
})
|
|
3936
4108
|
.catch(() => {})
|
|
@@ -3942,6 +4114,7 @@ function MacrosSection(props) {
|
|
|
3942
4114
|
// Hotkey picker: not a name field — press the key you want.
|
|
3943
4115
|
const [catching, setCatching] = React.useState(false)
|
|
3944
4116
|
const [jargonInput, setJargonInput] = React.useState(null)
|
|
4117
|
+
const [showWizard, setShowWizard] = React.useState(false)
|
|
3945
4118
|
React.useEffect(() => {
|
|
3946
4119
|
if (!catching) return undefined
|
|
3947
4120
|
const onKey = (event) => {
|
|
@@ -4208,6 +4381,7 @@ function MacrosSection(props) {
|
|
|
4208
4381
|
React.createElement('span', { className: 'dvs-sub' }, hint),
|
|
4209
4382
|
)
|
|
4210
4383
|
const statsData = (statusData && statusData.providerStats) || {}
|
|
4384
|
+
const recentDiagnostics = (statusData && statusData.recentDiagnostics) || []
|
|
4211
4385
|
const hostOnline = statusLatency !== null
|
|
4212
4386
|
const whisperRunning = !!(statusData && statusData.whisperRunning)
|
|
4213
4387
|
const providerCount = (statusData && Array.isArray(statusData.providers)) ? statusData.providers.length : chainOptions.length
|
|
@@ -4219,7 +4393,15 @@ function MacrosSection(props) {
|
|
|
4219
4393
|
whisperRunning: whisperRunning,
|
|
4220
4394
|
providerCount: providerCount,
|
|
4221
4395
|
onRefresh: fetchStatus,
|
|
4396
|
+
onOpenWizard: () => setShowWizard((v) => !v),
|
|
4222
4397
|
}),
|
|
4398
|
+
showWizard ? React.createElement(FirstRunWizard, {
|
|
4399
|
+
t: t,
|
|
4400
|
+
draft: draft,
|
|
4401
|
+
setTop: setTop,
|
|
4402
|
+
writable: writable,
|
|
4403
|
+
onClose: () => setShowWizard(false),
|
|
4404
|
+
}) : null,
|
|
4223
4405
|
React.createElement('div', { className: 'cb-section-card' },
|
|
4224
4406
|
React.createElement('div', { className: 'cb-section-title' },
|
|
4225
4407
|
React.createElement('span', null, '🎙️ ' + t('dictationBtn')),
|
|
@@ -4432,6 +4614,7 @@ function MacrosSection(props) {
|
|
|
4432
4614
|
React.createElement(ProviderDashboard, {
|
|
4433
4615
|
t: t,
|
|
4434
4616
|
statsData: statsData,
|
|
4617
|
+
recentDiagnostics: recentDiagnostics,
|
|
4435
4618
|
onRefresh: fetchStatus,
|
|
4436
4619
|
}),
|
|
4437
4620
|
React.createElement(UpdaterCard, {
|
|
@@ -4451,6 +4634,395 @@ function MacrosSection(props) {
|
|
|
4451
4634
|
),
|
|
4452
4635
|
)
|
|
4453
4636
|
}
|
|
4637
|
+
// ------------------------------------------------------- first-run wizard (#265)
|
|
4638
|
+
function FirstRunWizard(props) {
|
|
4639
|
+
const t = props.t || moduleT
|
|
4640
|
+
const draft = props.draft
|
|
4641
|
+
const setTop = props.setTop
|
|
4642
|
+
const writable = props.writable
|
|
4643
|
+
const onClose = props.onClose
|
|
4644
|
+
|
|
4645
|
+
const isSecure = typeof window !== 'undefined' ? (window.isSecureContext || window.location.protocol === 'https:' || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') : true
|
|
4646
|
+
const hasMediaDevices = typeof navigator !== 'undefined' && !!navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function'
|
|
4647
|
+
const hasMediaRecorder = typeof MediaRecorder !== 'undefined'
|
|
4648
|
+
|
|
4649
|
+
const [micPermission, setMicPermission] = React.useState('prompt')
|
|
4650
|
+
const [devices, setDevices] = React.useState([])
|
|
4651
|
+
const [selectedDev, setSelectedDev] = React.useState((draft && draft.micDeviceId) || '')
|
|
4652
|
+
const [signalTesting, setSignalTesting] = React.useState(false)
|
|
4653
|
+
const [currentRms, setCurrentRms] = React.useState(0)
|
|
4654
|
+
const [peakRms, setPeakRms] = React.useState(0)
|
|
4655
|
+
const [testingStt, setTestingStt] = React.useState(false)
|
|
4656
|
+
const [testCountdown, setTestCountdown] = React.useState(0)
|
|
4657
|
+
const [testResult, setTestResult] = React.useState(null)
|
|
4658
|
+
const [testError, setTestError] = React.useState('')
|
|
4659
|
+
const [testLatency, setTestLatency] = React.useState(null)
|
|
4660
|
+
|
|
4661
|
+
const signalStreamRef = React.useRef(null)
|
|
4662
|
+
const audioCtxRef = React.useRef(null)
|
|
4663
|
+
const animFrameRef = React.useRef(null)
|
|
4664
|
+
|
|
4665
|
+
React.useEffect(() => {
|
|
4666
|
+
if (typeof navigator !== 'undefined' && navigator.permissions && navigator.permissions.query) {
|
|
4667
|
+
navigator.permissions.query({ name: 'microphone' })
|
|
4668
|
+
.then((p) => {
|
|
4669
|
+
if (p) {
|
|
4670
|
+
setMicPermission(p.state)
|
|
4671
|
+
p.onchange = () => setMicPermission(p.state)
|
|
4672
|
+
}
|
|
4673
|
+
})
|
|
4674
|
+
.catch(() => {})
|
|
4675
|
+
}
|
|
4676
|
+
}, [])
|
|
4677
|
+
|
|
4678
|
+
const refreshDevices = () => {
|
|
4679
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
|
|
4680
|
+
navigator.mediaDevices.enumerateDevices()
|
|
4681
|
+
.then((list) => {
|
|
4682
|
+
const audioInputs = list.filter((d) => d.kind === 'audioinput')
|
|
4683
|
+
setDevices(audioInputs)
|
|
4684
|
+
})
|
|
4685
|
+
.catch(() => {})
|
|
4686
|
+
}
|
|
4687
|
+
|
|
4688
|
+
React.useEffect(() => {
|
|
4689
|
+
refreshDevices()
|
|
4690
|
+
}, [])
|
|
4691
|
+
|
|
4692
|
+
const stopSignalTest = () => {
|
|
4693
|
+
if (animFrameRef.current) {
|
|
4694
|
+
cancelAnimationFrame(animFrameRef.current)
|
|
4695
|
+
animFrameRef.current = null
|
|
4696
|
+
}
|
|
4697
|
+
if (signalStreamRef.current) {
|
|
4698
|
+
try { signalStreamRef.current.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
|
|
4699
|
+
signalStreamRef.current = null
|
|
4700
|
+
}
|
|
4701
|
+
if (audioCtxRef.current) {
|
|
4702
|
+
try { audioCtxRef.current.close() } catch (_) {}
|
|
4703
|
+
audioCtxRef.current = null
|
|
4704
|
+
}
|
|
4705
|
+
setSignalTesting(false)
|
|
4706
|
+
setCurrentRms(0)
|
|
4707
|
+
}
|
|
4708
|
+
|
|
4709
|
+
React.useEffect(() => {
|
|
4710
|
+
return () => {
|
|
4711
|
+
stopSignalTest()
|
|
4712
|
+
}
|
|
4713
|
+
}, [])
|
|
4714
|
+
|
|
4715
|
+
const requestPermissionAndTestSignal = async () => {
|
|
4716
|
+
stopSignalTest()
|
|
4717
|
+
setTestError('')
|
|
4718
|
+
try {
|
|
4719
|
+
const constraints = {
|
|
4720
|
+
audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
|
|
4721
|
+
}
|
|
4722
|
+
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
|
4723
|
+
signalStreamRef.current = stream
|
|
4724
|
+
setMicPermission('granted')
|
|
4725
|
+
refreshDevices()
|
|
4726
|
+
|
|
4727
|
+
const AC = typeof AudioContext !== 'undefined' ? AudioContext
|
|
4728
|
+
: (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
|
|
4729
|
+
if (!AC) return
|
|
4730
|
+
const actx = new AC()
|
|
4731
|
+
audioCtxRef.current = actx
|
|
4732
|
+
if (actx.state === 'suspended') { actx.resume().catch(() => {}) }
|
|
4733
|
+
const src = actx.createMediaStreamSource(stream)
|
|
4734
|
+
const analyser = actx.createAnalyser()
|
|
4735
|
+
analyser.fftSize = 128
|
|
4736
|
+
src.connect(analyser)
|
|
4737
|
+
|
|
4738
|
+
setSignalTesting(true)
|
|
4739
|
+
const buf = new Uint8Array(analyser.frequencyBinCount)
|
|
4740
|
+
|
|
4741
|
+
const checkLevel = () => {
|
|
4742
|
+
if (!signalStreamRef.current) return
|
|
4743
|
+
analyser.getByteFrequencyData(buf)
|
|
4744
|
+
let sum = 0
|
|
4745
|
+
for (let i = 0; i < buf.length; i++) sum += buf[i]
|
|
4746
|
+
const rms = Math.min(100, Math.round((sum / buf.length / 255) * 100 * 2.2))
|
|
4747
|
+
setCurrentRms(rms)
|
|
4748
|
+
setPeakRms((prev) => Math.max(prev, rms))
|
|
4749
|
+
animFrameRef.current = requestAnimationFrame(checkLevel)
|
|
4750
|
+
}
|
|
4751
|
+
animFrameRef.current = requestAnimationFrame(checkLevel)
|
|
4752
|
+
} catch (err) {
|
|
4753
|
+
setMicPermission('denied')
|
|
4754
|
+
setTestError(String(err && err.message ? err.message : err))
|
|
4755
|
+
stopSignalTest()
|
|
4756
|
+
}
|
|
4757
|
+
}
|
|
4758
|
+
|
|
4759
|
+
const runSttTest = async () => {
|
|
4760
|
+
stopSignalTest()
|
|
4761
|
+
setTestError('')
|
|
4762
|
+
setTestResult(null)
|
|
4763
|
+
setTestLatency(null)
|
|
4764
|
+
setTestingStt(true)
|
|
4765
|
+
setTestCountdown(3)
|
|
4766
|
+
|
|
4767
|
+
let testStream = null
|
|
4768
|
+
let recorder = null
|
|
4769
|
+
const chunks = []
|
|
4770
|
+
|
|
4771
|
+
try {
|
|
4772
|
+
const constraints = {
|
|
4773
|
+
audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
|
|
4774
|
+
}
|
|
4775
|
+
testStream = await navigator.mediaDevices.getUserMedia(constraints)
|
|
4776
|
+
let mime = 'audio/webm;codecs=opus'
|
|
4777
|
+
if (!MediaRecorder.isTypeSupported(mime)) mime = ''
|
|
4778
|
+
recorder = mime ? new MediaRecorder(testStream, { mimeType: mime }) : new MediaRecorder(testStream)
|
|
4779
|
+
recorder.ondataavailable = (e) => {
|
|
4780
|
+
if (e.data && e.data.size > 0) chunks.push(e.data)
|
|
4781
|
+
}
|
|
4782
|
+
recorder.start()
|
|
4783
|
+
|
|
4784
|
+
let left = 3
|
|
4785
|
+
const countdownTimer = setInterval(() => {
|
|
4786
|
+
left -= 1
|
|
4787
|
+
setTestCountdown(left)
|
|
4788
|
+
if (left <= 0) {
|
|
4789
|
+
clearInterval(countdownTimer)
|
|
4790
|
+
if (recorder.state !== 'inactive') recorder.stop()
|
|
4791
|
+
}
|
|
4792
|
+
}, 1000)
|
|
4793
|
+
|
|
4794
|
+
await new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
|
|
4795
|
+
|
|
4796
|
+
try { testStream.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
|
|
4797
|
+
|
|
4798
|
+
const blob = new Blob(chunks, { type: mime || 'audio/webm' })
|
|
4799
|
+
if (blob.size < 400) {
|
|
4800
|
+
setTestError(t('wizardTestEmpty'))
|
|
4801
|
+
setTestingStt(false)
|
|
4802
|
+
return
|
|
4803
|
+
}
|
|
4804
|
+
|
|
4805
|
+
setTestCountdown(0)
|
|
4806
|
+
const startMs = Date.now()
|
|
4807
|
+
const dataBase64 = await blobToBase64(blob)
|
|
4808
|
+
const payload = {
|
|
4809
|
+
dataBase64,
|
|
4810
|
+
mimeType: mime || recorder.mimeType || 'audio/webm',
|
|
4811
|
+
mode: 'dictation',
|
|
4812
|
+
subMode: 'clean',
|
|
4813
|
+
}
|
|
4814
|
+
|
|
4815
|
+
const res = await fetch('/dsh-voice/transcribe', {
|
|
4816
|
+
method: 'POST',
|
|
4817
|
+
headers: { 'content-type': 'application/json' },
|
|
4818
|
+
body: JSON.stringify(payload),
|
|
4819
|
+
})
|
|
4820
|
+
const data = await res.json()
|
|
4821
|
+
const latency = Date.now() - startMs
|
|
4822
|
+
setTestLatency(latency)
|
|
4823
|
+
|
|
4824
|
+
if (!res.ok || !data || !data.ok) {
|
|
4825
|
+
const msg = (data && data.error && data.error.message) || `HTTP ${res.status}`
|
|
4826
|
+
setTestError(msg)
|
|
4827
|
+
} else {
|
|
4828
|
+
const recognized = String(data.text || '').trim()
|
|
4829
|
+
if (recognized) {
|
|
4830
|
+
setTestResult(recognized)
|
|
4831
|
+
if (typeof setTop === 'function') {
|
|
4832
|
+
setTop('wizardCompleted', true)
|
|
4833
|
+
}
|
|
4834
|
+
} else {
|
|
4835
|
+
setTestError(t('wizardTestEmpty'))
|
|
4836
|
+
}
|
|
4837
|
+
}
|
|
4838
|
+
} catch (err) {
|
|
4839
|
+
setTestError(String(err && err.message ? err.message : err))
|
|
4840
|
+
} finally {
|
|
4841
|
+
if (testStream) {
|
|
4842
|
+
try { testStream.getTracks().forEach((trk) => trk.stop()) } catch (_) {}
|
|
4843
|
+
}
|
|
4844
|
+
setTestingStt(false)
|
|
4845
|
+
}
|
|
4846
|
+
}
|
|
4847
|
+
|
|
4848
|
+
// Determine active chain info
|
|
4849
|
+
const dictationChain = (draft && draft.dictation && draft.dictation.chain) || []
|
|
4850
|
+
const activeProvider = (dictationChain[0] && (dictationChain[0].provider || dictationChain[0])) || 'sensevoice'
|
|
4851
|
+
let areaLabel = t('wizardAreaServer')
|
|
4852
|
+
if (activeProvider === 'browser' || activeProvider === 'browser-webgpu') {
|
|
4853
|
+
areaLabel = t('wizardAreaBrowser')
|
|
4854
|
+
} else if (activeProvider !== 'sensevoice' && activeProvider !== 'local-whisper') {
|
|
4855
|
+
areaLabel = t('wizardAreaCloud')
|
|
4856
|
+
}
|
|
4857
|
+
|
|
4858
|
+
let nextStepHint = ''
|
|
4859
|
+
if (!isSecure) {
|
|
4860
|
+
nextStepHint = t('wizardHintHttps')
|
|
4861
|
+
} else if (micPermission === 'denied') {
|
|
4862
|
+
nextStepHint = t('wizardHintPermission')
|
|
4863
|
+
} else if (peakRms === 0 && signalTesting) {
|
|
4864
|
+
nextStepHint = t('wizardHintDevice')
|
|
4865
|
+
} else if (testError) {
|
|
4866
|
+
nextStepHint = t('wizardHintProvider')
|
|
4867
|
+
}
|
|
4868
|
+
|
|
4869
|
+
return React.createElement('div', { className: 'cb-section-card', style: { borderColor: 'var(--dsw-alias-state-brand-primary)' } },
|
|
4870
|
+
React.createElement('div', { className: 'cb-section-title' },
|
|
4871
|
+
React.createElement('span', null, '✨ ' + t('wizardTitle')),
|
|
4872
|
+
React.createElement('div', { style: { display: 'flex', gap: '8px' } },
|
|
4873
|
+
testResult ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '✓ ' + t('wizardStatusCompleted')) : null,
|
|
4874
|
+
onClose ? React.createElement('button', {
|
|
4875
|
+
type: 'button', className: 'cb-btn cb-btn-mini', onClick: onClose, title: t('wizardClose'),
|
|
4876
|
+
}, '✕') : null,
|
|
4877
|
+
),
|
|
4878
|
+
),
|
|
4879
|
+
React.createElement('div', { className: 'cb-section-desc' }, t('wizardDesc')),
|
|
4880
|
+
|
|
4881
|
+
// Step 1: Environment & Permissions
|
|
4882
|
+
React.createElement('div', { className: 'dvs-card' },
|
|
4883
|
+
React.createElement('div', { className: 'dvs-h' }, t('wizardStep1')),
|
|
4884
|
+
React.createElement('div', { className: 'cb-row' },
|
|
4885
|
+
React.createElement('span', { className: 'cb-badge ' + (isSecure ? 'cb-badge-ok' : 'cb-badge-err') },
|
|
4886
|
+
isSecure ? ('✓ ' + t('wizardSecureContextOk')) : ('✗ ' + t('wizardSecureContextBad'))
|
|
4887
|
+
),
|
|
4888
|
+
React.createElement('span', { className: 'cb-badge ' + (hasMediaDevices ? 'cb-badge-ok' : 'cb-badge-err') },
|
|
4889
|
+
hasMediaDevices ? ('✓ ' + t('wizardMediaDevicesOk')) : ('✗ ' + t('wizardMediaDevicesBad'))
|
|
4890
|
+
),
|
|
4891
|
+
React.createElement('span', { className: 'cb-badge ' + (hasMediaRecorder ? 'cb-badge-ok' : 'cb-badge-err') },
|
|
4892
|
+
hasMediaRecorder ? ('✓ ' + t('wizardMediaRecorderOk')) : ('✗ ' + t('wizardMediaRecorderBad'))
|
|
4893
|
+
),
|
|
4894
|
+
React.createElement('span', { className: 'cb-badge ' + (micPermission === 'granted' ? 'cb-badge-ok' : (micPermission === 'denied' ? 'cb-badge-err' : 'cb-badge-warn')) },
|
|
4895
|
+
micPermission === 'granted' ? ('✓ ' + t('wizardMicPermissionOk')) : (micPermission === 'denied' ? ('✗ ' + t('wizardMicPermissionBad')) : ('· ' + t('wizardMicPermissionPrompt')))
|
|
4896
|
+
),
|
|
4897
|
+
),
|
|
4898
|
+
React.createElement('div', { className: 'cb-field', style: { marginTop: '8px' } },
|
|
4899
|
+
React.createElement('label', null, t('wizardMicLabel')),
|
|
4900
|
+
React.createElement('div', { className: 'cb-row' },
|
|
4901
|
+
React.createElement('select', {
|
|
4902
|
+
value: selectedDev,
|
|
4903
|
+
style: { flex: 1, minWidth: '240px' },
|
|
4904
|
+
onChange: (e) => {
|
|
4905
|
+
setSelectedDev(e.target.value)
|
|
4906
|
+
if (typeof setTop === 'function') setTop('micDeviceId', e.target.value)
|
|
4907
|
+
stopSignalTest()
|
|
4908
|
+
},
|
|
4909
|
+
},
|
|
4910
|
+
React.createElement('option', { value: '' }, 'Default System Microphone'),
|
|
4911
|
+
devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId }, d.label || `Microphone ${d.deviceId.slice(0, 8)}…`))
|
|
4912
|
+
),
|
|
4913
|
+
React.createElement('button', {
|
|
4914
|
+
type: 'button',
|
|
4915
|
+
className: 'cb-btn',
|
|
4916
|
+
onClick: requestPermissionAndTestSignal,
|
|
4917
|
+
}, t('wizardRequestMic')),
|
|
4918
|
+
),
|
|
4919
|
+
),
|
|
4920
|
+
),
|
|
4921
|
+
|
|
4922
|
+
// Step 2: Live Audio Signal Test
|
|
4923
|
+
React.createElement('div', { className: 'dvs-card' },
|
|
4924
|
+
React.createElement('div', { className: 'dvs-h' }, t('wizardStep2')),
|
|
4925
|
+
React.createElement('div', { className: 'cb-row', style: { alignItems: 'center' } },
|
|
4926
|
+
React.createElement('button', {
|
|
4927
|
+
type: 'button',
|
|
4928
|
+
className: 'cb-btn ' + (signalTesting ? 'cb-btn-danger' : 'cb-btn-primary'),
|
|
4929
|
+
onClick: signalTesting ? stopSignalTest : requestPermissionAndTestSignal,
|
|
4930
|
+
}, signalTesting ? t('wizardStopSignalCheck') : t('wizardStartSignalCheck')),
|
|
4931
|
+
signalTesting ? React.createElement('span', { className: 'dvs-sub' }, t('wizardSignalTesting')) : null,
|
|
4932
|
+
),
|
|
4933
|
+
signalTesting ? React.createElement('div', { style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '6px' } },
|
|
4934
|
+
React.createElement('div', {
|
|
4935
|
+
style: {
|
|
4936
|
+
height: '14px',
|
|
4937
|
+
borderRadius: '7px',
|
|
4938
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
4939
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
4940
|
+
overflow: 'hidden',
|
|
4941
|
+
position: 'relative',
|
|
4942
|
+
},
|
|
4943
|
+
},
|
|
4944
|
+
React.createElement('div', {
|
|
4945
|
+
style: {
|
|
4946
|
+
width: `${currentRms}%`,
|
|
4947
|
+
height: '100%',
|
|
4948
|
+
background: currentRms > 5 ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-dimmed)',
|
|
4949
|
+
transition: 'width 0.05s linear',
|
|
4950
|
+
},
|
|
4951
|
+
}),
|
|
4952
|
+
),
|
|
4953
|
+
React.createElement('span', { className: currentRms > 5 ? 'dvs-ok' : 'dvs-sub' },
|
|
4954
|
+
currentRms > 5
|
|
4955
|
+
? t('wizardSignalOk').replace('{level}', currentRms)
|
|
4956
|
+
: t('wizardSignalZero')
|
|
4957
|
+
),
|
|
4958
|
+
) : null,
|
|
4959
|
+
),
|
|
4960
|
+
|
|
4961
|
+
// Step 3: Transcription Round-Trip Test
|
|
4962
|
+
React.createElement('div', { className: 'dvs-card' },
|
|
4963
|
+
React.createElement('div', { className: 'dvs-h' }, t('wizardStep3')),
|
|
4964
|
+
React.createElement('div', { className: 'cb-row' },
|
|
4965
|
+
React.createElement('span', { className: 'dvs-sub' }, `${t('wizardProviderLabel')} ${activeProvider}`),
|
|
4966
|
+
React.createElement('span', { className: 'cb-badge' }, `${t('wizardProcessingArea')} ${areaLabel}`),
|
|
4967
|
+
),
|
|
4968
|
+
React.createElement('div', { className: 'cb-row', style: { marginTop: '8px' } },
|
|
4969
|
+
React.createElement('button', {
|
|
4970
|
+
type: 'button',
|
|
4971
|
+
className: 'cb-btn cb-btn-primary',
|
|
4972
|
+
disabled: testingStt || micPermission === 'denied',
|
|
4973
|
+
onClick: runSttTest,
|
|
4974
|
+
}, testingStt
|
|
4975
|
+
? (testCountdown > 0 ? t('wizardRecordingTest').replace('{left}', testCountdown) : t('wizardTranscribingTest'))
|
|
4976
|
+
: t('wizardStartTestRecord')
|
|
4977
|
+
),
|
|
4978
|
+
),
|
|
4979
|
+
testResult ? React.createElement('div', { style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '4px' } },
|
|
4980
|
+
React.createElement('div', { className: 'dvs-ok' },
|
|
4981
|
+
'✓ ' + t('wizardTestSuccess').replace('{latency}', testLatency || 0)
|
|
4982
|
+
),
|
|
4983
|
+
React.createElement('blockquote', {
|
|
4984
|
+
style: {
|
|
4985
|
+
margin: '4px 0 0 0',
|
|
4986
|
+
padding: '8px 12px',
|
|
4987
|
+
borderRadius: '6px',
|
|
4988
|
+
background: 'var(--dsw-alias-bg-layer-3)',
|
|
4989
|
+
borderLeft: '4px solid var(--dsw-alias-state-success-primary)',
|
|
4990
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
4991
|
+
fontStyle: 'italic',
|
|
4992
|
+
},
|
|
4993
|
+
}, `“${testResult}”`),
|
|
4994
|
+
) : null,
|
|
4995
|
+
testError ? React.createElement('div', { className: 'cb-alert-err', style: { marginTop: '8px' } },
|
|
4996
|
+
t('wizardTestFailed').replace('{error}', testError)
|
|
4997
|
+
) : null,
|
|
4998
|
+
),
|
|
4999
|
+
|
|
5000
|
+
// Next Step Hint / Feedback
|
|
5001
|
+
nextStepHint ? React.createElement('div', { className: 'dvs-wait', style: { fontStyle: 'italic' } },
|
|
5002
|
+
t('wizardNextStepHint').replace('{hint}', nextStepHint)
|
|
5003
|
+
) : null,
|
|
5004
|
+
|
|
5005
|
+
// Wizard Bottom Actions
|
|
5006
|
+
React.createElement('div', { className: 'cb-row', style: { justifyContent: 'space-between', marginTop: '6px' } },
|
|
5007
|
+
React.createElement('button', {
|
|
5008
|
+
type: 'button',
|
|
5009
|
+
className: 'cb-btn',
|
|
5010
|
+
onClick: () => {
|
|
5011
|
+
if (typeof setTop === 'function') setTop('wizardCompleted', true)
|
|
5012
|
+
if (onClose) onClose()
|
|
5013
|
+
},
|
|
5014
|
+
}, t('wizardSkip')),
|
|
5015
|
+
testResult ? React.createElement('button', {
|
|
5016
|
+
type: 'button',
|
|
5017
|
+
className: 'cb-btn cb-btn-primary',
|
|
5018
|
+
onClick: () => {
|
|
5019
|
+
if (typeof setTop === 'function') setTop('wizardCompleted', true)
|
|
5020
|
+
if (onClose) onClose()
|
|
5021
|
+
},
|
|
5022
|
+
}, '✓ ' + t('wizardFinishSuccess')) : null,
|
|
5023
|
+
),
|
|
5024
|
+
)
|
|
5025
|
+
}
|
|
4454
5026
|
// Card in Settings → Plugins → Plugin settings (#18)
|
|
4455
5027
|
function PluginCard(props) {
|
|
4456
5028
|
const page = !!(props && props.view === 'page')
|
package/lib/host-routes.js
CHANGED
|
@@ -96,6 +96,7 @@ export function registerHostRoutes(ctx, deps) {
|
|
|
96
96
|
dictationPrompts: cfg.dictationPrompts || {},
|
|
97
97
|
voiceMacros: Array.isArray(cfg.voiceMacros) ? cfg.voiceMacros : [],
|
|
98
98
|
providerStats: statsTracker.get(),
|
|
99
|
+
recentDiagnostics: typeof statsTracker.getDiagnostics === 'function' ? statsTracker.getDiagnostics() : [],
|
|
99
100
|
})
|
|
100
101
|
},
|
|
101
102
|
}), 'dsh-voice: /status route')
|
|
@@ -133,6 +134,19 @@ export function registerHostRoutes(ctx, deps) {
|
|
|
133
134
|
},
|
|
134
135
|
}), 'dsh-voice: /polish route')
|
|
135
136
|
|
|
137
|
+
ctx.effect(() => ctx.webServer.register({
|
|
138
|
+
kind: 'exact',
|
|
139
|
+
path: '/dsh-voice/diagnostics/export',
|
|
140
|
+
handler: async (req, res) => {
|
|
141
|
+
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
142
|
+
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
143
|
+
const report = typeof statsTracker.exportAnonymizedReport === 'function'
|
|
144
|
+
? statsTracker.exportAnonymizedReport({ version: '0.9.34', environment: 'production' })
|
|
145
|
+
: { summary: statsTracker.get(), recentDiagnostics: [] }
|
|
146
|
+
writeJson(res, 200, { ok: true, report })
|
|
147
|
+
},
|
|
148
|
+
}), 'dsh-voice: /diagnostics/export route')
|
|
149
|
+
|
|
136
150
|
ctx.effect(() => ctx.webServer.register({
|
|
137
151
|
kind: 'exact',
|
|
138
152
|
path: '/dsh-voice/transcribe',
|
|
@@ -178,13 +192,31 @@ export function registerHostRoutes(ctx, deps) {
|
|
|
178
192
|
await startSensevoice()
|
|
179
193
|
}
|
|
180
194
|
|
|
195
|
+
const reqStart = Date.now()
|
|
181
196
|
const controller = new AbortController()
|
|
182
197
|
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
198
|
+
let out = null
|
|
199
|
+
let polishTookMs = 0
|
|
183
200
|
try {
|
|
184
|
-
|
|
201
|
+
out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
|
|
185
202
|
if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
|
|
186
203
|
const cmd = sessionCommand(out.text)
|
|
187
|
-
if (cmd) {
|
|
204
|
+
if (cmd) {
|
|
205
|
+
if (typeof statsTracker.recordDiagnostic === 'function') {
|
|
206
|
+
statsTracker.recordDiagnostic({
|
|
207
|
+
mode: payload.mode || 'dictation',
|
|
208
|
+
subMode: 'command',
|
|
209
|
+
mimeType: mime,
|
|
210
|
+
audioBytes: bytes.length,
|
|
211
|
+
provider: out.provider,
|
|
212
|
+
success: true,
|
|
213
|
+
totalTookMs: Date.now() - reqStart,
|
|
214
|
+
sttMs: out.tookMs || 0,
|
|
215
|
+
vadMs: Number(payload.vadMs) || 0,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return
|
|
219
|
+
}
|
|
188
220
|
}
|
|
189
221
|
const subMode = payload.subMode || payload.dictationMode || cfg.dictationMode || 'clean'
|
|
190
222
|
const rawText = out.text
|
|
@@ -192,7 +224,9 @@ export function registerHostRoutes(ctx, deps) {
|
|
|
192
224
|
|
|
193
225
|
// Dictation modes & polish (#264): verbatim skips LLM entirely (0 LLM calls)
|
|
194
226
|
if (subMode !== 'verbatim' && (modeCfg.polish === true || payload.polish === true)) {
|
|
227
|
+
const polishStart = Date.now()
|
|
195
228
|
text = await polishText(rawText, { ...modeCfg, subMode, prompt: payload.prompt }, controller.signal)
|
|
229
|
+
polishTookMs = Date.now() - polishStart
|
|
196
230
|
}
|
|
197
231
|
|
|
198
232
|
// Voice action macros (#167)
|
|
@@ -211,8 +245,37 @@ export function registerHostRoutes(ctx, deps) {
|
|
|
211
245
|
action = actionRes.action
|
|
212
246
|
}
|
|
213
247
|
}
|
|
214
|
-
|
|
248
|
+
const totalTookMs = Date.now() - reqStart
|
|
249
|
+
if (typeof statsTracker.recordDiagnostic === 'function') {
|
|
250
|
+
statsTracker.recordDiagnostic({
|
|
251
|
+
mode: payload.mode || 'dictation',
|
|
252
|
+
subMode,
|
|
253
|
+
mimeType: mime,
|
|
254
|
+
audioBytes: bytes.length,
|
|
255
|
+
provider: out.provider,
|
|
256
|
+
success: true,
|
|
257
|
+
totalTookMs,
|
|
258
|
+
sttMs: out.tookMs || 0,
|
|
259
|
+
polishMs: polishTookMs,
|
|
260
|
+
vadMs: Number(payload.vadMs) || 0,
|
|
261
|
+
wordCount: text ? text.split(/\s+/).filter(Boolean).length : 0,
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
writeJson(res, 200, { ok: true, text, rawText, subMode, action, provider: out.provider, tookMs: out.tookMs, polishTookMs, totalTookMs })
|
|
215
265
|
} catch (e) {
|
|
266
|
+
if (typeof statsTracker.recordDiagnostic === 'function') {
|
|
267
|
+
statsTracker.recordDiagnostic({
|
|
268
|
+
mode: payload.mode || 'dictation',
|
|
269
|
+
subMode: payload.subMode || 'clean',
|
|
270
|
+
mimeType: mime,
|
|
271
|
+
audioBytes: bytes.length,
|
|
272
|
+
provider: (out && out.provider) || 'unknown',
|
|
273
|
+
success: false,
|
|
274
|
+
totalTookMs: Date.now() - reqStart,
|
|
275
|
+
vadMs: Number(payload.vadMs) || 0,
|
|
276
|
+
error: String(e && e.message || e),
|
|
277
|
+
})
|
|
278
|
+
}
|
|
216
279
|
writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
|
|
217
280
|
} finally {
|
|
218
281
|
clearTimeout(timer)
|
package/lib/stats.js
CHANGED
|
@@ -3,6 +3,55 @@
|
|
|
3
3
|
|
|
4
4
|
export function createStatsTracker() {
|
|
5
5
|
const stats = new Map()
|
|
6
|
+
const diagnostics = []
|
|
7
|
+
const MAX_DIAGNOSTICS = 50
|
|
8
|
+
|
|
9
|
+
function recordDiagnostic(entry = {}) {
|
|
10
|
+
diagnostics.push({
|
|
11
|
+
id: Math.random().toString(36).slice(2, 10),
|
|
12
|
+
timestamp: Date.now(),
|
|
13
|
+
mode: entry.mode || 'dictation',
|
|
14
|
+
subMode: entry.subMode || 'clean',
|
|
15
|
+
mimeType: entry.mimeType || 'audio/webm',
|
|
16
|
+
audioBytes: Number(entry.audioBytes) || 0,
|
|
17
|
+
provider: entry.provider || 'unknown',
|
|
18
|
+
success: !!entry.success,
|
|
19
|
+
totalTookMs: Number(entry.totalTookMs) || 0,
|
|
20
|
+
breakdown: {
|
|
21
|
+
vadMs: Number(entry.vadMs) || 0,
|
|
22
|
+
sttMs: Number(entry.sttMs) || 0,
|
|
23
|
+
polishMs: Number(entry.polishMs) || 0,
|
|
24
|
+
},
|
|
25
|
+
chainAttempts: Array.isArray(entry.chainAttempts) ? entry.chainAttempts.map((a) => ({
|
|
26
|
+
provider: a.provider || 'unknown',
|
|
27
|
+
ok: !!a.ok,
|
|
28
|
+
tookMs: Number(a.tookMs) || 0,
|
|
29
|
+
reason: a.reason ? String(a.reason).slice(0, 150) : undefined,
|
|
30
|
+
})) : [],
|
|
31
|
+
error: entry.error ? String(entry.error).slice(0, 200) : undefined,
|
|
32
|
+
wordCount: typeof entry.wordCount === 'number' ? entry.wordCount : undefined,
|
|
33
|
+
})
|
|
34
|
+
if (diagnostics.length > MAX_DIAGNOSTICS) {
|
|
35
|
+
diagnostics.shift()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getDiagnostics() {
|
|
40
|
+
return diagnostics.slice()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function exportAnonymizedReport(appInfo = {}) {
|
|
44
|
+
return {
|
|
45
|
+
exportedAt: new Date().toISOString(),
|
|
46
|
+
app: {
|
|
47
|
+
name: '@goodandready/dsh-voice',
|
|
48
|
+
version: appInfo.version || '0.9.34',
|
|
49
|
+
environment: appInfo.environment || 'production',
|
|
50
|
+
},
|
|
51
|
+
summary: get(),
|
|
52
|
+
recentDiagnostics: getDiagnostics(),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
6
55
|
|
|
7
56
|
function record(key, res = {}) {
|
|
8
57
|
if (!key) return
|
|
@@ -51,9 +100,10 @@ export function createStatsTracker() {
|
|
|
51
100
|
|
|
52
101
|
function clear() {
|
|
53
102
|
stats.clear()
|
|
103
|
+
diagnostics.length = 0
|
|
54
104
|
}
|
|
55
105
|
|
|
56
|
-
return { record, get, clear }
|
|
106
|
+
return { record, get, clear, recordDiagnostic, getDiagnostics, exportAnonymizedReport }
|
|
57
107
|
}
|
|
58
108
|
|
|
59
109
|
export function mergeContextVocabulary(baseVocab = [], contextWords = [], maxWords = 50) {
|
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
|
|
22
|
-
const
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
3
|
+
"version": "0.9.34",
|
|
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",
|