@goodandready/dsh-voice 0.4.2 → 0.6.0

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 CHANGED
@@ -30,6 +30,7 @@ Restart the Web UI afterwards, then hard-refresh the browser.
30
30
 
31
31
  | Key | Service | Default model | Credential |
32
32
  |---|---|---|---|
33
+ | `browser` | the browser's own speech recognition | — | none, and nothing is uploaded to the host |
33
34
  | `deepgram` | Deepgram | `nova-2` | `DEEPGRAM_API_KEY` |
34
35
  | `groq` | Groq | `whisper-large-v3-turbo` | `GROQ_API_KEY` |
35
36
  | `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` |
@@ -39,6 +40,32 @@ Keys are read through the DSH credentials service (Settings → Credentials, or
39
40
  `$DSH_HOME/.credentials.yaml`), falling back to the process environment. A
40
41
  provider without a key is skipped, not fatal.
41
42
 
43
+ ### Ready-made providers
44
+
45
+ Six providers are filled in already — put the name in a chain and add the key:
46
+
47
+ | Name | Model | Credential |
48
+ |---|---|---|
49
+ | `openai` | `whisper-1` | `OPENAI_API_KEY` |
50
+ | `siliconflow` | `FunAudioLLM/SenseVoiceSmall` | `SILICONFLOW_API_KEY` |
51
+ | `deepinfra` | `openai/whisper-large-v3-turbo` | `DEEPINFRA_API_KEY` |
52
+ | `fireworks` | `whisper-v3-turbo` | `FIREWORKS_API_KEY` |
53
+ | `mistral` | `voxtral-mini-latest` | `MISTRAL_API_KEY` |
54
+ | `openrouter` | `google/gemini-2.5-flash` | `OPENROUTER_API_KEY` |
55
+
56
+ ```yaml
57
+ - id: dsh-voice
58
+ config:
59
+ message:
60
+ chain:
61
+ - provider: openai
62
+ - provider: local-whisper
63
+ ```
64
+
65
+ Every endpoint was probed without a key before being written down: all six answered `401`, the answer of a path that exists and wants credentials. The model ids are starting points — override `model` in a chain row to change one.
66
+
67
+ A preset is the same form as a custom provider with the fields filled in, so a `customProviders` entry under the same name replaces it outright.
68
+
42
69
  ### Your own providers
43
70
 
44
71
  Any OpenAI-compatible API can be added as a provider and used in the chains
@@ -77,6 +104,37 @@ The chat template accepts WAV and MP3 only, while the browser records
77
104
  webm/opus — the plugin converts with ffmpeg, the same way the local whisper
78
105
  provider does, so **ffmpeg is required for `openai-chat-audio`**.
79
106
 
107
+ ## Three ways to speak
108
+
109
+ | Gesture | What happens |
110
+ |---|---|
111
+ | Click the microphone | dictation: speech is cut on pauses and each phrase is appended to the composer |
112
+ | Click the wave | a voice message: recording runs until you stop it, then the text is sent after a cancel window |
113
+ | **Hold the wave** | records only while held; release sends it, moving the pointer off the button discards |
114
+ | **Hold `Ctrl`** | the same without reaching for the mouse; `Escape` discards |
115
+
116
+ The hotkey is `hotkey` in the settings — a modifier name (`Control`, `Alt`, `Shift`) or a `KeyboardEvent` code. Empty turns it off.
117
+
118
+ ## Recognition in the browser
119
+
120
+ Put `browser` first in a chain and speech is recognised by the browser itself: no key, no upload to this host, and the text appears **while you are still speaking** — an interim caption in the recording bar, with each finished phrase going into the composer.
121
+
122
+ ```yaml
123
+ - id: dsh-voice
124
+ config:
125
+ dictation:
126
+ chain:
127
+ - provider: browser
128
+ - provider: local-whisper # если браузер не умеет — обычный путь
129
+ ```
130
+
131
+ Two things to know before choosing it:
132
+
133
+ - **Chrome sends the audio to Google.** Firefox has no such API at all. Everything else in this plugin keeps audio between your browser and your own host, so this provider is the one exception — it is never used unless you put it in a chain yourself.
134
+ - It needs a secure context (HTTPS or localhost), like the microphone itself.
135
+
136
+ Put a normal provider after it: if the browser cannot do it, recording falls back to the chain as usual.
137
+
80
138
  ## Configure (Web GUI)
