vzcode 2.29.0 → 2.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/bindings_wasm_bg-9w8E-TmY.wasm +0 -0
- package/dist/assets/buildWorker-DylWm2VX.js +687 -0
- package/dist/assets/index-BfDRE_ou.js +364 -0
- package/dist/assets/index-Dyt9tpmu.css +1 -0
- package/dist/assets/worker-D8p1JlHW.js +342 -0
- package/dist/assets/worker-b9bkZkXK.js +116 -0
- package/dist/assets/{worker-CoJaqtpi.js → worker-k7HZ3t-V.js} +12 -12
- package/dist/index.html +2 -2
- package/dist/llm-streaming-server/chatOperations.js +40 -10
- package/package.json +1 -1
- package/src/client/App/index.tsx +38 -24
- package/src/client/CodeEditor/getOrCreateEditor.tsx +6 -1
- package/src/client/CodeEditor/index.tsx +4 -0
- package/src/client/VZCodeContext/useVZCodeState.ts +1 -0
- package/src/client/VZLeft.tsx +17 -2
- package/src/client/VZMiddle.tsx +12 -0
- package/src/client/VZSidebar/VisualEditor/VisualEditor.tsx +101 -48
- package/src/client/VZSidebar/VisualEditor/utils.ts +113 -0
- package/src/client/VZSidebar/index.tsx +23 -6
- package/src/client/aiChatButtonEvent.ts +61 -0
- package/src/client/featureFlags.ts +8 -0
- package/src/client/index.ts +6 -0
- package/src/client/main.jsx +22 -0
- package/src/llm-streaming-server/chatOperations.ts +53 -12
- package/src/llm-streaming-server/llmStreaming.ts +0 -3
- package/src/llm-streaming-ui/components/ChatInput.tsx +49 -29
- package/src/llm-streaming-ui/components/Message.tsx +0 -2
- package/src/llm-streaming-ui/components/useSpeechRecognition.ts +84 -60
- package/src/vite-env.d.ts +1 -0
- package/dist/assets/bindings_wasm_bg--2fP_mkJ.wasm +0 -0
- package/dist/assets/buildWorker-BV4WiKg8.js +0 -687
- package/dist/assets/index-CCmGeE_1.css +0 -1
- package/dist/assets/index-JcHUw7hN.js +0 -403
- package/dist/assets/worker-AbbeoMbH.js +0 -340
- package/dist/assets/worker-B71kfwKL.js +0 -125
|
@@ -40,14 +40,22 @@ import { useDragAndDrop } from './useDragAndDrop';
|
|
|
40
40
|
import { createAICopyPasteHandlers } from './aiCopyPaste';
|
|
41
41
|
import {
|
|
42
42
|
enableLiveKit,
|
|
43
|
-
enableAIChat,
|
|
43
|
+
enableAIChat as enableAIChatFeatureFlag,
|
|
44
44
|
} from '../featureFlags';
|
|
45
|
+
import { handleAIChatButtonClick } from '../aiChatButtonEvent';
|
|
45
46
|
import './styles.scss';
|
|
46
47
|
import { VisualEditor } from './VisualEditor';
|
|
47
48
|
|
|
48
49
|
const enableConnectionStatus = true;
|
|
49
50
|
|
|
50
51
|
export const VZSidebar = ({
|
|
52
|
+
// Host-provided override so consumers (e.g. VizHub Studio, which has
|
|
53
|
+
// its own top-bar "AI Edit" entry point) can hide the "Edit with AI"
|
|
54
|
+
// sidebar button. Defaults to the compile-time feature flag.
|
|
55
|
+
enableAIChat = enableAIChatFeatureFlag,
|
|
56
|
+
// When set, clicking the button dispatches this custom event instead
|
|
57
|
+
// of opening VZCode's built-in AI chat, letting the host handle it.
|
|
58
|
+
aiChatButtonEvent,
|
|
51
59
|
createFileTooltipText = (
|
|
52
60
|
<>
|
|
53
61
|
<strong>New file</strong>
|
|
@@ -135,6 +143,8 @@ export const VZSidebar = ({
|
|
|
135
143
|
voiceChatToolTipText?: React.ReactNode;
|
|
136
144
|
aiChatToolTipText?: React.ReactNode;
|
|
137
145
|
visualEditorToolTipText?: React.ReactNode;
|
|
146
|
+
enableAIChat?: boolean;
|
|
147
|
+
aiChatButtonEvent?: string;
|
|
138
148
|
}) => {
|
|
139
149
|
const {
|
|
140
150
|
files,
|
|
@@ -341,11 +351,17 @@ export const VZSidebar = ({
|
|
|
341
351
|
<i
|
|
342
352
|
id="ai-chat-icon"
|
|
343
353
|
className="icon-button icon-button-dark"
|
|
344
|
-
onClick={() =>
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
354
|
+
onClick={() =>
|
|
355
|
+
handleAIChatButtonClick({
|
|
356
|
+
aiChatButtonEvent,
|
|
357
|
+
target: window,
|
|
358
|
+
openAIChat: () => {
|
|
359
|
+
setIsAIChatOpen(true);
|
|
360
|
+
setIsSearchOpen(false);
|
|
361
|
+
setSidebarView(true); // Switch to AI chat view
|
|
362
|
+
},
|
|
363
|
+
})
|
|
364
|
+
}
|
|
349
365
|
>
|
|
350
366
|
<SparklesSVG />
|
|
351
367
|
</i>
|
|
@@ -411,6 +427,7 @@ export const VZSidebar = ({
|
|
|
411
427
|
setIsVisualEditorOpen(true);
|
|
412
428
|
setIsAIChatOpen(false);
|
|
413
429
|
setIsSearchOpen(false);
|
|
430
|
+
setSidebarView(false); // Switch to files view width for visual editor
|
|
414
431
|
}}
|
|
415
432
|
>
|
|
416
433
|
<AdjustmentSVG />
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-facing event emitted when the VZCode sidebar "Edit with AI"
|
|
3
|
+
* button is clicked in "emit" mode.
|
|
4
|
+
*
|
|
5
|
+
* A host opts in by passing `aiChatButtonEvent` to `VZSidebar` /
|
|
6
|
+
* `VZLeft`. When set, clicking `#ai-chat-icon` dispatches this event
|
|
7
|
+
* instead of opening VZCode's built-in AI chat panel, so the host can
|
|
8
|
+
* route the click to its own AI editing entry point (e.g. VizHub
|
|
9
|
+
* Studio's top-bar "AI Edit" action).
|
|
10
|
+
*
|
|
11
|
+
* The constant is exported so hosts don't hardcode the event name.
|
|
12
|
+
*/
|
|
13
|
+
export const AI_CHAT_BUTTON_EVENT =
|
|
14
|
+
'vzcode:ai-chat-button-click';
|
|
15
|
+
|
|
16
|
+
export interface AIChatButtonEventDetail {
|
|
17
|
+
source: 'vzcode-sidebar';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Dispatch the host-facing AI chat button event.
|
|
22
|
+
* Kept as a standalone function so it is unit-testable without a DOM.
|
|
23
|
+
*/
|
|
24
|
+
export function emitAIChatButtonEvent(
|
|
25
|
+
eventName: string,
|
|
26
|
+
target: EventTarget,
|
|
27
|
+
): void {
|
|
28
|
+
target.dispatchEvent(
|
|
29
|
+
new CustomEvent<AIChatButtonEventDetail>(eventName, {
|
|
30
|
+
bubbles: true,
|
|
31
|
+
composed: true,
|
|
32
|
+
detail: { source: 'vzcode-sidebar' },
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Decide what clicking the sidebar AI chat button should do.
|
|
39
|
+
*
|
|
40
|
+
* - When `aiChatButtonEvent` is set, emit the event and leave all
|
|
41
|
+
* VZCode state untouched (the host owns the action).
|
|
42
|
+
* - Otherwise, fall back to opening VZCode's built-in AI chat.
|
|
43
|
+
*
|
|
44
|
+
* Extracted from the component so the branching is unit-testable
|
|
45
|
+
* without rendering or a real DOM.
|
|
46
|
+
*/
|
|
47
|
+
export function handleAIChatButtonClick({
|
|
48
|
+
aiChatButtonEvent,
|
|
49
|
+
target,
|
|
50
|
+
openAIChat,
|
|
51
|
+
}: {
|
|
52
|
+
aiChatButtonEvent?: string;
|
|
53
|
+
target: EventTarget;
|
|
54
|
+
openAIChat: () => void;
|
|
55
|
+
}): void {
|
|
56
|
+
if (aiChatButtonEvent) {
|
|
57
|
+
emitAIChatButtonEvent(aiChatButtonEvent, target);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
openAIChat();
|
|
61
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
export const enableLiveKit =
|
|
2
2
|
import.meta.env.VITE_ENABLE_LIVEKIT === 'true';
|
|
3
3
|
|
|
4
|
+
// Enables the speech-to-text microphone button in the AI chat
|
|
5
|
+
// input. Off by default: speech recognition is a browser media
|
|
6
|
+
// feature, and merely constructing a `SpeechRecognition` instance
|
|
7
|
+
// can make the browser show a microphone permission prompt. Set
|
|
8
|
+
// `VITE_ENABLE_VOICE_INPUT=true` to opt in.
|
|
9
|
+
export const enableVoiceInput =
|
|
10
|
+
import.meta.env.VITE_ENABLE_VOICE_INPUT === 'true';
|
|
11
|
+
|
|
4
12
|
export const enableAIChat = true;
|
|
5
13
|
|
|
6
14
|
export const enableDiffView = true;
|
package/src/client/index.ts
CHANGED
|
@@ -11,6 +11,12 @@ export { VZKeyboardShortcutsDoc } from './VZKeyboardShortcutsDoc';
|
|
|
11
11
|
export { VZResizer } from './VZResizer';
|
|
12
12
|
export { VZLeft } from './VZLeft';
|
|
13
13
|
export { VZMiddle } from './VZMiddle';
|
|
14
|
+
export {
|
|
15
|
+
AI_CHAT_BUTTON_EVENT,
|
|
16
|
+
emitAIChatButtonEvent,
|
|
17
|
+
handleAIChatButtonClick,
|
|
18
|
+
} from './aiChatButtonEvent';
|
|
19
|
+
export type { AIChatButtonEventDetail } from './aiChatButtonEvent';
|
|
14
20
|
export {
|
|
15
21
|
SplitPaneResizeProvider,
|
|
16
22
|
SplitPaneResizeContext,
|
package/src/client/main.jsx
CHANGED
|
@@ -3,6 +3,28 @@ import * as ReactDOM from 'react-dom/client';
|
|
|
3
3
|
import App from './App';
|
|
4
4
|
import { BrowserRouter } from 'react-router-dom';
|
|
5
5
|
|
|
6
|
+
// Global error handler to catch CodeMirror DOM position errors
|
|
7
|
+
// These can occur when CodeMirror instances are created but not yet
|
|
8
|
+
// attached to the DOM, or when presence indicators try to render
|
|
9
|
+
// on offscreen editor instances.
|
|
10
|
+
window.addEventListener('error', (event) => {
|
|
11
|
+
// Check if this is the specific CodeMirror posFromDOM/posAtDOM error
|
|
12
|
+
if (
|
|
13
|
+
event.error instanceof RangeError &&
|
|
14
|
+
event.error.message.includes(
|
|
15
|
+
'Trying to find position for a DOM position outside of the document',
|
|
16
|
+
)
|
|
17
|
+
) {
|
|
18
|
+
// Log the error for debugging purposes
|
|
19
|
+
console.log(
|
|
20
|
+
'Suppressed CodeMirror DOM position error:',
|
|
21
|
+
event.error,
|
|
22
|
+
);
|
|
23
|
+
// Prevent the error from being logged to console by returning true
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
6
28
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
7
29
|
<BrowserRouter>
|
|
8
30
|
<App />
|
|
@@ -335,30 +335,66 @@ export const finalizeAIMessage = (
|
|
|
335
335
|
};
|
|
336
336
|
|
|
337
337
|
/**
|
|
338
|
-
* Adds diff data to
|
|
338
|
+
* Adds diff data to a specific AI message by ID
|
|
339
|
+
* Phase 3 Fix: Use explicit message ID instead of assuming
|
|
340
|
+
* last message is AI, preventing bugs with rapid message sends
|
|
339
341
|
*/
|
|
340
342
|
export const addDiffToAIMessage = (
|
|
341
343
|
shareDBDoc: ShareDBDoc<VizContent>,
|
|
342
344
|
chatId: VizChatId,
|
|
343
345
|
diffData: any,
|
|
344
|
-
beforeCommitId?: string, // Commit ID before AI changes
|
|
346
|
+
beforeCommitId?: string, // Commit ID before AI changes
|
|
347
|
+
messageId?: string, // Explicit AI message ID to update
|
|
345
348
|
) => {
|
|
346
349
|
const chat = shareDBDoc.data.chats[chatId];
|
|
350
|
+
if (!chat || !chat.messages) {
|
|
351
|
+
console.warn(
|
|
352
|
+
`Chat ${chatId} not found or has no messages`,
|
|
353
|
+
);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
347
357
|
const messages = [...chat.messages];
|
|
358
|
+
let targetMessageIndex = -1;
|
|
348
359
|
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
360
|
+
// Phase 3 Fix: Use explicit message ID if provided
|
|
361
|
+
if (messageId) {
|
|
362
|
+
targetMessageIndex = messages.findIndex(
|
|
363
|
+
(msg) => msg.id === messageId,
|
|
364
|
+
);
|
|
365
|
+
if (
|
|
366
|
+
targetMessageIndex === -1 ||
|
|
367
|
+
messages[targetMessageIndex].role !== 'assistant'
|
|
368
|
+
) {
|
|
369
|
+
console.warn(
|
|
370
|
+
`AI message with id ${messageId} not found in chat ${chatId}`,
|
|
371
|
+
);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
} else {
|
|
375
|
+
// Fallback: Find the most recent AI message (safer than
|
|
376
|
+
// just assuming last message)
|
|
377
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
378
|
+
if (messages[i].role === 'assistant') {
|
|
379
|
+
targetMessageIndex = i;
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (targetMessageIndex === -1) {
|
|
385
|
+
console.warn(`No AI message found in chat ${chatId}`);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Update the target message with diff data
|
|
391
|
+
if (targetMessageIndex >= 0) {
|
|
355
392
|
const newMessage = {
|
|
356
|
-
...messages[
|
|
393
|
+
...messages[targetMessageIndex],
|
|
357
394
|
diffData,
|
|
358
|
-
...(beforeCommitId && { beforeCommitId }),
|
|
395
|
+
...(beforeCommitId && { beforeCommitId }),
|
|
359
396
|
};
|
|
360
|
-
|
|
361
|
-
(messages[lastAIMessageIndex] as any) = newMessage;
|
|
397
|
+
(messages[targetMessageIndex] as any) = newMessage;
|
|
362
398
|
|
|
363
399
|
const messageOp = diff(shareDBDoc.data, {
|
|
364
400
|
...shareDBDoc.data,
|
|
@@ -373,6 +409,11 @@ export const addDiffToAIMessage = (
|
|
|
373
409
|
});
|
|
374
410
|
|
|
375
411
|
shareDBDoc.submitOp(messageOp);
|
|
412
|
+
|
|
413
|
+
DEBUG &&
|
|
414
|
+
console.log(
|
|
415
|
+
`addDiffToAIMessage: Successfully added diff to message ${targetMessageIndex} in chat ${chatId}`,
|
|
416
|
+
);
|
|
376
417
|
}
|
|
377
418
|
};
|
|
378
419
|
|
|
@@ -78,7 +78,6 @@ export const createLLMFunction = ({
|
|
|
78
78
|
},
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
|
|
82
81
|
let fullContent = '';
|
|
83
82
|
let generationId = '';
|
|
84
83
|
let currentEditingFileName = null;
|
|
@@ -253,8 +252,6 @@ export const createLLMFunction = ({
|
|
|
253
252
|
const chunks = [];
|
|
254
253
|
let reasoningContent = '';
|
|
255
254
|
|
|
256
|
-
|
|
257
|
-
|
|
258
255
|
// Configure reasoning tokens based on enableReasoningTokens flag
|
|
259
256
|
const requestConfig: any = {
|
|
260
257
|
model: modelName,
|
|
@@ -10,10 +10,50 @@ import {
|
|
|
10
10
|
ButtonGroup,
|
|
11
11
|
ToggleButton,
|
|
12
12
|
} from '../../client/bootstrap';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
enableAskMode,
|
|
15
|
+
enableVoiceInput,
|
|
16
|
+
} from '../../client/featureFlags';
|
|
14
17
|
import { useSpeechRecognition } from './useSpeechRecognition';
|
|
15
18
|
import { MicSVG, MicOffSVG } from '../../client/Icons';
|
|
16
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Microphone toggle for voice typing. Purely presentational: the
|
|
22
|
+
* speech recognition state lives in `ChatInputComponent`, and is only
|
|
23
|
+
* ever instantiated when the user actually toggles the button.
|
|
24
|
+
*
|
|
25
|
+
* Rendered only when `enableVoiceInput` is set. See `featureFlags`.
|
|
26
|
+
*/
|
|
27
|
+
const VoiceInputButton = ({
|
|
28
|
+
isSpeaking,
|
|
29
|
+
onToggle,
|
|
30
|
+
}: {
|
|
31
|
+
isSpeaking: boolean;
|
|
32
|
+
onToggle: () => void;
|
|
33
|
+
}) => {
|
|
34
|
+
const label = isSpeaking
|
|
35
|
+
? 'Stop voice typing'
|
|
36
|
+
: 'Start voice typing';
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Button
|
|
40
|
+
variant={isSpeaking ? 'danger' : 'outline-secondary'}
|
|
41
|
+
size="sm"
|
|
42
|
+
onClick={onToggle}
|
|
43
|
+
aria-label={label}
|
|
44
|
+
title={label}
|
|
45
|
+
style={{
|
|
46
|
+
borderColor: isSpeaking ? undefined : '#dee2e6',
|
|
47
|
+
borderRadius: '0.375rem',
|
|
48
|
+
borderWidth: '1px',
|
|
49
|
+
borderStyle: 'solid',
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
{isSpeaking ? <MicOffSVG /> : <MicSVG />}
|
|
53
|
+
</Button>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
17
57
|
interface ChatInputProps {
|
|
18
58
|
aiChatMessage: string;
|
|
19
59
|
setAIChatMessage: (message: string) => void;
|
|
@@ -39,7 +79,8 @@ const ChatInputComponent = ({
|
|
|
39
79
|
}: ChatInputProps) => {
|
|
40
80
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
41
81
|
|
|
42
|
-
//
|
|
82
|
+
// Speech recognition is created lazily on first toggle, so simply
|
|
83
|
+
// mounting this hook does not touch any browser media APIs.
|
|
43
84
|
const {
|
|
44
85
|
isSpeaking,
|
|
45
86
|
toggleSpeechRecognition,
|
|
@@ -186,33 +227,12 @@ const ChatInputComponent = ({
|
|
|
186
227
|
{aiChatMessage ? 'Press Enter to send' : ''}
|
|
187
228
|
</span>
|
|
188
229
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
isSpeaking
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
aria-label={
|
|
196
|
-
isSpeaking
|
|
197
|
-
? 'Stop voice typing'
|
|
198
|
-
: 'Start voice typing'
|
|
199
|
-
}
|
|
200
|
-
title={
|
|
201
|
-
isSpeaking
|
|
202
|
-
? 'Stop voice typing'
|
|
203
|
-
: 'Start voice typing'
|
|
204
|
-
}
|
|
205
|
-
style={{
|
|
206
|
-
borderColor: isSpeaking
|
|
207
|
-
? undefined
|
|
208
|
-
: '#dee2e6',
|
|
209
|
-
borderRadius: '0.375rem',
|
|
210
|
-
borderWidth: '1px',
|
|
211
|
-
borderStyle: 'solid',
|
|
212
|
-
}}
|
|
213
|
-
>
|
|
214
|
-
{isSpeaking ? <MicOffSVG /> : <MicSVG />}
|
|
215
|
-
</Button>
|
|
230
|
+
{enableVoiceInput && (
|
|
231
|
+
<VoiceInputButton
|
|
232
|
+
isSpeaking={isSpeaking}
|
|
233
|
+
onToggle={toggleSpeechRecognition}
|
|
234
|
+
/>
|
|
235
|
+
)}
|
|
216
236
|
<Button
|
|
217
237
|
variant={
|
|
218
238
|
aiChatMessage.trim()
|
|
@@ -13,8 +13,6 @@ import { DiffView, DiffViewRef } from './DiffView';
|
|
|
13
13
|
import { UnifiedFilesDiff } from '../../utils/fileDiff';
|
|
14
14
|
import { enableDiffView } from '../../client/featureFlags';
|
|
15
15
|
|
|
16
|
-
const DEBUG = true;
|
|
17
|
-
|
|
18
16
|
interface MessageProps {
|
|
19
17
|
id: string;
|
|
20
18
|
role: 'user' | 'assistant';
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
useState,
|
|
3
3
|
useEffect,
|
|
4
4
|
useCallback,
|
|
5
|
+
useRef,
|
|
5
6
|
Dispatch,
|
|
6
7
|
SetStateAction,
|
|
7
8
|
} from 'react';
|
|
@@ -12,18 +13,41 @@ export interface UseSpeechRecognitionResult {
|
|
|
12
13
|
stopSpeaking: () => void;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Speech-to-text input for the AI chat box.
|
|
18
|
+
*
|
|
19
|
+
* The underlying `SpeechRecognition` instance is created lazily, on the
|
|
20
|
+
* first call to `toggleSpeechRecognition`, rather than eagerly when the
|
|
21
|
+
* hook mounts. Eager construction can make some browsers show a
|
|
22
|
+
* microphone permission prompt as soon as the editor loads, before the
|
|
23
|
+
* user has asked for voice input.
|
|
24
|
+
*/
|
|
15
25
|
export const useSpeechRecognition = (
|
|
16
26
|
onTranscriptChange: Dispatch<SetStateAction<string>>,
|
|
17
27
|
): UseSpeechRecognitionResult => {
|
|
18
28
|
const [isSpeaking, setIsSpeaking] =
|
|
19
29
|
useState<boolean>(false);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
// `SpeechRecognition` is not part of the DOM lib, so the type
|
|
31
|
+
// reference is suppressed here, as with the constructor lookups below.
|
|
32
|
+
// @ts-ignore
|
|
33
|
+
const recognitionRef = useRef<SpeechRecognition | null>(
|
|
34
|
+
null,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Keep the latest callback in a ref so the lazily-created
|
|
38
|
+
// recognition instance always calls the current handler, without
|
|
39
|
+
// having to tear down and recreate the instance.
|
|
40
|
+
const onTranscriptChangeRef = useRef(onTranscriptChange);
|
|
26
41
|
useEffect(() => {
|
|
42
|
+
onTranscriptChangeRef.current = onTranscriptChange;
|
|
43
|
+
}, [onTranscriptChange]);
|
|
44
|
+
|
|
45
|
+
// Create the SpeechRecognition instance on first use.
|
|
46
|
+
const getRecognition = useCallback(() => {
|
|
47
|
+
if (recognitionRef.current) {
|
|
48
|
+
return recognitionRef.current;
|
|
49
|
+
}
|
|
50
|
+
|
|
27
51
|
// Check if browser supports SpeechRecognition
|
|
28
52
|
const SpeechRecognition =
|
|
29
53
|
// @ts-ignore
|
|
@@ -31,56 +55,52 @@ export const useSpeechRecognition = (
|
|
|
31
55
|
// @ts-ignore
|
|
32
56
|
window.webkitSpeechRecognition;
|
|
33
57
|
|
|
34
|
-
if (SpeechRecognition) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
58
|
+
if (!SpeechRecognition) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const recognitionInstance = new SpeechRecognition();
|
|
63
|
+
recognitionInstance.continuous = true;
|
|
64
|
+
recognitionInstance.interimResults = true;
|
|
65
|
+
|
|
66
|
+
recognitionInstance.onresult = (event) => {
|
|
67
|
+
let finalText = '';
|
|
68
|
+
let interimText = '';
|
|
69
|
+
|
|
70
|
+
// Process all results to separate final and interim text
|
|
71
|
+
for (let i = 0; i < event.results.length; i++) {
|
|
72
|
+
const result = event.results[i];
|
|
73
|
+
if (result.isFinal) {
|
|
74
|
+
finalText += result[0].transcript;
|
|
75
|
+
} else {
|
|
76
|
+
interimText += result[0].transcript;
|
|
51
77
|
}
|
|
78
|
+
}
|
|
52
79
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
setRecognition(recognitionInstance);
|
|
76
|
-
|
|
77
|
-
return () => {
|
|
78
|
-
recognitionInstance.abort();
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
}, [onTranscriptChange]);
|
|
80
|
+
// Combine final and interim text and update the prompt
|
|
81
|
+
onTranscriptChangeRef.current(
|
|
82
|
+
finalText + interimText,
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
recognitionInstance.onerror = (event) => {
|
|
87
|
+
console.error(
|
|
88
|
+
'Speech recognition error',
|
|
89
|
+
event.error,
|
|
90
|
+
);
|
|
91
|
+
setIsSpeaking(false);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
recognitionInstance.onend = () => {
|
|
95
|
+
setIsSpeaking(false);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
recognitionRef.current = recognitionInstance;
|
|
99
|
+
return recognitionInstance;
|
|
100
|
+
}, []);
|
|
82
101
|
|
|
83
102
|
const toggleSpeechRecognition = useCallback(() => {
|
|
103
|
+
const recognition = getRecognition();
|
|
84
104
|
if (!recognition) {
|
|
85
105
|
console.error(
|
|
86
106
|
'Speech recognition not supported in this browser',
|
|
@@ -92,21 +112,25 @@ export const useSpeechRecognition = (
|
|
|
92
112
|
recognition.stop();
|
|
93
113
|
setIsSpeaking(false);
|
|
94
114
|
} else {
|
|
95
|
-
// Reset final transcript when starting new recognition
|
|
96
|
-
setFinalTranscript('');
|
|
97
115
|
recognition.start();
|
|
98
116
|
setIsSpeaking(true);
|
|
99
117
|
}
|
|
100
|
-
}, [
|
|
118
|
+
}, [getRecognition, isSpeaking]);
|
|
101
119
|
|
|
102
120
|
const stopSpeaking = useCallback(() => {
|
|
103
|
-
if (
|
|
104
|
-
|
|
121
|
+
if (recognitionRef.current && isSpeaking) {
|
|
122
|
+
recognitionRef.current.stop();
|
|
105
123
|
setIsSpeaking(false);
|
|
106
|
-
// Reset final transcript when manually stopping
|
|
107
|
-
setFinalTranscript('');
|
|
108
124
|
}
|
|
109
|
-
}, [isSpeaking
|
|
125
|
+
}, [isSpeaking]);
|
|
126
|
+
|
|
127
|
+
// Release the microphone if the component unmounts while listening.
|
|
128
|
+
useEffect(
|
|
129
|
+
() => () => {
|
|
130
|
+
recognitionRef.current?.abort();
|
|
131
|
+
},
|
|
132
|
+
[],
|
|
133
|
+
);
|
|
110
134
|
|
|
111
135
|
return {
|
|
112
136
|
isSpeaking,
|
package/src/vite-env.d.ts
CHANGED
|
Binary file
|