dsh-live-voice 0.2.2 → 0.3.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 (138) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/DEVELOPMENT.md +13 -0
  3. package/LICENSE +201 -674
  4. package/PLAN.md +28 -0
  5. package/README.md +65 -110
  6. package/docs/ARCHITECTURE.md +40 -0
  7. package/docs/CHOOSING-AN-ENGINE.md +120 -0
  8. package/docs/CONFIGURATION.md +149 -0
  9. package/docs/REVIEW.md +35 -0
  10. package/docs/VOICE-LIFECYCLE.md +59 -0
  11. package/lib/client.js +3652 -1576
  12. package/lib/server.js +641 -453
  13. package/package.json +17 -6
  14. package/scripts/build.ts +16 -4
  15. package/scripts/preview-ui.ts +1 -1
  16. package/src/{client/index.ts → app/client/apply.tsx} +152 -59
  17. package/src/app/client/i18n/DshLanguageBoundary.tsx +42 -0
  18. package/src/app/client/i18n/catalogs/base.ts +231 -0
  19. package/src/app/client/i18n/catalogs/en.ts +258 -0
  20. package/src/app/client/i18n/catalogs/es.ts +279 -0
  21. package/src/app/client/i18n/catalogs/fr.ts +281 -0
  22. package/src/app/client/i18n/catalogs/hi.ts +263 -0
  23. package/src/app/client/i18n/catalogs/index.ts +18 -0
  24. package/src/app/client/i18n/catalogs/pt-BR.ts +270 -0
  25. package/src/app/client/i18n/catalogs/zh.ts +248 -0
  26. package/src/app/client/i18n/index.ts +4 -0
  27. package/src/app/client/i18n/registerDshLocales.ts +43 -0
  28. package/src/app/client/i18n/runtime.tsx +58 -0
  29. package/src/app/client/index.ts +3 -0
  30. package/src/app/client/registerSlots.tsx +30 -0
  31. package/src/app/client/slotDefinitions.ts +32 -0
  32. package/src/app/server/apply.ts +479 -0
  33. package/src/app/server/index.ts +2 -0
  34. package/src/app/server/registerRoutes.ts +11 -0
  35. package/src/modules/conversation/components/ConversationControls.tsx +40 -0
  36. package/src/modules/conversation/components/ConversationStatusBar.tsx +155 -0
  37. package/src/modules/conversation/components/DeliveryModeButton.tsx +43 -0
  38. package/src/modules/conversation/components/MicrophoneButton.tsx +6 -0
  39. package/src/modules/conversation/components/PlaybackControls.tsx +41 -0
  40. package/src/modules/conversation/components/SpeakButton.tsx +30 -0
  41. package/src/modules/conversation/components/Waveform.tsx +68 -0
  42. package/src/modules/conversation/components/conversationStatus.ts +17 -0
  43. package/src/modules/conversation/components/createConversationComponents.tsx +505 -0
  44. package/src/modules/conversation/components/index.ts +8 -0
  45. package/src/modules/conversation/hooks/index.ts +2 -0
  46. package/src/modules/conversation/hooks/useConversationActions.ts +27 -0
  47. package/src/modules/conversation/hooks/useConversationController.ts +15 -0
  48. package/src/modules/conversation/index.ts +3 -0
  49. package/src/{client → modules/conversation/models}/chat.ts +2 -1
  50. package/src/modules/conversation/models/index.ts +1 -0
  51. package/src/{core → modules/core}/coordinator.ts +133 -7
  52. package/src/modules/core/index.ts +6 -0
  53. package/src/{engines/qwen-http-host.ts → modules/core/qwen/QwenHttpHost.ts} +2 -2
  54. package/src/modules/core/qwen/QwenSettings.tsx +148 -0
  55. package/src/{core → modules/core}/settings.ts +23 -0
  56. package/src/modules/recognition/components/RecognitionCapabilityStatus.tsx +19 -0
  57. package/src/{engines/recognition/qwen-http.ts → modules/recognition/engines/qwen/QwenRecognitionEngine.ts} +1 -1
  58. package/src/{engines/recognition/whisper-http.ts → modules/recognition/engines/whisper/WhisperRecognitionEngine.ts} +1 -1
  59. package/src/modules/recognition/engines/whisper/WhisperSettings.tsx +150 -0
  60. package/src/modules/recognition/index.ts +4 -0
  61. package/src/modules/recognition/qwen/QwenRecognitionSettings.tsx +4 -0
  62. package/src/modules/settings/components/LiveVoiceSettings.tsx +72 -0
  63. package/src/modules/settings/components/SettingsHeader.tsx +49 -0
  64. package/src/modules/settings/components/VersionBadges.tsx +39 -0
  65. package/src/modules/settings/components/createLiveVoiceSettings.tsx +8 -0
  66. package/src/modules/settings/components/index.ts +4 -0
  67. package/src/modules/settings/hooks/index.ts +3 -0
  68. package/src/modules/settings/hooks/useAudioDevices.ts +38 -0
  69. package/src/modules/settings/hooks/useLiveVoiceSettings.ts +4 -0
  70. package/src/modules/settings/hooks/useReleaseStatus.ts +17 -0
  71. package/src/modules/settings/index.ts +4 -0
  72. package/src/modules/settings/models/index.ts +1 -0
  73. package/src/modules/settings/models/settingsStorage.ts +14 -0
  74. package/src/modules/settings/sections/conversation/ConversationDelaySettings.tsx +24 -0
  75. package/src/modules/settings/sections/conversation/ConversationSettingsSection.tsx +23 -0
  76. package/src/modules/settings/sections/conversation/DeliverySettings.tsx +38 -0
  77. package/src/modules/settings/sections/conversation/HoldToTalkSettings.tsx +20 -0
  78. package/src/modules/settings/sections/conversation/VoiceModeSettings.tsx +31 -0
  79. package/src/modules/settings/sections/conversation/index.ts +5 -0
  80. package/src/modules/settings/sections/index.ts +3 -0
  81. package/src/modules/settings/sections/recognition/RecognitionEngineSettings.tsx +109 -0
  82. package/src/modules/settings/sections/recognition/RecognitionFilterSettings.tsx +33 -0
  83. package/src/modules/settings/sections/recognition/RecognitionSettingsSection.tsx +20 -0
  84. package/src/modules/settings/sections/recognition/RecognitionStatus.tsx +19 -0
  85. package/src/modules/settings/sections/recognition/SilenceDetectionSettings.tsx +58 -0
  86. package/src/modules/settings/sections/recognition/VoiceCommandSettings.tsx +41 -0
  87. package/src/modules/settings/sections/recognition/index.ts +5 -0
  88. package/src/modules/settings/sections/speak/OutputFilterSettings.tsx +39 -0
  89. package/src/modules/settings/sections/speak/PlaybackPolicySettings.tsx +26 -0
  90. package/src/modules/settings/sections/speak/SpeakSettingsSection.tsx +16 -0
  91. package/src/modules/settings/sections/speak/SpeechAdvancedSettings.tsx +81 -0
  92. package/src/modules/settings/sections/speak/SpeechEngineSettings.tsx +80 -0
  93. package/src/modules/settings/sections/speak/index.ts +4 -0
  94. package/src/modules/settings/services/releases.ts +117 -0
  95. package/src/modules/speak/engines/audio/HostAudioEngine.ts +167 -0
  96. package/src/modules/speak/engines/audio/M4aAacTranscoder.ts +58 -0
  97. package/src/modules/speak/engines/qwen/QwenSpeakingEngine.ts +14 -0
  98. package/src/{engines/speaking/say.ts → modules/speak/engines/say/SaySpeakingEngine.ts} +26 -4
  99. package/src/modules/speak/index.ts +5 -0
  100. package/src/modules/speak/qwen/QwenSpeakingSettings.tsx +4 -0
  101. package/src/modules/speak/services/speechQueue.ts +2 -0
  102. package/src/server.ts +9 -365
  103. package/src/shared/design-system/buttons/IconButton.tsx +33 -0
  104. package/src/shared/design-system/buttons/PillButton.tsx +11 -0
  105. package/src/shared/design-system/buttons/ToggleButton.tsx +6 -0
  106. package/src/shared/design-system/buttons/index.ts +3 -0
  107. package/src/shared/design-system/feedback/ErrorMessage.tsx +27 -0
  108. package/src/shared/design-system/feedback/StatusBadge.tsx +9 -0
  109. package/src/shared/design-system/feedback/StatusMessage.tsx +4 -0
  110. package/src/shared/design-system/feedback/index.ts +3 -0
  111. package/src/shared/design-system/forms/CheckboxField.tsx +20 -0
  112. package/src/shared/design-system/forms/NumberField.tsx +12 -0
  113. package/src/shared/design-system/forms/SelectField.tsx +20 -0
  114. package/src/shared/design-system/forms/TextAreaField.tsx +12 -0
  115. package/src/shared/design-system/forms/TextField.tsx +12 -0
  116. package/src/shared/design-system/forms/index.ts +5 -0
  117. package/src/shared/design-system/icons/Icon.tsx +20 -0
  118. package/src/shared/design-system/icons/icons.ts +16 -0
  119. package/src/shared/design-system/icons/index.ts +2 -0
  120. package/src/shared/design-system/index.ts +5 -0
  121. package/src/shared/design-system/layout/SettingsCard.tsx +9 -0
  122. package/src/shared/design-system/layout/SettingsSection.tsx +9 -0
  123. package/src/shared/design-system/layout/SettingsSubcard.tsx +14 -0
  124. package/src/shared/design-system/layout/SettingsTabs.tsx +54 -0
  125. package/src/shared/design-system/layout/index.ts +4 -0
  126. package/src/{client/styles.ts → styles/index.ts} +3 -1
  127. package/src/client/components.ts +0 -1080
  128. package/src/client/qwen-settings.ts +0 -144
  129. package/src/client/whisper-settings.ts +0 -147
  130. package/src/engines/speaking/qwen-http.ts +0 -124
  131. /package/src/{core → modules/core}/filters.ts +0 -0
  132. /package/src/{core → modules/core}/microphone.ts +0 -0
  133. /package/src/{core → modules/core}/ownership.ts +0 -0
  134. /package/src/{core → modules/core}/transcript.ts +0 -0
  135. /package/src/{engines/recognition/browser.ts → modules/recognition/engines/browser/BrowserRecognitionEngine.ts} +0 -0
  136. /package/src/{engines/recognition/whisper-http-host.ts → modules/recognition/engines/whisper/whisperRecognitionHost.ts} +0 -0
  137. /package/src/{engines/speaking/browser.ts → modules/speak/engines/browser/BrowserSpeakingEngine.ts} +0 -0
  138. /package/src/{engines/speaking/say-client.ts → modules/speak/engines/say/sayClient.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "dsh-live-voice",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
+ "dshTestedVersion": "0.1.6-alpha.2",
4
5
  "type": "module",
5
6
  "scripts": {
6
7
  "typecheck": "tsc --noEmit",
@@ -8,10 +9,12 @@
8
9
  "build": "npm run typecheck && node --experimental-strip-types scripts/build.ts",
9
10
  "check:dist": "node scripts/check-dist.mjs",
10
11
  "dev": "node --experimental-strip-types scripts/build.ts --watch",
11
- "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"scripts/**/*.ts\"",
12
- "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\" \"scripts/**/*.ts\"",
12
+ "format": "prettier --write \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\" \"scripts/**/*.ts\"",
13
+ "format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\" \"scripts/**/*.ts\"",
13
14
  "setup:hooks": "git config core.hooksPath .githooks",
14
- "prepack": "npm run build"
15
+ "prepack": "npm run build",
16
+ "preview": "previewjs .",
17
+ "preview:check": "previewjs . --help"
15
18
  },
16
19
  "description": "Local-first hands-free AI voice assistant plugin for DeepSeek Harness (DSH), with speech-to-text, text-to-speech, voice commands, and local speech engines.",
17
20
  "keywords": [
@@ -53,7 +56,7 @@
53
56
  "name": "wads.dev",
54
57
  "url": "https://www.npmjs.com/org/wads.dev"
55
58
  },
56
- "license": "GPL-3.0-only",
59
+ "license": "Apache-2.0",
57
60
  "files": [
58
61
  "src/",
59
62
  "lib/",
@@ -63,6 +66,7 @@
63
66
  "PLAN.md",
64
67
  "DEVELOPMENT.md",
65
68
  "HISTORY.md",
69
+ "docs/",
66
70
  "scripts/",
67
71
  "LICENSE"
68
72
  ],
@@ -75,6 +79,8 @@
75
79
  "url": "git+https://github.com/victorwads/dsh-live-voice.git"
76
80
  },
