@goodandready/dsh-voice 0.9.6 → 0.9.7

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
@@ -2020,14 +2020,20 @@ window.__ModuleLoader__.load({
2020
2020
  // locale: NS is required — the core resolves `label` through the inject face,
2021
2021
  // and without it the label throws "cannot get property t without inject" while
2022
2022
  // the slot snapshot is built, breaking the whole client batch.
2023
- ctx.slots.inject('conversation.input.right', () => ctx.slots.register(
2024
- { name: 'conversation.input.right', id: '@goodandready/dsh-voice', order: 6, locale: NS, label: () => t('title') },
2025
- (props) => React.createElement(VoiceButtons, { input: props.input, inputActions: props.inputActions }),
2026
- ))
2027
- ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
2028
- { name: 'conversation.input.dock', id: 'dsh-voice-rec', order: 0, locale: NS, label: () => t('recordSlot') },
2029
- (props) => React.createElement(RecordPill, { input: props.input, inputActions: props.inputActions, ctx: ctx }),
2030
- ))
2023
+ ctx.effect(() => {
2024
+ const offRight = ctx.slots.inject('conversation.input.right', () => ctx.slots.register(
2025
+ { name: 'conversation.input.right', id: '@goodandready/dsh-voice', order: 6, locale: NS, label: () => t('title') },
2026
+ (props) => React.createElement(VoiceButtons, { input: props.input, inputActions: props.inputActions }),
2027
+ ))
2028
+ const offDock = ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
2029
+ { name: 'conversation.input.dock', id: 'dsh-voice-rec', order: 0, locale: NS, label: () => t('recordSlot') },
2030
+ (props) => React.createElement(RecordPill, { input: props.input, inputActions: props.inputActions, ctx: ctx }),
2031
+ ))
2032
+ return () => {
2033
+ if (typeof offRight === 'function') offRight()
2034
+ if (typeof offDock === 'function') offDock()
2035
+ }
2036
+ }, 'dsh-voice: composer slots')
2031
2037
  }
2032
2038
 
2033
2039
  // ------------------------------------------------------- settings page
