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.
- package/CHANGELOG.md +39 -1
- package/DEVELOPMENT.md +13 -0
- package/LICENSE +201 -674
- package/PLAN.md +28 -0
- package/README.md +65 -110
- package/docs/ARCHITECTURE.md +40 -0
- package/docs/CHOOSING-AN-ENGINE.md +120 -0
- package/docs/CONFIGURATION.md +149 -0
- package/docs/REVIEW.md +35 -0
- package/docs/VOICE-LIFECYCLE.md +59 -0
- package/lib/client.js +3652 -1576
- package/lib/server.js +641 -453
- package/package.json +17 -6
- package/scripts/build.ts +16 -4
- package/scripts/preview-ui.ts +1 -1
- package/src/{client/index.ts → app/client/apply.tsx} +152 -59
- package/src/app/client/i18n/DshLanguageBoundary.tsx +42 -0
- package/src/app/client/i18n/catalogs/base.ts +231 -0
- package/src/app/client/i18n/catalogs/en.ts +258 -0
- package/src/app/client/i18n/catalogs/es.ts +279 -0
- package/src/app/client/i18n/catalogs/fr.ts +281 -0
- package/src/app/client/i18n/catalogs/hi.ts +263 -0
- package/src/app/client/i18n/catalogs/index.ts +18 -0
- package/src/app/client/i18n/catalogs/pt-BR.ts +270 -0
- package/src/app/client/i18n/catalogs/zh.ts +248 -0
- package/src/app/client/i18n/index.ts +4 -0
- package/src/app/client/i18n/registerDshLocales.ts +43 -0
- package/src/app/client/i18n/runtime.tsx +58 -0
- package/src/app/client/index.ts +3 -0
- package/src/app/client/registerSlots.tsx +30 -0
- package/src/app/client/slotDefinitions.ts +32 -0
- package/src/app/server/apply.ts +479 -0
- package/src/app/server/index.ts +2 -0
- package/src/app/server/registerRoutes.ts +11 -0
- package/src/modules/conversation/components/ConversationControls.tsx +40 -0
- package/src/modules/conversation/components/ConversationStatusBar.tsx +155 -0
- package/src/modules/conversation/components/DeliveryModeButton.tsx +43 -0
- package/src/modules/conversation/components/MicrophoneButton.tsx +6 -0
- package/src/modules/conversation/components/PlaybackControls.tsx +41 -0
- package/src/modules/conversation/components/SpeakButton.tsx +30 -0
- package/src/modules/conversation/components/Waveform.tsx +68 -0
- package/src/modules/conversation/components/conversationStatus.ts +17 -0
- package/src/modules/conversation/components/createConversationComponents.tsx +505 -0
- package/src/modules/conversation/components/index.ts +8 -0
- package/src/modules/conversation/hooks/index.ts +2 -0
- package/src/modules/conversation/hooks/useConversationActions.ts +27 -0
- package/src/modules/conversation/hooks/useConversationController.ts +15 -0
- package/src/modules/conversation/index.ts +3 -0
- package/src/{client → modules/conversation/models}/chat.ts +2 -1
- package/src/modules/conversation/models/index.ts +1 -0
- package/src/{core → modules/core}/coordinator.ts +133 -7
- package/src/modules/core/index.ts +6 -0
- package/src/{engines/qwen-http-host.ts → modules/core/qwen/QwenHttpHost.ts} +2 -2
- package/src/modules/core/qwen/QwenSettings.tsx +148 -0
- package/src/{core → modules/core}/settings.ts +23 -0
- package/src/modules/recognition/components/RecognitionCapabilityStatus.tsx +19 -0
- package/src/{engines/recognition/qwen-http.ts → modules/recognition/engines/qwen/QwenRecognitionEngine.ts} +1 -1
- package/src/{engines/recognition/whisper-http.ts → modules/recognition/engines/whisper/WhisperRecognitionEngine.ts} +1 -1
- package/src/modules/recognition/engines/whisper/WhisperSettings.tsx +150 -0
- package/src/modules/recognition/index.ts +4 -0
- package/src/modules/recognition/qwen/QwenRecognitionSettings.tsx +4 -0
- package/src/modules/settings/components/LiveVoiceSettings.tsx +72 -0
- package/src/modules/settings/components/SettingsHeader.tsx +49 -0
- package/src/modules/settings/components/VersionBadges.tsx +39 -0
- package/src/modules/settings/components/createLiveVoiceSettings.tsx +8 -0
- package/src/modules/settings/components/index.ts +4 -0
- package/src/modules/settings/hooks/index.ts +3 -0
- package/src/modules/settings/hooks/useAudioDevices.ts +38 -0
- package/src/modules/settings/hooks/useLiveVoiceSettings.ts +4 -0
- package/src/modules/settings/hooks/useReleaseStatus.ts +17 -0
- package/src/modules/settings/index.ts +4 -0
- package/src/modules/settings/models/index.ts +1 -0
- package/src/modules/settings/models/settingsStorage.ts +14 -0
- package/src/modules/settings/sections/conversation/ConversationDelaySettings.tsx +24 -0
- package/src/modules/settings/sections/conversation/ConversationSettingsSection.tsx +23 -0
- package/src/modules/settings/sections/conversation/DeliverySettings.tsx +38 -0
- package/src/modules/settings/sections/conversation/HoldToTalkSettings.tsx +20 -0
- package/src/modules/settings/sections/conversation/VoiceModeSettings.tsx +31 -0
- package/src/modules/settings/sections/conversation/index.ts +5 -0
- package/src/modules/settings/sections/index.ts +3 -0
- package/src/modules/settings/sections/recognition/RecognitionEngineSettings.tsx +109 -0
- package/src/modules/settings/sections/recognition/RecognitionFilterSettings.tsx +33 -0
- package/src/modules/settings/sections/recognition/RecognitionSettingsSection.tsx +20 -0
- package/src/modules/settings/sections/recognition/RecognitionStatus.tsx +19 -0
- package/src/modules/settings/sections/recognition/SilenceDetectionSettings.tsx +58 -0
- package/src/modules/settings/sections/recognition/VoiceCommandSettings.tsx +41 -0
- package/src/modules/settings/sections/recognition/index.ts +5 -0
- package/src/modules/settings/sections/speak/OutputFilterSettings.tsx +39 -0
- package/src/modules/settings/sections/speak/PlaybackPolicySettings.tsx +26 -0
- package/src/modules/settings/sections/speak/SpeakSettingsSection.tsx +16 -0
- package/src/modules/settings/sections/speak/SpeechAdvancedSettings.tsx +81 -0
- package/src/modules/settings/sections/speak/SpeechEngineSettings.tsx +80 -0
- package/src/modules/settings/sections/speak/index.ts +4 -0
- package/src/modules/settings/services/releases.ts +117 -0
- package/src/modules/speak/engines/audio/HostAudioEngine.ts +167 -0
- package/src/modules/speak/engines/audio/M4aAacTranscoder.ts +58 -0
- package/src/modules/speak/engines/qwen/QwenSpeakingEngine.ts +14 -0
- package/src/{engines/speaking/say.ts → modules/speak/engines/say/SaySpeakingEngine.ts} +26 -4
- package/src/modules/speak/index.ts +5 -0
- package/src/modules/speak/qwen/QwenSpeakingSettings.tsx +4 -0
- package/src/modules/speak/services/speechQueue.ts +2 -0
- package/src/server.ts +9 -365
- package/src/shared/design-system/buttons/IconButton.tsx +33 -0
- package/src/shared/design-system/buttons/PillButton.tsx +11 -0
- package/src/shared/design-system/buttons/ToggleButton.tsx +6 -0
- package/src/shared/design-system/buttons/index.ts +3 -0
- package/src/shared/design-system/feedback/ErrorMessage.tsx +27 -0
- package/src/shared/design-system/feedback/StatusBadge.tsx +9 -0
- package/src/shared/design-system/feedback/StatusMessage.tsx +4 -0
- package/src/shared/design-system/feedback/index.ts +3 -0
- package/src/shared/design-system/forms/CheckboxField.tsx +20 -0
- package/src/shared/design-system/forms/NumberField.tsx +12 -0
- package/src/shared/design-system/forms/SelectField.tsx +20 -0
- package/src/shared/design-system/forms/TextAreaField.tsx +12 -0
- package/src/shared/design-system/forms/TextField.tsx +12 -0
- package/src/shared/design-system/forms/index.ts +5 -0
- package/src/shared/design-system/icons/Icon.tsx +20 -0
- package/src/shared/design-system/icons/icons.ts +16 -0
- package/src/shared/design-system/icons/index.ts +2 -0
- package/src/shared/design-system/index.ts +5 -0
- package/src/shared/design-system/layout/SettingsCard.tsx +9 -0
- package/src/shared/design-system/layout/SettingsSection.tsx +9 -0
- package/src/shared/design-system/layout/SettingsSubcard.tsx +14 -0
- package/src/shared/design-system/layout/SettingsTabs.tsx +54 -0
- package/src/shared/design-system/layout/index.ts +4 -0
- package/src/{client/styles.ts → styles/index.ts} +3 -1
- package/src/client/components.ts +0 -1080
- package/src/client/qwen-settings.ts +0 -144
- package/src/client/whisper-settings.ts +0 -147
- package/src/engines/speaking/qwen-http.ts +0 -124
- /package/src/{core → modules/core}/filters.ts +0 -0
- /package/src/{core → modules/core}/microphone.ts +0 -0
- /package/src/{core → modules/core}/ownership.ts +0 -0
- /package/src/{core → modules/core}/transcript.ts +0 -0
- /package/src/{engines/recognition/browser.ts → modules/recognition/engines/browser/BrowserRecognitionEngine.ts} +0 -0
- /package/src/{engines/recognition/whisper-http-host.ts → modules/recognition/engines/whisper/whisperRecognitionHost.ts} +0 -0
- /package/src/{engines/speaking/browser.ts → modules/speak/engines/browser/BrowserSpeakingEngine.ts} +0 -0
- /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.
|
|
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": "
|
|
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/
|
|
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');
|
package/scripts/preview-ui.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import {
|
|
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 '
|
|
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
|
|
26
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
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, {
|
|
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 ?
|
|
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
|
|
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 ?
|
|
497
|
+
return controller ? <SettingsPanel controller={controller} /> : null;
|
|
402
498
|
}
|
|
403
|
-
ctx
|
|
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 ?
|
|
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
|
-
|
|
444
|
-
controller
|
|
445
|
-
questionOnly
|
|
446
|
-
overlay
|
|
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 ?
|
|
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
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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 ?
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
+
}
|