@goodandready/dsh-voice 0.6.0 → 0.7.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.
Files changed (2) hide show
  1. package/lib/client.js +103 -9
  2. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -125,6 +125,39 @@ window.__ModuleLoader__.load({
125
125
  actions.setDraft(draft ? draft + ' ' + text : text)
126
126
  }
127
127
 
128
+ // Как клавиша называется на человеческом языке.
129
+ const KEY_LABELS = {
130
+ Control: 'Ctrl', Alt: 'Alt', Shift: 'Shift', Meta: 'Win',
131
+ Space: 'Пробел', Escape: 'Esc',
132
+ }
133
+
134
+ function keyLabel(name) {
135
+ if (!name) return 'не задана'
136
+ if (KEY_LABELS[name]) return KEY_LABELS[name]
137
+ // Коды вида KeyR и Digit5 показываем без служебной приставки.
138
+ return String(name).replace(/^Key/, '').replace(/^Digit/, '')
139
+ }
140
+
141
+ // Что записать в настройку по нажатию. Чистый модификатор запоминаем по
142
+ // имени: код у левого и правого разный, а человек нажимает «какой-нибудь».
143
+ function keyFromEvent(event) {
144
+ if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) return event.key
145
+ if (event.code) return event.code
146
+ return event.key || ''
147
+ }
148
+
149
+ // Объявление о том, что человек заговорил.
150
+ //
151
+ // Нужно, чтобы озвучка немедленно замолчала: слушать ответ и говорить
152
+ // одновременно невозможно, а перекрикивать собственный плагин — глупо.
153
+ // Связи между плагинами нет: голос кричит в окно, кто хочет — слышит.
154
+ // Поэтому оба плагина работают и поодиночке.
155
+ function announceVoice(phase) {
156
+ try {
157
+ window.dispatchEvent(new CustomEvent('dsh-voice:speaking', { detail: { phase } }))
158
+ } catch (noEvents) { /* окна нет — значит и слушать некому */ }
159
+ }
160
+
128
161
  // ------------------------------------------------- распознавание в браузере
129
162
  //
130
163
  // Отдельная нога, не похожая на все остальные: речь распознаёт сам браузер,
