@@ -32,6 +32,7 @@ import type { ChatStatus, FileUIPart } from "ai";
3232import {
3333 ImageIcon ,
3434 Loader2Icon ,
35+ MicIcon ,
3536 PaperclipIcon ,
3637 PlusIcon ,
3738 SendIcon ,
@@ -961,6 +962,149 @@ export const PromptInputSubmit = ({
961962 ) ;
962963} ;
963964
965+ interface SpeechRecognition extends EventTarget {
966+ continuous : boolean ;
967+ interimResults : boolean ;
968+ lang : string ;
969+ start ( ) : void ;
970+ stop ( ) : void ;
971+ onstart : ( ( this : SpeechRecognition , ev : Event ) => any ) | null ;
972+ onend : ( ( this : SpeechRecognition , ev : Event ) => any ) | null ;
973+ onresult : ( ( this : SpeechRecognition , ev : SpeechRecognitionEvent ) => any ) | null ;
974+ onerror : ( ( this : SpeechRecognition , ev : SpeechRecognitionErrorEvent ) => any ) | null ;
975+ }
976+
977+ interface SpeechRecognitionEvent extends Event {
978+ results : SpeechRecognitionResultList ;
979+ }
980+
981+ interface SpeechRecognitionResultList {
982+ readonly length : number ;
983+ item ( index : number ) : SpeechRecognitionResult ;
984+ [ index : number ] : SpeechRecognitionResult ;
985+ }
986+
987+ interface SpeechRecognitionResult {
988+ readonly length : number ;
989+ item ( index : number ) : SpeechRecognitionAlternative ;
990+ [ index : number ] : SpeechRecognitionAlternative ;
991+ isFinal : boolean ;
992+ }
993+
994+ interface SpeechRecognitionAlternative {
995+ transcript : string ;
996+ confidence : number ;
997+ }
998+
999+ interface SpeechRecognitionErrorEvent extends Event {
1000+ error : string ;
1001+ }
1002+
1003+ declare global {
1004+ interface Window {
1005+ SpeechRecognition : {
1006+ new ( ) : SpeechRecognition ;
1007+ } ;
1008+ webkitSpeechRecognition : {
1009+ new ( ) : SpeechRecognition ;
1010+ } ;
1011+ }
1012+ }
1013+
1014+ export type PromptInputSpeechButtonProps = ComponentProps < typeof Button > & {
1015+ textareaRef ?: RefObject < HTMLTextAreaElement | null > ;
1016+ onTranscriptionChange ?: ( text : string ) => void ;
1017+ } ;
1018+
1019+ export const PromptInputSpeechButton = ( {
1020+ className,
1021+ textareaRef,
1022+ onTranscriptionChange,
1023+ ...props
1024+ } : PromptInputSpeechButtonProps ) => {
1025+ const [ isListening , setIsListening ] = useState ( false ) ;
1026+ const [ recognition , setRecognition ] = useState < SpeechRecognition | null > ( null ) ;
1027+ const recognitionRef = useRef < SpeechRecognition | null > ( null ) ;
1028+
1029+ useEffect ( ( ) => {
1030+ if ( typeof window !== "undefined" && ( "SpeechRecognition" in window || "webkitSpeechRecognition" in window ) ) {
1031+ const SpeechRecognition = window . SpeechRecognition || window . webkitSpeechRecognition ;
1032+ const speechRecognition = new SpeechRecognition ( ) ;
1033+
1034+ speechRecognition . continuous = true ;
1035+ speechRecognition . interimResults = true ;
1036+ speechRecognition . lang = "en-US" ;
1037+
1038+ speechRecognition . onstart = ( ) => {
1039+ setIsListening ( true ) ;
1040+ } ;
1041+
1042+ speechRecognition . onend = ( ) => {
1043+ setIsListening ( false ) ;
1044+ } ;
1045+
1046+ speechRecognition . onresult = ( event ) => {
1047+ let finalTranscript = "" ;
1048+
1049+ for ( let i = 0 ; i < event . results . length ; i ++ ) {
1050+ if ( event . results [ i ] . isFinal ) {
1051+ finalTranscript += event . results [ i ] [ 0 ] . transcript ;
1052+ }
1053+ }
1054+
1055+ if ( finalTranscript && textareaRef ?. current ) {
1056+ const textarea = textareaRef . current ;
1057+ const currentValue = textarea . value ;
1058+ const newValue = currentValue + ( currentValue ? " " : "" ) + finalTranscript ;
1059+
1060+ textarea . value = newValue ;
1061+ textarea . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) ) ;
1062+ onTranscriptionChange ?.( newValue ) ;
1063+ }
1064+ } ;
1065+
1066+ speechRecognition . onerror = ( event ) => {
1067+ console . error ( "Speech recognition error:" , event . error ) ;
1068+ setIsListening ( false ) ;
1069+ } ;
1070+
1071+ recognitionRef . current = speechRecognition ;
1072+ setRecognition ( speechRecognition ) ;
1073+ }
1074+
1075+ return ( ) => {
1076+ if ( recognitionRef . current ) {
1077+ recognitionRef . current . stop ( ) ;
1078+ }
1079+ } ;
1080+ } , [ textareaRef , onTranscriptionChange ] ) ;
1081+
1082+ const toggleListening = useCallback ( ( ) => {
1083+ if ( ! recognition ) return ;
1084+
1085+ if ( isListening ) {
1086+ recognition . stop ( ) ;
1087+ } else {
1088+ recognition . start ( ) ;
1089+ }
1090+ } , [ recognition , isListening ] ) ;
1091+
1092+ return (
1093+ < PromptInputButton
1094+ className = { cn (
1095+ "relative transition-all duration-200" ,
1096+ isListening && "animate-pulse bg-accent text-accent-foreground" ,
1097+ className
1098+ ) }
1099+ onClick = { toggleListening }
1100+ disabled = { ! recognition }
1101+ { ...props }
1102+ >
1103+ < MicIcon className = "size-4" />
1104+ </ PromptInputButton >
1105+ ) ;
1106+ } ;
1107+
9641108export type PromptInputModelSelectProps = ComponentProps < typeof Select > ;
9651109
9661110export const PromptInputModelSelect = ( props : PromptInputModelSelectProps ) => (
0 commit comments