Eduards commited on
Commit
1890c4e
·
unverified ·
2 Parent(s): 67862d4 d1ae347

Merge pull request #537 from wonderwhy-er/Voice-input-with-fixes

Browse files
app/components/chat/BaseChat.tsx CHANGED
@@ -23,45 +23,8 @@ import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButto
23
  import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
24
 
25
  import FilePreview from './FilePreview';
26
-
27
- // @ts-ignore TODO: Introduce proper types
28
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
29
- const ModelSelector = ({ model, setModel, provider, setProvider, modelList, providerList, apiKeys }) => {
30
- return (
31
- <div className="mb-2 flex gap-2 flex-col sm:flex-row">
32
- <select
33
- value={provider?.name}
34
- onChange={(e) => {
35
- setProvider(providerList.find((p: ProviderInfo) => p.name === e.target.value));
36
-
37
- const firstModel = [...modelList].find((m) => m.provider == e.target.value);
38
- setModel(firstModel ? firstModel.name : '');
39
- }}
40
- className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all"
41
- >
42
- {providerList.map((provider: ProviderInfo) => (
43
- <option key={provider.name} value={provider.name}>
44
- {provider.name}
45
- </option>
46
- ))}
47
- </select>
48
- <select
49
- key={provider?.name}
50
- value={model}
51
- onChange={(e) => setModel(e.target.value)}
52
- className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all lg:max-w-[70%]"
53
- >
54
- {[...modelList]
55
- .filter((e) => e.provider == provider?.name && e.name)
56
- .map((modelOption) => (
57
- <option key={modelOption.name} value={modelOption.name}>
58
- {modelOption.label}
59
- </option>
60
- ))}
61
- </select>
62
- </div>
63
- );
64
- };
65
 
66
  const TEXTAREA_MIN_HEIGHT = 76;
67
 
@@ -92,6 +55,7 @@ interface BaseChatProps {
92
  imageDataList?: string[];
93
  setImageDataList?: (dataList: string[]) => void;
94
  }
 
95
  export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
96
  (
97
  {
@@ -126,7 +90,11 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
126
  const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
127
  const [modelList, setModelList] = useState(MODEL_LIST);
128
  const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
 
 
 
129
 
 
130
  useEffect(() => {
131
  // Load API keys from cookies on component mount
132
  try {
@@ -149,8 +117,72 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
149
  initializeModelList().then((modelList) => {
150
  setModelList(modelList);
151
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  }, []);
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  const updateApiKey = (provider: string, key: string) => {
155
  try {
156
  const updatedApiKeys = { ...apiKeys, [provider]: key };
@@ -316,7 +348,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
316
  </button>
317
  </div>
318
 
319
-
320
  <div className={isModelSettingsCollapsed ? 'hidden' : ''}>
321
  <ModelSelector
322
  key={provider?.name + ':' + modelList.length}
@@ -395,7 +426,12 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
395
 
396
  event.preventDefault();
397
 
398
- sendMessage?.(event);
 
 
 
 
 
399
  }
400
  }}
401
  value={input}
@@ -422,7 +458,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
422
  }
423
 
424
  if (input.length > 0 || uploadedFiles.length > 0) {
425
- sendMessage?.(event);
426
  }
427
  }}
428
  />
@@ -457,6 +493,13 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
457
  </>
458
  )}
459
  </IconButton>
 
 
 
 
 
 
 