81
139
 
82
140
  Settings → **Голос** (Voice) has four blocks:
package/lib/client.js CHANGED
@@ -125,6 +125,87 @@ window.__ModuleLoader__.load({
125
125
  actions.setDraft(draft ? draft + ' ' + text : text)
126
126
  }
127
127
 
128
+ // ------------------------------------------------- распознавание в браузере
129
+ //
130
+ // Отдельная нога, не похожая на все остальные: речь распознаёт сам браузер,
131
+ // на хост ничего не уходит, ключи не нужны, а текст появляется по словам
132
+ // прямо во время речи.
133
+ //
134
+ // Плата за это: в Chrome звук уходит на серверы Google. Поэтому провайдер
135
+ // никогда не включается сам — только если его прямо поставили в цепочку.
136
+ function speechRecognitionCtor() {
137
+ if (typeof window === 'undefined') return null
138
+ return window.SpeechRecognition || window.webkitSpeechRecognition || null
139
+ }
140
+
141
+ function browserRecognitionAvailable() {
142
+ return speechRecognitionCtor() !== null
143
+ }
144
+
145
+ /**
146
+ * @param options {{lang: string, continuous: boolean, onInterim, onFinal, onError}}
147
+ * @returns {{stop: Function, abort: Function}}
148
+ */
149
+ function startBrowserRecognition(options) {
150
+ const Ctor = speechRecognitionCtor()
151
+ const recognition = new Ctor()
152
+ recognition.lang = options.lang && options.lang !== 'auto' ? options.lang : 'ru-RU'
153
+ recognition.continuous = options.continuous !== false
154
+ recognition.interimResults = true
155
+ let stopped = false
156
+
157
+ recognition.onresult = (event) => {
158
+ let interim = ''
159
+ for (let i = event.resultIndex; i < event.results.length; i++) {
160
+ const result = event.results[i]
161
+ const text = String(result[0] && result[0].transcript || '').trim()
162
+ if (!text) continue
163
+ if (result.isFinal) options.onFinal(text)
164
+ else interim += (interim ? ' ' : '') + text
165
+ }
166
+ options.onInterim(interim)
167
+ }
168
+ recognition.onerror = (event) => {
169
+ // no-speech и aborted — обычная жизнь, а не поломка.
170
+ const code = event && event.error
171
+ if (code === 'no-speech' || code === 'aborted') return
172
+ options.onError(code || 'ошибка распознавания')
173
+ }
174
+ // Браузер обрывает распознавание сам: на паузах, по таймауту. Пока нас не
175
+ // остановили — поднимаем заново, иначе диктовка молча умрёт на первой паузе.
176
+ recognition.onend = () => {
177
+ if (stopped) return
178
+ try { recognition.start() } catch (alreadyRunning) { /* уже поднято */ }
179
+ }
180
+
181
+ try { recognition.start() } catch (cannotStart) {
182
+ options.onError(String(cannotStart && cannotStart.message || cannotStart))
183
+ }
184
+ return {
185
+ stop() { stopped = true; try { recognition.stop() } catch (already) { /* уже стоит */ } },
186
+ abort() { stopped = true; try { recognition.abort() } catch (already) { /* уже стоит */ } },
187
+ }
188
+ }
189
+
190
+ // Какие провайдеры стоят в цепочке режима — узнаём у хоста один раз.
191
+ // Нужно только чтобы понять, идти в браузер или писать файл.
192
+ let chainsPromise = null
193
+ function modeChain(mode) {
194
+ if (!chainsPromise) {
195
+ chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
196
+ .then((res) => res.json())
197
+ .then((data) => (data && data.modes) || {})
198
+ .catch(() => ({}))
199
+ }
200
+ return chainsPromise.then((modes) => {
201
+ const row = modes[mode] || {}
202
+ return {
203
+ chain: Array.isArray(row.chain) ? row.chain.map((e) => e && e.provider) : [],
204
+ language: row.language || 'ru',
205
+ }
206
+ })
207
+ }
208
+
128
209
  // ------------------------------------------------------------ recording
129
210
  function teardown(rec) {
130
211
  if (!rec) return
@@ -206,18 +287,125 @@ window.__ModuleLoader__.load({
206
287
  })
207
288
  }
208
289
 
290
+ // Распознавание браузером вместо записи файла. Возвращает false, если
291
+ // браузер этого не умеет, — тогда идём обычным путём.
292
+ function startBrowserLeg(mode, language) {
293
+ if (!browserRecognitionAvailable()) return false
294
+ const finals = []
295
+ voice.caption = ''
296
+ voice.browser = startBrowserRecognition({
297
+ lang: language,
298
+ continuous: true,
299
+ onInterim: (text) => { voice.caption = text; voice.notify() },
300
+ onFinal: (text) => {
301
+ finals.push(text)
302
+ voice.caption = ''
303
+ // Диктовка дописывает сразу, голосовое копит до отпускания.
304
+ if (mode === 'dictation') appendDraft(text)
305
+ else voice.notify()
306
+ },
307
+ onError: (reason) => {
308
+ // Браузер отказал уже после старта — честно говорим об этом, а не
309
+ // делаем вид, что слушаем.
310
+ voice.browser = null
311
+ voice.set({ phase: 'error', error: 'Браузер не распознал: ' + reason })
312
+ },
313
+ })
314
+ voice.browserFinals = finals
315
+ voice.notify()
316
+ return true
317
+ }
318
+
209
319
  function startRecording(mode) {
210
320
  if (voice.phase !== 'idle' && voice.phase !== 'error') return
211
- voice.set({ phase: 'recording', mode, error: '', levels: [] })
212
- openMic(mode)
213
- .then((rec) => { voice.rec = rec; voice.notify() })
214
- .catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
321
+ voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '' })
322
+ modeChain(mode).then((info) => {
323
+ // Браузерная нога только если её прямо поставили первой в цепочке.
324
+ if (info.chain[0] === 'browser' && startBrowserLeg(mode, info.language)) return
325
+ openMic(mode)
326
+ .then((rec) => { voice.rec = rec; voice.notify() })
327
+ .catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
328
+ })
215
329
  }
