dsh-live-voice 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -8
- package/lib/client.js +5959 -3222
- package/lib/server.js +29 -6
- package/package.json +9 -30
- package/src/app/AGENTS.md +1 -1
- package/src/app/ARCHITECTURE.md +1 -1
- package/src/app/client/apply.tsx +140 -16
- package/src/app/client/composerSelection.ts +92 -0
- package/src/app/client/i18n/catalogs/base.ts +33 -0
- package/src/app/client/i18n/catalogs/en.ts +36 -0
- package/src/app/client/i18n/catalogs/es.ts +36 -0
- package/src/app/client/i18n/catalogs/fr.ts +37 -0
- package/src/app/client/i18n/catalogs/hi.ts +35 -0
- package/src/app/client/i18n/catalogs/pt-BR.ts +37 -0
- package/src/app/client/i18n/catalogs/zh.ts +35 -0
- package/src/modules/AGENTS.md +1 -1
- package/src/modules/conversation/ARCHITECTURE.md +4 -2
- package/src/modules/conversation/components/AutoPlaybackToggle.tsx +31 -0
- package/src/modules/conversation/components/ConversationControls.tsx +23 -8
- package/src/modules/conversation/components/ConversationStatusBar.tsx +72 -94
- package/src/modules/conversation/components/DeliveryModeButton.tsx +3 -3
- package/src/modules/conversation/components/MeetingControls.tsx +64 -0
- package/src/modules/conversation/components/MicrophoneButton.tsx +6 -1
- package/src/modules/conversation/components/PlaybackControls.tsx +20 -5
- package/src/modules/conversation/components/RecognitionBar.tsx +14 -0
- package/src/modules/conversation/components/ScrollingSpeechCaption.tsx +100 -0
- package/src/modules/conversation/components/SpeechStatusBar.tsx +99 -0
- package/src/modules/conversation/components/Waveform.tsx +8 -3
- package/src/modules/conversation/components/conversationStatus.ts +0 -2
- package/src/modules/conversation/components/index.ts +1 -0
- package/src/modules/conversation/components/speechCaption.ts +27 -0
- package/src/modules/conversation/models/chat.ts +14 -2
- package/src/modules/conversation/models/meeting.ts +140 -0
- package/src/modules/conversation/models/meetingTranscript.ts +56 -0
- package/src/modules/core/ARCHITECTURE.md +1 -1
- package/src/modules/core/coordinator.ts +204 -51
- package/src/modules/core/developerExtension.ts +26 -0
- package/src/modules/core/diagnostics.ts +150 -0
- package/src/modules/core/filters.ts +20 -15
- package/src/modules/core/markdownSpeech.ts +154 -0
- package/src/modules/core/settings.ts +17 -13
- package/src/modules/core/sharedAudio.ts +82 -0
- package/src/modules/core/speechDefaults.ts +21 -0
- package/src/modules/recognition/engines/browser/BrowserRecognitionEngine.ts +8 -1
- package/src/modules/recognition/engines/whisper/WhisperRecognitionEngine.ts +10 -3
- package/src/modules/settings/components/LiveVoiceSettings.tsx +17 -3
- package/src/modules/settings/sections/conversation/ConversationDelaySettings.tsx +8 -3
- package/src/modules/settings/sections/conversation/DeliverySettings.tsx +25 -2
- package/src/modules/speak/engines/audio/HostAudioEngine.ts +19 -2
- package/src/modules/speak/engines/browser/BrowserSpeakingEngine.ts +33 -2
- package/src/shared/design-system/buttons/ToggleButton.tsx +8 -1
- package/src/shared/design-system/icons/icons.ts +4 -0
- package/src/styles/index.ts +11 -3
- package/CHANGELOG.md +0 -240
- package/DEVELOPMENT.md +0 -83
- package/PLAN.md +0 -296
- package/docs/ARCHITECTURE.md +0 -33
- package/docs/CHOOSING-AN-ENGINE.md +0 -120
- package/docs/CONFIGURATION.md +0 -163
- package/docs/REVIEW.md +0 -35
- package/docs/VOICE-LIFECYCLE.md +0 -63
- package/scripts/build.ts +0 -62
- package/scripts/check-dist.mjs +0 -38
- package/scripts/preview-ui.ts +0 -31
- package/scripts/probe-browser.ts +0 -46
package/lib/server.js
CHANGED
|
@@ -4,6 +4,26 @@ import { homedir as homedir2 } from "node:os";
|
|
|
4
4
|
import { dirname as dirname2, join as join2 } from "node:path";
|
|
5
5
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
6
6
|
|
|
7
|
+
// src/modules/core/speechDefaults.ts
|
|
8
|
+
var notices = {
|
|
9
|
+
en: "You can see the code in our conversation history.",
|
|
10
|
+
pt: "Voc\xEA pode ver o c\xF3digo no hist\xF3rico da nossa conversa.",
|
|
11
|
+
es: "Puedes ver el c\xF3digo en el historial de nuestra conversaci\xF3n.",
|
|
12
|
+
fr: "Vous pouvez voir le code dans l\u2019historique de notre conversation.",
|
|
13
|
+
hi: "\u0906\u092A \u0939\u092E\u093E\u0930\u0940 \u092C\u093E\u0924\u091A\u0940\u0924 \u0915\u0947 \u0907\u0924\u093F\u0939\u093E\u0938 \u092E\u0947\u0902 \u0915\u094B\u0921 \u0926\u0947\u0916 \u0938\u0915\u0924\u0947 \u0939\u0948\u0902\u0964",
|
|
14
|
+
zh: "\u4F60\u53EF\u4EE5\u5728\u6211\u4EEC\u7684\u5BF9\u8BDD\u5386\u53F2\u4E2D\u67E5\u770B\u4EE3\u7801\u3002"
|
|
15
|
+
};
|
|
16
|
+
var defaults = /* @__PURE__ */ new Set([
|
|
17
|
+
...Object.values(notices),
|
|
18
|
+
"Look the code on out conversation",
|
|
19
|
+
"You can see the code on our conversation history.",
|
|
20
|
+
"You can see the code on our conversation history"
|
|
21
|
+
]);
|
|
22
|
+
function resolveCodeNotice(value, lang = "en") {
|
|
23
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
24
|
+
return !text || defaults.has(text) ? notices[String(lang).toLowerCase().split("-")[0]] || notices.en : text;
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
// src/modules/core/settings.ts
|
|
8
28
|
var voiceDetectionPresets = Object.freeze({
|
|
9
29
|
short: Object.freeze({
|
|
@@ -38,6 +58,7 @@ var isQwenVoice = (value) => qwenVoices.some((voice) => voice.value === value);
|
|
|
38
58
|
var defaultAgentVoiceContext = `Live Voice output is active. Your entire user-facing response will be spoken aloud.
|
|
39
59
|
Be concise and conversational. Lead with the answer or next action. Avoid unnecessary repetition, long preambles, dense lists, raw code, paths, and verbose status narration.
|
|
40
60
|
Do not narrate routine tool activity by default. If the user explicitly asks you to keep them informed while working, provide brief spoken progress updates only at meaningful milestones.`;
|
|
61
|
+
var autoSendDelayOptions = Object.freeze([0.6, 0.8, 1, 2, 3, 4, 5, 6]);
|
|
41
62
|
var defaultSettings = Object.freeze({
|
|
42
63
|
engine: "browser",
|
|
43
64
|
recognitionEngine: "browser",
|
|
@@ -47,14 +68,15 @@ var defaultSettings = Object.freeze({
|
|
|
47
68
|
voiceDetectionCustomSilenceMs: 1e3,
|
|
48
69
|
recognitionMaxUtteranceSeconds: 60,
|
|
49
70
|
microphoneEnabled: true,
|
|
50
|
-
holdToTalkEnabled:
|
|
71
|
+
holdToTalkEnabled: false,
|
|
51
72
|
announceAssistantMessages: true,
|
|
52
73
|
agentVoiceContextEnabled: true,
|
|
53
74
|
agentVoiceContext: defaultAgentVoiceContext,
|
|
54
75
|
interruptSpeechOnUserMessage: false,
|
|
55
76
|
sendingMode: "manual",
|
|
77
|
+
dshBusyEnterBehavior: "queue",
|
|
56
78
|
autoSendDelaySeconds: 4,
|
|
57
|
-
assistantSpeechDelaySeconds:
|
|
79
|
+
assistantSpeechDelaySeconds: 0,
|
|
58
80
|
mode: "speaker",
|
|
59
81
|
lang: "pt-BR",
|
|
60
82
|
recognitionLang: "pt-BR",
|
|
@@ -73,7 +95,7 @@ var defaultSettings = Object.freeze({
|
|
|
73
95
|
voiceCommandClear: "clear all, clear message",
|
|
74
96
|
outputCodeFilterEnabled: true,
|
|
75
97
|
outputCodeMaxLines: 5,
|
|
76
|
-
outputCodeNotice:
|
|
98
|
+
outputCodeNotice: resolveCodeNotice(void 0, "pt-BR"),
|
|
77
99
|
rate: 1,
|
|
78
100
|
segmentGapMs: 400
|
|
79
101
|
});
|
|
@@ -97,9 +119,10 @@ function normalizeSettings(value) {
|
|
|
97
119
|
agentVoiceContextEnabled: typeof source.agentVoiceContextEnabled === "boolean" ? source.agentVoiceContextEnabled : defaultSettings.agentVoiceContextEnabled,
|
|
98
120
|
agentVoiceContext: typeof source.agentVoiceContext === "string" && source.agentVoiceContext.length <= 4e3 && !source.agentVoiceContext.includes("\0") ? source.agentVoiceContext.trim() : defaultSettings.agentVoiceContext,
|
|
99
121
|
interruptSpeechOnUserMessage: typeof source.interruptSpeechOnUserMessage === "boolean" ? source.interruptSpeechOnUserMessage : defaultSettings.interruptSpeechOnUserMessage,
|
|
122
|
+
dshBusyEnterBehavior: ["queue", "steer"].includes(source.dshBusyEnterBehavior) ? source.dshBusyEnterBehavior : defaultSettings.dshBusyEnterBehavior,
|
|
100
123
|
sendingMode: source.sendingMode === "automatic" ? "queue" : ["manual", "queue", "steer"].includes(source.sendingMode) ? source.sendingMode : defaultSettings.sendingMode,
|
|
101
|
-
autoSendDelaySeconds:
|
|
102
|
-
assistantSpeechDelaySeconds: Number.isInteger(source.assistantSpeechDelaySeconds) && source.assistantSpeechDelaySeconds >=
|
|
124
|
+
autoSendDelaySeconds: autoSendDelayOptions.includes(source.autoSendDelaySeconds) ? source.autoSendDelaySeconds : defaultSettings.autoSendDelaySeconds,
|
|
125
|
+
assistantSpeechDelaySeconds: Number.isInteger(source.assistantSpeechDelaySeconds) && source.assistantSpeechDelaySeconds >= 0 && source.assistantSpeechDelaySeconds <= 4 ? source.assistantSpeechDelaySeconds : defaultSettings.assistantSpeechDelaySeconds,
|
|
103
126
|
mode: ["speaker", "headphones"].includes(source.mode) ? source.mode : defaultSettings.mode,
|
|
104
127
|
lang: typeof source.lang === "string" && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(source.lang) ? source.lang : defaultSettings.lang,
|
|
105
128
|
recognitionLang: source.recognitionLang === "auto" || typeof source.recognitionLang === "string" && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(source.recognitionLang) ? source.recognitionLang : typeof source.lang === "string" && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(source.lang) ? source.lang : defaultSettings.recognitionLang,
|
|
@@ -139,7 +162,7 @@ function normalizeSettings(value) {
|
|
|
139
162
|
),
|
|
140
163
|
outputCodeFilterEnabled: typeof source.outputCodeFilterEnabled === "boolean" ? source.outputCodeFilterEnabled : defaultSettings.outputCodeFilterEnabled,
|
|
141
164
|
outputCodeMaxLines: Number.isInteger(source.outputCodeMaxLines) && source.outputCodeMaxLines >= 0 && source.outputCodeMaxLines <= 100 ? source.outputCodeMaxLines : defaultSettings.outputCodeMaxLines,
|
|
142
|
-
outputCodeNotice: typeof source.outputCodeNotice === "string" && source.outputCodeNotice.trim() && source.outputCodeNotice.length <= 300 && !source.outputCodeNotice.includes("\0") ? source.outputCodeNotice.
|
|
165
|
+
outputCodeNotice: typeof source.outputCodeNotice === "string" && source.outputCodeNotice.trim() && source.outputCodeNotice.length <= 300 && !source.outputCodeNotice.includes("\0") ? resolveCodeNotice(source.outputCodeNotice, source.lang || defaultSettings.lang) : resolveCodeNotice(defaultSettings.outputCodeNotice, source.lang || defaultSettings.lang),
|
|
143
166
|
rate: Number.isFinite(source.rate) && source.rate >= 0.1 && source.rate <= 3 ? source.rate : 1,
|
|
144
167
|
segmentGapMs: Number.isFinite(source.segmentGapMs) && source.segmentGapMs >= 0 && source.segmentGapMs <= 2e3 ? Math.round(source.segmentGapMs) : 400
|
|
145
168
|
};
|
package/package.json
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-live-voice",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"dshTestedVersion": "0.2.
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"dshTestedVersion": "0.2.1-alpha.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"
|
|
7
|
+
"build": "node --experimental-strip-types ../../scripts/build.ts --package live-voice",
|
|
8
8
|
"test": "npm run build && node --test .test-dist/*.test.js",
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"dev": "node --experimental-strip-types scripts/build.ts --watch",
|
|
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\"",
|
|
14
|
-
"setup:hooks": "git config core.hooksPath .githooks",
|
|
9
|
+
"typecheck": "tsc --noEmit",
|
|
10
|
+
"dev": "node --experimental-strip-types ../../scripts/build.ts --package live-voice --watch",
|
|
15
11
|
"prepack": "npm run build",
|
|
16
12
|
"preview": "previewjs .",
|
|
17
13
|
"preview:check": "previewjs . --help"
|
|
@@ -62,12 +58,7 @@
|
|
|
62
58
|
"lib/",
|
|
63
59
|
"cordis.patch.yml",
|
|
64
60
|
"README.md",
|
|
65
|
-
"CHANGELOG.md",
|
|
66
|
-
"PLAN.md",
|
|
67
|
-
"DEVELOPMENT.md",
|
|
68
61
|
"HISTORY.md",
|
|
69
|
-
"docs/",
|
|
70
|
-
"scripts/",
|
|
71
62
|
"LICENSE"
|
|
72
63
|
],
|
|
73
64
|
"publishConfig": {
|
|
@@ -76,21 +67,8 @@
|
|
|
76
67
|
},
|
|
77
68
|
"repository": {
|
|
78
69
|
"type": "git",
|
|
79
|
-
"url": "git+https://github.com/victorwads/dsh-live-voice.git"
|
|
80
|
-
|
|
81
|
-
"devDependencies": {
|
|
82
|
-
"@previewjs/cli": "^1.30.1",
|
|
83
|
-
"@previewjs/plugin-react": "^17.1.0",
|
|
84
|
-
"@types/node": "26.6.2",
|
|
85
|
-
"@types/react": "19.3.0",
|
|
86
|
-
"@types/react-dom": "19.3.0",
|
|
87
|
-
"esbuild": "0.28.2",
|
|
88
|
-
"jsdom": "30.1.0",
|
|
89
|
-
"playwright-core": "1.63.0",
|
|
90
|
-
"prettier": "3.9.8",
|
|
91
|
-
"react": "19.3.0",
|
|
92
|
-
"react-dom": "19.3.0",
|
|
93
|
-
"typescript": "7.0.2"
|
|
70
|
+
"url": "git+https://github.com/victorwads/dsh-live-voice.git",
|
|
71
|
+
"directory": "packages/live-voice"
|
|
94
72
|
},
|
|
95
73
|
"main": "./lib/server.js",
|
|
96
74
|
"exports": {
|
|
@@ -115,6 +93,7 @@
|
|
|
115
93
|
},
|
|
116
94
|
"dependencies": {
|
|
117
95
|
"@wads.dev/i18n-react": "^0.0.1-alpha.0",
|
|
118
|
-
"@wads.dev/i18n-ts": "0.0.1-alpha.0"
|
|
96
|
+
"@wads.dev/i18n-ts": "0.0.1-alpha.0",
|
|
97
|
+
"marked": "^18.1.0"
|
|
119
98
|
}
|
|
120
99
|
}
|
package/src/app/AGENTS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Application boundary
|
|
2
2
|
|
|
3
|
-
Read [ARCHITECTURE.md](ARCHITECTURE.md) before changing composition roots, routes, slots, or language registration. Read the repository [AGENTS.md](
|
|
3
|
+
Read [ARCHITECTURE.md](ARCHITECTURE.md) before changing composition roots, routes, slots, or language registration. Read the repository [AGENTS.md](../../../../AGENTS.md) for product, validation, and authorization rules.
|
|
4
4
|
|
|
5
5
|
- Inspect the actual DSH extension API before changing an integration point. Preserve established slot names, IDs, ordering, injected services, authenticated same-origin routes, and browser/host separation.
|
|
6
6
|
- Keep UI copy in the typed catalogs under `client/i18n/`; update every supported language in alphabetical key order. Use DSH `ctx.locale`, not an independent preference. UI locale is independent of recognition/synthesis languages and configured voice-command phrases.
|
package/src/app/ARCHITECTURE.md
CHANGED
|
@@ -7,4 +7,4 @@
|
|
|
7
7
|
- `client/i18n` owns the only UI translation tree: typed, alphabetized catalogs for every supported locale, runtime selection synchronized with `ctx.locale`, DSH registration, and the boundary wrapping slot components. UI language must not change STT/TTS language or user-defined command phrases.
|
|
8
8
|
- Styles are assembled from `src/styles/index.ts`; design-system primitives live under `src/shared/design-system` rather than here.
|
|
9
9
|
|
|
10
|
-
Read [the repository overview](
|
|
10
|
+
Read [the repository overview](../../../../docs/ARCHITECTURE.md) for dependency direction and stable contracts, and the relevant module architecture before editing feature behavior. Read [application agent guidance](AGENTS.md) for operational constraints.
|
package/src/app/client/apply.tsx
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
import React from 'react';
|
|
3
|
+
import { SpeechStatusBar } from '../../modules/conversation/components/SpeechStatusBar.js';
|
|
4
|
+
import { MeetingController } from '../../modules/conversation/models/meeting.js';
|
|
5
|
+
import { SharedAudioMeter, requestSharedAudio } from '../../modules/core/sharedAudio.js';
|
|
6
|
+
import {
|
|
7
|
+
MeetingToggle,
|
|
8
|
+
MeetingBars,
|
|
9
|
+
} from '../../modules/conversation/components/MeetingControls.js';
|
|
3
10
|
import { createPortal } from 'react-dom';
|
|
11
|
+
import { preserveComposerSelection } from './composerSelection.js';
|
|
4
12
|
import { MicrophoneMeter, VoiceCoordinator, VoiceOwnership } from '../../modules/core/index.js';
|
|
5
13
|
import {
|
|
6
14
|
BrowserRecognitionEngine,
|
|
@@ -22,6 +30,10 @@ import { registerLiveVoiceLocales } from './i18n/index.js';
|
|
|
22
30
|
import { registerConversationSlots, registerSettingsSlot } from './registerSlots.js';
|
|
23
31
|
import { styles } from '../../styles/index.js';
|
|
24
32
|
import { createSettingsClient } from '../../modules/settings/models/settingsStorage.js';
|
|
33
|
+
import {
|
|
34
|
+
publishDiagnosticSource,
|
|
35
|
+
readCoordinatorDiagnostics,
|
|
36
|
+
} from '../../modules/core/diagnostics.js';
|
|
25
37
|
import {
|
|
26
38
|
assistantMessages,
|
|
27
39
|
addressedTurn,
|
|
@@ -42,6 +54,43 @@ export function apply(ctx) {
|
|
|
42
54
|
// A route change may replace every conversation slot, but the next committed
|
|
43
55
|
// composer should inherit the user's explicit choice to remain in voice mode.
|
|
44
56
|
let voiceModeActive = false;
|
|
57
|
+
const diagnosticListeners = new Set();
|
|
58
|
+
const notifyDiagnostics = () => {
|
|
59
|
+
for (const listener of diagnosticListeners) {
|
|
60
|
+
try {
|
|
61
|
+
listener();
|
|
62
|
+
} catch {
|
|
63
|
+
/* Observers never own voice behavior. */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
ctx.effect(() => {
|
|
68
|
+
const unpublish = publishDiagnosticSource(window, {
|
|
69
|
+
version: 1,
|
|
70
|
+
read: ({ includeContent = false } = {}) => ({
|
|
71
|
+
application: { disposed, voiceModeActive, retiring: retiring.size },
|
|
72
|
+
sessions: Object.fromEntries(
|
|
73
|
+
[...controllers].map(([id, entry]) => [
|
|
74
|
+
id,
|
|
75
|
+
readCoordinatorDiagnostics(entry.controller, includeContent),
|
|
76
|
+
]),
|
|
77
|
+
),
|
|
78
|
+
settingsControllers: [...settingsControllers].map((controller) =>
|
|
79
|
+
readCoordinatorDiagnostics(controller, includeContent),
|
|
80
|
+
),
|
|
81
|
+
}),
|
|
82
|
+
subscribe: (listener) => {
|
|
83
|
+
diagnosticListeners.add(listener);
|
|
84
|
+
return () => {
|
|
85
|
+
diagnosticListeners.delete(listener);
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
return () => {
|
|
90
|
+
unpublish();
|
|
91
|
+
diagnosticListeners.clear();
|
|
92
|
+
};
|
|
93
|
+
}, 'dsh-live-voice: diagnostic bridge');
|
|
45
94
|
const publishVoiceContext = (
|
|
46
95
|
entry,
|
|
47
96
|
active = entry?.controller?.getSnapshot().conversation === true,
|
|
@@ -146,10 +195,15 @@ export function apply(ctx) {
|
|
|
146
195
|
entry.questionCapture = null;
|
|
147
196
|
entry.controller.patch({ answeringQuestion: false });
|
|
148
197
|
publishVoiceContext(entry, false);
|
|
198
|
+
entry.unsubscribeMeetingMicrophone?.();
|
|
149
199
|
entry.chatListeners.clear();
|
|
150
200
|
if (controllers.get(entry.key) === entry) controllers.delete(entry.key);
|
|
201
|
+
notifyDiagnostics();
|
|
151
202
|
// Keep teardown in the hardware handoff barrier, not in the session registry.
|
|
152
|
-
const done = run(
|
|
203
|
+
const done = run(
|
|
204
|
+
entry.controller,
|
|
205
|
+
Promise.all([entry.controller.dispose(), entry.meeting?.dispose()]),
|
|
206
|
+
);
|
|
153
207
|
const barrier = { endConversation: () => done };
|
|
154
208
|
retiring.add(barrier);
|
|
155
209
|
void done.finally(() => retiring.delete(barrier));
|
|
@@ -185,21 +239,29 @@ export function apply(ctx) {
|
|
|
185
239
|
const meter = new MicrophoneMeter();
|
|
186
240
|
const recognition = recognitionFor(settings, meter);
|
|
187
241
|
entry.controller = new VoiceCoordinator({
|
|
242
|
+
persistSettings: (next) => savePreferences(entry.controller, next),
|
|
188
243
|
recognition,
|
|
189
244
|
engines: { browser: engineBrowser, say: engineSay, 'qwen-http': engineQwen },
|
|
190
245
|
meter,
|
|
191
246
|
composer: {
|
|
192
247
|
getDraft: () => entry.draft,
|
|
248
|
+
appendFinal: (text, startedAt) => {
|
|
249
|
+
if (entry.meeting?.getSnapshot().shared.listening) {
|
|
250
|
+
entry.meeting.transcript.append('microphone', text, startedAt);
|
|
251
|
+
return entry.draft;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
},
|
|
193
255
|
submit: (mode = 'queue') => {
|
|
194
256
|
const owner = [...entry.composers.values()].at(-1);
|
|
195
257
|
if (!owner) return;
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
// the
|
|
201
|
-
//
|
|
202
|
-
owner.submitAccelerated?.();
|
|
258
|
+
const busyEnter = entry.controller.getSnapshot().settings.dshBusyEnterBehavior;
|
|
259
|
+
if (mode !== busyEnter || busyEnter === 'steer') {
|
|
260
|
+
// InputActions.submit() always queues; it does not emulate normal Enter.
|
|
261
|
+
// Use an actual Enter gesture for steering-default composers, adding Ctrl
|
|
262
|
+
// only for the opposite mode. Read the preference at delivery time.
|
|
263
|
+
// Never fall back to normal submit: that would deliver the wrong mode.
|
|
264
|
+
owner.submitAccelerated?.(mode !== busyEnter);
|
|
203
265
|
return;
|
|
204
266
|
}
|
|
205
267
|
owner.actions.submit?.();
|
|
@@ -237,16 +299,23 @@ export function apply(ctx) {
|
|
|
237
299
|
if (entry.pendingDraft === undefined) entry.staleDrafts.clear();
|
|
238
300
|
entry.staleDrafts.add(entry.draft);
|
|
239
301
|
if (entry.publishedDraft !== undefined) entry.staleDrafts.add(entry.publishedDraft);
|
|
302
|
+
const previous = entry.draft;
|
|
240
303
|
entry.draft = text;
|
|
241
304
|
entry.pendingDraft = text;
|
|
242
305
|
entry.pendingRevision = entry.publishedRevision;
|
|
243
|
-
|
|
306
|
+
const append = text.length > previous.length && text.startsWith(previous);
|
|
307
|
+
if (append) preserveComposerSelection(() => owner.actions.setDraft(text));
|
|
308
|
+
else owner.actions.setDraft(text);
|
|
244
309
|
},
|
|
245
310
|
},
|
|
246
311
|
settings,
|
|
247
312
|
});
|
|
248
313
|
const controller = entry.controller;
|
|
249
|
-
entry.unsubscribeVoiceContext = controller.subscribe(() =>
|
|
314
|
+
entry.unsubscribeVoiceContext = controller.subscribe(() => {
|
|
315
|
+
publishVoiceContext(entry);
|
|
316
|
+
notifyDiagnostics();
|
|
317
|
+
});
|
|
318
|
+
notifyDiagnostics();
|
|
250
319
|
publishVoiceContext(entry);
|
|
251
320
|
entry.chat = ctx.uiConversation.binding(sessionId).target('chat');
|
|
252
321
|
entry.chatListeners = new Set();
|
|
@@ -363,6 +432,34 @@ export function apply(ctx) {
|
|
|
363
432
|
);
|
|
364
433
|
};
|
|
365
434
|
}
|
|
435
|
+
entry.meeting = new MeetingController({
|
|
436
|
+
composer: entry.controller.composer,
|
|
437
|
+
settings: () => entry.controller.getSnapshot().settings,
|
|
438
|
+
translate: (key) => t('dsh-live-voice.meeting.' + key),
|
|
439
|
+
createSource: (source, settings) => {
|
|
440
|
+
const meter = source === 'shared' ? new SharedAudioMeter() : new MicrophoneMeter();
|
|
441
|
+
return { meter, engine: recognitionFor(settings, meter) };
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
entry.startMeetingSource = (source) => {
|
|
445
|
+
try {
|
|
446
|
+
const request = requestSharedAudio();
|
|
447
|
+
request.catch(() => {});
|
|
448
|
+
void entry.meeting.start(source, request);
|
|
449
|
+
} catch {
|
|
450
|
+
entry.meeting.patch(source, { error: t('dsh-live-voice.meeting.failed') });
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
let microphoneActive = false;
|
|
454
|
+
const unsubscribeMeetingMicrophone = controller.subscribe(() => {
|
|
455
|
+
const state = controller.getSnapshot();
|
|
456
|
+
const next = Boolean((state.listening || state.starting) && !state.muted);
|
|
457
|
+
if (next !== microphoneActive) {
|
|
458
|
+
microphoneActive = next;
|
|
459
|
+
entry.meeting.transcript.setActive('microphone', next);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
entry.unsubscribeMeetingMicrophone = unsubscribeMeetingMicrophone;
|
|
366
463
|
controllers.set(key, entry);
|
|
367
464
|
void preferences.ready.catch(() => {
|
|
368
465
|
if (!entry.closed)
|
|
@@ -415,7 +512,7 @@ export function apply(ctx) {
|
|
|
415
512
|
if (!input || typeof props.inputActions?.setDraft !== 'function') return;
|
|
416
513
|
entry.composers.set(token.current, {
|
|
417
514
|
actions: props.inputActions,
|
|
418
|
-
submitAccelerated: () => {
|
|
515
|
+
submitAccelerated: (accelerated = true) => {
|
|
419
516
|
const active = document.activeElement;
|
|
420
517
|
const editor =
|
|
421
518
|
active?.nodeType === 1 && active.isContentEditable
|
|
@@ -429,7 +526,7 @@ export function apply(ctx) {
|
|
|
429
526
|
new KeyboardEventCtor('keydown', {
|
|
430
527
|
key: 'Enter',
|
|
431
528
|
code: 'Enter',
|
|
432
|
-
ctrlKey:
|
|
529
|
+
ctrlKey: accelerated,
|
|
433
530
|
bubbles: true,
|
|
434
531
|
cancelable: true,
|
|
435
532
|
}),
|
|
@@ -467,7 +564,19 @@ export function apply(ctx) {
|
|
|
467
564
|
function Buttons(props) {
|
|
468
565
|
const entry = useEntry(props.sessionId, 'buttons');
|
|
469
566
|
useComposer(entry, props);
|
|
470
|
-
return entry ?
|
|
567
|
+
return entry ? (
|
|
568
|
+
<>
|
|
569
|
+
<MeetingToggle
|
|
570
|
+
meeting={entry.meeting}
|
|
571
|
+
onToggle={() => {
|
|
572
|
+
const value = entry.meeting.getSnapshot().shared;
|
|
573
|
+
if (value.starting || value.listening) void entry.meeting.stop('shared');
|
|
574
|
+
else entry.startMeetingSource('shared');
|
|
575
|
+
}}
|
|
576
|
+
/>
|
|
577
|
+
<ConversationControls controller={entry.controller} />
|
|
578
|
+
</>
|
|
579
|
+
) : null;
|
|
471
580
|
}
|
|
472
581
|
function Settings() {
|
|
473
582
|
const [controller, setController] = React.useState(null);
|
|
@@ -478,6 +587,7 @@ export function apply(ctx) {
|
|
|
478
587
|
const meter = new MicrophoneMeter();
|
|
479
588
|
const recognition = recognitionFor(settings, meter);
|
|
480
589
|
const c = new VoiceCoordinator({
|
|
590
|
+
persistSettings: (next) => savePreferences(c, next),
|
|
481
591
|
recognition,
|
|
482
592
|
engines: {
|
|
483
593
|
browser,
|
|
@@ -510,6 +620,8 @@ export function apply(ctx) {
|
|
|
510
620
|
const update = c.updateSettings.bind(c);
|
|
511
621
|
c.applySavedSettings = update;
|
|
512
622
|
settingsControllers.add(c);
|
|
623
|
+
const unsubscribeDiagnostics = c.subscribe(notifyDiagnostics);
|
|
624
|
+
notifyDiagnostics();
|
|
513
625
|
void preferences.ready.catch(() =>
|
|
514
626
|
c.patch({ error: t('dsh-live-voice.settings.persistence.loadError') }),
|
|
515
627
|
);
|
|
@@ -527,7 +639,9 @@ export function apply(ctx) {
|
|
|
527
639
|
const [savedSettings] = await Promise.all([
|
|
528
640
|
saved,
|
|
529
641
|
c.endConversation(),
|
|
530
|
-
...active.map((entry) =>
|
|
642
|
+
...active.map((entry) =>
|
|
643
|
+
Promise.all([entry.controller.endConversation(), entry.meeting.end()]),
|
|
644
|
+
),
|
|
531
645
|
]);
|
|
532
646
|
if (revision !== settingsRevision || disposed || c.disposed) return;
|
|
533
647
|
for (const entry of controllers.values()) {
|
|
@@ -546,7 +660,9 @@ export function apply(ctx) {
|
|
|
546
660
|
refresh();
|
|
547
661
|
return () => {
|
|
548
662
|
document.removeEventListener('dsh-live-voice:capabilitieschanged', refresh);
|
|
663
|
+
unsubscribeDiagnostics();
|
|
549
664
|
settingsControllers.delete(c);
|
|
665
|
+
notifyDiagnostics();
|
|
550
666
|
void c.dispose();
|
|
551
667
|
};
|
|
552
668
|
}, []);
|
|
@@ -556,7 +672,13 @@ export function apply(ctx) {
|
|
|
556
672
|
function Dock(props) {
|
|
557
673
|
const entry = useEntry(props.sessionId, 'dock');
|
|
558
674
|
useComposer(entry, props);
|
|
559
|
-
return entry ?
|
|
675
|
+
return entry ? (
|
|
676
|
+
<div className="dlv-bar-stack">
|
|
677
|
+
<SpeechStatusBar controller={entry.controller} />
|
|
678
|
+
<MeetingBars meeting={entry.meeting} />
|
|
679
|
+
<ConversationStatusBar controller={entry.controller} includeSpeech={false} />
|
|
680
|
+
</div>
|
|
681
|
+
) : null;
|
|
560
682
|
}
|
|
561
683
|
function QuestionStatusView({ entry }) {
|
|
562
684
|
const snapshot = React.useSyncExternalStore(
|
|
@@ -647,8 +769,10 @@ export function apply(ctx) {
|
|
|
647
769
|
ctx.effect(() => {
|
|
648
770
|
const stop = () => {
|
|
649
771
|
ownership.cancel();
|
|
650
|
-
for (const entry of controllers.values())
|
|
772
|
+
for (const entry of controllers.values()) {
|
|
651
773
|
run(entry.controller, entry.controller.endConversation());
|
|
774
|
+
void entry.meeting.end();
|
|
775
|
+
}
|
|
652
776
|
};
|
|
653
777
|
let holdToTalk = null;
|
|
654
778
|
const candidate = () => {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Preserve browser selection around DSH's synchronous, whole-draft editor write.
|
|
2
|
+
* Selection changes are published back through the editor's normal selectionchange bridge.
|
|
3
|
+
* Capture offsets, not live Ranges: setDraft replaces the underlying text nodes.
|
|
4
|
+
*/
|
|
5
|
+
export function preserveComposerSelection(write: () => void, doc: Document = document) {
|
|
6
|
+
const active = doc.activeElement as HTMLElement | null;
|
|
7
|
+
const selection = doc.getSelection();
|
|
8
|
+
const editable = active?.closest<HTMLElement>('[contenteditable="true"]');
|
|
9
|
+
const ownsSelection =
|
|
10
|
+
editable &&
|
|
11
|
+
selection?.anchorNode &&
|
|
12
|
+
selection.focusNode &&
|
|
13
|
+
editable.contains(selection.anchorNode) &&
|
|
14
|
+
editable.contains(selection.focusNode);
|
|
15
|
+
const offset = (node: Node, at: number) => {
|
|
16
|
+
const range = doc.createRange();
|
|
17
|
+
range.selectNodeContents(editable!);
|
|
18
|
+
range.setEnd(node, at);
|
|
19
|
+
return range.toString().length;
|
|
20
|
+
};
|
|
21
|
+
const anchor = ownsSelection ? offset(selection!.anchorNode!, selection!.anchorOffset) : null;
|
|
22
|
+
const focus = ownsSelection ? offset(selection!.focusNode!, selection!.focusOffset) : null;
|
|
23
|
+
const outside =
|
|
24
|
+
!ownsSelection && selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null;
|
|
25
|
+
const input = active as HTMLInputElement | HTMLTextAreaElement | null;
|
|
26
|
+
const inputSelection =
|
|
27
|
+
input && typeof input.selectionStart === 'number'
|
|
28
|
+
? {
|
|
29
|
+
start: input.selectionStart,
|
|
30
|
+
end: input.selectionEnd!,
|
|
31
|
+
direction: input.selectionDirection!,
|
|
32
|
+
}
|
|
33
|
+
: null;
|
|
34
|
+
const scrollTop = active?.scrollTop,
|
|
35
|
+
scrollLeft = active?.scrollLeft;
|
|
36
|
+
const bookmark = (node: Node, at: number) => {
|
|
37
|
+
const path: number[] = [];
|
|
38
|
+
let child = node;
|
|
39
|
+
while (child !== editable && child.parentNode) {
|
|
40
|
+
path.unshift(Array.prototype.indexOf.call(child.parentNode.childNodes, child));
|
|
41
|
+
child = child.parentNode;
|
|
42
|
+
}
|
|
43
|
+
return { path, at, type: node.nodeType, text: node.textContent };
|
|
44
|
+
};
|
|
45
|
+
const anchorMark = ownsSelection
|
|
46
|
+
? bookmark(selection!.anchorNode!, selection!.anchorOffset)
|
|
47
|
+
: null;
|
|
48
|
+
const focusMark = ownsSelection ? bookmark(selection!.focusNode!, selection!.focusOffset) : null;
|
|
49
|
+
write();
|
|
50
|
+
if (active?.isConnected && doc.activeElement !== active) active.focus({ preventScroll: true });
|
|
51
|
+
if (ownsSelection && editable!.isConnected) {
|
|
52
|
+
const point = (at: number): [Node, number] => {
|
|
53
|
+
const walker = doc.createTreeWalker(editable!, 4);
|
|
54
|
+
let node: Node | null;
|
|
55
|
+
let last: Node = editable!;
|
|
56
|
+
while ((node = walker.nextNode())) {
|
|
57
|
+
last = node;
|
|
58
|
+
const length = node.textContent?.length ?? 0;
|
|
59
|
+
if (at <= length) return [node, at];
|
|
60
|
+
at -= length;
|
|
61
|
+
}
|
|
62
|
+
return [last, last === editable ? last.childNodes.length : (last.textContent?.length ?? 0)];
|
|
63
|
+
};
|
|
64
|
+
const restorePoint = (mark: NonNullable<typeof anchorMark>, at: number): [Node, number] => {
|
|
65
|
+
let node: Node | undefined = editable!;
|
|
66
|
+
for (const index of mark.path) node = node?.childNodes[index];
|
|
67
|
+
// Preserve paragraph/empty-line boundaries when the rebuilt structure matches.
|
|
68
|
+
if (
|
|
69
|
+
node &&
|
|
70
|
+
node.nodeType === mark.type &&
|
|
71
|
+
(node.nodeType !== 3 || node.textContent?.startsWith(mark.text ?? '')) &&
|
|
72
|
+
mark.at <= (node.nodeType === 3 ? node.textContent!.length : node.childNodes.length)
|
|
73
|
+
)
|
|
74
|
+
return [node, mark.at];
|
|
75
|
+
return point(at);
|
|
76
|
+
};
|
|
77
|
+
const [a, ao] = restorePoint(anchorMark!, anchor!);
|
|
78
|
+
const [f, fo] = restorePoint(focusMark!, focus!);
|
|
79
|
+
selection!.setBaseAndExtent(a, ao, f, fo);
|
|
80
|
+
// Let Lexical adopt the restored DOM selection instead of retaining selectEnd().
|
|
81
|
+
doc.dispatchEvent(new doc.defaultView!.Event('selectionchange'));
|
|
82
|
+
} else if (inputSelection && active?.isConnected) {
|
|
83
|
+
input!.setSelectionRange(inputSelection.start, inputSelection.end, inputSelection.direction);
|
|
84
|
+
} else if (outside?.startContainer.isConnected && outside.endContainer.isConnected) {
|
|
85
|
+
selection?.removeAllRanges();
|
|
86
|
+
selection?.addRange(outside);
|
|
87
|
+
}
|
|
88
|
+
if (active?.isConnected) {
|
|
89
|
+
active.scrollTop = scrollTop!;
|
|
90
|
+
active.scrollLeft = scrollLeft!;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -23,12 +23,14 @@ export interface LiveVoiceTranslation extends Translation {
|
|
|
23
23
|
'dsh-live-voice.commons.input.listening': string;
|
|
24
24
|
'dsh-live-voice.commons.input.listeningBadge': string;
|
|
25
25
|
'dsh-live-voice.commons.manual': string;
|
|
26
|
+
'dsh-live-voice.commons.milliseconds': string;
|
|
26
27
|
'dsh-live-voice.commons.off': string;
|
|
27
28
|
'dsh-live-voice.commons.on': string;
|
|
28
29
|
'dsh-live-voice.commons.pluginName': string;
|
|
29
30
|
'dsh-live-voice.commons.queue': string;
|
|
30
31
|
'dsh-live-voice.commons.repository.starLabel': string;
|
|
31
32
|
'dsh-live-voice.commons.repository.starLink': string;
|
|
33
|
+
'dsh-live-voice.commons.second': string;
|
|
32
34
|
'dsh-live-voice.commons.seconds': string;
|
|
33
35
|
'dsh-live-voice.commons.send': string;
|
|
34
36
|
'dsh-live-voice.commons.status.ready': string;
|
|
@@ -43,6 +45,24 @@ export interface LiveVoiceTranslation extends Translation {
|
|
|
43
45
|
'dsh-live-voice.commons.version.label': string;
|
|
44
46
|
'dsh-live-voice.commons.version.link': string;
|
|
45
47
|
'dsh-live-voice.commons.version.title': string;
|
|
48
|
+
'dsh-live-voice.meeting.failed': string;
|
|
49
|
+
'dsh-live-voice.meeting.help': string;
|
|
50
|
+
'dsh-live-voice.meeting.httpRequired': string;
|
|
51
|
+
'dsh-live-voice.meeting.inactive': string;
|
|
52
|
+
'dsh-live-voice.meeting.listening': string;
|
|
53
|
+
'dsh-live-voice.meeting.microphone': string;
|
|
54
|
+
'dsh-live-voice.meeting.noAudio': string;
|
|
55
|
+
'dsh-live-voice.meeting.processing': string;
|
|
56
|
+
'dsh-live-voice.meeting.shared': string;
|
|
57
|
+
'dsh-live-voice.meeting.start': string;
|
|
58
|
+
'dsh-live-voice.meeting.startShared': string;
|
|
59
|
+
'dsh-live-voice.meeting.starting': string;
|
|
60
|
+
'dsh-live-voice.meeting.stop': string;
|
|
61
|
+
'dsh-live-voice.meeting.stopShared': string;
|
|
62
|
+
'dsh-live-voice.meeting.timestamps': string;
|
|
63
|
+
'dsh-live-voice.meeting.timestampsBadge': string;
|
|
64
|
+
'dsh-live-voice.meeting.title': string;
|
|
65
|
+
'dsh-live-voice.meeting.unavailable': string;
|
|
46
66
|
'dsh-live-voice.recognition.autoSend.countdownHelp': string;
|
|
47
67
|
'dsh-live-voice.recognition.browser.autoInstallPack': string;
|
|
48
68
|
'dsh-live-voice.recognition.browser.help': string;
|
|
@@ -131,6 +151,10 @@ export interface LiveVoiceTranslation extends Translation {
|
|
|
131
151
|
'dsh-live-voice.settings.autoSend.countdown': string;
|
|
132
152
|
'dsh-live-voice.settings.autoSend.delay': string;
|
|
133
153
|
'dsh-live-voice.settings.close': string;
|
|
154
|
+
'dsh-live-voice.settings.delivery.dshEnterHelp': string;
|
|
155
|
+
'dsh-live-voice.settings.delivery.dshEnterQuestion': string;
|
|
156
|
+
'dsh-live-voice.settings.delivery.dshEnterQueue': string;
|
|
157
|
+
'dsh-live-voice.settings.delivery.dshEnterSteer': string;
|
|
134
158
|
'dsh-live-voice.settings.delivery.label': string;
|
|
135
159
|
'dsh-live-voice.settings.delivery.manualLabel': string;
|
|
136
160
|
'dsh-live-voice.settings.delivery.queueLabel': string;
|
|
@@ -153,6 +177,8 @@ export interface LiveVoiceTranslation extends Translation {
|
|
|
153
177
|
'dsh-live-voice.speak.agentContext.help': string;
|
|
154
178
|
'dsh-live-voice.speak.agentContext.label': string;
|
|
155
179
|
'dsh-live-voice.speak.agentContext.restore': string;
|
|
180
|
+
'dsh-live-voice.speak.autoPlayback.disable': string;
|
|
181
|
+
'dsh-live-voice.speak.autoPlayback.enable': string;
|
|
156
182
|
'dsh-live-voice.speak.autoPlayback.enabled': string;
|
|
157
183
|
'dsh-live-voice.speak.autoPlayback.help': string;
|
|
158
184
|
'dsh-live-voice.speak.autoPlayback.label': string;
|
|
@@ -164,6 +190,11 @@ export interface LiveVoiceTranslation extends Translation {
|
|
|
164
190
|
'dsh-live-voice.speak.browser.name': string;
|
|
165
191
|
'dsh-live-voice.speak.browser.outputHelp': string;
|
|
166
192
|
'dsh-live-voice.speak.browser.voice': string;
|
|
193
|
+
'dsh-live-voice.speak.captions.approximate': string;
|
|
194
|
+
'dsh-live-voice.speak.captions.loading': string;
|
|
195
|
+
'dsh-live-voice.speak.captions.position': string;
|
|
196
|
+
'dsh-live-voice.speak.captions.progress': string;
|
|
197
|
+
'dsh-live-voice.speak.captions.title': string;
|
|
167
198
|
'dsh-live-voice.speak.engine.label': string;
|
|
168
199
|
'dsh-live-voice.speak.engine.playbackHelp': string;
|
|
169
200
|
'dsh-live-voice.speak.filters.code.enabled': string;
|
|
@@ -186,6 +217,7 @@ export interface LiveVoiceTranslation extends Translation {
|
|
|
186
217
|
'dsh-live-voice.speak.playback.message': string;
|
|
187
218
|
'dsh-live-voice.speak.playback.next': string;
|
|
188
219
|
'dsh-live-voice.speak.playback.pause': string;
|
|
220
|
+
'dsh-live-voice.speak.playback.previous': string;
|
|
189
221
|
'dsh-live-voice.speak.playback.resume': string;
|
|
190
222
|
'dsh-live-voice.speak.playback.stop': string;
|
|
191
223
|
'dsh-live-voice.speak.playback.stopAll': string;
|
|
@@ -215,6 +247,7 @@ export interface LiveVoiceTranslation extends Translation {
|
|
|
215
247
|
'dsh-live-voice.speak.rate.label': string;
|
|
216
248
|
'dsh-live-voice.speak.responseDelay.help': string;
|
|
217
249
|
'dsh-live-voice.speak.responseDelay.label': string;
|
|
250
|
+
'dsh-live-voice.speak.responseDelay.none': string;
|
|
218
251
|
'dsh-live-voice.speak.segmentGap.help': string;
|
|
219
252
|
'dsh-live-voice.speak.segmentGap.label': string;
|
|
220
253
|
'dsh-live-voice.speak.status.paused': string;
|