diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 23ee665..8c7589a 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -23,45 +23,8 @@ import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButto import { ExamplePrompts } from '~/components/chat/ExamplePrompts'; import FilePreview from './FilePreview'; - -// @ts-ignore TODO: Introduce proper types -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const ModelSelector = ({ model, setModel, provider, setProvider, modelList, providerList, apiKeys }) => { - return ( -
- - -
- ); -}; +import { ModelSelector } from '~/components/chat/ModelSelector'; +import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition'; const TEXTAREA_MIN_HEIGHT = 76; @@ -92,6 +55,7 @@ interface BaseChatProps { imageDataList?: string[]; setImageDataList?: (dataList: string[]) => void; } + export const BaseChat = React.forwardRef( ( { @@ -126,7 +90,11 @@ export const BaseChat = React.forwardRef( const [apiKeys, setApiKeys] = useState>({}); const [modelList, setModelList] = useState(MODEL_LIST); const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false); + const [isListening, setIsListening] = useState(false); + const [recognition, setRecognition] = useState(null); + const [transcript, setTranscript] = useState(''); + console.log(transcript); useEffect(() => { // Load API keys from cookies on component mount try { @@ -149,8 +117,72 @@ export const BaseChat = React.forwardRef( initializeModelList().then((modelList) => { setModelList(modelList); }); + + if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + const recognition = new SpeechRecognition(); + recognition.continuous = true; + recognition.interimResults = true; + + recognition.onresult = (event) => { + const transcript = Array.from(event.results) + .map((result) => result[0]) + .map((result) => result.transcript) + .join(''); + + setTranscript(transcript); + + if (handleInputChange) { + const syntheticEvent = { + target: { value: transcript }, + } as React.ChangeEvent; + handleInputChange(syntheticEvent); + } + }; + + recognition.onerror = (event) => { + console.error('Speech recognition error:', event.error); + setIsListening(false); + }; + + setRecognition(recognition); + } }, []); + const startListening = () => { + if (recognition) { + recognition.start(); + setIsListening(true); + } + }; + + const stopListening = () => { + if (recognition) { + recognition.stop(); + setIsListening(false); + } + }; + + const handleSendMessage = (event: React.UIEvent, messageInput?: string) => { + if (sendMessage) { + sendMessage(event, messageInput); + + if (recognition) { + recognition.abort(); // Stop current recognition + setTranscript(''); // Clear transcript + setIsListening(false); + + // Clear the input by triggering handleInputChange with empty value + if (handleInputChange) { + const syntheticEvent = { + target: { value: '' }, + } as React.ChangeEvent; + handleInputChange(syntheticEvent); + } + } + } + }; + const updateApiKey = (provider: string, key: string) => { try { const updatedApiKeys = { ...apiKeys, [provider]: key }; @@ -316,7 +348,6 @@ export const BaseChat = React.forwardRef( -
( event.preventDefault(); - sendMessage?.(event); + if (isStreaming) { + handleStop?.(); + return; + } + + handleSendMessage?.(event); } }} value={input} @@ -422,7 +458,7 @@ export const BaseChat = React.forwardRef( } if (input.length > 0 || uploadedFiles.length > 0) { - sendMessage?.(event); + handleSendMessage?.(event); } }} /> @@ -457,6 +493,13 @@ export const BaseChat = React.forwardRef( )} + + {chatStarted && {() => }}
{input.length > 3 ? ( @@ -471,7 +514,15 @@ export const BaseChat = React.forwardRef( {!chatStarted && ImportButtons(importChat)} - {!chatStarted && ExamplePrompts(sendMessage)} + {!chatStarted && + ExamplePrompts((event, messageInput) => { + if (isStreaming) { + handleStop?.(); + return; + } + + handleSendMessage?.(event, messageInput); + })} {() => } diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx new file mode 100644 index 0000000..1bc7a66 --- /dev/null +++ b/app/components/chat/ModelSelector.tsx @@ -0,0 +1,63 @@ +import type { ProviderInfo } from '~/types/model'; +import type { ModelInfo } from '~/utils/types'; + +interface ModelSelectorProps { + model?: string; + setModel?: (model: string) => void; + provider?: ProviderInfo; + setProvider?: (provider: ProviderInfo) => void; + modelList: ModelInfo[]; + providerList: ProviderInfo[]; + apiKeys: Record; +} + +export const ModelSelector = ({ + model, + setModel, + provider, + setProvider, + modelList, + providerList, +}: ModelSelectorProps) => { + return ( +
+ + +
+ ); +}; diff --git a/app/components/chat/SpeechRecognition.tsx b/app/components/chat/SpeechRecognition.tsx new file mode 100644 index 0000000..18c66c7 --- /dev/null +++ b/app/components/chat/SpeechRecognition.tsx @@ -0,0 +1,28 @@ +import { IconButton } from '~/components/ui/IconButton'; +import { classNames } from '~/utils/classNames'; +import React from 'react'; + +export const SpeechRecognitionButton = ({ + isListening, + onStart, + onStop, + disabled, +}: { + isListening: boolean; + onStart: () => void; + onStop: () => void; + disabled: boolean; +}) => { + return ( + + {isListening ?
:
} + + ); +}; diff --git a/app/types/global.d.ts b/app/types/global.d.ts index a1f6789..6a03036 100644 --- a/app/types/global.d.ts +++ b/app/types/global.d.ts @@ -1,3 +1,5 @@ interface Window { showDirectoryPicker(): Promise; + webkitSpeechRecognition: typeof SpeechRecognition; + SpeechRecognition: typeof SpeechRecognition; } diff --git a/app/utils/logger.ts b/app/utils/logger.ts index 1a5c932..9b2c31c 100644 --- a/app/utils/logger.ts +++ b/app/utils/logger.ts @@ -11,7 +11,7 @@ interface Logger { setLevel: (level: DebugLevel) => void; } -let currentLevel: DebugLevel = import.meta.env.VITE_LOG_LEVEL ?? import.meta.env.DEV ? 'debug' : 'info'; +let currentLevel: DebugLevel = (import.meta.env.VITE_LOG_LEVEL ?? import.meta.env.DEV) ? 'debug' : 'info'; const isWorker = 'HTMLRewriter' in globalThis; const supportsColor = !isWorker; diff --git a/package.json b/package.json index 78d504e..8dd1fbb 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "@cloudflare/workers-types": "^4.20241127.0", "@remix-run/dev": "^2.15.0", "@types/diff": "^5.2.3", + "@types/dom-speech-recognition": "^0.0.4", "@types/file-saver": "^2.0.7", "@types/js-cookie": "^3.0.6", "@types/react": "^18.3.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95bf9b0..1bf0c53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,6 +222,9 @@ importers: '@types/diff': specifier: ^5.2.3 version: 5.2.3 + '@types/dom-speech-recognition': + specifier: ^0.0.4 + version: 0.0.4 '@types/file-saver': specifier: ^2.0.7 version: 2.0.7 @@ -2039,6 +2042,9 @@ packages: '@types/diff@5.2.3': resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==} + '@types/dom-speech-recognition@0.0.4': + resolution: {integrity: sha512-zf2GwV/G6TdaLwpLDcGTIkHnXf8JEf/viMux+khqKQKDa8/8BAUtXXZS563GnvJ4Fg0PBLGAaFf2GekEVSZ6GQ==} + '@types/eslint@8.56.10': resolution: {integrity: sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==} @@ -7464,6 +7470,8 @@ snapshots: '@types/diff@5.2.3': {} + '@types/dom-speech-recognition@0.0.4': {} + '@types/eslint@8.56.10': dependencies: '@types/estree': 1.0.6 @@ -7812,7 +7820,7 @@ snapshots: '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.0) '@vanilla-extract/babel-plugin-debug-ids': 1.1.0 '@vanilla-extract/css': 1.16.1 - esbuild: 0.17.6 + esbuild: 0.17.19 eval: 0.1.8 find-up: 5.0.0 javascript-stringify: 2.1.0 diff --git a/tsconfig.json b/tsconfig.json index fd161f9..8ef1458 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ESNext"], - "types": ["@remix-run/cloudflare", "vite/client", "@cloudflare/workers-types/2023-07-01"], + "types": ["@remix-run/cloudflare", "vite/client", "@cloudflare/workers-types/2023-07-01", "@types/dom-speech-recognition"], "isolatedModules": true, "esModuleInterop": true, "jsx": "react-jsx",