@@ -106,6 +106,7 @@ const SEND_BUTTON_ACTIVE =
106106const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
107107
108108const MAX_CHAT_TEXTAREA_HEIGHT = 200
109+ const SPEECH_RECOGNITION_LANG = 'en-US'
109110
110111const DROP_OVERLAY_ICONS = [
111112 PdfIcon ,
@@ -267,13 +268,18 @@ export function UserInput({
267268 const [ isListening , setIsListening ] = useState ( false )
268269 const recognitionRef = useRef < SpeechRecognitionInstance | null > ( null )
269270 const prefixRef = useRef ( '' )
271+ const valueRef = useRef ( value )
270272
271273 useEffect ( ( ) => {
272274 return ( ) => {
273275 recognitionRef . current ?. abort ( )
274276 }
275277 } , [ ] )
276278
279+ useEffect ( ( ) => {
280+ valueRef . current = value
281+ } , [ value ] )
282+
277283 const textareaRef = mentionMenu . textareaRef
278284 const wasSendingRef = useRef ( false )
279285 const atInsertPosRef = useRef < number | null > ( null )
@@ -390,6 +396,84 @@ export function UserInput({
390396 [ textareaRef ]
391397 )
392398
399+ const startRecognition = useCallback ( ( ) : boolean => {
400+ const w = window as WindowWithSpeech
401+ const SpeechRecognitionAPI = w . SpeechRecognition || w . webkitSpeechRecognition
402+ if ( ! SpeechRecognitionAPI ) return false
403+
404+ const recognition = new SpeechRecognitionAPI ( )
405+ recognition . continuous = true
406+ recognition . interimResults = true
407+ recognition . lang = SPEECH_RECOGNITION_LANG
408+
409+ recognition . onresult = ( event : SpeechRecognitionEvent ) => {
410+ let transcript = ''
411+ for ( let i = 0 ; i < event . results . length ; i ++ ) {
412+ transcript += event . results [ i ] [ 0 ] . transcript
413+ }
414+ const prefix = prefixRef . current
415+ const newVal = prefix ? `${ prefix } ${ transcript } ` : transcript
416+ setValue ( newVal )
417+ valueRef . current = newVal
418+ }
419+
420+ recognition . onend = ( ) => {
421+ if ( recognitionRef . current === recognition ) {
422+ prefixRef . current = valueRef . current
423+ try {
424+ recognition . start ( )
425+ } catch {
426+ recognitionRef . current = null
427+ setIsListening ( false )
428+ }
429+ }
430+ }
431+
432+ recognition . onerror = ( e : SpeechRecognitionErrorEvent ) => {
433+ if ( recognitionRef . current !== recognition ) return
434+ if ( e . error === 'aborted' || e . error === 'not-allowed' ) {
435+ recognitionRef . current = null
436+ setIsListening ( false )
437+ }
438+ }
439+
440+ recognitionRef . current = recognition
441+ try {
442+ recognition . start ( )
443+ return true
444+ } catch {
445+ recognitionRef . current = null
446+ return false
447+ }
448+ } , [ ] )
449+
450+ const restartRecognition = useCallback (
451+ ( newPrefix : string ) => {
452+ if ( ! recognitionRef . current ) return
453+ prefixRef . current = newPrefix
454+ recognitionRef . current . abort ( )
455+ recognitionRef . current = null
456+ if ( ! startRecognition ( ) ) {
457+ setIsListening ( false )
458+ }
459+ } ,
460+ [ startRecognition ]
461+ )
462+
463+ const toggleListening = useCallback ( ( ) => {
464+ if ( isListening ) {
465+ recognitionRef . current ?. stop ( )
466+ recognitionRef . current = null
467+ setIsListening ( false )
468+ return
469+ }
470+
471+ prefixRef . current = value
472+ if ( startRecognition ( ) ) {
473+ setIsListening ( true )
474+ }
475+ } , [ isListening , value , startRecognition ] )
476+
393477 const handleSubmit = useCallback ( ( ) => {
394478 const fileAttachmentsForApi : FileAttachmentForApi [ ] = files . attachedFiles
395479 . filter ( ( f ) => ! f . uploading && f . key )
@@ -407,13 +491,14 @@ export function UserInput({
407491 contextManagement . selectedContexts . length > 0 ? contextManagement . selectedContexts : undefined
408492 )
409493 setValue ( '' )
494+ restartRecognition ( '' )
410495 files . clearAttachedFiles ( )
411496 contextManagement . clearContexts ( )
412497
413498 if ( textareaRef . current ) {
414499 textareaRef . current . style . height = 'auto'
415500 }
416- } , [ onSubmit , files , value , contextManagement , textareaRef ] )
501+ } , [ onSubmit , files , value , contextManagement , textareaRef , restartRecognition ] )
417502
418503 const handleKeyDown = useCallback (
419504 ( e : React . KeyboardEvent < HTMLTextAreaElement > ) => {
@@ -488,27 +573,33 @@ export function UserInput({
488573 [ handleSubmit , mentionTokensWithContext , value , textareaRef ]
489574 )
490575
491- const handleInputChange = useCallback ( ( e : React . ChangeEvent < HTMLTextAreaElement > ) => {
492- const newValue = e . target . value
493- const caret = e . target . selectionStart ?? newValue . length
494-
495- if (
496- caret > 0 &&
497- newValue . charAt ( caret - 1 ) === '@' &&
498- ( caret === 1 || / \s / . test ( newValue . charAt ( caret - 2 ) ) )
499- ) {
500- const before = newValue . slice ( 0 , caret - 1 )
501- const after = newValue . slice ( caret )
502- setValue ( `${ before } ${ after } ` )
503- atInsertPosRef . current = caret - 1
504- setPlusMenuOpen ( true )
505- setPlusMenuSearch ( '' )
506- setPlusMenuActiveIndex ( 0 )
507- return
508- }
576+ const handleInputChange = useCallback (
577+ ( e : React . ChangeEvent < HTMLTextAreaElement > ) => {
578+ const newValue = e . target . value
579+ const caret = e . target . selectionStart ?? newValue . length
580+
581+ if (
582+ caret > 0 &&
583+ newValue . charAt ( caret - 1 ) === '@' &&
584+ ( caret === 1 || / \s / . test ( newValue . charAt ( caret - 2 ) ) )
585+ ) {
586+ const before = newValue . slice ( 0 , caret - 1 )
587+ const after = newValue . slice ( caret )
588+ const adjusted = `${ before } ${ after } `
589+ setValue ( adjusted )
590+ atInsertPosRef . current = caret - 1
591+ setPlusMenuOpen ( true )
592+ setPlusMenuSearch ( '' )
593+ setPlusMenuActiveIndex ( 0 )
594+ restartRecognition ( adjusted )
595+ return
596+ }
509597
510- setValue ( newValue )
511- } , [ ] )
598+ setValue ( newValue )
599+ restartRecognition ( newValue )
600+ } ,
601+ [ restartRecognition ]
602+ )
512603
513604 const handleSelectAdjust = useCallback ( ( ) => {
514605 const textarea = textareaRef . current
@@ -536,56 +627,6 @@ export function UserInput({
536627 [ isInitialView ]
537628 )
538629
539- const toggleListening = useCallback ( ( ) => {
540- if ( isListening ) {
541- recognitionRef . current ?. stop ( )
542- recognitionRef . current = null
543- setIsListening ( false )
544- return
545- }
546-
547- const w = window as WindowWithSpeech
548- const SpeechRecognitionAPI = w . SpeechRecognition || w . webkitSpeechRecognition
549- if ( ! SpeechRecognitionAPI ) return
550-
551- prefixRef . current = value
552-
553- const recognition = new SpeechRecognitionAPI ( )
554- recognition . continuous = true
555- recognition . interimResults = true
556- recognition . lang = 'en-US'
557-
558- recognition . onresult = ( event : SpeechRecognitionEvent ) => {
559- let transcript = ''
560- for ( let i = 0 ; i < event . results . length ; i ++ ) {
561- transcript += event . results [ i ] [ 0 ] . transcript
562- }
563- const prefix = prefixRef . current
564- setValue ( prefix ? `${ prefix } ${ transcript } ` : transcript )
565- }
566-
567- recognition . onend = ( ) => {
568- if ( recognitionRef . current === recognition ) {
569- try {
570- recognition . start ( )
571- } catch {
572- recognitionRef . current = null
573- setIsListening ( false )
574- }
575- }
576- }
577- recognition . onerror = ( e : SpeechRecognitionErrorEvent ) => {
578- if ( e . error === 'aborted' || e . error === 'not-allowed' ) {
579- recognitionRef . current = null
580- setIsListening ( false )
581- }
582- }
583-
584- recognitionRef . current = recognition
585- recognition . start ( )
586- setIsListening ( true )
587- } , [ isListening , value ] )
588-
589630 const renderOverlayContent = useCallback ( ( ) => {
590631 const contexts = contextManagement . selectedContexts
591632
0 commit comments