@@ -2158,6 +2164,7 @@ window.__ModuleLoader__.load({
2158
2164
 
2159
2165
  // One chain editor: provider+model rows with reordering.
2160
2166
  function ChainEditor(props) {
2167
+ const t = (props && props.t) || moduleT
2161
2168
  const rows = Array.isArray(props.value) ? props.value : []
2162
2169
  const change = (i, patch) => {
2163
2170
  const next = rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r))
@@ -2198,6 +2205,7 @@ window.__ModuleLoader__.load({
2198
2205
 
2199
2206
  // Custom providers: name, API template, where to call, how to authorize.
2200
2207
  function CustomEditor(props) {
2208
+ const t = (props && props.t) || moduleT
2201
2209
  const rows = Array.isArray(props.value) ? props.value : []
2202
2210
  const change = (i, patch) => props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
2203
2211
  const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
@@ -2216,7 +2224,7 @@ window.__ModuleLoader__.load({
2216
2224
  React.createElement('select', {
2217
2225
  value: row.template || 'openai-transcriptions', disabled: !props.writable,
2218
2226
  onChange: (e) => change(i, { template: e.target.value }),
2219
- }, TEMPLATES.map((t) => React.createElement('option', { key: t, value: t }, t))),
2227
+ }, TEMPLATES.map((tmpl) => React.createElement('option', { key: tmpl, value: tmpl }, tmpl))),
2220
2228
  React.createElement('button', {
2221
2229
  type: 'button', className: 'dvs-mini', title: t('remove'),
2222
2230
  disabled: !props.writable, onClick: () => remove(i),
@@ -2244,6 +2252,598 @@ window.__ModuleLoader__.load({
2244
2252
  ),
2245
2253
  )
2246
2254
  }
2255
+ // Provider Telemetry & Latency Dashboard
2256
+ function ProviderDashboard(props) {
2257
+ const t = props.t || moduleT
2258
+ const statsData = props.statsData || {}
2259
+ return React.createElement('div', { className: 'cb-section-card' },
2260
+ React.createElement('div', { className: 'cb-section-title' },
2261
+ React.createElement('span', null, '📊 ' + t('providerDashboard')),
2262
+ React.createElement('button', {
2263
+ type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
2264
+ onClick: props.onRefresh,
2265
+ }, '🔄 ' + t('refreshStats')),
2266
+ ),
2267
+ React.createElement('div', { className: 'dvo-dash' },
2268
+ React.createElement('div', { className: 'dvo-dash-grid' },
2269
+ Object.keys(statsData).length === 0
2270
+ ? React.createElement('div', { className: 'dvs-sub' }, t('idle'))
2271
+ : Object.entries(statsData).map(([key, item]) => {
2272
+ const avg = item.avgTookMs || 0
2273
+ const hasErrors = item.failures > 0
2274
+ let badgeClass = 'dvo-badge-idle'
2275
+ let badgeText = t('idle')
2276
+ if (item.attempts > 0) {
2277
+ if (hasErrors && item.successes === 0) {
2278
+ badgeClass = 'dvo-badge-err'
2279
+ badgeText = t('error')
2280
+ } else if (avg > 0 && avg < 400) {
2281
+ badgeClass = 'dvo-badge-fast'
2282
+ badgeText = avg + 'ms · ' + t('fast')
2283
+ } else if (avg >= 400 && avg <= 1500) {
2284
+ badgeClass = 'dvo-badge-norm'
2285
+ badgeText = avg + 'ms · ' + t('normal')
2286
+ } else {
2287
+ badgeClass = 'dvo-badge-slow'
2288
+ badgeText = avg + 'ms · ' + t('slow')
2289
+ }
2290
+ }
2291
+ const rate = item.attempts > 0 ? Math.round((item.successes / item.attempts) * 100) : 100
2292
+ return React.createElement('div', { key, className: 'dvo-dash-item', title: item.lastError ? `Error: ${item.lastError}` : '' },
2293
+ React.createElement('div', { className: 'dvo-dash-name' }, key),
2294
+ React.createElement('div', { className: 'dvo-dash-row' },
2295
+ React.createElement('span', { className: 'dvo-badge ' + badgeClass }, badgeText),
2296
+ React.createElement('span', null, rate + '%'),
2297
+ ),
2298
+ React.createElement('div', { className: 'dvo-dash-row' },
2299
+ React.createElement('span', null, `${item.successes}/${item.attempts}`),
2300
+ React.createElement('span', null, t('successRate')),
2301
+ ),
2302
+ )
2303
+ }),
2304
+ ),
2305
+ ),
2306
+ )
2307
+ }
2308
+ // 5. Hardware & Audio Engines Card
2309
+ function HardwareOptionsCard(props) {
2310
+ const t = props.t || moduleT
2311
+ const draft = props.draft
2312
+ const setTop = props.setTop
2313
+ const writable = props.writable
2314
+ const catching = props.catching
2315
+ const setCatching = props.setCatching
2316
+ const devices = props.devices || []
2317
+ const testingMic = props.testingMic
2318
+ const toggleTestMic = props.toggleTestMic
2319
+ const testLevel = props.testLevel || 0
2320
+ const gateDbVal = props.gateDbVal
2321
+ const gateThresholdPercent = props.gateThresholdPercent
2322
+ const onClearKey = props.onClearKey
2323
+
2324
+ const textField = (key, label, hint) => React.createElement('div', { className: 'cb-field' },
2325
+ React.createElement('label', null, label),
2326
+ React.createElement('input', {
2327
+ type: 'text', value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
2328
+ onChange: (e) => setTop(key, e.target.value),
2329
+ }),
2330
+ React.createElement('span', { className: 'dvs-sub' }, hint),
2331
+ )
2332
+
2333
+ return React.createElement('div', { className: 'cb-section-card' },
2334
+ React.createElement('div', { className: 'cb-section-title' },
2335
+ React.createElement('span', null, '⚙️ ' + t('hardwareTitle')),
2336
+ ),
2337
+ React.createElement('div', { className: 'cb-section-desc' }, t('hardwareDesc')),
2338
+ // Hotkey picker
2339
+ React.createElement('div', { className: 'cb-field' },
2340
+ React.createElement('label', null, t('hotkey')),
2341
+ React.createElement('div', { className: 'cb-row' },
2342
+ React.createElement('button', {
2343
+ type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable,
2344
+ onClick: () => setCatching(true),
2345
+ }, catching ? t('pressKey') : keyLabel(draft && draft.hotkey)),
2346
+ React.createElement('button', {
2347
+ type: 'button', className: 'cb-btn cb-btn-mini', title: t('clearKey'),
2348
+ disabled: !writable, onClick: onClearKey,
2349
+ }, '×'),
2350
+ ),
2351
+ React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
2352
+ ),
2353
+ // Mic & noise gate
2354
+ React.createElement('div', { className: 'cb-field' },
2355
+ React.createElement('label', null, t('mic')),
2356
+ React.createElement('div', { className: 'cb-row' },
2357
+ React.createElement('select', {
2358
+ value: String((draft && draft.micDeviceId) ?? ''), disabled: !writable || testingMic,
2359
+ onChange: (e) => setTop('micDeviceId', e.target.value),
2360
+ style: { flex: 1 },
2361
+ },
2362
+ React.createElement('option', { value: '' }, t('micDefault')),
2363
+ devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
2364
+ d.label || d.deviceId.slice(0, 12))),
2365
+ ),
2366
+ React.createElement('button', {
2367
+ type: 'button',
2368
+ className: 'cb-btn ' + (testingMic ? 'cb-btn-primary' : 'cb-btn-secondary'),
2369
+ onClick: toggleTestMic,
2370
+ style: { flex: 'none' },
2371
+ }, testingMic ? t('stopTest') : t('testMic')),
2372
+ ),
2373
+ testingMic ? React.createElement('div', { className: 'dvo-mic-test' },
2374
+ React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, t('micLevel') + ':'),
2375
+ React.createElement('div', { className: 'dvo-meter-bar' },
2376
+ React.createElement('div', { className: 'dvo-meter-fill', style: { width: `${testLevel}%` } }),
2377
+ gateThresholdPercent > 0 ? React.createElement('div', { className: 'dvo-meter-gate', style: { left: `${gateThresholdPercent}%` }, title: `${gateDbVal} dB` }) : null,
2378
+ ),
2379
+ React.createElement('span', { style: { fontSize: '11px', fontVariantNumeric: 'tabular-nums', width: '32px', textAlign: 'right' } }, `${testLevel}%`),
2380
+ ) : null,
2381
+ React.createElement('div', { className: 'cb-row', style: { marginTop: '6px' } },
2382
+ React.createElement('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)', minWidth: '120px' } }, t('noiseGate') + ':'),
2383
+ React.createElement('select', {
2384
+ value: String(gateDbVal), disabled: !writable,
2385
+ onChange: (e) => setTop('noiseGateDb', Number(e.target.value)),
2386
+ style: { flex: 1 },
2387
+ },
2388
+ React.createElement('option', { value: '-999' }, t('noiseGateOff')),
2389
+ React.createElement('option', { value: '-50' }, '-50 dB (Low / Gentle)'),
2390
+ React.createElement('option', { value: '-45' }, '-45 dB (Standard Default)'),
2391
+ React.createElement('option', { value: '-35' }, '-35 dB (Medium Room)'),
2392
+ React.createElement('option', { value: '-25' }, '-25 dB (High / Mechanical Clicks)'),
2393
+ ),
2394
+ ),
2395
+ React.createElement('span', { className: 'dvs-sub' }, t('noiseGateHint')),
2396
+ ),
2397
+ // Endpoint text fields
2398
+ React.createElement('div', { className: 'cb-grid-2' },
2399
+ textField('whisperUrl', t('whisperEndpoint'), t('whisperEndpointHint')),
2400
+ textField('whisperBin', t('whisperBin'), t('whisperBinHint')),
2401
+ textField('whisperModel', t('whisperModel'), t('whisperModelHint')),
2402
+ textField('deepgramBaseUrl', t('deepgramEndpoint'), t('deepgramEndpointHint')),
2403
+ textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
2404
+ textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
2405
+ textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceModelHint')),
2406
+ textField('polishBaseUrl', t('polishBaseUrl'), t('polishBaseUrlHint')),
2407
+ textField('polishModel', t('polishModel'), ''),
2408
+ textField('polishKeyEnv', t('polishKeyEnv'), ''),
2409
+ ),
2410
+ // Engine checkboxes
2411
+ React.createElement('div', { className: 'cb-row' },
2412
+ React.createElement('label', { className: 'cb-check-label' },
2413
+ React.createElement('input', {
2414
+ type: 'checkbox', checked: !!(draft && draft.autoStart), disabled: !writable,
2415
+ onChange: (e) => setTop('autoStart', e.target.checked),
2416
+ }),
2417
+ t('whisperAutostart'),
2418
+ ),
2419
+ React.createElement('label', { className: 'cb-check-label' },
2420
+ React.createElement('input', {
2421
+ type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
2422
+ onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
2423
+ }),
2424
+ t('sensevoiceAutostart'),
2425
+ ),
2426
+ React.createElement('label', { className: 'cb-check-label' },
2427
+ React.createElement('input', {
2428
+ type: 'checkbox', checked: !!(draft && draft.localOnly), disabled: !writable,
2429
+ onChange: (e) => setTop('localOnly', e.target.checked),
2430
+ }),
2431
+ t('localOnly'),
2432
+ ),
2433
+ React.createElement('label', { className: 'cb-check-label' },
2434
+ React.createElement('input', {
2435
+ type: 'checkbox', checked: !!(draft && draft.beep), disabled: !writable,
2436
+ onChange: (e) => setTop('beep', e.target.checked),
2437
+ }),
2438
+ t('beep'),
2439
+ ),
2440
+ React.createElement('label', { className: 'cb-check-label' },
2441
+ React.createElement('input', {
2442
+ type: 'checkbox', checked: draft ? draft.noiseSuppression !== false : true, disabled: !writable,
2443
+ onChange: (e) => setTop('noiseSuppression', e.target.checked),
2444
+ }),
2445
+ t('noiseSuppression'),
2446
+ ),
2447
+ React.createElement('label', { className: 'cb-check-label' },
2448
+ React.createElement('input', {
2449
+ type: 'checkbox', checked: draft ? draft.contextGlossary !== false : true, disabled: !writable,
2450
+ onChange: (e) => setTop('contextGlossary', e.target.checked),
2451
+ }),
2452
+ t('contextGlossary'),
2453
+ ),
2454
+ React.createElement('label', { className: 'cb-check-label' },
2455
+ React.createElement('input', {
2456
+ type: 'checkbox', checked: !!(draft && draft.voiceCommands), disabled: !writable,
2457
+ onChange: (e) => setTop('voiceCommands', e.target.checked),
2458
+ }),
2459
+ t('voiceCommandsLabel'),
2460
+ ),
2461
+ React.createElement('label', { className: 'cb-check-label' },
2462
+ React.createElement('input', {
2463
+ type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
2464
+ onChange: (e) => setTop('normalizeTranscript', e.target.checked),
2465
+ }),
2466
+ t('normalizeTranscript'),
2467
+ ),
2468
+ ),
2469
+ // Visualizer select
2470
+ React.createElement('div', { className: 'cb-field' },
2471
+ React.createElement('label', null, t('visualizerStyle')),
2472
+ React.createElement('select', {
2473
+ value: String((draft && draft.visualizerStyle) || 'liquid-wave'), disabled: !writable,
2474
+ onChange: (e) => setTop('visualizerStyle', e.target.value),
2475
+ },
2476
+ React.createElement('option', { value: 'liquid-wave' }, t('visLiquidWave')),
2477
+ React.createElement('option', { value: 'dynamic-orb' }, t('visDynamicOrb')),
2478
+ React.createElement('option', { value: 'bars' }, t('visBars')),
2479
+ React.createElement('option', { value: 'off' }, t('visOff')),
2480
+ ),
2481
+ ),
2482
+ // Vocabulary textarea
2483
+ React.createElement('div', { className: 'cb-field' },
2484
+ React.createElement('label', null, t('vocabulary')),
2485
+ React.createElement('textarea', {
2486
+ rows: 3, disabled: !writable,
2487
+ value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
2488
+ onChange: (e) => setTop('vocabulary', e.target.value.split('\n').map((x) => x.trim()).filter(Boolean)),
2489
+ }),
2490
+ ),
2491
+ )
2492
+ }
2493
+ // Custom hooks for VoiceSection modularity
2494
+ function useSensevoiceInstaller(fetchStatus) {
2495
+ const [sensevoiceState, setSensevoiceState] = React.useState(null)
2496
+ const [installingSensevoice, setInstallingSensevoice] = React.useState(false)
2497
+
2498
+ const fetchSensevoice = () => {
2499
+ fetch('/dsh-voice/sensevoice-installer')
2500
+ .then((r) => r.json())
2501
+ .then((d) => {
2502
+ setSensevoiceState(d)
2503
+ setInstallingSensevoice(!!(d && d.installing))
2504
+ })
2505
+ .catch(() => {})
2506
+ }
2507
+
2508
+ React.useEffect(() => {
2509
+ fetchSensevoice()
2510
+ }, [])
2511
+
2512
+ const triggerInstallSensevoice = () => {
2513
+ setInstallingSensevoice(true)
2514
+ fetch('/dsh-voice/sensevoice-installer', {
2515
+ method: 'POST',
2516
+ headers: {
2517
+ 'x-dsh-plugin-update': '1',
2518
+ 'Content-Type': 'application/json',
2519
+ },
2520
+ })
2521
+ .then((r) => r.json())
2522
+ .then(() => {
2523
+ const timer = setInterval(() => {
2524
+ fetch('/dsh-voice/sensevoice-installer')
2525
+ .then((r) => r.json())
2526
+ .then((d) => {
2527
+ setSensevoiceState(d)
2528
+ if (!d.installing) {
2529
+ clearInterval(timer)
2530
+ setInstallingSensevoice(false)
2531
+ if (typeof fetchStatus === 'function') fetchStatus()
2532
+ }
2533
+ })
2534
+ .catch(() => clearInterval(timer))
2535
+ }, 2000)
2536
+ })
2537
+ .catch(() => setInstallingSensevoice(false))
2538
+ }
2539
+
2540
+ return { sensevoiceState, installingSensevoice, triggerInstallSensevoice }
2541
+ }
2542
+
2543
+ function useMicTester(draft, value, setErr) {
2544
+ const [testingMic, setTestingMic] = React.useState(false)
2545
+ const [testLevel, setTestLevel] = React.useState(0)
2546
+ const testRef = React.useRef(null)
2547
+
2548
+ const toggleTestMic = async () => {
2549
+ if (testingMic) {
2550
+ if (testRef.current) {
2551
+ testRef.current.active = false
2552
+ try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
2553
+ if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) { /* already closed */ } }
2554
+ testRef.current = null
2555
+ }
2556
+ setTestingMic(false)
2557
+ setTestLevel(0)
2558
+ return
2559
+ }
2560
+ try {
2561
+ const devId = (draft && draft.micDeviceId) || (value && value.micDeviceId) || ''
2562
+ const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
2563
+ if (devId) audio.deviceId = { exact: devId }
2564
+ const stream = await navigator.mediaDevices.getUserMedia({ audio })
2565
+ const AC = typeof AudioContext !== 'undefined' ? AudioContext : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
2566
+ if (!AC) return
2567
+ const audioCtx = new AC()
2568
+ if (audioCtx.state === 'suspended') await audioCtx.resume().catch(() => {})
2569
+ const src = audioCtx.createMediaStreamSource(stream)
2570
+ let lastNode = src
2571
+ try {
2572
+ const filter = audioCtx.createBiquadFilter()
2573
+ filter.type = 'highpass'
2574
+ filter.frequency.value = 80
2575
+ filter.Q.value = 0.707
2576
+ src.connect(filter)
2577
+ lastNode = filter
2578
+ } catch (e) { /* filter fallback */ }
2579
+ const analyser = audioCtx.createAnalyser()
2580
+ analyser.fftSize = 128
2581
+ lastNode.connect(analyser)
2582
+ testRef.current = { stream, audioCtx, analyser, active: true }
2583
+ setTestingMic(true)
2584
+
2585
+ const check = () => {
2586
+ if (!testRef.current || !testRef.current.active) return
2587
+ const arr = new Uint8Array(analyser.frequencyBinCount)
2588
+ analyser.getByteFrequencyData(arr)
2589
+ let sum = 0
2590
+ for (let i = 0; i < arr.length; i++) sum += arr[i]
2591
+ const avg = sum / (arr.length || 1)
2592
+ setTestLevel(Math.min(100, Math.round((avg / 128) * 100)))
2593
+ requestAnimationFrame(check)
2594
+ }
2595
+ requestAnimationFrame(check)
2596
+ } catch (e) {
2597
+ if (typeof setErr === 'function') setErr(String(e && e.message ? e.message : e))
2598
+ }
2599
+ }
2600
+
2601
+ React.useEffect(() => {
2602
+ return () => {
2603
+ if (testRef.current) {
2604
+ testRef.current.active = false
2605
+ try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
2606
+ if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) { /* already closed */ } }
2607
+ testRef.current = null
2608
+ }
2609
+ }
2610
+ }, [])
2611
+
2612
+ const gateDbVal = Number((draft && draft.noiseGateDb !== undefined) ? draft.noiseGateDb : (value && value.noiseGateDb !== undefined) ? value.noiseGateDb : -45)
2613
+ const gateThresholdPercent = gateDbVal <= -90 ? 0 : Math.min(100, Math.max(0, Math.round(((gateDbVal + 60) / 40) * 100)))
2614
+
2615
+ return { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent }
2616
+ }
2617
+
2618
+ function usePluginUpdater() {
2619
+ const [updaterStatus, setUpdaterStatus] = React.useState(null)
2620
+ const [updating, setUpdating] = React.useState(false)
2621
+ const [updaterMsg, setUpdaterMsg] = React.useState('')
2622
+ const [updaterLoading, setUpdaterLoading] = React.useState(false)
2623
+
2624
+ const checkUpdater = React.useCallback(async () => {
2625
+ setUpdaterLoading(true)
2626
+ setUpdaterMsg('')
2627
+ try {
2628
+ const res = await fetch('/api/dsh-voice/update', {
2629
+ headers: { accept: 'application/json' },
2630
+ cache: 'no-store',
2631
+ })
2632
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
2633
+ const data = await res.json()
2634
+ setUpdaterStatus(data)
2635
+ } catch (e) {
2636
+ /* best effort update check */
2637
+ } finally {
2638
+ setUpdaterLoading(false)
2639
+ }
2640
+ }, [])
2641
+
2642
+ const onUpdateNow = async () => {
2643
+ if (updating) return
2644
+ setUpdating(true)
2645
+ setUpdaterMsg('')
2646
+ try {
2647
+ const res = await fetch('/api/dsh-voice/update', {
2648
+ method: 'POST',
2649
+ headers: {
2650
+ 'content-type': 'application/json',
2651
+ 'x-dsh-plugin-update': '1',
2652
+ },
2653
+ cache: 'no-store',
2654
+ })
2655
+ const data = await res.json().catch(() => ({}))
2656
+ if (!res.ok || data.ok === false) {
2657
+ throw new Error(data.error || `HTTP ${res.status}`)
2658
+ }
2659
+ setUpdaterMsg(data.message || 'Update completed successfully.')
2660
+ checkUpdater()
2661
+ } catch (err) {
2662
+ setUpdaterMsg(err && err.message ? err.message : String(err))
2663
+ } finally {
2664
+ setUpdating(false)
2665
+ }
2666
+ }
2667
+
2668
+ return { updaterStatus, updating, updaterMsg, updaterLoading, checkUpdater, onUpdateNow }
2669
+ }
2670
+ // Developer Lexicon & IT Jargon
2671
+ function JargonEditor(props) {
2672
+ const t = props.t || moduleT
2673
+ const draft = props.draft
2674
+ const setTop = props.setTop
2675
+ const writable = props.writable
2676
+ const [jargonInput, setJargonInput] = React.useState(null)
2677
+
2678
+ return React.createElement('div', { className: 'cb-section-card' },
2679
+ React.createElement('div', { className: 'cb-section-title' },
2680
+ React.createElement('span', null, '📖 ' + t('jargonTitle')),
2681
+ ),
2682
+ React.createElement('label', { className: 'cb-check-label', style: { marginBottom: '8px' } },
2683
+ React.createElement('input', {
2684
+ type: 'checkbox',
2685
+ checked: draft ? draft.techJargonCorrection !== false : true,
2686
+ disabled: !writable,
2687
+ onChange: (e) => setTop('techJargonCorrection', e.target.checked),
2688
+ }),
2689
+ t('techJargonCorrection'),
2690
+ ),
2691
+ React.createElement('div', { className: 'cb-field' },
2692
+ React.createElement('label', null, t('jargonAdd') + ' (spoken -> replacement)'),
2693
+ React.createElement('textarea', {
2694
+ rows: 3,
2695
+ disabled: !writable,
2696
+ placeholder: t('jargonPlaceholder'),
2697
+ value: jargonInput !== null
2698
+ ? jargonInput
2699
+ : (Array.isArray(draft && draft.jargonDictionary)
2700
+ ? draft.jargonDictionary.map((item) => item && item.from ? `${item.from} -> ${item.to}` : '').filter(Boolean).join('\n')
2701
+ : ''),
2702
+ onChange: (e) => setJargonInput(e.target.value),
2703
+ onBlur: () => {
2704
+ if (jargonInput === null) return
2705
+ const lines = jargonInput.split('\n')
2706
+ const parsed = lines
2707
+ .map((line) => {
2708
+ const parts = line.split(/->|=|:/)
2709
+ if (parts.length >= 2) {
2710
+ const from = parts[0].trim()
2711
+ const to = parts.slice(1).join(':').trim()
2712
+ if (from && to) return { from, to }
2713
+ }
2714
+ return null
2715
+ })
2716
+ .filter(Boolean)
2717
+ setTop('jargonDictionary', parsed)
2718
+ },
2719
+ }),
2720
+ ),
2721
+ )
2722
+ }
2723
+ // SenseVoice Local ASR (1-Click Setup)
2724
+ function SensevoiceSection(props) {
2725
+ const t = props.t || moduleT
2726
+ const sensevoiceState = props.sensevoiceState
2727
+ const installingSensevoice = props.installingSensevoice
2728
+ const writable = props.writable
2729
+ const onInstall = props.onInstall
2730
+
2731
+ return React.createElement('div', { className: 'cb-section-card' },
2732
+ React.createElement('div', { className: 'cb-section-title' },
2733
+ React.createElement('span', null, '⚡ ' + t('sensevoiceTitle')),
2734
+ sensevoiceState && sensevoiceState.installed
2735
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('sensevoiceReady') + ' (small-int8)')
2736
+ : React.createElement('span', { className: 'cb-badge cb-badge-warn' },
2737
+ installingSensevoice ? t('sensevoiceDownloading') : t('sensevoiceNotInstalled')
2738
+ ),
2739
+ ),
2740
+ React.createElement('div', { className: 'cb-section-desc' }, t('sensevoiceDesc')),
2741
+ React.createElement('div', { className: 'dvo-installer-box' },
2742
+ React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', justifyContent: 'space-between', gap: '12px' } },
2743
+ React.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-content-secondary)' } },
2744
+ sensevoiceState && sensevoiceState.path
2745
+ ? ('~/' + sensevoiceState.path.replace(/^.*[\\/](\.dsh[\\/]models[\\/].*)$/, '$1'))
2746
+ : '~/.dsh/models/sensevoice'
2747
+ ),
2748
+ React.createElement('button', {
2749
+ type: 'button',
2750
+ className: 'cb-btn cb-btn-primary',
2751
+ style: { fontSize: '12px', padding: '6px 12px' },
2752
+ disabled: installingSensevoice || !writable,
2753
+ onClick: onInstall,
2754
+ }, installingSensevoice ? ('⏳ ' + t('installingSensevoice')) : ('📥 ' + t('installSensevoice'))),
2755
+ ),
2756
+ installingSensevoice
2757
+ ? React.createElement('div', { style: { marginTop: '8px' } },
2758
+ React.createElement('div', { className: 'dvo-progress-bar' },
2759
+ React.createElement('div', { className: 'dvo-progress-bar-fill' })
2760
+ ),
2761
+ React.createElement('div', { style: { fontSize: '11px', marginTop: '4px', color: 'var(--dsw-alias-content-secondary)' } },
2762
+ (sensevoiceState && sensevoiceState.progress) || t('sensevoiceDownloading')
2763
+ )
2764
+ )
2765
+ : null
2766
+ )
2767
+ )
2768
+ }
2769
+ // 1. Connection & Engine Status Card
2770
+ function ConnectionStatusCard(props) {
2771
+ const t = props.t || moduleT
2772
+ const hostOnline = props.hostOnline
2773
+ const statusLatency = props.statusLatency
2774
+ const whisperRunning = props.whisperRunning
2775
+ const providerCount = props.providerCount
2776
+ const onRefresh = props.onRefresh
2777
+
2778
+ return React.createElement('div', { className: 'cb-section-card' },
2779
+ React.createElement('div', { className: 'cb-section-title' },
2780
+ React.createElement('span', null, t('statusTitle')),
2781
+ React.createElement('button', {
2782
+ type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
2783
+ onClick: onRefresh,
2784
+ }, '🔄 ' + t('refreshStats')),
2785
+ ),
2786
+ React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px', flexWrap: 'wrap' } },
2787
+ React.createElement('span', { className: 'cb-badge ' + (hostOnline ? 'cb-badge-ok' : 'cb-badge-err') },
2788
+ hostOnline ? (`✓ ${t('hostOnline')} (${statusLatency}ms)`) : (`✗ ${t('hostOffline')}`)
2789
+ ),
2790
+ React.createElement('span', { className: 'cb-badge ' + (whisperRunning ? 'cb-badge-ok' : 'cb-badge-warn') },
2791
+ whisperRunning ? (`✓ ${t('whisperReady')}`) : (`· ${t('whisperStopped')}`)
2792
+ ),
2793
+ React.createElement('span', { className: 'cb-badge' }, `${providerCount} ${t('providersAvailable')}`),
2794
+ ),
2795
+ )
2796
+ }
2797
+ // Plugin Version & Updates Card
2798
+ function UpdaterCard(props) {
2799
+ const t = props.t || moduleT
2800
+ const updaterStatus = props.updaterStatus
2801
+ const updaterLoading = props.updaterLoading
2802
+ const updating = props.updating
2803
+ const updaterMsg = props.updaterMsg
2804
+ const onCheck = props.onCheck
2805
+ const onUpdateNow = props.onUpdateNow
2806
+
2807
+ return React.createElement('div', { className: 'cb-section-card' },
2808
+ React.createElement('div', { className: 'cb-section-title' },
2809
+ React.createElement('span', null, '📦 ' + t('versionTitle')),
2810
+ React.createElement('button', {
2811
+ type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
2812
+ onClick: onCheck, disabled: updaterLoading || updating,
2813
+ }, updaterLoading ? '⏳…' : '🔄 ' + t('refreshStats')),
2814
+ ),
2815
+ React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
2816
+ React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
2817
+ React.createElement('span', { className: 'cb-badge cb-badge-ok' },
2818
+ 'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.6')
2819
+ ),
2820
+ updaterStatus && updaterStatus.updateAvailable
2821
+ ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
2822
+ t('updateAvailable') + ': v' + updaterStatus.latestVersion
2823
+ )
2824
+ : React.createElement('span', { className: 'cb-badge cb-badge-ok' },
2825
+ t('upToDate')
2826
+ ),
2827
+ updaterStatus && updaterStatus.updateAvailable
2828
+ ? React.createElement('button', {
2829
+ type: 'button', className: 'cb-btn cb-btn-primary', style: { fontSize: '12px', padding: '4px 10px' },
2830
+ onClick: onUpdateNow, disabled: updating,
2831
+ }, updating ? t('updating') : t('updateNow'))
2832
+ : null,
2833
+ ),
2834
+ updaterMsg
2835
+ ? React.createElement('div', {
2836
+ style: {
2837
+ marginTop: '8px',
2838
+ fontSize: '12px',
2839
+ color: updaterMsg.includes('fail') || updaterMsg.includes('失败')
2840
+ ? 'var(--dsw-alias-state-warning-primary)'
2841
+ : 'var(--dsw-alias-state-success-primary)',
2842
+ },
2843
+ }, updaterMsg)
2844
+ : null,
2845
+ )
2846
+ }
2247
2847
  // ------------------------------------------------------- settings voice section