460
  {chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
461
  </div>
462
  {input.length > 3 ? (
@@ -471,7 +514,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
471
  </div>
472
  </div>
473
  {!chatStarted && ImportButtons(importChat)}
474
- {!chatStarted && ExamplePrompts(sendMessage)}
 
 
 
 
 
 
 
 
475
  </div>
476
  <ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
477
  </div>
 
23
  import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
24
 
25
  import FilePreview from './FilePreview';
26
+ import { ModelSelector } from '~/components/chat/ModelSelector';
27
+ import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
  const TEXTAREA_MIN_HEIGHT = 76;
30
 
 
55
  imageDataList?: string[];
56
  setImageDataList?: (dataList: string[]) => void;
57
  }
58
+
59
  export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
60
  (
61
  {
 
90
  const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
91
  const [modelList, setModelList] = useState(MODEL_LIST);
92
  const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
93
+ const [isListening, setIsListening] = useState(false);
94
+ const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
95
+ const [transcript, setTranscript] = useState('');
96
 
97
+ console.log(transcript);
98
  useEffect(() => {
99
  // Load API keys from cookies on component mount
100
  try {
 
117
  initializeModelList().then((modelList) => {
118
  setModelList(modelList);
119
  });
120
+
121
+ if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
122
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
123
+ const recognition = new SpeechRecognition();
124
+ recognition.continuous = true;
125
+ recognition.interimResults = true;
126
+
127
+ recognition.onresult = (event) => {
128
+ const transcript = Array.from(event.results)
129
+ .map((result) => result[0])
130
+ .map((result) => result.transcript)
131
+ .join('');
132
+
133
+ setTranscript(transcript);
134
+
135
+ if (handleInputChange) {
136
+ const syntheticEvent = {
137
+ target: { value: transcript },
138
+ } as React.ChangeEvent<HTMLTextAreaElement>;
139
+ handleInputChange(syntheticEvent);
140
+ }
141
+ };
142
+
143
+ recognition.onerror = (event) => {
144
+ console.error('Speech recognition error:', event.error);
145
+ setIsListening(false);
146
+ };
147
+
148
+ setRecognition(recognition);
149
+ }
150
  }, []);
151
 
152
+ const startListening = () => {
153
+ if (recognition) {
154
+ recognition.start();
155
+ setIsListening(true);
156
+ }
157
+ };
158
+
159
+ const stopListening = () => {
160
+ if (recognition) {
161
+ recognition.stop();
162
+ setIsListening(false);
163
+ }
164
+ };
165
+
166
+ const handleSendMessage = (event: React.UIEvent, messageInput?: string) => {
167
+ if (sendMessage) {
168
+ sendMessage(event, messageInput);
169
+
170
+ if (recognition) {
171
+ recognition.abort(); // Stop current recognition
172
+ setTranscript(''); // Clear transcript
173
+ setIsListening(false);
174
+
175
+ // Clear the input by triggering handleInputChange with empty value
176
+ if (handleInputChange) {
177
+ const syntheticEvent = {
178
+ target: { value: '' },
179
+ } as React.ChangeEvent<HTMLTextAreaElement>;
180
+ handleInputChange(syntheticEvent);
181
+ }
182
+ }
183
+ }
184
+ };
185
+
186
  const updateApiKey = (provider: string, key: string) => {
187
  try {
188
  const updatedApiKeys = { ...apiKeys, [provider]: key };
 
348
  </button>
349
  </div>
350
 
 
351
  <div className={isModelSettingsCollapsed ? 'hidden' : ''}>
352
  <ModelSelector
353
  key={provider?.name + ':' + modelList.length}
 
426
 
427
  event.preventDefault();
428
 
429
+ if (isStreaming) {
430
+ handleStop?.();
431
+ return;
432
+ }
433
+
434
+ handleSendMessage?.(event);
435
  }
436
  }}
437
  value={input}
 
458
  }
459
 
460
  if (input.length > 0 || uploadedFiles.length > 0) {
461
+ handleSendMessage?.(event);
462
  }
463
  }}
464
  />
 
493
  </>
494
  )}
495
  </IconButton>
