diff --git a/client/src/context/EnhancedVoiceProvider.tsx b/client/src/context/EnhancedVoiceProvider.tsx new file mode 100644 index 0000000..f2e3d5b --- /dev/null +++ b/client/src/context/EnhancedVoiceProvider.tsx @@ -0,0 +1,44 @@ +import React, { createContext, useContext, useEffect } from 'react'; +import { useEnhancedVoice } from '@/hooks/useEnhancedVoice'; + +interface EnhancedVoiceContextType { + isListening: boolean; + isSupported: boolean; + isProcessing: boolean; + currentTranscript: string; + voiceCommands: any[]; + settings: any; + startListening: () => void; + stopListening: () => void; + toggleListening: () => void; + speak: (text: string, options?: any) => void; + executeVoiceCommand: (command: string) => Promise; + updateSettings: (settings: any) => void; +} + +const EnhancedVoiceContext = createContext(undefined); + +export function EnhancedVoiceProvider({ children }: { children: React.ReactNode }) { + const voiceSystem = useEnhancedVoice(); + + // Initialize voice system on mount + useEffect(() => { + if (voiceSystem.isSupported) { + console.log('Enhanced voice system initialized'); + } + }, [voiceSystem.isSupported]); + + return ( + + {children} + + ); +} + +export function useEnhancedVoiceContext() { + const context = useContext(EnhancedVoiceContext); + if (context === undefined) { + throw new Error('useEnhancedVoiceContext must be used within an EnhancedVoiceProvider'); + } + return context; +} \ No newline at end of file diff --git a/client/src/context/VoiceProvider.tsx b/client/src/context/VoiceProvider.tsx index d41ca02..42f3bfc 100644 --- a/client/src/context/VoiceProvider.tsx +++ b/client/src/context/VoiceProvider.tsx @@ -1,15 +1,21 @@ import React, { createContext, useContext, useState, useEffect } from "react"; import { useVoice } from "@/hooks/useVoice"; +import { useLocation } from "wouter"; +import { useQueryClient } from "@tanstack/react-query"; interface VoiceContextType { isListening: boolean; isSupported: boolean; + isProcessing: boolean; + currentTranscript: string; language: 'en' | 'ar'; startListening: () => void; stopListening: () => void; + toggleListening: () => void; setLanguage: (lang: 'en' | 'ar') => void; executeVoiceCommand: (command: string) => Promise; speak: (text: string, lang?: 'en' | 'ar') => void; + speakAndExecute: (text: string, command?: () => Promise) => Promise; } const VoiceContext = createContext(undefined); @@ -17,41 +23,348 @@ const VoiceContext = createContext(undefined); export function VoiceProvider({ children }: { children: React.ReactNode }) { const { isListening, startListening, stopListening, isSupported } = useVoice(); const [language, setLanguage] = useState<'en' | 'ar'>('en'); + const [isProcessing, setIsProcessing] = useState(false); + const [currentTranscript, setCurrentTranscript] = useState(''); + const [, setLocation] = useLocation(); + const queryClient = useQueryClient(); const executeVoiceCommand = async (command: string) => { + setIsProcessing(true); + setCurrentTranscript(command); + try { - // Process voice commands for navigation and actions - const lowerCommand = command.toLowerCase(); + const lowerCommand = command.toLowerCase().trim(); - if (lowerCommand.includes('navigate') || lowerCommand.includes('go to')) { - // Handle navigation commands - if (lowerCommand.includes('dashboard')) { - window.location.href = '/dashboard'; - } else if (lowerCommand.includes('tasks')) { - window.location.href = '/dashboard?tab=tasks'; - } else if (lowerCommand.includes('finance')) { - window.location.href = '/dashboard?tab=finance'; + // Navigation commands with immediate feedback + if (lowerCommand.includes('go to') || lowerCommand.includes('navigate') || lowerCommand.includes('open')) { + if (lowerCommand.includes('dashboard') || lowerCommand.includes('home')) { + setLocation('/dashboard'); + speak('Opening dashboard'); + } else if (lowerCommand.includes('task') && !lowerCommand.includes('create')) { + setLocation('/tasks'); + speak('Opening tasks'); + } else if (lowerCommand.includes('finance') || lowerCommand.includes('money')) { + setLocation('/finances'); + speak('Opening finances'); + } else if (lowerCommand.includes('voice')) { + setLocation('/voice'); + speak('Opening voice commands'); + } else if (lowerCommand.includes('ai') || lowerCommand.includes('chat')) { + setLocation('/ai'); + speak('Opening AI assistant'); + } else if (lowerCommand.includes('analytics')) { + setLocation('/analytics'); + speak('Opening analytics'); + } else if (lowerCommand.includes('settings')) { + setLocation('/settings'); + speak('Opening settings'); } - } else if (lowerCommand.includes('create task') || lowerCommand.includes('أنشئ مهمة')) { - // Handle task creation - // This would integrate with your task creation API - console.log('Voice task creation:', command); - } else if (lowerCommand.includes('book appointment') || lowerCommand.includes('احجز موعد')) { - // Handle appointment booking - console.log('Voice appointment booking:', command); + return; } + + // Task management with API integration + if (lowerCommand.includes('create task') || lowerCommand.includes('new task') || lowerCommand.includes('add task')) { + const taskMatch = command.match(/(?:create task|new task|add task)\s+(.+)/i); + if (taskMatch) { + await createTaskByVoice(taskMatch[1].trim(), command); + } else { + speak('What task would you like to create?'); + } + return; + } + + if (lowerCommand.includes('complete task') || lowerCommand.includes('finish task')) { + const taskMatch = command.match(/(?:complete task|finish task)\s+(.+)/i); + if (taskMatch) { + await completeTaskByVoice(taskMatch[1].trim()); + } else { + speak('Which task would you like to complete?'); + } + return; + } + + // Financial management with real-time updates + if (lowerCommand.includes('add expense') || lowerCommand.includes('record expense')) { + const amountMatch = command.match(/(\d+(?:\.\d{2})?)/); + if (amountMatch) { + const description = extractDescription(command); + await addExpenseByVoice(parseFloat(amountMatch[1]), description); + } else { + speak('How much was the expense?'); + } + return; + } + + if (lowerCommand.includes('add income') || lowerCommand.includes('record income')) { + const amountMatch = command.match(/(\d+(?:\.\d{2})?)/); + if (amountMatch) { + const description = extractDescription(command); + await addIncomeByVoice(parseFloat(amountMatch[1]), description); + } else { + speak('How much income would you like to record?'); + } + return; + } + + // AI interactions + if (lowerCommand.includes('tell me a joke') || lowerCommand.includes('joke')) { + await getJokeByVoice(); + return; + } + + if (lowerCommand.includes('help') || lowerCommand.includes('commands') || lowerCommand.includes('what can i say')) { + speakAvailableCommands(); + return; + } + + // General AI chat processing + await processAIChat(command); + } catch (error) { console.error('Voice command execution error:', error); + speak('Sorry, I had trouble processing that command. Please try again.'); + } finally { + setIsProcessing(false); } }; + // Voice command processing functions + const createTaskByVoice = async (title: string, fullCommand: string) => { + try { + const priority = extractPriority(fullCommand) || 'medium'; + const dueDate = extractDueDate(fullCommand); + + const response = await fetch('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + title, + description: `Created by voice: "${fullCommand}"`, + priority, + dueDate, + }), + }); + + if (response.ok) { + queryClient.invalidateQueries({ queryKey: ['/api/tasks'] }); + speak(`Task "${title}" created successfully with ${priority} priority`); + } else { + speak('Failed to create task. Please try again.'); + } + } catch (error) { + speak('There was an error creating the task.'); + } + }; + + const completeTaskByVoice = async (taskTitle: string) => { + try { + const tasksResponse = await fetch('/api/tasks', { credentials: 'include' }); + + if (tasksResponse.ok) { + const { tasks } = await tasksResponse.json(); + const matchingTask = tasks.find((task: any) => + task.title.toLowerCase().includes(taskTitle.toLowerCase()) + ); + + if (matchingTask) { + const updateResponse = await fetch(`/api/tasks/${matchingTask.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ status: 'completed' }), + }); + + if (updateResponse.ok) { + queryClient.invalidateQueries({ queryKey: ['/api/tasks'] }); + speak(`Task "${matchingTask.title}" marked as completed`); + } + } else { + speak(`Could not find a task matching "${taskTitle}"`); + } + } + } catch (error) { + speak('There was an error completing the task.'); + } + }; + + const addExpenseByVoice = async (amount: number, description: string) => { + try { + const response = await fetch('/api/financial/records', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + type: 'expense', + amount, + description: description || 'Voice recorded expense', + category: 'general', + }), + }); + + if (response.ok) { + queryClient.invalidateQueries({ queryKey: ['/api/financial/records'] }); + queryClient.invalidateQueries({ queryKey: ['/api/financial/summary'] }); + speak(`Expense of $${amount} recorded successfully`); + } + } catch (error) { + speak('There was an error recording the expense.'); + } + }; + + const addIncomeByVoice = async (amount: number, description: string) => { + try { + const response = await fetch('/api/financial/records', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + type: 'income', + amount, + description: description || 'Voice recorded income', + category: 'general', + }), + }); + + if (response.ok) { + queryClient.invalidateQueries({ queryKey: ['/api/financial/records'] }); + queryClient.invalidateQueries({ queryKey: ['/api/financial/summary'] }); + speak(`Income of $${amount} recorded successfully`); + } + } catch (error) { + speak('There was an error recording the income.'); + } + }; + + const getJokeByVoice = async () => { + try { + const response = await fetch('/api/ai/daily-joke', { credentials: 'include' }); + + if (response.ok) { + const { joke } = await response.json(); + speak(joke); + } else { + speak('Sorry, I could not get a joke right now.'); + } + } catch (error) { + speak('There was an error getting a joke.'); + } + }; + + const processAIChat = async (message: string) => { + try { + const response = await fetch('/api/ai/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ message }), + }); + + if (response.ok) { + const { content } = await response.json(); + speak(content); + } else { + speak('I did not understand that command. Say "help" to hear available commands.'); + } + } catch (error) { + speak('I did not understand that command. Say "help" to hear available commands.'); + } + }; + + const speakAvailableCommands = () => { + const commands = [ + 'You can say:', + 'Go to dashboard, tasks, finances, voice, AI, analytics, or settings', + 'Create task followed by the task name', + 'Complete task followed by the task name', + 'Add expense or income followed by the amount', + 'Tell me a joke', + 'Or ask me any question' + ].join('. '); + + speak(commands); + }; + + // Utility functions + const extractDescription = (command: string): string => { + const patterns = [ + /(?:for|on)\s+(.+)/i, + /(?:expense|income)\s+(?:of\s+)?(?:\$?\d+(?:\.\d{2})?)\s+(?:for|on)\s+(.+)/i, + ]; + + for (const pattern of patterns) { + const match = command.match(pattern); + if (match) return match[1].trim(); + } + return ''; + }; + + const extractPriority = (command: string): 'low' | 'medium' | 'high' | null => { + if (command.toLowerCase().includes('high priority') || command.toLowerCase().includes('urgent')) { + return 'high'; + } + if (command.toLowerCase().includes('low priority')) { + return 'low'; + } + return null; + }; + + const extractDueDate = (command: string): string | null => { + const today = new Date(); + + if (command.toLowerCase().includes('today')) { + return today.toISOString().split('T')[0]; + } + if (command.toLowerCase().includes('tomorrow')) { + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + return tomorrow.toISOString().split('T')[0]; + } + if (command.toLowerCase().includes('next week')) { + const nextWeek = new Date(today); + nextWeek.setDate(nextWeek.getDate() + 7); + return nextWeek.toISOString().split('T')[0]; + } + return null; + }; + const speak = (text: string, lang: 'en' | 'ar' = language) => { if ('speechSynthesis' in window) { + window.speechSynthesis.cancel(); + const utterance = new SpeechSynthesisUtterance(text); - utterance.lang = lang === 'ar' ? 'ar-OM' : 'en-US'; // Omani Arabic support - utterance.rate = 0.9; + utterance.lang = lang === 'ar' ? 'ar-OM' : 'en-US'; + utterance.rate = 1.1; // Faster for better UX utterance.pitch = 1; - speechSynthesis.speak(utterance); + utterance.volume = 0.8; + + // Use enhanced voice if available + const voices = window.speechSynthesis.getVoices(); + const preferredVoice = voices.find(voice => + voice.lang.startsWith(lang === 'ar' ? 'ar' : 'en') && + (voice.name.includes('Enhanced') || voice.name.includes('Premium')) + ) || voices.find(voice => voice.lang.startsWith(lang === 'ar' ? 'ar' : 'en')); + + if (preferredVoice) { + utterance.voice = preferredVoice; + } + + window.speechSynthesis.speak(utterance); + } + }; + + const toggleListening = () => { + if (isListening) { + stopListening(); + } else { + startListening(); + } + }; + + const speakAndExecute = async (text: string, command?: () => Promise) => { + speak(text); + if (command) { + await command(); } }; diff --git a/client/src/hooks/useEnhancedVoice.tsx b/client/src/hooks/useEnhancedVoice.tsx new file mode 100644 index 0000000..b8be8de --- /dev/null +++ b/client/src/hooks/useEnhancedVoice.tsx @@ -0,0 +1,574 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useLocation } from 'wouter'; +import { useQueryClient } from '@tanstack/react-query'; +import { useToast } from '@/hooks/use-toast'; + +// Extend Window interface for speech recognition +declare global { + interface Window { + SpeechRecognition: any; + webkitSpeechRecognition: any; + } +} + +interface VoiceCommand { + command: string; + confidence: number; + timestamp: Date; + processed: boolean; +} + +interface VoiceSettings { + language: 'en-US' | 'ar-SA'; + continuous: boolean; + interimResults: boolean; + maxAlternatives: number; + confidenceThreshold: number; +} + +export function useEnhancedVoice() { + const [isListening, setIsListening] = useState(false); + const [isSupported, setIsSupported] = useState(false); + const [currentTranscript, setCurrentTranscript] = useState(''); + const [voiceCommands, setVoiceCommands] = useState([]); + const [isProcessing, setIsProcessing] = useState(false); + const [, setLocation] = useLocation(); + const queryClient = useQueryClient(); + const { toast } = useToast(); + + const recognitionRef = useRef(null); + const timeoutRef = useRef(null); + const commandQueueRef = useRef([]); + + const [settings, setSettings] = useState({ + language: 'en-US', + continuous: true, + interimResults: true, + maxAlternatives: 3, + confidenceThreshold: 0.7, + }); + + // Initialize speech recognition + useEffect(() => { + if (typeof window !== 'undefined') { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + if (SpeechRecognition) { + setIsSupported(true); + recognitionRef.current = new SpeechRecognition(); + setupRecognition(); + } + } + }, []); + + const setupRecognition = useCallback(() => { + if (!recognitionRef.current) return; + + const recognition = recognitionRef.current; + recognition.continuous = settings.continuous; + recognition.interimResults = settings.interimResults; + recognition.lang = settings.language; + recognition.maxAlternatives = settings.maxAlternatives; + + recognition.onstart = () => { + setIsListening(true); + console.log('Voice recognition started'); + }; + + recognition.onend = () => { + setIsListening(false); + console.log('Voice recognition ended'); + }; + + recognition.onerror = (event) => { + console.error('Voice recognition error:', event.error); + setIsListening(false); + if (event.error === 'no-speech') { + toast({ + title: "No speech detected", + description: "Please try speaking again", + variant: "default", + }); + } + }; + + recognition.onresult = (event) => { + let finalTranscript = ''; + let interimTranscript = ''; + + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + const transcript = result[0].transcript; + + if (result.isFinal) { + finalTranscript += transcript; + if (result[0].confidence >= settings.confidenceThreshold) { + processVoiceCommand(transcript, result[0].confidence); + } + } else { + interimTranscript += transcript; + } + } + + setCurrentTranscript(finalTranscript || interimTranscript); + }; + }, [settings, toast]); + + const processVoiceCommand = useCallback(async (command: string, confidence: number) => { + const voiceCommand: VoiceCommand = { + command, + confidence, + timestamp: new Date(), + processed: false, + }; + + setVoiceCommands(prev => [...prev, voiceCommand]); + commandQueueRef.current.push(command); + + if (!isProcessing) { + executeCommandQueue(); + } + }, [isProcessing]); + + const executeCommandQueue = useCallback(async () => { + if (commandQueueRef.current.length === 0) return; + + setIsProcessing(true); + + while (commandQueueRef.current.length > 0) { + const command = commandQueueRef.current.shift(); + if (command) { + await executeVoiceCommand(command); + await new Promise(resolve => setTimeout(resolve, 100)); // Small delay between commands + } + } + + setIsProcessing(false); + }, []); + + const executeVoiceCommand = useCallback(async (command: string) => { + const lowerCommand = command.toLowerCase().trim(); + + try { + // Navigation commands + if (lowerCommand.includes('go to') || lowerCommand.includes('navigate') || lowerCommand.includes('open')) { + if (lowerCommand.includes('dashboard') || lowerCommand.includes('home')) { + setLocation('/dashboard'); + speak('Navigating to dashboard'); + } else if (lowerCommand.includes('task') && !lowerCommand.includes('create')) { + setLocation('/dashboard?tab=tasks'); + speak('Opening tasks'); + } else if (lowerCommand.includes('finance') || lowerCommand.includes('money')) { + setLocation('/dashboard?tab=finance'); + speak('Opening finances'); + } else if (lowerCommand.includes('voice') || lowerCommand.includes('command')) { + setLocation('/dashboard?tab=voice'); + speak('Opening voice commands'); + } else if (lowerCommand.includes('ai') || lowerCommand.includes('chat')) { + setLocation('/dashboard?tab=ai'); + speak('Opening AI assistant'); + } + return; + } + + // Task management commands + if (lowerCommand.includes('create task') || lowerCommand.includes('new task') || lowerCommand.includes('add task')) { + const taskTitle = extractTaskTitle(command); + if (taskTitle) { + await createTaskByVoice(taskTitle, command); + } else { + speak('What task would you like to create?'); + } + return; + } + + if (lowerCommand.includes('complete task') || lowerCommand.includes('finish task')) { + const taskTitle = extractTaskTitle(command); + if (taskTitle) { + await completeTaskByVoice(taskTitle); + } else { + speak('Which task would you like to complete?'); + } + return; + } + + // Financial commands + if (lowerCommand.includes('add expense') || lowerCommand.includes('record expense')) { + const amount = extractAmount(command); + const description = extractDescription(command); + if (amount) { + await addExpenseByVoice(amount, description); + } else { + speak('How much was the expense?'); + } + return; + } + + if (lowerCommand.includes('add income') || lowerCommand.includes('record income')) { + const amount = extractAmount(command); + const description = extractDescription(command); + if (amount) { + await addIncomeByVoice(amount, description); + } else { + speak('How much income would you like to record?'); + } + return; + } + + // General AI commands + if (lowerCommand.includes('tell me a joke') || lowerCommand.includes('joke')) { + await getJokeByVoice(); + return; + } + + if (lowerCommand.includes('what can i say') || lowerCommand.includes('help') || lowerCommand.includes('commands')) { + speakAvailableCommands(); + return; + } + + // If no specific command matched, try general AI processing + await processGeneralCommand(command); + + } catch (error) { + console.error('Error executing voice command:', error); + speak('Sorry, I had trouble processing that command.'); + } + }, [setLocation, queryClient]); + + // Voice command processing functions + const createTaskByVoice = async (title: string, fullCommand: string) => { + try { + const priority = extractPriority(fullCommand) || 'medium'; + const dueDate = extractDueDate(fullCommand); + + const response = await fetch('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + title, + description: `Created by voice: "${fullCommand}"`, + priority, + dueDate, + }), + }); + + if (response.ok) { + queryClient.invalidateQueries({ queryKey: ['/api/tasks'] }); + speak(`Task "${title}" created successfully with ${priority} priority`); + toast({ + title: "Task Created", + description: `"${title}" has been added to your tasks`, + }); + } else { + speak('Failed to create task. Please try again.'); + } + } catch (error) { + speak('There was an error creating the task.'); + } + }; + + const completeTaskByVoice = async (taskTitle: string) => { + try { + // First get all tasks to find the matching one + const tasksResponse = await fetch('/api/tasks', { + credentials: 'include', + }); + + if (tasksResponse.ok) { + const { tasks } = await tasksResponse.json(); + const matchingTask = tasks.find((task: any) => + task.title.toLowerCase().includes(taskTitle.toLowerCase()) + ); + + if (matchingTask) { + const updateResponse = await fetch(`/api/tasks/${matchingTask.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ status: 'completed' }), + }); + + if (updateResponse.ok) { + queryClient.invalidateQueries({ queryKey: ['/api/tasks'] }); + speak(`Task "${matchingTask.title}" marked as completed`); + toast({ + title: "Task Completed", + description: `"${matchingTask.title}" has been completed`, + }); + } + } else { + speak(`Could not find a task matching "${taskTitle}"`); + } + } + } catch (error) { + speak('There was an error completing the task.'); + } + }; + + const addExpenseByVoice = async (amount: number, description: string) => { + try { + const response = await fetch('/api/financial/records', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + type: 'expense', + amount, + description: description || 'Voice recorded expense', + category: 'general', + }), + }); + + if (response.ok) { + queryClient.invalidateQueries({ queryKey: ['/api/financial/records'] }); + queryClient.invalidateQueries({ queryKey: ['/api/financial/summary'] }); + speak(`Expense of $${amount} recorded successfully`); + toast({ + title: "Expense Recorded", + description: `$${amount} expense has been added`, + }); + } + } catch (error) { + speak('There was an error recording the expense.'); + } + }; + + const addIncomeByVoice = async (amount: number, description: string) => { + try { + const response = await fetch('/api/financial/records', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + type: 'income', + amount, + description: description || 'Voice recorded income', + category: 'general', + }), + }); + + if (response.ok) { + queryClient.invalidateQueries({ queryKey: ['/api/financial/records'] }); + queryClient.invalidateQueries({ queryKey: ['/api/financial/summary'] }); + speak(`Income of $${amount} recorded successfully`); + toast({ + title: "Income Recorded", + description: `$${amount} income has been added`, + }); + } + } catch (error) { + speak('There was an error recording the income.'); + } + }; + + const getJokeByVoice = async () => { + try { + const response = await fetch('/api/ai/daily-joke', { + credentials: 'include', + }); + + if (response.ok) { + const { joke } = await response.json(); + speak(joke); + } else { + speak('Sorry, I could not get a joke right now.'); + } + } catch (error) { + speak('There was an error getting a joke.'); + } + }; + + const processGeneralCommand = async (command: string) => { + try { + const response = await fetch('/api/ai/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ message: command }), + }); + + if (response.ok) { + const { content } = await response.json(); + speak(content); + } else { + speak('I did not understand that command. Say "help" to hear available commands.'); + } + } catch (error) { + speak('I did not understand that command. Say "help" to hear available commands.'); + } + }; + + const speakAvailableCommands = () => { + const commands = [ + 'You can say:', + 'Go to dashboard, tasks, finances, voice, or AI', + 'Create task followed by the task name', + 'Complete task followed by the task name', + 'Add expense or income followed by the amount', + 'Tell me a joke', + 'Or ask me any question' + ].join('. '); + + speak(commands); + }; + + // Utility functions for command parsing + const extractTaskTitle = (command: string): string | null => { + const patterns = [ + /(?:create task|new task|add task)\s+(.+)/i, + /(?:complete task|finish task)\s+(.+)/i, + ]; + + for (const pattern of patterns) { + const match = command.match(pattern); + if (match) return match[1].trim(); + } + return null; + }; + + const extractAmount = (command: string): number | null => { + const patterns = [ + /\$(\d+(?:\.\d{2})?)/, + /(\d+(?:\.\d{2})?)\s*dollars?/i, + /(\d+(?:\.\d{2})?)/, + ]; + + for (const pattern of patterns) { + const match = command.match(pattern); + if (match) { + const amount = parseFloat(match[1]); + if (!isNaN(amount) && amount > 0) return amount; + } + } + return null; + }; + + const extractDescription = (command: string): string => { + const patterns = [ + /(?:for|on)\s+(.+)/i, + /(?:expense|income)\s+(?:of\s+)?(?:\$?\d+(?:\.\d{2})?)\s+(?:for|on)\s+(.+)/i, + ]; + + for (const pattern of patterns) { + const match = command.match(pattern); + if (match) return match[1].trim(); + } + return ''; + }; + + const extractPriority = (command: string): 'low' | 'medium' | 'high' | null => { + if (command.toLowerCase().includes('high priority') || command.toLowerCase().includes('urgent')) { + return 'high'; + } + if (command.toLowerCase().includes('low priority')) { + return 'low'; + } + return null; + }; + + const extractDueDate = (command: string): string | null => { + const today = new Date(); + + if (command.toLowerCase().includes('today')) { + return today.toISOString().split('T')[0]; + } + if (command.toLowerCase().includes('tomorrow')) { + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + return tomorrow.toISOString().split('T')[0]; + } + if (command.toLowerCase().includes('next week')) { + const nextWeek = new Date(today); + nextWeek.setDate(nextWeek.getDate() + 7); + return nextWeek.toISOString().split('T')[0]; + } + return null; + }; + + const speak = useCallback((text: string, options?: { rate?: number; pitch?: number; volume?: number }) => { + if ('speechSynthesis' in window) { + // Cancel any ongoing speech + window.speechSynthesis.cancel(); + + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = settings.language; + utterance.rate = options?.rate || 1.1; // Slightly faster for better UX + utterance.pitch = options?.pitch || 1; + utterance.volume = options?.volume || 0.8; + + // Use a more natural voice if available + const voices = window.speechSynthesis.getVoices(); + const preferredVoice = voices.find(voice => + voice.lang.startsWith(settings.language.split('-')[0]) && + (voice.name.includes('Enhanced') || voice.name.includes('Premium')) + ) || voices.find(voice => voice.lang.startsWith(settings.language.split('-')[0])); + + if (preferredVoice) { + utterance.voice = preferredVoice; + } + + window.speechSynthesis.speak(utterance); + } + }, [settings.language]); + + const startListening = useCallback(() => { + if (recognitionRef.current && !isListening) { + setCurrentTranscript(''); + recognitionRef.current.start(); + } + }, [isListening]); + + const stopListening = useCallback(() => { + if (recognitionRef.current && isListening) { + recognitionRef.current.stop(); + } + }, [isListening]); + + const toggleListening = useCallback(() => { + if (isListening) { + stopListening(); + } else { + startListening(); + } + }, [isListening, startListening, stopListening]); + + const updateSettings = useCallback((newSettings: Partial) => { + setSettings(prev => ({ ...prev, ...newSettings })); + }, []); + + // Auto-restart listening if it stops unexpectedly + useEffect(() => { + if (isListening && recognitionRef.current) { + const recognition = recognitionRef.current; + + const handleEnd = () => { + if (isListening) { + // Restart after a short delay + setTimeout(() => { + if (isListening) { + recognition.start(); + } + }, 1000); + } + }; + + recognition.addEventListener('end', handleEnd); + return () => recognition.removeEventListener('end', handleEnd); + } + }, [isListening]); + + return { + isListening, + isSupported, + isProcessing, + currentTranscript, + voiceCommands, + settings, + startListening, + stopListening, + toggleListening, + speak, + executeVoiceCommand, + updateSettings, + }; +} \ No newline at end of file diff --git a/client/src/hooks/useVoiceIntegration.tsx b/client/src/hooks/useVoiceIntegration.tsx new file mode 100644 index 0000000..540f90d --- /dev/null +++ b/client/src/hooks/useVoiceIntegration.tsx @@ -0,0 +1,338 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useLocation } from 'wouter'; +import { useQueryClient } from '@tanstack/react-query'; +import { useToast } from '@/hooks/use-toast'; + +interface VoiceSettings { + language: string; + continuous: boolean; + interimResults: boolean; + confidenceThreshold: number; +} + +export function useVoiceIntegration() { + const [isListening, setIsListening] = useState(false); + const [isSupported, setIsSupported] = useState(false); + const [currentTranscript, setCurrentTranscript] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); + const [, setLocation] = useLocation(); + const queryClient = useQueryClient(); + const { toast } = useToast(); + + const recognitionRef = useRef(null); + + const [settings] = useState({ + language: 'en-US', + continuous: true, + interimResults: true, + confidenceThreshold: 0.7, + }); + + // Initialize speech recognition + useEffect(() => { + if (typeof window !== 'undefined') { + const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + if (SpeechRecognition) { + setIsSupported(true); + recognitionRef.current = new SpeechRecognition(); + setupRecognition(); + } + } + }, []); + + const setupRecognition = useCallback(() => { + if (!recognitionRef.current) return; + + const recognition = recognitionRef.current; + recognition.continuous = settings.continuous; + recognition.interimResults = settings.interimResults; + recognition.lang = settings.language; + recognition.maxAlternatives = 3; + + recognition.onstart = () => { + setIsListening(true); + }; + + recognition.onend = () => { + setIsListening(false); + }; + + recognition.onerror = (event: any) => { + console.error('Voice recognition error:', event.error); + setIsListening(false); + }; + + recognition.onresult = (event: any) => { + let finalTranscript = ''; + + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + const transcript = result[0].transcript; + + if (result.isFinal) { + finalTranscript += transcript; + if (result[0].confidence >= settings.confidenceThreshold) { + processVoiceCommand(transcript); + } + } + } + + if (finalTranscript) { + setCurrentTranscript(finalTranscript); + } + }; + }, [settings]); + + const processVoiceCommand = useCallback(async (command: string) => { + setIsProcessing(true); + await executeVoiceCommand(command); + setIsProcessing(false); + }, []); + + const executeVoiceCommand = useCallback(async (command: string) => { + const lowerCommand = command.toLowerCase().trim(); + + try { + // Navigation commands + if (lowerCommand.includes('dashboard') || lowerCommand.includes('home')) { + setLocation('/dashboard'); + speak('Opening dashboard'); + return; + } + + if (lowerCommand.includes('task') && !lowerCommand.includes('create')) { + setLocation('/dashboard'); + // Trigger tasks tab if possible + speak('Opening tasks'); + return; + } + + if (lowerCommand.includes('finance') || lowerCommand.includes('money')) { + setLocation('/dashboard'); + speak('Opening finances'); + return; + } + + // Task commands + if (lowerCommand.includes('create task') || lowerCommand.includes('new task')) { + const taskMatch = command.match(/(?:create task|new task)\s+(.+)/i); + if (taskMatch) { + await createTask(taskMatch[1].trim()); + } else { + speak('What task would you like to create?'); + } + return; + } + + // Financial commands + if (lowerCommand.includes('add expense')) { + const amountMatch = command.match(/(\d+(?:\.\d{2})?)/); + if (amountMatch) { + await addExpense(parseFloat(amountMatch[1]), command); + } else { + speak('How much was the expense?'); + } + return; + } + + if (lowerCommand.includes('add income')) { + const amountMatch = command.match(/(\d+(?:\.\d{2})?)/); + if (amountMatch) { + await addIncome(parseFloat(amountMatch[1]), command); + } else { + speak('How much income would you like to record?'); + } + return; + } + + // AI commands + if (lowerCommand.includes('joke')) { + await getJoke(); + return; + } + + if (lowerCommand.includes('help') || lowerCommand.includes('commands')) { + speakHelp(); + return; + } + + // General AI chat + await processAIChat(command); + + } catch (error) { + console.error('Error executing voice command:', error); + speak('Sorry, I had trouble processing that command.'); + } + }, [setLocation, queryClient]); + + const createTask = async (title: string) => { + try { + const response = await fetch('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + title, + description: 'Created by voice command', + priority: 'medium', + }), + }); + + if (response.ok) { + queryClient.invalidateQueries({ queryKey: ['/api/tasks'] }); + speak(`Task "${title}" created successfully`); + toast({ + title: "Task Created", + description: `"${title}" has been added`, + }); + } else { + speak('Failed to create task'); + } + } catch (error) { + speak('Error creating task'); + } + }; + + const addExpense = async (amount: number, description: string) => { + try { + const response = await fetch('/api/financial/records', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + type: 'expense', + amount, + description: description || 'Voice expense', + category: 'general', + }), + }); + + if (response.ok) { + queryClient.invalidateQueries({ queryKey: ['/api/financial/records'] }); + queryClient.invalidateQueries({ queryKey: ['/api/financial/summary'] }); + speak(`Expense of $${amount} recorded`); + toast({ + title: "Expense Added", + description: `$${amount} expense recorded`, + }); + } + } catch (error) { + speak('Error recording expense'); + } + }; + + const addIncome = async (amount: number, description: string) => { + try { + const response = await fetch('/api/financial/records', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + type: 'income', + amount, + description: description || 'Voice income', + category: 'general', + }), + }); + + if (response.ok) { + queryClient.invalidateQueries({ queryKey: ['/api/financial/records'] }); + queryClient.invalidateQueries({ queryKey: ['/api/financial/summary'] }); + speak(`Income of $${amount} recorded`); + toast({ + title: "Income Added", + description: `$${amount} income recorded`, + }); + } + } catch (error) { + speak('Error recording income'); + } + }; + + const getJoke = async () => { + try { + const response = await fetch('/api/ai/daily-joke', { + credentials: 'include', + }); + + if (response.ok) { + const data = await response.json(); + speak(data.joke); + } + } catch (error) { + speak('Could not get a joke right now'); + } + }; + + const processAIChat = async (message: string) => { + try { + const response = await fetch('/api/ai/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ message }), + }); + + if (response.ok) { + const data = await response.json(); + speak(data.content); + } else { + speak('I did not understand that command'); + } + } catch (error) { + speak('I did not understand that command'); + } + }; + + const speakHelp = () => { + const helpText = `You can say: Go to dashboard, Create task followed by the task name, Add expense or income with amount, Tell me a joke, or ask me any question`; + speak(helpText); + }; + + const speak = useCallback((text: string) => { + if ('speechSynthesis' in window) { + window.speechSynthesis.cancel(); + + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = settings.language; + utterance.rate = 1.1; + utterance.pitch = 1; + utterance.volume = 0.8; + + window.speechSynthesis.speak(utterance); + } + }, [settings.language]); + + const startListening = useCallback(() => { + if (recognitionRef.current && !isListening) { + setCurrentTranscript(''); + recognitionRef.current.start(); + } + }, [isListening]); + + const stopListening = useCallback(() => { + if (recognitionRef.current && isListening) { + recognitionRef.current.stop(); + } + }, [isListening]); + + const toggleListening = useCallback(() => { + if (isListening) { + stopListening(); + } else { + startListening(); + } + }, [isListening, startListening, stopListening]); + + return { + isListening, + isSupported, + isProcessing, + currentTranscript, + startListening, + stopListening, + toggleListening, + speak, + executeVoiceCommand, + }; +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0122947..5fef6db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@anthropic-ai/sdk": "^0.37.0", "@hookform/resolvers": "^3.10.0", "@jridgewell/trace-mapping": "^0.3.25", "@neondatabase/serverless": "^0.10.4", @@ -58,6 +59,7 @@ "multer": "^2.0.0", "nanoid": "^5.1.5", "next-themes": "^0.4.6", + "openai": "^5.1.1", "passport": "^0.7.0", "passport-local": "^1.0.0", "react": "^18.3.1", @@ -130,6 +132,36 @@ "node": ">=6.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.37.0.tgz", + "integrity": "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.111", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.111.tgz", + "integrity": "sha512-90sGdgA+QLJr1F9X79tQuEut0gEYIfkX9pydI4XGRgvFo9g2JWswefI+WUSUHPYVBHYSEfTEqBxA5hQvAZB3Mw==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -3483,6 +3515,16 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/passport": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", @@ -3622,6 +3664,18 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3635,6 +3689,18 @@ "node": ">= 0.6" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", @@ -3708,6 +3774,12 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", @@ -3925,6 +3997,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -4043,6 +4128,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -4330,6 +4427,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4965,6 +5071,20 @@ "zod": ">=3.0.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5038,13 +5158,10 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, "engines": { "node": ">= 0.4" } @@ -5058,6 +5175,33 @@ "node": ">= 0.4" } }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", @@ -5137,6 +5281,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -5359,6 +5512,41 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5452,16 +5640,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -5478,6 +5671,19 @@ "node": ">=6" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.8.1.tgz", @@ -5534,12 +5740,12 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5563,10 +5769,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -5575,11 +5781,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, "engines": { "node": ">= 0.4" }, @@ -5615,6 +5824,15 @@ "node": ">= 0.8" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -6105,6 +6323,15 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -6354,6 +6581,46 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-gyp-build": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.3.tgz", @@ -6449,6 +6716,27 @@ "node": ">= 0.8" } }, + "node_modules/openai": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/openai/-/openai-5.1.1.tgz", + "integrity": "sha512-lgIdLqvpLpz8xPUKcEIV6ml+by74mbSBz8zv/AHHebtLn/WdpH4kdXT3/Q5uUKDHg3vHV/z9+G9wZINRX6rkDg==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -7914,6 +8202,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -9091,6 +9385,31 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index ba86b7e..47f9576 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "db:push": "drizzle-kit push" }, "dependencies": { + "@anthropic-ai/sdk": "^0.37.0", "@hookform/resolvers": "^3.10.0", "@jridgewell/trace-mapping": "^0.3.25", "@neondatabase/serverless": "^0.10.4", @@ -60,6 +61,7 @@ "multer": "^2.0.0", "nanoid": "^5.1.5", "next-themes": "^0.4.6", + "openai": "^5.1.1", "passport": "^0.7.0", "passport-local": "^1.0.0", "react": "^18.3.1",