@openvulcan/dsh-vulcancode 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +30 -5
  2. package/cordis.patch.yml +2 -0
  3. package/node_modules/@openvulcan/dsh-decision-guard/lib/client.js +52 -14
  4. package/node_modules/@openvulcan/dsh-decision-guard/package.json +2 -2
  5. package/node_modules/@openvulcan/dsh-vulcan-account/package.json +2 -2
  6. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/client.js +30 -30
  7. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/committed-fiber.d.ts +18 -0
  8. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/committed-fiber.js +62 -0
  9. package/node_modules/@openvulcan/dsh-vulcan-answers/package.json +1 -1
  10. package/node_modules/@openvulcan/dsh-vulcan-luaskills/package.json +1 -1
  11. package/node_modules/@openvulcan/dsh-vulcan-memory/assets/markdown-memory-guide.md +1 -0
  12. package/node_modules/@openvulcan/dsh-vulcan-memory/assets/memory-curator/SKILL.md +1 -1
  13. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/errors.d.ts +7 -0
  14. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/errors.js +11 -0
  15. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/index.d.ts +1 -1
  16. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/index.js +1 -1
  17. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/operations.js +22 -3
  18. package/node_modules/@openvulcan/dsh-vulcan-memory/package.json +1 -1
  19. package/node_modules/@openvulcan/dsh-vulcan-reasoning/package.json +1 -1
  20. package/node_modules/@openvulcan/dsh-vulcan-style/lib/client.js +4 -0
  21. package/node_modules/@openvulcan/dsh-vulcan-style/package.json +1 -1
  22. package/node_modules/@openvulcan/dsh-vulcan-voice-input/LICENSE +21 -0
  23. package/node_modules/@openvulcan/dsh-vulcan-voice-input/lib/client.js +1567 -0
  24. package/node_modules/@openvulcan/dsh-vulcan-voice-input/lib/cloud.d.ts +39 -0
  25. package/node_modules/@openvulcan/dsh-vulcan-voice-input/lib/cloud.js +187 -0
  26. package/node_modules/@openvulcan/dsh-vulcan-voice-input/lib/contract.d.ts +58 -0
  27. package/node_modules/@openvulcan/dsh-vulcan-voice-input/lib/contract.js +1 -0
  28. package/node_modules/@openvulcan/dsh-vulcan-voice-input/lib/index.d.ts +49 -0
  29. package/node_modules/@openvulcan/dsh-vulcan-voice-input/lib/index.js +204 -0
  30. package/node_modules/@openvulcan/dsh-vulcan-voice-input/package.json +46 -0
  31. package/node_modules/@openvulcan/openvulcan-account/package.json +1 -1
  32. package/node_modules/@openvulcan/vulcan-decision/package.json +2 -2
  33. package/package.json +13 -11