496
+
497
+ <SpeechRecognitionButton
498
+ isListening={isListening}
499
+ onStart={startListening}
500
+ onStop={stopListening}
501
+ disabled={isStreaming}
502
+ />
503
  {chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
504
  </div>
505
  {input.length > 3 ? (
 
514
  </div>
515
  </div>
516
  {!chatStarted && ImportButtons(importChat)}
517
+ {!chatStarted &&
518
+ ExamplePrompts((event, messageInput) => {
519
+ if (isStreaming) {
520
+ handleStop?.();
521
+ return;
522
+ }
523
+
524
+ handleSendMessage?.(event, messageInput);
525
+ })}
526
  </div>
527
  <ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
528
  </div>
app/components/chat/ModelSelector.tsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ProviderInfo } from '~/types/model';
2
+ import type { ModelInfo } from '~/utils/types';
3
+
4
+ interface ModelSelectorProps {
5
+ model?: string;
6
+ setModel?: (model: string) => void;
7
+ provider?: ProviderInfo;
8
+ setProvider?: (provider: ProviderInfo) => void;
9
+ modelList: ModelInfo[];
10
+ providerList: ProviderInfo[];
11
+ apiKeys: Record<string, string>;
12
+ }
13
+
14
+ export const ModelSelector = ({
15
+ model,
16
+ setModel,
17
+ provider,
18
+ setProvider,
19
+ modelList,
20
+ providerList,
21
+ }: ModelSelectorProps) => {
22
+ return (
23
+ <div className="mb-2 flex gap-2 flex-col sm:flex-row">
24
+ <select
25
+ value={provider?.name ?? ''}
26
+ onChange={(e) => {
27
+ const newProvider = providerList.find((p: ProviderInfo) => p.name === e.target.value);
28
+
29
+ if (newProvider && setProvider) {
30
+ setProvider(newProvider);
31
+ }
32
+
33
+ const firstModel = [...modelList].find((m) => m.provider === e.target.value);
34
+
35
+ if (firstModel && setModel) {
36
+ setModel(firstModel.name);
37
+ }
38
+ }}
39
+ className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all"
40
+ >
41
+ {providerList.map((provider: ProviderInfo) => (
42
+ <option key={provider.name} value={provider.name}>
43
+ {provider.name}
44
+ </option>
45
+ ))}
46
+ </select>
47
+ <select
48
+ key={provider?.name}
49
+ value={model}
50
+ onChange={(e) => setModel?.(e.target.value)}
51
+ className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all lg:max-w-[70%]"
52
+ >
53
+ {[...modelList]
54
+ .filter((e) => e.provider == provider?.name && e.name)
55
+ .map((modelOption) => (
56
+ <option key={modelOption.name} value={modelOption.name}>
57
+ {modelOption.label}
58
+ </option>
59
+ ))}
60
+ </select>
61
+ </div>
62
+ );
63
+ };
app/components/chat/SpeechRecognition.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IconButton } from '~/components/ui/IconButton';
2
+ import { classNames } from '~/utils/classNames';
3
+ import React from 'react';
4
+
5
+ export const SpeechRecognitionButton = ({
6
+ isListening,
7
+ onStart,
8
+ onStop,
9
+ disabled,
10
+ }: {
11
+ isListening: boolean;
12
+ onStart: () => void;
13
+ onStop: () => void;
14
+ disabled: boolean;
15
+ }) => {
16
+ return (
17
+ <IconButton
18
+ title={isListening ? 'Stop listening' : 'Start speech recognition'}
19
+ disabled={disabled}
20
+ className={classNames('transition-all', {
21
+ 'text-bolt-elements-item-contentAccent': isListening,
22
+ })}
23
+ onClick={isListening ? onStop : onStart}
24
+ >
25
+ {isListening ? <div className="i-ph:microphone-slash text-xl" /> : <div className="i-ph:microphone text-xl" />}
26
+ </IconButton>
27
+ );
28
+ };
app/types/global.d.ts CHANGED
@@ -1,3 +1,5 @@
1
  interface Window {
2
  showDirectoryPicker(): Promise<FileSystemDirectoryHandle>;
 
 
3
  }
 
1
  interface Window {
2
  showDirectoryPicker(): Promise<FileSystemDirectoryHandle>;
3
+ webkitSpeechRecognition: typeof SpeechRecognition;
4
+ SpeechRecognition: typeof SpeechRecognition;
5
  }
app/utils/logger.ts CHANGED
@@ -11,7 +11,7 @@ interface Logger {
11
  setLevel: (level: DebugLevel) => void;
12
  }
13
 
14
- let currentLevel: DebugLevel = import.meta.env.VITE_LOG_LEVEL ?? import.meta.env.DEV ? 'debug' : 'info';
15
 
16
  const isWorker = 'HTMLRewriter' in globalThis;
17
  const supportsColor = !isWorker;
 
11
  setLevel: (level: DebugLevel) => void;
12
  }
13
 
14
+ let currentLevel: DebugLevel = (import.meta.env.VITE_LOG_LEVEL ?? import.meta.env.DEV) ? 'debug' : 'info';
15
 
16
  const isWorker = 'HTMLRewriter' in globalThis;
17
  const supportsColor = !isWorker;
