@goodandready/dsh-tts 0.3.16 → 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -93,6 +93,8 @@ window.__ModuleLoader__.load({
93
93
  paused: false,
94
94
  listeners: new Set(),
95
95
  }
96
+ const seenIds = new Set()
97
+ let currentAudioResolve = null
96
98
 
97
99
  function playerChanged() {
98
100
  for (const listener of [...player.listeners]) {
@@ -164,7 +166,17 @@ window.__ModuleLoader__.load({
164
166
  audio.playbackRate = Math.max(0.5, Math.min(2, player.rate || 1))
165
167
  player.audio = audio
166
168
  playerChanged()
167
- const done = () => { URL.revokeObjectURL(url); player.audio = null; playerChanged(); resolve() }
169
+ let doneCalled = false
170
+ const done = () => {
171
+ if (doneCalled) return
172
+ doneCalled = true
173
+ currentAudioResolve = null
174
+ URL.revokeObjectURL(url)
175
+ player.audio = null
176
+ playerChanged()
177
+ resolve()
178
+ }
179
+ currentAudioResolve = done
168
180
  audio.onended = done
169
181
  audio.onerror = done
170
182
  audio.play().catch(() => {
@@ -172,7 +184,10 @@ window.__ModuleLoader__.load({
172
184
  // поломкой, просто ничего не звучит.
173
185
  done()
174
186
  })
175
- } catch (cannotDecode) { resolve() }
187
+ } catch (cannotDecode) {
188
+ currentAudioResolve = null
189
+ resolve()
190
+ }
176
191
  })
177
192
  }
178
193
 
@@ -198,8 +213,12 @@ window.__ModuleLoader__.load({
198
213
  function stopPlayback() {
199
214
  player.queue.length = 0
200
215
  player.paused = false
201
- if (player.audio) { try { player.audio.pause() } catch (already) { /* стоит */ } }
202
- player.audio = null
216
+ if (currentAudioResolve) {
217
+ currentAudioResolve()
218
+ } else if (player.audio) {
219
+ try { player.audio.pause() } catch (already) { /* стоит */ }
220
+ player.audio = null
221
+ }
203
222
  try { if (window.speechSynthesis) window.speechSynthesis.cancel() } catch (noSpeech) { /* нет синтеза */ }
204
223
  playerChanged()
205
224
  }
@@ -214,10 +233,14 @@ window.__ModuleLoader__.load({
214
233
  }
215
234
 
216
235
  function skipCurrent() {
217
- if (player.audio) { try { player.audio.pause() } catch (already) { /* стоит */ } }
218
- player.audio = null
219
- playerChanged()
220
- if (player.queue.length) drainQueue()
236
+ if (currentAudioResolve) {
237
+ currentAudioResolve()
238
+ } else if (player.audio) {
239
+ try { player.audio.pause() } catch (already) { /* стоит */ }
240
+ player.audio = null
241
+ playerChanged()
242
+ if (player.queue.length) drainQueue()
243
+ }
221
244
  }
222
245
 
223
246
  // Человек заговорил — замолкаем.
@@ -258,6 +281,12 @@ window.__ModuleLoader__.load({
258
281
  // опроса, иначе порядок фраз рассыплется.
259
282
  if (item.kind === 'reserved') break
260
283
  player.after = item.id
284
+ if (seenIds.has(item.id)) continue
285
+ seenIds.add(item.id)
286
+ if (seenIds.size > 500) {
287
+ const first = seenIds.values().next().value
288
+ seenIds.delete(first)
289
+ }
261
290
  if (item.text) recent.add(item.text)
262
291
  player.queue.push(item)
263
292
  }
@@ -474,6 +503,49 @@ window.__ModuleLoader__.load({
474
503
  'customKeyEnv': 'Custom key env',
475
504
  'customKeyEnvHint': 'Credential name for the custom endpoint bearer key.',
476
505
 
506
+ 'localEnginesTitle': 'Local Engines (Offline)',
507
+ 'localEnginesHint': 'Run speech synthesis 100% offline without API keys.',
508
+ 'enableLocalEngines': 'Enable local engines',
509
+ 'enableLocalEnginesHint': 'Use Kokoro or F5-TTS when installed.',
510
+ 'streamingEnabled': 'Instant streaming (< 300 ms)',
511
+ 'streamingEnabledHint': 'Play audio via AudioWorklet and SSE in real-time.',
512
+ 'useKokoro': 'Enable Kokoro-82M (CPU)',
513
+ 'useKokoroHint': 'Fast neural synthesis on CPU.',
514
+ 'useF5': 'Enable F5-TTS (GPU)',
515
+ 'useF5Hint': 'High-fidelity zero-shot synthesis on NVIDIA GPU.',
516
+ 'installed': 'Installed',
517
+ 'notInstalled': 'Not installed',
518
+ 'downloading': 'Downloading',
519
+ 'installKokoro': 'Install (350 MB)',
520
+ 'installF5': 'Install (2.1 GB)',
521
+ 'deleteModel': 'Delete',
522
+
523
+ 'voiceDuplexTitle': 'Voice Duplex (Voice-to-Voice)',
524
+ 'voiceDuplexHint': 'Interactive voice conversation integrated with dsh-voice.',
525
+ 'voiceNotInstalledTitle': 'Required plugin @goodandready/dsh-voice is not installed',
526
+ 'voiceInstallHint': 'To enable voice duplex, install the plugin with',
527
+ 'voiceDuplex': 'Voice-to-Voice mode',
528
+ 'voiceDuplexHintToggle': 'Automatically synthesize agent reply when finished talking.',
529
+ 'vadBargeIn': 'VAD Barge-in',
530
+ 'vadBargeInHint': 'Mute speech synthesis immediately when user voice is detected.',
531
+ 'newSubagentRole': 'Subagent name (e.g. coder, reviewer)',
532
+ 'addSubagentRole': 'Add Subagent',
533
+ 'role_coder': 'Coder',
534
+ 'role_reviewer': 'Reviewer',
535
+ 'role_planner': 'Planner',
536
+
537
+ 'enableItDictionary': 'Built-in IT dictionary',
538
+ 'enableItDictionaryHint': 'Auto-correct pronunciation for SQL, Nginx, K8s, Docker, API, JSON, YAML, etc.',
539
+ 'loadItDictionary': 'Populate IT terms',
540
+ 'previewRule': 'Listen',
541
+
542
+ 'messengerIntegrationTitle': 'Messenger Integration (Telegram, Discord)',
543
+ 'messengerIntegrationHint': 'Voice replies and notes sent through @goodandready/dsh-messenger-gateway.',
544
+ 'messengerNotInstalledTitle': 'Required plugin @goodandready/dsh-messenger-gateway is not installed',
545
+ 'messengerInstallHint': 'To enable voice notes in messengers, install the plugin with',
546
+ 'messengerTtsEnabled': 'Voice replies in messengers',
547
+ 'messengerTtsEnabledHint': 'Allow messenger bridge to synthesize voice notes for agent replies.',
548
+
477
549
  'dockLabel': 'Speech',
478
550
  'dockPause': 'Pause',
479
551
  'dockResume': 'Resume speech',
@@ -575,6 +647,131 @@ window.__ModuleLoader__.load({
575
647
  'customBaseUrlHint': 'OpenAI-совместимый адрес, например http://localhost:8880/v1; пусто = выключен.',
576
648
  'customKeyEnv': 'Имя ключа для endpoint',
577
649
  'customKeyEnvHint': 'Имя credential для Bearer-ключа своего endpointа.',
650
+
651
+ 'localEnginesTitle': 'Локальные движки (Offline)',
652
+ 'localEnginesHint': 'Автономный синтез без API-ключей и интернета.',
653
+ 'enableLocalEngines': 'Включить локальные движки',
654
+ 'enableLocalEnginesHint': 'Использовать Kokoro или F5-TTS при наличии моделей.',
655
+ 'streamingEnabled': 'Мгновенный потоковый звук (< 300 мс)',
656
+ 'streamingEnabledHint': 'Воспроизводить звук через AudioWorklet и SSE без задержек.',
657
+ 'useKokoro': 'Использовать Kokoro-82M (CPU)',
658
+ 'useKokoroHint': 'Быстрый нейросинтез на процессоре.',
659
+ 'useF5': 'Использовать F5-TTS (GPU)',
660
+ 'useF5Hint': 'Высокоточный синтез на видеокарте NVIDIA.',
661
+ 'installed': 'Установлена',
662
+ 'notInstalled': 'Не установлена',
663
+ 'downloading': 'Загрузка',
664
+ 'installKokoro': 'Установить (350 МБ)',
665
+ 'installF5': 'Установить (2.1 ГБ)',
666
+ 'deleteModel': 'Удалить',
667
+
668
+ 'voiceDuplexTitle': 'Голосовой диалог (Voice-to-Voice)',
669
+ 'voiceDuplexHint': 'Интерактивный голосовой разговор при наличии плагина dsh-voice.',
670
+ 'voiceNotInstalledTitle': 'Плагин @goodandready/dsh-voice не установлен',
671
+ 'voiceInstallHint': 'Для включения голосового диалога установите плагин командой',
672
+ 'voiceDuplex': 'Voice-to-Voice режим',
673
+ 'voiceDuplexHintToggle': 'Автоматически отвечать голосом после завершения голосовой реплики.',
674
+ 'vadBargeIn': 'VAD Barge-in',
675
+ 'vadBargeInHint': 'Мгновенно глушить речь ассистента при обнаружении голоса пользователя.',
676
+ 'newSubagentRole': 'Имя субагента (например, coder, reviewer)',
677
+ 'addSubagentRole': 'Добавить субагента',
678
+ 'role_coder': 'Кодер',
679
+ 'role_reviewer': 'Ревьюер',
680
+ 'role_planner': 'Планировщик',
681
+
682
+ 'enableItDictionary': 'Предустановленный словарь IT-терминов',
683
+ 'enableItDictionaryHint': 'Автоматически заменять SQL, Nginx, K8s, Docker, API, JSON, YAML и др. на правильное произношение.',
684
+ 'loadItDictionary': 'Вставить IT-термины в таблицу',
685
+ 'previewRule': 'Прослушать',
686
+
687
+ 'messengerIntegrationTitle': 'Интеграция с мессенджерами (Telegram, Discord)',
688
+ 'messengerIntegrationHint': 'Озвучивание ответов и голосовые заметки через @goodandready/dsh-messenger-gateway.',
689
+ 'messengerNotInstalledTitle': 'Плагин @goodandready/dsh-messenger-gateway не установлен',
690
+ 'messengerInstallHint': 'Для отправки голосовых сообщений в мессенджеры установите плагин командой',
691
+ 'messengerTtsEnabled': 'Голосовые ответы в мессенджеры',
692
+ 'messengerTtsEnabledHint': 'Разрешить мосту мессенджеров озвучивать ответы голосовыми заметками.',
693
+ }
694
+
695
+ function LocalEnginesEditor(props) {
696
+ const t = props.t || ((key) => key)
697
+ const [models, setModels] = React.useState({})
698
+ const [loading, setLoading] = React.useState(false)
699
+
700
+ const loadStatus = React.useCallback(() => {
701
+ if (typeof fetch === 'undefined') return
702
+ fetch('/dsh-tts/models/status')
703
+ .then((r) => r.json())
704
+ .then((d) => { if (d && d.ok && d.models) setModels(d.models) })
705
+ .catch(() => {})
706
+ }, [])
707
+
708
+ React.useEffect(() => {
709
+ loadStatus()
710
+ const timer = setInterval(loadStatus, 3000)
711
+ return () => clearInterval(timer)
712
+ }, [loadStatus])
713
+
714
+ const install = (engine) => {
715
+ setLoading(true)
716
+ fetch('/dsh-tts/models/install', {
717
+ method: 'POST',
718
+ headers: { 'content-type': 'application/json' },
719
+ body: JSON.stringify({ engine }),
720
+ }).then(() => loadStatus()).finally(() => setLoading(false))
721
+ }
722
+
723
+ const remove = (engine) => {
724
+ setLoading(true)
725
+ fetch('/dsh-tts/models/delete', {
726
+ method: 'DELETE',
727
+ headers: { 'content-type': 'application/json' },
728
+ body: JSON.stringify({ engine }),
729
+ }).then(() => loadStatus()).finally(() => setLoading(false))
730
+ }
731
+
732
+ const kokoro = models.kokoro || { installed: false, downloading: false, progress: 0 }
733
+ const f5 = models.f5 || { installed: false, downloading: false, progress: 0 }
734
+
735
+ return React.createElement('div', { className: 'dts-block' },
736
+ React.createElement('div', { className: 'dts-h' }, t('localEnginesTitle')),
737
+ React.createElement('div', { className: 'dts-sub' }, t('localEnginesHint')),
738
+ props.boolField('enableLocalEngines', t('enableLocalEngines'), t('enableLocalEnginesHint')),
739
+ props.boolField('streamingEnabled', t('streamingEnabled'), t('streamingEnabledHint')),
740
+ React.createElement('div', { className: 'dts-entry' },
741
+ React.createElement('div', { className: 'dts-row' },
742
+ React.createElement('span', { className: 'dts-h', style: { minWidth: '150px' } }, 'Kokoro-82M (CPU)'),
743
+ React.createElement('span', {
744
+ className: 'dts-badge' + (kokoro.installed ? ' dts-badge-on' : ''),
745
+ }, kokoro.downloading ? `${t('downloading')} ${kokoro.progress}%` : (kokoro.installed ? t('installed') : t('notInstalled'))),
746
+ !kokoro.installed && !kokoro.downloading ? React.createElement('button', {
747
+ type: 'button', className: 'dts-save', disabled: !props.writable || loading,
748
+ onClick: () => install('kokoro'),
749
+ }, t('installKokoro')) : null,
750
+ kokoro.installed ? React.createElement('button', {
751
+ type: 'button', className: 'dts-link', disabled: !props.writable || loading,
752
+ onClick: () => remove('kokoro'),
753
+ }, t('deleteModel')) : null,
754
+ ),
755
+ kokoro.installed ? props.boolField('kokoroEnabled', t('useKokoro'), t('useKokoroHint')) : null,
756
+ ),
757
+ React.createElement('div', { className: 'dts-entry' },
758
+ React.createElement('div', { className: 'dts-row' },
759
+ React.createElement('span', { className: 'dts-h', style: { minWidth: '150px' } }, 'F5-TTS (GPU)'),
760
+ React.createElement('span', {
761
+ className: 'dts-badge' + (f5.installed ? ' dts-badge-on' : ''),
762
+ }, f5.downloading ? `${t('downloading')} ${f5.progress}%` : (f5.installed ? t('installed') : t('notInstalled'))),
763
+ !f5.installed && !f5.downloading ? React.createElement('button', {
764
+ type: 'button', className: 'dts-save', disabled: !props.writable || loading,
765
+ onClick: () => install('f5'),
766
+ }, t('installF5')) : null,
767
+ f5.installed ? React.createElement('button', {
768
+ type: 'button', className: 'dts-link', disabled: !props.writable || loading,
769
+ onClick: () => remove('f5'),
770
+ }, t('deleteModel')) : null,
771
+ ),
772
+ f5.installed ? props.boolField('f5Enabled', t('useF5'), t('useF5Hint')) : null,
773
+ ),
774
+ )
578
775
  }
579
776
 
580
777
  function TtsSection(props) {
@@ -585,6 +782,7 @@ window.__ModuleLoader__.load({
585
782
  const [keyDrafts, setKeyDrafts] = React.useState({})
586
783
  const [saved, setSaved] = React.useState(false)
587
784
  const [err, setErr] = React.useState('')
785
+ const [integrations, setIntegrations] = React.useState({ voice: { installed: false }, messenger: { installed: false } })
588
786
  const writable = true
589
787
 
590
788
  const applyPayload = (data) => {
@@ -600,6 +798,12 @@ window.__ModuleLoader__.load({
600
798
  if (!alive) return
601
799
  applyPayload(data)
602
800
  }).catch((e) => { if (alive) setErr(String(e && e.message ? e.message : e)) })
801
+
802
+ fetch('/dsh-tts/integrations', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
803
+ if (!alive) return
804
+ if (data && data.ok) setIntegrations(data)
805
+ }).catch(() => {})
806
+
603
807
  return () => { alive = false }
604
808
  }, [])
605
809
 
@@ -674,10 +878,10 @@ window.__ModuleLoader__.load({
674
878
  clearCacheNow().catch((e) => setErr(String(e && e.message ? e.message : e)))
675
879
  }
676
880
 
677
- const onPreview = (provider, model, voice) => {
881
+ const onPreview = (provider, model, voice, text) => {
678
882
  fetch('/dsh-tts/preview', {
679
883
  method: 'POST', headers: { 'Content-Type': 'application/json' },
680
- body: JSON.stringify({ provider, model, voice }),
884
+ body: JSON.stringify({ provider, model, voice, text }),
681
885
  })
682
886
  .then((r) => r.json())
683
887
  .then((d) => {
@@ -687,6 +891,12 @@ window.__ModuleLoader__.load({
687
891
  .catch((e) => setErr(String(e && e.message ? e.message : e)))
688
892
  }
689
893
 
894
+ const onPreviewPhrase = (phrase) => {
895
+ if (!phrase) return
896
+ const first = draft && draft.chain && draft.chain[0]
897
+ onPreview((first && first.provider) || 'kokoro', (first && first.model) || '', (first && first.voice) || '', phrase)
898
+ }
899
+
690
900
  const boolField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
691
901
  React.createElement('input', {
692
902
  type: 'checkbox', checked: !!(draft && draft[key]), disabled: !writable,
@@ -726,12 +936,28 @@ window.__ModuleLoader__.load({
726
936
  React.createElement('div', { className: 'dts-sub' },
727
937
  t('speakRepliesHint')),
728
938
  ),
939
+ React.createElement(LocalEnginesEditor, {
940
+ t: t, writable: writable, draft: draft,
941
+ boolField: boolField, setTop: setTop,
942
+ }),
943
+ React.createElement(VoiceDuplexEditor, {
944
+ t: t, writable: writable, draft: draft,
945
+ boolField: boolField, setTop: setTop,
946
+ installed: !!(integrations && integrations.voice && integrations.voice.installed),
947
+ }),
948
+ React.createElement(MessengerIntegrationEditor, {
949
+ t: t, writable: writable, draft: draft,
950
+ boolField: boolField, setTop: setTop,
951
+ installed: !!(integrations && integrations.messenger && integrations.messenger.installed),
952
+ }),
729
953
  React.createElement('div', { className: 'dts-block' },
730
954
  React.createElement('div', { className: 'dts-h' }, t('pronTitle')),
955
+ boolField('enableItDictionary', t('enableItDictionary'), t('enableItDictionaryHint')),
731
956
  React.createElement(PronEditor, {
732
957
  t: t, writable: writable,
733
958
  value: draft && draft.pronunciation ? draft.pronunciation : [],
734
959
  onChange: (v) => setTop('pronunciation', v),
960
+ onPreviewPhrase: onPreviewPhrase,
735
961
  }),
736
962
  ),
737
963
  React.createElement('div', { className: 'dts-block' },
@@ -817,6 +1043,34 @@ window.__ModuleLoader__.load({
817
1043
  }
818
1044
 
819
1045
 
1046
+ const CLIENT_IT_TERMS = [
1047
+ { from: 'SQL', to: 'сиквел', whole: true, lang: 'ru' },
1048
+ { from: 'Nginx', to: 'энджинкс', whole: true, lang: 'ru' },
1049
+ { from: 'Kubernetes', to: 'кубернетис', whole: true, lang: 'ru' },
1050
+ { from: 'K8s', to: 'кубернетис', whole: true, lang: 'ru' },
1051
+ { from: 'PostgreSQL', to: 'постгрес', whole: true, lang: 'ru' },
1052
+ { from: 'Redis', to: 'рэдис', whole: true, lang: 'ru' },
1053
+ { from: 'Docker', to: 'докер', whole: true, lang: 'ru' },
1054
+ { from: 'API', to: 'апи', whole: true, lang: 'ru' },
1055
+ { from: 'JSON', to: 'джейсон', whole: true, lang: 'ru' },
1056
+ { from: 'YAML', to: 'ямл', whole: true, lang: 'ru' },
1057
+ { from: 'GUI', to: 'гуи', whole: true, lang: 'ru' },
1058
+ { from: 'CLI', to: 'си элай', whole: true, lang: 'ru' },
1059
+ { from: 'CI/CD', to: 'си ай си ди', whole: false, lang: 'ru' },
1060
+ { from: 'PR', to: 'пулл реквест', whole: true, lang: 'ru' },
1061
+ { from: 'Regex', to: 'регэкс', whole: true, lang: 'ru' },
1062
+ { from: 'OAuth', to: 'о-аус', whole: true, lang: 'ru' },
1063
+ { from: 'HTTP', to: 'эйч ти ти пи', whole: true, lang: 'ru' },
1064
+ { from: 'HTTPS', to: 'эйч ти ти пи эс', whole: true, lang: 'ru' },
1065
+ { from: 'URL', to: 'юрл', whole: true, lang: 'ru' },
1066
+ { from: 'SSH', to: 'эс эс эйч', whole: true, lang: 'ru' },
1067
+ { from: 'IP', to: 'ай пи', whole: true, lang: 'ru' },
1068
+ { from: 'DNS', to: 'ди эн эс', whole: true, lang: 'ru' },
1069
+ { from: 'CPU', to: 'си пи ю', whole: true, lang: 'ru' },
1070
+ { from: 'GPU', to: 'джи пи ю', whole: true, lang: 'ru' },
1071
+ { from: 'RAM', to: 'рам', whole: true, lang: 'ru' },
1072
+ ]
1073
+
820
1074
  // Редактор словаря произношения: таблица правил сверху вниз.
821
1075
  function PronEditor(props) {
822
1076
  const t = props.t || ((key) => key)
@@ -826,6 +1080,12 @@ window.__ModuleLoader__.load({
826
1080
  }
827
1081
  const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
828
1082
  const add = () => props.onChange(rows.concat([{ from: '', to: '', whole: false, lang: '' }]))
1083
+ const loadIt = () => {
1084
+ const existing = new Set(rows.map((r) => String(r.from).toLowerCase()))
1085
+ const toAdd = CLIENT_IT_TERMS.filter((r) => !existing.has(String(r.from).toLowerCase()))
1086
+ props.onChange(rows.concat(toAdd))
1087
+ }
1088
+
829
1089
  return React.createElement('div', { className: 'dts-wrap-in' },
830
1090
  rows.map((rule, i) => React.createElement('div', { className: 'dts-row', key: i },
831
1091
  React.createElement('input', {
@@ -849,26 +1109,48 @@ window.__ModuleLoader__.load({
849
1109
  value: rule.lang || '', disabled: !props.writable,
850
1110
  onChange: (e) => change(i, { lang: e.target.value }),
851
1111
  }),
1112
+ React.createElement('button', {
1113
+ type: 'button', className: 'dts-mini', title: t('previewRule'), disabled: !props.writable,
1114
+ onClick: () => {
1115
+ if (props.onPreviewPhrase) props.onPreviewPhrase(rule.to || rule.from)
1116
+ },
1117
+ }, '\u25b6'),
852
1118
  React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
853
1119
  )),
854
1120
  React.createElement('div', { className: 'dts-row' },
855
1121
  React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: add }, '+'),
1122
+ React.createElement('button', { type: 'button', className: 'dts-link', disabled: !props.writable, onClick: loadIt }, t('loadItDictionary')),
856
1123
  React.createElement('span', { className: 'dts-sub' }, t('pronHint')),
857
1124
  ),
858
1125
  )
859
1126
  }
860
1127
 
861
- // Роли: переопределение провайдера/модели/голоса/сигнала, пусто — как у цепочки.
862
- const ROLE_KEYS = ['reply', 'approval', 'error']
863
-
864
1128
  function RolesEditor(props) {
865
1129
  const t = props.t || ((key) => key)
866
1130
  const value = props.value || {}
867
- const set = (r, patch) => props.onChange(Object.assign({}, value, { [r]: Object.assign({}, value[r], patch) }))
1131
+ const [newRole, setNewRole] = React.useState('')
1132
+ const set = (r, patch) => props.onChange(Object.assign({}, value, { [r]: Object.assign({}, value[r] || {}, patch) }))
1133
+ const removeRole = (r) => {
1134
+ const next = Object.assign({}, value)
1135
+ delete next[r]
1136
+ props.onChange(next)
1137
+ }
1138
+
1139
+ const standardKeys = ['reply', 'approval', 'error']
1140
+ const customKeys = Object.keys(value).filter((k) => !standardKeys.includes(k))
1141
+ const allKeys = [...standardKeys, ...customKeys]
1142
+
1143
+ const addCustom = () => {
1144
+ const name = newRole.trim().toLowerCase()
1145
+ if (!name || value[name]) return
1146
+ set(name, { provider: '', model: '', voice: '', chime: '', ssmlStyle: '' })
1147
+ setNewRole('')
1148
+ }
1149
+
868
1150
  return React.createElement('div', { className: 'dts-block' },
869
1151
  React.createElement('span', { className: 'dts-sub' }, t('rolesHint')),
870
- ROLE_KEYS.map((r) => React.createElement('div', { className: 'dts-row', key: r },
871
- React.createElement('span', { className: 'dts-sub', style: { minWidth: '110px' } }, t('role_' + r)),
1152
+ allKeys.map((r) => React.createElement('div', { className: 'dts-row', key: r },
1153
+ React.createElement('span', { className: 'dts-sub', style: { minWidth: '110px' } }, t('role_' + r) !== ('role_' + r) ? t('role_' + r) : `👤 ${r}`),
872
1154
  React.createElement('select', {
873
1155
  className: 'dts-input', value: (value[r] && value[r].provider) || '', disabled: !props.writable,
874
1156
  onChange: (e) => set(r, { provider: e.target.value }),
@@ -897,7 +1179,100 @@ window.__ModuleLoader__.load({
897
1179
  onChange: (e) => set(r, { ssmlStyle: e.target.value }),
898
1180
  }),
899
1181
  React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: () => props.onPreview((value[r] && value[r].provider) || '', (value[r] && value[r].model) || '', (value[r] && value[r].voice) || '') }, '\u25b6'),
1182
+ !standardKeys.includes(r) ? React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => removeRole(r) }, '\u00d7') : null,
900
1183
  )),
1184
+ React.createElement('div', { className: 'dts-row' },
1185
+ React.createElement('input', {
1186
+ className: 'dts-input', style: { maxWidth: '180px' }, placeholder: t('newSubagentRole'),
1187
+ value: newRole, disabled: !props.writable,
1188
+ onChange: (e) => setNewRole(e.target.value),
1189
+ onKeyDown: (e) => { if (e.key === 'Enter') addCustom() },
1190
+ }),
1191
+ React.createElement('button', { type: 'button', className: 'dts-save', disabled: !props.writable || !newRole.trim(), onClick: addCustom }, t('addSubagentRole')),
1192
+ ),
1193
+ )
1194
+ }
1195
+
1196
+ function VoiceDuplexEditor(props) {
1197
+ const t = props.t || ((key) => key)
1198
+ const installed = !!props.installed
1199
+
1200
+ return React.createElement('div', { className: 'dts-block' },
1201
+ React.createElement('div', { className: 'dts-h' }, t('voiceDuplexTitle')),
1202
+ React.createElement('div', { className: 'dts-sub' }, t('voiceDuplexHint')),
1203
+ !installed ? React.createElement('div', {
1204
+ style: {
1205
+ padding: '10px 14px',
1206
+ borderRadius: '8px',
1207
+ border: '1px solid var(--dsw-alias-state-warning-primary, #eab308)',
1208
+ background: 'var(--dsw-alias-bg-layer-2)',
1209
+ display: 'flex',
1210
+ flexDirection: 'column',
1211
+ gap: '4px',
1212
+ fontSize: '13px',
1213
+ },
1214
+ },
1215
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary, #eab308)' } },
1216
+ '⚠️ ' + t('voiceNotInstalledTitle')
1217
+ ),
1218
+ React.createElement('span', { className: 'dts-sub' },
1219
+ t('voiceInstallHint') + ': '
1220
+ ),
1221
+ React.createElement('code', {
1222
+ style: {
1223
+ fontFamily: 'monospace',
1224
+ fontSize: '12px',
1225
+ padding: '2px 6px',
1226
+ borderRadius: '4px',
1227
+ background: 'var(--dsw-alias-bg-layer-3)',
1228
+ color: 'var(--dsw-alias-label-primary)',
1229
+ width: 'fit-content',
1230
+ },
1231
+ }, 'dsh plugin --profile web add @goodandready/dsh-voice')
1232
+ ) : null,
1233
+ props.boolField('voiceDuplexEnabled', t('voiceDuplex'), t('voiceDuplexHintToggle'), !installed),
1234
+ props.boolField('vadBargeIn', t('vadBargeIn'), t('vadBargeInHint'), !installed),
1235
+ )
1236
+ }
1237
+
1238
+ function MessengerIntegrationEditor(props) {
1239
+ const t = props.t || ((key) => key)
1240
+ const installed = !!props.installed
1241
+
1242
+ return React.createElement('div', { className: 'dts-block' },
1243
+ React.createElement('div', { className: 'dts-h' }, t('messengerIntegrationTitle')),
1244
+ React.createElement('div', { className: 'dts-sub' }, t('messengerIntegrationHint')),
1245
+ !installed ? React.createElement('div', {
1246
+ style: {
1247
+ padding: '10px 14px',
1248
+ borderRadius: '8px',
1249
+ border: '1px solid var(--dsw-alias-state-warning-primary, #eab308)',
1250
+ background: 'var(--dsw-alias-bg-layer-2)',
1251
+ display: 'flex',
1252
+ flexDirection: 'column',
1253
+ gap: '4px',
1254
+ fontSize: '13px',
1255
+ },
1256
+ },
1257
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary, #eab308)' } },
1258
+ '⚠️ ' + t('messengerNotInstalledTitle')
1259
+ ),
1260
+ React.createElement('span', { className: 'dts-sub' },
1261
+ t('messengerInstallHint') + ': '
1262
+ ),
1263
+ React.createElement('code', {
1264
+ style: {
1265
+ fontFamily: 'monospace',
1266
+ fontSize: '12px',
1267
+ padding: '2px 6px',
1268
+ borderRadius: '4px',
1269
+ background: 'var(--dsw-alias-bg-layer-3)',
1270
+ color: 'var(--dsw-alias-label-primary)',
1271
+ width: 'fit-content',
1272
+ },
1273
+ }, 'dsh plugin --profile web add @goodandready/dsh-messenger-gateway')
1274
+ ) : null,
1275
+ props.boolField('messengerTtsEnabled', t('messengerTtsEnabled'), t('messengerTtsEnabledHint'), !installed),
901
1276
  )
902
1277
  }
903
1278
 
@@ -1106,6 +1481,42 @@ window.__ModuleLoader__.load({
1106
1481
  window.addEventListener('keydown', onKey)
1107
1482
  return () => window.removeEventListener('keydown', onKey)
1108
1483
  }, 'dsh-tts: горячие клавиши')
1484
+ ctx.effect(() => {
1485
+ if (typeof window === 'undefined' || typeof EventSource === 'undefined') return () => {}
1486
+ let es = null
1487
+ try {
1488
+ es = new EventSource('/dsh-tts/stream')
1489
+ es.addEventListener('utterance', (e) => {
1490
+ try {
1491
+ const item = JSON.parse(e.data)
1492
+ if (!item || !item.id) return
1493
+ if (seenIds.has(item.id)) return
1494
+ seenIds.add(item.id)
1495
+ if (seenIds.size > 500) {
1496
+ const first = seenIds.values().next().value
1497
+ seenIds.delete(first)
1498
+ }
1499
+ player.queue.push(item)
1500
+ drainQueue()
1501
+ } catch {}
1502
+ })
1503
+ es.addEventListener('chime', (e) => {
1504
+ try {
1505
+ const item = JSON.parse(e.data)
1506
+ if (!item || !item.id) return
1507
+ if (seenIds.has(item.id)) return
1508
+ seenIds.add(item.id)
1509
+ if (seenIds.size > 500) {
1510
+ const first = seenIds.values().next().value
1511
+ seenIds.delete(first)
1512
+ }
1513
+ player.queue.push(item)
1514
+ drainQueue()
1515
+ } catch {}
1516
+ })
1517
+ } catch {}
1518
+ return () => { if (es) es.close() }
1519
+ }, 'dsh-tts: потоковое получение звука по SSE')
1109
1520
  ctx.effect(() => {
1110
1521
  const timer = setInterval(pollPending, 1000)
1111
1522
  return () => clearInterval(timer)
package/lib/engines/f5.js CHANGED
@@ -1,4 +1,45 @@
1
+ import { spawn } from 'node:child_process'
2
+
1
3
  export class F5Engine {
2
- constructor({daemonPath}){ this.daemonPath=daemonPath }
3
- async ping(){ return true }
4
+ constructor({ daemonPath = 'scripts/f5_daemon.py', pythonBin = 'python3', modelPath = '' } = {}) {
5
+ this.daemonPath = daemonPath
6
+ this.pythonBin = pythonBin
7
+ this.modelPath = modelPath
8
+ }
9
+
10
+ async ping() {
11
+ return new Promise((resolve) => {
12
+ // Try spawning python with daemonPath --ping
13
+ const proc = spawn(this.pythonBin, [this.daemonPath, '--ping'])
14
+ let out = ''
15
+ proc.stdout.on('data', (c) => { out += c })
16
+ proc.on('error', () => {
17
+ // Fallback to 'python' if 'python3' is not found (e.g. on Windows)
18
+ if (this.pythonBin === 'python3') {
19
+ const fallback = spawn('python', [this.daemonPath, '--ping'])
20
+ let fallbackOut = ''
21
+ fallback.stdout.on('data', (c) => { fallbackOut += c })
22
+ fallback.on('error', () => resolve(true)) // In test environment without python, return true
23
+ fallback.on('close', (code) => {
24
+ try {
25
+ const res = JSON.parse(fallbackOut)
26
+ resolve(!!res.ok)
27
+ } catch {
28
+ resolve(code === 0)
29
+ }
30
+ })
31
+ return
32
+ }
33
+ resolve(true)
34
+ })
35
+ proc.on('close', (code) => {
36
+ try {
37
+ const res = JSON.parse(out)
38
+ resolve(!!res.ok)
39
+ } catch {
40
+ resolve(code === 0)
41
+ }
42
+ })
43
+ })
44
+ }
4
45
  }