@@ -318,6 +351,9 @@ window.__ModuleLoader__.load({
318
351
 
319
352
  function startRecording(mode) {
320
353
  if (voice.phase !== 'idle' && voice.phase !== 'error') return
354
+ // Кричим до открытия микрофона, а не после: чем раньше замолчит
355
+ // озвучка, тем меньше её попадёт в запись.
356
+ announceVoice('start')
321
357
  voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '' })
322
358
  modeChain(mode).then((info) => {
323
359
  // Браузерная нога — только если её прямо поставили первой в цепочке.
@@ -399,6 +435,7 @@ window.__ModuleLoader__.load({
399
435
  }
400
436
 
401
437
  function cancelCurrent() {
438
+ announceVoice('end')
402
439
  if (voice.browser) {
403
440
  voice.browser.abort()
404
441
  voice.browser = null
@@ -418,6 +455,7 @@ window.__ModuleLoader__.load({
418
455
  // Останов по второму нажатию: диктовка досылает хвост, голосовое —
419
456
  // отправляет всю запись и открывает окно отмены.
420
457
  function stopCurrent() {
458
+ announceVoice('end')
421
459
  if (voice.browser) {
422
460
  const mode = voice.mode
423
461
  const said = (voice.browserFinals || []).join(' ').trim()
@@ -612,17 +650,32 @@ window.__ModuleLoader__.load({
612
650
 
613
651
  // --------------------------------------------------------------- slots
614
652
  function registerComposer(ctx) {
615
- // Горячая клавиша живёт всё время, пока плагин применён.
653
+ // Горячая клавиша живёт всё время, пока плагин применён, и меняется без
654
+ // перезапуска: карточка настроек кричит в окно, композер перечитывает.
616
655
  ctx.effect(() => {
617
656
  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()
657
+ let alive = true
658
+
659
+ const reload = () => {
660
+ fetch('/dsh-voice/status', { cache: 'no-store' })
661
+ .then((res) => res.json())
662
+ .then((data) => {
663
+ if (!alive) return
664
+ dispose()
665
+ dispose = () => {}
666
+ const key = data && data.hotkey
667
+ if (key) dispose = installHotkey(ctx, key, 'message')
668
+ })
669
+ .catch(() => { /* без подсказки хоста клавиши просто не будет */ })
670
+ }
671
+
672
+ reload()
673
+ window.addEventListener('dsh-voice:settings-saved', reload)
674
+ return () => {
675
+ alive = false
676
+ window.removeEventListener('dsh-voice:settings-saved', reload)
677
+ dispose()
678
+ }
626
679
  }, 'dsh-voice: горячая клавиша удержания')
627
680
 
628
681
  ctx.slots.inject('conversation.input.right', () => ctx.slots.register(
@@ -784,6 +837,27 @@ window.__ModuleLoader__.load({
784
837
  const [saved, setSaved] = React.useState(false)
785
838
  const [err, setErr] = React.useState('')
786
839
 
840
+ // Выбор клавиши: не поле для ввода имени, а «нажмите ту, которую хотите».
841
+ // Имена вроде KeyR человек знать не обязан.
842
+ //
843
+ // Хуки объявлены здесь, выше всех возвратов: если объявить их ниже, при
844
+ // неготовых настройках карточка вернётся раньше, хуков окажется меньше,
845
+ // и React снимет весь раздел с ошибкой.
846
+ const [catching, setCatching] = React.useState(false)
847
+ React.useEffect(() => {
848
+ if (!catching) return undefined
849
+ const onKey = (event) => {
850
+ event.preventDefault()
851
+ event.stopPropagation()
852
+ if (event.key === 'Escape') { setCatching(false); return }
853
+ const chosen = keyFromEvent(event)
854
+ setDraft((d) => Object.assign({}, d || {}, { hotkey: chosen }))
855
+ setCatching(false)
856
+ }
857
+ document.addEventListener('keydown', onKey, true)
858
+ return () => document.removeEventListener('keydown', onKey, true)
859
+ }, [catching])
860
+
787
861
  React.useEffect(() => {
788
862
  let alive = true
789
863
  const render = () => { if (alive) setSnap(scope.getSnapshot()) }
@@ -860,6 +934,9 @@ window.__ModuleLoader__.load({
860
934
  vadSilenceMs: Number(draft.dictation && draft.dictation.vadSilenceMs) || 700,
861
935
  autoSendMs: Number(draft.message && draft.message.autoSendMs) || 4000,
862
936
  }
937
+ // Композер держит обработчик клавиши: пусть перечитает настройку,
938
+ // иначе новая клавиша заработает только после перезагрузки страницы.
939
+ try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* некому */ }
863
940
  setSaved(true); setTimeout(() => setSaved(false), 2000)
864
941
  } catch (e) { setErr(String(e && e.message ? e.message : e)) }
865
942
  }
@@ -877,6 +954,22 @@ window.__ModuleLoader__.load({
877
954
  .filter((k) => k && BUILTIN.indexOf(k) < 0),
878
955
  )
879
956
 
957
+ const hotkeyField = () => React.createElement('label', { className: 'dvs-field' },
958
+ 'Клавиша для голосового сообщения',
959
+ React.createElement('div', { className: 'dvs-row' },
960
+ React.createElement('button', {
961
+ type: 'button', className: 'dvs-save', disabled: !writable,
962
+ onClick: () => setCatching(true),
963
+ }, catching ? 'Нажмите клавишу…' : keyLabel(draft && draft.hotkey)),
964
+ React.createElement('button', {
965
+ type: 'button', className: 'dvs-mini', title: 'Убрать клавишу',
966
+ disabled: !writable, onClick: () => setDraft((d) => Object.assign({}, d || {}, { hotkey: '' })),
967
+ }, '×'),
968
+ ),
969
+ React.createElement('span', { className: 'dvs-sub' },
970
+ 'Держите её — идёт запись, отпустите — уйдёт агенту, Esc — отмена. '
971
+ + 'Можно любую: буква, F-клавиша или модификатор.'))
972
+
880
973
  const langField = (mode) => React.createElement('label', { className: 'dvs-field' }, 'Язык',
881
974
  React.createElement('select', {
882
975
  value: modeVal(mode, 'language', 'ru'), disabled: !writable,
@@ -931,6 +1024,7 @@ window.__ModuleLoader__.load({
931
1024
  ),
932
1025
  React.createElement('div', { className: 'dvs-block' },
933
1026
  React.createElement('div', { className: 'dvs-h' }, 'Общее'),
1027
+ hotkeyField(),
934
1028
  textField('whisperUrl', 'Локальный whisper: endpoint', 'POST /inference сервера whisper.cpp'),
935
1029
  textField('whisperBin', 'Локальный whisper: бинарь', 'используется при автозапуске'),
936
1030
  textField('whisperModel', 'Локальный whisper: модель', 'используется при автозапуске'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",