dsh-live-voice 0.0.1-developing → 0.1.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/DEVELOPMENT.md +70 -0
- package/HISTORY.md +10 -85
- package/PLAN.md +244 -0
- package/README.md +88 -96
- package/cordis.patch.yml +3 -0
- package/lib/client.js +3015 -0
- package/lib/server.js +1006 -0
- package/package.json +69 -6
- package/scripts/build.ts +50 -0
- package/scripts/check-dist.mjs +38 -0
- package/scripts/preview-ui.ts +31 -0
- package/scripts/probe-browser.ts +46 -0
- package/src/client/chat.ts +40 -0
- package/src/client/components.ts +778 -0
- package/src/client/index.ts +430 -0
- package/src/client/qwen-settings.ts +144 -0
- package/src/client/styles.ts +36 -0
- package/src/client/whisper-settings.ts +147 -0
- package/src/core/coordinator.ts +564 -0
- package/src/core/microphone.ts +171 -0
- package/src/core/ownership.ts +31 -0
- package/src/core/settings.ts +113 -0
- package/src/core/transcript.ts +48 -0
- package/src/engines/qwen-http-host.ts +240 -0
- package/src/engines/recognition/browser.ts +314 -0
- package/src/engines/recognition/qwen-http.ts +36 -0
- package/src/engines/recognition/whisper-http-host.ts +210 -0
- package/src/engines/recognition/whisper-http.ts +191 -0
- package/src/engines/speaking/browser.ts +136 -0
- package/src/engines/speaking/qwen-http.ts +119 -0
- package/src/engines/speaking/say-client.ts +96 -0
- package/src/engines/speaking/say.ts +271 -0
- package/src/server.ts +365 -0
- package/AGENTS.md +0 -52
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { VoiceCoordinator } from '../core/coordinator.ts';
|
|
4
|
+
import { VoiceOwnership } from '../core/ownership.ts';
|
|
5
|
+
import { normalizeSettings } from '../core/settings.ts';
|
|
6
|
+
import { MicrophoneMeter } from '../core/microphone.ts';
|
|
7
|
+
import { BrowserSpeakingEngine } from '../engines/speaking/browser.ts';
|
|
8
|
+
import { SayClientEngine } from '../engines/speaking/say-client.ts';
|
|
9
|
+
import { BrowserRecognitionEngine } from '../engines/recognition/browser.ts';
|
|
10
|
+
import { WhisperHttpRecognitionEngine } from '../engines/recognition/whisper-http.ts';
|
|
11
|
+
import { QwenHttpRecognitionEngine } from '../engines/recognition/qwen-http.ts';
|
|
12
|
+
import { QwenHttpSpeakingEngine } from '../engines/speaking/qwen-http.ts';
|
|
13
|
+
import { createComponents } from './components.ts';
|
|
14
|
+
import { styles } from './styles.ts';
|
|
15
|
+
import { assistantMessages, addressedTurn, latestUserSequence } from './chat.ts';
|
|
16
|
+
|
|
17
|
+
export const inject = ['slots', 'connection', 'uiConversation'];
|
|
18
|
+
export function apply(ctx) {
|
|
19
|
+
const e = React.createElement;
|
|
20
|
+
const { MicrophoneButtons, RecordingBar, SpeakButton, SettingsPanel } = createComponents(React);
|
|
21
|
+
const controllers = new Map();
|
|
22
|
+
const retiring = new Set();
|
|
23
|
+
let disposed = false;
|
|
24
|
+
const ownership = new VoiceOwnership();
|
|
25
|
+
const recognitionFor = (settings, meter) =>
|
|
26
|
+
settings.recognitionEngine === 'qwen-http'
|
|
27
|
+
? new QwenHttpRecognitionEngine({
|
|
28
|
+
meter,
|
|
29
|
+
voiceDetectionPreset: settings.voiceDetectionPreset,
|
|
30
|
+
})
|
|
31
|
+
: settings.recognitionEngine === 'whisper-http'
|
|
32
|
+
? new WhisperHttpRecognitionEngine({
|
|
33
|
+
meter,
|
|
34
|
+
voiceDetectionPreset: settings.voiceDetectionPreset,
|
|
35
|
+
})
|
|
36
|
+
: new BrowserRecognitionEngine({
|
|
37
|
+
processLocally: settings.recognitionProcessLocally,
|
|
38
|
+
autoInstallLocalPack: settings.recognitionAutoInstall,
|
|
39
|
+
});
|
|
40
|
+
const run = (controller, promise) =>
|
|
41
|
+
Promise.resolve(promise).catch((error) => {
|
|
42
|
+
if (!disposed && !controller.disposed)
|
|
43
|
+
controller.patch({ error: error?.message || String(error) });
|
|
44
|
+
});
|
|
45
|
+
function retire(entry) {
|
|
46
|
+
if (entry.closed) return;
|
|
47
|
+
entry.closed = true;
|
|
48
|
+
entry.request++;
|
|
49
|
+
entry.composers.clear();
|
|
50
|
+
entry.unsubscribe?.();
|
|
51
|
+
entry.unsubscribe = null;
|
|
52
|
+
entry.chatListeners.clear();
|
|
53
|
+
if (controllers.get(entry.key) === entry) controllers.delete(entry.key);
|
|
54
|
+
// Keep teardown in the hardware handoff barrier, not in the session registry.
|
|
55
|
+
const done = run(entry.controller, entry.controller.dispose());
|
|
56
|
+
const barrier = { endConversation: () => done };
|
|
57
|
+
retiring.add(barrier);
|
|
58
|
+
void done.finally(() => retiring.delete(barrier));
|
|
59
|
+
}
|
|
60
|
+
function get(sessionId) {
|
|
61
|
+
const key = String(sessionId);
|
|
62
|
+
if (controllers.has(key)) return controllers.get(key);
|
|
63
|
+
const entry = {
|
|
64
|
+
key,
|
|
65
|
+
draft: '',
|
|
66
|
+
pendingDraft: undefined,
|
|
67
|
+
composers: new Map(),
|
|
68
|
+
refs: 0,
|
|
69
|
+
buttons: 0,
|
|
70
|
+
request: 0,
|
|
71
|
+
closed: false,
|
|
72
|
+
};
|
|
73
|
+
let settings = {};
|
|
74
|
+
try {
|
|
75
|
+
settings = normalizeSettings(
|
|
76
|
+
JSON.parse(localStorage.getItem('dsh-live-voice.settings') || '{}'),
|
|
77
|
+
);
|
|
78
|
+
} catch {
|
|
79
|
+
settings = normalizeSettings(null);
|
|
80
|
+
}
|
|
81
|
+
const engineBrowser = new BrowserSpeakingEngine({ lang: settings.lang || 'pt-BR' });
|
|
82
|
+
const engineSay = new SayClientEngine({ rpc: ctx.connection.rpc });
|
|
83
|
+
const engineQwen = new QwenHttpSpeakingEngine({ lang: settings.lang || 'pt-BR' });
|
|
84
|
+
const meter = new MicrophoneMeter();
|
|
85
|
+
const recognition = recognitionFor(settings, meter);
|
|
86
|
+
entry.controller = new VoiceCoordinator({
|
|
87
|
+
recognition,
|
|
88
|
+
engines: { browser: engineBrowser, say: engineSay, 'qwen-http': engineQwen },
|
|
89
|
+
meter,
|
|
90
|
+
composer: {
|
|
91
|
+
getDraft: () => entry.draft,
|
|
92
|
+
submit: () => {
|
|
93
|
+
const owner = [...entry.composers.values()].at(-1);
|
|
94
|
+
owner?.actions.submit?.();
|
|
95
|
+
},
|
|
96
|
+
setDraft: (text) => {
|
|
97
|
+
if (disposed || entry.closed) return;
|
|
98
|
+
const owner = [...entry.composers.values()].at(-1);
|
|
99
|
+
if (!owner) return;
|
|
100
|
+
// `setDraft()` updates Lexical synchronously but the subscribed InputState
|
|
101
|
+
// can publish on a subsequent React commit. Preserve the optimistic value
|
|
102
|
+
// until that exact publication arrives; otherwise another slot render can
|
|
103
|
+
// make a later recognition result start from stale text.
|
|
104
|
+
entry.draft = text;
|
|
105
|
+
entry.pendingDraft = text;
|
|
106
|
+
owner.actions.setDraft(text);
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
settings,
|
|
110
|
+
});
|
|
111
|
+
const controller = entry.controller;
|
|
112
|
+
entry.chat = ctx.uiConversation.binding(sessionId).target('chat');
|
|
113
|
+
entry.chatListeners = new Set();
|
|
114
|
+
entry.subscribeChat = (listener) => {
|
|
115
|
+
if (entry.closed) return () => {};
|
|
116
|
+
entry.chatListeners.add(listener);
|
|
117
|
+
return () => entry.chatListeners.delete(listener);
|
|
118
|
+
};
|
|
119
|
+
entry.readChat = entry.chat.getSnapshot.bind(entry.chat);
|
|
120
|
+
const refresh = (baseline = false) => {
|
|
121
|
+
if (disposed || entry.closed) return;
|
|
122
|
+
const snapshot = entry.readChat();
|
|
123
|
+
const userSeq = latestUserSequence(snapshot);
|
|
124
|
+
if (
|
|
125
|
+
!baseline &&
|
|
126
|
+
userSeq > entry.userSeq &&
|
|
127
|
+
controller.getSnapshot().settings.interruptSpeechOnUserMessage &&
|
|
128
|
+
(controller.getSnapshot().speaking || controller.getSnapshot().paused)
|
|
129
|
+
)
|
|
130
|
+
run(controller, controller.stopSpeech());
|
|
131
|
+
entry.userSeq = Math.max(entry.userSeq ?? -1, userSeq);
|
|
132
|
+
for (const message of assistantMessages(snapshot))
|
|
133
|
+
controller.observeMessage(message.id, message.text, {
|
|
134
|
+
complete: message.complete,
|
|
135
|
+
baseline: baseline || message.interrupted,
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
refresh(true);
|
|
139
|
+
entry.unsubscribe = entry.chat.subscribe(() => {
|
|
140
|
+
refresh();
|
|
141
|
+
for (const listener of entry.chatListeners) listener();
|
|
142
|
+
});
|
|
143
|
+
const update = controller.updateSettings.bind(controller);
|
|
144
|
+
controller.updateSettings = (next) => {
|
|
145
|
+
if (disposed || entry.closed) return;
|
|
146
|
+
update(next);
|
|
147
|
+
engineBrowser.lang = controller.getSnapshot().settings.lang;
|
|
148
|
+
engineQwen.lang = controller.getSnapshot().settings.lang;
|
|
149
|
+
try {
|
|
150
|
+
localStorage.setItem(
|
|
151
|
+
'dsh-live-voice.settings',
|
|
152
|
+
JSON.stringify(controller.getSnapshot().settings),
|
|
153
|
+
);
|
|
154
|
+
} catch {}
|
|
155
|
+
run(controller, controller.refreshCapabilities());
|
|
156
|
+
};
|
|
157
|
+
// Cancel only this entry's queued acquisition. Global cancellation here would
|
|
158
|
+
// invalidate a newer session while its predecessor is being unmounted.
|
|
159
|
+
for (const method of ['stopListening', 'cancelDictation', 'endConversation', 'stopSpeech']) {
|
|
160
|
+
const original = controller[method].bind(controller);
|
|
161
|
+
controller[method] = (...args) => {
|
|
162
|
+
entry.request++;
|
|
163
|
+
return original(...args);
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
for (const method of ['startDictation', 'startConversation', 'speak']) {
|
|
167
|
+
const original = controller[method].bind(controller);
|
|
168
|
+
controller[method] = (...args) => {
|
|
169
|
+
if (disposed || entry.closed || !entry.refs) return Promise.resolve();
|
|
170
|
+
if (method !== 'speak' && !entry.composers.size) return Promise.resolve();
|
|
171
|
+
const request = ++entry.request;
|
|
172
|
+
return ownership.run(
|
|
173
|
+
controller,
|
|
174
|
+
[...controllers.values()].map((other) => other.controller).concat([...retiring]),
|
|
175
|
+
() => {
|
|
176
|
+
if (disposed || entry.closed || !entry.refs || request !== entry.request) return;
|
|
177
|
+
if (method !== 'speak' && !entry.composers.size) return;
|
|
178
|
+
return original(...args);
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
controllers.set(key, entry);
|
|
184
|
+
run(controller, controller.refreshCapabilities());
|
|
185
|
+
return entry;
|
|
186
|
+
}
|
|
187
|
+
function useEntry(sessionId, kind) {
|
|
188
|
+
const [entry, setEntry] = React.useState(null);
|
|
189
|
+
React.useLayoutEffect(() => {
|
|
190
|
+
if (disposed) return;
|
|
191
|
+
// Allocation and subscriptions happen only for committed mounts. Suspended
|
|
192
|
+
// or abandoned renders never acquire a session, probe engines, or retain it.
|
|
193
|
+
const current = get(sessionId);
|
|
194
|
+
current.refs++;
|
|
195
|
+
if (kind === 'buttons') current.buttons++;
|
|
196
|
+
setEntry(current);
|
|
197
|
+
return () => {
|
|
198
|
+
current.refs--;
|
|
199
|
+
if (kind === 'buttons') current.buttons--;
|
|
200
|
+
if (!current.refs) current.request++;
|
|
201
|
+
// StrictMode replay reacquires the same entry before this microtask.
|
|
202
|
+
queueMicrotask(() => {
|
|
203
|
+
if (!current.refs) retire(current);
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
}, [sessionId, kind]);
|
|
207
|
+
return entry?.key === String(sessionId) && !entry.closed ? entry : null;
|
|
208
|
+
}
|
|
209
|
+
function useComposer(entry, props) {
|
|
210
|
+
// DSH publishes shell.state as useInput and shell.actions as inputActions.
|
|
211
|
+
// Subscribe even when an embedding also supplies an input snapshot: that
|
|
212
|
+
// snapshot is not the reactive owner of the shell-owned editor.
|
|
213
|
+
const subscribedInput = props.useInput?.((value) => value);
|
|
214
|
+
const input = subscribedInput ?? props.input;
|
|
215
|
+
const token = React.useRef({});
|
|
216
|
+
React.useLayoutEffect(() => {
|
|
217
|
+
if (!entry || entry.closed || disposed) return;
|
|
218
|
+
return () => {
|
|
219
|
+
entry.composers.delete(token.current);
|
|
220
|
+
// Message-action mounts may outlive the composer; never retain its actions.
|
|
221
|
+
if (!entry.composers.size) {
|
|
222
|
+
entry.request++;
|
|
223
|
+
queueMicrotask(() => {
|
|
224
|
+
if (!entry.closed && !entry.composers.size)
|
|
225
|
+
run(entry.controller, entry.controller.endConversation());
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}, [entry]);
|
|
230
|
+
React.useLayoutEffect(() => {
|
|
231
|
+
if (!entry || entry.closed || disposed) return;
|
|
232
|
+
if (!input || typeof props.inputActions?.setDraft !== 'function') return;
|
|
233
|
+
entry.composers.set(token.current, { actions: props.inputActions });
|
|
234
|
+
}, [entry, input, props.inputActions]);
|
|
235
|
+
React.useLayoutEffect(() => {
|
|
236
|
+
if (!entry || entry.closed || disposed || !input) return;
|
|
237
|
+
const published = typeof input.draft === 'string' ? input.draft : '';
|
|
238
|
+
// A voice write is optimistic until React commits the editor publication.
|
|
239
|
+
// Unrelated slot renders must not restore the previous draft in between.
|
|
240
|
+
if (entry.pendingDraft === undefined) entry.draft = published;
|
|
241
|
+
else if (published === entry.pendingDraft) {
|
|
242
|
+
entry.draft = published;
|
|
243
|
+
entry.pendingDraft = undefined;
|
|
244
|
+
}
|
|
245
|
+
entry.controller.composerChanged(entry.draft);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
function Buttons(props) {
|
|
249
|
+
const entry = useEntry(props.sessionId, 'buttons');
|
|
250
|
+
useComposer(entry, props);
|
|
251
|
+
return entry ? e(MicrophoneButtons, { controller: entry.controller }) : null;
|
|
252
|
+
}
|
|
253
|
+
function Settings() {
|
|
254
|
+
const [controller, setController] = React.useState(null);
|
|
255
|
+
React.useEffect(() => {
|
|
256
|
+
let settings;
|
|
257
|
+
try {
|
|
258
|
+
settings = normalizeSettings(
|
|
259
|
+
JSON.parse(localStorage.getItem('dsh-live-voice.settings') || '{}'),
|
|
260
|
+
);
|
|
261
|
+
} catch {
|
|
262
|
+
settings = normalizeSettings(null);
|
|
263
|
+
}
|
|
264
|
+
const browser = new BrowserSpeakingEngine({ lang: settings.lang });
|
|
265
|
+
const qwen = new QwenHttpSpeakingEngine({ lang: settings.lang });
|
|
266
|
+
const meter = new MicrophoneMeter();
|
|
267
|
+
const recognition = recognitionFor(settings, meter);
|
|
268
|
+
const c = new VoiceCoordinator({
|
|
269
|
+
recognition,
|
|
270
|
+
engines: {
|
|
271
|
+
browser,
|
|
272
|
+
say: new SayClientEngine({ rpc: ctx.connection.rpc }),
|
|
273
|
+
'qwen-http': qwen,
|
|
274
|
+
},
|
|
275
|
+
meter,
|
|
276
|
+
composer: { getDraft: () => '', setDraft: () => {} },
|
|
277
|
+
settings,
|
|
278
|
+
});
|
|
279
|
+
const speak = c.speak.bind(c);
|
|
280
|
+
c.speak = (...args) =>
|
|
281
|
+
ownership.run(
|
|
282
|
+
c,
|
|
283
|
+
[...controllers.values()].map((entry) => entry.controller).concat([...retiring]),
|
|
284
|
+
() => speak(...args),
|
|
285
|
+
);
|
|
286
|
+
const update = c.updateSettings.bind(c);
|
|
287
|
+
let settingsRevision = 0;
|
|
288
|
+
c.updateSettings = async (next) => {
|
|
289
|
+
const revision = ++settingsRevision;
|
|
290
|
+
const previousEngine = c.getSnapshot().settings.recognitionEngine;
|
|
291
|
+
update(next);
|
|
292
|
+
browser.lang = c.getSnapshot().settings.lang;
|
|
293
|
+
qwen.lang = c.getSnapshot().settings.lang;
|
|
294
|
+
const settings = c.getSnapshot().settings;
|
|
295
|
+
if (previousEngine !== settings.recognitionEngine)
|
|
296
|
+
c.replaceRecognition(recognitionFor(settings, c.meter));
|
|
297
|
+
localStorage.setItem('dsh-live-voice.settings', JSON.stringify(settings));
|
|
298
|
+
run(c, c.refreshCapabilities());
|
|
299
|
+
const active = [...controllers.values()];
|
|
300
|
+
await Promise.allSettled([
|
|
301
|
+
c.endConversation(),
|
|
302
|
+
...active.map((entry) => entry.controller.endConversation()),
|
|
303
|
+
]);
|
|
304
|
+
if (revision !== settingsRevision || disposed || c.disposed) return;
|
|
305
|
+
for (const entry of controllers.values()) {
|
|
306
|
+
if (entry.closed) continue;
|
|
307
|
+
if (
|
|
308
|
+
entry.controller.getSnapshot().settings.recognitionEngine !== settings.recognitionEngine
|
|
309
|
+
)
|
|
310
|
+
entry.controller.replaceRecognition(recognitionFor(settings, entry.controller.meter));
|
|
311
|
+
entry.controller.updateSettings(settings);
|
|
312
|
+
run(entry.controller, entry.controller.refreshCapabilities());
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
const refresh = () => run(c, c.refreshCapabilities());
|
|
316
|
+
document.addEventListener('dsh-live-voice:capabilitieschanged', refresh);
|
|
317
|
+
setController(c);
|
|
318
|
+
refresh();
|
|
319
|
+
return () => {
|
|
320
|
+
document.removeEventListener('dsh-live-voice:capabilitieschanged', refresh);
|
|
321
|
+
void c.dispose();
|
|
322
|
+
};
|
|
323
|
+
}, []);
|
|
324
|
+
return controller ? e(SettingsPanel, { controller }) : null;
|
|
325
|
+
}
|
|
326
|
+
ctx.slots.inject('settings.section', () =>
|
|
327
|
+
ctx.slots.register(
|
|
328
|
+
{ name: 'settings.section', id: 'dsh-live-voice', order: 65, label: 'Live Voice' },
|
|
329
|
+
Settings,
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
function Dock(props) {
|
|
333
|
+
const entry = useEntry(props.sessionId, 'dock');
|
|
334
|
+
useComposer(entry, props);
|
|
335
|
+
return entry ? e(RecordingBar, { controller: entry.controller }) : null;
|
|
336
|
+
}
|
|
337
|
+
function ActionView({ entry, messageId }) {
|
|
338
|
+
const snapshot = React.useSyncExternalStore(
|
|
339
|
+
entry.controller.subscribe,
|
|
340
|
+
entry.controller.getSnapshot,
|
|
341
|
+
);
|
|
342
|
+
const chat = React.useSyncExternalStore(entry.subscribeChat, entry.readChat);
|
|
343
|
+
const message = addressedTurn(assistantMessages(chat), messageId);
|
|
344
|
+
const capability = snapshot.capabilities[snapshot.settings.engine];
|
|
345
|
+
const active = snapshot.speaking && message.id === snapshot.activeMessageId;
|
|
346
|
+
const unavailable = capability?.supported !== true;
|
|
347
|
+
return e(SpeakButton, {
|
|
348
|
+
active,
|
|
349
|
+
disabled: !message.text.trim() || (!active && unavailable),
|
|
350
|
+
label: unavailable ? capability?.reason || 'Checking speech output…' : undefined,
|
|
351
|
+
onClick: () =>
|
|
352
|
+
run(
|
|
353
|
+
entry.controller,
|
|
354
|
+
active ? entry.controller.stopSpeech() : entry.controller.speak(message.text, message.id),
|
|
355
|
+
),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
function Action(props) {
|
|
359
|
+
const entry = useEntry(props.sessionId, 'action');
|
|
360
|
+
return entry ? e(ActionView, { entry, messageId: props.messageId }) : null;
|
|
361
|
+
}
|
|
362
|
+
ctx.effect(() => {
|
|
363
|
+
const style = document.createElement('style');
|
|
364
|
+
style.dataset.plugin = 'dsh-live-voice';
|
|
365
|
+
style.textContent = styles;
|
|
366
|
+
document.head.appendChild(style);
|
|
367
|
+
return () => style.remove();
|
|
368
|
+
});
|
|
369
|
+
for (const [name, id, order, component] of [
|
|
370
|
+
['conversation.input.right', 'live-voice-controls', 6, Buttons],
|
|
371
|
+
['conversation.input.dock', 'live-voice-status', -100, Dock],
|
|
372
|
+
['conversation.chat.assistant-actions', 'live-voice-speak', 5, Action],
|
|
373
|
+
])
|
|
374
|
+
ctx.slots.inject(name, () =>
|
|
375
|
+
ctx.slots.register({ name, id, order, label: 'DSH Live Voice' }, component),
|
|
376
|
+
);
|
|
377
|
+
ctx.effect(() => {
|
|
378
|
+
const stop = () => {
|
|
379
|
+
ownership.cancel();
|
|
380
|
+
for (const entry of controllers.values())
|
|
381
|
+
run(entry.controller, entry.controller.endConversation());
|
|
382
|
+
};
|
|
383
|
+
const onKey = (event) => {
|
|
384
|
+
if (
|
|
385
|
+
disposed ||
|
|
386
|
+
event.defaultPrevented ||
|
|
387
|
+
!event.ctrlKey ||
|
|
388
|
+
!event.shiftKey ||
|
|
389
|
+
event.code !== 'Space' ||
|
|
390
|
+
event.repeat
|
|
391
|
+
)
|
|
392
|
+
return;
|
|
393
|
+
const candidates = [...controllers.values()].filter(
|
|
394
|
+
(entry) => entry.buttons > 0 && entry.composers.size > 0,
|
|
395
|
+
);
|
|
396
|
+
if (candidates.length !== 1) return;
|
|
397
|
+
event.preventDefault();
|
|
398
|
+
const c = candidates[0].controller;
|
|
399
|
+
run(
|
|
400
|
+
c,
|
|
401
|
+
c.getSnapshot().listening || c.getSnapshot().starting
|
|
402
|
+
? c.stopListening()
|
|
403
|
+
: c.startDictation(),
|
|
404
|
+
);
|
|
405
|
+
};
|
|
406
|
+
const refreshCapabilities = () => {
|
|
407
|
+
for (const entry of controllers.values())
|
|
408
|
+
run(entry.controller, entry.controller.refreshCapabilities());
|
|
409
|
+
document.dispatchEvent(new Event('dsh-live-voice:capabilitieschanged'));
|
|
410
|
+
};
|
|
411
|
+
const visibilityChanged = () => {
|
|
412
|
+
if (document.visibilityState === 'visible') refreshCapabilities();
|
|
413
|
+
};
|
|
414
|
+
window.speechSynthesis?.addEventListener?.('voiceschanged', refreshCapabilities);
|
|
415
|
+
navigator.mediaDevices?.addEventListener?.('devicechange', refreshCapabilities);
|
|
416
|
+
document.addEventListener('visibilitychange', visibilityChanged);
|
|
417
|
+
document.addEventListener('keydown', onKey);
|
|
418
|
+
window.addEventListener('pagehide', stop);
|
|
419
|
+
return () => {
|
|
420
|
+
disposed = true;
|
|
421
|
+
ownership.close();
|
|
422
|
+
window.speechSynthesis?.removeEventListener?.('voiceschanged', refreshCapabilities);
|
|
423
|
+
navigator.mediaDevices?.removeEventListener?.('devicechange', refreshCapabilities);
|
|
424
|
+
document.removeEventListener('visibilitychange', visibilityChanged);
|
|
425
|
+
document.removeEventListener('keydown', onKey);
|
|
426
|
+
window.removeEventListener('pagehide', stop);
|
|
427
|
+
for (const entry of controllers.values()) retire(entry);
|
|
428
|
+
};
|
|
429
|
+
});
|
|
430
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
const BASE = '/api/dsh-live-voice/qwen';
|
|
3
|
+
const UNLOADED =
|
|
4
|
+
'Qwen settings routes are not loaded. A normal DSH server restart is required to load updated plugin routes; refreshing this page alone is not enough.';
|
|
5
|
+
export async function qwenSettingsRequest(
|
|
6
|
+
path,
|
|
7
|
+
{ method = 'GET', config, signal } = {},
|
|
8
|
+
fetchImpl = globalThis.fetch,
|
|
9
|
+
) {
|
|
10
|
+
const response = await fetchImpl(BASE + path, {
|
|
11
|
+
method,
|
|
12
|
+
credentials: 'same-origin',
|
|
13
|
+
signal,
|
|
14
|
+
headers: config ? { 'content-type': 'application/json' } : undefined,
|
|
15
|
+
body: config ? JSON.stringify(config) : undefined,
|
|
16
|
+
});
|
|
17
|
+
if (response.status === 401 || response.status === 403)
|
|
18
|
+
throw new Error('Sign in to DSH to manage Qwen settings.');
|
|
19
|
+
if (response.status === 404 || response.status === 405) throw new Error(UNLOADED);
|
|
20
|
+
let body;
|
|
21
|
+
try {
|
|
22
|
+
body = await response.json();
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(UNLOADED);
|
|
25
|
+
}
|
|
26
|
+
if (typeof body?.ok !== 'boolean') throw new Error(UNLOADED);
|
|
27
|
+
if (!response.ok || !body.ok)
|
|
28
|
+
throw new Error(body.error?.message || 'Qwen settings request failed.');
|
|
29
|
+
return body.value;
|
|
30
|
+
}
|
|
31
|
+
export function createQwenSettings(React) {
|
|
32
|
+
const h = React.createElement;
|
|
33
|
+
return function QwenSettings({ controller }) {
|
|
34
|
+
const [draft, setDraft] = React.useState({
|
|
35
|
+
baseUrl: 'http://127.0.0.1:8080/',
|
|
36
|
+
timeoutMs: 300000,
|
|
37
|
+
});
|
|
38
|
+
const [busy, setBusy] = React.useState(true),
|
|
39
|
+
[loaded, setLoaded] = React.useState(false),
|
|
40
|
+
[error, setError] = React.useState(''),
|
|
41
|
+
[message, setMessage] = React.useState('');
|
|
42
|
+
const active = React.useRef(null);
|
|
43
|
+
async function run(action) {
|
|
44
|
+
active.current?.abort();
|
|
45
|
+
const abort = new AbortController();
|
|
46
|
+
active.current = abort;
|
|
47
|
+
setBusy(true);
|
|
48
|
+
setError('');
|
|
49
|
+
setMessage('');
|
|
50
|
+
try {
|
|
51
|
+
if (action === 'load') {
|
|
52
|
+
const value = await qwenSettingsRequest('/config', { signal: abort.signal });
|
|
53
|
+
if (!abort.signal.aborted) {
|
|
54
|
+
setDraft(value);
|
|
55
|
+
setLoaded(true);
|
|
56
|
+
}
|
|
57
|
+
} else if (action === 'save') {
|
|
58
|
+
await controller.endConversation?.();
|
|
59
|
+
const value = await qwenSettingsRequest('/config', {
|
|
60
|
+
method: 'PUT',
|
|
61
|
+
config: { ...draft, timeoutMs: Number(draft.timeoutMs) },
|
|
62
|
+
signal: abort.signal,
|
|
63
|
+
});
|
|
64
|
+
if (!abort.signal.aborted) {
|
|
65
|
+
setDraft(value);
|
|
66
|
+
setMessage('Saved on the DSH host. Active Qwen requests were cancelled.');
|
|
67
|
+
await controller.refreshCapabilities?.();
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
const value = await qwenSettingsRequest('/test', {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
config: { ...draft, timeoutMs: Number(draft.timeoutMs) },
|
|
73
|
+
signal: abort.signal,
|
|
74
|
+
});
|
|
75
|
+
if (!abort.signal.aborted) {
|
|
76
|
+
if (!value.supported) throw new Error(value.reason || 'Qwen health check failed.');
|
|
77
|
+
setMessage(
|
|
78
|
+
'Connection successful. Both Qwen ASR and TTS are loaded. Unsaved edits have not been applied.',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch (reason) {
|
|
83
|
+
if (!abort.signal.aborted) setError(reason.message || String(reason));
|
|
84
|
+
} finally {
|
|
85
|
+
if (!abort.signal.aborted) setBusy(false);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
React.useEffect(() => {
|
|
89
|
+
void run('load');
|
|
90
|
+
return () => active.current?.abort();
|
|
91
|
+
}, []);
|
|
92
|
+
const field = (label, key, type = 'text') =>
|
|
93
|
+
h(
|
|
94
|
+
'label',
|
|
95
|
+
null,
|
|
96
|
+
label,
|
|
97
|
+
h('input', {
|
|
98
|
+
type,
|
|
99
|
+
value: draft[key],
|
|
100
|
+
disabled: busy || !loaded,
|
|
101
|
+
autoComplete: 'off',
|
|
102
|
+
...(type === 'number' ? { min: 1000, max: 600000, step: 1 } : {}),
|
|
103
|
+
onChange: (event) => {
|
|
104
|
+
setDraft({ ...draft, [key]: event.target.value });
|
|
105
|
+
setMessage('Unsaved changes');
|
|
106
|
+
setError('');
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
return h(
|
|
111
|
+
React.Fragment,
|
|
112
|
+
null,
|
|
113
|
+
h(
|
|
114
|
+
'p',
|
|
115
|
+
null,
|
|
116
|
+
'Host-wide settings for the Qwen3 ASR + TTS server. Enter any HTTP or HTTPS base URL reachable from the DSH host. The browser accesses it through authenticated DSH routes.',
|
|
117
|
+
),
|
|
118
|
+
field('Qwen API base URL', 'baseUrl'),
|
|
119
|
+
field('Request timeout (ms)', 'timeoutMs', 'number'),
|
|
120
|
+
h(
|
|
121
|
+
'div',
|
|
122
|
+
{ className: 'dlv-settings-actions' },
|
|
123
|
+
h(
|
|
124
|
+
'button',
|
|
125
|
+
{ type: 'button', disabled: busy || !loaded, onClick: () => run('save') },
|
|
126
|
+
'Save Qwen settings',
|
|
127
|
+
),
|
|
128
|
+
h(
|
|
129
|
+
'button',
|
|
130
|
+
{ type: 'button', disabled: busy || !loaded, onClick: () => run('test') },
|
|
131
|
+
'Test Qwen server',
|
|
132
|
+
),
|
|
133
|
+
h(
|
|
134
|
+
'button',
|
|
135
|
+
{ type: 'button', disabled: busy, onClick: () => run('load') },
|
|
136
|
+
'Reload saved settings',
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
busy ? h('p', { role: 'status' }, 'Contacting DSH host…') : null,
|
|
140
|
+
message ? h('p', { role: 'status' }, message) : null,
|
|
141
|
+
error ? h('p', { role: 'alert' }, error) : null,
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// Independent presentation for the DSH Live Voice controls.
|
|
3
|
+
export const styles = `
|
|
4
|
+
.dlv-icon-button{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;flex-shrink:0;cursor:pointer;background:transparent;color:var(--dsw-alias-label-secondary);padding:0;font:inherit}
|
|
5
|
+
.dlv-icon-button svg{display:block;width:20px;height:20px}
|
|
6
|
+
.dlv-mic{width:30px;height:30px;border:1px solid var(--dsw-alias-border-l1);border-radius:50%}
|
|
7
|
+
.dlv-mic:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}
|
|
8
|
+
.dlv-speaker{width:28px;height:28px;border:0;border-radius:28px;padding:5px;color:var(--dsw-alias-label-tertiary)}
|
|
9
|
+
.dlv-speaker:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
|
|
10
|
+
.dlv-speaker[aria-pressed=true]{color:var(--dsw-alias-label-primary)}
|
|
11
|
+
.dlv-icon-button:disabled{opacity:.4;cursor:default}
|
|
12
|
+
.dlv-icon-button:focus-visible,.dlv-settings :is(input,select):focus-visible{outline:2px solid var(--dsw-alias-label-primary);outline-offset:3px}
|
|
13
|
+
.dlv-bar-wrap{width:100%;min-width:0}
|
|
14
|
+
.dlv-pill{display:flex;align-items:center;box-sizing:border-box;gap:10px;min-height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px rgba(0,0,0,.18)}
|
|
15
|
+
.dlv-pill-button{width:34px;height:34px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}
|
|
16
|
+
.dlv-pill-button:hover{background:var(--dsw-alias-bg-layer-2)}
|
|
17
|
+
.dlv-live-toggle{width:auto;min-width:58px;height:34px;padding:0 9px;gap:5px;border:1px solid var(--dsw-alias-border-l2);border-radius:17px;color:var(--dsw-alias-label-primary)}
|
|
18
|
+
.dlv-live-toggle svg{width:18px;height:18px}
|
|
19
|
+
.dlv-toggle-state{min-width:22px;font-size:10px;font-weight:700;line-height:1;letter-spacing:.04em;text-align:left}
|
|
20
|
+
.dlv-live-toggle:hover{background:var(--dsw-alias-bg-layer-2)}
|
|
21
|
+
.dlv-live-toggle[aria-checked=false]{color:var(--dsw-alias-label-tertiary);border-color:var(--dsw-alias-border-l1);opacity:.72}
|
|
22
|
+
.dlv-live-toggle[aria-checked=true]{background:var(--dsw-alias-bg-layer-2)}
|
|
23
|
+
.dlv-wave{display:block;flex:1 1 180px;min-width:30px;width:100%;height:40px;color:var(--dsw-alias-label-primary)}
|
|
24
|
+
.dlv-status{flex:1 1 120px;min-width:0;font-size:13px;line-height:1.4;color:var(--dsw-alias-label-secondary)}
|
|
25
|
+
.dlv-error{color:var(--dsw-alias-state-error-primary);overflow-wrap:anywhere;font-size:13px;max-width:720px;margin:8px auto}
|
|
26
|
+
.dlv-settings{box-sizing:border-box;padding:18px;width:100%;display:grid;gap:16px;max-width:640px;color:var(--dsw-alias-label-primary)}
|
|
27
|
+
.dlv-settings h3,.dlv-settings p{margin:0}
|
|
28
|
+
.dlv-settings-group,.dlv-settings-card{margin:0;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;min-width:0}.dlv-settings-group{display:grid;gap:14px;padding:16px}.dlv-settings-group legend{padding:0 6px;font-weight:600;color:var(--dsw-alias-label-primary)}.dlv-settings-card>summary,.dlv-settings-subcard>summary{cursor:pointer;font-weight:650;list-style:none;display:flex;align-items:center;justify-content:space-between;padding:12px}.dlv-settings-card>summary::-webkit-details-marker,.dlv-settings-subcard>summary::-webkit-details-marker{display:none}.dlv-settings-card>summary:after,.dlv-settings-subcard>summary:after{content:"›";transform:rotate(90deg);transition:transform .15s}.dlv-settings-card:not([open])>summary:after,.dlv-settings-subcard:not([open])>summary:after{transform:rotate(0)}.dlv-settings-card-body{display:grid;gap:12px;padding:0 12px 12px}.dlv-settings-subcard{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;min-width:0}.dlv-settings-subcard>summary{padding:10px}.dlv-settings-subcard-body{display:grid;gap:10px;padding:0 10px 10px}
|
|
29
|
+
.dlv-settings label{display:grid;gap:6px;font-size:14px}.dlv-settings label.dlv-check{display:flex;align-items:center;gap:8px}.dlv-settings label.dlv-check input{width:auto}
|
|
30
|
+
.dlv-settings :is(input,select){box-sizing:border-box;width:100%;padding:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:inherit;font:inherit}
|
|
31
|
+
.dlv-settings small,.dlv-settings p{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}
|
|
32
|
+
.dlv-preset-group{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.dlv-settings label.dlv-preset{display:flex;align-items:flex-start;gap:8px;padding:10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;cursor:pointer}.dlv-settings label.dlv-preset:has(input:checked){border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1)}.dlv-settings label.dlv-preset input{width:auto;margin-top:3px}.dlv-preset span{display:grid;gap:3px}.dlv-vad-summary{font-weight:500}
|
|
33
|
+
.dlv-settings-actions{display:flex;flex-wrap:wrap;gap:8px}.dlv-settings-actions button{padding:8px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:inherit;cursor:pointer}.dlv-settings-actions button:disabled{opacity:.45;cursor:default}
|
|
34
|
+
@media(max-width:480px){.dlv-preset-group{grid-template-columns:1fr}.dlv-pill{flex-wrap:wrap}.dlv-wave{flex-basis:90px}.dlv-status{flex-basis:100px}}
|
|
35
|
+
`;
|
|
36
|
+
export default styles;
|