2248
2848
  function VoiceSection(props) {
2249
2849
  const t = (props && props.t) || moduleT
@@ -2263,7 +2863,6 @@ window.__ModuleLoader__.load({
2263
2863
  const [err, setErr] = React.useState('')
2264
2864
  const [serverConfig, setServerConfig] = React.useState(null)
2265
2865
  const [configLoading, setConfigLoading] = React.useState(true)
2266
- const [jargonInput, setJargonInput] = React.useState(null)
2267
2866
 
2268
2867
  React.useEffect(() => {
2269
2868
  let alive = true
@@ -2336,165 +2935,55 @@ window.__ModuleLoader__.load({
2336
2935
  } else if (ctx.settingsScope && typeof ctx.settingsScope.describe === 'function') {
2337
2936
  ctx.settingsScope.describe().load()
2338
2937
  }
2339
- } catch (e) { /* service not up yet */ }
2340
- }, 1000)
2341
- return () => clearInterval(timer)
2342
- }, [ready])
2343
-
2344
- const deepClone = (obj) => {
2345
- if (typeof structuredClone === 'function') {
2346
- try { return structuredClone(obj) } catch (e) { /* fallback */ }
2347
- }
2348
- try { return JSON.parse(JSON.stringify(obj)) } catch (e) { return Object.assign({}, obj) }
2349
- }
2350
-
2351
- React.useEffect(() => { if (ready && draft === null) setDraft(deepClone(value)) }, [ready, draft, value])
2352
-
2353
- React.useEffect(() => {
2354
- if (!ready) return
2355
- const val = value || {}
2356
- voice.settings = Object.assign({}, voice.settings, {
2357
- vadSilenceMs: Number(val.dictation && val.dictation.vadSilenceMs) || 700,
2358
- autoSendMs: Number(val.message && val.message.autoSendMs) || 4000,
2359
- beep: !!val.beep,
2360
- micDeviceId: String(val.micDeviceId || ''),
2361
- historyLimit: Number(val.historyLimit) || 20,
2362
- voiceCommands: !!val.voiceCommands,
2363
- sendDelayMs: Number(val.dictation && val.dictation.sendDelayMs) || 0,
2364
- stream: !!(val.dictation && val.dictation.stream),
2365
- streamChunkMs: Number(val.dictation && val.dictation.streamChunkMs) || 1200,
2366
- vadAdapt: Number(val.dictation && val.dictation.vadAdapt) || 0,
2367
- wakeWord: String(val.wakeWord || ''),
2368
- bargeIn: !!val.bargeIn,
2369
- polishSend: !!(val.message && val.message.polishSend),
2370
- sessionCommands: !!(val.message && val.message.sessionCommands),
2371
- polishBaseUrl: String(val.polishBaseUrl || ''),
2372
- noiseSuppression: val.noiseSuppression !== false,
2373
- noiseGateDb: Number(val.noiseGateDb !== undefined ? val.noiseGateDb : -45),
2374
- contextGlossary: val.contextGlossary !== false,
2375
- visualizerStyle: val.visualizerStyle || 'liquid-wave',
2376
- gatedTurnTaking: val.gatedTurnTaking !== false,
2377
- autoSendVisualRing: val.autoSendVisualRing !== false,
2378
- voiceActions: val.voiceActions !== false,
2379
- techJargonCorrection: val.techJargonCorrection !== false,
2380
- liveInterimPreview: val.liveInterimPreview !== false,
2381
- structuredPromptVoice: val.structuredPromptVoice !== false,
2382
- })
2383
- }, [ready, value])
2384
-
2385
- const [sensevoiceState, setSensevoiceState] = React.useState(null)
2386
- const [installingSensevoice, setInstallingSensevoice] = React.useState(false)
2387
-
2388
- const fetchSensevoice = () => {
2389
- fetch('/dsh-voice/sensevoice-installer')
2390
- .then((r) => r.json())
2391
- .then((d) => {
2392
- setSensevoiceState(d)
2393
- setInstallingSensevoice(!!(d && d.installing))
2394
- })
2395
- .catch(() => {})
2396
- }
2397
-
2398
- React.useEffect(() => {
2399
- fetchSensevoice()
2400
- }, [])
2401
-
2402
- const triggerInstallSensevoice = () => {
2403
- setInstallingSensevoice(true)
2404
- fetch('/dsh-voice/sensevoice-installer', {
2405
- method: 'POST',
2406
- headers: {
2407
- 'x-dsh-plugin-update': '1',
2408
- 'Content-Type': 'application/json',
2409
- },
2410
- })
2411
- .then((r) => r.json())
2412
- .then(() => {
2413
- const timer = setInterval(() => {
2414
- fetch('/dsh-voice/sensevoice-installer')
2415
- .then((r) => r.json())
2416
- .then((d) => {
2417
- setSensevoiceState(d)
2418
- if (!d.installing) {
2419
- clearInterval(timer)
2420
- setInstallingSensevoice(false)
2421
- fetchStatus()
2422
- }
2423
- })
2424
- .catch(() => clearInterval(timer))
2425
- }, 2000)
2426
- })
2427
- .catch(() => setInstallingSensevoice(false))
2428
- }
2429
-
2430
- // Interactive microphone tester
2431
- const [testingMic, setTestingMic] = React.useState(false)
2432
- const [testLevel, setTestLevel] = React.useState(0)
2433
- const testRef = React.useRef(null)
2434
-
2435
- const toggleTestMic = async () => {
2436
- if (testingMic) {
2437
- if (testRef.current) {
2438
- testRef.current.active = false
2439
- try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
2440
- if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) { /* already closed */ } }
2441
- testRef.current = null
2442
- }
2443
- setTestingMic(false)
2444
- setTestLevel(0)
2445
- return
2446
- }
2447
- try {
2448
- const devId = (draft && draft.micDeviceId) || (value && value.micDeviceId) || ''
2449
- const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
2450
- if (devId) audio.deviceId = { exact: devId }
2451
- const stream = await navigator.mediaDevices.getUserMedia({ audio })
2452
- const AC = typeof AudioContext !== 'undefined' ? AudioContext : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
2453
- if (!AC) return
2454
- const audioCtx = new AC()
2455
- if (audioCtx.state === 'suspended') await audioCtx.resume().catch(() => {})
2456
- const src = audioCtx.createMediaStreamSource(stream)
2457
- let lastNode = src
2458
- try {
2459
- const filter = audioCtx.createBiquadFilter()
2460
- filter.type = 'highpass'
2461
- filter.frequency.value = 80
2462
- filter.Q.value = 0.707
2463
- src.connect(filter)
2464
- lastNode = filter
2465
- } catch (e) { /* filter fallback */ }
2466
- const analyser = audioCtx.createAnalyser()
2467
- analyser.fftSize = 128
2468
- lastNode.connect(analyser)
2469
- testRef.current = { stream, audioCtx, analyser, active: true }
2470
- setTestingMic(true)
2938
+ } catch (e) { /* service not up yet */ }
2939
+ }, 1000)
2940
+ return () => clearInterval(timer)
2941
+ }, [ready])
2471
2942
 
