Spaces:
Sleeping
Sleeping
| /** | |
| * VoiceButton - Floating microphone button for voice interaction. | |
| * | |
| * Renders as a fixed FAB positioned above the mobile bottom navigation. | |
| * Orchestrates the voice interaction lifecycle: | |
| * 1. Click mic to start / stop quick listening (one-shot commands) | |
| * 2. Display real-time transcript while listening | |
| * 3. Parse final transcript into a command intent | |
| * 4. Execute navigation or show the help modal | |
| * 5. Speak the response via TTS | |
| * | |
| * Also provides: | |
| * - Help button to open VoiceCommands modal | |
| * - Chat button to open the full VoiceChat panel | |
| * - First-time VoiceTutorial on initial interaction | |
| */ | |
| import { useState, useEffect, useCallback, useRef } from "react"; | |
| import { useNavigate } from "react-router-dom"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { MicrophoneIcon } from "@heroicons/react/24/solid"; | |
| import { | |
| QuestionMarkCircleIcon, | |
| ExclamationTriangleIcon, | |
| XMarkIcon, | |
| ChatBubbleLeftRightIcon, | |
| } from "@heroicons/react/24/outline"; | |
| import PropTypes from "prop-types"; | |
| import useVoice from "@hooks/useVoice"; | |
| import useApp from "@hooks/useApp"; | |
| import useLocation from "@hooks/useLocation"; | |
| import { | |
| parseCommand, | |
| speak, | |
| cancelSpeech, | |
| checkCompatibility, | |
| requestMicrophonePermission, | |
| isTutorialShown, | |
| getVoiceSettings, | |
| INTENTS, | |
| } from "@services/voiceService"; | |
| import { sendVoiceMessage } from "@services/voiceAssistantApi"; | |
| import VoiceTranscript from "./VoiceTranscript"; | |
| import VoiceCommands from "./VoiceCommands"; | |
| import VoiceChat from "./VoiceChat"; | |
| import VoiceTutorial from "./VoiceTutorial"; | |
| // --------------------------------------------------------------------------- | |
| // Constants | |
| // --------------------------------------------------------------------------- | |
| const PERMISSION_GRANTED = "granted"; | |
| const PERMISSION_DENIED = "denied"; | |
| /** Duration (ms) to keep the command result visible before auto-dismissing. */ | |
| const RESULT_DISPLAY_DURATION_MS = 4000; | |
| // --------------------------------------------------------------------------- | |
| // Component | |
| // --------------------------------------------------------------------------- | |
| /** Intents that benefit from Groq-powered backend responses. */ | |
| const DATA_INTENTS = new Set([ | |
| INTENTS.QUERY_WEATHER_TIME, | |
| INTENTS.NAVIGATE_WEATHER, | |
| INTENTS.QUERY_CROP_PRICE, | |
| INTENTS.NAVIGATE_APMC, | |
| INTENTS.QUERY_DISEASE_TREATMENT, | |
| INTENTS.NAVIGATE_DISEASE, | |
| INTENTS.COMPARE_PRICES, | |
| INTENTS.READ_ALERTS, | |
| INTENTS.BEST_APMC, | |
| ]); | |
| /** | |
| * Build route state to pass entity data to target pages. | |
| * This enables auto-filling search fields and triggering data fetches. | |
| */ | |
| function _buildRouteState(parsedResult, aiResult, userLocation) { | |
| const state = { fromVoice: true }; | |
| const entities = parsedResult?.entities || {}; | |
| // Pass commodity for APMC page | |
| if (entities.crops && entities.crops.length > 0) { | |
| state.commodity = entities.crops[0]; | |
| } | |
| // Pass location for weather page | |
| if (userLocation && userLocation.taluka) { | |
| state.location = userLocation; | |
| } | |
| // Also extract commodity from backend response if available | |
| if (aiResult?.data?.commodity) { | |
| state.commodity = aiResult.data.commodity; | |
| } | |
| return state; | |
| } | |
| function VoiceButton({ className = "" }) { | |
| const navigate = useNavigate(); | |
| const { language } = useApp(); | |
| const locationCtx = useLocation(); | |
| const { | |
| isSupported, | |
| isListening, | |
| transcript, | |
| interimTranscript, | |
| error: voiceError, | |
| startListening, | |
| stopListening, | |
| clearError, | |
| } = useVoice(); | |
| const [showTranscript, setShowTranscript] = useState(false); | |
| const [showCommands, setShowCommands] = useState(false); | |
| const [showChat, setShowChat] = useState(false); | |
| const [showTutorial, setShowTutorial] = useState(false); | |
| const [commandResult, setCommandResult] = useState(null); | |
| const [micPermission, setMicPermission] = useState(null); | |
| const [localError, setLocalError] = useState(null); | |
| const lastProcessedTranscript = useRef(""); | |
| const compatibility = useRef(checkCompatibility()); | |
| const hasInteracted = useRef(false); | |
| // --------------------------------------------------------------------------- | |
| // Process final transcript into a command (one-shot mode) | |
| // --------------------------------------------------------------------------- | |
| useEffect(() => { | |
| if (showChat) return; // Chat handles its own transcript processing | |
| if (!transcript || transcript === lastProcessedTranscript.current) return; | |
| lastProcessedTranscript.current = transcript; | |
| const result = parseCommand(transcript, language); | |
| setCommandResult(result); | |
| // Navigation-only commands (home, help) | |
| if (result.intent !== INTENTS.UNKNOWN && result.confidence >= 0.5) { | |
| if (result.intent === INTENTS.SHOW_HELP) { | |
| setShowCommands(true); | |
| const settings = getVoiceSettings(); | |
| if (settings.autoSpeak) { | |
| speak(result.response, language, settings).catch(() => {}); | |
| } | |
| return; | |
| } | |
| } | |
| // For data-driven intents, call the Groq-powered backend | |
| if (DATA_INTENTS.has(result.intent) && result.confidence >= 0.5) { | |
| const location = locationCtx.hasLocation | |
| ? { state: locationCtx.state, district: locationCtx.district, taluka: locationCtx.taluka } | |
| : null; | |
| // Show loading state | |
| setCommandResult({ | |
| ...result, | |
| response: language === "hi" ? "जानकारी ला रहा हूँ..." : "Fetching information...", | |
| }); | |
| sendVoiceMessage({ message: transcript, language, location }) | |
| .then((aiResult) => { | |
| setCommandResult({ | |
| intent: result.intent, | |
| confidence: result.confidence, | |
| route: aiResult.navigate_to || result.route, | |
| response: aiResult.response, | |
| }); | |
| const settings = getVoiceSettings(); | |
| if (settings.autoSpeak) { | |
| speak(aiResult.response, language, settings).catch(() => {}); | |
| } | |
| // Navigate with entity data as route state | |
| const targetRoute = aiResult.navigate_to || result.route; | |
| if (targetRoute) { | |
| const routeState = _buildRouteState(result, aiResult, location); | |
| navigate(targetRoute, { state: routeState }); | |
| } | |
| }) | |
| .catch(() => { | |
| // Fallback: navigate and speak the basic response | |
| setCommandResult(result); | |
| if (result.route) { | |
| const routeState = _buildRouteState(result, null, location); | |
| navigate(result.route, { state: routeState }); | |
| } | |
| const settings = getVoiceSettings(); | |
| if (settings.autoSpeak) { | |
| speak(result.response, language, settings).catch(() => {}); | |
| } | |
| }); | |
| return; | |
| } | |
| // Non-data intents: navigate and speak directly | |
| if (result.intent !== INTENTS.UNKNOWN && result.confidence >= 0.5 && result.route) { | |
| navigate(result.route); | |
| } | |
| const settings = getVoiceSettings(); | |
| if (settings.autoSpeak) { | |
| speak(result.response, language, settings).catch(() => {}); | |
| } | |
| }, [transcript, language, navigate, showChat, locationCtx]); | |
| // --------------------------------------------------------------------------- | |
| // Show / hide transcript panel alongside listening state | |
| // --------------------------------------------------------------------------- | |
| useEffect(() => { | |
| if (isListening && !showChat) { | |
| setShowTranscript(true); | |
| setCommandResult(null); | |
| } | |
| }, [isListening, showChat]); | |
| // --------------------------------------------------------------------------- | |
| // Auto-dismiss command result after a short delay | |
| // --------------------------------------------------------------------------- | |
| useEffect(() => { | |
| if (!commandResult) return; | |
| const timer = setTimeout(() => { | |
| setCommandResult(null); | |
| setShowTranscript(false); | |
| }, RESULT_DISPLAY_DURATION_MS); | |
| return () => clearTimeout(timer); | |
| }, [commandResult]); | |
| // --------------------------------------------------------------------------- | |
| // Handlers | |
| // --------------------------------------------------------------------------- | |
| const ensureMicPermission = useCallback(async () => { | |
| if (!compatibility.current.isFullySupported) { | |
| setLocalError( | |
| "Voice recognition is not supported in this browser. Please use Chrome or Edge.", | |
| ); | |
| return false; | |
| } | |
| if (micPermission !== PERMISSION_GRANTED) { | |
| const state = await requestMicrophonePermission(); | |
| setMicPermission(state); | |
| if (state === PERMISSION_DENIED) { | |
| setLocalError( | |
| "Microphone access was denied. Please allow microphone permissions in your browser settings.", | |
| ); | |
| return false; | |
| } | |
| } | |
| return true; | |
| }, [micPermission]); | |
| const maybeShowTutorial = useCallback(() => { | |
| if (!hasInteracted.current && !isTutorialShown()) { | |
| hasInteracted.current = true; | |
| setShowTutorial(true); | |
| return true; | |
| } | |
| hasInteracted.current = true; | |
| return false; | |
| }, []); | |
| const handleToggleListening = useCallback(async () => { | |
| if (isListening) { | |
| stopListening(); | |
| return; | |
| } | |
| // Show tutorial on first interaction | |
| if (maybeShowTutorial()) return; | |
| const permitted = await ensureMicPermission(); | |
| if (!permitted) return; | |
| clearError(); | |
| setLocalError(null); | |
| setCommandResult(null); | |
| lastProcessedTranscript.current = ""; | |
| startListening(); | |
| }, [ | |
| isListening, | |
| maybeShowTutorial, | |
| ensureMicPermission, | |
| clearError, | |
| startListening, | |
| stopListening, | |
| ]); | |
| const handleOpenChat = useCallback(() => { | |
| // Show tutorial on first interaction | |
| if (maybeShowTutorial()) return; | |
| if (isListening) { | |
| stopListening(); | |
| } | |
| setShowTranscript(false); | |
| cancelSpeech(); | |
| setShowChat(true); | |
| }, [maybeShowTutorial, isListening, stopListening]); | |
| const handleCloseChat = useCallback(() => { | |
| setShowChat(false); | |
| }, []); | |
| const handleDismissError = useCallback(() => { | |
| setLocalError(null); | |
| clearError(); | |
| }, [clearError]); | |
| const handleCloseTranscript = useCallback(() => { | |
| setShowTranscript(false); | |
| if (isListening) { | |
| stopListening(); | |
| } | |
| cancelSpeech(); | |
| }, [isListening, stopListening]); | |
| const handleCloseTutorial = useCallback(() => { | |
| setShowTutorial(false); | |
| }, []); | |
| // --------------------------------------------------------------------------- | |
| // Render guards | |
| // --------------------------------------------------------------------------- | |
| if (!isSupported && !compatibility.current.speechRecognition) { | |
| return null; | |
| } | |
| const displayError = localError || voiceError; | |
| // When chat is open, only render the chat panel (hide FAB) | |
| if (showChat) { | |
| return ( | |
| <> | |
| <VoiceChat | |
| isOpen={showChat} | |
| onClose={handleCloseChat} | |
| language={language} | |
| /> | |
| <VoiceTutorial | |
| isOpen={showTutorial} | |
| onClose={handleCloseTutorial} | |
| language={language} | |
| /> | |
| </> | |
| ); | |
| } | |
| return ( | |
| <> | |
| <div | |
| className={`fixed bottom-20 right-4 md:bottom-6 md:right-6 z-40 flex flex-col items-end gap-3 ${className}`} | |
| > | |
| {/* Error notification */} | |
| <AnimatePresence> | |
| {displayError && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 10, scale: 0.95 }} | |
| animate={{ opacity: 1, y: 0, scale: 1 }} | |
| exit={{ opacity: 0, y: 10, scale: 0.95 }} | |
| className="flex items-start gap-2 max-w-xs bg-danger-50 border border-danger-200 text-danger-800 rounded-lg px-3 py-2 text-sm shadow-card" | |
| > | |
| <ExclamationTriangleIcon | |
| className="h-5 w-5 shrink-0 text-danger-500 mt-0.5" | |
| aria-hidden="true" | |
| /> | |
| <span className="flex-1">{displayError}</span> | |
| <button | |
| type="button" | |
| onClick={handleDismissError} | |
| className="shrink-0 p-0.5 rounded hover:bg-danger-100 transition-colors" | |
| aria-label="Dismiss error" | |
| > | |
| <XMarkIcon className="h-4 w-4" /> | |
| </button> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* Transcript panel (one-shot mode) */} | |
| <AnimatePresence> | |
| {showTranscript && ( | |
| <VoiceTranscript | |
| transcript={transcript} | |
| interimTranscript={interimTranscript} | |
| isListening={isListening} | |
| commandResult={commandResult} | |
| language={language} | |
| onClose={handleCloseTranscript} | |
| /> | |
| )} | |
| </AnimatePresence> | |
| {/* Button cluster */} | |
| <div className="flex items-center gap-2"> | |
| {/* Help button */} | |
| <motion.button | |
| type="button" | |
| onClick={() => setShowCommands(true)} | |
| className={[ | |
| "h-10 w-10 rounded-full bg-white border border-neutral-200", | |
| "text-neutral-500 shadow-card flex items-center justify-center", | |
| "hover:bg-neutral-50 hover:text-neutral-700 transition-colors", | |
| "focus:outline-none focus:ring-2 focus:ring-primary-500", | |
| ].join(" ")} | |
| aria-label="Show voice commands" | |
| whileHover={{ scale: 1.05 }} | |
| whileTap={{ scale: 0.95 }} | |
| > | |
| <QuestionMarkCircleIcon className="h-5 w-5" /> | |
| </motion.button> | |
| {/* Chat button */} | |
| <motion.button | |
| type="button" | |
| onClick={handleOpenChat} | |
| className={[ | |
| "h-10 w-10 rounded-full bg-white border border-neutral-200", | |
| "text-neutral-500 shadow-card flex items-center justify-center", | |
| "hover:bg-neutral-50 hover:text-neutral-700 transition-colors", | |
| "focus:outline-none focus:ring-2 focus:ring-primary-500", | |
| ].join(" ")} | |
| aria-label="Open voice chat" | |
| whileHover={{ scale: 1.05 }} | |
| whileTap={{ scale: 0.95 }} | |
| > | |
| <ChatBubbleLeftRightIcon className="h-5 w-5" /> | |
| </motion.button> | |
| {/* Microphone FAB */} | |
| <div className="relative"> | |
| {/* Pulse rings animation */} | |
| <AnimatePresence> | |
| {isListening && ( | |
| <> | |
| <motion.div | |
| className="absolute inset-0 rounded-full bg-primary-400" | |
| initial={{ scale: 1, opacity: 0.4 }} | |
| animate={{ scale: 2, opacity: 0 }} | |
| transition={{ | |
| duration: 1.5, | |
| repeat: Infinity, | |
| ease: "easeOut", | |
| }} | |
| /> | |
| <motion.div | |
| className="absolute inset-0 rounded-full bg-primary-400" | |
| initial={{ scale: 1, opacity: 0.3 }} | |
| animate={{ scale: 1.8, opacity: 0 }} | |
| transition={{ | |
| duration: 1.5, | |
| repeat: Infinity, | |
| ease: "easeOut", | |
| delay: 0.5, | |
| }} | |
| /> | |
| </> | |
| )} | |
| </AnimatePresence> | |
| <motion.button | |
| type="button" | |
| onClick={handleToggleListening} | |
| className={[ | |
| "relative z-10 h-14 w-14 rounded-full shadow-lg", | |
| "flex items-center justify-center", | |
| "transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500", | |
| isListening | |
| ? "bg-danger-600 text-white hover:bg-danger-700" | |
| : "bg-primary-600 text-white hover:bg-primary-700", | |
| ].join(" ")} | |
| aria-label={ | |
| isListening ? "Stop listening" : "Start voice command" | |
| } | |
| aria-pressed={isListening} | |
| whileHover={{ scale: 1.05 }} | |
| whileTap={{ scale: 0.9 }} | |
| > | |
| <MicrophoneIcon className="h-6 w-6" /> | |
| </motion.button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Voice commands help modal */} | |
| <VoiceCommands | |
| isOpen={showCommands} | |
| onClose={() => setShowCommands(false)} | |
| language={language} | |
| /> | |
| {/* First-time tutorial */} | |
| <VoiceTutorial | |
| isOpen={showTutorial} | |
| onClose={handleCloseTutorial} | |
| language={language} | |
| /> | |
| </> | |
| ); | |
| } | |
| VoiceButton.propTypes = { | |
| className: PropTypes.string, | |
| }; | |
| export default VoiceButton; | |