@nextclaw/ui 0.25.1 → 0.25.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/dist/.vite/manifest.json +190 -190
- package/dist/assets/{account-panel-QhOMbAQP.js → account-panel-BXTCFv7d.js} +1 -1
- package/dist/assets/{api-BWFOIoUm.js → api-DqzQ-_3w.js} +1 -1
- package/dist/assets/{app-data-BnrEdmg2.js → app-data-C54N2p8b.js} +1 -1
- package/dist/assets/app-presenter-provider-V0xnU_Wb.js +115 -0
- package/dist/assets/{appearance-settings-page-C5z5sOkC.js → appearance-settings-page-83T2AtVm.js} +1 -1
- package/dist/assets/{channels-list-page-CzXYZZtK.js → channels-list-page-Bv7CVSqV.js} +1 -1
- package/dist/assets/{config-split-page-CxLWGwdt.js → config-split-page-DqAXKLmG.js} +1 -1
- package/dist/assets/{confirm-dialog-BWS7gA_z.js → confirm-dialog-BTKCWMn6.js} +1 -1
- package/dist/assets/{desktop-capabilities-page-BJNGkq4q.js → desktop-capabilities-page-zLzCSB7A.js} +1 -1
- package/dist/assets/{desktop-update-config-7tBum3MA.js → desktop-update-config-PmofcFf6.js} +1 -1
- package/dist/assets/{doc-browser-BNiqa-zm.js → doc-browser-BJrre2S_.js} +1 -1
- package/dist/assets/doc-browser-BPqA-zQx.js +1 -0
- package/dist/assets/{doc-browser-context-Dp0dEMyT.js → doc-browser-context-TILuCY5c.js} +1 -1
- package/dist/assets/{extensions-WWEYpaUh.js → extensions-CVUFK6Jj.js} +1 -1
- package/dist/assets/{i18n-ClHx6jwj.js → i18n-20rSH40D.js} +2 -2
- package/dist/assets/{i18n-provider-C1NxogO4.js → i18n-provider-BPRq6S7O.js} +1 -1
- package/dist/assets/{keyboard-shortcuts-settings-page-CS9BFOBW.js → keyboard-shortcuts-settings-page-CkQbSKwI.js} +1 -1
- package/dist/assets/login-page-Dlyvp-81.js +1 -0
- package/dist/assets/{login-page-CL0SrBCf.js → login-page-f8qQ4xKY.js} +1 -1
- package/dist/assets/main-D2dWy8fM.css +1 -0
- package/dist/assets/main-i18n-catalog.config-DZ-5ollN.js +1 -0
- package/dist/assets/{main-CvVBCOlN.js → main-ky5Efhq1.js} +2 -2
- package/dist/assets/{mcp-marketplace-page-D75hPmzO.js → mcp-marketplace-page-FfIfEo6w.js} +1 -1
- package/dist/assets/mcp-marketplace-page-sH7VptN_.js +1 -0
- package/dist/assets/{model-config-page-DFfcV9lu.js → model-config-page-Bcm6-pCX.js} +1 -1
- package/dist/assets/panel-app-i18n-catalog.config-p3qAY_Eq.js +1 -0
- package/dist/assets/{panelStandalone-D2CqTyi_.js → panelStandalone-By-6dDXS.js} +2 -2
- package/dist/assets/{privacy-settings-page-CE-xF9bo.js → privacy-settings-page-B1sHLAc1.js} +1 -1
- package/dist/assets/{projects-BF0wpY4X.js → projects-O3GTERwP.js} +1 -1
- package/dist/assets/{provider-scoped-model-input-BvZitHkT.js → provider-scoped-model-input-BQsugx1v.js} +1 -1
- package/dist/assets/{providers-config-page-cDeyluVG.js → providers-config-page-BDEdHJbw.js} +1 -1
- package/dist/assets/{pwa-dXn0s_lD.js → pwa-D_nZUwG0.js} +1 -1
- package/dist/assets/remote-CFWp4wyH.js +1 -0
- package/dist/assets/{runtime-config-page-DNo4dzS1.js → runtime-config-page-BHTKwgDi.js} +1 -1
- package/dist/assets/{search-config-page-CA0HxVy0.js → search-config-page-DWKlMHgH.js} +1 -1
- package/dist/assets/{secrets-config-page-iT1wamQp.js → secrets-config-page-DiQL088Y.js} +1 -1
- package/dist/assets/security-config-DmTDKKY4.js +1 -0
- package/dist/assets/{service-apps-panel-CU0tFrqT.js → service-apps-panel-CEnyYsQp.js} +1 -1
- package/dist/assets/{tag-chip-CY0Hqk_l.js → tag-chip-B1AYPOKL.js} +1 -1
- package/dist/assets/{theme-provider-BvpXGXWH.js → theme-provider-DzvAi3pe.js} +1 -1
- package/dist/assets/{use-config-C8ZkQiTc.js → use-config-BEdbtpD4.js} +1 -1
- package/dist/assets/{use-desktop-capabilities-DR9snbeo.js → use-desktop-capabilities-BkuRd2cY.js} +1 -1
- package/dist/index.html +18 -18
- package/dist/panel-standalone.html +5 -5
- package/package.json +5 -5
- package/src/features/chat/features/conversation/components/__tests__/session-voice-input.test-utils.ts +18 -5
- package/src/features/chat/features/conversation/components/chat-voice-input-panel.tsx +37 -14
- package/src/features/chat/features/conversation/components/session-conversation-input.tsx +3 -3
- package/src/features/chat/features/conversation/hooks/__tests__/use-chat-voice-input.test.tsx +116 -10
- package/src/features/chat/features/conversation/hooks/use-chat-voice-input.ts +15 -2
- package/src/features/chat/managers/chat-voice-input.manager.ts +26 -4
- package/src/features/chat/utils/chat-voice-permissions.utils.test.ts +36 -0
- package/src/features/chat/utils/chat-voice-permissions.utils.ts +31 -0
- package/src/shared/lib/i18n/locales/en-US/chat.json +18 -3
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +18 -3
- package/dist/assets/app-presenter-provider-CDRBc_lQ.js +0 -115
- package/dist/assets/doc-browser-DZDqghNc.js +0 -1
- package/dist/assets/login-page-Dq5nPUCU.js +0 -1
- package/dist/assets/main-CLwCjK9U.css +0 -1
- package/dist/assets/main-i18n-catalog.config-D4qFo4OB.js +0 -1
- package/dist/assets/mcp-marketplace-page-CATnpZ2Q.js +0 -1
- package/dist/assets/panel-app-i18n-catalog.config-oUwmxoE7.js +0 -1
- package/dist/assets/remote-CV5wQVVj.js +0 -1
- package/dist/assets/security-config-B38PLI3M.js +0 -1
package/src/features/chat/features/conversation/hooks/__tests__/use-chat-voice-input.test.tsx
CHANGED
|
@@ -26,21 +26,90 @@ const beginDraft = () => draft;
|
|
|
26
26
|
beforeEach(() => {
|
|
27
27
|
vi.useFakeTimers();
|
|
28
28
|
recognition = new Recognition();
|
|
29
|
-
manager = new ChatVoiceInputManager(() => recognition);
|
|
29
|
+
manager = new ChatVoiceInputManager(() => recognition, async () => null);
|
|
30
30
|
commit = vi.fn<(text?: string) => void>();
|
|
31
31
|
draft = { commit, update: vi.fn(), cancel: vi.fn() };
|
|
32
32
|
useChatVoiceShortcutStore.getState().setShortcut(null);
|
|
33
33
|
vi.spyOn(document, 'hasFocus').mockReturnValue(true);
|
|
34
34
|
});
|
|
35
35
|
afterEach(() => {
|
|
36
|
-
manager.cancel();
|
|
36
|
+
act(() => { manager.cancel(); });
|
|
37
37
|
vi.useRealTimers();
|
|
38
38
|
vi.restoreAllMocks();
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
describe('dictation lifecycle', () => {
|
|
42
|
-
it('
|
|
42
|
+
it('saves on exit only after the final tail arrives and closes without rolling back the draft', async () => {
|
|
43
|
+
manager.start('en', 'one', draft);
|
|
44
|
+
await Promise.resolve();
|
|
45
|
+
recognition.onstart?.();
|
|
46
|
+
recognition.result('hello');
|
|
47
|
+
manager.saveAndClose();
|
|
48
|
+
manager.saveAndClose();
|
|
49
|
+
expect(recognition.stop).toHaveBeenCalledOnce();
|
|
50
|
+
expect(commit).not.toHaveBeenCalled();
|
|
51
|
+
recognition.result('hello', 'tail');
|
|
52
|
+
recognition.onend?.();
|
|
53
|
+
expect(draft.update).toHaveBeenLastCalledWith('hello tail', '');
|
|
54
|
+
expect(commit).toHaveBeenCalledOnce();
|
|
55
|
+
expect(draft.cancel).not.toHaveBeenCalled();
|
|
56
|
+
expect(manager.getSnapshot().phase).toBe('idle');
|
|
57
|
+
});
|
|
58
|
+
it.each(['timeout', 'network'])('preserves visible dictation on explicit exit even if finalization reports %s', async (error) => {
|
|
59
|
+
manager.start('en', 'one', draft);
|
|
60
|
+
await Promise.resolve();
|
|
61
|
+
recognition.onstart?.();
|
|
62
|
+
recognition.onresult?.({ results: [{ isFinal: false, 0: { transcript: 'visible words' } }] });
|
|
63
|
+
manager.saveAndClose();
|
|
64
|
+
if (error === 'timeout') vi.advanceTimersByTime(8000);
|
|
65
|
+
else recognition.onerror?.({ error });
|
|
66
|
+
expect(commit).toHaveBeenCalledExactlyOnceWith();
|
|
67
|
+
expect(draft.cancel).not.toHaveBeenCalled();
|
|
68
|
+
expect(manager.getSnapshot().phase).toBe('idle');
|
|
69
|
+
});
|
|
70
|
+
it('does not count time spent in the explicit permission prompt as a speech-service timeout', async () => {
|
|
71
|
+
let resolveCheck!: (value: null) => void;
|
|
72
|
+
manager = new ChatVoiceInputManager(() => recognition,
|
|
73
|
+
() => new Promise<null>((resolve) => { resolveCheck = resolve; }));
|
|
74
|
+
manager.start('en', 'one', draft);
|
|
75
|
+
vi.advanceTimersByTime(30000);
|
|
76
|
+
expect(manager.getSnapshot().phase).toBe('starting');
|
|
77
|
+
resolveCheck(null);
|
|
78
|
+
await Promise.resolve();
|
|
79
|
+
expect(recognition.start).toHaveBeenCalledOnce();
|
|
80
|
+
vi.advanceTimersByTime(15000);
|
|
81
|
+
expect(manager.getSnapshot().error).toBe('timeout');
|
|
82
|
+
});
|
|
83
|
+
it('checks permission on retry and ignores a successful check after cancellation', async () => {
|
|
84
|
+
let resolveCheck!: (value: null) => void;
|
|
85
|
+
const check = vi.fn().mockResolvedValueOnce('permission')
|
|
86
|
+
.mockImplementationOnce(() => new Promise<null>((resolve) => { resolveCheck = resolve; }));
|
|
87
|
+
manager = new ChatVoiceInputManager(() => recognition, check);
|
|
88
|
+
manager.start('en', 'one', draft);
|
|
89
|
+
await Promise.resolve();
|
|
90
|
+
manager.start('en', 'one', draft);
|
|
91
|
+
expect(check).toHaveBeenCalledTimes(2);
|
|
92
|
+
manager.cancel();
|
|
93
|
+
resolveCheck(null);
|
|
94
|
+
await Promise.resolve();
|
|
95
|
+
expect(recognition.start).not.toHaveBeenCalled();
|
|
96
|
+
expect(manager.getSnapshot().phase).toBe('idle');
|
|
97
|
+
});
|
|
98
|
+
it('starts again after a successful permission check and identifies missing devices', async () => {
|
|
99
|
+
const check = vi.fn().mockResolvedValueOnce('no-device').mockResolvedValueOnce(null);
|
|
100
|
+
manager = new ChatVoiceInputManager(() => recognition, check);
|
|
101
|
+
manager.start('en', 'one', draft);
|
|
102
|
+
await Promise.resolve();
|
|
103
|
+
expect(manager.getSnapshot().error).toBe('no-device');
|
|
104
|
+
manager.start('en', 'one', draft);
|
|
105
|
+
await Promise.resolve();
|
|
106
|
+
expect(recognition.start).toHaveBeenCalledOnce();
|
|
107
|
+
recognition.onstart?.();
|
|
108
|
+
expect(manager.getSnapshot().phase).toBe('recording');
|
|
109
|
+
});
|
|
110
|
+
it('waits for the final transcript before opening settings without restarting recording', async () => {
|
|
43
111
|
manager.start('zh', 'one', draft);
|
|
112
|
+
await Promise.resolve();
|
|
44
113
|
recognition.onstart?.();
|
|
45
114
|
recognition.result('first');
|
|
46
115
|
manager.openSettings();
|
|
@@ -53,15 +122,17 @@ describe('dictation lifecycle', () => {
|
|
|
53
122
|
manager.cancel();
|
|
54
123
|
expect(recognition.start).toHaveBeenCalledOnce();
|
|
55
124
|
});
|
|
56
|
-
it('still opens settings when finishing recognition fails', () => {
|
|
125
|
+
it('still opens settings when finishing recognition fails', async () => {
|
|
57
126
|
manager.start('en', 'one', draft);
|
|
127
|
+
await Promise.resolve();
|
|
58
128
|
recognition.onstart?.();
|
|
59
129
|
manager.openSettings();
|
|
60
130
|
recognition.onerror?.({ error: 'no-speech' });
|
|
61
131
|
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', error: null, text: '', interim: '' });
|
|
62
132
|
});
|
|
63
|
-
it('replaces cumulative results, waits for the final tail, and inserts once', () => {
|
|
133
|
+
it('replaces cumulative results, waits for the final tail, and inserts once', async () => {
|
|
64
134
|
manager.start('zh', 'one', draft);
|
|
135
|
+
await Promise.resolve();
|
|
65
136
|
expect(manager.getSnapshot().phase).toBe('starting');
|
|
66
137
|
recognition.onstart?.();
|
|
67
138
|
recognition.result('hello');
|
|
@@ -105,8 +176,9 @@ describe('dictation lifecycle', () => {
|
|
|
105
176
|
expect(recognition.abort).toHaveBeenCalledOnce();
|
|
106
177
|
expect(commit).not.toHaveBeenCalled();
|
|
107
178
|
});
|
|
108
|
-
it('commits confirmed text on interruption without a second insertion', () => {
|
|
179
|
+
it('commits confirmed text on interruption without a second insertion', async () => {
|
|
109
180
|
manager.start('en', 'one', draft);
|
|
181
|
+
await Promise.resolve();
|
|
110
182
|
recognition.onstart?.();
|
|
111
183
|
recognition.result('keep this');
|
|
112
184
|
manager.interrupt();
|
|
@@ -114,11 +186,13 @@ describe('dictation lifecycle', () => {
|
|
|
114
186
|
manager.cancel();
|
|
115
187
|
expect(commit).toHaveBeenCalledOnce();
|
|
116
188
|
});
|
|
117
|
-
it('times out startup and stopping and ends recording at 60 seconds', () => {
|
|
189
|
+
it('times out startup and stopping and ends recording at 60 seconds', async () => {
|
|
118
190
|
manager.start('en', 'one', draft);
|
|
191
|
+
await Promise.resolve();
|
|
119
192
|
vi.advanceTimersByTime(15000);
|
|
120
193
|
expect(manager.getSnapshot().error).toBe('timeout');
|
|
121
194
|
manager.start('en', 'one', draft);
|
|
195
|
+
await Promise.resolve();
|
|
122
196
|
recognition.onstart?.();
|
|
123
197
|
vi.advanceTimersByTime(60000);
|
|
124
198
|
expect(manager.getSnapshot().phase).toBe('stopping');
|
|
@@ -126,18 +200,50 @@ describe('dictation lifecycle', () => {
|
|
|
126
200
|
vi.advanceTimersByTime(8000);
|
|
127
201
|
expect(manager.getSnapshot().error).toBe('timeout');
|
|
128
202
|
});
|
|
129
|
-
it('handles unsupported APIs and thrown starts', () => {
|
|
203
|
+
it('handles unsupported APIs and thrown starts', async () => {
|
|
130
204
|
const unsupported = new ChatVoiceInputManager(() => null);
|
|
131
205
|
unsupported.start('en', 'one', draft);
|
|
132
206
|
expect(unsupported.getSnapshot().error).toBe('unsupported');
|
|
133
207
|
recognition.start.mockImplementation(() => { throw new Error('device'); });
|
|
134
208
|
manager.start('en', 'one', draft);
|
|
209
|
+
await Promise.resolve();
|
|
135
210
|
expect(manager.getSnapshot().error).toBe('failed');
|
|
136
211
|
});
|
|
137
212
|
|
|
138
213
|
});
|
|
139
214
|
|
|
140
215
|
describe('keyboard and page integration', () => {
|
|
216
|
+
it('keeps a pending authorization alive across window blur and starts after permission is granted', async () => {
|
|
217
|
+
renderHook(() => useChatVoiceInput(manager, 'one', 'en', true, beginDraft));
|
|
218
|
+
act(() => { manager.start('en', 'one', draft); window.dispatchEvent(new Event('blur')); });
|
|
219
|
+
expect(manager.getSnapshot().phase).toBe('starting');
|
|
220
|
+
expect(recognition.abort).not.toHaveBeenCalled();
|
|
221
|
+
await act(async () => { await Promise.resolve(); recognition.onstart?.(); });
|
|
222
|
+
expect(manager.getSnapshot().phase).toBe('recording');
|
|
223
|
+
});
|
|
224
|
+
it.each(['visibilitychange', 'pagehide'])('cancels pending authorization on %s and ignores a late grant', (event) => {
|
|
225
|
+
renderHook(() => useChatVoiceInput(manager, 'one', 'en', true, beginDraft));
|
|
226
|
+
act(() => { manager.start('en', 'one', draft); });
|
|
227
|
+
const lateStart = recognition.onstart;
|
|
228
|
+
vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
|
|
229
|
+
act(() => { (event === 'pagehide' ? window : document).dispatchEvent(new Event(event)); lateStart?.(); });
|
|
230
|
+
expect(recognition.abort).toHaveBeenCalledOnce();
|
|
231
|
+
expect(manager.getSnapshot().phase).not.toBe('recording');
|
|
232
|
+
});
|
|
233
|
+
it('still interrupts active recording on window blur', async () => {
|
|
234
|
+
renderHook(() => useChatVoiceInput(manager, 'one', 'en', true, beginDraft));
|
|
235
|
+
await act(async () => { manager.start('en', 'one', draft); await Promise.resolve(); recognition.onstart?.(); window.dispatchEvent(new Event('blur')); });
|
|
236
|
+
expect(manager.getSnapshot().error).toBe('interrupted');
|
|
237
|
+
expect(recognition.abort).toHaveBeenCalledOnce();
|
|
238
|
+
});
|
|
239
|
+
it('still stops an active recording when the page becomes hidden', async () => {
|
|
240
|
+
renderHook(() => useChatVoiceInput(manager, 'one', 'en', true, beginDraft));
|
|
241
|
+
await act(async () => { manager.start('en', 'one', draft); await Promise.resolve(); recognition.onstart?.(); });
|
|
242
|
+
vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
|
|
243
|
+
act(() => { document.dispatchEvent(new Event('visibilitychange')); });
|
|
244
|
+
expect(manager.getSnapshot().error).toBe('interrupted');
|
|
245
|
+
expect(recognition.abort).toHaveBeenCalledOnce();
|
|
246
|
+
});
|
|
141
247
|
const bind = (ctrl = false, alt = false) => useChatVoiceShortcutStore.getState().setShortcut({ code: 'KeyV', ctrl, meta: false, alt, shift: false });
|
|
142
248
|
const key = (type: string, options: KeyboardEventInit = {}) => new KeyboardEvent(type, { code: 'KeyV', bubbles: true, cancelable: true, ...options });
|
|
143
249
|
it('never steals paste, normal typing, or input-method composition', () => {
|
|
@@ -153,10 +259,10 @@ describe('keyboard and page integration', () => {
|
|
|
153
259
|
expect(recognition.start).not.toHaveBeenCalled();
|
|
154
260
|
input.remove();
|
|
155
261
|
});
|
|
156
|
-
it('holds a configured combination and stops when a modifier is released', () => {
|
|
262
|
+
it('holds a configured combination and stops when a modifier is released', async () => {
|
|
157
263
|
bind(true, true);
|
|
158
264
|
renderHook(() => useChatVoiceInput(manager, 'one', 'en', true, beginDraft));
|
|
159
|
-
act(() => { window.dispatchEvent(key('keydown', { ctrlKey: true, altKey: true })); recognition.onstart?.(); });
|
|
265
|
+
await act(async () => { window.dispatchEvent(key('keydown', { ctrlKey: true, altKey: true })); await Promise.resolve(); recognition.onstart?.(); });
|
|
160
266
|
expect(recognition.start).toHaveBeenCalledOnce();
|
|
161
267
|
act(() => { window.dispatchEvent(key('keyup', { code: 'AltLeft', key: 'Alt' })); });
|
|
162
268
|
expect(recognition.stop).toHaveBeenCalledOnce();
|
|
@@ -36,13 +36,26 @@ export function useChatVoiceInput(manager: ChatVoiceInputManager, context: strin
|
|
|
36
36
|
if (held) manager.cancel();
|
|
37
37
|
};
|
|
38
38
|
}, [manager, context, language, keyboardEnabled, beginDraft, shortcut]);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const onEscape = (event: KeyboardEvent) => {
|
|
41
|
+
if (event.key !== 'Escape' || event.isComposing || ['idle', 'ready'].includes(manager.getSnapshot().phase)) return;
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
event.stopPropagation();
|
|
44
|
+
manager.saveAndClose();
|
|
45
|
+
};
|
|
46
|
+
window.addEventListener('keydown', onEscape, true);
|
|
47
|
+
return () => { window.removeEventListener('keydown', onEscape, true); };
|
|
48
|
+
}, [manager]);
|
|
39
49
|
useEffect(() => {
|
|
40
50
|
const onVisibility = () => { if (document.hidden) manager.interrupt(); };
|
|
41
|
-
|
|
51
|
+
// Native authorization prompts can blur the window before recognition starts.
|
|
52
|
+
// Actual page departure is handled independently, including pending requests.
|
|
53
|
+
const onBlur = () => { if (manager.getSnapshot().phase !== 'starting') manager.interrupt(); };
|
|
54
|
+
window.addEventListener('blur', onBlur);
|
|
42
55
|
window.addEventListener('pagehide', manager.cancel);
|
|
43
56
|
document.addEventListener('visibilitychange', onVisibility);
|
|
44
57
|
return () => {
|
|
45
|
-
window.removeEventListener('blur',
|
|
58
|
+
window.removeEventListener('blur', onBlur);
|
|
46
59
|
window.removeEventListener('pagehide', manager.cancel);
|
|
47
60
|
document.removeEventListener('visibilitychange', onVisibility);
|
|
48
61
|
manager.cancel();
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ChatComposerDictationSession } from '@nextclaw/agent-chat-ui';
|
|
2
|
+
import { checkMicrophoneAccess, classifyMicrophoneError } from '@/features/chat/utils/chat-voice-permissions.utils';
|
|
2
3
|
|
|
3
4
|
export type VoicePhase = 'idle' | 'ready' | 'starting' | 'recording' | 'stopping' | 'error';
|
|
4
|
-
export type VoiceError = 'unsupported' | 'permission' | 'network' | 'audio-capture' | 'no-speech' | 'interrupted' | 'timeout' | 'failed';
|
|
5
|
+
export type VoiceError = 'unsupported' | 'permission' | 'service-denied' | 'no-device' | 'device-busy' | 'network' | 'audio-capture' | 'no-speech' | 'interrupted' | 'timeout' | 'failed';
|
|
5
6
|
export type VoiceSnapshot = { phase: VoicePhase; text: string; interim: string; seconds: number; error: VoiceError | null };
|
|
6
7
|
export type SpeechRecognitionLike = {
|
|
7
8
|
lang: string;
|
|
@@ -33,9 +34,11 @@ export class ChatVoiceInputManager {
|
|
|
33
34
|
private ticker: ReturnType<typeof setInterval> | undefined;
|
|
34
35
|
private context = '';
|
|
35
36
|
private settingsRequested = false;
|
|
37
|
+
private exitRequested = false;
|
|
36
38
|
private draft: ChatComposerDictationSession | null = null;
|
|
37
39
|
|
|
38
|
-
constructor(private readonly createRecognition = createBrowserRecognition
|
|
40
|
+
constructor(private readonly createRecognition = createBrowserRecognition,
|
|
41
|
+
private readonly checkMicrophone = checkMicrophoneAccess) {}
|
|
39
42
|
|
|
40
43
|
getSnapshot = (): VoiceSnapshot => this.snapshot;
|
|
41
44
|
subscribe = (listener: () => void): (() => void) => {
|
|
@@ -74,9 +77,19 @@ export class ChatVoiceInputManager {
|
|
|
74
77
|
recognition.continuous = true;
|
|
75
78
|
recognition.interimResults = true;
|
|
76
79
|
this.connect(recognition);
|
|
80
|
+
void this.checkAndStart(recognition);
|
|
81
|
+
} catch (error) { this.fail(classifyMicrophoneError(error)); }
|
|
82
|
+
};
|
|
83
|
+
private checkAndStart = async (recognition: SpeechRecognitionLike): Promise<void> => {
|
|
84
|
+
try {
|
|
85
|
+
const error = await this.checkMicrophone();
|
|
86
|
+
if (this.recognition !== recognition || this.snapshot.phase !== 'starting') return;
|
|
87
|
+
if (error) { this.fail(error); return; }
|
|
77
88
|
this.setDeadline(15000);
|
|
78
89
|
recognition.start();
|
|
79
|
-
} catch
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (this.recognition === recognition) this.fail(classifyMicrophoneError(error));
|
|
92
|
+
}
|
|
80
93
|
};
|
|
81
94
|
private connect = (recognition: SpeechRecognitionLike): void => {
|
|
82
95
|
recognition.onstart = () => {
|
|
@@ -104,13 +117,14 @@ export class ChatVoiceInputManager {
|
|
|
104
117
|
};
|
|
105
118
|
recognition.onerror = ({ error }) => {
|
|
106
119
|
if (this.recognition !== recognition) return;
|
|
107
|
-
const reason: VoiceError = error === 'not-allowed'
|
|
120
|
+
const reason: VoiceError = error === 'service-not-allowed' ? 'service-denied' : error === 'not-allowed'
|
|
108
121
|
? 'permission'
|
|
109
122
|
: error === 'network' || error === 'audio-capture' || error === 'no-speech' ? error : 'failed';
|
|
110
123
|
this.fail(reason);
|
|
111
124
|
};
|
|
112
125
|
recognition.onend = () => {
|
|
113
126
|
if (this.recognition !== recognition) return;
|
|
127
|
+
if (this.exitRequested) { this.release(false); this.acceptDraft(); return; }
|
|
114
128
|
const text = this.snapshot.text.trim();
|
|
115
129
|
this.release(false);
|
|
116
130
|
if (!text) { this.fail('no-speech'); return; }
|
|
@@ -129,6 +143,12 @@ export class ChatVoiceInputManager {
|
|
|
129
143
|
this.setDeadline(8000);
|
|
130
144
|
try { this.recognition.stop(); } catch { this.fail('failed'); }
|
|
131
145
|
};
|
|
146
|
+
saveAndClose = (): void => {
|
|
147
|
+
this.exitRequested = true;
|
|
148
|
+
if (this.snapshot.phase === 'recording') { this.finish(); return; }
|
|
149
|
+
if (this.snapshot.phase === 'stopping') return;
|
|
150
|
+
this.acceptDraft();
|
|
151
|
+
};
|
|
132
152
|
interrupt = (): void => {
|
|
133
153
|
if (isActive(this.snapshot.phase)) this.fail('interrupted');
|
|
134
154
|
};
|
|
@@ -139,6 +159,7 @@ export class ChatVoiceInputManager {
|
|
|
139
159
|
};
|
|
140
160
|
cancel = (): void => {
|
|
141
161
|
this.settingsRequested = false;
|
|
162
|
+
this.exitRequested = false;
|
|
142
163
|
this.release();
|
|
143
164
|
this.context = '';
|
|
144
165
|
this.draft?.cancel();
|
|
@@ -146,6 +167,7 @@ export class ChatVoiceInputManager {
|
|
|
146
167
|
this.update(initialSnapshot());
|
|
147
168
|
};
|
|
148
169
|
private fail = (error: VoiceError): void => {
|
|
170
|
+
if (this.exitRequested) { this.acceptDraft(); return; }
|
|
149
171
|
this.release();
|
|
150
172
|
if (this.snapshot.text) this.draft?.commit(this.snapshot.text);
|
|
151
173
|
else this.draft?.cancel();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
2
|
+
import { checkMicrophoneAccess, classifyMicrophoneError, voicePermissionInstructionKeys } from './chat-voice-permissions.utils';
|
|
3
|
+
|
|
4
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
5
|
+
|
|
6
|
+
it.each([
|
|
7
|
+
['NotAllowedError', 'permission'], ['NotFoundError', 'no-device'],
|
|
8
|
+
['NotSupportedError', 'unsupported'],
|
|
9
|
+
['NotReadableError', 'device-busy'], ['AbortError', 'device-busy'], ['Error', 'failed'],
|
|
10
|
+
])('classifies %s from the actual media API', (name, expected) => {
|
|
11
|
+
expect(classifyMicrophoneError(new DOMException('microphone', name))).toBe(expected);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('rechecks access and immediately releases every captured track', async () => {
|
|
15
|
+
const stop = vi.fn();
|
|
16
|
+
const getUserMedia = vi.fn().mockResolvedValue({ getTracks: () => [{ stop }, { stop }] });
|
|
17
|
+
vi.stubGlobal('navigator', { mediaDevices: { getUserMedia } });
|
|
18
|
+
expect(await checkMicrophoneAccess()).toBeNull();
|
|
19
|
+
expect(getUserMedia).toHaveBeenCalledWith({ audio: true });
|
|
20
|
+
expect(stop).toHaveBeenCalledTimes(2);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('reports unavailable media API and an actual denial without caching', async () => {
|
|
24
|
+
vi.stubGlobal('navigator', {});
|
|
25
|
+
expect(await checkMicrophoneAccess()).toBe('unsupported');
|
|
26
|
+
vi.stubGlobal('navigator', { mediaDevices: { getUserMedia: vi.fn().mockRejectedValue(new DOMException('', 'NotAllowedError')) } });
|
|
27
|
+
expect(await checkMicrophoneAccess()).toBe('permission');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it.each([
|
|
31
|
+
['Macintosh Chrome', 'Mac'], ['Windows Chrome', 'Windows'], ['iPhone', 'Ios'],
|
|
32
|
+
['Android Chrome', 'Android'], ['Linux', 'System'],
|
|
33
|
+
])('offers instructions matching %s', (ua, platform) => {
|
|
34
|
+
expect(voicePermissionInstructionKeys(ua)).toEqual(['chatInputVoicePermissionBrowser', `chatInputVoicePermission${platform}`]);
|
|
35
|
+
expect(voicePermissionInstructionKeys(`${ua} Electron`)[0]).toBe('chatInputVoicePermissionDesktop');
|
|
36
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type MicrophoneError = 'permission' | 'no-device' | 'device-busy' | 'unsupported' | 'failed';
|
|
2
|
+
|
|
3
|
+
export function classifyMicrophoneError(error: unknown): MicrophoneError {
|
|
4
|
+
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
|
|
5
|
+
if (name === 'NotSupportedError') return 'unsupported';
|
|
6
|
+
if (name === 'NotAllowedError' || name === 'SecurityError') return 'permission';
|
|
7
|
+
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') return 'no-device';
|
|
8
|
+
if (name === 'NotReadableError' || name === 'AbortError') return 'device-busy';
|
|
9
|
+
return 'failed';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Check on explicit start/retry; release every track even if the panel was closed. */
|
|
13
|
+
export async function checkMicrophoneAccess(): Promise<MicrophoneError | null> {
|
|
14
|
+
if (!navigator.mediaDevices?.getUserMedia) return 'unsupported';
|
|
15
|
+
try {
|
|
16
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
17
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
18
|
+
return null;
|
|
19
|
+
} catch (error) {
|
|
20
|
+
return classifyMicrophoneError(error);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function voicePermissionInstructionKeys(userAgent: string): string[] {
|
|
25
|
+
const system = /iPhone|iPad|iPod/i.test(userAgent) ? 'chatInputVoicePermissionIos'
|
|
26
|
+
: /Android/i.test(userAgent) ? 'chatInputVoicePermissionAndroid'
|
|
27
|
+
: /Macintosh|Mac OS X/i.test(userAgent) ? 'chatInputVoicePermissionMac'
|
|
28
|
+
: /Windows/i.test(userAgent) ? 'chatInputVoicePermissionWindows'
|
|
29
|
+
: 'chatInputVoicePermissionSystem';
|
|
30
|
+
return [/Electron/i.test(userAgent) ? 'chatInputVoicePermissionDesktop' : 'chatInputVoicePermissionBrowser', system];
|
|
31
|
+
}
|
|
@@ -640,17 +640,32 @@
|
|
|
640
640
|
"chatInputAttachmentTooLarge": "Files must be {maxMb} MB or smaller.",
|
|
641
641
|
"chatInputAttachmentReadFailed": "Failed to read the file. Please try again.",
|
|
642
642
|
"chatInputVoice": "Voice input",
|
|
643
|
-
"chatInputVoicePermissionDenied": "Microphone
|
|
643
|
+
"chatInputVoicePermissionDenied": "Microphone access was denied. The browser may not distinguish site permission from system permission. Check the following settings.",
|
|
644
|
+
"chatInputVoicePermissionPurpose": "Voice input uses your microphone to turn speech into a draft. Choose Allow when prompted; review the text before sending it.",
|
|
645
|
+
"chatInputVoiceCheckAndRetry": "Check microphone and retry",
|
|
646
|
+
"chatInputVoicePermissionRetryHint": "After allowing access, select “Check microphone and retry”. If the system requires an app restart, save your draft before reopening your browser or NextClaw.",
|
|
647
|
+
"chatInputVoicePermissionBrowser": "Open the site information or permissions menu beside the address bar and allow Microphone for this site. Alternatively, search browser settings for Microphone and check whether this site is blocked.",
|
|
648
|
+
"chatInputVoicePermissionDesktop": "Check that the system allows NextClaw to use the microphone. If access is already allowed, reopen NextClaw and retry.",
|
|
649
|
+
"chatInputVoicePermissionMac": "macOS: open System Settings → Privacy & Security → Microphone, and allow your browser or NextClaw.",
|
|
650
|
+
"chatInputVoicePermissionWindows": "Windows: open Settings → Privacy & security (Privacy on Windows 10) → Microphone. Enable microphone access and access for desktop apps.",
|
|
651
|
+
"chatInputVoicePermissionIos": "iPhone / iPad: check Microphone in the browser’s website settings. Also check Settings → Privacy & Security → Microphone for browser access, if listed.",
|
|
652
|
+
"chatInputVoicePermissionAndroid": "Android: open Settings → Apps → your browser → Permissions → Microphone and allow access. Also check this website’s browser permissions.",
|
|
653
|
+
"chatInputVoicePermissionSystem": "In system sound settings, select an available input device and check whether microphone access is restricted for your browser or NextClaw.",
|
|
654
|
+
"chatInputVoiceNoDevice": "No microphone was detected. Connect or enable an input device, then check again.",
|
|
655
|
+
"chatInputVoiceDeviceBusy": "The microphone cannot be read. Another app or a system restriction may be preventing access. Close other recording apps, check your input device and retry.",
|
|
656
|
+
"chatInputVoiceServiceDenied": "The browser denied access to speech recognition. Check its speech service settings or organization policy, or use a browser that supports speech recognition.",
|
|
644
657
|
"chatInputVoiceUnsupported": "Speech input is not supported in this browser.",
|
|
645
658
|
"chatInputVoiceNoSpeech": "Could not hear you. Please try again.",
|
|
646
659
|
"chatInputVoiceFailed": "Voice input failed. Please try again.",
|
|
647
660
|
"chatInputVoicePrivacy": "Click to dictate at the cursor. Underlined words are still being recognized. Your browser may process audio online. Nothing is sent automatically.",
|
|
648
661
|
"chatInputVoiceStart": "Start recording",
|
|
649
662
|
"chatInputVoiceFinish": "Finish recording",
|
|
663
|
+
"chatInputVoiceSaveAndClose": "Save and close",
|
|
664
|
+
"chatInputVoiceEscapeHint": "Press Esc to save dictation and close",
|
|
650
665
|
"chatInputVoiceCancel": "Cancel",
|
|
651
|
-
"chatInputVoiceStarting": "
|
|
666
|
+
"chatInputVoiceStarting": "Preparing",
|
|
652
667
|
"chatInputVoiceRecording": "Listening",
|
|
653
|
-
"chatInputVoiceFinishing": "Finishing
|
|
668
|
+
"chatInputVoiceFinishing": "Finishing",
|
|
654
669
|
"chatInputVoiceReview": "Recording stopped",
|
|
655
670
|
"chatInputVoiceSpeak": "Speak now. Your transcript will appear here.",
|
|
656
671
|
"chatInputVoiceReviewHint": "Confirmed words remain in your draft; provisional words were removed. Continue editing or try again.",
|
|
@@ -640,17 +640,32 @@
|
|
|
640
640
|
"chatInputAttachmentTooLarge": "文件不能超过 {maxMb} MB。",
|
|
641
641
|
"chatInputAttachmentReadFailed": "读取文件失败,请重试。",
|
|
642
642
|
"chatInputVoice": "语音输入",
|
|
643
|
-
"chatInputVoicePermissionDenied": "
|
|
643
|
+
"chatInputVoicePermissionDenied": "无法获得麦克风权限。浏览器可能无法区分网站权限和系统权限,请按以下步骤检查。",
|
|
644
|
+
"chatInputVoicePermissionPurpose": "语音输入需要使用麦克风,把你说的话转为草稿。出现权限提示时请选择允许;文字由你检查后发送。",
|
|
645
|
+
"chatInputVoiceCheckAndRetry": "检测麦克风并重试",
|
|
646
|
+
"chatInputVoicePermissionRetryHint": "允许后点击“检测麦克风并重试”。若系统要求重启应用,请先保留草稿再重开浏览器或 NextClaw。",
|
|
647
|
+
"chatInputVoicePermissionBrowser": "打开地址栏左侧的网站信息或权限菜单,将当前网站的“麦克风”设为允许。找不到时,在浏览器设置中搜索“麦克风”,检查当前网站是否被屏蔽。",
|
|
648
|
+
"chatInputVoicePermissionDesktop": "检查系统是否允许 NextClaw 使用麦克风。如果权限已允许但仍失败,请重开 NextClaw 后重试。",
|
|
649
|
+
"chatInputVoicePermissionMac": "macOS:打开系统设置 → 隐私与安全性 → 麦克风,允许当前浏览器或 NextClaw。",
|
|
650
|
+
"chatInputVoicePermissionWindows": "Windows:打开设置 → 隐私和安全性(Windows 10 为“隐私”)→ 麦克风,开启麦克风访问及桌面应用访问。",
|
|
651
|
+
"chatInputVoicePermissionIos": "iPhone / iPad:在浏览器的网站设置中检查麦克风权限;同时在系统设置 → 隐私与安全性 → 麦克风中检查浏览器权限(如有该项)。",
|
|
652
|
+
"chatInputVoicePermissionAndroid": "Android:打开系统设置 → 应用 → 当前浏览器 → 权限 → 麦克风,允许使用;并检查浏览器中当前网站的权限。",
|
|
653
|
+
"chatInputVoicePermissionSystem": "在系统声音设置中确认已选中可用输入设备,并检查系统是否限制了浏览器或 NextClaw 的麦克风访问。",
|
|
654
|
+
"chatInputVoiceNoDevice": "没有检测到麦克风。请连接或启用输入设备,再检测重试。",
|
|
655
|
+
"chatInputVoiceDeviceBusy": "麦克风无法读取,可能被其他应用占用或被系统限制。请关闭其他录音应用,检查输入设备后重试。",
|
|
656
|
+
"chatInputVoiceServiceDenied": "浏览器拒绝了语音识别服务。请检查浏览器的语音服务设置或组织策略,或使用支持语音识别的浏览器。",
|
|
644
657
|
"chatInputVoiceUnsupported": "当前浏览器不支持语音输入。",
|
|
645
658
|
"chatInputVoiceNoSpeech": "没有听清,请再试一次。",
|
|
646
659
|
"chatInputVoiceFailed": "语音输入失败,请重试。",
|
|
647
660
|
"chatInputVoicePrivacy": "点击录音,文字实时显示在光标处,下划线表示尚未确定的内容。浏览器语音服务可能联网处理声音,不会自动发送。",
|
|
648
661
|
"chatInputVoiceStart": "开始录音",
|
|
649
662
|
"chatInputVoiceFinish": "结束录音",
|
|
663
|
+
"chatInputVoiceSaveAndClose": "保存并退出",
|
|
664
|
+
"chatInputVoiceEscapeHint": "按 Esc 保存语音文字并退出",
|
|
650
665
|
"chatInputVoiceCancel": "取消",
|
|
651
|
-
"chatInputVoiceStarting": "
|
|
666
|
+
"chatInputVoiceStarting": "准备录音",
|
|
652
667
|
"chatInputVoiceRecording": "正在聆听",
|
|
653
|
-
"chatInputVoiceFinishing": "
|
|
668
|
+
"chatInputVoiceFinishing": "完成转写",
|
|
654
669
|
"chatInputVoiceReview": "录音已停止",
|
|
655
670
|
"chatInputVoiceSpeak": "请说话,转写会显示在这里",
|
|
656
671
|
"chatInputVoiceReviewHint": "已确认的文字保留在草稿中,未确定的片段已撤回。你可以继续编辑或重试。",
|