2472
- const check = () => {
2473
- if (!testRef.current || !testRef.current.active) return
2474
- const arr = new Uint8Array(analyser.frequencyBinCount)
2475
- analyser.getByteFrequencyData(arr)
2476
- let sum = 0
2477
- for (let i = 0; i < arr.length; i++) sum += arr[i]
2478
- const avg = sum / (arr.length || 1)
2479
- setTestLevel(Math.min(100, Math.round((avg / 128) * 100)))
2480
- requestAnimationFrame(check)
2481
- }
2482
- requestAnimationFrame(check)
2483
- } catch (e) {
2484
- setErr(String(e && e.message ? e.message : e))
2943
+ const deepClone = (obj) => {
2944
+ if (typeof structuredClone === 'function') {
2945
+ try { return structuredClone(obj) } catch (e) { /* fallback */ }
2485
2946
  }
2947
+ try { return JSON.parse(JSON.stringify(obj)) } catch (e) { return Object.assign({}, obj) }
2486
2948
  }
2487
2949
 
2950
+ React.useEffect(() => { if (ready && draft === null) setDraft(deepClone(value)) }, [ready, draft, value])
2951
+
2488
2952
  React.useEffect(() => {
2489
- return () => {
2490
- if (testRef.current) {
2491
- testRef.current.active = false
2492
- try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
2493
- if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) { /* already closed */ } }
2494
- testRef.current = null
2495
- }
2496
- }
2497
- }, [])
2953
+ if (!ready) return
2954
+ const val = value || {}
2955
+ voice.settings = Object.assign({}, voice.settings, {
2956
+ vadSilenceMs: Number(val.dictation && val.dictation.vadSilenceMs) || 700,
2957
+ autoSendMs: Number(val.message && val.message.autoSendMs) || 4000,
2958
+ beep: !!val.beep,
2959
+ micDeviceId: String(val.micDeviceId || ''),
2960
+ historyLimit: Number(val.historyLimit) || 20,
2961
+ voiceCommands: !!val.voiceCommands,
2962
+ sendDelayMs: Number(val.dictation && val.dictation.sendDelayMs) || 0,
2963
+ stream: !!(val.dictation && val.dictation.stream),
2964
+ streamChunkMs: Number(val.dictation && val.dictation.streamChunkMs) || 1200,
2965
+ vadAdapt: Number(val.dictation && val.dictation.vadAdapt) || 0,
2966
+ wakeWord: String(val.wakeWord || ''),
2967
+ bargeIn: !!val.bargeIn,
2968
+ polishSend: !!(val.message && val.message.polishSend),
2969
+ sessionCommands: !!(val.message && val.message.sessionCommands),
2970
+ polishBaseUrl: String(val.polishBaseUrl || ''),
2971
+ noiseSuppression: val.noiseSuppression !== false,
2972
+ noiseGateDb: Number(val.noiseGateDb !== undefined ? val.noiseGateDb : -45),
2973
+ contextGlossary: val.contextGlossary !== false,
2974
+ visualizerStyle: val.visualizerStyle || 'liquid-wave',
2975
+ gatedTurnTaking: val.gatedTurnTaking !== false,
2976
+ autoSendVisualRing: val.autoSendVisualRing !== false,
2977
+ voiceActions: val.voiceActions !== false,
2978
+ techJargonCorrection: val.techJargonCorrection !== false,
2979
+ liveInterimPreview: val.liveInterimPreview !== false,
2980
+ structuredPromptVoice: val.structuredPromptVoice !== false,
2981
+ })
2982
+ }, [ready, value])
2983
+
2984
+ const { sensevoiceState, installingSensevoice, triggerInstallSensevoice } = useSensevoiceInstaller(fetchStatus)
2985
+
2986
+ const { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent } = useMicTester(draft, value, setErr)
2498
2987
 
