@goodandready/dsh-voice 0.8.6 → 0.8.8
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/README.md +6 -1
- package/lib/client.js +132 -19
- package/lib/index.js +94 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -149,7 +149,12 @@ The card has four blocks:
|
|
|
149
149
|
- **Your own providers** — an OpenAI-compatible API per card: name, template,
|
|
150
150
|
base URL, model, credential name. The name becomes selectable in both chains
|
|
151
151
|
as soon as it is filled in.
|
|
152
|
-
- **General** — local whisper endpoint, binary, model, autostart
|
|
152
|
+
- **General** — local whisper endpoint, binary, model, autostart, beep, localOnly,
|
|
153
|
+
microphone, custom vocabulary, offline polish endpoint (`polishBaseUrl`/`polishModel`/`polishKeyEnv`).
|
|
154
|
+
- **Voice message** also has `polishSend` (polish the whole draft before sending) and
|
|
155
|
+
`sessionCommands` ("send", "cancel", "stop", "continue" act on the session instead of text).
|
|
156
|
+
- **Dictation** also has a wake word: browser recognition starts recording when speech
|
|
157
|
+
begins with that phrase (empty disables it).
|
|
153
158
|
|
|
154
159
|
Speed matters for dictation and accuracy for messages, which is why the chains
|
|
155
160
|
are separate: a sensible pair is Deepgram → Groq → local for dictation and
|
package/lib/client.js
CHANGED
|
@@ -101,6 +101,17 @@ window.__ModuleLoader__.load({
|
|
|
101
101
|
'streamChunkMs': 'Stream chunk (ms)',
|
|
102
102
|
'vadAdapt': 'Adaptive silence',
|
|
103
103
|
'vadAdaptHint': 'Auto-tune the silence threshold to the pace of your speech. 0 = fixed',
|
|
104
|
+
'wakeWord': 'Wake word',
|
|
105
|
+
'wakeWordHint': 'Browser recognition starts recording when speech begins with this phrase. Empty = off',
|
|
106
|
+
'bargeIn': 'Barge-in',
|
|
107
|
+
'polishSend': 'Polish whole draft before sending',
|
|
108
|
+
'polishSendHint': 'Run the composed draft through the model right before sending',
|
|
109
|
+
'sessionCommands': 'Voice session commands',
|
|
110
|
+
'sessionCommandsHint': '"send", "cancel", "stop", "continue" act on the session instead of becoming text',
|
|
111
|
+
'polishBaseUrl': 'Offline polish endpoint',
|
|
112
|
+
'polishBaseUrlHint': 'OpenAI-compatible /chat/completions base URL, e.g. a local Ollama. Empty = harness model',
|
|
113
|
+
'polishModel': 'Offline polish model',
|
|
114
|
+
'polishKeyEnv': 'Offline polish key credential',
|
|
104
115
|
'voiceCommandsLabel': 'Voice edit commands ("new line", "paragraph")',
|
|
105
116
|
'normalizeTranscriptHint': 'transcribe_audio: spoken numbers to digits, tidy punctuation',
|
|
106
117
|
'messageTitle': 'Voice message',
|
|
@@ -199,6 +210,17 @@ window.__ModuleLoader__.load({
|
|
|
199
210
|
'streamChunkMs': 'Кусок потока (мс)',
|
|
200
211
|
'vadAdapt': 'Адаптивная тишина',
|
|
201
212
|
'vadAdaptHint': 'Авто-подстройка порога под темп речи. 0 = фикс. поведение',
|
|
213
|
+
'wakeWord': 'Слово-активатор',
|
|
214
|
+
'wakeWordHint': 'Распознавание в браузере начинает запись, если речь начинается с этой фразы. Пусто = выкл',
|
|
215
|
+
'bargeIn': 'Перебивание',
|
|
216
|
+
'polishSend': 'Полировка всего текста перед отправкой',
|
|
217
|
+
'polishSendHint': 'Прогнать весь текст через модель перед отправкой агенту',
|
|
218
|
+
'sessionCommands': 'Голосовые команды сессии',
|
|
219
|
+
'sessionCommandsHint': '«отправь», «отмени», «стоп», «продолжи» — действия сессии, а не текст',
|
|
220
|
+
'polishBaseUrl': 'Локальный ендпоинт полировки',
|
|
221
|
+
'polishBaseUrlHint': 'OpenAI-совместимый /chat/completions базовый URL, напр. локальный Ollama. Пусто = модель харнесса',
|
|
222
|
+
'polishModel': 'Модель офлайн-полировки',
|
|
223
|
+
'polishKeyEnv': 'Ключ офлайн-полировки',
|
|
202
224
|
'voiceCommandsLabel': 'Голосовые команды («с новой строки», «абзац»)',
|
|
203
225
|
'normalizeTranscriptHint': 'transcribe_audio: числа словами — в цифры, аккуратная пунктуация',
|
|
204
226
|
'messageTitle': 'Голосовое сообщение',
|
|
@@ -319,7 +341,8 @@ window.__ModuleLoader__.load({
|
|
|
319
341
|
if (!res.ok || !parsed || !parsed.ok) {
|
|
320
342
|
throw new Error((parsed && parsed.error && parsed.error.message) || `HTTP ${res.status}`)
|
|
321
343
|
}
|
|
322
|
-
|
|
344
|
+
if (parsed.command) return { command: parsed.command, text: '' }
|
|
345
|
+
return { text: String(parsed.text || '').trim(), command: null }
|
|
323
346
|
}
|
|
324
347
|
|
|
325
348
|
function tidyPhrase(text) {
|
|
@@ -616,7 +639,22 @@ window.__ModuleLoader__.load({
|
|
|
616
639
|
voice.browser = startBrowserRecognition({
|
|
617
640
|
lang: language,
|
|
618
641
|
continuous: true,
|
|
619
|
-
onInterim: (text) => {
|
|
642
|
+
onInterim: (text) => {
|
|
643
|
+
voice.caption = text; voice.notify()
|
|
644
|
+
// Wake-word (#45): если interim начинается с ключевой фразы —
|
|
645
|
+
// обрываем прослушивание browser и уходим в обычную запись.
|
|
646
|
+
const ww = String(voice.settings.wakeWord || '').trim().toLowerCase()
|
|
647
|
+
if (ww && mode === 'dictation' && !voice.rec) {
|
|
648
|
+
const t = String(text || '').trim().toLowerCase()
|
|
649
|
+
if (t.startsWith(ww)) {
|
|
650
|
+
voice.browser = null
|
|
651
|
+
voice.caption = ''
|
|
652
|
+
openMic(mode)
|
|
653
|
+
.then((rec) => { voice.rec = rec; voice.notify() })
|
|
654
|
+
.catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
},
|
|
620
658
|
onFinal: (text) => {
|
|
621
659
|
finals.push(text)
|
|
622
660
|
voice.caption = ''
|
|
@@ -773,7 +811,9 @@ window.__ModuleLoader__.load({
|
|
|
773
811
|
if (blob.size < 1200) { voice.set({ phase: 'idle' }); return }
|
|
774
812
|
voice.set({ phase: 'processing' })
|
|
775
813
|
try {
|
|
776
|
-
const
|
|
814
|
+
const out = await sendAudio(blob, rec.mime, mode)
|
|
815
|
+
if (out.command) { runSessionCommand(out.command); return }
|
|
816
|
+
const text = out.text || ''
|
|
777
817
|
if (!text) { voice.set({ phase: 'error', error: t('nothingHeard') }); return }
|
|
778
818
|
appendDraft(text)
|
|
779
819
|
if (mode === 'message') {
|
|
@@ -791,8 +831,46 @@ window.__ModuleLoader__.load({
|
|
|
791
831
|
voice.pending = null
|
|
792
832
|
voice.set({ phase: 'idle' })
|
|
793
833
|
const actions = voice.inputActions
|
|
794
|
-
if (actions
|
|
795
|
-
|
|
834
|
+
if (!actions || typeof actions.submit !== 'function') return
|
|
835
|
+
// Полировка всего draft перед отправкой (#46). Ошибка не блокирует.
|
|
836
|
+
if (voice.settings.polishSend) {
|
|
837
|
+
const run = async () => {
|
|
838
|
+
try {
|
|
839
|
+
const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
|
|
840
|
+
if (draft.trim()) {
|
|
841
|
+
const res = await fetch('/dsh-voice/polish', {
|
|
842
|
+
method: 'POST',
|
|
843
|
+
headers: { 'content-type': 'application/json' },
|
|
844
|
+
body: JSON.stringify({ text: draft }),
|
|
845
|
+
})
|
|
846
|
+
const parsed = await res.json().catch(() => null)
|
|
847
|
+
if (parsed && parsed.ok && typeof parsed.text === 'string' && parsed.text.trim()) {
|
|
848
|
+
actions.setDraft(parsed.text.trim())
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
} catch (e) { /* полировка best-effort */ }
|
|
852
|
+
}
|
|
853
|
+
run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /* занят */ } }, 0))
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
setTimeout(() => { try { actions.submit() } catch (e) { /* композер занят */ } }, 0)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Голосовые команды сессии (#48): чистые «отправь/отмени/стоп/продолжи».
|
|
860
|
+
function runSessionCommand(cmd) {
|
|
861
|
+
const actions = voice.inputActions
|
|
862
|
+
voice.set({ phase: 'idle', pending: null })
|
|
863
|
+
switch (cmd) {
|
|
864
|
+
case 'send':
|
|
865
|
+
case 'continue':
|
|
866
|
+
if (actions && typeof actions.submit === 'function') actions.submit()
|
|
867
|
+
break
|
|
868
|
+
case 'cancel':
|
|
869
|
+
case 'stop':
|
|
870
|
+
// Сбрасываем ожидание/запись; текст draft намеренно не трогаем.
|
|
871
|
+
break
|
|
872
|
+
default:
|
|
873
|
+
break
|
|
796
874
|
}
|
|
797
875
|
}
|
|
798
876
|
|
|
@@ -1010,6 +1088,10 @@ window.__ModuleLoader__.load({
|
|
|
1010
1088
|
stream: !!(data && data.modes && data.modes.dictation && data.modes.dictation.stream),
|
|
1011
1089
|
streamChunkMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.streamChunkMs) || 1200,
|
|
1012
1090
|
vadAdapt: Number(data && data.modes && data.modes.dictation && data.modes.dictation.vadAdapt) || 0,
|
|
1091
|
+
wakeWord: String((data && data.wakeWord) || ''),
|
|
1092
|
+
bargeIn: !!(data && data.bargeIn),
|
|
1093
|
+
polishSend: !!(data && data.modes && data.modes.message && data.modes.message.polishSend),
|
|
1094
|
+
sessionCommands: !!(data && data.modes && data.modes.message && data.modes.message.sessionCommands),
|
|
1013
1095
|
})
|
|
1014
1096
|
})
|
|
1015
1097
|
.catch(() => { /* без подсказки хоста клавиши просто не будет */ })
|
|
@@ -1221,6 +1303,14 @@ window.__ModuleLoader__.load({
|
|
|
1221
1303
|
return () => { alive = false; off() }
|
|
1222
1304
|
}, [])
|
|
1223
1305
|
|
|
1306
|
+
const [devices, setDevices] = React.useState([])
|
|
1307
|
+
React.useEffect(() => {
|
|
1308
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
|
|
1309
|
+
navigator.mediaDevices.enumerateDevices()
|
|
1310
|
+
.then((list) => setDevices(list.filter((d) => d.kind === 'audioinput')))
|
|
1311
|
+
.catch(() => {})
|
|
1312
|
+
}, [])
|
|
1313
|
+
|
|
1224
1314
|
// Снимок приходит со статусом, и он важнее самого значения.
|
|
1225
1315
|
// loading — ответа хоста ещё нет;
|
|
1226
1316
|
// unavailable — хост ответил, но наш namespace ему пока неизвестен:
|
|
@@ -1267,6 +1357,11 @@ window.__ModuleLoader__.load({
|
|
|
1267
1357
|
stream: !!(value && value.dictation && value.dictation.stream),
|
|
1268
1358
|
streamChunkMs: Number(value && value.dictation && value.dictation.streamChunkMs) || 1200,
|
|
1269
1359
|
vadAdapt: Number(value && value.dictation && value.dictation.vadAdapt) || 0,
|
|
1360
|
+
wakeWord: String((snap && snap.wakeWord) || ''),
|
|
1361
|
+
bargeIn: !!(snap && snap.bargeIn),
|
|
1362
|
+
polishSend: !!(value && value.message && value.message.polishSend),
|
|
1363
|
+
sessionCommands: !!(value && value.message && value.message.sessionCommands),
|
|
1364
|
+
polishBaseUrl: String((snap && snap.polishBaseUrl) || ''),
|
|
1270
1365
|
})
|
|
1271
1366
|
}, [ready, value, snap])
|
|
1272
1367
|
|
|
@@ -1355,26 +1450,14 @@ window.__ModuleLoader__.load({
|
|
|
1355
1450
|
t('hotkeyHint1')
|
|
1356
1451
|
+ t('hotkeyHint2')))
|
|
1357
1452
|
|
|
1358
|
-
const micField = () => {
|
|
1359
|
-
const devices = React.useState([])[0]
|
|
1360
|
-
const setDevices = React.useState([])[1]
|
|
1361
|
-
React.useEffect(() => {
|
|
1362
|
-
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
|
|
1363
|
-
navigator.mediaDevices.enumerateDevices()
|
|
1364
|
-
.then((list) => setDevices(list.filter((d) => d.kind === 'audioinput')))
|
|
1365
|
-
.catch(() => {})
|
|
1366
|
-
}, [])
|
|
1367
|
-
return React.createElement('label', { className: 'dvs-field' }, t('mic'),
|
|
1453
|
+
const micField = () => React.createElement('label', { className: 'dvs-field' }, t('mic'),
|
|
1368
1454
|
React.createElement('select', {
|
|
1369
1455
|
value: String((snap && snap.micDeviceId) || ''), disabled: !writable,
|
|
1370
1456
|
onChange: (e) => setTop('micDeviceId', e.target.value),
|
|
1371
1457
|
},
|
|
1372
1458
|
React.createElement('option', { value: '' }, t('micDefault')),
|
|
1373
1459
|
devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
|
|
1374
|
-
d.label || d.deviceId.slice(0, 12))))
|
|
1375
|
-
)
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1460
|
+
d.label || d.deviceId.slice(0, 12)))))
|
|
1378
1461
|
const langField = (mode) => React.createElement('label', { className: 'dvs-field' }, t('language'),
|
|
1379
1462
|
React.createElement('select', {
|
|
1380
1463
|
value: modeVal(mode, 'language', 'ru'), disabled: !writable,
|
|
@@ -1413,6 +1496,11 @@ window.__ModuleLoader__.load({
|
|
|
1413
1496
|
onChange: (e) => setIn('dictation', 'polish', e.target.checked),
|
|
1414
1497
|
})),
|
|
1415
1498
|
React.createElement('div', { className: 'dvs-sub' }, t('polishHint')),
|
|
1499
|
+
React.createElement('label', { className: 'dvs-field', title: t('wakeWordHint') }, t('wakeWord'),
|
|
1500
|
+
React.createElement('input', {
|
|
1501
|
+
type: 'text', value: String((snap && snap.wakeWord) || ''), disabled: !writable,
|
|
1502
|
+
onChange: (e) => setTop('wakeWord', e.target.value),
|
|
1503
|
+
})),
|
|
1416
1504
|
React.createElement('label', { className: 'dvs-field', title: t('streamHint') }, t('stream'),
|
|
1417
1505
|
React.createElement('input', {
|
|
1418
1506
|
type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.stream), disabled: !writable,
|
|
@@ -1441,6 +1529,16 @@ window.__ModuleLoader__.load({
|
|
|
1441
1529
|
type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
|
|
1442
1530
|
onChange: (e) => setIn('message', 'polish', e.target.checked),
|
|
1443
1531
|
})),
|
|
1532
|
+
React.createElement('label', { className: 'dvs-field', title: t('polishSendHint') }, t('polishSend'),
|
|
1533
|
+
React.createElement('input', {
|
|
1534
|
+
type: 'checkbox', checked: !!(draft && draft.message && draft.message.polishSend), disabled: !writable,
|
|
1535
|
+
onChange: (e) => setIn('message', 'polishSend', e.target.checked),
|
|
1536
|
+
})),
|
|
1537
|
+
React.createElement('label', { className: 'dvs-field', title: t('sessionCommandsHint') }, t('sessionCommands'),
|
|
1538
|
+
React.createElement('input', {
|
|
1539
|
+
type: 'checkbox', checked: !!(draft && draft.message && draft.message.sessionCommands), disabled: !writable,
|
|
1540
|
+
onChange: (e) => setIn('message', 'sessionCommands', e.target.checked),
|
|
1541
|
+
})),
|
|
1444
1542
|
),
|
|
1445
1543
|
React.createElement('div', { className: 'dvs-block' },
|
|
1446
1544
|
React.createElement('div', { className: 'dvs-h' }, t('customTitle')),
|
|
@@ -1490,6 +1588,21 @@ window.__ModuleLoader__.load({
|
|
|
1490
1588
|
value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
|
|
1491
1589
|
onChange: (e) => setTop('vocabulary', e.target.value.split('\n').map((x) => x.trim()).filter(Boolean)),
|
|
1492
1590
|
})),
|
|
1591
|
+
React.createElement('label', { className: 'dvs-field', title: t('polishBaseUrlHint') }, t('polishBaseUrl'),
|
|
1592
|
+
React.createElement('input', {
|
|
1593
|
+
type: 'text', value: String((snap && snap.polishBaseUrl) || ''), disabled: !writable,
|
|
1594
|
+
onChange: (e) => setTop('polishBaseUrl', e.target.value),
|
|
1595
|
+
})),
|
|
1596
|
+
React.createElement('label', { className: 'dvs-field' }, t('polishModel'),
|
|
1597
|
+
React.createElement('input', {
|
|
1598
|
+
type: 'text', value: String((draft && draft.polishModel) || ''), disabled: !writable,
|
|
1599
|
+
onChange: (e) => setTop('polishModel', e.target.value),
|
|
1600
|
+
})),
|
|
1601
|
+
React.createElement('label', { className: 'dvs-field' }, t('polishKeyEnv'),
|
|
1602
|
+
React.createElement('input', {
|
|
1603
|
+
type: 'text', value: String((draft && draft.polishKeyEnv) || ''), disabled: !writable,
|
|
1604
|
+
onChange: (e) => setTop('polishKeyEnv', e.target.value),
|
|
1605
|
+
})),
|
|
1493
1606
|
),
|
|
1494
1607
|
React.createElement('div', { className: 'dvs-row' },
|
|
1495
1608
|
React.createElement('button', { type: 'button', className: 'dvs-save', disabled: !writable, onClick: save }, t('save')),
|
package/lib/index.js
CHANGED
|
@@ -83,6 +83,10 @@ export const Config = z.object({
|
|
|
83
83
|
language: z.string().default('ru'),
|
|
84
84
|
autoSendMs: z.number().default(4000)
|
|
85
85
|
.description('Cancel window before the recognized text is sent to the agent.'),
|
|
86
|
+
sessionCommands: z.boolean().default(false)
|
|
87
|
+
.description('Voice session commands: a clean "send", "cancel", "stop", "continue" does not become text — it drives the composer/session.'),
|
|
88
|
+
polishSend: z.boolean().default(false)
|
|
89
|
+
.description('Polish the whole composed draft through the model right before sending, not just single phrases.'),
|
|
86
90
|
}).default({}),
|
|
87
91
|
hotkey: z.string().default('Control')
|
|
88
92
|
.description('Hold this key anywhere in the page to record a voice message; release to send, '
|
|
@@ -120,6 +124,16 @@ export const Config = z.object({
|
|
|
120
124
|
.description('Custom words (names, terms) hinted to providers so they recognize them correctly.'),
|
|
121
125
|
voiceCommands: z.boolean().default(false)
|
|
122
126
|
.description('During dictation, spoken edit commands ("new line", "paragraph") become real line breaks instead of words.'),
|
|
127
|
+
wakeWord: z.string().default('')
|
|
128
|
+
.description('Heads-free dictation: a phrase that, when recognized by the browser leg, starts a recording. Empty disables.'),
|
|
129
|
+
bargeIn: z.boolean().default(false)
|
|
130
|
+
.description('Ongoing playback or a long turn is interrupted by detected speech (browser leg).'),
|
|
131
|
+
polishBaseUrl: z.string().default('')
|
|
132
|
+
.description('Offline polish: OpenAI-compatible /chat/completions endpoint (e.g. local Ollama). Empty uses the harness model.'),
|
|
133
|
+
polishModel: z.string().default('')
|
|
134
|
+
.description('Offline polish: model id on polishBaseUrl.'),
|
|
135
|
+
polishKeyEnv: z.string().default('')
|
|
136
|
+
.description('Offline polish: credential name for the api key. Empty means no Authorization header.'),
|
|
123
137
|
})
|
|
124
138
|
|
|
125
139
|
const MIME_BY_EXT = {
|
|
@@ -236,18 +250,40 @@ export function apply(ctx, baseConfig) {
|
|
|
236
250
|
return runChain(order, providers)
|
|
237
251
|
}
|
|
238
252
|
|
|
239
|
-
// Полировка транскрипта
|
|
240
|
-
// блокирует: возвращаем сырой текст.
|
|
253
|
+
// Полировка транскрипта (#35) с поддержкой локального LLM (#47).
|
|
254
|
+
// Ошибка/таймаут не блокирует: возвращаем сырой текст.
|
|
241
255
|
async function polishText(text, modeCfg, signal) {
|
|
242
|
-
|
|
256
|
+
const enable = modeCfg && (modeCfg.polish === true || modeCfg.polishSend === true)
|
|
257
|
+
if (!enable || !text) return text
|
|
258
|
+
const cfg = live()
|
|
259
|
+
const ask =
|
|
260
|
+
'Fix the punctuation and spelling of this dictated text and split it into '
|
|
261
|
+
+ 'paragraphs where the speaker changes topic. Remove filler words ("um", "uh", '
|
|
262
|
+
+ '"ээ", "ну", "как бы"). Keep the original language, wording and meaning. '
|
|
263
|
+
+ 'Reply with the polished text only:\n\n' + text
|
|
243
264
|
try {
|
|
265
|
+
// Локальный OpenAI-совместимый эндпоинт (#47), когда задан.
|
|
266
|
+
if (cfg.polishBaseUrl) {
|
|
267
|
+
const headers = { 'content-type': 'application/json' }
|
|
268
|
+
if (cfg.polishKeyEnv) {
|
|
269
|
+
const key = await resolveKey(cfg.polishKeyEnv)
|
|
270
|
+
if (key) headers.authorization = 'Bearer ' + key
|
|
271
|
+
}
|
|
272
|
+
const res = await fetch(
|
|
273
|
+
String(cfg.polishBaseUrl).replace(/\/+$/, '') + '/chat/completions',
|
|
274
|
+
{ method: 'POST', headers, signal, body: JSON.stringify({
|
|
275
|
+
model: cfg.polishModel || 'local-model',
|
|
276
|
+
messages: [{ role: 'user', content: ask }],
|
|
277
|
+
}) })
|
|
278
|
+
if (!res.ok) return text
|
|
279
|
+
const data = await res.json().catch(() => null)
|
|
280
|
+
const pick = data && data.choices && data.choices[0] && data.choices[0].message
|
|
281
|
+
&& data.choices[0].message.content
|
|
282
|
+
return (pick && String(pick).trim()) || text
|
|
283
|
+
}
|
|
284
|
+
// Штатная модель харнесса.
|
|
244
285
|
const llm = ctx.llm
|
|
245
286
|
if (!llm || typeof llm.stream !== 'function') return text
|
|
246
|
-
const ask =
|
|
247
|
-
'Fix the punctuation and spelling of this dictated text and split it into '
|
|
248
|
-
+ 'paragraphs where the speaker changes topic. Remove filler words ("um", "uh", '
|
|
249
|
-
+ '"ээ", "ну", "как бы"). Keep the original language, wording and meaning. '
|
|
250
|
-
+ 'Reply with the polished text only:\n\n' + text
|
|
251
287
|
let acc = ''
|
|
252
288
|
for await (const chunk of llm.stream({ messages: [{ role: 'user', content: ask }], signal })) {
|
|
253
289
|
acc += (chunk && (chunk.text || (chunk.delta && chunk.delta.text))) || ''
|
|
@@ -257,6 +293,23 @@ export function apply(ctx, baseConfig) {
|
|
|
257
293
|
} catch { return text }
|
|
258
294
|
}
|
|
259
295
|
|
|
296
|
+
// Чистые голосовые команды сессии (#48). Возвращает название команды или null.
|
|
297
|
+
// Распознаются на сервере, чтобы не попадать в текст композера.
|
|
298
|
+
const SESSION_COMMANDS = [
|
|
299
|
+
{ re: /^(отправь|отправить|пошли|send)\s*[.!?]*$/i, cmd: 'send' },
|
|
300
|
+
{ re: /^(отмени|отмена|cancel|отменить)\s*[.!?]*$/i, cmd: 'cancel' },
|
|
301
|
+
{ re: /^(стоп|stop|хватит)\s*[.!?]*$/i, cmd: 'stop' },
|
|
302
|
+
{ re: /^(продолжи|continue|продолжай)\s*[.!?]*$/i, cmd: 'continue' },
|
|
303
|
+
]
|
|
304
|
+
function sessionCommand(text) {
|
|
305
|
+
const t = String(text || '').trim().toLowerCase()
|
|
306
|
+
if (!t) return null
|
|
307
|
+
for (const { re, cmd } of SESSION_COMMANDS) {
|
|
308
|
+
if (re.test(t)) return cmd
|
|
309
|
+
}
|
|
310
|
+
return null
|
|
311
|
+
}
|
|
312
|
+
|
|
260
313
|
ctx.effect(() => ctx.webServer.register({
|
|
261
314
|
kind: 'exact',
|
|
262
315
|
path: '/dsh-voice/status',
|
|
@@ -283,6 +336,7 @@ export function apply(ctx, baseConfig) {
|
|
|
283
336
|
message: {
|
|
284
337
|
chain: cfg.message.chain, language: cfg.message.language,
|
|
285
338
|
autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
|
|
339
|
+
sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend,
|
|
286
340
|
},
|
|
287
341
|
},
|
|
288
342
|
beep: cfg.beep,
|
|
@@ -290,10 +344,36 @@ export function apply(ctx, baseConfig) {
|
|
|
290
344
|
micDeviceId: cfg.micDeviceId,
|
|
291
345
|
historyLimit: cfg.historyLimit,
|
|
292
346
|
voiceCommands: cfg.voiceCommands,
|
|
347
|
+
wakeWord: String(cfg.wakeWord || ''),
|
|
348
|
+
bargeIn: !!cfg.bargeIn,
|
|
349
|
+
polishBaseUrl: String(cfg.polishBaseUrl || ''),
|
|
293
350
|
})
|
|
294
351
|
},
|
|
295
352
|
}), 'dsh-voice: /status route')
|
|
296
353
|
|
|
354
|
+
ctx.effect(() => ctx.webServer.register({
|
|
355
|
+
kind: 'exact',
|
|
356
|
+
path: '/dsh-voice/polish',
|
|
357
|
+
handler: async (req, res) => {
|
|
358
|
+
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
359
|
+
const cfg = live()
|
|
360
|
+
let raw
|
|
361
|
+
try { raw = await readBody(req, cfg.maxFileBytes + 1024 * 1024) } catch (e) { writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return }
|
|
362
|
+
let payload = {}
|
|
363
|
+
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* пусто */ }
|
|
364
|
+
const text = typeof payload.text === 'string' ? payload.text.trim() : ''
|
|
365
|
+
if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
|
|
366
|
+
const controller = new AbortController()
|
|
367
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
368
|
+
try {
|
|
369
|
+
const out = await polishText(text, { polish: true }, controller.signal)
|
|
370
|
+
writeJson(res, 200, { ok: true, text: out })
|
|
371
|
+
} catch (e) {
|
|
372
|
+
writeJson(res, 502, { ok: false, error: { code: 'polish', message: String(e && e.message || e) } })
|
|
373
|
+
} finally { clearTimeout(timer) }
|
|
374
|
+
},
|
|
375
|
+
}), 'dsh-voice: /polish route')
|
|
376
|
+
|
|
297
377
|
ctx.effect(() => ctx.webServer.register({
|
|
298
378
|
kind: 'exact',
|
|
299
379
|
path: '/dsh-voice/transcribe',
|
|
@@ -333,6 +413,12 @@ export function apply(ctx, baseConfig) {
|
|
|
333
413
|
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
334
414
|
try {
|
|
335
415
|
const out = await transcribe(modeCfg, bytes, mime, controller.signal)
|
|
416
|
+
// Голосовые команды сессии (#48): чистые «отправь/отмени/стоп/продолжи»
|
|
417
|
+
// не становятся текстом, а возвращаются командой для браузера.
|
|
418
|
+
if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
|
|
419
|
+
const cmd = sessionCommand(out.text)
|
|
420
|
+
if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
|
|
421
|
+
}
|
|
336
422
|
const text = await polishText(out.text, modeCfg, controller.signal)
|
|
337
423
|
writeJson(res, 200, { ok: true, text, provider: out.provider, tookMs: out.tookMs })
|
|
338
424
|
} catch (e) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.8",
|
|
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",
|