@voice-ai-workforce/react
Version:
React components with 3-tier interface modes for voice-controlled workforce applications
275 lines (274 loc) • 18.9 kB
JavaScript
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;