2499
2988
  const [statusData, setStatusData] = React.useState(null)
2500
2989
  const [statusLatency, setStatusLatency] = React.useState(null)
@@ -2521,72 +3010,7 @@ window.__ModuleLoader__.load({
2521
3010
  fetchStatus()
2522
3011
  }, [fetchStatus])
2523
3012
 
2524
- const [updaterStatus, setUpdaterStatus] = React.useState(null)
2525
- const [updating, setUpdating] = React.useState(false)
2526
- const [updaterMsg, setUpdaterMsg] = React.useState('')
2527
- const [updaterLoading, setUpdaterLoading] = React.useState(false)
2528
-
2529
- const checkUpdater = React.useCallback(async () => {
2530
- setUpdaterLoading(true)
2531
- setUpdaterMsg('')
2532
- try {
2533
- const res = await fetch('/api/dsh-voice/update', {
2534
- headers: { accept: 'application/json' },
2535
- cache: 'no-store',
2536
- })
2537
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
2538
- const data = await res.json()
2539
- setUpdaterStatus(data)
2540
- } catch (e) {
2541
- /* best effort update check */
2542
- } finally {
2543
- setUpdaterLoading(false)
2544
- }
2545
- }, [])
2546
-
2547
- const onUpdateNow = async () => {
2548
- setUpdating(true)
2549
- setUpdaterMsg('')
2550
- try {
2551
- const res = await fetch('/api/dsh-voice/update', {
2552
- method: 'POST',
2553
- headers: {
2554
- 'x-dsh-plugin-update': '1',
2555
- 'content-type': 'application/json',
2556
- },
2557
- })
2558
- const data = await res.json()
2559
- if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`)
2560
- setUpdaterStatus(data)
2561
- setUpdaterMsg(t('updateSuccess'))
2562
- } catch (e) {
2563
- setUpdaterMsg(t('updateFailed') + ' ' + (e?.message || String(e)))
2564
- } finally {
2565
- setUpdating(false)
2566
- }
2567
- }
2568
-
2569
- React.useEffect(() => {
2570
- checkUpdater()
2571
- }, [checkUpdater])
2572
-
2573
- if (!ready) {
2574
- const waiting = configLoading || (!snap || snap.status === 'loading')
2575
- return React.createElement('div', { className: 'cb-page' },
2576
- React.createElement('div', { className: 'dvs-wait' }, waiting
2577
- ? t('loadingSettings')
2578
- : t('notReady1')
2579
- + t('notReady2')),
2580
- )
2581
- }
2582
-
2583
- const setIn = (mode, key, v) => setDraft((d) => {
2584
- const next = deepClone(d || {})
2585
- next[mode] = next[mode] || {}
2586
- next[mode][key] = v
2587
- return next
2588
- })
2589
- const setTop = (key, v) => setDraft((d) => Object.assign({}, d || {}, { [key]: v }))
3013
+ const { updaterStatus, updating, updaterMsg, updaterLoading, checkUpdater, onUpdateNow } = usePluginUpdater()
2590
3014
 
2591
3015
  const save = async () => {
2592
3016
  setErr(''); setSaved(false)
@@ -2691,72 +3115,6 @@ window.__ModuleLoader__.load({
2691
3115
  .filter((k) => k && BUILTIN.indexOf(k) < 0),
2692
3116
  )
2693
3117
 
2694
- const hotkeyField = () => React.createElement('div', { className: 'cb-field' },
2695
- React.createElement('label', null, t('hotkey')),
2696
- React.createElement('div', { className: 'cb-row' },
2697
- React.createElement('button', {
2698
- type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable,
2699
- onClick: () => setCatching(true),
2700
- }, catching ? t('pressKey') : keyLabel(draft && draft.hotkey)),
2701
- React.createElement('button', {
2702
- type: 'button', className: 'cb-btn cb-btn-mini', title: t('clearKey'),
2703
- disabled: !writable, onClick: () => {
2704
- setDraft((d) => Object.assign({}, d || {}, { hotkey: '' }))
2705
- voice.hotkey = ''
2706
- clearGlobalHotkey()
2707
- },
2708
- }, '×'),
2709
- ),
2710
- React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
2711
- )
2712
-
2713
- const gateDbVal = Number((draft && draft.noiseGateDb !== undefined) ? draft.noiseGateDb : (value && value.noiseGateDb !== undefined) ? value.noiseGateDb : -45)
2714
- const gateThresholdPercent = gateDbVal <= -90 ? 0 : Math.min(100, Math.max(0, Math.round(((gateDbVal + 60) / 40) * 100)))
2715
-
2716
- const micField = () => React.createElement('div', { className: 'cb-field' },
2717
- React.createElement('label', null, t('mic')),
2718
- React.createElement('div', { className: 'cb-row' },
2719
- React.createElement('select', {
2720
- value: String((draft && draft.micDeviceId) ?? (value && value.micDeviceId) ?? ''), disabled: !writable || testingMic,
2721
- onChange: (e) => setTop('micDeviceId', e.target.value),
2722
- style: { flex: 1 },
2723
- },
2724
- React.createElement('option', { value: '' }, t('micDefault')),
2725
- devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
2726
- d.label || d.deviceId.slice(0, 12))),
2727
- ),
2728
- React.createElement('button', {
2729
- type: 'button',
2730
- className: 'cb-btn ' + (testingMic ? 'cb-btn-primary' : 'cb-btn-secondary'),
2731
- onClick: toggleTestMic,
2732
- style: { flex: 'none' },
2733
- }, testingMic ? t('stopTest') : t('testMic')),
2734
- ),
2735
- testingMic ? React.createElement('div', { className: 'dvo-mic-test' },
2736
- React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, t('micLevel') + ':'),
2737
- React.createElement('div', { className: 'dvo-meter-bar' },
2738
- React.createElement('div', { className: 'dvo-meter-fill', style: { width: `${testLevel}%` } }),
2739
- gateThresholdPercent > 0 ? React.createElement('div', { className: 'dvo-meter-gate', style: { left: `${gateThresholdPercent}%` }, title: `${gateDbVal} dB` }) : null,
2740
- ),
2741
- React.createElement('span', { style: { fontSize: '11px', fontVariantNumeric: 'tabular-nums', width: '32px', textAlign: 'right' } }, `${testLevel}%`),
2742
- ) : null,
2743
- React.createElement('div', { className: 'cb-row', style: { marginTop: '6px' } },
2744
- React.createElement('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)', minWidth: '120px' } }, t('noiseGate') + ':'),
2745
- React.createElement('select', {
2746
- value: String(gateDbVal), disabled: !writable,
2747
- onChange: (e) => setTop('noiseGateDb', Number(e.target.value)),
2748
- style: { flex: 1 },
2749
- },
2750
- React.createElement('option', { value: '-999' }, t('noiseGateOff')),
2751
- React.createElement('option', { value: '-50' }, '-50 dB (Low / Gentle)'),
2752
- React.createElement('option', { value: '-45' }, '-45 dB (Standard Default)'),
2753
- React.createElement('option', { value: '-35' }, '-35 dB (Medium Room)'),
2754
- React.createElement('option', { value: '-25' }, '-25 dB (High / Mechanical Clicks)'),
2755
- ),
2756
- ),
2757
- React.createElement('span', { className: 'dvs-sub' }, t('noiseGateHint')),
2758
- )
2759
-
2760
3118
  const langField = (mode) => React.createElement('div', { className: 'cb-field' },
2761
3119
  React.createElement('label', null, t('language')),
2762
3120
  React.createElement('select', {
@@ -2774,14 +3132,6 @@ window.__ModuleLoader__.load({
2774
3132
  React.createElement('span', { className: 'dvs-sub' }, hint),
2775
3133
  )
2776
3134
 
2777
- const textField = (key, label, hint) => React.createElement('div', { className: 'cb-field' },
2778
- React.createElement('label', null, label),
2779
- React.createElement('input', {
2780
- type: 'text', value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
2781
- onChange: (e) => setTop(key, e.target.value),
2782
- }),
2783
- React.createElement('span', { className: 'dvs-sub' }, hint),
2784
- )
2785
3135
 
2786
3136
  const statsData = (statusData && statusData.providerStats) || {}
2787
3137
  const hostOnline = statusLatency !== null
@@ -2790,25 +3140,14 @@ window.__ModuleLoader__.load({
2790
3140
 
2791
3141
  return React.createElement('div', { className: 'cb-page' },
2792
3142
  // 1. Connection & Engine Status Card
2793
- React.createElement('div', { className: 'cb-section-card' },
2794
- React.createElement('div', { className: 'cb-section-title' },
2795
- React.createElement('span', null, t('statusTitle')),
2796
- React.createElement('button', {
2797
- type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
2798
- onClick: fetchStatus,
2799
- }, '🔄 ' + t('refreshStats')),
2800
- ),
2801
- React.createElement('div', { className: 'cb-section-desc' }, t('statusDesc')),
2802
- React.createElement('div', { className: 'cb-row' },
2803
- hostOnline
2804
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '● ' + t('badgeHostOnline').replace('{ms}', statusLatency))
2805
- : React.createElement('span', { className: 'cb-badge cb-badge-bad' }, '● ' + t('badgeHostOffline')),
2806
- whisperRunning
2807
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '● ' + t('badgeWhisperActive'))
2808
- : React.createElement('span', { className: 'cb-badge cb-badge-warn' }, '○ ' + t('badgeWhisperInactive')),
2809
- React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '✓ ' + t('badgeProviders').replace('{count}', providerCount)),
2810
- ),
2811
- ),
3143
+ React.createElement(ConnectionStatusCard, {
3144
+ t: t,
3145
+ hostOnline: hostOnline,
3146
+ statusLatency: statusLatency,
3147
+ whisperRunning: whisperRunning,
3148
+ providerCount: providerCount,
3149
+ onRefresh: fetchStatus,
3150
+ }),
2812
3151
 
2813
3152
  // 2. Dictation Mode Card
2814
3153
  React.createElement('div', { className: 'cb-section-card' },
@@ -2817,7 +3156,7 @@ window.__ModuleLoader__.load({
2817
3156
  ),
2818
3157
  React.createElement('div', { className: 'cb-section-desc' }, t('dictationHint')),
2819
3158
  React.createElement(ChainEditor, {
2820
- value: draft && draft.dictation ? draft.dictation.chain : [], writable: writable,
3159
+ t: t, value: draft && draft.dictation ? draft.dictation.chain : [], writable: writable,
2821
3160
  options: chainOptions,
2822
3161
  onChange: (v) => setIn('dictation', 'chain', v),
2823
3162
  }),
@@ -2862,7 +3201,7 @@ window.__ModuleLoader__.load({
2862
3201
  ),
2863
3202
  React.createElement('div', { className: 'cb-section-desc' }, t('messageHint')),
2864
3203
  React.createElement(ChainEditor, {
2865
- value: draft && draft.message ? draft.message.chain : [], writable: writable,
3204
+ t: t, value: draft && draft.message ? draft.message.chain : [], writable: writable,
2866
3205
  options: chainOptions,
2867
3206
  onChange: (v) => setIn('message', 'chain', v),
2868
3207
  }),
@@ -2908,144 +3247,35 @@ window.__ModuleLoader__.load({
2908
3247
  }),
2909
3248
  ),
2910
3249
 
2911
- // 5. Hardware & Local Engines Card
2912
- React.createElement('div', { className: 'cb-section-card' },
2913
- React.createElement('div', { className: 'cb-section-title' },
2914
- React.createElement('span', null, '🔧 ' + t('hardwareTitle')),
2915
- ),
2916
- React.createElement('div', { className: 'cb-section-desc' }, t('hardwareDesc')),
2917
- hotkeyField(),
2918
- micField(),
2919
- React.createElement('div', { className: 'cb-grid-2' },
2920
- textField('whisperUrl', t('whisperEndpoint'), t('whisperEndpointHint')),
2921
- textField('whisperBin', t('whisperBin'), t('whisperBinHint')),
2922
- textField('whisperModel', t('whisperModel'), t('whisperModelHint')),
2923
- textField('deepgramBaseUrl', t('deepgramEndpoint'), t('deepgramEndpointHint')),
2924
- textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
2925
- textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
2926
- textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceModelHint')),
2927
- textField('polishBaseUrl', t('polishBaseUrl'), t('polishBaseUrlHint')),
2928
- textField('polishModel', t('polishModel'), ''),
2929
- textField('polishKeyEnv', t('polishKeyEnv'), ''),
2930
- ),
2931
- React.createElement('div', { className: 'cb-row' },
2932
- React.createElement('label', { className: 'cb-check-label' },
2933
- React.createElement('input', {
2934
- type: 'checkbox', checked: !!(draft && draft.autoStart), disabled: !writable,
2935
- onChange: (e) => setTop('autoStart', e.target.checked),
2936
- }),
2937
- t('whisperAutostart'),
2938
- ),
2939
- React.createElement('label', { className: 'cb-check-label' },
2940
- React.createElement('input', {
2941
- type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
2942
- onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
2943
- }),
2944
- t('sensevoiceAutostart'),
2945
- ),
2946
- React.createElement('label', { className: 'cb-check-label' },
2947
- React.createElement('input', {
2948
- type: 'checkbox', checked: !!(draft && draft.localOnly), disabled: !writable,
2949
- onChange: (e) => setTop('localOnly', e.target.checked),
2950
- }),
2951
- t('localOnly'),
2952
- ),
2953
- React.createElement('label', { className: 'cb-check-label' },
2954
- React.createElement('input', {
2955
- type: 'checkbox', checked: !!(draft && draft.beep), disabled: !writable,
2956
- onChange: (e) => setTop('beep', e.target.checked),
2957
- }),
2958
- t('beep'),
2959
- ),
2960
- React.createElement('label', { className: 'cb-check-label' },
2961
- React.createElement('input', {
2962
- type: 'checkbox', checked: draft ? draft.noiseSuppression !== false : true, disabled: !writable,
2963
- onChange: (e) => setTop('noiseSuppression', e.target.checked),
2964
- }),
2965
- t('noiseSuppression'),
2966
- ),
2967
- React.createElement('label', { className: 'cb-check-label' },
2968
- React.createElement('input', {
2969
- type: 'checkbox', checked: draft ? draft.contextGlossary !== false : true, disabled: !writable,
2970
- onChange: (e) => setTop('contextGlossary', e.target.checked),
2971
- }),
2972
- t('contextGlossary'),
2973
- ),
2974
- React.createElement('label', { className: 'cb-check-label' },
2975
- React.createElement('input', {
2976
- type: 'checkbox', checked: !!(draft && draft.voiceCommands), disabled: !writable,
2977
- onChange: (e) => setTop('voiceCommands', e.target.checked),
2978
- }),
2979
- t('voiceCommandsLabel'),
2980
- ),
2981
- React.createElement('label', { className: 'cb-check-label' },
2982
- React.createElement('input', {
2983
- type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
2984
- onChange: (e) => setTop('normalizeTranscript', e.target.checked),
2985
- }),
2986
- t('normalizeTranscript'),
2987
- ),
2988
- ),
2989
- React.createElement('div', { className: 'cb-field' },
2990
- React.createElement('label', null, t('visualizerStyle')),
2991
- React.createElement('select', {
2992
- value: String((draft && draft.visualizerStyle) || 'liquid-wave'), disabled: !writable,
2993
- onChange: (e) => setTop('visualizerStyle', e.target.value),
2994
- },
2995
- React.createElement('option', { value: 'liquid-wave' }, t('visLiquidWave')),
2996
- React.createElement('option', { value: 'dynamic-orb' }, t('visDynamicOrb')),
2997
- React.createElement('option', { value: 'bars' }, t('visBars')),
2998
- React.createElement('option', { value: 'off' }, t('visOff')),
2999
- ),
3000
- ),
3001
- React.createElement('div', { className: 'cb-field' },
3002
- React.createElement('label', null, t('vocabulary')),
3003
- React.createElement('textarea', {
3004
- rows: 3, disabled: !writable,
3005
- value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
3006
- onChange: (e) => setTop('vocabulary', e.target.value.split('\n').map((x) => x.trim()).filter(Boolean)),
3007
- }),
3008
- ),
3009
- ),
3250
+ // 5. Hardware & Audio Engines Card
3251
+ React.createElement(HardwareOptionsCard, {
3252
+ t: t,
3253
+ draft: draft,
3254
+ setTop: setTop,
3255
+ writable: writable,
3256
+ catching: catching,
3257
+ setCatching: setCatching,
3258
+ devices: devices,
3259
+ testingMic: testingMic,
3260
+ toggleTestMic: toggleTestMic,
3261
+ testLevel: testLevel,
3262
+ gateDbVal: gateDbVal,
3263
+ gateThresholdPercent: gateThresholdPercent,
3264
+ onClearKey: () => {
3265
+ setDraft((d) => Object.assign({}, d || {}, { hotkey: '' }))
3266
+ voice.hotkey = ''
3267
+ clearGlobalHotkey()
3268
+ },
3269
+ }),
3010
3270
 
3011
3271
  // 6. SenseVoice Local ASR (1-Click Setup)
3012
- React.createElement('div', { className: 'cb-section-card' },
3013
- React.createElement('div', { className: 'cb-section-title' },
3014
- React.createElement('span', null, '⚡ ' + t('sensevoiceTitle')),
3015
- sensevoiceState && sensevoiceState.installed
3016
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('sensevoiceReady') + ' (small-int8)')
3017
- : React.createElement('span', { className: 'cb-badge cb-badge-warn' },
3018
- installingSensevoice ? t('sensevoiceDownloading') : t('sensevoiceNotInstalled')
3019
- ),
3020
- ),
3021
- React.createElement('div', { className: 'cb-section-desc' }, t('sensevoiceDesc')),
3022
- React.createElement('div', { className: 'dvo-installer-box' },
3023
- React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', justifyContent: 'space-between', gap: '12px' } },
3024
- React.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-content-secondary)' } },
3025
- sensevoiceState && sensevoiceState.path
3026
- ? ('~/' + sensevoiceState.path.replace(/^.*[\\/](\.dsh[\\/]models[\\/].*)$/, '$1'))
3027
- : '~/.dsh/models/sensevoice'
3028
- ),
3029
- React.createElement('button', {
3030
- type: 'button',
3031
- className: 'cb-btn cb-btn-primary',
3032
- style: { fontSize: '12px', padding: '6px 12px' },
3033
- disabled: installingSensevoice || !writable,
3034
- onClick: triggerInstallSensevoice,
3035
- }, installingSensevoice ? ('⏳ ' + t('installingSensevoice')) : ('📥 ' + t('installSensevoice'))),
3036
- ),
3037
- installingSensevoice
3038
- ? React.createElement('div', { style: { marginTop: '8px' } },
3039
- React.createElement('div', { className: 'dvo-progress-bar' },
3040
- React.createElement('div', { className: 'dvo-progress-bar-fill' })
3041
- ),
3042
- React.createElement('div', { style: { fontSize: '11px', marginTop: '4px', color: 'var(--dsw-alias-content-secondary)' } },
3043
- (sensevoiceState && sensevoiceState.progress) || t('sensevoiceDownloading')
3044
- )
3045
- )
3046
- : null
3047
- )
3048
- ),
3272
+ React.createElement(SensevoiceSection, {
3273
+ t: t,
3274
+ sensevoiceState: sensevoiceState,
3275
+ installingSensevoice: installingSensevoice,
3276
+ writable: writable,
3277
+ onInstall: triggerInstallSensevoice,
3278
+ }),
3049
3279
 
3050
3280
  // 7. Turn-Taking & Audio Coordination
3051
3281
  React.createElement('div', { className: 'cb-section-card' },
@@ -3121,143 +3351,30 @@ window.__ModuleLoader__.load({
3121
3351
  ),
3122
3352
 
3123
3353
  // 9. Developer Lexicon & IT Jargon
3124
- React.createElement('div', { className: 'cb-section-card' },
3125
- React.createElement('div', { className: 'cb-section-title' },
3126
- React.createElement('span', null, '📖 ' + t('jargonTitle')),
3127
- ),
3128
- React.createElement('label', { className: 'cb-check-label', style: { marginBottom: '8px' } },
3129
- React.createElement('input', {
3130
- type: 'checkbox',
3131
- checked: draft ? draft.techJargonCorrection !== false : true,
3132
- disabled: !writable,
3133
- onChange: (e) => setTop('techJargonCorrection', e.target.checked),
3134
- }),
3135
- t('techJargonCorrection'),
3136
- ),
3137
- React.createElement('div', { className: 'cb-field' },
3138
- React.createElement('label', null, t('jargonAdd') + ' (spoken -> replacement)'),
3139
- React.createElement('textarea', {
3140
- rows: 3,
3141
- disabled: !writable,
3142
- placeholder: t('jargonPlaceholder'),
3143
- value: jargonInput !== null
3144
- ? jargonInput
3145
- : (Array.isArray(draft && draft.jargonDictionary)
3146
- ? draft.jargonDictionary.map((item) => item && item.from ? `${item.from} -> ${item.to}` : '').filter(Boolean).join('\n')
3147
- : ''),
3148
- onChange: (e) => {
3149
- setJargonInput(e.target.value)
3150
- },
3151
- onBlur: () => {
3152
- if (jargonInput === null) return
3153
- const lines = jargonInput.split('\n')
3154
- const parsed = lines
3155
- .map((line) => {
3156
- const parts = line.split(/->|=|:/)
3157
- if (parts.length >= 2) {
3158
- const from = parts[0].trim()
3159
- const to = parts.slice(1).join(':').trim()
3160
- if (from && to) return { from, to }
3161
- }
3162
- return null
3163
- })
3164
- .filter(Boolean)
3165
- setTop('jargonDictionary', parsed)
3166
- },
3167
- }),
3168
- ),
3169
- ),
3354
+ React.createElement(JargonEditor, {
3355
+ t: t,
3356
+ draft: draft,
3357
+ setTop: setTop,
3358
+ writable: writable,
3359
+ }),
3170
3360
 
3171
3361
  // 10. Provider Telemetry & Latency Dashboard
3172
- React.createElement('div', { className: 'cb-section-card' },
3173
- React.createElement('div', { className: 'cb-section-title' },
3174
- React.createElement('span', null, '📊 ' + t('providerDashboard')),
3175
- React.createElement('button', {
3176
- type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
3177
- onClick: fetchStatus,
3178
- }, '🔄 ' + t('refreshStats')),
3179
- ),
3180
- React.createElement('div', { className: 'dvo-dash' },
3181
- React.createElement('div', { className: 'dvo-dash-grid' },
3182
- Object.keys(statsData).length === 0
3183
- ? React.createElement('div', { className: 'dvs-sub' }, t('idle'))
3184
- : Object.entries(statsData).map(([key, item]) => {
3185
- const avg = item.avgTookMs || 0
3186
- const hasErrors = item.failures > 0
3187
- let badgeClass = 'dvo-badge-idle'
3188
- let badgeText = t('idle')
3189
- if (item.attempts > 0) {
3190
- if (hasErrors && item.successes === 0) {
3191
- badgeClass = 'dvo-badge-err'
3192
- badgeText = t('error')
3193
- } else if (avg > 0 && avg < 400) {
3194
- badgeClass = 'dvo-badge-fast'
3195
- badgeText = avg + 'ms · ' + t('fast')
3196
- } else if (avg >= 400 && avg <= 1500) {
3197
- badgeClass = 'dvo-badge-norm'
3198
- badgeText = avg + 'ms · ' + t('normal')
3199
- } else {
3200
- badgeClass = 'dvo-badge-slow'
3201
- badgeText = avg + 'ms · ' + t('slow')
3202
- }
3203
- }
3204
- const rate = item.attempts > 0 ? Math.round((item.successes / item.attempts) * 100) : 100
3205
- return React.createElement('div', { key, className: 'dvo-dash-item', title: item.lastError ? `Error: ${item.lastError}` : '' },
3206
- React.createElement('div', { className: 'dvo-dash-name' }, key),
3207
- React.createElement('div', { className: 'dvo-dash-row' },
3208
- React.createElement('span', { className: 'dvo-badge ' + badgeClass }, badgeText),
3209
- React.createElement('span', null, rate + '%'),
3210
- ),
3211
- React.createElement('div', { className: 'dvo-dash-row' },
3212
- React.createElement('span', null, `${item.successes}/${item.attempts}`),
3213
- React.createElement('span', null, t('successRate')),
3214
- ),
3215
- )
3216
- }),
3217
- ),
3218
- ),
3219
- ),
3362
+ React.createElement(ProviderDashboard, {
3363
+ t: t,
3364
+ statsData: statsData,
3365
+ onRefresh: fetchStatus,
3366
+ }),
3220
3367
 
3221
3368
  // 7. Plugin Version & Updates Card
3222
- React.createElement('div', { className: 'cb-section-card' },
3223
- React.createElement('div', { className: 'cb-section-title' },
3224
- React.createElement('span', null, '📦 ' + t('versionTitle')),
3225
- React.createElement('button', {
3226
- type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
3227
- onClick: checkUpdater, disabled: updaterLoading || updating,
3228
- }, updaterLoading ? '⏳…' : '🔄 ' + t('refreshStats')),
3229
- ),
3230
- React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
3231
- React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
3232
- React.createElement('span', { className: 'cb-badge cb-badge-ok' },
3233
- 'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.4')
3234
- ),
3235
- updaterStatus && updaterStatus.updateAvailable
3236
- ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
3237
- t('updateAvailable') + ': v' + updaterStatus.latestVersion
3238
- )
3239
- : React.createElement('span', { className: 'cb-badge cb-badge-ok' },
3240
- t('upToDate')
3241
- ),
3242
- updaterStatus && updaterStatus.updateAvailable
3243
- ? React.createElement('button', {
3244
- type: 'button', className: 'cb-btn cb-btn-primary', style: { fontSize: '12px', padding: '4px 10px' },
3245
- onClick: onUpdateNow, disabled: updating,
3246
- }, updating ? t('updating') : t('updateNow'))
3247
- : null,
3248
- ),
3249
- updaterMsg
3250
- ? React.createElement('div', {
3251
- style: {
3252
- marginTop: '8px',
3253
- fontSize: '12px',
3254
- color: updaterMsg.includes('fail') || updaterMsg.includes('失败')
3255
- ? 'var(--dsw-alias-state-warning-primary)'
3256
- : 'var(--dsw-alias-state-success-primary)',
3257
- },
3258
- }, updaterMsg)
3259
- : null,
3260
- ),
3369
+ React.createElement(UpdaterCard, {
3370
+ t: t,
3371
+ updaterStatus: updaterStatus,
3372
+ updaterLoading: updaterLoading,
3373
+ updating: updating,
3374
+ updaterMsg: updaterMsg,
3375
+ onCheck: checkUpdater,
3376
+ onUpdateNow: onUpdateNow,
3377
+ }),
3261
3378
 
3262
3379
  // Action bar (Save)
3263
3380
  React.createElement('div', { className: 'cb-row' },
@@ -3301,42 +3418,42 @@ window.__ModuleLoader__.load({
3301
3418
  }
3302
3419
 
3303
3420
  function registerSettings(ctx) {
3304
- // List seat (plugins.item): the seat the Plugins page renders as the plugin's
3305
- // own page with its configuration — the host draws the title, icon and crumb
3306
- // and asks for view 'summary' (the card's one-liner) or view 'page' (the form).
3307
- // The label is a static string on purpose: it is resolved while the page
3308
- // renders, and a locale lookup there would take the whole client batch down.
3309
- ctx.slots.inject('plugins.item', () => ctx.slots.register(
3310
- {
3311
- name: 'plugins.item',
3312
- id: ROW_ID,
3313
- order: 60,
3314
- label: () => 'Voice input',
3315
- locale: NS,
3316
- inject: () => ({ ctx: ctx }),
3317
- },
3318
- PluginCard,
3319
- ))
3320
- // Row seat (plugins.row.config): rendered in the plugin row in Plugins table.
3321
- ctx.slots.inject('plugins.row.config', () => ctx.slots.register(
3322
- {
3323
- name: 'plugins.row.config',
3324
- key: ROW_CONFIG_KEY,
3325
- locale: NS,
3326
- inject: () => ({ ctx: ctx }),
3327
- },
3328
- PluginCard,
3329
- ))
3330
- // Legacy seat fallback
3331
- ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
3332
- {
3333
- name: 'settings.plugin.item',
3334
- key: NS,
3335
- locale: NS,
3336
- inject: () => ({ ctx: ctx }),
3337
- },
3338
- PluginCard,
3339
- ))
3421
+ ctx.effect(() => {
3422
+ const offItem = ctx.slots.inject('plugins.item', () => ctx.slots.register(
3423
+ {
3424
+ name: 'plugins.item',
3425
+ id: ROW_ID,
3426
+ order: 60,
3427
+ label: () => 'Voice input',
3428
+ locale: NS,
3429
+ inject: () => ({ ctx: ctx }),
3430
+ },
3431
+ PluginCard,
3432
+ ))
3433
+ const offRow = ctx.slots.inject('plugins.row.config', () => ctx.slots.register(
3434
+ {
3435
+ name: 'plugins.row.config',
3436
+ key: ROW_CONFIG_KEY,
3437
+ locale: NS,
3438
+ inject: () => ({ ctx: ctx }),
3439
+ },
3440
+ PluginCard,
3441
+ ))
3442
+ const offLegacy = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
3443
+ {
3444
+ name: 'settings.plugin.item',
3445
+ key: NS,
3446
+ locale: NS,
3447
+ inject: () => ({ ctx: ctx }),
3448
+ },
3449
+ PluginCard,
3450
+ ))
3451
+ return () => {
3452
+ if (typeof offItem === 'function') offItem()
3453
+ if (typeof offRow === 'function') offRow()
3454
+ if (typeof offLegacy === 'function') offLegacy()
3455
+ }
3456
+ }, 'dsh-voice: settings card slots')
3340
3457
  }
3341
3458
 
3342
3459
  exports.inject = ['timer', 'slots', 'configForms', 'locale']