216
330
 
217
331
  function startDictation() { startRecording('dictation') }
218
332
  function startMessage() { startRecording('message') }
219
333
 
334
+ // --------------------------------------------------------- удержание
335
+ //
336
+ // Два жеста одной кнопкой: короткое нажатие включает запись до второго
337
+ // нажатия (как было), удержание пишет ровно пока держишь. Отпустил — ушло.
338
+ //
339
+ // Различаем по времени: если отпустили раньше порога — это клик.
340
+ const HOLD_THRESHOLD_MS = 350
341
+
342
+ const hold = { active: false, mode: null, startedAt: 0, armed: false }
343
+
344
+ function beginHold(mode) {
345
+ if (hold.armed || (voice.phase !== 'idle' && voice.phase !== 'error')) return
346
+ hold.armed = true
347
+ hold.mode = mode
348
+ hold.startedAt = Date.now()
349
+ hold.active = false
350
+ // Запись начинаем сразу: ждать порога — значит потерять первое слово.
351
+ startRecording(mode)
352
+ voice.holding = true
353
+ voice.notify()
354
+ }
355
+
356
+ function endHold(cancelled) {
357
+ if (!hold.armed) return
358
+ const heldMs = Date.now() - hold.startedAt
359
+ hold.armed = false
360
+ hold.active = false
361
+ voice.holding = false
362
+ // Короткое нажатие — это клик: запись уже идёт, оставляем её включённой,
363
+ // остановит второе нажатие.
364
+ if (!cancelled && heldMs < HOLD_THRESHOLD_MS) { voice.notify(); return }
365
+ if (cancelled) cancelCurrent()
366
+ else stopCurrent()
367
+ }
368
+
369
+ // Горячая клавиша: держать её удобнее, чем целиться мышью. Пока клавиша
370
+ // зажата — идёт запись, Esc отменяет.
371
+ function hotkeyMatches(event, name) {
372
+ if (name === 'Control') return event.key === 'Control'
373
+ if (name === 'Alt') return event.key === 'Alt'
374
+ if (name === 'Shift') return event.key === 'Shift'
375
+ return event.code === name || event.key === name
376
+ }
377
+
378
+ function installHotkey(ctx, keyName, mode) {
379
+ if (typeof document === 'undefined' || !keyName) return () => {}
380
+ const down = (event) => {
381
+ if (event.repeat) return
382
+ // В поле ввода горячая клавиша-модификатор не мешает: она сама по себе
383
+ // ничего не печатает. А вот обычную букву перехватывать нельзя.
384
+ if (hotkeyMatches(event, keyName)) beginHold(mode)
385
+ }
386
+ const up = (event) => {
387
+ if (hotkeyMatches(event, keyName)) endHold(false)
388
+ else if (event.key === 'Escape' && hold.armed) endHold(true)
389
+ }
390
+ const blur = () => { if (hold.armed) endHold(true) }
391
+ document.addEventListener('keydown', down, true)
392
+ document.addEventListener('keyup', up, true)
393
+ window.addEventListener('blur', blur)
394
+ return () => {
395
+ document.removeEventListener('keydown', down, true)
396
+ document.removeEventListener('keyup', up, true)
397
+ window.removeEventListener('blur', blur)
398
+ }
399
+ }
400
+
220
401
  function cancelCurrent() {
402
+ if (voice.browser) {
403
+ voice.browser.abort()
404
+ voice.browser = null
405
+ voice.browserFinals = null
406
+ voice.set({ phase: 'idle', error: '', caption: '' })
407
+ return
408
+ }
221
409
  const rec = voice.rec
222
410
  voice.pending = null
223
411
  if (!rec) { voice.set({ phase: 'idle', error: '' }); return }
@@ -230,6 +418,23 @@ window.__ModuleLoader__.load({
230
418
  // Останов по второму нажатию: диктовка досылает хвост, голосовое —
231
419
  // отправляет всю запись и открывает окно отмены.
232
420
  function stopCurrent() {
421
+ if (voice.browser) {
422
+ const mode = voice.mode
423
+ const said = (voice.browserFinals || []).join(' ').trim()
424
+ voice.browser.stop()
425
+ voice.browser = null
426
+ voice.browserFinals = null
427
+ voice.caption = ''
428
+ if (!said) { voice.set({ phase: 'idle' }); return }
429
+ if (mode === 'message') {
430
+ appendDraft(said)
431
+ voice.set({ phase: 'pending', pending: { text: said, leftMs: voice.settings.autoSendMs } })
432
+ } else {
433
+ // Диктовка дописывала по ходу — добавлять нечего.
434
+ voice.set({ phase: 'idle' })
435
+ }
436
+ return
437
+ }
233
438
  const rec = voice.rec
234
439
  if (!rec || rec.closing) return
235
440
  rec.closing = true
@@ -285,7 +490,11 @@ window.__ModuleLoader__.load({
285
490
  }, micIcon()),
286
491
  React.createElement('button', {
287
492
  type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
288
- title: err ? v.error : 'Голосовое сообщение', onClick: startMessage,
493
+ title: err ? v.error : 'Голосовое сообщение нажать или удерживать',
494
+ // Удержание: пишет, пока держишь; увёл курсор далеко вверх — отмена.
495
+ onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
496
+ onPointerUp: () => endHold(false),
497
+ onPointerLeave: () => { if (hold.armed) endHold(true) },
289
498
  }, waveIcon()),
290
499
  )
291
500
  }
@@ -361,11 +570,22 @@ window.__ModuleLoader__.load({
361
570
  if (v.phase === 'idle') return null
362
571
 
363
572
  if (v.phase === 'recording') {
573
+ const inBrowser = !!voice.browser
364
574
  const hint = v.mode === 'dictation' ? 'Диктовка — текст дописывается в строку' : 'Запись голосового'
575
+ // Живая подпись: что слышно прямо сейчас. Пока браузер не выдал
576
+ // окончательный кусок, текст черновой и меняется на глазах.
577
+ const caption = voice.caption || (inBrowser ? '' : null)
365
578
  return React.createElement('div', { className: 'dvo-pill' },
366
579
  React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: 'Отмена', onClick: cancelCurrent }, xIcon()),
367
- React.createElement('canvas', { className: 'dvo-wave', ref: canvasRef, width: 720, height: 40 }),
368
- React.createElement('span', { className: 'dvo-status' }, hint),
580
+ inBrowser
581
+ ? null
582
+ : React.createElement('canvas', { className: 'dvo-wave', ref: canvasRef, width: 720, height: 40 }),
583
+ React.createElement('span', { className: 'dvo-status' },
584
+ caption
585
+ ? caption
586
+ : (voice.holding
587
+ ? 'Держите — отпустите, чтобы отправить'
588
+ : (inBrowser ? 'Слушаю в браузере…' : hint))),
369
589
  React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: 'Стоп', onClick: stopCurrent }, stopIcon()),
370
590
  )
371
591
  }
