@goodandready/dsh-voice 0.8.28 → 0.8.30
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/cordis.patch.yml +1 -1
- package/lib/client.js +215 -39
- package/lib/http-util.js +48 -0
- package/lib/index.js +48 -11
- package/lib/normalize.js +2 -0
- package/lib/provider-http.js +64 -0
- package/lib/providers.js +2 -38
- package/lib/updater.js +266 -0
- package/lib/wav.js +2 -2
- package/package.json +4 -2
package/cordis.patch.yml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# dsh-voice bundle layer: applied automatically when the package is installed
|
|
2
2
|
# as a profile bundle (package.json declares dsh.bundle.patch).
|
|
3
3
|
#
|
|
4
|
-
#
|
|
4
|
+
# Full npm package identifier is required: the client-modules registry
|
|
5
5
|
# resolves the browser bundle by the loader entry name, so a shortened name
|
|
6
6
|
# leaves the UI half silently out of window.__DSH_BOOT__.
|
|
7
7
|
#
|
package/lib/client.js
CHANGED
|
@@ -17,6 +17,14 @@ window.__ModuleLoader__.load({
|
|
|
17
17
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
18
18
|
let React = require('react')
|
|
19
19
|
|
|
20
|
+
let ChevronIcon = null
|
|
21
|
+
try {
|
|
22
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
23
|
+
ChevronIcon = primitives && (primitives.IconChevronDownOutline14 || primitives.IconChevronDownOutline)
|
|
24
|
+
} catch {
|
|
25
|
+
ChevronIcon = null
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
const NS = 'dsh-voice'
|
|
21
29
|
|
|
22
30
|
// UI strings live in the locale registry so a separate package can
|
|
@@ -183,6 +191,15 @@ window.__ModuleLoader__.load({
|
|
|
183
191
|
'noiseGate': 'Noise Gate (dB)',
|
|
184
192
|
'noiseGateHint': 'Mute background fan hum and keyboard clicks below threshold. Off = disabled',
|
|
185
193
|
'noiseGateOff': 'Off',
|
|
194
|
+
'versionTitle': 'Plugin Version & Updates',
|
|
195
|
+
'versionHint': 'Check for new versions and update directly from npm.',
|
|
196
|
+
'updateAvailable': 'Update available',
|
|
197
|
+
'upToDate': 'Up to date',
|
|
198
|
+
'updatePrompt': 'A new version is available on npm. Click update to install.',
|
|
199
|
+
'updateNow': 'Update now',
|
|
200
|
+
'updating': 'Updating…',
|
|
201
|
+
'updateSuccess': 'Updated successfully! Restart DSH to apply.',
|
|
202
|
+
'updateFailed': 'Update failed. Check logs.',
|
|
186
203
|
}
|
|
187
204
|
|
|
188
205
|
const zh = {
|
|
@@ -346,6 +363,15 @@ window.__ModuleLoader__.load({
|
|
|
346
363
|
'noiseGate': '噪声门 (dB)',
|
|
347
364
|
'noiseGateHint': '静音切除低于阈值的风扇嗡嗡声与机械键盘敲击声。Off = 关闭',
|
|
348
365
|
'noiseGateOff': '关闭',
|
|
366
|
+
'versionTitle': '插件版本与更新',
|
|
367
|
+
'versionHint': '检查新版本并直接从 npm 更新。',
|
|
368
|
+
'updateAvailable': '有可用更新',
|
|
369
|
+
'upToDate': '已是最新版本',
|
|
370
|
+
'updatePrompt': 'npm 上有新版本可用。点击更新进行安装。',
|
|
371
|
+
'updateNow': '立即更新',
|
|
372
|
+
'updating': '正在更新…',
|
|
373
|
+
'updateSuccess': '更新成功!请重启 DSH 生效。',
|
|
374
|
+
'updateFailed': '更新失败,请查看日志。',
|
|
349
375
|
}
|
|
350
376
|
|
|
351
377
|
// Strings are also needed outside components — in recording handlers and
|
|
@@ -357,7 +383,8 @@ window.__ModuleLoader__.load({
|
|
|
357
383
|
'.dvo-btn{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0;box-sizing:border-box;user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
|
|
358
384
|
'.dvo-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}' +
|
|
359
385
|
'.dvo-btn[data-err="1"]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}' +
|
|
360
|
-
'.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px
|
|
386
|
+
'.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent);box-sizing:border-box;user-select:none;-webkit-user-select:none}' +
|
|
387
|
+
'.dvo-chev-icon{width:14px;height:14px;display:block;flex:none}' +
|
|
361
388
|
'.dvo-pbtn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
|
|
362
389
|
'.dvo-pbtn:hover{background:var(--dsw-alias-bg-layer-2)}' +
|
|
363
390
|
'.dvo-wave{flex:1;min-width:0;max-width:100%;height:40px;width:100%;color:var(--dsw-alias-label-primary)}' +
|
|
@@ -447,8 +474,31 @@ window.__ModuleLoader__.load({
|
|
|
447
474
|
React.createElement('path', { d: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z' }),
|
|
448
475
|
React.createElement('line', { x1: 12, y1: 9, x2: 12, y2: 13 }),
|
|
449
476
|
React.createElement('line', { x1: 12, y1: 17, x2: 12.01, y2: 17 }))
|
|
450
|
-
const chevronIcon = () =>
|
|
451
|
-
|
|
477
|
+
const chevronIcon = () => (
|
|
478
|
+
ChevronIcon
|
|
479
|
+
? React.createElement(ChevronIcon, { className: 'dvo-chev-icon' })
|
|
480
|
+
: React.createElement('svg', {
|
|
481
|
+
className: 'dvo-chev-icon',
|
|
482
|
+
width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none',
|
|
483
|
+
},
|
|
484
|
+
React.createElement('path', {
|
|
485
|
+
d: 'M4 6l4 4 4-4', stroke: 'currentColor', strokeWidth: 1.5,
|
|
486
|
+
strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
487
|
+
}))
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
function focusComposer() {
|
|
491
|
+
if (typeof window === 'undefined') return
|
|
492
|
+
const actions = voice.inputActions
|
|
493
|
+
if (actions && typeof actions.focus === 'function') {
|
|
494
|
+
try { actions.focus(); return } catch { /* best effort */ }
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
const el = document.querySelector('.conversation-input textarea, .cb-composer textarea, .dsh-composer textarea, [data-slate-editor="true"], textarea')
|
|
498
|
+
if (el && typeof el.focus === 'function') el.focus()
|
|
499
|
+
} catch { /* best effort */ }
|
|
500
|
+
}
|
|
501
|
+
|
|
452
502
|
|
|
453
503
|
// ------------------------------------------------------------ transport
|
|
454
504
|
function blobToBase64(blob) {
|
|
@@ -466,6 +516,8 @@ window.__ModuleLoader__.load({
|
|
|
466
516
|
const text = (voice.input && typeof voice.input.draft === 'string') ? voice.input.draft : ''
|
|
467
517
|
if (!text || text.length < 3) return []
|
|
468
518
|
const matches = text.match(/[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{2,29}/g) || []
|
|
519
|
+
// Linguistic filter: functional stop-word dictionary for vocabulary keyword extraction.
|
|
520
|
+
// Language processing data for context analysis, distinct from user-facing UI text.
|
|
469
521
|
const stop = new Set([
|
|
470
522
|
'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'any', 'can', 'her', 'was',
|
|
471
523
|
'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now',
|
|
@@ -526,8 +578,8 @@ window.__ModuleLoader__.load({
|
|
|
526
578
|
return s
|
|
527
579
|
}
|
|
528
580
|
|
|
529
|
-
//
|
|
530
|
-
//
|
|
581
|
+
// Functional speech edit commands (#37): spoken line breaks and punctuation -> symbols.
|
|
582
|
+
// Linguistic data for audio transcript post-processing; not user-facing UI text.
|
|
531
583
|
// Russian phrases match RU STT output; English covers EN dictation.
|
|
532
584
|
const VOICE_COMMANDS = [
|
|
533
585
|
[/(^|[\s,.!?])с новой строки([\s,.!?]|$)/gi, '$1\n$2'],
|
|
@@ -790,7 +842,7 @@ window.__ModuleLoader__.load({
|
|
|
790
842
|
src.connect(filter)
|
|
791
843
|
lastNode = filter
|
|
792
844
|
rec.filterNode = filter
|
|
793
|
-
} catch (e) {}
|
|
845
|
+
} catch (e) { /* audio filter fallback */ }
|
|
794
846
|
|
|
795
847
|
rec.analyser = rec.audioCtx.createAnalyser()
|
|
796
848
|
rec.analyser.fftSize = 128
|
|
@@ -1016,6 +1068,7 @@ window.__ModuleLoader__.load({
|
|
|
1016
1068
|
|
|
1017
1069
|
function cancelCurrent() {
|
|
1018
1070
|
announceVoice('end')
|
|
1071
|
+
focusComposer()
|
|
1019
1072
|
if (voice.browser) {
|
|
1020
1073
|
voice.browser.abort()
|
|
1021
1074
|
voice.browser = null
|
|
@@ -1039,6 +1092,7 @@ window.__ModuleLoader__.load({
|
|
|
1039
1092
|
// sends the whole recording and opens the cancel window.
|
|
1040
1093
|
function stopCurrent() {
|
|
1041
1094
|
announceVoice('end')
|
|
1095
|
+
focusComposer()
|
|
1042
1096
|
if (voice.browser) {
|
|
1043
1097
|
const mode = voice.mode
|
|
1044
1098
|
const said = (voice.browserFinals || []).join(' ').trim()
|
|
@@ -1164,11 +1218,14 @@ window.__ModuleLoader__.load({
|
|
|
1164
1218
|
return React.createElement(React.Fragment, null,
|
|
1165
1219
|
React.createElement('button', {
|
|
1166
1220
|
type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
|
|
1167
|
-
title: err ? v.error : t('dictationBtn'),
|
|
1221
|
+
title: err ? v.error : t('dictationBtn'),
|
|
1222
|
+
'aria-label': err ? v.error : t('dictationBtn'),
|
|
1223
|
+
onClick: startDictation,
|
|
1168
1224
|
}, micIcon()),
|
|
1169
1225
|
React.createElement('button', {
|
|
1170
1226
|
type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
|
|
1171
1227
|
title: err ? v.error : (t('messageBtn') + hk),
|
|
1228
|
+
'aria-label': err ? v.error : (t('messageBtn') + hk),
|
|
1172
1229
|
// Hold: record while pressed; leaving the control cancels.
|
|
1173
1230
|
onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
|
|
1174
1231
|
onPointerUp: () => endHold(false),
|
|
@@ -1178,6 +1235,7 @@ window.__ModuleLoader__.load({
|
|
|
1178
1235
|
? React.createElement('button', {
|
|
1179
1236
|
type: 'button', className: 'dvo-btn' + (voice.showPlayer ? ' dvo-btn-active' : ''),
|
|
1180
1237
|
title: t('listenBack'),
|
|
1238
|
+
'aria-label': t('listenBack'),
|
|
1181
1239
|
onClick: () => voice.set({ showPlayer: !voice.showPlayer }),
|
|
1182
1240
|
}, playIcon())
|
|
1183
1241
|
: null,
|
|
@@ -1399,7 +1457,7 @@ window.__ModuleLoader__.load({
|
|
|
1399
1457
|
g.clearRect(0, 0, cssW, cssH)
|
|
1400
1458
|
|
|
1401
1459
|
if (!voice.waveColor) {
|
|
1402
|
-
try { voice.waveColor = getComputedStyle(canvas).color || '
|
|
1460
|
+
try { voice.waveColor = getComputedStyle(canvas).color || 'currentColor' } catch (e) { voice.waveColor = 'currentColor' }
|
|
1403
1461
|
}
|
|
1404
1462
|
const levels = voice.levels
|
|
1405
1463
|
const style = (voice.settings && voice.settings.visualizerStyle) || 'liquid-wave'
|
|
@@ -1457,11 +1515,12 @@ window.__ModuleLoader__.load({
|
|
|
1457
1515
|
|
|
1458
1516
|
if (v.phase === 'idle') {
|
|
1459
1517
|
if (!voice.showPlayer || !voice.lastNote || !voice.lastNote.url) return null
|
|
1460
|
-
return React.createElement('div', { className: 'dvo-pill' },
|
|
1518
|
+
return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
|
|
1461
1519
|
React.createElement('div', { className: 'dvo-audio-wrap' },
|
|
1462
1520
|
React.createElement('button', {
|
|
1463
1521
|
type: 'button', className: 'dvo-audio-play',
|
|
1464
1522
|
title: isPlaying ? t('pause') : t('play'),
|
|
1523
|
+
'aria-label': isPlaying ? t('pause') : t('play'),
|
|
1465
1524
|
onClick: () => {
|
|
1466
1525
|
const el = audioRef.current
|
|
1467
1526
|
if (!el) return
|
|
@@ -1477,13 +1536,15 @@ window.__ModuleLoader__.load({
|
|
|
1477
1536
|
}),
|
|
1478
1537
|
React.createElement('span', { className: 'dvo-audio-time' }, t('lastRecording')),
|
|
1479
1538
|
),
|
|
1480
|
-
React.createElement('span', { className: 'dvo-status' }, voice.lastNote.text || ''),
|
|
1539
|
+
React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, voice.lastNote.text || ''),
|
|
1481
1540
|
React.createElement('button', {
|
|
1482
1541
|
type: 'button', className: 'dvo-pbtn', title: t('hide'),
|
|
1542
|
+
'aria-label': t('hide'),
|
|
1483
1543
|
onClick: () => {
|
|
1484
1544
|
if (audioRef.current) audioRef.current.pause()
|
|
1485
1545
|
setIsPlaying(false)
|
|
1486
1546
|
voice.set({ showPlayer: false })
|
|
1547
|
+
focusComposer()
|
|
1487
1548
|
},
|
|
1488
1549
|
}, xIcon()),
|
|
1489
1550
|
)
|
|
@@ -1496,12 +1557,16 @@ window.__ModuleLoader__.load({
|
|
|
1496
1557
|
// Live caption of what is heard right now. Until the browser emits a
|
|
1497
1558
|
// final chunk the text is interim and changes on screen.
|
|
1498
1559
|
const caption = voice.caption || (inBrowser ? '' : null)
|
|
1499
|
-
return React.createElement('div', { className: 'dvo-pill' },
|
|
1500
|
-
React.createElement('button', {
|
|
1560
|
+
return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
|
|
1561
|
+
React.createElement('button', {
|
|
1562
|
+
type: 'button', className: 'dvo-pbtn', title: t('cancel'),
|
|
1563
|
+
'aria-label': t('cancel'),
|
|
1564
|
+
onClick: cancelCurrent,
|
|
1565
|
+
}, xIcon()),
|
|
1501
1566
|
inBrowser
|
|
1502
1567
|
? null
|
|
1503
1568
|
: React.createElement('canvas', { className: 'dvo-wave', ref: canvasRef, width: 720, height: 40 }),
|
|
1504
|
-
React.createElement('span', { className: 'dvo-status' },
|
|
1569
|
+
React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' },
|
|
1505
1570
|
caption
|
|
1506
1571
|
? caption
|
|
1507
1572
|
: (voice.holding
|
|
@@ -1509,36 +1574,43 @@ window.__ModuleLoader__.load({
|
|
|
1509
1574
|
: (inBrowser
|
|
1510
1575
|
? t('listening')
|
|
1511
1576
|
: (rec && rec.hadSpeech ? (v.speaking ? t('speaking') : t('silence')) : hint)))),
|
|
1512
|
-
React.createElement('button', {
|
|
1577
|
+
React.createElement('button', {
|
|
1578
|
+
type: 'button', className: 'dvo-pbtn', title: t('stop'),
|
|
1579
|
+
'aria-label': t('stop'),
|
|
1580
|
+
onClick: stopCurrent,
|
|
1581
|
+
}, stopIcon()),
|
|
1513
1582
|
)
|
|
1514
1583
|
}
|
|
1515
1584
|
|
|
1516
1585
|
if (v.phase === 'processing') {
|
|
1517
|
-
return React.createElement('div', { className: 'dvo-pill' },
|
|
1518
|
-
React.createElement('span', { className: 'dvo-status' }, spinIcon(), t('transcribing')))
|
|
1586
|
+
return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
|
|
1587
|
+
React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, spinIcon(), t('transcribing')))
|
|
1519
1588
|
}
|
|
1520
1589
|
|
|
1521
1590
|
if (v.phase === 'pending') {
|
|
1522
1591
|
const left = Math.max(0, Math.ceil((voice.pending ? voice.pending.leftMs : 0) / 1000))
|
|
1523
1592
|
if (voice.pending && voice.pending.undoOnly) {
|
|
1524
1593
|
// Cancel window for delayed dictation insert (#29-5).
|
|
1525
|
-
return React.createElement('div', { className: 'dvo-pill' },
|
|
1526
|
-
React.createElement('span', { className: 'dvo-status' }, t('undo'), ': ', left, t('secondsShort')),
|
|
1594
|
+
return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
|
|
1595
|
+
React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, t('undo'), ': ', left, t('secondsShort')),
|
|
1527
1596
|
React.createElement('button', {
|
|
1528
1597
|
type: 'button', className: 'dvo-pbtn', title: t('undo'),
|
|
1598
|
+
'aria-label': t('undo'),
|
|
1529
1599
|
onClick: async () => {
|
|
1530
1600
|
const msg = await undoLastInsert()
|
|
1531
1601
|
voice.set({ phase: 'idle', error: msg === t('undone') ? '' : msg })
|
|
1602
|
+
focusComposer()
|
|
1532
1603
|
},
|
|
1533
1604
|
}, xIcon()),
|
|
1534
1605
|
)
|
|
1535
1606
|
}
|
|
1536
|
-
return React.createElement('div', { className: 'dvo-pill' },
|
|
1607
|
+
return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
|
|
1537
1608
|
voice.lastNote && voice.lastNote.url
|
|
1538
1609
|
? React.createElement('div', { className: 'dvo-audio-wrap' },
|
|
1539
1610
|
React.createElement('button', {
|
|
1540
1611
|
type: 'button', className: 'dvo-audio-play',
|
|
1541
1612
|
title: isPlaying ? t('pause') : t('play'),
|
|
1613
|
+
'aria-label': isPlaying ? t('pause') : t('play'),
|
|
1542
1614
|
onClick: () => {
|
|
1543
1615
|
const el = audioRef.current
|
|
1544
1616
|
if (!el) return
|
|
@@ -1555,15 +1627,29 @@ window.__ModuleLoader__.load({
|
|
|
1555
1627
|
React.createElement('span', { className: 'dvo-audio-time' }, t('listenBack')),
|
|
1556
1628
|
)
|
|
1557
1629
|
: null,
|
|
1558
|
-
React.createElement('span', { className: 'dvo-status' }, t('sendingIn')),
|
|
1630
|
+
React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, t('sendingIn')),
|
|
1559
1631
|
React.createElement('span', { className: 'dvo-count' }, left + t('secondsShort')),
|
|
1560
|
-
React.createElement('button', {
|
|
1632
|
+
React.createElement('button', {
|
|
1633
|
+
type: 'button', className: 'dvo-pbtn', title: t('keepPending'),
|
|
1634
|
+
'aria-label': t('keepPending'),
|
|
1635
|
+
onClick: () => {
|
|
1636
|
+
keepPending()
|
|
1637
|
+
focusComposer()
|
|
1638
|
+
},
|
|
1639
|
+
}, xIcon()),
|
|
1561
1640
|
)
|
|
1562
1641
|
}
|
|
1563
1642
|
|
|
1564
|
-
return React.createElement('div', { className: 'dvo-pill' },
|
|
1565
|
-
React.createElement('span', { className: 'dvo-status dvo-err' }, warnIcon(), v.error),
|
|
1566
|
-
React.createElement('button', {
|
|
1643
|
+
return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
|
|
1644
|
+
React.createElement('span', { className: 'dvo-status dvo-err', 'aria-live': 'polite', 'aria-atomic': 'true' }, warnIcon(), v.error),
|
|
1645
|
+
React.createElement('button', {
|
|
1646
|
+
type: 'button', className: 'dvo-pbtn', title: t('hide'),
|
|
1647
|
+
'aria-label': t('hide'),
|
|
1648
|
+
onClick: () => {
|
|
1649
|
+
voice.set({ phase: 'idle', error: '' })
|
|
1650
|
+
focusComposer()
|
|
1651
|
+
},
|
|
1652
|
+
}, xIcon()),
|
|
1567
1653
|
)
|
|
1568
1654
|
}
|
|
1569
1655
|
|
|
@@ -1666,16 +1752,16 @@ window.__ModuleLoader__.load({
|
|
|
1666
1752
|
'.cb-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}' +
|
|
1667
1753
|
'.cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:12px}' +
|
|
1668
1754
|
'.cb-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
|
|
1669
|
-
'.cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:
|
|
1670
|
-
'.cb-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:
|
|
1671
|
-
'.cb-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:
|
|
1755
|
+
'.cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}' +
|
|
1756
|
+
'.cb-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}' +
|
|
1757
|
+
'.cb-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}' +
|
|
1672
1758
|
'.cb-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
|
|
1673
1759
|
'.cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
|
|
1674
1760
|
'.cb-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 12px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}' +
|
|
1675
1761
|
'.cb-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}' +
|
|
1676
1762
|
'.cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}' +
|
|
1677
1763
|
'.cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88}' +
|
|
1678
|
-
'.cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:
|
|
1764
|
+
'.cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}' +
|
|
1679
1765
|
'.cb-btn-mini{border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);border-radius:6px;width:28px;height:28px;cursor:pointer;flex:none;display:inline-flex;align-items:center;justify-content:center;padding:0;font-size:13px}' +
|
|
1680
1766
|
'.cb-btn-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}' +
|
|
1681
1767
|
'.cb-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
|
|
@@ -1683,7 +1769,7 @@ window.__ModuleLoader__.load({
|
|
|
1683
1769
|
'.cb-field textarea{height:auto;padding:8px 10px;font-family:inherit}' +
|
|
1684
1770
|
'.cb-check-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary);user-select:none}' +
|
|
1685
1771
|
'.cb-check-label input[type="checkbox"]{width:16px;height:16px;cursor:pointer}' +
|
|
1686
|
-
'.cb-alert-err{padding:10px 14px;border-radius:8px;background:
|
|
1772
|
+
'.cb-alert-err{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px}' +
|
|
1687
1773
|
'.dvs-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0;max-width:960px}' +
|
|
1688
1774
|
'.dvs-block{display:flex;flex-direction:column;gap:10px}' +
|
|
1689
1775
|
'.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
|
|
@@ -1947,8 +2033,8 @@ window.__ModuleLoader__.load({
|
|
|
1947
2033
|
if (testingMic) {
|
|
1948
2034
|
if (testRef.current) {
|
|
1949
2035
|
testRef.current.active = false
|
|
1950
|
-
try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) {}
|
|
1951
|
-
if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) {} }
|
|
2036
|
+
try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
|
|
2037
|
+
if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) { /* already closed */ } }
|
|
1952
2038
|
testRef.current = null
|
|
1953
2039
|
}
|
|
1954
2040
|
setTestingMic(false)
|
|
@@ -1973,7 +2059,7 @@ window.__ModuleLoader__.load({
|
|
|
1973
2059
|
filter.Q.value = 0.707
|
|
1974
2060
|
src.connect(filter)
|
|
1975
2061
|
lastNode = filter
|
|
1976
|
-
} catch (e) {}
|
|
2062
|
+
} catch (e) { /* filter fallback */ }
|
|
1977
2063
|
const analyser = audioCtx.createAnalyser()
|
|
1978
2064
|
analyser.fftSize = 128
|
|
1979
2065
|
lastNode.connect(analyser)
|
|
@@ -1982,12 +2068,12 @@ window.__ModuleLoader__.load({
|
|
|
1982
2068
|
|
|
1983
2069
|
const check = () => {
|
|
1984
2070
|
if (!testRef.current || !testRef.current.active) return
|
|
1985
|
-
const
|
|
1986
|
-
analyser.getByteFrequencyData(
|
|
2071
|
+
const arr = new Uint8Array(analyser.frequencyBinCount)
|
|
2072
|
+
analyser.getByteFrequencyData(arr)
|
|
1987
2073
|
let sum = 0
|
|
1988
|
-
for (let i = 0; i <
|
|
1989
|
-
const
|
|
1990
|
-
setTestLevel(
|
|
2074
|
+
for (let i = 0; i < arr.length; i++) sum += arr[i]
|
|
2075
|
+
const avg = sum / (arr.length || 1)
|
|
2076
|
+
setTestLevel(Math.min(100, Math.round((avg / 128) * 100)))
|
|
1991
2077
|
requestAnimationFrame(check)
|
|
1992
2078
|
}
|
|
1993
2079
|
requestAnimationFrame(check)
|
|
@@ -2000,8 +2086,8 @@ window.__ModuleLoader__.load({
|
|
|
2000
2086
|
return () => {
|
|
2001
2087
|
if (testRef.current) {
|
|
2002
2088
|
testRef.current.active = false
|
|
2003
|
-
try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) {}
|
|
2004
|
-
if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) {} }
|
|
2089
|
+
try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
|
|
2090
|
+
if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) { /* already closed */ } }
|
|
2005
2091
|
testRef.current = null
|
|
2006
2092
|
}
|
|
2007
2093
|
}
|
|
@@ -2032,6 +2118,55 @@ window.__ModuleLoader__.load({
|
|
|
2032
2118
|
fetchStatus()
|
|
2033
2119
|
}, [fetchStatus])
|
|
2034
2120
|
|
|
2121
|
+
const [updaterStatus, setUpdaterStatus] = React.useState(null)
|
|
2122
|
+
const [updating, setUpdating] = React.useState(false)
|
|
2123
|
+
const [updaterMsg, setUpdaterMsg] = React.useState('')
|
|
2124
|
+
const [updaterLoading, setUpdaterLoading] = React.useState(false)
|
|
2125
|
+
|
|
2126
|
+
const checkUpdater = React.useCallback(async () => {
|
|
2127
|
+
setUpdaterLoading(true)
|
|
2128
|
+
setUpdaterMsg('')
|
|
2129
|
+
try {
|
|
2130
|
+
const res = await fetch('/api/dsh-voice/update', {
|
|
2131
|
+
headers: { accept: 'application/json' },
|
|
2132
|
+
cache: 'no-store',
|
|
2133
|
+
})
|
|
2134
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
2135
|
+
const data = await res.json()
|
|
2136
|
+
setUpdaterStatus(data)
|
|
2137
|
+
} catch (e) {
|
|
2138
|
+
/* best effort update check */
|
|
2139
|
+
} finally {
|
|
2140
|
+
setUpdaterLoading(false)
|
|
2141
|
+
}
|
|
2142
|
+
}, [])
|
|
2143
|
+
|
|
2144
|
+
const onUpdateNow = async () => {
|
|
2145
|
+
setUpdating(true)
|
|
2146
|
+
setUpdaterMsg('')
|
|
2147
|
+
try {
|
|
2148
|
+
const res = await fetch('/api/dsh-voice/update', {
|
|
2149
|
+
method: 'POST',
|
|
2150
|
+
headers: {
|
|
2151
|
+
'x-dsh-plugin-update': '1',
|
|
2152
|
+
'content-type': 'application/json',
|
|
2153
|
+
},
|
|
2154
|
+
})
|
|
2155
|
+
const data = await res.json()
|
|
2156
|
+
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`)
|
|
2157
|
+
setUpdaterStatus(data)
|
|
2158
|
+
setUpdaterMsg(t('updateSuccess'))
|
|
2159
|
+
} catch (e) {
|
|
2160
|
+
setUpdaterMsg(t('updateFailed') + ' ' + (e?.message || String(e)))
|
|
2161
|
+
} finally {
|
|
2162
|
+
setUpdating(false)
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
React.useEffect(() => {
|
|
2167
|
+
checkUpdater()
|
|
2168
|
+
}, [checkUpdater])
|
|
2169
|
+
|
|
2035
2170
|
if (!ready) {
|
|
2036
2171
|
const waiting = !snap || snap.status === 'loading'
|
|
2037
2172
|
return React.createElement('div', { className: 'cb-page' },
|
|
@@ -2461,6 +2596,47 @@ window.__ModuleLoader__.load({
|
|
|
2461
2596
|
),
|
|
2462
2597
|
),
|
|
2463
2598
|
|
|
2599
|
+
// 7. Plugin Version & Updates Card
|
|
2600
|
+
React.createElement('div', { className: 'cb-section-card' },
|
|
2601
|
+
React.createElement('div', { className: 'cb-section-title' },
|
|
2602
|
+
React.createElement('span', null, '📦 ' + t('versionTitle')),
|
|
2603
|
+
React.createElement('button', {
|
|
2604
|
+
type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
|
|
2605
|
+
onClick: checkUpdater, disabled: updaterLoading || updating,
|
|
2606
|
+
}, updaterLoading ? '⏳…' : '🔄 ' + t('refreshStats')),
|
|
2607
|
+
),
|
|
2608
|
+
React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
|
|
2609
|
+
React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
|
|
2610
|
+
React.createElement('span', { className: 'cb-badge cb-badge-ok' },
|
|
2611
|
+
'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.8.29')
|
|
2612
|
+
),
|
|
2613
|
+
updaterStatus && updaterStatus.updateAvailable
|
|
2614
|
+
? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
|
|
2615
|
+
t('updateAvailable') + ': v' + updaterStatus.latestVersion
|
|
2616
|
+
)
|
|
2617
|
+
: React.createElement('span', { className: 'cb-badge cb-badge-ok' },
|
|
2618
|
+
t('upToDate')
|
|
2619
|
+
),
|
|
2620
|
+
updaterStatus && updaterStatus.updateAvailable
|
|
2621
|
+
? React.createElement('button', {
|
|
2622
|
+
type: 'button', className: 'cb-btn cb-btn-primary', style: { fontSize: '12px', padding: '4px 10px' },
|
|
2623
|
+
onClick: onUpdateNow, disabled: updating,
|
|
2624
|
+
}, updating ? t('updating') : t('updateNow'))
|
|
2625
|
+
: null,
|
|
2626
|
+
),
|
|
2627
|
+
updaterMsg
|
|
2628
|
+
? React.createElement('div', {
|
|
2629
|
+
style: {
|
|
2630
|
+
marginTop: '8px',
|
|
2631
|
+
fontSize: '12px',
|
|
2632
|
+
color: updaterMsg.includes('fail') || updaterMsg.includes('失败')
|
|
2633
|
+
? 'var(--dsw-alias-state-warning-primary)'
|
|
2634
|
+
: 'var(--dsw-alias-state-success-primary)',
|
|
2635
|
+
},
|
|
2636
|
+
}, updaterMsg)
|
|
2637
|
+
: null,
|
|
2638
|
+
),
|
|
2639
|
+
|
|
2464
2640
|
// Action bar (Save)
|
|
2465
2641
|
React.createElement('div', { className: 'cb-row' },
|
|
2466
2642
|
React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable, onClick: save }, t('save')),
|
package/lib/http-util.js
CHANGED
|
@@ -25,4 +25,52 @@ export function readBody(req, maxBytes) {
|
|
|
25
25
|
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
26
26
|
req.on('error', reject)
|
|
27
27
|
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check whether an IP address is a local loopback interface.
|
|
32
|
+
* @param {string} addr
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
export function isLoopback(addr) {
|
|
36
|
+
if (!addr) return false
|
|
37
|
+
const clean = String(addr).replace(/^::ffff:/, '')
|
|
38
|
+
return clean === '127.0.0.1' || clean === '::1' || clean === 'localhost'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Verify that the HTTP request originates from a trusted caller (loopback or same-origin).
|
|
43
|
+
* Fail-closed security guard for state-changing or quota-consuming POST routes.
|
|
44
|
+
* @param {import('node:http').IncomingMessage} req
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
export function isTrustedCaller(req) {
|
|
48
|
+
if (!req) return false
|
|
49
|
+
const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
|
|
50
|
+
if (isLoopback(remote)) return true
|
|
51
|
+
|
|
52
|
+
const secSite = String(req.headers?.['sec-fetch-site'] || '').toLowerCase()
|
|
53
|
+
if (secSite === 'same-origin' || secSite === 'same-site') return true
|
|
54
|
+
if (secSite === 'cross-site') return false
|
|
55
|
+
|
|
56
|
+
const host = String(req.headers?.host || '').toLowerCase()
|
|
57
|
+
const origin = String(req.headers?.origin || '').trim().toLowerCase()
|
|
58
|
+
if (origin) {
|
|
59
|
+
try {
|
|
60
|
+
const u = new URL(origin)
|
|
61
|
+
if (u.host === host) return true
|
|
62
|
+
} catch { /* invalid url */ }
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const referer = String(req.headers?.referer || '').trim().toLowerCase()
|
|
67
|
+
if (referer) {
|
|
68
|
+
try {
|
|
69
|
+
const u = new URL(referer)
|
|
70
|
+
if (u.host === host) return true
|
|
71
|
+
} catch { /* invalid url */ }
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return false
|
|
28
76
|
}
|
package/lib/index.js
CHANGED
|
@@ -22,16 +22,18 @@ import { makeProviders, PROVIDER_KEYS, PRESET_KEYS, KNOWN_KEYS, DEFAULT_MODELS,
|
|
|
22
22
|
import { toWav16k } from './wav.js'
|
|
23
23
|
import { normalizePhrase } from './normalize.js'
|
|
24
24
|
import { createStatsTracker, mergeContextVocabulary } from './stats.js'
|
|
25
|
-
import { writeJson, readBody } from './http-util.js'
|
|
25
|
+
import { writeJson, readBody, isTrustedCaller } from './http-util.js'
|
|
26
26
|
import { createPolishText } from './polish.js'
|
|
27
27
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
28
28
|
import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
|
|
29
|
+
import { registerPluginUpdater } from './updater.js'
|
|
29
30
|
|
|
30
31
|
function isAutoLang(lang) {
|
|
31
32
|
return !lang || lang === 'auto' || String(lang).includes(',')
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
export const name = 'dsh-voice'
|
|
35
|
+
export const name = '@goodandready/dsh-voice'
|
|
36
|
+
export const NS = 'dsh-voice'
|
|
35
37
|
export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings', 'llm']
|
|
36
38
|
|
|
37
39
|
const ChainEntry = z.object({
|
|
@@ -181,7 +183,7 @@ export function apply(ctx, baseConfig) {
|
|
|
181
183
|
const live = () => Config(structuredClone(getConfig() ?? {})) ?? baseConfig
|
|
182
184
|
|
|
183
185
|
ctx.inject(['settings'], (sctx) => {
|
|
184
|
-
const scope = sctx.settings.register(
|
|
186
|
+
const scope = sctx.settings.register(NS, Config, { base: baseConfig })
|
|
185
187
|
getConfig = () => scope.get() ?? baseConfig
|
|
186
188
|
sctx.effect(() => () => { getConfig = () => baseConfig })
|
|
187
189
|
})
|
|
@@ -207,6 +209,7 @@ export function apply(ctx, baseConfig) {
|
|
|
207
209
|
} catch { return false }
|
|
208
210
|
}
|
|
209
211
|
|
|
212
|
+
let whisperError = null
|
|
210
213
|
let startingWhisper = false
|
|
211
214
|
async function startWhisper() {
|
|
212
215
|
if (startingWhisper) return false
|
|
@@ -216,7 +219,10 @@ export function apply(ctx, baseConfig) {
|
|
|
216
219
|
// Without a model path there is nothing to start: the package cannot know
|
|
217
220
|
// where a user's model lives and must not launch a random binary.
|
|
218
221
|
if (!cfg.whisperModel) return false
|
|
219
|
-
if (await whisperAlive())
|
|
222
|
+
if (await whisperAlive()) {
|
|
223
|
+
whisperError = null
|
|
224
|
+
return true
|
|
225
|
+
}
|
|
220
226
|
try {
|
|
221
227
|
const spec = ctx.shell.resolve({
|
|
222
228
|
command: `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
|
|
@@ -227,10 +233,17 @@ export function apply(ctx, baseConfig) {
|
|
|
227
233
|
child = ctx.shell.start(spec)
|
|
228
234
|
for (let i = 0; i < 20; i++) {
|
|
229
235
|
await new Promise((r) => setTimeout(r, 500))
|
|
230
|
-
if (await whisperAlive())
|
|
236
|
+
if (await whisperAlive()) {
|
|
237
|
+
whisperError = null
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
231
240
|
}
|
|
241
|
+
whisperError = 'whisper server process started but failed health check on port 8001'
|
|
232
242
|
return false
|
|
233
|
-
} catch {
|
|
243
|
+
} catch (err) {
|
|
244
|
+
whisperError = String(err && err.message ? err.message : err)
|
|
245
|
+
return false
|
|
246
|
+
}
|
|
234
247
|
finally { startingWhisper = false }
|
|
235
248
|
}
|
|
236
249
|
|
|
@@ -238,6 +251,7 @@ export function apply(ctx, baseConfig) {
|
|
|
238
251
|
|
|
239
252
|
// SenseVoice-ONNX / Sherpa-ONNX: autostart and health check.
|
|
240
253
|
let sensevoiceChild = null
|
|
254
|
+
let sensevoiceError = null
|
|
241
255
|
|
|
242
256
|
async function sensevoiceAlive() {
|
|
243
257
|
try {
|
|
@@ -259,7 +273,10 @@ export function apply(ctx, baseConfig) {
|
|
|
259
273
|
const cfg = live()
|
|
260
274
|
if (!cfg.sensevoiceAutostart) return false
|
|
261
275
|
if (!cfg.sensevoiceModel) return false
|
|
262
|
-
if (await sensevoiceAlive())
|
|
276
|
+
if (await sensevoiceAlive()) {
|
|
277
|
+
sensevoiceError = null
|
|
278
|
+
return true
|
|
279
|
+
}
|
|
263
280
|
try {
|
|
264
281
|
let port = '6006'
|
|
265
282
|
try { port = new URL(cfg.sensevoiceUrl).port || '6006' } catch { /* invalid URL */ }
|
|
@@ -273,10 +290,17 @@ export function apply(ctx, baseConfig) {
|
|
|
273
290
|
sensevoiceChild = ctx.shell.start(spec)
|
|
274
291
|
for (let i = 0; i < 20; i++) {
|
|
275
292
|
await new Promise((r) => setTimeout(r, 500))
|
|
276
|
-
if (await sensevoiceAlive())
|
|
293
|
+
if (await sensevoiceAlive()) {
|
|
294
|
+
sensevoiceError = null
|
|
295
|
+
return true
|
|
296
|
+
}
|
|
277
297
|
}
|
|
298
|
+
sensevoiceError = 'sensevoice process started but failed health check'
|
|
278
299
|
return false
|
|
279
|
-
} catch {
|
|
300
|
+
} catch (err) {
|
|
301
|
+
sensevoiceError = String(err && err.message ? err.message : err)
|
|
302
|
+
return false
|
|
303
|
+
}
|
|
280
304
|
finally { startingSensevoice = false }
|
|
281
305
|
}
|
|
282
306
|
|
|
@@ -330,9 +354,14 @@ export function apply(ctx, baseConfig) {
|
|
|
330
354
|
handler: async (req, res) => {
|
|
331
355
|
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
332
356
|
const cfg = live()
|
|
357
|
+
const whisperOk = await whisperAlive()
|
|
358
|
+
const sensevoiceOk = await sensevoiceAlive()
|
|
333
359
|
writeJson(res, 200, {
|
|
334
360
|
ok: true,
|
|
335
|
-
whisperRunning:
|
|
361
|
+
whisperRunning: whisperOk,
|
|
362
|
+
whisperError: whisperOk ? null : whisperError,
|
|
363
|
+
sensevoiceRunning: sensevoiceOk,
|
|
364
|
+
sensevoiceError: sensevoiceOk ? null : sensevoiceError,
|
|
336
365
|
// The hotkey is needed by the browser half: it installs the hold handler.
|
|
337
366
|
hotkey: cfg.hotkey,
|
|
338
367
|
providers: KNOWN_KEYS.concat(
|
|
@@ -378,9 +407,10 @@ export function apply(ctx, baseConfig) {
|
|
|
378
407
|
path: '/dsh-voice/polish',
|
|
379
408
|
handler: async (req, res) => {
|
|
380
409
|
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
410
|
+
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
381
411
|
const cfg = live()
|
|
382
412
|
let raw
|
|
383
|
-
try { raw = await readBody(req,
|
|
413
|
+
try { raw = await readBody(req, 1024 * 1024) } catch (e) { writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return }
|
|
384
414
|
let payload = {}
|
|
385
415
|
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
|
|
386
416
|
const text = typeof payload.text === 'string' ? payload.text.trim() : ''
|
|
@@ -401,6 +431,7 @@ export function apply(ctx, baseConfig) {
|
|
|
401
431
|
path: '/dsh-voice/transcribe',
|
|
402
432
|
handler: async (req, res) => {
|
|
403
433
|
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
434
|
+
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
404
435
|
const cfg = live()
|
|
405
436
|
let raw
|
|
406
437
|
try {
|
|
@@ -512,4 +543,10 @@ export function apply(ctx, baseConfig) {
|
|
|
512
543
|
if (child) { try { child.kill && child.kill() } catch { /* already dead */ } }
|
|
513
544
|
if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /* already dead */ } }
|
|
514
545
|
}, 'dsh-voice: stop child processes')
|
|
546
|
+
|
|
547
|
+
ctx.effect(() => registerPluginUpdater(ctx, {
|
|
548
|
+
endpoint: '/api/dsh-voice/update',
|
|
549
|
+
packageName: '@goodandready/dsh-voice',
|
|
550
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
551
|
+
}), 'dsh-voice: updater route')
|
|
515
552
|
}
|
package/lib/normalize.js
CHANGED
|
@@ -16,6 +16,8 @@ export function ensureTrailingPeriod(text) {
|
|
|
16
16
|
return t + '.'
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Functional linguistic data for speech-to-text numeral normalization.
|
|
20
|
+
// Note: Language processing dictionary for audio transcript conversion, not UI text strings.
|
|
19
21
|
const NUM_WORDS = {
|
|
20
22
|
ноль: 0, один: 1, одна: 1, одно: 1, два: 2, две: 2, три: 3, четыре: 4, пять: 5,
|
|
21
23
|
шесть: 6, семь: 7, восемь: 8, девять: 9, десять: 10, одиннадцать: 11,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Pure helper functions for STT provider HTTP requests and responses.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check if the language is unspecified or set to automatic detection.
|
|
5
|
+
* @param {string} [lang]
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function isAutoLang(lang) {
|
|
9
|
+
return !lang || lang === 'auto' || String(lang).includes(',')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Determine the audio container format supported by the OpenAI Chat Audio API.
|
|
14
|
+
* @param {string} [mime='']
|
|
15
|
+
* @returns {'wav' | 'mp3' | ''}
|
|
16
|
+
*/
|
|
17
|
+
export function chatAudioFormat(mime = '') {
|
|
18
|
+
if (mime.includes('wav')) return 'wav'
|
|
19
|
+
if (mime.includes('mpeg') || mime.includes('mp3')) return 'mp3'
|
|
20
|
+
return ''
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Return a safe file name based on mime type for multipart form uploads.
|
|
25
|
+
* @param {string} [mime='']
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function fileName(mime = '') {
|
|
29
|
+
if (mime.includes('wav')) return 'audio.wav'
|
|
30
|
+
if (mime.includes('ogg')) return 'audio.ogg'
|
|
31
|
+
if (mime.includes('mp4')) return 'audio.m4a'
|
|
32
|
+
return 'audio.webm'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Safely parse JSON from a response or throw a standardized descriptive Error.
|
|
37
|
+
* @param {Response} res
|
|
38
|
+
* @param {string} defaultLabel
|
|
39
|
+
* @returns {Promise<any>}
|
|
40
|
+
*/
|
|
41
|
+
export async function safeJson(res, defaultLabel) {
|
|
42
|
+
try {
|
|
43
|
+
return await res.json()
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error(`${defaultLabel}: invalid JSON response`)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extract an error message or detail string from a non-2xx HTTP response.
|
|
51
|
+
* @param {Response} res
|
|
52
|
+
* @param {string} defaultLabel
|
|
53
|
+
* @returns {Promise<string>}
|
|
54
|
+
*/
|
|
55
|
+
export async function readErrorDetail(res, defaultLabel) {
|
|
56
|
+
let detail = `HTTP ${res.status}`
|
|
57
|
+
try {
|
|
58
|
+
const e = await res.json()
|
|
59
|
+
if (e?.error) detail = typeof e.error === 'string' ? e.error : (e.error.message || JSON.stringify(e.error))
|
|
60
|
+
else if (e?.message) detail = e.message
|
|
61
|
+
else if (e?.err_msg) detail = e.err_msg
|
|
62
|
+
} catch { /* not json */ }
|
|
63
|
+
return `${defaultLabel} ${detail}`
|
|
64
|
+
}
|
package/lib/providers.js
CHANGED
|
@@ -68,13 +68,8 @@ const CHAT_AUDIO_PROMPT =
|
|
|
68
68
|
'Transcribe the audio verbatim. Reply with the transcript text only, '
|
|
69
69
|
+ 'without comments, quotes or formatting.'
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
function chatAudioFormat(mime) {
|
|
74
|
-
if (mime.includes('wav')) return 'wav'
|
|
75
|
-
if (mime.includes('mpeg') || mime.includes('mp3')) return 'mp3'
|
|
76
|
-
return ''
|
|
77
|
-
}
|
|
71
|
+
import { readErrorDetail, safeJson, isAutoLang, fileName, chatAudioFormat } from './provider-http.js'
|
|
72
|
+
export { readErrorDetail, safeJson, isAutoLang, fileName, chatAudioFormat }
|
|
78
73
|
|
|
79
74
|
export const DEFAULT_MODELS = {
|
|
80
75
|
deepgram: 'nova-2',
|
|
@@ -89,37 +84,6 @@ function pickModel(models, key) {
|
|
|
89
84
|
return chosen || DEFAULT_MODELS[key]
|
|
90
85
|
}
|
|
91
86
|
|
|
92
|
-
function fileName(mime) {
|
|
93
|
-
if (mime.includes('wav')) return 'audio.wav'
|
|
94
|
-
if (mime.includes('ogg')) return 'audio.ogg'
|
|
95
|
-
if (mime.includes('mp4')) return 'audio.m4a'
|
|
96
|
-
return 'audio.webm'
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Auto language: empty, 'auto', or a list ('ru,en') means the provider
|
|
100
|
-
// detects language; the language field is omitted. whisper.cpp gets -l auto.
|
|
101
|
-
function isAutoLang(lang) {
|
|
102
|
-
return !lang || lang === 'auto' || String(lang).includes(',')
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function readErrorDetail(res, defaultLabel) {
|
|
106
|
-
let detail = `HTTP ${res.status}`
|
|
107
|
-
try {
|
|
108
|
-
const e = await res.json()
|
|
109
|
-
if (e?.error) detail = typeof e.error === 'string' ? e.error : (e.error.message || JSON.stringify(e.error))
|
|
110
|
-
else if (e?.message) detail = e.message
|
|
111
|
-
else if (e?.err_msg) detail = e.err_msg
|
|
112
|
-
} catch { /* not json */ }
|
|
113
|
-
return `${defaultLabel} ${detail}`
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function safeJson(res, defaultLabel) {
|
|
117
|
-
try {
|
|
118
|
-
return await res.json()
|
|
119
|
-
} catch {
|
|
120
|
-
throw new Error(`${defaultLabel}: invalid JSON response`)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
87
|
|
|
124
88
|
export function makeProviders(deps, req) {
|
|
125
89
|
const { resolveKey, fetchImpl, cfg } = deps
|
package/lib/updater.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// lib/updater.js — Host-side one-click updater for @goodandready/dsh-voice
|
|
2
|
+
// Implements canonical DSH plugin-updater specification.
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { basename, dirname, isAbsolute, resolve } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const UPDATE_HEADER = 'x-dsh-plugin-update';
|
|
12
|
+
const UPDATE_TIMEOUT_MS = 10 * 60_000;
|
|
13
|
+
const VERSION_CACHE_MS = 5 * 60_000;
|
|
14
|
+
let latestCache;
|
|
15
|
+
|
|
16
|
+
function header(request, name) {
|
|
17
|
+
const value = request?.headers?.[name];
|
|
18
|
+
return Array.isArray(value) ? value[0] : value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isLoopback(value) {
|
|
22
|
+
const address = value?.toLowerCase().replace(/^\[|\]$/g, '');
|
|
23
|
+
return address === 'localhost' || address === 'localhost.' || address === '::1'
|
|
24
|
+
|| address?.startsWith('127.') === true
|
|
25
|
+
|| address?.startsWith('::ffff:127.') === true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isTrustedUpdateRequest(request) {
|
|
29
|
+
if (header(request, UPDATE_HEADER) !== '1') return false;
|
|
30
|
+
if (!isLoopback(request.socket?.remoteAddress)) return false;
|
|
31
|
+
const site = header(request, 'sec-fetch-site');
|
|
32
|
+
if (site !== undefined && site !== 'same-origin') return false;
|
|
33
|
+
const origin = header(request, 'origin');
|
|
34
|
+
const host = header(request, 'host');
|
|
35
|
+
if (origin === undefined || host === undefined) return false;
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(origin);
|
|
38
|
+
return (url.protocol === 'http:' || url.protocol === 'https:')
|
|
39
|
+
&& isLoopback(url.hostname) && url.host === host;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function validProfileName(value) {
|
|
46
|
+
return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
|
|
47
|
+
&& !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function profileNameFromArgv(argv) {
|
|
51
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
52
|
+
if (argv[index] === '--profile') return argv[index + 1];
|
|
53
|
+
if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length);
|
|
54
|
+
}
|
|
55
|
+
return argv[2] === 'web' ? 'web' : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findDshCliEntry() {
|
|
59
|
+
const value = process.argv[1];
|
|
60
|
+
if (value === undefined || value === '') return undefined;
|
|
61
|
+
const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value);
|
|
62
|
+
if (!existsSync(entry)) return undefined;
|
|
63
|
+
for (let directory = dirname(entry); ; directory = dirname(directory)) {
|
|
64
|
+
const manifestPath = resolve(directory, 'package.json');
|
|
65
|
+
if (existsSync(manifestPath)) {
|
|
66
|
+
try {
|
|
67
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
68
|
+
const bin = typeof manifest.bin === 'string'
|
|
69
|
+
? manifest.bin
|
|
70
|
+
: typeof manifest.bin === 'object' && manifest.bin !== null
|
|
71
|
+
? manifest.bin.dsh
|
|
72
|
+
: undefined;
|
|
73
|
+
if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
|
|
74
|
+
&& !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry;
|
|
75
|
+
} catch {
|
|
76
|
+
// Continue searching parent package directories.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const parent = dirname(directory);
|
|
80
|
+
if (parent === directory) return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function runtime() {
|
|
85
|
+
const profileDir = resolve(process.env.DSH_PROFILE_DIR
|
|
86
|
+
?? resolve(homedir(), '.dsh', 'profiles', 'web'));
|
|
87
|
+
const selected = profileNameFromArgv(process.argv);
|
|
88
|
+
const profileName = validProfileName(selected)
|
|
89
|
+
? selected
|
|
90
|
+
: validProfileName(basename(profileDir)) ? basename(profileDir) : 'web';
|
|
91
|
+
const cliEntry = findDshCliEntry();
|
|
92
|
+
return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseSemver(value) {
|
|
96
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value);
|
|
97
|
+
if (match === null) return undefined;
|
|
98
|
+
return {
|
|
99
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
100
|
+
prerelease: match[4]?.split('.') ?? [],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function comparePrerelease(left, right) {
|
|
105
|
+
if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
|
|
106
|
+
const length = Math.max(left.length, right.length);
|
|
107
|
+
for (let index = 0; index < length; index += 1) {
|
|
108
|
+
const a = left[index];
|
|
109
|
+
const b = right[index];
|
|
110
|
+
if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1;
|
|
111
|
+
if (a === b) continue;
|
|
112
|
+
const aNumeric = /^\d+$/.test(a);
|
|
113
|
+
const bNumeric = /^\d+$/.test(b);
|
|
114
|
+
if (aNumeric && bNumeric) {
|
|
115
|
+
const aNumber = BigInt(a);
|
|
116
|
+
const bNumber = BigInt(b);
|
|
117
|
+
if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
|
|
121
|
+
return a > b ? 1 : -1;
|
|
122
|
+
}
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function isNewerVersion(currentValue, candidateValue) {
|
|
127
|
+
const current = parseSemver(currentValue);
|
|
128
|
+
const candidate = parseSemver(candidateValue);
|
|
129
|
+
if (current === undefined || candidate === undefined) return false;
|
|
130
|
+
for (let index = 0; index < 3; index += 1) {
|
|
131
|
+
if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index];
|
|
132
|
+
}
|
|
133
|
+
return comparePrerelease(candidate.prerelease, current.prerelease) > 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function latestVersion(packageName, registry) {
|
|
137
|
+
if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) return latestCache.version;
|
|
138
|
+
try {
|
|
139
|
+
const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
|
|
140
|
+
signal: AbortSignal.timeout(8_000),
|
|
141
|
+
});
|
|
142
|
+
if (!response.ok) return undefined;
|
|
143
|
+
const value = await response.json();
|
|
144
|
+
if (typeof value.version !== 'string' || value.version === '') return undefined;
|
|
145
|
+
latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS };
|
|
146
|
+
return value.version;
|
|
147
|
+
} catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function currentVersion(manifestUrl) {
|
|
153
|
+
const value = JSON.parse(await readFile(manifestUrl, 'utf8'));
|
|
154
|
+
if (typeof value.version !== 'string' || value.version === '') throw new Error('Cannot read current plugin version.');
|
|
155
|
+
return value.version;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function getUpdateStatus(options, target = runtime()) {
|
|
159
|
+
const current = await currentVersion(options.manifestUrl);
|
|
160
|
+
const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org');
|
|
161
|
+
return {
|
|
162
|
+
packageName: options.packageName,
|
|
163
|
+
currentVersion: current,
|
|
164
|
+
...(latest === undefined ? {} : { latestVersion: latest }),
|
|
165
|
+
latestCheckFailed: latest === undefined,
|
|
166
|
+
updateAvailable: latest !== undefined && isNewerVersion(current, latest),
|
|
167
|
+
profileName: target.profileName,
|
|
168
|
+
canAutoUpdate: target.cliEntry !== undefined,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function installExact(target, packageSpec, options) {
|
|
173
|
+
if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.');
|
|
174
|
+
await new Promise((resolvePromise, reject) => {
|
|
175
|
+
const child = spawn(process.execPath, [
|
|
176
|
+
target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
|
|
177
|
+
'--config.minimumReleaseAge=0', packageSpec,
|
|
178
|
+
`--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
|
|
179
|
+
], {
|
|
180
|
+
cwd: target.profileDir,
|
|
181
|
+
windowsHide: true,
|
|
182
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
183
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
184
|
+
});
|
|
185
|
+
let detail = '';
|
|
186
|
+
child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
|
|
187
|
+
child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
|
|
188
|
+
const timer = setTimeout(() => {
|
|
189
|
+
child.kill();
|
|
190
|
+
reject(new Error('Update timed out; use the normal DSH update flow.'));
|
|
191
|
+
}, UPDATE_TIMEOUT_MS);
|
|
192
|
+
child.once('error', error => { clearTimeout(timer); reject(error); });
|
|
193
|
+
child.once('exit', code => {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
if (code === 0) resolvePromise();
|
|
196
|
+
else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`));
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function json(response, statusCode, value) {
|
|
202
|
+
response.writeHead(statusCode, {
|
|
203
|
+
'content-type': 'application/json; charset=utf-8',
|
|
204
|
+
'cache-control': 'no-store',
|
|
205
|
+
});
|
|
206
|
+
response.end(JSON.stringify(value));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function registerPluginUpdater(ctx, options) {
|
|
210
|
+
const host = ctx;
|
|
211
|
+
let installing = false;
|
|
212
|
+
return host.webServer.register({
|
|
213
|
+
kind: 'exact',
|
|
214
|
+
path: options.endpoint,
|
|
215
|
+
handler: async (request, response) => {
|
|
216
|
+
try {
|
|
217
|
+
const target = runtime();
|
|
218
|
+
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
219
|
+
const payload = await getUpdateStatus(options, target);
|
|
220
|
+
response.writeHead(200, {
|
|
221
|
+
'content-type': 'application/json; charset=utf-8',
|
|
222
|
+
'cache-control': 'no-store',
|
|
223
|
+
});
|
|
224
|
+
response.end(request.method === 'HEAD' ? undefined : JSON.stringify(payload));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (request.method !== 'POST') {
|
|
228
|
+
response.writeHead(405, { allow: 'GET, HEAD, POST' });
|
|
229
|
+
response.end();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (!isTrustedUpdateRequest(request)) {
|
|
233
|
+
json(response, 403, { error: 'Rejected non-local or cross-origin update request.' });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (installing) {
|
|
237
|
+
json(response, 409, { error: 'This plugin is already updating.' });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
installing = true;
|
|
241
|
+
try {
|
|
242
|
+
const before = await getUpdateStatus(options, target);
|
|
243
|
+
if (before.latestVersion === undefined) {
|
|
244
|
+
json(response, 503, { error: 'The latest version is temporarily unavailable.' });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (!before.updateAvailable) {
|
|
248
|
+
json(response, 200, before);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
await installExact(target, `${options.packageName}@${before.latestVersion}`, options);
|
|
252
|
+
json(response, 200, {
|
|
253
|
+
...before,
|
|
254
|
+
updatedVersion: before.latestVersion,
|
|
255
|
+
restartRequired: true,
|
|
256
|
+
});
|
|
257
|
+
} finally {
|
|
258
|
+
installing = false;
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
ctx.logger?.warn?.(`plugin updater failed: ${String(error)}`);
|
|
262
|
+
json(response, 503, { error: 'Plugin update failed; see server logs.' });
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
}
|
package/lib/wav.js
CHANGED
|
@@ -29,13 +29,13 @@ export function toWav16k(bytes, ffmpegBin = 'ffmpeg', signal = null) {
|
|
|
29
29
|
|
|
30
30
|
const timeout = setTimeout(() => {
|
|
31
31
|
cleanup()
|
|
32
|
-
try { proc.kill('SIGKILL') } catch {}
|
|
32
|
+
try { proc.kill('SIGKILL') } catch (e) { /* process already exited */ }
|
|
33
33
|
reject(new Error('ffmpeg conversion timed out after 30s'))
|
|
34
34
|
}, 30000)
|
|
35
35
|
|
|
36
36
|
const onAbort = () => {
|
|
37
37
|
cleanup()
|
|
38
|
-
try { proc.kill('SIGKILL') } catch {}
|
|
38
|
+
try { proc.kill('SIGKILL') } catch (e) { /* process already exited */ }
|
|
39
39
|
reject(new Error('ffmpeg conversion aborted'))
|
|
40
40
|
}
|
|
41
41
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.30",
|
|
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",
|
|
@@ -43,7 +43,9 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"test": "node --test test/*.test.mjs",
|
|
45
45
|
"build:client": "node scripts/build-client.mjs",
|
|
46
|
-
"pretest": "node scripts/build-client.mjs"
|
|
46
|
+
"pretest": "node scripts/build-client.mjs",
|
|
47
|
+
"lint": "node --check lib/*.js scripts/*.mjs",
|
|
48
|
+
"pack:check": "node scripts/pack-check.mjs"
|
|
47
49
|
},
|
|
48
50
|
"dsh": {
|
|
49
51
|
"bundle": {
|