package.json CHANGED
@@ -101,6 +101,7 @@
101
  "@cloudflare/workers-types": "^4.20241127.0",
102
  "@remix-run/dev": "^2.15.0",
103
  "@types/diff": "^5.2.3",
 
104
  "@types/file-saver": "^2.0.7",
105
  "@types/js-cookie": "^3.0.6",
106
  "@types/react": "^18.3.12",
 
101
  "@cloudflare/workers-types": "^4.20241127.0",
102
  "@remix-run/dev": "^2.15.0",
103
  "@types/diff": "^5.2.3",
104
+ "@types/dom-speech-recognition": "^0.0.4",
105
  "@types/file-saver": "^2.0.7",
106
  "@types/js-cookie": "^3.0.6",
107
  "@types/react": "^18.3.12",
pnpm-lock.yaml CHANGED
@@ -222,6 +222,9 @@ importers:
222
  '@types/diff':
223
  specifier: ^5.2.3
224
  version: 5.2.3
 
 
 
225
  '@types/file-saver':
226
  specifier: ^2.0.7
227
  version: 2.0.7
@@ -2039,6 +2042,9 @@ packages:
2039
  '@types/[email protected]':
2040
  resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==}
2041
 
 
 
 
2042
  '@types/[email protected]':
2043
  resolution: {integrity: sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==}
2044
 
@@ -7464,6 +7470,8 @@ snapshots:
7464
 
7465
  '@types/[email protected]': {}
7466
 
 
 
7467
  '@types/[email protected]':
7468
  dependencies:
7469
  '@types/estree': 1.0.6
@@ -7812,7 +7820,7 @@ snapshots:
7812
  '@babel/plugin-syntax-typescript': 7.25.9(@babel/[email protected])
7813
  '@vanilla-extract/babel-plugin-debug-ids': 1.1.0
7814
  '@vanilla-extract/css': 1.16.1
7815
- esbuild: 0.17.6
7816
  eval: 0.1.8
7817
  find-up: 5.0.0
7818
  javascript-stringify: 2.1.0
 
222
  '@types/diff':
223
  specifier: ^5.2.3
224
  version: 5.2.3
225
+ '@types/dom-speech-recognition':
226
+ specifier: ^0.0.4
227
+ version: 0.0.4
228
  '@types/file-saver':
229
  specifier: ^2.0.7
230
  version: 2.0.7
 
2042
  '@types/[email protected]':
2043
  resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==}
2044
 
2045
+ '@types/[email protected]':
2046
+ resolution: {integrity: sha512-zf2GwV/G6TdaLwpLDcGTIkHnXf8JEf/viMux+khqKQKDa8/8BAUtXXZS563GnvJ4Fg0PBLGAaFf2GekEVSZ6GQ==}
2047
+
2048
  '@types/[email protected]':
2049
  resolution: {integrity: sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==}
2050
 
 
7470
 
7471
  '@types/[email protected]': {}
7472
 
7473
+ '@types/[email protected]': {}
7474
+
7475
  '@types/[email protected]':
7476
  dependencies:
7477
  '@types/estree': 1.0.6
 
7820
  '@babel/plugin-syntax-typescript': 7.25.9(@babel/[email protected])
7821
  '@vanilla-extract/babel-plugin-debug-ids': 1.1.0
7822
  '@vanilla-extract/css': 1.16.1
7823
+ esbuild: 0.17.19
7824
  eval: 0.1.8
7825
  find-up: 5.0.0
7826
  javascript-stringify: 2.1.0
tsconfig.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
  "compilerOptions": {
3
  "lib": ["DOM", "DOM.Iterable", "ESNext"],
4
- "types": ["@remix-run/cloudflare", "vite/client", "@cloudflare/workers-types/2023-07-01"],
5
  "isolatedModules": true,
6
  "esModuleInterop": true,
7
  "jsx": "react-jsx",
 
1
  {
2
  "compilerOptions": {
3
  "lib": ["DOM", "DOM.Iterable", "ESNext"],
4
+ "types": ["@remix-run/cloudflare", "vite/client", "@cloudflare/workers-types/2023-07-01", "@types/dom-speech-recognition"],
5
  "isolatedModules": true,
6
  "esModuleInterop": true,
7
  "jsx": "react-jsx",