@@ -0,0 +1,1567 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@openvulcan/dsh-vulcan-voice-input",
3
+ factory: (platformRequire) => {
4
+ var definitions = {
5
+ "./composer": function (module, exports, require) {
6
+ "use strict";
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.MicrophoneIcon = MicrophoneIcon;
9
+ exports.ComposerVoice = ComposerVoice;
10
+ const jsx_runtime_1 = require("react/jsx-runtime");
11
+ const react_1 = require("react");
12
+ const quick_1 = require("./quick");
13
+ const shared_1 = require("./shared");
14
+ const shortcut_1 = require("./shortcut");
15
+ const wav_1 = require("./wav");
16
+ // MediaRecorder only receives a format supported by the current browser.
17
+ // 录音器仅使用当前浏览器支持的格式。
18
+ const FORMATS = ['audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4'];
19
+ // Brief recording results disappear independently of ongoing progress.
20
+ // 录音结果提示会自动消失,进行中的状态独立显示。
21
+ const NOTICE_MS = 2000;
22
+ // Match VulcanCode's grace period for a lone modifier before opening the microphone.
23
+ // 沿用 VulcanCode 的单独修饰键等待时间,再打开麦克风。
24
+ const MODIFIER_HOLD_MS = 150;
25
+ /**
26
+ * Identify the physical press that owns a release or cancellation.
27
+ * 识别该次释放或取消对应的物理按压。
28
+ */
29
+ function sameHold(first, second) {
30
+ if ('shortcut' in first)
31
+ return 'shortcut' in second && first.shortcut === second.shortcut;
32
+ return 'key' in first ? 'key' in second && first.key === second.key
33
+ : 'pointer' in second && first.pointer === second.pointer;
34
+ }
35
+ /**
36
+ * Release the microphone and both recording clocks.
37
+ * 释放麦克风和两项录音计时。
38
+ */
39
+ function release(recording) {
40
+ window.clearInterval(recording.tick);
41
+ window.clearTimeout(recording.deadline);
42
+ (0, shared_1.releaseMicrophone)(recording.stream);
43
+ delete recording.stream;
44
+ }
45
+ /**
46
+ * Cancel an operation before releasing its recorder so a stop callback cannot submit it.
47
+ * 在释放录音器前取消操作,防止停止回调提交已取消的录音。
48
+ */
49
+ function discard(recording) {
50
+ if (recording === undefined)
51
+ return;
52
+ recording.controller.abort();
53
+ if (recording.recorder?.state === 'recording')
54
+ recording.recorder.stop();
55
+ release(recording);
56
+ }
57
+ /**
58
+ * Render the same microphone mark at toolbar or settings navigation size.
59
+ * 按工具栏或设置导航尺寸绘制同一麦克风图标。
60
+ */
61
+ function MicrophoneIcon({ size }) {
62
+ return (0, jsx_runtime_1.jsxs)("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.65, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [(0, jsx_runtime_1.jsx)("rect", { x: "9", y: "3", width: "6", height: "12", rx: "3" }), (0, jsx_runtime_1.jsx)("path", { d: "M5.5 11v1a6.5 6.5 0 0 0 13 0v-1M12 18.5V22M9 22h6" })] });
63
+ }
64
+ /**
65
+ * Record speech for one composer and return the transcript to its original draft.
66
+ * 为单个输入框录音,并将转写结果回填至原草稿。
67
+ */
68
+ function ComposerVoice({ status, preferences, actions, openSettings, t }) {
69
+ // Read the shared account state without giving the browser access to the device token.
70
+ // 读取共享账号状态,浏览器无需访问设备令牌。
71
+ const state = (0, react_1.useSyncExternalStore)(status.subscribe, status.getSnapshot);
72
+ const authorization = state?.status === 'ready' ? state.authorizationId : undefined;
73
+ const current = (0, react_1.useRef)(undefined);
74
+ const toolbar = (0, react_1.useRef)(null);
75
+ const [phase, setPhase] = (0, react_1.useState)('idle');
76
+ const [seconds, setSeconds] = (0, react_1.useState)(0);
77
+ const [notice, setNotice] = (0, react_1.useState)();
78
+ const [pending, setPending] = (0, react_1.useState)();
79
+ (0, react_1.useEffect)(() => {
80
+ /**
81
+ * Cancel when the page is hidden or the user presses Escape during capture.
82
+ * 页面隐藏或用户在录音时按 Escape 时取消。
83
+ */
84
+ const cancel = () => {
85
+ if (current.current === undefined)
86
+ return;
87
+ discard(current.current);
88
+ current.current = undefined;
89
+ setPhase('idle');
90
+ };
91
+ /**
92
+ * Only react when the page loses visibility.
93
+ * 仅在页面不可见时处理。
94
+ */
95
+ const onVisibility = () => { if (document.hidden)
96
+ cancel(); };
97
+ /**
98
+ * Let Escape dismiss this active recording.
99
+ * 允许 Escape 关闭当前录音。
100
+ */
101
+ const onKey = (event) => { if (event.key === 'Escape')
102
+ cancel(); };
103
+ document.addEventListener('visibilitychange', onVisibility);
104
+ document.addEventListener('keydown', onKey);
105
+ window.addEventListener('blur', cancel);
106
+ return () => {
107
+ document.removeEventListener('visibilitychange', onVisibility);
108
+ document.removeEventListener('keydown', onKey);
109
+ window.removeEventListener('blur', cancel);
110
+ discard(current.current);
111
+ current.current = undefined;
112
+ };
113
+ }, []);
114
+ (0, react_1.useEffect)(() => {
115
+ // Track physical sides because both Ctrl keys share one ctrlKey flag.
116
+ // 左右 Ctrl 共用 ctrlKey 标志,因此按物理键记录状态。
117
+ const heldModifiers = new Set();
118
+ let pendingModifier;
119
+ /**
120
+ * Clear a pending single modifier before it can open the microphone.
121
+ * 在单独修饰键打开麦克风前清除等待状态。
122
+ */
123
+ function clearPending() {
124
+ if (pendingModifier === undefined)
125
+ return;
126
+ window.clearTimeout(pendingModifier.timer);
127
+ pendingModifier = undefined;
128
+ }
129
+ /**
130
+ * Check that the supplied event or focus target belongs to this draft editor.
131
+ * 检查传入的事件目标或焦点是否属于当前草稿编辑器。
132
+ */
133
+ function ownsEditor(element) {
134
+ if (!(element instanceof Element))
135
+ return false;
136
+ const editor = element.closest('[data-composer-input]');
137
+ return editor !== null && toolbar.current?.closest('[data-composer-card]')?.contains(editor) === true;
138
+ }
139
+ /**
140
+ * Yield a standalone modifier hold to an ordinary chord before dsh handles the next key.
141
+ * 在 dsh 处理下一个按键前,让单独修饰键录音让位于普通组合键。
142
+ */
143
+ function onOtherDown(event) {
144
+ if ((0, shortcut_1.isModifierCode)(event.code))
145
+ heldModifiers.add(event.code);
146
+ if (pendingModifier !== undefined && pendingModifier.code !== event.code)
147
+ clearPending();
148
+ const recording = current.current;
149
+ if (recording === undefined || recording.submit || !('shortcut' in recording.hold))
150
+ return;
151
+ const active = recording.hold.shortcut;
152
+ if ((0, shortcut_1.isModifierCode)(active) && active !== event.code)
153
+ cancel();
154
+ }
155
+ /**
156
+ * Handle the configured chord only when this conversation's draft editor has focus.
157
+ * 仅在当前会话草稿编辑器聚焦时响应已设置的组合键。
158
+ */
159
+ const onDown = (event) => {
160
+ if (event.defaultPrevented || !ownsEditor(event.target))
161
+ return;
162
+ const chord = (0, shortcut_1.shortcutFromKey)(event);
163
+ if (chord === '' || chord !== preferences.getShortcut())
164
+ return;
165
+ if (status.getSnapshot()?.status !== 'ready')
166
+ return;
167
+ if ((0, shortcut_1.isModifierCode)(chord)) {
168
+ if (event.repeat || heldModifiers.size !== 1 || pendingModifier !== undefined || current.current !== undefined)
169
+ return;
170
+ pendingModifier = { code: chord, timer: window.setTimeout(() => {
171
+ pendingModifier = undefined;
172
+ if (!document.hidden && ownsEditor(document.activeElement) && preferences.getShortcut() === chord) {
173
+ void start({ shortcut: chord });
174
+ }
175
+ }, MODIFIER_HOLD_MS) };
176
+ return;
177
+ }
178
+ event.preventDefault();
179
+ if (!event.repeat)
180
+ void start({ shortcut: chord });
181
+ };
182
+ /**
183
+ * Release the active shortcut as soon as any of its keys lifts.
184
+ * 快捷键任一组成键松开时结束对应录音。
185
+ */
186
+ const onUp = (event) => {
187
+ heldModifiers.delete(event.code);
188
+ if (pendingModifier?.code === event.code)
189
+ clearPending();
190
+ const recording = current.current;
191
+ if (recording === undefined || recording.submit || !('shortcut' in recording.hold))
192
+ return;
193
+ if (!(0, shortcut_1.releasesShortcut)(recording.hold.shortcut, event))
194
+ return;
195
+ event.preventDefault();
196
+ releaseHold(recording.hold);
197
+ };
198
+ /**
199
+ * Forget pending keys when the document may no longer receive their releases.
200
+ * 文档可能收不到松键事件时清除等待中的按键。
201
+ */
202
+ function resetKeys() {
203
+ heldModifiers.clear();
204
+ clearPending();
205
+ }
206
+ /**
207
+ * Clear the hold delay when the page is hidden.
208
+ * 页面隐藏时清除长按等待状态。
209
+ */
210
+ const onHidden = () => { if (document.hidden)
211
+ resetKeys(); };
212
+ window.addEventListener('keydown', onOtherDown, true);
213
+ window.addEventListener('keydown', onDown);
214
+ window.addEventListener('keyup', onUp, true);
215
+ window.addEventListener('blur', resetKeys);
216
+ document.addEventListener('visibilitychange', onHidden);
217
+ return () => {
218
+ resetKeys();
219
+ window.removeEventListener('keydown', onOtherDown, true);
220
+ window.removeEventListener('keydown', onDown);
221
+ window.removeEventListener('keyup', onUp, true);
222
+ window.removeEventListener('blur', resetKeys);
223
+ document.removeEventListener('visibilitychange', onHidden);
224
+ };
225
+ }, [actions, preferences, status, t]);
226
+ (0, react_1.useEffect)(() => {
227
+ setPending(undefined);
228
+ setNotice(undefined);
229
+ if (current.current !== undefined && current.current.authorizationId !== authorization) {
230
+ discard(current.current);
231
+ current.current = undefined;
232
+ setPhase('idle');
233
+ }
234
+ }, [authorization]);
235
+ (0, react_1.useEffect)(() => {
236
+ if (notice === undefined)
237
+ return;
238
+ // An earlier notice's timer must not clear feedback from a newer recording.
239
+ // 旧提示的计时器不能清除新录音产生的提示。
240
+ const timer = window.setTimeout(() => {
241
+ setNotice(current => current === notice ? undefined : current);
242
+ }, NOTICE_MS);
243
+ return () => window.clearTimeout(timer);
244
+ }, [notice]);
245
+ /**
246
+ * Verify the recording is still attached to this composer and the same login.
247
+ * 确认录音仍属于当前输入框和同一次登录。
248
+ */
249
+ function stillCurrent(recording) {
250
+ const snapshot = status.getSnapshot();
251
+ return current.current === recording && !recording.controller.signal.aborted &&
252
+ snapshot?.status === 'ready' && snapshot.authorizationId === recording.authorizationId;
253
+ }
254
+ /**
255
+ * Encode and upload the stopped recording, then use dsh's revision-aware draft action.
256
+ * 编码并上传已停止的录音,再使用 dsh 带版本校验的草稿操作。
257
+ */
258
+ async function finish(recording, mimeType) {
259
+ release(recording);
260
+ if (!stillCurrent(recording))
261
+ return;
262
+ try {
263
+ // Missing chunks and silent PCM have the same outcome as a successful blank transcript.
264
+ // 没有录音片段、PCM 静音与转写成功但文本为空属于同一种结果。
265
+ const wav = recording.chunks.length === 0
266
+ ? null
267
+ : await (0, wav_1.encodeVoiceInputWav)(new Blob(recording.chunks, { type: mimeType }), recording.maxSeconds);
268
+ if (!stillCurrent(recording))
269
+ return;
270
+ // Text stays empty when there is no audio to send.
271
+ // 没有可上传的音频时,转写文本保持为空。
272
+ let text = '';
273
+ if (wav !== null) {
274
+ const result = await (0, shared_1.requestTranscript)(wav, recording.authorizationId, recording.selection, recording.controller.signal);
275
+ if (!stillCurrent(recording))
276
+ return;
277
+ if (!result.ok) {
278
+ const retry = result.retryAfter === undefined ? '' : ' ' + t('retry').replace('{seconds}', String(result.retryAfter));
279
+ setNotice({ text: t(result.code) + retry });
280
+ if (result.code === 'signed_out' || result.code === 'selection_unavailable')
281
+ status.refresh(true);
282
+ return;
283
+ }
284
+ text = result.text.trim();
285
+ }
286
+ if (text === '') {
287
+ setNotice({ text: t('noSpeech') });
288
+ }
289
+ else if (!actions.insertText(text, recording.insertion)) {
290
+ setPending({ text, authorizationId: recording.authorizationId });
291
+ setNotice({ text: t('draftChanged') });
292
+ }
293
+ }
294
+ catch {
295
+ if (stillCurrent(recording))
296
+ setNotice({ text: t('unavailable') });
297
+ }
298
+ finally {
299
+ if (current.current === recording) {
300
+ current.current = undefined;
301
+ setPhase('idle');
302
+ }
303
+ }
304
+ }
305
+ /**
306
+ * Start recording for a physical hold while the account is ready.
307
+ * 账号就绪时,为一次物理按压启动录音。
308
+ */
309
+ async function start(hold) {
310
+ const ready = status.getSnapshot();
311
+ if (ready?.status !== 'ready')
312
+ return;
313
+ if (current.current !== undefined)
314
+ return;
315
+ // Prevent recording with an unavailable preference before requesting the microphone.
316
+ // 所选参数已不可用时,在打开麦克风前提示用户。
317
+ const selection = preferences.selection(ready.site, ready.catalog);
318
+ const model = ready.catalog.models.find(entry => entry.id === selection.modelId);
319
+ if (model === undefined || (selection.language !== undefined && !model.languages.includes(selection.language))) {
320
+ setNotice({ text: t('selection_unavailable') });
321
+ return;
322
+ }
323
+ setNotice(undefined);
324
+ setPending(undefined);
325
+ const mimeType = typeof MediaRecorder === 'undefined' ? undefined : FORMATS.find(type => MediaRecorder.isTypeSupported(type));
326
+ if (mimeType === undefined || navigator.mediaDevices === undefined) {
327
+ setNotice({ text: t('unsupported') });
328
+ return;
329
+ }
330
+ const recording = {
331
+ controller: new AbortController(), authorizationId: ready.authorizationId, hold,
332
+ maxSeconds: ready.maxSeconds, selection,
333
+ insertion: actions.captureInsertion(), chunks: [], submit: false,
334
+ };
335
+ current.current = recording;
336
+ setPhase('opening');
337
+ try {
338
+ const stream = await (0, shared_1.openMicrophone)(preferences.getDevice());
339
+ if (!stillCurrent(recording)) {
340
+ (0, shared_1.releaseMicrophone)(stream);
341
+ return;
342
+ }
343
+ recording.stream = stream;
344
+ const recorder = new MediaRecorder(stream, { mimeType });
345
+ recording.recorder = recorder;
346
+ recorder.ondataavailable = event => { if (event.data.size !== 0)
347
+ recording.chunks.push(event.data); };
348
+ recorder.onstop = () => {
349
+ if (recording.submit) {
350
+ void finish(recording, recorder.mimeType);
351
+ }
352
+ else if (stillCurrent(recording)) {
353
+ // An unplugged microphone can stop MediaRecorder without a user action.
354
+ // 拔出麦克风可能在没有用户操作时停止录音器。
355
+ cancel();
356
+ setNotice({ text: t('micError') });
357
+ }
358
+ };
359
+ recorder.onerror = () => {
360
+ if (!stillCurrent(recording))
361
+ return;
362
+ cancel();
363
+ setNotice({ text: t('micError') });
364
+ };
365
+ recorder.start();
366
+ const started = performance.now();
367
+ recording.started = started;
368
+ setSeconds(ready.maxSeconds);
369
+ setPhase('recording');
370
+ recording.tick = window.setInterval(() => {
371
+ setSeconds(Math.max(0, ready.maxSeconds - Math.floor((performance.now() - started) / 1000)));
372
+ }, 250);
373
+ recording.deadline = window.setTimeout(() => { submitRecording(recording); }, ready.maxSeconds * 1000);
374
+ }
375
+ catch {
376
+ if (current.current === recording) {
377
+ cancel();
378
+ setNotice({ text: t('micError') });
379
+ }
380
+ }
381
+ }
382
+ /**
383
+ * Submit audio when the matching hold ends after the minimum recording duration.
384
+ * 对应的按压松开且满足最短录音时长时提交音频。
385
+ */
386
+ function releaseHold(hold) {
387
+ const recording = current.current;
388
+ if (recording === undefined || recording.submit || !sameHold(recording.hold, hold))
389
+ return;
390
+ if (recording.started === undefined || recording.recorder?.state !== 'recording') {
391
+ cancel();
392
+ return;
393
+ }
394
+ // Measure duration at release so an asynchronous MediaRecorder stop cannot extend a short press.
395
+ // 在松开时计算时长,避免 MediaRecorder 异步停止将短按误算为有效录音。
396
+ const elapsed = performance.now() - recording.started;
397
+ if (elapsed < 250) {
398
+ cancel();
399
+ setNotice({ text: t('tooShort') });
400
+ return;
401
+ }
402
+ submitRecording(recording);
403
+ }
404
+ /**
405
+ * Commit a recording once, whether released by the user or stopped at the duration limit.
406
+ * 录音由用户松开或达到时限时只提交一次。
407
+ */
408
+ function submitRecording(recording) {
409
+ if (!stillCurrent(recording) || recording.submit || recording.recorder?.state !== 'recording')
410
+ return;
411
+ // Mark the submission before stopping so a later release cannot enqueue another upload.
412
+ // 先标记已提交再停止录音器,防止稍后的松开操作重复上传。
413
+ recording.submit = true;
414
+ window.clearInterval(recording.tick);
415
+ window.clearTimeout(recording.deadline);
416
+ setPhase('transcribing');
417
+ recording.recorder.stop();
418
+ }
419
+ /**
420
+ * Cancel after an interrupted pointer gesture, without interrupting a normal release.
421
+ * 指针手势意外中断时取消,不打断正常释放。
422
+ */
423
+ function cancelPointer(pointer) {
424
+ const recording = current.current;
425
+ if (recording !== undefined && !recording.submit && sameHold(recording.hold, { pointer }))
426
+ cancel();
427
+ }
428
+ /**
429
+ * Cancel capture or upload and return the toolbar to idle.
430
+ * 取消录音或上传,使工具栏回到空闲状态。
431
+ */
432
+ function cancel() {
433
+ discard(current.current);
434
+ current.current = undefined;
435
+ setPhase('idle');
436
+ }
437
+ /**
438
+ * Insert a retained transcript only after the user explicitly confirms the new draft position.
439
+ * 用户明确确认新的草稿位置后,才插入保留的转写。
440
+ */
441
+ function insertPending() {
442
+ const snapshot = status.getSnapshot();
443
+ if (pending === undefined || snapshot?.status !== 'ready' || pending.authorizationId !== snapshot.authorizationId ||
444
+ !actions.insertText(pending.text, actions.captureInsertion()))
445
+ return;
446
+ setPending(undefined);
447
+ setNotice(undefined);
448
+ }
449
+ // Keep status and recovery actions in the microphone's floating surface throughout the operation.
450
+ // 录音全程将状态与恢复操作放在麦克风浮层中。
451
+ const feedback = phase !== 'idle' || notice !== undefined ? (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [phase === 'recording' && (0, jsx_runtime_1.jsxs)("span", { className: "vvi-recording", children: [(0, jsx_runtime_1.jsx)("span", { className: "vvi-dot", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("span", { role: "status", children: t('recording') }), (0, jsx_runtime_1.jsxs)("span", { className: "vvi-timer", role: "timer", children: [seconds, "s"] })] }), (phase === 'opening' || phase === 'transcribing') && (0, jsx_runtime_1.jsx)("span", { role: "status", children: t(phase) }), phase !== 'idle' && (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: cancel, children: t('cancel') }), notice !== undefined && (0, jsx_runtime_1.jsx)("span", { className: "vvi-notice", role: "status", children: notice.text })] }) : undefined;
452
+ return (0, jsx_runtime_1.jsx)("div", { ref: toolbar, className: "vvi-composer", children: (0, jsx_runtime_1.jsx)(quick_1.VoiceTrigger, { icon: (0, jsx_runtime_1.jsx)(MicrophoneIcon, { size: 17 }), state: state, preferences: preferences, feedback: feedback, pendingAction: pending === undefined ? undefined : (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: insertPending, children: t('insert') }), idle: phase === 'idle', pressed: phase === 'opening' || phase === 'recording', openSettings: openSettings, start: hold => { void start(hold); }, release: releaseHold, cancelPointer: cancelPointer, t: t }) });
453
+ }
454
+
455
+ },
456
+ "./dsh-client": function (module, exports, require) {
457
+ "use strict";
458
+ Object.defineProperty(exports, "__esModule", { value: true });
459
+
460
+ },
461
+ "./fields": function (module, exports, require) {
462
+ "use strict";
463
+ Object.defineProperty(exports, "__esModule", { value: true });
464
+ exports.ModelFields = ModelFields;
465
+ exports.MicrophonePicker = MicrophonePicker;
466
+ exports.ShortcutField = ShortcutField;
467
+ const jsx_runtime_1 = require("react/jsx-runtime");
468
+ const react_1 = require("react");
469
+ const shortcut_1 = require("./shortcut");
470
+ /**
471
+ * List local microphone devices and refresh after permission or device changes.
472
+ * 列出本地麦克风,在授权或设备变化后刷新。
473
+ */
474
+ function useMicrophones(revision) {
475
+ const [devices, setDevices] = (0, react_1.useState)([]);
476
+ (0, react_1.useEffect)(() => {
477
+ const media = navigator.mediaDevices;
478
+ if (media === undefined)
479
+ return;
480
+ let generation = 0;
481
+ /**
482
+ * Publish only the most recent enumeration result.
483
+ * 仅发布最近一次设备枚举的结果。
484
+ */
485
+ const refresh = () => {
486
+ const current = ++generation;
487
+ void media.enumerateDevices().then(all => {
488
+ if (current !== generation)
489
+ return;
490
+ setDevices(all.filter(item => item.kind === 'audioinput' && item.deviceId !== '' &&
491
+ item.deviceId !== 'default' && item.deviceId !== 'communications'));
492
+ }, () => { if (current === generation)
493
+ setDevices([]); });
494
+ };
495
+ refresh();
496
+ media.addEventListener('devicechange', refresh);
497
+ return () => {
498
+ generation += 1;
499
+ media.removeEventListener('devicechange', refresh);
500
+ };
501
+ }, [revision]);
502
+ return devices;
503
+ }
504
+ /**
505
+ * Let either surface select the model and its supported language.
506
+ * 允许在任一界面选择模型及该模型支持的语言。
507
+ */
508
+ function ModelFields({ ready, preferences, t }) {
509
+ const { site, catalog } = ready;
510
+ (0, react_1.useSyncExternalStore)(preferences.subscribe, preferences.getSnapshot);
511
+ const selection = preferences.selection(site, catalog);
512
+ const current = catalog.models.find(model => model.id === selection.modelId);
513
+ const defaultModel = catalog.models.find(model => model.id === catalog.defaultModelId);
514
+ const languageNames = new Intl.DisplayNames([t('languageLocale')], { type: 'language' });
515
+ // Keep withdrawn preferences visible until the user picks a different value.
516
+ // 保留已下架的选项,直到用户主动改选。
517
+ const retiredLanguage = selection.language !== undefined && !current?.languages.includes(selection.language);
518
+ return (0, jsx_runtime_1.jsxs)("div", { className: "vvi-fields", children: [(0, jsx_runtime_1.jsxs)("label", { children: [t('model'), (0, jsx_runtime_1.jsxs)("select", { value: preferences.getModelChoice(site), onChange: event => { preferences.setModelChoice(site, event.target.value); }, children: [(0, jsx_runtime_1.jsx)("option", { value: "", children: t('siteDefault').replace('{model}', defaultModel?.label ?? catalog.defaultModelId) }), current === undefined && (0, jsx_runtime_1.jsx)("option", { value: selection.modelId, disabled: true, children: t('retiredChoice').replace('{value}', selection.modelId) }), catalog.models.map(model => (0, jsx_runtime_1.jsx)("option", { value: model.id, children: model.label }, model.id))] })] }), (0, jsx_runtime_1.jsxs)("label", { children: [t('language'), (0, jsx_runtime_1.jsxs)("select", { value: selection.language ?? '', disabled: !retiredLanguage && !current?.languages.length, onChange: event => { preferences.setLanguage(site, selection.modelId, event.target.value); }, children: [(0, jsx_runtime_1.jsx)("option", { value: "", children: t('autoLanguage') }), retiredLanguage && (0, jsx_runtime_1.jsx)("option", { value: selection.language, disabled: true, children: t('retiredChoice').replace('{value}', selection.language) }), current?.languages.map(language => (0, jsx_runtime_1.jsx)("option", { value: language, children: languageNames.of(language) ?? language }, language))] })] })] });
519
+ }
520
+ /**
521
+ * Pick the same local microphone from settings or the floating card.
522
+ * 在设置或浮层中选择同一个本地麦克风。
523
+ */
524
+ function MicrophonePicker({ preferences, revision = 0, disabled = false, t }) {
525
+ const devices = useMicrophones(revision);
526
+ (0, react_1.useSyncExternalStore)(preferences.subscribe, preferences.getSnapshot);
527
+ const selected = preferences.getDevice();
528
+ return (0, jsx_runtime_1.jsxs)("select", { "aria-label": t('microphone'), value: selected, disabled: disabled || navigator.mediaDevices === undefined, onChange: event => { preferences.setDevice(event.target.value); }, children: [(0, jsx_runtime_1.jsx)("option", { value: "", children: t('system') }), selected !== '' && !devices.some(item => item.deviceId === selected) && (0, jsx_runtime_1.jsx)("option", { value: selected, children: t('missing') }), devices.map((item, index) => (0, jsx_runtime_1.jsx)("option", { value: item.deviceId, children: item.label || t('unnamed').replace('{index}', String(index + 1)) }, item.deviceId))] });
529
+ }
530
+ /**
531
+ * Record a local push-to-talk shortcut from a focusable read-only field.
532
+ * 在可聚焦的只读字段中录入本地按住说话快捷键。
533
+ */
534
+ function ShortcutField({ preferences, t }) {
535
+ (0, react_1.useSyncExternalStore)(preferences.subscribe, preferences.getSnapshot);
536
+ const chord = preferences.getShortcut();
537
+ // Save an isolated modifier only on release, after allowing a chord to follow.
538
+ // 单独修饰键只在松开时保存,为随后可能出现的组合键留出机会。
539
+ const candidate = (0, react_1.useRef)('');
540
+ const heldModifiers = (0, react_1.useRef)(new Set());
541
+ return (0, jsx_runtime_1.jsxs)("div", { className: "vvi-shortcut-row", children: [(0, jsx_runtime_1.jsx)("input", { "aria-label": t('shortcut'), readOnly: true, value: (0, shortcut_1.shortcutLabel)(chord, t), placeholder: t('captureShortcut'), onBlur: () => { candidate.current = ''; heldModifiers.current.clear(); }, onKeyDown: event => {
542
+ const next = (0, shortcut_1.shortcutFromKey)(event.nativeEvent);
543
+ if ((0, shortcut_1.isModifierCode)(event.code)) {
544
+ if (event.repeat)
545
+ return;
546
+ heldModifiers.current.add(event.code);
547
+ candidate.current = heldModifiers.current.size === 1 ? next : '';
548
+ return;
549
+ }
550
+ candidate.current = '';
551
+ if (next === '')
552
+ return;
553
+ event.preventDefault();
554
+ event.stopPropagation();
555
+ if (!event.repeat)
556
+ preferences.setShortcut(next);
557
+ }, onKeyUp: event => {
558
+ heldModifiers.current.delete(event.code);
559
+ if (candidate.current === '' || candidate.current !== event.code)
560
+ return;
561
+ candidate.current = '';
562
+ event.preventDefault();
563
+ event.stopPropagation();
564
+ preferences.setShortcut(event.code);
565
+ } }), (0, jsx_runtime_1.jsx)("button", { type: "button", disabled: chord === '', onClick: () => { preferences.setShortcut(''); }, children: t('clearShortcut') })] });
566
+ }
567
+
568
+ },
569
+ "./i18n": function (module, exports, require) {
570
+ "use strict";
571
+ Object.defineProperty(exports, "__esModule", { value: true });
572
+ exports.en = exports.zh = void 0;
573
+ // The same keys drive the toolbar, service status and microphone settings in both languages.
574
+ // 同一组文案键用于中英文工具栏、服务状态和麦克风设置。
575
+ exports.zh = {
576
+ title: 'OpenVulcan 语音输入',
577
+ lead: '用麦克风口述消息,转写完成后可在输入框里编辑。',
578
+ service: '转写服务',
579
+ microphone: '麦克风',
580
+ serviceHint: '松开后,录音会发往 OpenVulcan 账号所属站点免费转写。',
581
+ model: '转写模型',
582
+ siteDefault: '站点默认({model})',
583
+ language: '优先语言',
584
+ autoLanguage: '自动识别',
585
+ retiredChoice: '{value}(已不可用)',
586
+ languageLocale: 'zh-CN',
587
+ shortcut: '录音快捷键',
588
+ shortcutHint: '在对话输入框按住快捷键说话,松开任一组成键转写。可单独绑定右 Ctrl 等左右修饰键。',
589
+ captureShortcut: '点击后按快捷键,如右 Ctrl',
590
+ shortcutLeft: '左 {key}',
591
+ shortcutRight: '右 {key}',
592
+ shortcutNumpad: '数字键盘 {key}',
593
+ clearShortcut: '清除',
594
+ holdHint: '按住说话,松开转写',
595
+ selection_unavailable: '所选模型或语言已不可用,请检查设置后重录。',
596
+ loading: '正在检查',
597
+ ready: '可用',
598
+ signed_out: '请登录 OpenVulcan',
599
+ disabled: '当前站点未开放语音转写',
600
+ unavailable: '转写服务暂不可用',
601
+ cn: '中国站',
602
+ global: '国际站',
603
+ refresh: '重新检查',
604
+ account: '打开账号设置',
605
+ system: '系统默认',
606
+ missing: '所选麦克风暂未列出',
607
+ unnamed: '麦克风 {index}',
608
+ test: '测试麦克风',
609
+ stopTest: '结束测试',
610
+ testHint: '对着麦克风说话,观察输入电平。测试音频仅在本机处理。',
611
+ meter: '麦克风输入电平',
612
+ unsupported: '当前页面无法访问麦克风。',
613
+ micError: '无法打开麦克风。请检查权限和所选设备。',
614
+ openSettings: '打开语音输入设置',
615
+ opening: '正在打开麦克风',
616
+ recording: '录音中',
617
+ cancel: '取消',
618
+ transcribing: '正在转写',
619
+ duration: '录满 {seconds} 秒自动转写',
620
+ tooShort: '录音太短,请再说一次。',
621
+ noSpeech: '没有识别到语音。',
622
+ draftChanged: '草稿已变化,请确认后插入转写结果。',
623
+ insert: '插入转写',
624
+ invalid_audio: '无法转写这段录音,请重新录制。',
625
+ rate_limited: '已达到转写限额,请稍后再试。',
626
+ busy: '转写服务正忙,请稍后再试。',
627
+ retry: '可在 {seconds} 秒后重试。',
628
+ };
629
+ // English text mirrors the exact Chinese dictionary keys.
630
+ // 英文文案与中文词典的键严格一致。
631
+ exports.en = {
632
+ title: 'OpenVulcan voice input',
633
+ lead: 'Dictate a message and edit the transcript in the composer.',
634
+ service: 'Transcription service',
635
+ microphone: 'Microphone',
636
+ serviceHint: 'When you release, your OpenVulcan account’s site transcribes the audio for free.',
637
+ model: 'Transcription model',
638
+ siteDefault: 'Site default ({model})',
639
+ language: 'Preferred language',
640
+ autoLanguage: 'Detect automatically',
641
+ retiredChoice: '{value} (unavailable)',
642
+ languageLocale: 'en',
643
+ shortcut: 'Recording shortcut',
644
+ shortcutHint: 'Hold your shortcut in the composer to speak. Release any key in it to transcribe. You can bind a single modifier, such as Right Ctrl.',
645
+ captureShortcut: 'Press a shortcut (e.g. Right Ctrl)',
646
+ shortcutLeft: 'Left {key}',
647
+ shortcutRight: 'Right {key}',
648
+ shortcutNumpad: 'Numpad {key}',
649
+ clearShortcut: 'Clear',
650
+ holdHint: 'Hold to speak. Release to transcribe.',
651
+ selection_unavailable: 'The selected model or language is unavailable. Check settings and record again.',
652
+ loading: 'Checking',
653
+ ready: 'Available',
654
+ signed_out: 'Sign in to OpenVulcan',
655
+ disabled: 'Voice transcription is disabled on this site',
656
+ unavailable: 'Transcription service is unavailable',
657
+ cn: 'China',
658
+ global: 'Global',
659
+ refresh: 'Check again',
660
+ account: 'Open account settings',
661
+ system: 'System default',
662
+ missing: 'Selected microphone is not listed',
663
+ unnamed: 'Microphone {index}',
664
+ test: 'Test microphone',
665
+ stopTest: 'End test',
666
+ testHint: 'Speak and watch the input level. Test audio stays on this device.',
667
+ meter: 'Microphone input level',
668
+ unsupported: 'Microphone access is unavailable on this page.',
669
+ micError: 'Could not open the microphone. Check permission and the selected device.',
670
+ openSettings: 'Open voice input settings',
671
+ opening: 'Opening microphone',
672
+ recording: 'Recording',
673
+ cancel: 'Cancel',
674
+ transcribing: 'Transcribing',
675
+ duration: 'Automatically transcribes after {seconds} seconds',
676
+ tooShort: 'The recording is too short. Please try again.',
677
+ noSpeech: 'No speech was recognized.',
678
+ draftChanged: 'Your draft changed. Confirm to insert the transcript.',
679
+ insert: 'Insert transcript',
680
+ invalid_audio: 'Could not transcribe this recording. Record it again.',
681
+ rate_limited: 'You reached the transcription limit. Try again later.',
682
+ busy: 'Transcription is busy. Try again later.',
683
+ retry: 'Try again in {seconds} seconds.',
684
+ };
685
+
686
+ },
687
+ "./index": function (module, exports, require) {
688
+ "use strict";
689
+ Object.defineProperty(exports, "__esModule", { value: true });
690
+ exports.inject = void 0;
691
+ exports.apply = apply;
692
+ const jsx_runtime_1 = require("react/jsx-runtime");
693
+ const composer_1 = require("./composer");
694
+ const i18n_1 = require("./i18n");
695
+ const shared_1 = require("./shared");
696
+ const settings_1 = require("./settings");
697
+ const style_1 = require("./style");
698
+ // Cordis supplies the slot and locale services before loading the client contribution.
699
+ // Cordis 在加载浏览器扩展前提供插槽与本地化服务。
700
+ exports.inject = ['slots', 'locale'];
701
+ /**
702
+ * Add the OpenVulcan microphone and its settings to dsh without claiming the local voice slot.
703
+ * 将 OpenVulcan 麦克风及设置接入 dsh,并保留本地语音使用的插槽。
704
+ */
705
+ function apply(ctx) {
706
+ const status = new shared_1.VoiceStatus();
707
+ const preferences = new shared_1.VoicePreferences();
708
+ /**
709
+ * Open a known settings section through the sidebar's shell action.
710
+ * 通过侧栏宿主操作打开已知的设置页。
711
+ */
712
+ function open(section) {
713
+ const shell = ctx.slots.entries('sidebar.settings')[0]?.store?.create();
714
+ shell?.actions.openSection(section);
715
+ }
716
+ ctx.effect(style_1.installStyle, 'vulcan-voice-input: stylesheet');
717
+ ctx.effect(() => ctx.locale.register(shared_1.SECTION, { zh: i18n_1.zh, en: i18n_1.en }), 'vulcan-voice-input: dictionaries');
718
+ ctx.effect(() => {
719
+ /**
720
+ * Invalidate the previous connection's state before reading from the new Host.
721
+ * 在读取新宿主前使上一个连接的状态失效。
722
+ */
723
+ const reconnect = () => { status.reset(); status.refresh(); };
724
+ /**
725
+ * Read login changes after returning from the browser's authorization tab.
726
+ * 从浏览器授权页返回后检查登录状态变化。
727
+ */
728
+ const refresh = () => { if (!document.hidden)
729
+ status.refresh(); };
730
+ const stop = ctx.on('connection/reset', reconnect);
731
+ const interval = window.setInterval(refresh, 8000);
732
+ window.addEventListener('focus', refresh);
733
+ document.addEventListener('visibilitychange', refresh);
734
+ refresh();
735
+ return () => {
736
+ window.clearInterval(interval);
737
+ window.removeEventListener('focus', refresh);
738
+ document.removeEventListener('visibilitychange', refresh);
739
+ stop();
740
+ status.reset();
741
+ };
742
+ }, 'vulcan-voice-input: Host state');
743
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
744
+ name: 'settings.section',
745
+ id: shared_1.SECTION,
746
+ order: 25,
747
+ label: () => ctx.locale.bind(shared_1.SECTION)('title'),
748
+ locale: shared_1.SECTION,
749
+ }, ({ t }) => (0, jsx_runtime_1.jsx)(settings_1.VoiceSettings, { status: status, preferences: preferences, openAccount: () => { open(shared_1.ACCOUNT_SECTION); }, t: t })));
750
+ ctx.slots.inject('settings.section.icon', () => ctx.slots.register({
751
+ name: 'settings.section.icon', id: shared_1.SECTION,
752
+ }, composer_1.MicrophoneIcon));
753
+ ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
754
+ name: 'conversation.input.right',
755
+ id: shared_1.SECTION,
756
+ order: 14,
757
+ locale: shared_1.SECTION,
758
+ }, ({ t, inputActions, sessionId }) => (0, jsx_runtime_1.jsx)(composer_1.ComposerVoice, { status: status, preferences: preferences, actions: inputActions, openSettings: () => { open(shared_1.SECTION); }, t: t }, sessionId)));
759
+ }
760
+
761
+ },
762
+ "./quick": function (module, exports, require) {
763
+ "use strict";
764
+ Object.defineProperty(exports, "__esModule", { value: true });
765
+ exports.VoiceTrigger = VoiceTrigger;
766
+ const jsx_runtime_1 = require("react/jsx-runtime");
767
+ const react_1 = require("react");
768
+ const react_dom_1 = require("react-dom");
769
+ const fields_1 = require("./fields");
770
+ /**
771
+ * Render the microphone and anchor its settings and optional feedback outside the toolbar layout.
772
+ * 渲染麦克风,并将快捷设置与可选提示锚定在工具栏布局之外。
773
+ */
774
+ function VoiceTrigger({ icon, state, preferences, idle, pressed, feedback, pendingAction, openSettings, start, release, cancelPointer, t }) {
775
+ const id = (0, react_1.useId)();
776
+ const button = (0, react_1.useRef)(null);
777
+ const panel = (0, react_1.useRef)(null);
778
+ const timer = (0, react_1.useRef)();
779
+ const suppressFocus = (0, react_1.useRef)(false);
780
+ const [expanded, setExpanded] = (0, react_1.useState)(false);
781
+ const [position, setPosition] = (0, react_1.useState)({});
782
+ const open = idle && expanded;
783
+ // Render one anchored surface so recording feedback and quick settings cannot overlap.
784
+ // 只渲染一个锚定浮层,避免录音提示与快捷设置重叠。
785
+ const floating = open || feedback !== undefined;
786
+ (0, react_1.useEffect)(() => () => window.clearTimeout(timer.current), []);
787
+ (0, react_1.useEffect)(() => {
788
+ if (idle)
789
+ return;
790
+ window.clearTimeout(timer.current);
791
+ setExpanded(false);
792
+ }, [idle]);
793
+ (0, react_1.useLayoutEffect)(() => {
794
+ if (!floating)
795
+ return;
796
+ /**
797
+ * Keep the fixed card inside the viewport while the composer moves.
798
+ * 输入框移动时,将固定定位浮层留在视口内。
799
+ */
800
+ const place = () => {
801
+ const anchor = button.current?.getBoundingClientRect();
802
+ if (anchor === undefined)
803
+ return;
804
+ // Bound the compact hint separately while keeping both surfaces aligned to the microphone.
805
+ // 为紧凑提示单独限制宽度,并让两种浮层都与麦克风对齐。
806
+ const maxWidth = Math.min(open ? 336 : 280, window.innerWidth - 16);
807
+ const below = window.innerHeight - anchor.bottom;
808
+ const above = anchor.top;
809
+ setPosition({
810
+ maxWidth, right: Math.max(8, Math.min(window.innerWidth - anchor.right, window.innerWidth - maxWidth - 8)),
811
+ ...(above >= below ? { bottom: below + anchor.height + 6, maxHeight: above - 14 }
812
+ : { top: anchor.bottom + 6, maxHeight: below - 14 }),
813
+ });
814
+ };
815
+ place();
816
+ window.addEventListener('resize', place);
817
+ window.addEventListener('scroll', place, true);
818
+ return () => {
819
+ window.removeEventListener('resize', place);
820
+ window.removeEventListener('scroll', place, true);
821
+ };
822
+ }, [floating, open]);
823
+ (0, react_1.useEffect)(() => {
824
+ if (!open)
825
+ return;
826
+ /**
827
+ * Dismiss the card when a click moves outside its trigger and controls.
828
+ * 点击触发按钮及控件以外的位置时关闭浮层。
829
+ */
830
+ const outside = (event) => {
831
+ if (event.target instanceof Node && !button.current?.contains(event.target) && !panel.current?.contains(event.target)) {
832
+ setExpanded(false);
833
+ }
834
+ };
835
+ /**
836
+ * Return keyboard focus to the trigger after Escape without reopening the card.
837
+ * 按 Escape 后将键盘焦点交回触发按钮,并避免重新打开浮层。
838
+ */
839
+ const escape = (event) => {
840
+ if (event.key !== 'Escape')
841
+ return;
842
+ if (panel.current?.contains(document.activeElement)) {
843
+ suppressFocus.current = true;
844
+ button.current?.focus();
845
+ suppressFocus.current = false;
846
+ }
847
+ setExpanded(false);
848
+ };
849
+ document.addEventListener('pointerdown', outside);
850
+ document.addEventListener('keydown', escape);
851
+ return () => {
852
+ document.removeEventListener('pointerdown', outside);
853
+ document.removeEventListener('keydown', escape);
854
+ };
855
+ }, [open]);
856
+ /**
857
+ * Delay pointer dismissal long enough to cross the gap to the card.
858
+ * 延迟指针触发的关闭,使指针有时间跨过间隙进入浮层。
859
+ */
860
+ function leave() {
861
+ window.clearTimeout(timer.current);
862
+ timer.current = window.setTimeout(() => {
863
+ if (!panel.current?.contains(document.activeElement) && !button.current?.matches(':focus-visible'))
864
+ setExpanded(false);
865
+ }, 250);
866
+ }
867
+ const ready = state?.status === 'ready' ? state : undefined;
868
+ const label = ready === undefined ? `${t(state?.status ?? 'loading')} · ${t('openSettings')}` : `${t('title')} · ${t('holdHint')}`;
869
+ return (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("button", { ref: button, type: "button", className: "vvi-mic", "aria-label": label, "aria-pressed": pressed, "aria-controls": floating ? id : undefined, "aria-expanded": open, disabled: !idle && !pressed, onPointerEnter: event => {
870
+ if (!idle || event.pointerType !== 'mouse')
871
+ return;
872
+ window.clearTimeout(timer.current);
873
+ timer.current = window.setTimeout(() => { setExpanded(true); }, 300);
874
+ }, onPointerLeave: leave, onFocus: event => { if (idle && !suppressFocus.current && event.currentTarget.matches(':focus-visible'))
875
+ setExpanded(true); }, onBlur: event => {
876
+ if (!panel.current?.contains(event.relatedTarget))
877
+ leave();
878
+ }, onContextMenu: event => { if (idle) {
879
+ event.preventDefault();
880
+ setExpanded(true);
881
+ } }, onPointerDown: event => {
882
+ if (event.button !== 0 || ready === undefined || !idle)
883
+ return;
884
+ event.preventDefault();
885
+ event.currentTarget.setPointerCapture(event.pointerId);
886
+ window.clearTimeout(timer.current);
887
+ setExpanded(false);
888
+ start({ pointer: event.pointerId });
889
+ }, onPointerUp: event => { release({ pointer: event.pointerId }); }, onPointerCancel: event => { cancelPointer(event.pointerId); }, onLostPointerCapture: event => { cancelPointer(event.pointerId); }, onClick: () => { if (idle && ready === undefined) {
890
+ setExpanded(false);
891
+ openSettings();
892
+ } }, onKeyDown: event => {
893
+ if (open && !event.shiftKey && (event.key === 'Tab' || event.key === 'ArrowDown')) {
894
+ event.preventDefault();
895
+ panel.current?.querySelector('select:not(:disabled), button:not(:disabled), input:not(:disabled)')?.focus();
896
+ }
897
+ else if (ready !== undefined && (event.code === 'Space' || event.code === 'Enter') &&
898
+ !(event.ctrlKey || event.altKey || event.shiftKey || event.metaKey)) {
899
+ event.preventDefault();
900
+ if (idle && !event.repeat) {
901
+ window.clearTimeout(timer.current);
902
+ setExpanded(false);
903
+ start({ key: event.code });
904
+ }
905
+ }
906
+ }, onKeyUp: event => { if (event.code === 'Space' || event.code === 'Enter')
907
+ release({ key: event.code }); }, children: icon }), floating && (0, react_dom_1.createPortal)((0, jsx_runtime_1.jsxs)("div", { ref: panel, id: id, role: open ? 'dialog' : undefined, "aria-label": open ? t('title') : undefined, className: open ? 'vvi-quick' : 'vvi-feedback', style: position, onPointerEnter: () => window.clearTimeout(timer.current), onPointerLeave: leave, onBlur: event => {
908
+ if (!event.currentTarget.contains(event.relatedTarget) && event.relatedTarget !== button.current)
909
+ leave();
910
+ }, children: [(feedback !== undefined || pendingAction !== undefined) && (0, jsx_runtime_1.jsxs)("div", { className: "vvi-feedback-row", children: [feedback, pendingAction] }), open && (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "vvi-quick-head", children: [(0, jsx_runtime_1.jsx)("strong", { children: t('title') }), ready !== undefined && (0, jsx_runtime_1.jsx)("small", { children: t(ready.site) })] }), (0, jsx_runtime_1.jsx)("p", { children: ready === undefined ? t(state?.status ?? 'loading') : t('holdHint') }), ready !== undefined && (0, jsx_runtime_1.jsx)(fields_1.ModelFields, { ready: ready, preferences: preferences, t: t }), (0, jsx_runtime_1.jsxs)("div", { className: "vvi-fields", children: [(0, jsx_runtime_1.jsxs)("label", { children: [t('microphone'), (0, jsx_runtime_1.jsx)(fields_1.MicrophonePicker, { preferences: preferences, t: t })] }), (0, jsx_runtime_1.jsxs)("div", { className: "vvi-quick-shortcut", children: [(0, jsx_runtime_1.jsx)("span", { children: t('shortcut') }), (0, jsx_runtime_1.jsx)(fields_1.ShortcutField, { preferences: preferences, t: t })] })] }), (0, jsx_runtime_1.jsx)("footer", { children: (0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => { setExpanded(false); openSettings(); }, children: [t('openSettings'), " ", (0, jsx_runtime_1.jsx)("span", { "aria-hidden": "true", children: "\u203A" })] }) })] })] }), document.body)] });
911
+ }
912
+
913
+ },
914
+ "./settings": function (module, exports, require) {
915
+ "use strict";
916
+ Object.defineProperty(exports, "__esModule", { value: true });
917
+ exports.VoiceSettings = VoiceSettings;
918
+ const jsx_runtime_1 = require("react/jsx-runtime");
919
+ const react_1 = require("react");
920
+ const fields_1 = require("./fields");
921
+ const shared_1 = require("./shared");
922
+ /**
923
+ * Release the local audio graph, microphone and timers. No recording is created.
924
+ * 释放本地音频图、麦克风和计时器。测试过程不创建录音。
925
+ */
926
+ function disposeTest(test) {
927
+ if (test === undefined)
928
+ return;
929
+ if (test.frame !== undefined)
930
+ cancelAnimationFrame(test.frame);
931
+ window.clearTimeout(test.timeout);
932
+ (0, shared_1.releaseMicrophone)(test.stream);
933
+ if (test.context !== undefined)
934
+ void test.context.close().catch(() => { });
935
+ }
936
+ /**
937
+ * Show the regional service status and let the user select and test a local microphone.
938
+ * 显示区域服务状态,并允许用户选择和测试本地麦克风。
939
+ */
940
+ function VoiceSettings({ status, preferences, openAccount, t }) {
941
+ // Settings and the composer read one service status and share one local device preference.
942
+ // 设置和输入框共用一份服务状态及本地设备偏好。
943
+ const state = (0, react_1.useSyncExternalStore)(status.subscribe, status.getSnapshot);
944
+ const ready = state?.status === 'ready' ? state : undefined;
945
+ const authorization = ready?.authorizationId;
946
+ const [deviceRevision, setDeviceRevision] = (0, react_1.useState)(0);
947
+ const [testing, setTesting] = (0, react_1.useState)(false);
948
+ const [level, setLevel] = (0, react_1.useState)(0);
949
+ const [problem, setProblem] = (0, react_1.useState)('');
950
+ const test = (0, react_1.useRef)(undefined);
951
+ const media = navigator.mediaDevices;
952
+ (0, react_1.useEffect)(() => {
953
+ /**
954
+ * End the local test when this settings page is no longer visible.
955
+ * 设置页失去可见性时结束本地测试。
956
+ */
957
+ const onVisibility = () => {
958
+ if (!document.hidden)
959
+ return;
960
+ disposeTest(test.current);
961
+ test.current = undefined;
962
+ setTesting(false);
963
+ setLevel(0);
964
+ };
965
+ document.addEventListener('visibilitychange', onVisibility);
966
+ return () => {
967
+ document.removeEventListener('visibilitychange', onVisibility);
968
+ disposeTest(test.current);
969
+ test.current = undefined;
970
+ };
971
+ }, []);
972
+ (0, react_1.useEffect)(() => {
973
+ if (test.current === undefined)
974
+ return;
975
+ disposeTest(test.current);
976
+ test.current = undefined;
977
+ setTesting(false);
978
+ setLevel(0);
979
+ }, [authorization]);
980
+ /**
981
+ * Stop testing and reset the level while retaining the device selection.
982
+ * 停止测试并重置电平,保留设备选择。
983
+ */
984
+ function stopTest() {
985
+ disposeTest(test.current);
986
+ test.current = undefined;
987
+ setTesting(false);
988
+ setLevel(0);
989
+ }
990
+ /**
991
+ * Show a short live level test after an explicit click. The graph has no output destination.
992
+ * 用户明确点击后进行短时实时电平测试,音频图不连接输出设备。
993
+ */
994
+ async function startTest() {
995
+ if (media === undefined || typeof AudioContext === 'undefined' || ready === undefined || test.current !== undefined)
996
+ return;
997
+ const operation = {};
998
+ test.current = operation;
999
+ setTesting(true);
1000
+ setProblem('');
1001
+ try {
1002
+ const stream = await (0, shared_1.openMicrophone)(preferences.getDevice());
1003
+ if (test.current !== operation) {
1004
+ (0, shared_1.releaseMicrophone)(stream);
1005
+ return;
1006
+ }
1007
+ operation.stream = stream;
1008
+ setDeviceRevision(value => value + 1);
1009
+ const context = new AudioContext();
1010
+ operation.context = context;
1011
+ const analyser = context.createAnalyser();
1012
+ analyser.fftSize = 512;
1013
+ context.createMediaStreamSource(stream).connect(analyser);
1014
+ const samples = new Float32Array(analyser.fftSize);
1015
+ operation.timeout = window.setTimeout(stopTest, 8000);
1016
+ await context.resume();
1017
+ if (test.current !== operation)
1018
+ return;
1019
+ /**
1020
+ * Read the RMS level and update the meter while this test is active.
1021
+ * 测试活动期间读取均方根电平并更新电平表。
1022
+ */
1023
+ const measure = () => {
1024
+ if (test.current !== operation)
1025
+ return;
1026
+ analyser.getFloatTimeDomainData(samples);
1027
+ const power = samples.reduce((sum, sample) => sum + sample * sample, 0);
1028
+ setLevel(Math.min(100, Math.round(Math.sqrt(power / samples.length) * 300)));
1029
+ operation.frame = requestAnimationFrame(measure);
1030
+ };
1031
+ measure();
1032
+ }
1033
+ catch {
1034
+ if (test.current === operation) {
1035
+ stopTest();
1036
+ setProblem(t('micError'));
1037
+ }
1038
+ }
1039
+ }
1040
+ // The status reports a site only when the host confirmed the account.
1041
+ // 只有宿主确认账号时,状态才包含站点。
1042
+ const site = state?.status === 'ready' || state?.status === 'disabled' ? state.site : undefined;
1043
+ const unavailable = media === undefined || typeof AudioContext === 'undefined';
1044
+ return (0, jsx_runtime_1.jsxs)("div", { className: "vvi-settings", "data-vulcan-settings-width": "full", children: [(0, jsx_runtime_1.jsxs)("header", { className: "vvi-head", "data-vulcan-settings-header": true, children: [(0, jsx_runtime_1.jsx)("h2", { children: t('title') }), (0, jsx_runtime_1.jsx)("p", { children: t('lead') })] }), (0, jsx_runtime_1.jsxs)("section", { className: "vvi-card", children: [(0, jsx_runtime_1.jsx)("h3", { children: t('service') }), (0, jsx_runtime_1.jsx)("p", { children: t('serviceHint') }), (0, jsx_runtime_1.jsxs)("div", { className: "vvi-row", children: [(0, jsx_runtime_1.jsxs)("div", { className: "vvi-state", role: "status", children: [(0, jsx_runtime_1.jsx)("span", { className: "vvi-badge", "data-voice-status": state?.status ?? 'loading', children: t(state?.status ?? 'loading') }), site !== undefined && (0, jsx_runtime_1.jsx)("span", { children: t(site) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "vvi-actions", children: [state?.status === 'signed_out' && (0, jsx_runtime_1.jsx)("button", { type: "button", className: "vvi-primary", onClick: openAccount, children: t('account') }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => { status.refresh(true); }, children: t('refresh') })] })] }), ready !== undefined && (0, jsx_runtime_1.jsx)(fields_1.ModelFields, { ready: ready, preferences: preferences, t: t }), ready !== undefined && (0, jsx_runtime_1.jsx)("small", { children: t('duration').replace('{seconds}', String(ready.maxSeconds)) })] }), (0, jsx_runtime_1.jsxs)("section", { className: "vvi-card", children: [(0, jsx_runtime_1.jsx)("h3", { children: t('microphone') }), (0, jsx_runtime_1.jsx)("p", { children: t('testHint') }), (0, jsx_runtime_1.jsxs)("div", { className: "vvi-row vvi-device-row", children: [(0, jsx_runtime_1.jsx)(fields_1.MicrophonePicker, { preferences: preferences, revision: deviceRevision, disabled: unavailable || testing, t: t }), (0, jsx_runtime_1.jsx)("button", { type: "button", disabled: ready === undefined || unavailable, onClick: () => {
1045
+ if (testing)
1046
+ stopTest();
1047
+ else
1048
+ void startTest();
1049
+ }, children: t(testing ? 'stopTest' : 'test') })] }), (0, jsx_runtime_1.jsx)("meter", { min: 0, max: 100, value: level, "aria-label": t('meter') }), unavailable && (0, jsx_runtime_1.jsx)("small", { children: t('unsupported') }), problem !== '' && (0, jsx_runtime_1.jsx)("small", { className: "vvi-error", role: "alert", children: problem })] }), (0, jsx_runtime_1.jsxs)("section", { className: "vvi-card", children: [(0, jsx_runtime_1.jsx)("h3", { children: t('shortcut') }), (0, jsx_runtime_1.jsx)("p", { children: t('shortcutHint') }), (0, jsx_runtime_1.jsx)(fields_1.ShortcutField, { preferences: preferences, t: t })] })] });
1050
+ }
1051
+
1052
+ },
1053
+ "./shared": function (module, exports, require) {
1054
+ "use strict";
1055
+ Object.defineProperty(exports, "__esModule", { value: true });
1056
+ exports.VoicePreferences = exports.VoiceStatus = exports.ACCOUNT_SECTION = exports.SECTION = exports.PACKAGE_NAME = void 0;
1057
+ exports.requestTranscript = requestTranscript;
1058
+ exports.openMicrophone = openMicrophone;
1059
+ exports.releaseMicrophone = releaseMicrophone;
1060
+ const shortcut_1 = require("./shortcut");
1061
+ // The dsh client bundler checks this identity against the package manifest.
1062
+ // dsh 客户端打包器会将此标识与包清单比对。
1063
+ exports.PACKAGE_NAME = '@openvulcan/dsh-vulcan-voice-input';
1064
+ // One same-origin route prefix and settings entry for the microphone UI.
1065
+ // 麦克风界面共用的同源路由前缀及设置页标识。
1066
+ const BASE = '/api/vulcan-voice-input/';
1067
+ exports.SECTION = 'vulcan-voice-input';
1068
+ exports.ACCOUNT_SECTION = 'vulcan-account';
1069
+ // Microphone IDs belong to a browser origin and stay on this device.
1070
+ // 麦克风标识属于浏览器来源,仅保存在本设备。
1071
+ const DEVICE_KEY = 'vulcan.dsh.voice-input-device.v1';
1072
+ // Keep preferences for each account site and published model separately.
1073
+ // 按账号站点与发布模型分别保存偏好。
1074
+ const MODEL_KEY = 'vulcan.dsh.voice-input-model.v1';
1075
+ const LANGUAGE_KEY = 'vulcan.dsh.voice-input-language.v1';
1076
+ const SHORTCUT_KEY = 'vulcan.dsh.voice-input-shortcut.v1';
1077
+ /**
1078
+ * A shared host status snapshot for the toolbar and settings page.
1079
+ * 输入框工具栏和设置页共用的宿主状态快照。
1080
+ */
1081
+ class VoiceStatus {
1082
+ // Start with no observed state and ignore replies from superseded requests.
1083
+ // 初始状态未知,并忽略已被替换的请求结果。
1084
+ #state;
1085
+ #request;
1086
+ #listeners = new Set();
1087
+ /**
1088
+ * Subscribe one rendered consumer and return its disposer.
1089
+ * 订阅一个已渲染的消费者并返回注销函数。
1090
+ */
1091
+ subscribe = (listener) => {
1092
+ this.#listeners.add(listener);
1093
+ return () => { this.#listeners.delete(listener); };
1094
+ };
1095
+ /**
1096
+ * Return the last observed status, or undefined before the first read.
1097
+ * 返回最近观察到的状态,首次读取前返回 undefined。
1098
+ */
1099
+ getSnapshot = () => this.#state;
1100
+ /**
1101
+ * Read the account status, optionally asking the Host to refresh the publication.
1102
+ * 读取账号状态,并可显式要求宿主刷新发布目录。
1103
+ */
1104
+ refresh(force = false) {
1105
+ if (this.#request !== undefined && !force)
1106
+ return;
1107
+ this.#request?.abort();
1108
+ const request = new AbortController();
1109
+ this.#request = request;
1110
+ void fetch(`${BASE}state${force ? '?refresh=1' : ''}`, {
1111
+ signal: request.signal, cache: 'no-store',
1112
+ }).then(async (response) => {
1113
+ if (!response.ok)
1114
+ throw new Error('Voice status unavailable');
1115
+ return await response.json();
1116
+ }).then(state => {
1117
+ if (this.#request !== request)
1118
+ return;
1119
+ this.#publish(state);
1120
+ }, () => {
1121
+ if (this.#request !== request)
1122
+ return;
1123
+ this.#publish({ status: 'unavailable' });
1124
+ }).finally(() => {
1125
+ if (this.#request === request)
1126
+ this.#request = undefined;
1127
+ });
1128
+ }
1129
+ /**
1130
+ * Invalidate the snapshot across host connections and drop pending replies.
1131
+ * 在宿主连接切换时使快照失效,并丢弃未完成的响应。
1132
+ */
1133
+ reset() {
1134
+ this.#request?.abort();
1135
+ this.#request = undefined;
1136
+ this.#publish(undefined);
1137
+ }
1138
+ /**
1139
+ * Publish a new snapshot to mounted consumers.
1140
+ * 向已挂载的消费者发布新快照。
1141
+ */
1142
+ #publish(state) {
1143
+ this.#state = state;
1144
+ for (const listener of this.#listeners)
1145
+ listener();
1146
+ }
1147
+ }
1148
+ exports.VoiceStatus = VoiceStatus;
1149
+ /**
1150
+ * Send an authorization-bound microphone WAV to the owning dsh Host.
1151
+ * 将绑定至设备授权的麦克风 WAV 发给所属 dsh 宿主。
1152
+ */
1153
+ async function requestTranscript(wav, authorizationId, selection, signal) {
1154
+ const response = await fetch(`${BASE}transcriptions`, {
1155
+ method: 'POST', body: wav, signal, cache: 'no-store',
1156
+ headers: {
1157
+ 'content-type': 'audio/wav', 'x-vulcan-voice-authorization': authorizationId,
1158
+ 'x-vulcan-voice-model': selection.modelId,
1159
+ ...(selection.language === undefined ? {} : { 'x-vulcan-asr-language': selection.language }),
1160
+ },
1161
+ });
1162
+ return await response.json();
1163
+ }
1164
+ /**
1165
+ * Share local voice preferences between settings and all composer cards.
1166
+ * 在设置页与所有输入框浮层之间共享本地语音偏好。
1167
+ */
1168
+ class VoicePreferences {
1169
+ // Memory also keeps the selected values usable when browser storage is unavailable.
1170
+ // 浏览器存储不可用时,仍在内存保留当前选择。
1171
+ #values = new Map();
1172
+ #listeners = new Set();
1173
+ #revision = 0;
1174
+ /**
1175
+ * Register a listener and return its disposer.
1176
+ * 注册监听器并返回注销函数。
1177
+ */
1178
+ subscribe = (listener) => {
1179
+ this.#listeners.add(listener);
1180
+ return () => { this.#listeners.delete(listener); };
1181
+ };
1182
+ /**
1183
+ * Return a stable snapshot until a preference changes.
1184
+ * 在偏好变更前返回稳定的快照。
1185
+ */
1186
+ getSnapshot = () => this.#revision;
1187
+ /**
1188
+ * Return the selected device ID, or an empty string for system default.
1189
+ * 返回所选设备标识,系统默认时返回空字符串。
1190
+ */
1191
+ getDevice() { return this.#read(DEVICE_KEY); }
1192
+ /**
1193
+ * Save a device ID or clear it to use the system default.
1194
+ * 保存设备标识,或清空以使用系统默认。
1195
+ */
1196
+ setDevice(device) { this.#write(DEVICE_KEY, device); }
1197
+ /**
1198
+ * Return the saved model ID, or an empty string to follow the site default.
1199
+ * 返回已保存的模型标识,空字符串表示跟随站点默认。
1200
+ */
1201
+ getModelChoice(site) { return this.#read(`${MODEL_KEY}:${site}`); }
1202
+ /**
1203
+ * Save a preferred model ID for this site, or clear it to follow the site default.
1204
+ * 保存当前站点的首选模型标识,或清空以跟随站点默认。
1205
+ */
1206
+ setModelChoice(site, id) { this.#write(`${MODEL_KEY}:${site}`, id); }
1207
+ /**
1208
+ * Save a language hint for this published model, or clear it for automatic detection.
1209
+ * 为当前发布模型保存优先语言,或清空以自动识别。
1210
+ */
1211
+ setLanguage(site, id, language) { this.#write(`${LANGUAGE_KEY}:${site}:${id}`, language); }
1212
+ /**
1213
+ * Resolve the recorded preference against the site default while preserving withdrawn selections.
1214
+ * 结合站点默认值解析已保存的偏好,并保留已下架的选择。
1215
+ */
1216
+ selection(site, catalog) {
1217
+ const choice = this.getModelChoice(site);
1218
+ const modelId = choice === '' ? catalog.defaultModelId : choice;
1219
+ const language = this.#read(`${LANGUAGE_KEY}:${site}:${modelId}`);
1220
+ return { modelId, ...(language === '' ? {} : { language }) };
1221
+ }
1222
+ /**
1223
+ * Return the local push-to-talk chord, or an empty string when unset.
1224
+ * 返回本地按住说话组合键,未设置时返回空字符串。
1225
+ */
1226
+ getShortcut() { return (0, shortcut_1.normalizeShortcut)(this.#read(SHORTCUT_KEY)); }
1227
+ /**
1228
+ * Store a canonical shortcut chord, or remove it.
1229
+ * 保存规范的快捷键组合,或清除它。
1230
+ */
1231
+ setShortcut(chord) { this.#write(SHORTCUT_KEY, chord); }
1232
+ /**
1233
+ * Read one saved string with an in-memory value for the current window.
1234
+ * 读取已保存的字符串,并在当前窗口保留内存值。
1235
+ */
1236
+ #read(key) {
1237
+ const cached = this.#values.get(key);
1238
+ if (cached !== undefined)
1239
+ return cached;
1240
+ let value = '';
1241
+ try {
1242
+ value = localStorage.getItem(key) ?? '';
1243
+ }
1244
+ catch { /* Storage may be blocked. 存储可能被禁用。 */ }
1245
+ this.#values.set(key, value);
1246
+ return value;
1247
+ }
1248
+ /**
1249
+ * Update one preference and notify mounted selectors.
1250
+ * 更新一项偏好并通知已挂载的选择器。
1251
+ */
1252
+ #write(key, value) {
1253
+ if (value === this.#read(key))
1254
+ return;
1255
+ this.#values.set(key, value);
1256
+ try {
1257
+ if (value === '')
1258
+ localStorage.removeItem(key);
1259
+ else
1260
+ localStorage.setItem(key, value);
1261
+ }
1262
+ catch { /* The selection remains active in this window. 选择在当前窗口仍然有效。 */ }
1263
+ this.#revision += 1;
1264
+ for (const listener of this.#listeners)
1265
+ listener();
1266
+ }
1267
+ }
1268
+ exports.VoicePreferences = VoicePreferences;
1269
+ /**
1270
+ * Open the selected microphone exactly, or the system default if no device was selected.
1271
+ * 精确打开已选麦克风,未选择时打开系统默认设备。
1272
+ */
1273
+ function openMicrophone(device) {
1274
+ return navigator.mediaDevices.getUserMedia({ audio: device === '' ? true : { deviceId: { exact: device } } });
1275
+ }
1276
+ /**
1277
+ * Release every track held by a cancelled or completed operation.
1278
+ * 释放已取消或已完成操作持有的所有音轨。
1279
+ */
1280
+ function releaseMicrophone(stream) {
1281
+ stream?.getTracks().forEach(track => track.stop());
1282
+ }
1283
+
1284
+ },
1285
+ "./shortcut": function (module, exports, require) {
1286
+ "use strict";
1287
+ Object.defineProperty(exports, "__esModule", { value: true });
1288
+ exports.isModifierCode = isModifierCode;
1289
+ exports.normalizeShortcut = normalizeShortcut;
1290
+ exports.shortcutFromKey = shortcutFromKey;
1291
+ exports.releasesShortcut = releasesShortcut;
1292
+ exports.shortcutLabel = shortcutLabel;
1293
+ // Match VulcanCode's physical keys while keeping ordinary typing available.
1294
+ // 匹配 VulcanCode 的物理按键,并保留普通键入。
1295
+ const PRIMARY_KEY = /^(?:Key[A-Z]|Digit[0-9]|Space|F(?:[1-9]|1[0-9]|2[0-4]))$/;
1296
+ const MODIFIER_KEY = /^(?:Control|Alt|Shift|Meta)(?:Left|Right)$/;
1297
+ const MODIFIERS = [
1298
+ ['ctrlKey', 'Control'], ['altKey', 'Alt'], ['shiftKey', 'Shift'], ['metaKey', 'Meta'],
1299
+ ];
1300
+ // Label physical punctuation and numpad keys independently of keyboard layout.
1301
+ // 使用与键盘布局无关的标签显示物理标点及数字键盘按键。
1302
+ const SYMBOLS = {
1303
+ Backquote: '`', Minus: '-', Equal: '=', BracketLeft: '[', BracketRight: ']', Backslash: '\\',
1304
+ Semicolon: ';', Quote: "'", Comma: ',', Period: '.', Slash: '/', IntlBackslash: '\\',
1305
+ IntlRo: '\\', IntlYen: '¥', NumpadAdd: '+', NumpadSubtract: '-', NumpadMultiply: '*',
1306
+ NumpadDivide: '/', NumpadDecimal: '.', NumpadEqual: '=', NumpadComma: ',',
1307
+ };
1308
+ /**
1309
+ * Identify whether the supplied physical code is a left or right modifier.
1310
+ * 判断传入的物理键码是否为左侧或右侧修饰键。
1311
+ */
1312
+ function isModifierCode(code) {
1313
+ return MODIFIER_KEY.test(code);
1314
+ }
1315
+ /**
1316
+ * Return the canonical form of a saved binding, or an empty string when invalid.
1317
+ * 返回已保存绑定的规范形式,无效时返回空字符串。
1318
+ */
1319
+ function normalizeShortcut(shortcut) {
1320
+ if (isModifierCode(shortcut))
1321
+ return shortcut;
1322
+ // Older dsh voice versions stored Ctrl where VulcanCode stores Control.
1323
+ // 旧版 dsh 语音以 Ctrl 表示 VulcanCode 的 Control。
1324
+ const canonical = shortcut.replace(/^Ctrl\+/, 'Control+');
1325
+ const modifiers = canonical.split('+');
1326
+ const code = modifiers.pop();
1327
+ const ordered = MODIFIERS.map(([, name]) => name).filter(name => modifiers.includes(name));
1328
+ return code !== undefined && (PRIMARY_KEY.test(code) || Object.hasOwn(SYMBOLS, code))
1329
+ && modifiers.join('+') === ordered.join('+')
1330
+ && (code.startsWith('F') || ordered.some(name => name !== 'Shift')) ? canonical : '';
1331
+ }
1332
+ /**
1333
+ * Return the binding represented by a keydown event, excluding IME and AltGr input.
1334
+ * 返回按下事件代表的绑定,排除输入法及 AltGr 输入。
1335
+ */
1336
+ function shortcutFromKey(event) {
1337
+ if (event.isComposing || event.key === 'AltGraph' || event.getModifierState('AltGraph'))
1338
+ return '';
1339
+ const held = MODIFIERS.filter(([field]) => event[field]).map(([, name]) => name);
1340
+ if (isModifierCode(event.code))
1341
+ return held.every(name => event.code.startsWith(name)) ? event.code : '';
1342
+ return normalizeShortcut([...held, event.code].join('+'));
1343
+ }
1344
+ /**
1345
+ * Recognize the release of the saved physical key or a participating chord modifier.
1346
+ * 识别已绑定物理键或组合键修饰键的松开。
1347
+ */
1348
+ function releasesShortcut(shortcut, event) {
1349
+ if (isModifierCode(shortcut))
1350
+ return event.code === shortcut;
1351
+ const parts = shortcut.split('+');
1352
+ return parts.includes(event.code) || parts.includes(event.key);
1353
+ }
1354
+ /**
1355
+ * Format a saved binding with localized sides and readable physical key labels.
1356
+ * 使用本地化左右方向与易读物理键名显示已保存的绑定。
1357
+ */
1358
+ function shortcutLabel(shortcut, t) {
1359
+ if (isModifierCode(shortcut)) {
1360
+ const key = shortcut.replace(/(?:Left|Right)$/, '').replace(/^Control$/, 'Ctrl');
1361
+ return t(shortcut.endsWith('Left') ? 'shortcutLeft' : 'shortcutRight').replace('{key}', key);
1362
+ }
1363
+ return shortcut.split('+').map(code => {
1364
+ if (code === 'Control')
1365
+ return 'Ctrl';
1366
+ const label = SYMBOLS[code] ?? code.replace(/^(?:Key|Digit)(?=[A-Z0-9]$)/, '');
1367
+ return code.startsWith('Numpad') ? t('shortcutNumpad').replace('{key}', label) : label;
1368
+ }).join('+');
1369
+ }
1370
+
1371
+ },
1372
+ "./style": function (module, exports, require) {
1373
+ "use strict";
1374
+ Object.defineProperty(exports, "__esModule", { value: true });
1375
+ exports.installStyle = installStyle;
1376
+ // Scope the Vulcan cards and compact controls to the voice plugin and follow the active host palette.
1377
+ // 将 Vulcan 卡片和紧凑控件限定在语音插件范围内,并跟随当前宿主配色。
1378
+ const CSS = String.raw `
1379
+ :is(.vvi-settings, .vvi-composer, .vvi-quick, .vvi-feedback) {
1380
+ --voice-ink: var(--vulcan-ink, var(--dsw-alias-label-primary));
1381
+ --voice-muted: var(--vulcan-ink-2, var(--dsw-alias-label-secondary));
1382
+ --voice-panel: var(--vulcan-panel, var(--dsw-alias-bg-layer-1));
1383
+ --voice-line: var(--vulcan-line, var(--dsw-alias-border-l3));
1384
+ --voice-focus: var(--dsw-alias-state-business-primary);
1385
+ --voice-error: var(--dsw-alias-state-error-primary);
1386
+ --voice-ember: var(--vulcan-ember, #e2511a);
1387
+ --voice-ready: #18774a;
1388
+ color: var(--voice-ink);
1389
+ }
1390
+ body[data-ds-dark-theme] :is(.vvi-settings, .vvi-composer, .vvi-quick, .vvi-feedback) {
1391
+ --voice-ember: var(--vulcan-ember, #ff6a2b);
1392
+ --voice-ready: #3fc48a;
1393
+ color-scheme: dark;
1394
+ }
1395
+ .vvi-settings { display: grid; gap: 16px; min-width: 0; padding-bottom: 8px; font-size: 13px; line-height: 1.5; }
1396
+ .vvi-head h2, .vvi-card h3 { margin: 0; font-weight: 600; }
1397
+ .vvi-head h2 { font-size: 16px; }
1398
+ .vvi-card h3 { font-size: 14px; }
1399
+ .vvi-settings p, .vvi-settings small { margin: 0; color: var(--voice-muted); }
1400
+ .vvi-head p { margin-top: 4px; }
1401
+ .vvi-card {
1402
+ position: relative; display: grid; gap: 10px; min-width: 0; padding: 14px 16px;
1403
+ border: 1px solid var(--voice-line); background: var(--voice-panel);
1404
+ }
1405
+ .vvi-card::before { position: absolute; top: -1px; left: -1px; width: 28px; height: 2px; background: var(--voice-ember); content: ''; }
1406
+ .vvi-row, .vvi-state, .vvi-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-width: 0; }
1407
+ .vvi-row { justify-content: space-between; gap: 10px 16px; }
1408
+ .vvi-device-row { justify-content: flex-start; }
1409
+ .vvi-state { color: var(--voice-muted); }
1410
+ .vvi-badge {
1411
+ padding: 1px 6px; border: 1px solid var(--voice-line); color: var(--voice-muted);
1412
+ font-size: 11px; font-weight: 600;
1413
+ }
1414
+ .vvi-badge[data-voice-status=ready] { color: var(--voice-ready); }
1415
+ .vvi-badge[data-voice-status=signed_out] { color: var(--voice-ember); }
1416
+ :is(.vvi-settings, .vvi-quick) :is(button, select, input), :is(.vvi-composer, .vvi-feedback) button {
1417
+ box-sizing: border-box; min-height: 28px; padding: 3px 8px; border: 1px solid var(--voice-line);
1418
+ border-radius: 0; background: var(--voice-panel); color: var(--voice-ink); font: inherit;
1419
+ }
1420
+ :is(.vvi-settings, .vvi-composer, .vvi-quick, .vvi-feedback) button { cursor: pointer; }
1421
+ :is(.vvi-settings, .vvi-composer, .vvi-quick, .vvi-feedback) button:hover:not(:disabled) {
1422
+ border-color: var(--voice-focus);
1423
+ }
1424
+ :is(.vvi-settings, .vvi-quick) :is(button, select):disabled { opacity: .6; cursor: default; }
1425
+ .vvi-settings .vvi-primary { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); }
1426
+ :is(.vvi-settings, .vvi-quick) :is(button, select, input):focus-visible, :is(.vvi-composer, .vvi-feedback) button:focus-visible {
1427
+ outline: 2px solid var(--voice-focus); outline-offset: 2px;
1428
+ }
1429
+ .vvi-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); gap: 10px 14px; }
1430
+ .vvi-fields > :is(label, .vvi-quick-shortcut) { display: grid; gap: 4px; min-width: 0; color: var(--voice-muted); }
1431
+ .vvi-fields select, .vvi-shortcut-row input { min-width: 0; width: 100%; }
1432
+ .vvi-shortcut-row { display: flex; gap: 8px; align-items: center; max-width: 380px; }
1433
+ .vvi-shortcut-row button { flex: none; }
1434
+ .vvi-settings .vvi-device-row select { flex: 1 1 200px; max-width: 380px; min-width: 0; }
1435
+ .vvi-settings meter { display: block; width: 100%; height: 8px; border: 0; border-radius: 0; background: var(--voice-line); }
1436
+ .vvi-settings meter::-webkit-meter-bar { height: 8px; border: 0; border-radius: 0; background: var(--voice-line); }
1437
+ .vvi-settings meter::-webkit-meter-optimum-value { border-radius: 0; background: var(--voice-ready); }
1438
+ .vvi-settings meter::-moz-meter-bar { background: var(--voice-ready); }
1439
+ .vvi-settings .vvi-error { color: var(--voice-error); }
1440
+ .vvi-composer { display: inline-flex; align-items: center; font-size: 12px; }
1441
+ .vvi-composer .vvi-mic { display: inline-flex; align-items: center; justify-content: center; width: 28px; padding: 0; touch-action: none; }
1442
+ .vvi-composer .vvi-mic[aria-pressed=true] { color: var(--voice-error); border-color: currentColor; }
1443
+ .vvi-recording { display: inline-flex; align-items: center; gap: 5px; color: var(--voice-error); }
1444
+ .vvi-timer { min-width: 4ch; font-variant-numeric: tabular-nums; text-align: right; }
1445
+ .vvi-dot { width: 6px; height: 6px; background: currentColor; }
1446
+ .vvi-notice { max-width: 18rem; color: var(--voice-muted); }
1447
+ :is(.vvi-quick, .vvi-feedback) {
1448
+ position: fixed; z-index: 60; box-sizing: border-box; overflow: auto;
1449
+ border: 1px solid var(--vulcan-line-strong, var(--voice-line)); border-radius: 0;
1450
+ background: var(--voice-panel); box-shadow: var(--vulcan-shadow-2, 0 8px 24px #0003);
1451
+ font-size: 12px; line-height: 1.4;
1452
+ }
1453
+ .vvi-quick { width: 336px; display: grid; gap: 10px; padding: 12px; }
1454
+ .vvi-feedback { width: max-content; padding: 6px 8px; }
1455
+ .vvi-feedback-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; overflow-wrap: anywhere; }
1456
+ .vvi-feedback-row button { flex: none; min-height: 24px; white-space: nowrap; }
1457
+ .vvi-quick > .vvi-feedback-row { padding-bottom: 8px; border-bottom: 1px solid var(--voice-line); }
1458
+ .vvi-quick::before { position: absolute; top: 0; left: 0; width: 28px; height: 2px; background: var(--voice-ember); content: ''; }
1459
+ .vvi-quick-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
1460
+ .vvi-quick-head strong { font-size: 13px; font-weight: 600; }
1461
+ .vvi-quick p, .vvi-quick small { margin: 0; color: var(--voice-muted); }
1462
+ .vvi-quick footer { display: flex; justify-content: flex-end; border-top: 1px solid var(--voice-line); padding-top: 8px; }
1463
+ `;
1464
+ /**
1465
+ * Mount the scoped voice styles and return a disposer for plugin reload.
1466
+ * 挂载语音模块限定样式,并返回用于插件重载的注销函数。
1467
+ */
1468
+ function installStyle() {
1469
+ const node = document.createElement('style');
1470
+ node.textContent = CSS;
1471
+ document.head.append(node);
1472
+ return () => node.remove();
1473
+ }
1474
+
1475
+ },
1476
+ "./wav": function (module, exports, require) {
1477
+ "use strict";
1478
+ Object.defineProperty(exports, "__esModule", { value: true });
1479
+ exports.encodeVoiceInputWav = encodeVoiceInputWav;
1480
+ // Resample speech at 16 kHz to keep the full recording within the ASR request size limit.
1481
+ // 语音重采样至 16 kHz,使最长录音也能满足语音识别请求体积限制。
1482
+ const VOICE_SAMPLE_RATE = 16_000;
1483
+ // A PCM WAV file uses a 44-byte header before the sample data.
1484
+ // PCM WAV 文件在采样数据之前使用 44 字节的文件头。
1485
+ const WAV_HEADER_BYTES = 44;
1486
+ // The PCM format uses signed 16-bit samples, stored as two little-endian bytes.
1487
+ // PCM 格式使用有符号 16 位采样,每个采样占两个小端字节。
1488
+ const PCM_SAMPLE_BYTES = 2;
1489
+ /**
1490
+ * Decode the finished browser recording and return one mono PCM WAV blob for transcription.
1491
+ * 解码已录制完成的浏览器音频,返回一个供转写使用的单声道 PCM WAV Blob。
1492
+ * @param recording A complete recording produced by MediaRecorder.
1493
+ * recording 是 MediaRecorder 生成的完整录音。
1494
+ * @param maxSeconds The site's maximum recording duration, in seconds.
1495
+ * maxSeconds 是站点允许的最长录音时长,单位为秒。
1496
+ * @returns A 16 kHz WAV blob, or null when every encoded PCM sample is silent.
1497
+ * 返回采样率为 16 kHz 的 WAV Blob;编码后的 PCM 采样全为静音时返回 null。
1498
+ */
1499
+ async function encodeVoiceInputWav(recording, maxSeconds) {
1500
+ // The offline decoder resamples to its context rate without playing the microphone recording.
1501
+ // 离线解码器会重采样至上下文的采样率,且不会播放麦克风录音。
1502
+ const decoder = new OfflineAudioContext(1, 1, VOICE_SAMPLE_RATE);
1503
+ // The decoded PCM buffer keeps its original channel count.
1504
+ // 解码后的 PCM 缓冲区保留原声道数量。
1505
+ const audio = await decoder.decodeAudioData(await recording.arrayBuffer());
1506
+ // Store one PCM sample per decoded frame after mixing the channels.
1507
+ // 混合声道后,每个解码帧保存一个 PCM 采样。
1508
+ const frames = Math.min(audio.length, maxSeconds * VOICE_SAMPLE_RATE);
1509
+ const sampleBytes = frames * PCM_SAMPLE_BYTES;
1510
+ // Allocate the header and sample data together for a single WAV blob.
1511
+ // 为文件头和采样数据分配同一块缓冲区,生成一个 WAV Blob。
1512
+ const bytes = new ArrayBuffer(WAV_HEADER_BYTES + sampleBytes);
1513
+ // The view writes the RIFF identifiers and little-endian PCM fields into that buffer.
1514
+ // 通过该视图将 RIFF 标识和小端 PCM 字段写入缓冲区。
1515
+ const wav = new DataView(bytes);
1516
+ wav.setUint32(0, 0x52494646); // RIFF
1517
+ wav.setUint32(4, bytes.byteLength - 8, true);
1518
+ wav.setUint32(8, 0x57415645); // WAVE
1519
+ wav.setUint32(12, 0x666d7420); // fmt
1520
+ wav.setUint32(16, 16, true);
1521
+ wav.setUint16(20, 1, true);
1522
+ wav.setUint16(22, 1, true);
1523
+ wav.setUint32(24, audio.sampleRate, true);
1524
+ wav.setUint32(28, audio.sampleRate * PCM_SAMPLE_BYTES, true);
1525
+ wav.setUint16(32, PCM_SAMPLE_BYTES, true);
1526
+ wav.setUint16(34, PCM_SAMPLE_BYTES * 8, true);
1527
+ wav.setUint32(36, 0x64617461); // data
1528
+ wav.setUint32(40, sampleBytes, true);
1529
+ // Average channels before quantizing so either input channel can carry speech.
1530
+ // 量化前取各声道平均值,保留任一输入声道上的语音。
1531
+ const channels = Array.from({ length: audio.numberOfChannels }, (_, channel) => audio.getChannelData(channel));
1532
+ // Use the encoded PCM to detect silence so even the quietest representable sample is kept.
1533
+ // 按编码后的 PCM 检测静音,保留所有可表示的微弱声音。
1534
+ let hasSignal = false;
1535
+ for (let frame = 0; frame < frames; frame += 1) {
1536
+ // Sum this frame across all channels for the mono mix.
1537
+ // 累加本帧的各声道采样,用于混合成单声道。
1538
+ let sum = 0;
1539
+ // All channels have audio.length samples, and frames is capped at that length.
1540
+ // 每个声道都有 audio.length 个采样,frames 已限制在该长度内。
1541
+ for (const channel of channels)
1542
+ sum += channel[frame];
1543
+ // Clamp the quantized average so full-scale samples cannot wrap around.
1544
+ // 限制量化后平均值的范围,避免满幅采样发生溢出回绕。
1545
+ const sample = Math.round(Math.max(-32_768, Math.min(32_767, sum / channels.length * 32_768)));
1546
+ wav.setInt16(WAV_HEADER_BYTES + frame * PCM_SAMPLE_BYTES, sample, true);
1547
+ if (sample !== 0)
1548
+ hasSignal = true;
1549
+ }
1550
+ return hasSignal ? new Blob([bytes], { type: "audio/wav" }) : null;
1551
+ }
1552
+
1553
+ },
1554
+ };
1555
+ var cache = {};
1556
+ function load(request) {
1557
+ if (!(request in definitions)) return platformRequire(request);
1558
+ if (!(request in cache)) {
1559
+ var module = { exports: {} };
1560
+ cache[request] = module;
1561
+ definitions[request](module, module.exports, load);
1562
+ }
1563
+ return cache[request].exports;
1564
+ }
1565
+ return load('./index');
1566
+ }
1567
+ });