@@ -12,6 +12,8 @@ import {
1212 CopyIcon ,
1313 ThumbsDownIcon ,
1414 ThumbsUpIcon ,
15+ SpeakerHighIcon ,
16+ SpeakerXIcon ,
1517} from "@phosphor-icons/react" ;
1618import ReactMarkdown from "react-markdown" ;
1719import SyntaxHighlighter from "react-syntax-highlighter" ;
@@ -23,6 +25,9 @@ import TabsSuggestion from "./tabs-suggestion";
2325import { useFont } from "@/contexts/font-context" ;
2426import { ModelSelector } from "@/components/ui/model-selector" ;
2527import { DEFAULT_MODEL_ID } from "@/models/constants" ;
28+ import SpeechRecognition , { useSpeechRecognition } from 'react-speech-recognition' ;
29+ import { useSpeechSynthesis } from 'react-speech-kit' ;
30+ import { toast } from "sonner" ;
2631
2732const geistMono = Geist_Mono ( {
2833 subsets : [ "latin" ] ,
@@ -40,12 +45,50 @@ interface Message {
4045const UIInput = ( ) => {
4146 const session = useSession ( ) ;
4247 const [ model , setModel ] = useState < string > ( DEFAULT_MODEL_ID ) ;
48+ const [ modeOfChatting , setModeOfChatting ] = useState < "text" | "voice" > ( "text" ) ;
4349 const [ query , setQuery ] = useState < string > ( "" ) ;
4450 const [ messages , setMessages ] = useState < Message [ ] > ( [ ] ) ;
4551 const [ showWelcome , setShowWelcome ] = useState ( true ) ;
4652 const [ isLoading , setIsLoading ] = useState ( false ) ;
4753 const messagesEndRef = useRef < HTMLDivElement > ( null ) ;
4854 const abortControllerRef = useRef < AbortController | null > ( null ) ;
55+ const welcomeSpokenRef = useRef ( false ) ;
56+
57+ const {
58+ transcript,
59+ listening,
60+ resetTranscript,
61+ browserSupportsSpeechRecognition
62+ } = useSpeechRecognition ( ) ;
63+
64+ const { speak, cancel, speaking, supported : ttsSupported , voices } = useSpeechSynthesis ( ) ;
65+ const [ selectedVoice , setSelectedVoice ] = useState < SpeechSynthesisVoice | null > ( null ) ;
66+
67+ useEffect ( ( ) => {
68+ if ( ! browserSupportsSpeechRecognition ) {
69+ toast . error ( "Your browser doesn't support speech recognition." ) ;
70+ }
71+ } , [ browserSupportsSpeechRecognition ] ) ;
72+
73+ useEffect ( ( ) => {
74+ if ( modeOfChatting === "voice" && ! ttsSupported ) {
75+ toast . error ( "Text-to-speech not supported in your browser" ) ;
76+ setModeOfChatting ( "text" ) ;
77+ }
78+ } , [ modeOfChatting , ttsSupported ] ) ;
79+
80+ useEffect ( ( ) => {
81+ if ( ttsSupported && voices . length > 0 ) {
82+ const defaultVoice = voices . find ( v => v . default ) || voices [ 0 ] ;
83+ setSelectedVoice ( defaultVoice ! ) ;
84+ }
85+ } , [ voices , ttsSupported ] ) ;
86+
87+ useEffect ( ( ) => {
88+ if ( listening ) {
89+ setQuery ( transcript ) ;
90+ }
91+ } , [ listening , transcript ] ) ;
4992
5093 const scrollToBottom = ( ) => {
5194 messagesEndRef . current ?. scrollIntoView ( { behavior : "smooth" } ) ;
@@ -55,6 +98,16 @@ const UIInput = () => {
5598 scrollToBottom ( ) ;
5699 } , [ messages ] ) ;
57100
101+ useEffect ( ( ) => {
102+ if ( showWelcome && messages . length === 0 && modeOfChatting === "voice" && ttsSupported && selectedVoice && ! welcomeSpokenRef . current ) {
103+ welcomeSpokenRef . current = true ;
104+ speak ( {
105+ text : `Hello mate, how may I help you today?` ,
106+ voice : selectedVoice
107+ } ) ;
108+ }
109+ } , [ showWelcome , messages . length , modeOfChatting , ttsSupported , selectedVoice , speak ] ) ;
110+
58111 const createChat = api . chat . createChat . useMutation ( {
59112 onError : ( error ) => {
60113 console . error ( "Error saving chat:" , error ) ;
@@ -105,7 +158,6 @@ const UIInput = () => {
105158 const { done, value } = await reader . read ( ) ;
106159
107160 if ( done ) {
108- // Final update with complete content
109161 setMessages ( ( prev ) =>
110162 prev . map ( ( msg ) =>
111163 msg . id === tempMessageId
@@ -118,6 +170,13 @@ const UIInput = () => {
118170 clearTimeout ( updateTimeout ) ;
119171 }
120172
173+ if ( modeOfChatting === "voice" && ttsSupported && selectedVoice ) {
174+ speak ( {
175+ text : accumulatedContent ,
176+ voice : selectedVoice
177+ } ) ;
178+ }
179+
121180 break ;
122181 }
123182
@@ -150,7 +209,6 @@ const UIInput = () => {
150209 error ?: string ;
151210 } ;
152211
153- // Handle error responses
154212 if ( parsedData . error ) {
155213 console . error ( "Stream error:" , parsedData . error ) ;
156214 setMessages ( ( prev ) =>
@@ -180,7 +238,6 @@ const UIInput = () => {
180238 }
181239 } catch ( error ) {
182240 console . error ( "Error processing stream:" , error ) ;
183- // Update message with error state
184241 setMessages ( ( prev ) =>
185242 prev . map ( ( msg ) =>
186243 msg . id === tempMessageId
@@ -209,9 +266,7 @@ const UIInput = () => {
209266 } ;
210267
211268 setQuery ( "" ) ;
212-
213269 setMessages ( ( prev ) => [ ...prev , userMessage ] ) ;
214-
215270 setIsLoading ( true ) ;
216271
217272 if ( abortControllerRef . current ) {
@@ -253,6 +308,29 @@ const UIInput = () => {
253308 }
254309 } ;
255310
311+ const handleStartListening = ( ) => {
312+ resetTranscript ( ) ;
313+ SpeechRecognition . startListening ( { continuous : true } ) ;
314+ toast . success ( "Listening..." , {
315+ description : "Speak now..." ,
316+ duration : 5000 ,
317+ } ) ;
318+ } ;
319+
320+ const handleStopListening = ( ) => {
321+ SpeechRecognition . stopListening ( ) ;
322+ toast . success ( "Stopped listening" , {
323+ description : "Processing your voice input..." ,
324+ } ) ;
325+ } ;
326+
327+ const toggleMode = ( ) => {
328+ if ( modeOfChatting === "voice" && speaking ) {
329+ cancel ( ) ;
330+ }
331+ setModeOfChatting ( modeOfChatting === "text" ? "voice" : "text" ) ;
332+ } ;
333+
256334 const { selectedFont } = useFont ( ) ;
257335
258336 return (
@@ -406,16 +484,37 @@ const UIInput = () => {
406484 </ div >
407485 < div className = "font-medium" >
408486 { message . role === "assistant" && (
409- < div className = "flex w-fit items-center text-base font-semibold" >
410- < div className = "hover:bg-accent flex size-7 items-center justify-center rounded-lg" >
487+ < div className = "flex w-fit items-center gap-2 text-base font-semibold" >
488+ < button className = "hover:bg-accent flex size-7 items-center justify-center rounded-lg" >
411489 < ThumbsUpIcon weight = "bold" />
412- </ div >
413- < div className = "hover:bg-accent flex size-7 items-center justify-center rounded-lg" >
490+ </ button >
491+ < button className = "hover:bg-accent flex size-7 items-center justify-center rounded-lg" >
414492 < ThumbsDownIcon weight = "bold" />
415- </ div >
416- < div className = "hover:bg-accent flex size-7 items-center justify-center rounded-lg" >
493+ </ button >
494+ < button className = "hover:bg-accent flex size-7 items-center justify-center rounded-lg" >
417495 < CopyIcon weight = "bold" />
418- </ div >
496+ </ button >
497+ { modeOfChatting === "voice" && (
498+ < button
499+ className = "hover:bg-accent flex size-7 items-center justify-center rounded-lg"
500+ onClick = { ( ) => {
501+ if ( speaking ) {
502+ cancel ( ) ;
503+ } else if ( ttsSupported && selectedVoice ) {
504+ speak ( {
505+ text : message . content ,
506+ voice : selectedVoice
507+ } ) ;
508+ }
509+ } }
510+ >
511+ { speaking ? (
512+ < SpeakerXIcon weight = "bold" />
513+ ) : (
514+ < SpeakerHighIcon weight = "bold" />
515+ ) }
516+ </ button >
517+ ) }
419518 </ div >
420519 ) }
421520 </ div >
@@ -447,15 +546,39 @@ const UIInput = () => {
447546 void handleCreateChat ( e as any ) ;
448547 }
449548 } }
450- placeholder = "Ask whatever you want to be"
549+ placeholder = {
550+ modeOfChatting === "voice"
551+ ? "Or type here..."
552+ : "Ask whatever you want to be"
553+ }
451554 className = "h-[2rem] resize-none rounded-none border-none bg-transparent px-0 py-1 shadow-none ring-0 focus-visible:ring-0 dark:bg-transparent"
452555 disabled = { isLoading }
453556 />
454557 < div className = "mt-2 flex items-center justify-between" >
455558 < div className = "flex items-center gap-2" >
456- < div className = "bg-accent flex size-8 items-center justify-center rounded-lg border" >
457- < MicrophoneIcon />
458- </ div >
559+ < Button
560+ variant = "ghost"
561+ size = "sm"
562+ onClick = { toggleMode }
563+ className = "text-xs"
564+ >
565+ { modeOfChatting === "text" ? "Switch to Voice" : "Switch to Text" }
566+ </ Button >
567+ { modeOfChatting === "voice" && (
568+ < div className = "bg-accent flex size-8 items-center justify-center rounded-lg border" >
569+ < button
570+ onClick = { listening ? handleStopListening : handleStartListening }
571+ disabled = { ! browserSupportsSpeechRecognition }
572+ >
573+ < MicrophoneIcon
574+ weight = "bold"
575+ className = { `text-foreground size-4 hover:text-primary cursor-pointer ${
576+ listening ? "text-red-500 animate-pulse" : ""
577+ } `}
578+ />
579+ </ button >
580+ </ div >
581+ ) }
459582 < ModelSelector
460583 value = { model }
461584 onValueChange = { setModel }
@@ -482,4 +605,4 @@ const UIInput = () => {
482605 ) ;
483606} ;
484607
485- export default UIInput ;
608+ export default UIInput ;
0 commit comments