77
81
  "devDependencies": {
82
+ "@previewjs/cli": "^1.30.1",
83
+ "@previewjs/plugin-react": "^17.1.0",
78
84
  "@types/node": "26.6.2",
79
85
  "@types/react": "19.3.0",
80
86
  "@types/react-dom": "19.3.0",
@@ -102,8 +108,13 @@
102
108
  "@deepseek-ai/dsh-client-ui-conversation",
103
109
  "@deepseek-ai/dsh-client-ui-chat",
104
110
  "@deepseek-ai/dsh-client-connection",
105
- "@deepseek-ai/dsh-client-ui-settings"
111
+ "@deepseek-ai/dsh-client-ui-settings",
112
+ "@deepseek-ai/dsh-client-locale"
106
113
  ]
107
114
  }
115
+ },
116
+ "dependencies": {
117
+ "@wads.dev/i18n-react": "^0.0.1-alpha.0",
118
+ "@wads.dev/i18n-ts": "0.0.1-alpha.0"
108
119
  }
109
120
  }
package/scripts/build.ts CHANGED
@@ -1,15 +1,26 @@
1
1
  // @ts-nocheck
2
2
  import { build, context, type BuildOptions } from 'esbuild';
3
- import { rm, mkdir } from 'node:fs/promises';
3
+ import { rm, mkdir, readFile } from 'node:fs/promises';
4
+
5
+ const packageMetadata = JSON.parse(await readFile('package.json', 'utf8'));
6
+ const versionDefine = {
7
+ __DLV_VERSION__: JSON.stringify(packageMetadata.version),
8
+ __DLV_TESTED_DSH_VERSION__: JSON.stringify(packageMetadata.dshTestedVersion),
9
+ };
4
10
 