@@ -392,6 +612,19 @@ window.__ModuleLoader__.load({
392
612
 
393
613
  // --------------------------------------------------------------- slots
394
614
  function registerComposer(ctx) {
615
+ // Горячая клавиша живёт всё время, пока плагин применён.
616
+ ctx.effect(() => {
617
+ let dispose = () => {}
618
+ fetch('/dsh-voice/status', { cache: 'no-store' })
619
+ .then((res) => res.json())
620
+ .then((data) => {
621
+ const key = data && data.hotkey
622
+ if (key) dispose = installHotkey(ctx, key, 'message')
623
+ })
624
+ .catch(() => { /* без подсказки хоста горячей клавиши просто не будет */ })
625
+ return () => dispose()
626
+ }, 'dsh-voice: горячая клавиша удержания')
627
+
395
628
  ctx.slots.inject('conversation.input.right', () => ctx.slots.register(
396
629
  { name: 'conversation.input.right', id: '@goodandready/dsh-voice', order: 6, label: () => 'Голос' },
397
630
  (props) => React.createElement(VoiceButtons, { input: props.input, inputActions: props.inputActions }),
@@ -403,9 +636,20 @@ window.__ModuleLoader__.load({
403
636
  }
404
637
 
405
638
  // ------------------------------------------------------- settings page
406
- const BUILTIN = ['deepgram', 'groq', 'hf', 'local-whisper']
639
+ const BUILTIN = [
640
+ 'browser', 'deepgram', 'groq', 'hf', 'local-whisper',
641
+ // Заготовки: адрес и модель уже прописаны на хосте, нужен только ключ.
642
+ 'openai', 'siliconflow', 'deepinfra', 'fireworks', 'mistral', 'openrouter',
643
+ ]
407
644
  const TEMPLATES = ['openai-transcriptions', 'openai-chat-audio']
408
645
  const MODEL_HINT = {
646
+ browser: 'распознаёт сам браузер, ключ не нужен',
647
+ openai: 'whisper-1 · ключ OPENAI_API_KEY',
648
+ siliconflow: 'FunAudioLLM/SenseVoiceSmall · ключ SILICONFLOW_API_KEY',
649
+ deepinfra: 'openai/whisper-large-v3-turbo · ключ DEEPINFRA_API_KEY',
650
+ fireworks: 'whisper-v3-turbo · ключ FIREWORKS_API_KEY',
651
+ mistral: 'voxtral-mini-latest · ключ MISTRAL_API_KEY',
652
+ openrouter: 'google/gemini-2.5-flash · ключ OPENROUTER_API_KEY',
409
653
  deepgram: 'nova-2', groq: 'whisper-large-v3-turbo',
410
654
  hf: 'openai/whisper-large-v3', 'local-whisper': 'задаётся при запуске сервера',
411
655
  }
package/lib/index.js CHANGED
@@ -18,7 +18,7 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
18
18
  import { readFile, stat } from 'node:fs/promises'
19
19
  import path from 'node:path'
20
20
  import { runChain } from './chain.js'
21
- import { makeProviders, PROVIDER_KEYS, DEFAULT_MODELS, CUSTOM_TEMPLATES } from './providers.js'
21
+ import { makeProviders, PROVIDER_KEYS, PRESET_KEYS, KNOWN_KEYS, DEFAULT_MODELS, CUSTOM_TEMPLATES } from './providers.js'
22
22
  import { toWav16k } from './wav.js'
23
23
 
24
24
  export const name = 'dsh-voice'
@@ -26,8 +26,11 @@ export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings']
26
26
 
27
27
  const ChainEntry = z.object({
28
28
  provider: z.string().default('local-whisper')
29
- .description(`Provider key: one of ${PROVIDER_KEYS.join(', ')}, `
30
- + 'or the name of an entry from customProviders.'),
29
+ .description(`Provider key. Built in: ${PROVIDER_KEYS.join(', ')}. `
30
+ + `Ready-made, just add the key: ${PRESET_KEYS.join(', ')}. `
31
+ + 'Or the name of an entry from customProviders. '
32
+ + '"browser" recognises speech in the page itself — no key, no upload to this host, '
33
+ + 'text appears while you speak; put a normal provider after it as a fallback.'),
31
34
  model: z.string().default('')
32
35
  .description('Model override. Empty means the provider default.'),
33
36
  })
@@ -66,6 +69,10 @@ export const Config = z.object({
66
69
  autoSendMs: z.number().default(4000)
67
70
  .description('Cancel window before the recognized text is sent to the agent.'),
68
71
  }).default({}),
72
+ hotkey: z.string().default('Control')
73
+ .description('Hold this key anywhere in the page to record a voice message; release to send, '
74
+ + 'Escape to discard. Modifier names (Control, Alt, Shift) or a KeyboardEvent code. '
75
+ + 'Empty disables the hotkey.'),
69
76
  customProviders: z.array(CustomProvider).default([])
70
77
  .description('Own recognition providers, usable in both chains next to the built-in ones.'),
71
78
  deepgramKeyEnv: z.string().default('DEEPGRAM_API_KEY'),
@@ -181,7 +188,7 @@ export function apply(ctx, baseConfig) {
181
188
  const models = {}
182
189
  const order = []
183
190
  for (const entry of Array.isArray(modeCfg.chain) ? modeCfg.chain : []) {
184
- if (!PROVIDER_KEYS.includes(entry.provider) && !customKeys.includes(entry.provider)) continue
191
+ if (!KNOWN_KEYS.includes(entry.provider) && !customKeys.includes(entry.provider)) continue
185
192
  order.push(entry.provider)
186
193
  // Для своего провайдера модель по умолчанию живёт в его описании,
187
194
  // подставит makeProviders — здесь пусто означает «бери оттуда».
@@ -203,7 +210,9 @@ export function apply(ctx, baseConfig) {
203
210
  writeJson(res, 200, {
204
211
  ok: true,
205
212
  whisperRunning: await whisperAlive(),
206
- providers: PROVIDER_KEYS.concat(
213
+ // Клавиша нужна браузерной половине: она вешает обработчик удержания.
214
+ hotkey: cfg.hotkey,
215
+ providers: KNOWN_KEYS.concat(
207
216
  (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
208
217
  .map((c) => String(c && c.key || '').trim()).filter(Boolean),
209
218
  ),
package/lib/providers.js CHANGED
@@ -2,13 +2,71 @@
2
2
  // в настройках. Чистые функции: сеть приходит параметром (fetchImpl), ключи —
3
3
  // через resolveKey, поэтому всё проверяется без реальных запросов.
4
4
 
5
- export const PROVIDER_KEYS = ['deepgram', 'groq', 'hf', 'local-whisper']
5
+ // 'browser' стоит в этом списке, но работает не здесь: речь распознаёт сам
6
+ // браузер, до хоста звук не доходит. Ключ нужен, чтобы такую цепочку принимал
7
+ // и валидатор настроек, и перебор ниже — иначе цепочка ['browser', 'groq'] на
8
+ // хосте оборвалась бы на первом же шаге вместо перехода к groq.
9
+ export const PROVIDER_KEYS = ['browser', 'deepgram', 'groq', 'hf', 'local-whisper']
6
10
 
7
11
  // Свой провайдер описывается одним из двух шаблонов, потому что
8
12
  // OpenAI-совместимые API разошлись: у OpenRouter, например, нет
9
13
  // /audio/transcriptions вовсе, и распознавание там идёт через чат.
10
14
  export const CUSTOM_TEMPLATES = ['openai-transcriptions', 'openai-chat-audio']
11
15
 
16
+ // Готовые провайдеры: то же самое, что свой провайдер, только адрес, модель и
17
+ // имя ключа уже проставлены. Нужен лишь ключ.
18
+ //
19
+ // Каждый адрес проверен запросом без ключа: все отвечают 401 «дайте ключ», то
20
+ // есть путь существует. Модель — разумная отправная точка, её можно заменить
21
+ // в строке цепочки, не трогая остальное.
22
+ //
23
+ // Своим провайдером с тем же именем можно перекрыть любую заготовку целиком:
24
+ // это не встроенный движок, а всего лишь заранее заполненная анкета.
25
+ export const PRESET_PROVIDERS = {
26
+ openai: {
27
+ template: 'openai-transcriptions',
28
+ baseURL: 'https://api.openai.com/v1',
29
+ model: 'whisper-1',
30
+ keyEnv: 'OPENAI_API_KEY',
31
+ },
32
+ siliconflow: {
33
+ template: 'openai-transcriptions',
34
+ baseURL: 'https://api.siliconflow.cn/v1',
35
+ model: 'FunAudioLLM/SenseVoiceSmall',
36
+ keyEnv: 'SILICONFLOW_API_KEY',
37
+ },
38
+ deepinfra: {
39
+ template: 'openai-transcriptions',
40
+ baseURL: 'https://api.deepinfra.com/v1/openai',
41
+ model: 'openai/whisper-large-v3-turbo',
42
+ keyEnv: 'DEEPINFRA_API_KEY',
43
+ },
44
+ fireworks: {
45
+ template: 'openai-transcriptions',
46
+ baseURL: 'https://api.fireworks.ai/inference/v1',
47
+ model: 'whisper-v3-turbo',
48
+ keyEnv: 'FIREWORKS_API_KEY',
49
+ },
50
+ mistral: {
51
+ template: 'openai-transcriptions',
52
+ baseURL: 'https://api.mistral.ai/v1',
53
+ model: 'voxtral-mini-latest',
54
+ keyEnv: 'MISTRAL_API_KEY',
55
+ },
56
+ // У OpenRouter нет /audio/transcriptions вовсе — только через чат.
57
+ openrouter: {
58
+ template: 'openai-chat-audio',
59
+ baseURL: 'https://openrouter.ai/api/v1',
60
+ model: 'google/gemini-2.5-flash',
61
+ keyEnv: 'OPENROUTER_API_KEY',
62
+ },
63
+ }
64
+
65
+ export const PRESET_KEYS = Object.keys(PRESET_PROVIDERS)
66
+
67
+ /** Все имена, которые можно ставить в цепочку без объявления своего провайдера. */
68
+ export const KNOWN_KEYS = PROVIDER_KEYS.concat(PRESET_KEYS)
69
+
12
70
  const CHAT_AUDIO_PROMPT =
13
71
  'Transcribe the audio verbatim. Reply with the transcript text only, '
14
72
  + 'without comments, quotes or formatting.'
@@ -210,12 +268,30 @@ export function makeProviders(deps, req) {
210
268
  }
211
269
  }
212
270
 
213
- const out = { deepgram, groq, hf, 'local-whisper': localWhisper }
271
+ // Если звук всё-таки доехал до хоста с 'browser' в цепочке, значит
272
+ // браузерная нога не сработала: отказываем понятно и идём к следующему.
273
+ async function browser() {
274
+ return {
275
+ ok: false,
276
+ provider: 'browser',
277
+ reason: 'browser: распознавание идёт в браузере, на хосте его нет',
278
+ }
279
+ }
280
+
281
+ const out = { browser, deepgram, groq, hf, 'local-whisper': localWhisper }
282
+
283
+ // Заготовки: те же свои провайдеры, только анкета заполнена заранее.
284
+ for (const key of PRESET_KEYS) {
285
+ if (out[key]) continue
286
+ out[key] = customProvider({ ...PRESET_PROVIDERS[key], key })
287
+ }
288
+
214
289
  for (const spec of Array.isArray(cfg.customProviders) ? cfg.customProviders : []) {
215
290
  const key = String(spec && spec.key || '').trim()
216
- // Встроенные не перекрываем: иначе опечатка в имени тихо подменит рабочего
217
- // провайдера в чужой цепочке.
218
- if (!key || out[key]) continue
291
+ // Встроенные движки не перекрываем: опечатка в имени тихо подменила бы
292
+ // рабочего провайдера в чужой цепочке. А заготовку перекрыть можно — она
293
+ // для того и заготовка, чтобы её правили.
294
+ if (!key || PROVIDER_KEYS.includes(key)) continue
219
295
  out[key] = customProvider({ ...spec, key })
220
296
  }
221
297
  return out
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
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",