UNPKG

@voice-ai-workforce/react

Version:

React components with 3-tier interface modes for voice-controlled workforce applications

275 lines (274 loc) 18.9 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; // packages/react/src/components/VoiceButton.tsx - Updated with Mode Support import { useState, useRef, useEffect } from 'react'; import { useVoiceAI } from '../hooks/useVoiceAI'; import { useComponentTheme } from '../hooks/useVoiceTheme'; import { useVoiceHistory } from '../hooks/useVoiceHistory'; import { useVoiceVisibility } from '../hooks/useVoiceVisibility'; import { SIZE_CLASSES, POSITION_CLASSES, ANIMATION_CLASSES } from '../utils/theme'; // Default quick commands const DEFAULT_QUICK_COMMANDS = ['help', 'status', 'clock in', 'clock out']; // Icons (keeping existing ones) const MicrophoneIcon = ({ className }) => (_jsxs("svg", { className: className, fill: "currentColor", viewBox: "0 0 24 24", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" })] })); const StopIcon = ({ className }) => (_jsx("svg", { className: className, fill: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { d: "M6 6h12v12H6V6z" }) })); const LoadingSpinner = ({ className }) => (_jsxs("svg", { className: `animate-spin ${className}`, fill: "none", viewBox: "0 0 24 24", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] })); // const HistoryIcon = ({ className }: { className?: string }) => ( // <svg className={className} fill="currentColor" viewBox="0 0 24 24"> // <path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/> // </svg> // ); const PlayIcon = ({ className }) => (_jsx("svg", { className: className, fill: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { d: "M8 5v14l11-7z" }) })); const CloseIcon = ({ className }) => (_jsx("svg", { className: className, fill: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { d: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" }) })); export const VoiceButton = ({ config, size = 'md', variant = 'primary', theme: customTheme, className = '', style, disabled = false, showMiniCenter = false, miniCenterPosition = 'bottom', quickCommands = DEFAULT_QUICK_COMMANDS, maxRecentCommands = 3, autoCloseMiniCenter = true, autoCloseDelay = 3000, onCommand, onResponse, onError, onMiniCenterToggle, children, listenText, stopText, 'aria-label': ariaLabel, // NEW: Mode support props mode, visibilityOverrides, customLabels: propCustomLabels, ...props }) => { const theme = useComponentTheme(customTheme); const buttonRef = useRef(null); const miniCenterRef = useRef(null); const autoCloseTimeoutRef = useRef(); // NEW: Resolve visibility and labels based on mode const { visibility, labels } = useVoiceVisibility(config, mode, visibilityOverrides); // Merge prop labels with resolved labels const effectiveLabels = { voiceButton: { ...labels.voiceButton, ...propCustomLabels?.voiceButton }, status: { ...labels.status, ...propCustomLabels?.status }, providers: { ...labels.providers, ...propCustomLabels?.providers }, errors: { ...labels.errors, ...propCustomLabels?.errors } }; // Use effective labels with fallbacks to props const finalListenText = listenText || effectiveLabels.voiceButton.startText || 'Start Listening'; const finalStopText = stopText || effectiveLabels.voiceButton.stopText || 'Stop Listening'; // State const [isMiniCenterOpen, setIsMiniCenterOpen] = useState(false); const [miniCenterTab, setMiniCenterTab] = useState('quick'); // Voice AI hook const { isListening, isProcessing, isAvailable, error, startListening, stopListening, processText, getState } = useVoiceAI({ config, onCommand: (command) => { onCommand?.(command); if (visibility.showMiniCenter && showMiniCenter && autoCloseMiniCenter) { scheduleAutoClose(); } }, onResponse, onError: (error) => { // Filter error based on visibility settings let filteredError = error; if (!visibility.showTechnicalErrors) { filteredError = { ...error, message: effectiveLabels.errors.generic || 'An error occurred', details: undefined // Hide technical details }; } onError?.(filteredError); }, autoStart: false }); // History hook const { getRecentCommands, replayCommand } = useVoiceHistory(); // Get recent commands and quick commands (filtered based on visibility) const recentCommands = visibility.showCommandHistory ? getRecentCommands(maxRecentCommands) : []; const availableCommands = config.commands?.registry?.commands || []; const quickCommandDefs = quickCommands .map(cmdName => availableCommands.find(cmd => cmd.triggers.some(trigger => trigger.toLowerCase().includes(cmdName.toLowerCase())))) .filter(Boolean); // Auto-close functionality const scheduleAutoClose = () => { if (autoCloseTimeoutRef.current) { clearTimeout(autoCloseTimeoutRef.current); } if (autoCloseMiniCenter) { autoCloseTimeoutRef.current = setTimeout(() => { setIsMiniCenterOpen(false); }, autoCloseDelay); } }; const cancelAutoClose = () => { if (autoCloseTimeoutRef.current) { clearTimeout(autoCloseTimeoutRef.current); } }; // Handle button click const handleButtonClick = async (e) => { e.stopPropagation(); if (disabled || !isAvailable) return; // Check if mini center should be shown (respects visibility settings) if (visibility.showMiniCenter && showMiniCenter && !isListening) { // Toggle mini center const newOpen = !isMiniCenterOpen; setIsMiniCenterOpen(newOpen); onMiniCenterToggle?.(newOpen); if (newOpen) { cancelAutoClose(); } } else { // Normal voice toggle try { if (isListening) { await stopListening(); } else { await startListening(); } } catch (err) { const errorMessage = !visibility.showTechnicalErrors ? effectiveLabels.errors.generic || 'Voice operation failed' : err instanceof Error ? err.message : 'Voice operation failed'; onError?.({ code: 'VOICE_OPERATION_FAILED', message: errorMessage, details: visibility.showTechnicalErrors ? err : undefined }); } } }; // Handle quick command execution const handleQuickCommand = async (command) => { try { if (command.examples && command.examples.length > 0) { await processText(command.examples[0]); } else { await processText(command.triggers[0]); } if (autoCloseMiniCenter) { setIsMiniCenterOpen(false); } } catch (err) { const errorMessage = !visibility.showTechnicalErrors ? effectiveLabels.errors.generic || 'Command failed' : 'Failed to execute command'; onError?.({ code: 'COMMAND_EXECUTION_FAILED', message: errorMessage, details: visibility.showTechnicalErrors ? err : undefined }); } }; // Handle recent command replay const handleReplayCommand = async (commandId) => { try { const command = replayCommand(commandId); if (command) { await processText(command.rawText); if (autoCloseMiniCenter) { setIsMiniCenterOpen(false); } } } catch (err) { const errorMessage = !visibility.showTechnicalErrors ? effectiveLabels.errors.generic || 'Replay failed' : 'Failed to replay command'; onError?.({ code: 'COMMAND_REPLAY_FAILED', message: errorMessage, details: visibility.showTechnicalErrors ? err : undefined }); } }; // Handle voice toggle from mini center const handleVoiceToggle = async () => { try { if (isListening) { await stopListening(); } else { await startListening(); if (autoCloseMiniCenter) { setIsMiniCenterOpen(false); } } } catch (err) { const errorMessage = !visibility.showTechnicalErrors ? effectiveLabels.errors.generic || 'Voice toggle failed' : err instanceof Error ? err.message : 'Voice operation failed'; onError?.({ code: 'VOICE_OPERATION_FAILED', message: errorMessage, details: visibility.showTechnicalErrors ? err : undefined }); } }; // Close mini center when clicking outside useEffect(() => { const handleClickOutside = (event) => { if (isMiniCenterOpen && buttonRef.current && miniCenterRef.current && !buttonRef.current.contains(event.target) && !miniCenterRef.current.contains(event.target)) { setIsMiniCenterOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [isMiniCenterOpen]); // Cleanup auto-close timeout useEffect(() => { return () => { if (autoCloseTimeoutRef.current) { clearTimeout(autoCloseTimeoutRef.current); } }; }, []); // Build button classes const isActive = isListening || isProcessing; const showError = !!error; const buttonClasses = [ 'relative inline-flex items-center justify-center rounded-full border-2 font-medium transition-all duration-200', 'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500', 'disabled:opacity-50 disabled:cursor-not-allowed', SIZE_CLASSES.button[size], showError ? 'border-red-500 bg-red-50 text-red-600' : '', isActive && !showError ? 'animate-pulse' : '', className ].filter(Boolean).join(' '); // Apply theme colors via style const buttonStyle = { ...style, backgroundColor: showError ? theme.colors.error : variant === 'primary' ? theme.colors.primary : variant === 'secondary' ? theme.colors.secondary : 'transparent', color: showError ? theme.colors.text.inverse : variant === 'ghost' ? theme.colors.text.primary : theme.colors.text.inverse, borderColor: showError ? theme.colors.error : variant === 'primary' ? theme.colors.primary : variant === 'secondary' ? theme.colors.secondary : theme.colors.border, }; // Determine icon to show const renderIcon = () => { if (isProcessing) { return _jsx(LoadingSpinner, { className: "w-1/2 h-1/2" }); } if (isListening) { return _jsx(StopIcon, { className: "w-1/2 h-1/2" }); } return _jsx(MicrophoneIcon, { className: "w-1/2 h-1/2" }); }; // Accessibility label - use effective labels const accessibilityLabel = ariaLabel || (isListening ? finalStopText : isProcessing ? (effectiveLabels.voiceButton.processingText || 'Processing voice...') : error ? (effectiveLabels.voiceButton.errorText || `Voice error: ${error}`) : finalListenText); return (_jsxs("div", { className: "relative inline-block", children: [_jsxs("button", { ref: buttonRef, type: "button", className: buttonClasses, style: buttonStyle, onClick: handleButtonClick, disabled: disabled || !isAvailable, "aria-label": accessibilityLabel, "aria-pressed": isListening, "aria-expanded": visibility.showMiniCenter && showMiniCenter ? isMiniCenterOpen : undefined, title: accessibilityLabel, ...props, children: [children || renderIcon(), isActive && !showError && (_jsx("div", { className: "absolute inset-0 rounded-full border-2 border-current opacity-30 animate-ping" })), showError && (_jsx("div", { className: "absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full border-2 border-white" })), visibility.showMiniCenter && showMiniCenter && !isListening && !isProcessing && (_jsx("div", { className: "absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 rounded-full border-2 border-white" }))] }), visibility.showMiniCenter && showMiniCenter && isMiniCenterOpen && (_jsx("div", { ref: miniCenterRef, className: `absolute z-50 ${POSITION_CLASSES[miniCenterPosition]} ${ANIMATION_CLASSES.fadeIn}`, style: { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }, onMouseEnter: cancelAutoClose, onMouseLeave: scheduleAutoClose, children: _jsxs("div", { className: "w-72 bg-white rounded-lg shadow-lg border p-4", style: { backgroundColor: theme.colors.surface, borderColor: theme.colors.border, boxShadow: theme.shadows.lg }, children: [_jsxs("div", { className: "flex items-center justify-between mb-3", children: [_jsx("h3", { className: "font-medium text-sm", style: { color: theme.colors.text.primary }, children: "Voice Commands" }), _jsxs("div", { className: "flex items-center space-x-2", children: [_jsx("button", { onClick: handleVoiceToggle, className: "p-1 rounded hover:bg-gray-100 transition-colors", style: { color: isListening ? theme.colors.status.listening : theme.colors.text.secondary }, title: isListening ? finalStopText : finalListenText, children: isListening ? _jsx(StopIcon, { className: "w-4 h-4" }) : _jsx(MicrophoneIcon, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => setIsMiniCenterOpen(false), className: "p-1 rounded hover:bg-gray-100 transition-colors", style: { color: theme.colors.text.secondary }, title: "Close", children: _jsx(CloseIcon, { className: "w-4 h-4" }) })] })] }), _jsxs("div", { className: "flex space-x-1 mb-3", children: [_jsx("button", { onClick: () => setMiniCenterTab('quick'), className: `px-3 py-1 text-xs rounded transition-colors ${miniCenterTab === 'quick' ? 'bg-blue-100 text-blue-700' : 'text-gray-600 hover:bg-gray-100'}`, children: "Quick" }), visibility.showCommandHistory && (_jsx("button", { onClick: () => setMiniCenterTab('recent'), className: `px-3 py-1 text-xs rounded transition-colors ${miniCenterTab === 'recent' ? 'bg-blue-100 text-blue-700' : 'text-gray-600 hover:bg-gray-100'}`, children: "Recent" }))] }), _jsxs("div", { className: "space-y-2 max-h-48 overflow-y-auto", children: [miniCenterTab === 'quick' && (_jsxs(_Fragment, { children: [quickCommandDefs.map((command, index) => (_jsx("button", { onClick: () => handleQuickCommand(command), className: "w-full text-left p-2 rounded hover:bg-gray-50 transition-colors group", style: { backgroundColor: 'transparent' }, children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("div", { className: "text-sm font-medium", style: { color: theme.colors.text.primary }, children: command.name }), _jsxs("div", { className: "text-xs", style: { color: theme.colors.text.secondary }, children: ["\"", command.triggers[0], "\""] })] }), _jsx(PlayIcon, { className: "w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity", // @ts-ignore style: { color: theme.colors.text.secondary } })] }) }, index))), quickCommandDefs.length === 0 && (_jsx("div", { className: "text-center py-4 text-sm", style: { color: theme.colors.text.muted }, children: "No quick commands available" }))] })), miniCenterTab === 'recent' && visibility.showCommandHistory && (_jsxs(_Fragment, { children: [recentCommands.map((command) => (_jsx("button", { onClick: () => handleReplayCommand(command.id), className: "w-full text-left p-2 rounded hover:bg-gray-50 transition-colors group", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("div", { className: "text-sm font-medium", style: { color: theme.colors.text.primary }, children: command.intent }), _jsxs("div", { className: "text-xs", style: { color: theme.colors.text.secondary }, children: ["\"", command.rawText, "\""] })] }), _jsxs("div", { className: "flex items-center space-x-1", children: [visibility.showConfidenceScores && (_jsxs("span", { className: "text-xs", style: { color: theme.colors.text.muted }, children: [Math.round(command.confidence * 100), "%"] })), _jsx(PlayIcon, { className: "w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity", //@ts-ignore style: { color: theme.colors.text.secondary } })] })] }) }, command.id))), recentCommands.length === 0 && (_jsx("div", { className: "text-center py-4 text-sm", style: { color: theme.colors.text.muted }, children: "No recent commands" }))] }))] }), (visibility.showProviderStatus || visibility.showStatusIndicator) && (_jsxs("div", { className: "mt-3 pt-3 border-t flex items-center justify-between text-xs", style: { borderColor: theme.colors.border, color: theme.colors.text.muted }, children: [_jsx("span", { children: visibility.showProviders && getState().activeProvider ? `Provider: ${getState().activeProvider}` : effectiveLabels.providers.generic }), _jsx("span", { className: `w-2 h-2 rounded-full ${isAvailable ? 'bg-green-500' : 'bg-red-500'}` })] }))] }) }))] })); }; export default VoiceButton;