5
11
  const client: BuildOptions = {
6
- entryPoints: ['src/client/index.ts'],
12
+ entryPoints: ['src/app/client/apply.tsx'],
7
13
  outfile: 'lib/client.js',
8
14
  bundle: true,
9
15
  format: 'cjs',
10
16
  platform: 'browser',
11
17
  target: ['es2022'],
12
18
  external: ['react', 'react-dom'],
19
+ jsx: 'transform',
20
+ jsxFactory: 'React.createElement',
21
+ jsxFragment: 'React.Fragment',
22
+ tsconfigRaw: { compilerOptions: { jsx: 'react' } },
23
+ define: versionDefine,
13
24
  banner: {
14
25
  js: 'window.__ModuleLoader__.load({id:"dsh-live-voice",factory:(require)=>{var module={exports:{}};var exports=module.exports;',
15
26
  },
@@ -17,7 +28,7 @@ const client: BuildOptions = {
17
28
  logLevel: 'info',
18
29
  };
19
30
  const host: BuildOptions = {
20
- entryPoints: ['src/server.ts'],
31
+ entryPoints: ['src/app/server/apply.ts'],
21
32
  outfile: 'lib/server.js',
22
33
  bundle: true,
23
34
  format: 'esm',
@@ -27,7 +38,7 @@ const host: BuildOptions = {
27
38
  logLevel: 'info',
28
39
  };
29
40
  const tests: BuildOptions = {
30
- entryPoints: ['test/*.test.ts'],
41
+ entryPoints: ['test/*.test.ts', 'test/*.test.tsx'],
31
42
  outdir: '.test-dist',
32
43
  outbase: 'test',
33
44
  bundle: true,
@@ -35,6 +46,7 @@ const tests: BuildOptions = {
35
46
  platform: 'node',
36
47
  target: ['node22'],
37
48
  packages: 'external',
49
+ define: versionDefine,
38
50
  logLevel: 'silent',
39
51
  };
40
52
  const watch = process.argv.includes('--watch');
@@ -2,7 +2,7 @@
2
2
  import { build } from 'esbuild';
3
3
  import { chromium } from 'playwright-core';
4
4
  import { mkdir } from 'node:fs/promises';
5
- const entry = `import React from 'react'; import {createRoot} from 'react-dom/client'; import {createComponents} from './src/client/components.ts'; import {styles} from './src/client/styles.ts'; const style=document.createElement('style');style.textContent=styles;document.head.appendChild(style); const state={conversation:true,listening:true,recognizing:true,speaking:false,paused:false,settings:{},capabilities:{}}; const c={subscribe:()=>()=>{},getSnapshot:()=>state,meter:{level:()=>0.4}};const {RecordingBar}=createComponents(React);createRoot(document.getElementById('root')).render(React.createElement(RecordingBar,{controller:c}));`;
5
+ const entry = `import React from 'react'; import {createRoot} from 'react-dom/client'; import {createConversationComponents} from './src/modules/conversation/components/createConversationComponents.tsx'; import {styles} from './src/styles/index.ts'; const style=document.createElement('style');style.textContent=styles;document.head.appendChild(style); const state={conversation:true,listening:true,recognizing:true,speaking:false,paused:false,settings:{},capabilities:{}}; const c={subscribe:()=>()=>{},getSnapshot:()=>state,meter:{level:()=>0.4}};const {RecordingBar}=createConversationComponents(React);createRoot(document.getElementById('root')).render(React.createElement(RecordingBar,{controller:c}));`;
6
6
  const output = await build({
7
7
  stdin: { contents: entry, resolveDir: process.cwd() },
8
8
  bundle: true,
@@ -1,29 +1,42 @@
1
1
  // @ts-nocheck
2
2
  import React from 'react';
3
3
  import { createPortal } from 'react-dom';
4
- import { VoiceCoordinator } from '../core/coordinator.ts';
5
- import { VoiceOwnership } from '../core/ownership.ts';
6
- import { normalizeSettings } from '../core/settings.ts';
7
- import { MicrophoneMeter } from '../core/microphone.ts';
8
- import { BrowserSpeakingEngine } from '../engines/speaking/browser.ts';
9
- import { SayClientEngine } from '../engines/speaking/say-client.ts';
10
- import { BrowserRecognitionEngine } from '../engines/recognition/browser.ts';
11
- import { WhisperHttpRecognitionEngine } from '../engines/recognition/whisper-http.ts';
12
- import { QwenHttpRecognitionEngine } from '../engines/recognition/qwen-http.ts';
13
- import { QwenHttpSpeakingEngine } from '../engines/speaking/qwen-http.ts';
14
- import { createComponents } from './components.ts';
15
- import { styles } from './styles.ts';
4
+ import {
5
+ MicrophoneMeter,
6
+ VoiceCoordinator,
7
+ VoiceOwnership,
8
+ normalizeSettings,
9
+ } from '../../modules/core/index.js';
10
+ import {
11
+ BrowserRecognitionEngine,
12
+ QwenHttpRecognitionEngine,
13
+ WhisperHttpRecognitionEngine,
14
+ } from '../../modules/recognition/index.js';
15
+ import {
16
+ BrowserSpeakingEngine,
17
+ HostAudioSpeakingEngine,
18
+ QwenHttpSpeakingEngine,
19
+ } from '../../modules/speak/index.js';
20
+ import { createLiveVoiceSettings } from '../../modules/settings/index.js';
21
+ import {
22
+ ConversationControls,
23
+ ConversationStatusBar,
24
+ SpeakButton as ModularSpeakButton,
25
+ } from '../../modules/conversation/index.js';
26
+ import { registerLiveVoiceLocales } from './i18n/index.js';
27
+ import { registerConversationSlots, registerSettingsSlot } from './registerSlots.js';
28
+ import { styles } from '../../styles/index.js';
16
29
  import {
17
30
  assistantMessages,
18
31
  addressedTurn,
19
32
  latestUserSequence,
20
33
  pendingQuestionSpeech,
21
- } from './chat.ts';
34
+ } from '../../modules/conversation/models/chat.js';
22
35
 
23
- export const inject = ['slots', 'connection', 'uiConversation', 'uiSession'];
36
+ export const inject = ['slots', 'connection', 'uiConversation', 'uiSession', 'locale'];
24
37
  export function apply(ctx) {
25
- const e = React.createElement;
26
- const { MicrophoneButtons, RecordingBar, SpeakButton, SettingsPanel } = createComponents(React);
38
+ const t = registerLiveVoiceLocales(ctx);
39
+ const SettingsPanel = createLiveVoiceSettings(t, ctx.locale);
27
40
  const controllers = new Map();
28
41
  const retiring = new Set();
29
42
  let disposed = false;
@@ -31,6 +44,29 @@ export function apply(ctx) {
31
44
  // A route change may replace every conversation slot, but the next committed
32
45
  // composer should inherit the user's explicit choice to remain in voice mode.
33
46
  let voiceModeActive = false;
47
+ const publishVoiceContext = (
48
+ entry,
49
+ active = entry?.controller?.getSnapshot().conversation === true,
50
+ ) => {
51
+ if (!entry) return;
52
+ const settings = entry.controller.getSnapshot().settings;
53
+ const body = JSON.stringify({
54
+ sessionId: entry.key,
55
+ active:
56
+ active &&
57
+ settings.announceAssistantMessages !== false &&
58
+ settings.agentVoiceContextEnabled !== false,
59
+ context: settings.agentVoiceContext,
60
+ });
61
+ if (body === entry.lastVoiceContextBody) return;
62
+ entry.lastVoiceContextBody = body;
63
+ void fetch('/api/dsh-live-voice/voice-context', {
64
+ method: 'PUT',
65
+ credentials: 'same-origin',
66
+ headers: { 'content-type': 'application/json' },
67
+ body,
68
+ }).catch(() => {});
69
+ };
34
70
  const ownership = new VoiceOwnership();
35
71
  const recognitionSettingKeys = new Set([
36
72
  'recognitionEngine',
@@ -70,10 +106,13 @@ export function apply(ctx) {
70
106
  entry.composers.clear();
71
107
  entry.unsubscribe?.();
72
108
  entry.unsubscribe = null;
109
+ entry.unsubscribeVoiceContext?.();
110
+ entry.unsubscribeVoiceContext = null;
73
111
  entry.unsubscribePendingQuestion?.();
74
112
  entry.unsubscribePendingQuestion = null;
75
113
  entry.questionCapture = null;
76
114
  entry.controller.patch({ answeringQuestion: false });
115
+ publishVoiceContext(entry, false);
77
116
  entry.chatListeners.clear();
78
117
  if (controllers.get(entry.key) === entry) controllers.delete(entry.key);
79
118
  // Keep teardown in the hardware handoff barrier, not in the session registry.
@@ -94,6 +133,7 @@ export function apply(ctx) {
94
133
  buttons: 0,
95
134
  request: 0,
96
135
  closed: false,
136
+ lastVoiceContextBody: null,
97
137
  };
98
138
  let settings = {};
99
139
  try {
@@ -104,7 +144,13 @@ export function apply(ctx) {
104
144
  settings = normalizeSettings(null);
105
145
  }
106
146
  const engineBrowser = new BrowserSpeakingEngine({ lang: settings.lang || 'pt-BR' });
107
- const engineSay = new SayClientEngine({ rpc: ctx.connection.rpc });
147
+ const engineSay = new HostAudioSpeakingEngine({
148
+ endpoint: '/api/dsh-live-voice/say/speech',
149
+ capability: '/api/dsh-live-voice/say/capabilities',
150
+ lang: settings.lang || 'pt-BR',
151
+ synthesisRate: () => 175,
152
+ playbackRate: (rate) => rate,
153
+ });
108
154
  const engineQwen = new QwenHttpSpeakingEngine({ lang: settings.lang || 'pt-BR' });
109
155
  const meter = new MicrophoneMeter();
110
156
  const recognition = recognitionFor(settings, meter);
@@ -116,7 +162,17 @@ export function apply(ctx) {
116
162
  getDraft: () => entry.draft,
117
163
  submit: (mode = 'queue') => {
118
164
  const owner = [...entry.composers.values()].at(-1);
119
- owner?.actions.submit?.(mode);
165
+ if (!owner) return;
166
+ if (mode === 'steer') {
167
+ // InputActions deliberately exposes no delivery-mode argument. Dispatch the
168
+ // DSH accelerated composer gesture instead: Ctrl/Cmd+Enter resolves to the
169
+ // opposite of the normal busy-enter policy, which is direct steering when
170
+ // the ordinary action queues. Never fall back to inputActions.submit() here:
171
+ // it would silently turn an explicit steer request into a queued message.
172
+ owner.submitAccelerated?.();
173
+ return;
174
+ }
175
+ owner.actions.submit?.();
120
176
  },
121
177
  handleQuestionResult: ({ final, interim }) => {
122
178
  const capture = entry.questionCapture;
@@ -158,6 +214,8 @@ export function apply(ctx) {
158
214
  settings,
159
215
  });
160
216
  const controller = entry.controller;
217
+ entry.unsubscribeVoiceContext = controller.subscribe(() => publishVoiceContext(entry));
218
+ publishVoiceContext(entry);
161
219
  entry.chat = ctx.uiConversation.binding(sessionId).target('chat');
162
220
  entry.chatListeners = new Set();
163
221
  entry.subscribeChat = (listener) => {
@@ -220,6 +278,7 @@ export function apply(ctx) {
220
278
  const update = controller.updateSettings.bind(controller);
221
279
  entry.applySettings = (next) => {
222
280
  update(next);
281
+ publishVoiceContext(entry);
223
282
  engineBrowser.lang = controller.getSnapshot().settings.lang;
224
283
  engineQwen.lang = controller.getSnapshot().settings.lang;
225
284
  run(controller, controller.refreshCapabilities());
@@ -242,7 +301,9 @@ export function apply(ctx) {
242
301
  controller[method] = (...args) => {
243
302
  entry.request++;
244
303
  if (method === 'endConversation' && args[0] !== true) voiceModeActive = false;
245
- return original(...args);
304
+ const result = original(...args);
305
+ Promise.resolve(result).finally(() => publishVoiceContext(entry));
306
+ return result;
246
307
  };
247
308
  }
248
309
  for (const method of ['startDictation', 'startHoldToTalk', 'startConversation', 'speak']) {
@@ -250,7 +311,13 @@ export function apply(ctx) {
250
311
  controller[method] = (...args) => {
251
312
  if (disposed || entry.closed || !entry.refs) return Promise.resolve();
252
313
  if (method !== 'speak' && !entry.composers.size) return Promise.resolve();
253
- if (method === 'startConversation') voiceModeActive = true;
314
+ if (method === 'startConversation') {
315
+ // Re-entering voice mode must not replay visible history or the portion
316
+ // of a response that streamed while voice mode was off. Consume the
317
+ // current chat snapshot before admitting new assistant text.
318
+ refresh(true);
319
+ voiceModeActive = true;
320
+ }
254
321
  const request = ++entry.request;
255
322
  return ownership.run(
256
323
  controller,
@@ -258,7 +325,9 @@ export function apply(ctx) {
258
325
  () => {
259
326
  if (disposed || entry.closed || !entry.refs || request !== entry.request) return;
260
327
  if (method !== 'speak' && !entry.composers.size) return;
261
- return original(...args);
328
+ const result = original(...args);
329
+ Promise.resolve(result).finally(() => publishVoiceContext(entry));
330
+ return result;
262
331
  },
263
332
  );
264
333
  };
@@ -309,7 +378,29 @@ export function apply(ctx) {
309
378
  React.useLayoutEffect(() => {
310
379
  if (!entry || entry.closed || disposed) return;
311
380
  if (!input || typeof props.inputActions?.setDraft !== 'function') return;
312
- entry.composers.set(token.current, { actions: props.inputActions });
381
+ entry.composers.set(token.current, {
382
+ actions: props.inputActions,
383
+ submitAccelerated: () => {
384
+ const active = document.activeElement;
385
+ const editor =
386
+ active?.nodeType === 1 && active.isContentEditable
387
+ ? active
388
+ : document.querySelector('[contenteditable="true"]');
389
+ if (!editor || editor.nodeType !== 1) return;
390
+ editor.focus();
391
+ const KeyboardEventCtor = editor.ownerDocument.defaultView?.KeyboardEvent;
392
+ if (!KeyboardEventCtor) return;
393
+ editor.dispatchEvent(
394
+ new KeyboardEventCtor('keydown', {
395
+ key: 'Enter',
396
+ code: 'Enter',
397
+ ctrlKey: true,
398
+ bubbles: true,
399
+ cancelable: true,
400
+ }),
401
+ );
402
+ },
403
+ });
313
404
  if (voiceModeActive && !entry.controller.getSnapshot().conversation)
314
405
  run(entry.controller, entry.controller.startConversation());
315
406
  }, [entry, input, props.inputActions]);
@@ -329,7 +420,7 @@ export function apply(ctx) {
329
420
  function Buttons(props) {
330
421
  const entry = useEntry(props.sessionId, 'buttons');
331
422
  useComposer(entry, props);
332
- return entry ? e(MicrophoneButtons, { controller: entry.controller }) : null;
423
+ return entry ? <ConversationControls controller={entry.controller} /> : null;
333
424
  }
334
425
  function Settings() {
335
426
  const [controller, setController] = React.useState(null);
@@ -350,7 +441,12 @@ export function apply(ctx) {
350
441
  recognition,
351
442
  engines: {
352
443
  browser,
353
- say: new SayClientEngine({ rpc: ctx.connection.rpc }),
444
+ say: new HostAudioSpeakingEngine({
445
+ endpoint: '/api/dsh-live-voice/say/speech',
446
+ capability: '/api/dsh-live-voice/say/capabilities',
447
+ synthesisRate: () => 175,
448
+ playbackRate: (rate) => rate,
449
+ }),
354
450
  'qwen-http': qwen,
355
451
  },
356
452
  meter,
@@ -398,18 +494,13 @@ export function apply(ctx) {
398
494
  void c.dispose();
399
495
  };
400
496
  }, []);
401
- return controller ? e(SettingsPanel, { controller }) : null;
497
+ return controller ? <SettingsPanel controller={controller} /> : null;
402
498
  }
403
- ctx.slots.inject('settings.section', () =>
404
- ctx.slots.register(
405
- { name: 'settings.section', id: 'dsh-live-voice', order: 65, label: 'Live Voice' },
406
- Settings,
407
- ),
408
- );
499
+ registerSettingsSlot(ctx, Settings, () => t('dsh-live-voice.commons.pluginName'));
409
500
  function Dock(props) {
410
501
  const entry = useEntry(props.sessionId, 'dock');
411
502
  useComposer(entry, props);
412
- return entry ? e(RecordingBar, { controller: entry.controller }) : null;
503
+ return entry ? <ConversationStatusBar controller={entry.controller} /> : null;
413
504
  }
414
505
  function QuestionStatusView({ entry }) {
415
506
  const snapshot = React.useSyncExternalStore(
@@ -440,19 +531,20 @@ export function apply(ctx) {
440
531
  const target = typeof document === 'undefined' ? null : document.body;
441
532
  return snapshot.answeringQuestion && target && overlayStyle
442
533
  ? createPortal(
443
- e(RecordingBar, {
444
- controller: entry.controller,
445
- questionOnly: true,
446
- overlay: true,
447
- overlayStyle,
448
- }),
534
+ <ConversationStatusBar
535
+ controller={entry.controller}
536
+ questionOnly
537
+ overlay
538
+ overlayStyle={overlayStyle}
539
+ />,
540
+
449
541
  target,
450
542
  )
451
543
  : null;
452
544
  }
453
545
  function QuestionStatus(props) {
454
546
  const entry = useEntry(props.sessionId, 'question-status');
455
- return entry ? e(QuestionStatusView, { entry }) : null;
547
+ return entry ? <QuestionStatusView entry={entry} /> : null;
456
548
  }
457
549
  function ActionView({ entry, messageId }) {
458
550
  const snapshot = React.useSyncExternalStore(
@@ -464,20 +556,27 @@ export function apply(ctx) {
464
556
  const capability = snapshot.capabilities[snapshot.settings.engine];
465
557
  const active = snapshot.speaking && message.id === snapshot.activeMessageId;
466
558
  const unavailable = capability?.supported !== true;
467
- return e(SpeakButton, {
468
- active,
469
- disabled: !message.text.trim() || (!active && unavailable),
470
- label: unavailable ? capability?.reason || 'Checking speech output…' : undefined,
471
- onClick: () =>
472
- run(
473
- entry.controller,
474
- active ? entry.controller.stopSpeech() : entry.controller.speak(message.text, message.id),
475
- ),
476
- });
559
+ return (
560
+ <ModularSpeakButton
561
+ active={active}
562
+ disabled={!message.text.trim() || (!active && unavailable)}
563
+ label={
564
+ unavailable ? capability?.reason || t('dsh-live-voice.speak.output.checking') : undefined
565
+ }
566
+ onClick={() =>
567
+ run(
568
+ entry.controller,
569
+ active
570
+ ? entry.controller.stopSpeech()
571
+ : entry.controller.speak(message.text, message.id),
572
+ )
573
+ }
574
+ />
575
+ );
477
576
  }
478
577
  function Action(props) {
479
578
  const entry = useEntry(props.sessionId, 'action');
480
- return entry ? e(ActionView, { entry, messageId: props.messageId }) : null;
579
+ return entry ? <ActionView entry={entry} messageId={props.messageId} /> : null;
481
580
  }
482
581
  ctx.effect(() => {
483
582
  const style = document.createElement('style');
@@ -486,15 +585,9 @@ export function apply(ctx) {
486
585
  document.head.appendChild(style);
487
586
  return () => style.remove();
488
587
  });
489
- for (const [name, id, order, component] of [
490
- ['conversation.input.right', 'live-voice-controls', 6, Buttons],
491
- ['conversation.input.dock', 'live-voice-status', -100, Dock],
492
- ['conversation.session.header.utilities', 'live-voice-question-status', 100, QuestionStatus],
493
- ['conversation.chat.assistant-actions', 'live-voice-speak', 5, Action],
494
- ])
495
- ctx.slots.inject(name, () =>
496
- ctx.slots.register({ name, id, order, label: 'DSH Live Voice' }, component),
497
- );
588
+ registerConversationSlots(ctx, { Buttons, Dock, QuestionStatus, Action }, () =>
589
+ t('dsh-live-voice.commons.pluginName'),
590
+ );
498
591
  ctx.effect(() => {
499
592
  const stop = () => {
500
593
  ownership.cancel();
@@ -0,0 +1,42 @@
1
+ import React from 'react';
2
+ import { LiveVoiceTranslationProvider, normalizeAppLocale, useLanguage } from './runtime.js';
3
+
4
+ const FALLBACK_DSH_LOCALE = Object.freeze({ active: 'en', revision: 0 });
5
+
6
+ export function DshLanguageBoundary({
7
+ locale,
8
+ children,
9
+ }: {
10
+ locale: any;
11
+ children: React.ReactNode;
12
+ }) {
13
+ const { current, setLanguage } = useLanguage();
14
+ const subscribe = React.useCallback(
15
+ (listener: () => void) => locale?.subscribe?.(listener) ?? (() => {}),
16
+ [locale],
17
+ );
18
+ const snapshot = React.useCallback(
19
+ () => locale?.getSnapshot?.() ?? FALLBACK_DSH_LOCALE,
20
+ [locale],
21
+ );
22
+ const dshLocale = React.useSyncExternalStore(subscribe, snapshot, snapshot);
23
+ const expected = normalizeAppLocale(dshLocale.active);
24
+ React.useEffect(() => {
25
+ if (current.locale !== expected) setLanguage(expected, true);
26
+ }, [current.locale, expected, setLanguage]);
27
+ return current.locale === expected ? children : null;
28
+ }
29
+
30
+ export function withAppLanguage<P extends object>(Component: React.ComponentType<P>, locale: any) {
31
+ function LocalizedComponent(props: P) {
32
+ return (
33
+ <LiveVoiceTranslationProvider>
34
+ <DshLanguageBoundary locale={locale}>
35
+ <Component {...props} />
36
+ </DshLanguageBoundary>
37
+ </LiveVoiceTranslationProvider>
38
+ );
39
+ }
40
+ LocalizedComponent.displayName = `AppLanguage(${Component.displayName || Component.name || 'Component'})`;
41
+ return LocalizedComponent;
42
+ }