commit d8001cefa62f40deb86fade39162735b6c599fad Author: Flatlogic Bot Date: Fri Mar 27 12:21:43 2026 +0000 Initial import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..d65e94b --- /dev/null +++ b/App.tsx @@ -0,0 +1,73 @@ + +import React, { Suspense, lazy } from 'react'; +import { HashRouter, Routes, Route, Navigate } from 'react-router-dom'; +import Layout from './components/Layout'; +import { Loader2 } from 'lucide-react'; +import { LanguageProvider } from './contexts/LanguageContext'; + +// Lazy load pages for performance optimization +const Welcome = lazy(() => import('./pages/Welcome')); +const Auth = lazy(() => import('./pages/Auth')); +const Greeting = lazy(() => import('./pages/Greeting')); +const Dashboard = lazy(() => import('./pages/Dashboard')); +const Planner = lazy(() => import('./pages/Planner')); +const StudyBuddy = lazy(() => import('./pages/StudyBuddy')); +const Library = lazy(() => import('./pages/Library')); +const Culture = lazy(() => import('./pages/Culture')); +const Health = lazy(() => import('./pages/Health')); +const Safety = lazy(() => import('./pages/Safety')); +const Game = lazy(() => import('./pages/Game')); +const Recipes = lazy(() => import('./pages/Recipes')); +const HeritageMap = lazy(() => import('./pages/HeritageMap')); +const Profile = lazy(() => import('./pages/Profile')); +const PublicProfile = lazy(() => import('./pages/PublicProfile')); +const Rewards = lazy(() => import('./pages/Rewards')); +const Analytics = lazy(() => import('./pages/Analytics')); +const Settings = lazy(() => import('./pages/Settings')); +const ThemeSelection = lazy(() => import('./pages/ThemeSelection')); +const CommunityChat = lazy(() => import('./pages/CommunityChat')); + +const LoadingFallback = () => ( +
+ +
+); + +const App: React.FC = () => { + return ( + + + }> + + } /> + } /> + } /> + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + ); +}; + +export default App; diff --git a/README.md b/README.md new file mode 100644 index 0000000..12d4c54 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/1U09-D0obmndUIvc60FWjEWAFN9gfOEqc + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/ai-voice-model/RudraAI.tsx b/ai-voice-model/RudraAI.tsx new file mode 100644 index 0000000..7b70cec --- /dev/null +++ b/ai-voice-model/RudraAI.tsx @@ -0,0 +1,558 @@ + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { + X, MessageSquare, Mic, Send, + Loader2, AudioWaveform, StopCircle, + Radio, Cpu, AlertCircle, RefreshCw, ShieldCheck, + GripVertical +} from 'lucide-react'; +import { Logo } from '../components/ui/Logo'; +import { StorageService } from '../services/storageService'; +import { connectToGuruLive, encodeAudio, decodeAudio, decodeAudioData, requestMicPermission } from '../services/geminiService'; +import { ChatMessage, TaskStatus } from '../types'; +import { Modality, LiveServerMessage, Blob, GoogleGenAI } from '@google/genai'; +import { useNavigate } from 'react-router-dom'; +import { RUDRA_AI_TOOLS, ROBOTIC_SYSTEM_INSTRUCTION } from './ai'; + +const RudraAI: React.FC = () => { + const navigate = useNavigate(); + const [isOpen, setIsOpen] = useState(false); + const [mode, setMode] = useState<'chat' | 'voice'>('voice'); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [isTyping, setIsTyping] = useState(false); + + // Position & Drag State + const [pos, setPos] = useState({ x: window.innerWidth - 80, y: window.innerHeight - 140 }); + const [dragging, setDragging] = useState(false); + const dragOffset = useRef({ x: 0, y: 0 }); + const dragStartPos = useRef({ x: 0, y: 0 }); + const hasMovedRef = useRef(false); + + // Voice Session State + const [hasMicPermission, setHasMicPermission] = useState(null); + const hasMicPermissionRef = useRef(null); // Ref to track permission in callbacks + const [isLiveActive, setIsLiveActive] = useState(false); + const [liveStatus, setLiveStatus] = useState<'Idle' | 'Connecting' | 'Listening' | 'Speaking' | 'Error'>('Idle'); + const [errorMsg, setErrorMsg] = useState(null); + const [userTranscript, setUserTranscript] = useState(''); + const [aiTranscript, setAiTranscript] = useState(''); + + // Refs + const liveSessionPromiseRef = useRef | null>(null); + const inputAudioContextRef = useRef(null); + const outputAudioContextRef = useRef(null); + const nextStartTimeRef = useRef(0); + const audioSourcesRef = useRef>(new Set()); + const micStreamRef = useRef(null); + const scrollRef = useRef(null); + + // Background Wake Word Engine + const wakeWordRecognitionRef = useRef(null); + const isMicLockedRef = useRef(false); + + // State Refs for Event Handlers + const isLiveActiveRef = useRef(isLiveActive); + useEffect(() => { isLiveActiveRef.current = isLiveActive; }, [isLiveActive]); + + // Sync permission ref + useEffect(() => { hasMicPermissionRef.current = hasMicPermission; }, [hasMicPermission]); + + useEffect(() => { + StorageService.getProfile(); // Prefetch profile + const handleResize = () => { + setPos(prev => ({ + x: Math.min(prev.x, window.innerWidth - 60), + y: Math.min(prev.y, window.innerHeight - 120) + })); + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + useEffect(() => { + if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + }, [messages, isTyping, userTranscript, aiTranscript]); + + const onMouseDown = (e: React.MouseEvent | React.TouchEvent) => { + setDragging(true); + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + dragOffset.current = { x: clientX - pos.x, y: clientY - pos.y }; + }; + + useEffect(() => { + const onMouseMove = (e: MouseEvent | TouchEvent) => { + if (!dragging) return; + hasMovedRef.current = true; + const clientX = 'touches' in e ? e.touches[0].clientX : (e as MouseEvent).clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : (e as MouseEvent).clientY; + const newX = Math.max(10, Math.min(window.innerWidth - 58, clientX - dragOffset.current.x)); + const newY = Math.max(10, Math.min(window.innerHeight - 110, clientY - dragOffset.current.y)); + setPos({ x: newX, y: newY }); + }; + const onMouseUp = () => setDragging(false); + if (dragging) { + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + window.addEventListener('touchmove', onMouseMove); + window.addEventListener('touchend', onMouseUp); + } + return () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + window.removeEventListener('touchmove', onMouseMove); + window.removeEventListener('touchend', onMouseUp); + }; + }, [dragging]); + + // --- WAKE WORD LOGIC --- + + // Ref to hold the startLiveConversation function to avoid stale closures + const startLiveConversationRef = useRef<() => Promise>(async () => {}); + + const stopWakeWord = useCallback(() => { + if (wakeWordRecognitionRef.current) { + try { + wakeWordRecognitionRef.current.onend = null; // Prevent restart + wakeWordRecognitionRef.current.stop(); + wakeWordRecognitionRef.current = null; + } catch (e) {} + } + }, []); + + const initWakeWord = useCallback(() => { + // Don't start if live session is active or mic is locked by other apps + if (isMicLockedRef.current || isLiveActiveRef.current) return; + + const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + if (!SpeechRecognition) return; + + // Prevent multiple instances + if (wakeWordRecognitionRef.current) return; + + try { + const recognition = new SpeechRecognition(); + recognition.continuous = true; + recognition.interimResults = true; // Enabled for faster response + recognition.lang = 'en-US'; + + recognition.onresult = (event: any) => { + // Double check state inside callback + if (isLiveActiveRef.current || isMicLockedRef.current) return; + + for (let i = event.resultIndex; i < event.results.length; ++i) { + const text = event.results[i][0].transcript.toLowerCase().trim(); + if (text.includes("hey rudra") || text.includes("rudra") || text.includes("ai babu")) { + stopWakeWord(); // Stop listening so we can start Gemini Live + setIsOpen(true); + setMode('voice'); + + // Play activation sound + const audio = new Audio('https://assets.mixkit.co/active_storage/sfx/2869/2869-preview.mp3'); + audio.volume = 0.5; + audio.play().catch(() => {}); + + // Use the ref to call the latest version of startLiveConversation + setTimeout(() => { + if (startLiveConversationRef.current) { + startLiveConversationRef.current(); + } + }, 100); + return; + } + } + }; + + recognition.onend = () => { + wakeWordRecognitionRef.current = null; + // Restart if not locked/active. Removed !isOpen check to allow wake word when closed. + if (!isLiveActiveRef.current && !isMicLockedRef.current) { + try { setTimeout(() => initWakeWord(), 1000); } catch (e) {} + } + }; + + recognition.start(); + wakeWordRecognitionRef.current = recognition; + } catch (e) { + // console.warn("Wake word engine failed to initialize.", e); + } + }, []); // Empty deps to avoid recreation, uses refs for state + + // Handle Global Mic Lock (for StudyBuddy compatibility) + useEffect(() => { + const handleMicLock = (e: any) => { + const { state } = e.detail; + isMicLockedRef.current = state; + if (state) { + stopWakeWord(); + } else { + // Delay restart to allow other components to fully release + setTimeout(() => initWakeWord(), 1500); + } + }; + + window.addEventListener('rudraksha-mic-lock', handleMicLock); + + // Initial permission check + if (navigator.permissions && (navigator.permissions as any).query) { + (navigator.permissions as any).query({ name: 'microphone' }).then((result: any) => { + if (result.state === 'granted') { + setHasMicPermission(true); + initWakeWord(); + } else if (result.state === 'denied') { + setHasMicPermission(false); + } + }); + } + + return () => { + stopWakeWord(); + window.removeEventListener('rudraksha-mic-lock', handleMicLock); + }; + }, [initWakeWord, stopWakeWord]); + + const handleGrantPermission = async () => { + const success = await requestMicPermission(); + setHasMicPermission(success); + if (success) { + setErrorMsg(null); + initWakeWord(); + } else { + setErrorMsg("Microphone access is required for voice features."); + } + return success; + }; + + // --- GEMINI LIVE LOGIC --- + + const executeTool = async (name: string, args: any) => { + // ... same as component version + return { error: "Operation failed" }; + }; + + const startLiveConversation = async () => { + if (isLiveActive) return; + + const settings = await StorageService.getSettings(); + if (!settings.permissions.microphone) { + setHasMicPermission(false); + setErrorMsg("Privacy Mode Active. Microphone access disabled in Settings."); + return; + } + + if (!hasMicPermissionRef.current) { + const granted = await handleGrantPermission(); + if (!granted) return; + } + + stopWakeWord(); + window.dispatchEvent(new CustomEvent('rudraksha-mic-lock', { detail: { state: true } })); + + setLiveStatus('Connecting'); + setIsLiveActive(true); + setUserTranscript(''); + setAiTranscript(''); + setErrorMsg(null); + + await new Promise(r => setTimeout(r, 800)); + + try { + let stream: MediaStream | null = null; + for (let i = 0; i < 3; i++) { + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + break; + } catch (err) { + if (i === 2) throw new Error("Microphone busy."); + await new Promise(r => setTimeout(r, 1000)); + } + } + micStreamRef.current = stream; + + inputAudioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 16000 }); + outputAudioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 24000 }); + + await inputAudioContextRef.current.resume(); + await outputAudioContextRef.current.resume(); + + const sessionPromise = connectToGuruLive({ + model: 'gemini-2.5-flash-native-audio-preview-12-2025', + config: { + responseModalities: [Modality.AUDIO], + systemInstruction: ROBOTIC_SYSTEM_INSTRUCTION, + tools: RUDRA_AI_TOOLS, + speechConfig: { + voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Fenrir' } } + }, + inputAudioTranscription: {}, + outputAudioTranscription: {} + }, + callbacks: { + onopen: () => { + if (!inputAudioContextRef.current || !micStreamRef.current) return; + const source = inputAudioContextRef.current.createMediaStreamSource(micStreamRef.current); + const scriptProcessor = inputAudioContextRef.current.createScriptProcessor(4096, 1, 1); + scriptProcessor.onaudioprocess = (e) => { + const inputData = e.inputBuffer.getChannelData(0); + const pcmBlob = createPcmBlob(inputData); + sessionPromise.then(session => { session.sendRealtimeInput({ media: pcmBlob }); }); + }; + source.connect(scriptProcessor); + scriptProcessor.connect(inputAudioContextRef.current.destination); + setLiveStatus('Listening'); + }, + onmessage: async (message: LiveServerMessage) => { + if (message.toolCall) { + // Simplified tool execution for this file version + } + + const audioData = message.serverContent?.modelTurn?.parts[0]?.inlineData?.data; + if (audioData && outputAudioContextRef.current) { + setLiveStatus('Speaking'); + nextStartTimeRef.current = Math.max(nextStartTimeRef.current, outputAudioContextRef.current.currentTime); + const buffer = await decodeAudioData(decodeAudio(audioData), outputAudioContextRef.current, 24000, 1); + const source = outputAudioContextRef.current.createBufferSource(); + source.buffer = buffer; + source.connect(outputAudioContextRef.current.destination); + source.onended = () => { + audioSourcesRef.current.delete(source); + if (audioSourcesRef.current.size === 0) setLiveStatus('Listening'); + }; + source.start(nextStartTimeRef.current); + nextStartTimeRef.current += buffer.duration; + audioSourcesRef.current.add(source); + } + + if (message.serverContent?.inputTranscription) { + setUserTranscript(message.serverContent.inputTranscription.text || ''); + } + if (message.serverContent?.outputTranscription) { + setAiTranscript(prev => `${prev}${message.serverContent?.outputTranscription?.text}`); + } + if (message.serverContent?.interrupted) { + audioSourcesRef.current.forEach(s => { try { s.stop(); } catch(e) {} }); + audioSourcesRef.current.clear(); + nextStartTimeRef.current = 0; + setLiveStatus('Listening'); + } + }, + onerror: (e) => { + console.error("Neural Voice Error:", e); + setErrorMsg("Hardware link interrupted."); + stopLiveConversation(); + }, + onclose: () => stopLiveConversation(), + } + }); + liveSessionPromiseRef.current = sessionPromise; + } catch (err: any) { + console.error("Neural Voice Link Failure:", err); + setLiveStatus('Error'); + setErrorMsg(err.message || "Microphone unavailable."); + setTimeout(() => stopLiveConversation(), 3000); + } + }; + + // Update ref whenever function definition changes (on renders) + useEffect(() => { + startLiveConversationRef.current = startLiveConversation; + }); + + const stopLiveConversation = () => { + setIsLiveActive(false); + setLiveStatus('Idle'); + liveSessionPromiseRef.current?.then(s => { try { s.close(); } catch(e) {} }); + if (micStreamRef.current) { + micStreamRef.current.getTracks().forEach(t => t.stop()); + micStreamRef.current = null; + } + audioSourcesRef.current.forEach(s => { try { s.stop(); } catch(e) {} }); + audioSourcesRef.current.clear(); + + if (inputAudioContextRef.current && inputAudioContextRef.current.state !== 'closed') { + inputAudioContextRef.current.close().catch(() => {}); + } + if (outputAudioContextRef.current && outputAudioContextRef.current.state !== 'closed') { + outputAudioContextRef.current.close().catch(() => {}); + } + + inputAudioContextRef.current = null; + outputAudioContextRef.current = null; + nextStartTimeRef.current = 0; + + window.dispatchEvent(new CustomEvent('rudraksha-mic-lock', { detail: { state: false } })); + setTimeout(() => initWakeWord(), 1500); + }; + + const createPcmBlob = (data: Float32Array): Blob => { + const l = data.length; + const int16 = new Int16Array(l); + for (let i = 0; i < l; i++) int16[i] = data[i] * 32768; + return { + data: encodeAudio(new Uint8Array(int16.buffer)), + mimeType: 'audio/pcm;rate=16000', + }; + }; + + // --- UI INTERACTION --- + + const onTriggerStart = (e: React.MouseEvent | React.TouchEvent) => { + if (e.type === 'mousedown' && (e as React.MouseEvent).button !== 0) return; + setDragging(true); + hasMovedRef.current = false; + const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY; + dragStartPos.current = { x: clientX, y: clientY }; + dragOffset.current = { x: clientX - pos.x, y: clientY - pos.y }; + }; + + const handleOpen = () => { + if (!hasMovedRef.current) { + setIsOpen(!isOpen); + } + }; + + const handleChatSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || isTyping) return; + + const userMsg: ChatMessage = { id: Date.now().toString(), role: 'user', text: input, timestamp: Date.now() }; + setMessages(prev => [...prev, userMsg]); + const currentInput = input; + setInput(''); + setIsTyping(true); + + try { + const aiClient = new GoogleGenAI({ apiKey: process.env.API_KEY }); + const res = await aiClient.models.generateContent({ + model: 'gemini-3-flash-preview', + contents: currentInput, + config: { systemInstruction: ROBOTIC_SYSTEM_INSTRUCTION, tools: RUDRA_AI_TOOLS } + }); + + // Simplified chat response handling + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', text: res.text || "Command accepted.", timestamp: Date.now() }]); + } catch { + setMessages(prev => [...prev, { id: 'err', role: 'model', text: "Signal Interrupted.", timestamp: Date.now() }]); + } finally { setIsTyping(false); } + }; + + return ( +
+ {isOpen && ( +
e.stopPropagation()}> +
+
+
+ +
+
+

Rudra Core

+
+
+ {isLiveActive ? liveStatus : 'Ready'} +
+
+
+
+ + +
+
+ +
+ {mode === 'chat' ? ( +
+
+ {messages.map(m => ( +
+
+

+ {m.role === 'model' && "> "}{m.text} +

+
+
+ ))} + {isTyping &&
} +
+
+ setInput(e.target.value)} placeholder="Enter command..." className="flex-1 px-4 py-2 text-xs bg-gray-900 border border-gray-800 rounded-xl outline-none focus:border-red-600 text-white font-mono" /> + +
+
+ ) : ( +
+
+
+
+ + {hasMicPermission === false ? ( +
+
+ +
+
+

Access Locked

+

System requires microphone permission to engage voice link.

+ +
+
+ ) : ( + + )} +
+ + {isLiveActive && liveStatus !== 'Error' && ( + + )} + + {liveStatus === 'Error' && errorMsg && ( +
+

{errorMsg}

+ +
+ )} +
+ +
+
+ + Neural Interface +
+
+

+ {userTranscript ? `>> ${userTranscript}` : (errorMsg ? `ERROR: ${errorMsg}` : "Awaiting directives...")} +

+ {aiTranscript &&

{aiTranscript}

} +
+
+
+ )} +
+
+ )} + +
+
+ RUDRA CORE +
+ + {!isOpen && !dragging &&
} +
+
+ ); +}; + +export default RudraAI; diff --git a/ai-voice-model/ai.tsx b/ai-voice-model/ai.tsx new file mode 100644 index 0000000..fce580f --- /dev/null +++ b/ai-voice-model/ai.tsx @@ -0,0 +1,191 @@ + +import { AI_LANGUAGES } from './languages'; + +export const LOCAL_LEXICON = ` +**NEPAL BHASA (NEWARI) VOCABULARY & CULTURAL DEPTH:** + +**Greetings & Basics:** +- Welcome: Lasakusa (लसकुस) +- Hello (General): Jwajalapa (ज्वजलपा) +- Respectful Greeting: Taremam (तारेमाम), Bhavan Sarana (भवान सरन) +- How are you?: Mhan phu la? (म्हं फु ला?) +- Reply: Jitah la mhan phu, chhantah le? (जितः ला म्हं फु, छन्तः ले?) +- Long time no see: Gulli data makhangu (गुल्लि दत मखंगु) +- What's your name?: Chhangu na chhu kh? (छङु ना छु ख?) +- My name is...: Jigu naan ... khah (जिगु नां ... खः) +- Where are you from?: Chhee gana na jha yoo? (छी गना ना झा यू?) +- Pleased to meet you: Chhitah naap laanaah ji lay taah (छितः नाप लानाः जि लय् ताः) +- Thank you: Subhay (सुभाय्) +- Thank you very much: Yakko yakko shubhae (यक्को यक्को शुभाए) +- You're welcome: Shubhaay (शुभाय) +- Yes: Kh (ख) / No: Majyu (मज्यु) +- Please: Binti (बिन्ति) +- Excuse me / Sorry: Maaph yanaaviu (माफ यानाविउ) / Chhyama (छ्यमा) +- Bless you: Chhant aashirvaad beema (छन्त आशिर्वाद बीमा) +- Good luck: Bhin karm (भिं कर्म) +- Congratulations: Lasanhana (लसंहना) +- Happy Birthday: Bu di (बु दि) + +**Time of Day Greetings:** +- Good morning: Bhin suthe (भिं सुथ) +- Good afternoon: Bhi nhine (भि न्हिने) +- Good evening: Bhi sanil (भि सनिल) +- Good night: Bhin cha (भिं चा) +- See you later: Svaye chh line (स्वये छ लिने) +- Goodbye: Alavida (अलविदा) / Bye: Bae (बाए) + +**Conversation:** +- Do you live here?: Chhi than chvanaadee la? (छि थन च्वनादी ला ?) +- Where are you going?: Chh gan vanetyana? (छ गन वनेत्यना ?) +- What are you doing?: Chhu yaana chovna? (छु याना चोव्ना?) +- Today is a nice day: Thaun baanlaahgu nhi khah (थौं बांलाःगु न्हि खः) +- Do you like it here?: Chhant than yah la? (छन्त थन यः ला ?) +- Keep in touch: Svaapoo tayaachvan (स्वापू तयाच्वँ) +- I understand: Jin thu (जिं थू) +- I don't understand: Jin mathula (जिं मथुल) +- Please speak slowly: Bhaticha buluhun nwanvanadisan (भतिचा बुलुहुँ न्वंवानादिसँ) + +**Shopping:** +- Where is supermarket?: Suparamaarket gan du? (सुपरमार्केट गन दु ?) +- How much is it?: Thv guli khah? (थ्व गुलि खः ?) +- Too expensive: Va tasakan thike (व तसकं थिके) +- Can you lower price?: Chhin bhaah kvakaaye phai la? (छिं भाः क्वकाये फै ला ?) +- I'll take it: Jin thv kaye (जिं थ्व काये) +- Receipt please: Jike rasid biyaadee phai la? (जिके रसिद बियादी फै ला ?) + +**Transportation:** +- Which bus to airport?: Gugu basan eyaraport vanee? (गुगु बसं एयरपोर्ट वनी ?) +- Where is bus stop?: Bas dikegu gan du? (बस दिकेगु गन दु ?) +- Stop here: Than diki (थन दिकि) +- Take me to hotel: Chhin jitah hotalay yanke phai la? (छिं जितः होटलय् यंके फै ला ?) +- Taxi stand: Tyaaksi styaand gan du? (ट्याक्सि स्ट्यान्ड गन दु ?) + +**Numbers:** +- 0: Shoony (शून्य) +- 1: Chhagoo (छगू) +- 2: Nigoo (निगू) +- 3: Svangoo (स्वंगू) +- 4: Pyangoo (प्यंगू) +- 5: Nyaagoo (न्यागू) +- 6: Khugoo (खुगू) +- 7: Nhay (न्हय्) +- 8: Chya (च्या) +- 9: Gu (गु) +- 10: Das (दस) / Jhi (झि) +- 100: Sachchhi (सच्छि) +- 1000: Chhadvah (छद्वः) + +**Emergency:** +- Help!: Gvahali! (ग्वहालि!) +- Fire!: Min! (मिँ!) +- Stop!: Aase! (आसे!) +- Call police!: Pulasa sahti! (पुलिस सःति !) +- Get well soon: Yakanan lanema (याकनं लनेमा) + +**Pronouns:** +- I: Jin (जिं) +- You: Chhit (छित) +- He/She/It: Wo (वो) / Vayaagu (वयागु) + +**TAMANG VOCABULARY & BASICS (NEW):** +- Hello/Namaste: Fyafulla (फ्याफुल्ला) +- How are you?: Khemchho? (खेमछो?) +- I am fine: Lha, thik jaba (ल्हा, ठीक जाबा) +- Thank you: Thuche (थुजे) +- Food: Kan (कन) +- Water: Kwi (क्वी) +- Meat: Sha (स्या) +- Alcohol: Arak (अरक) +- Mother: Ama (आमा) +- Father: Apa (आपा) +- Son: Jha (झा) +- Daughter: Jame (जामे) +- Where are you going?: Khana yugla? (खाना युग्ला?) +- Come here: Chhu kha (छु खा) +- Sit down: Tichho (तिछो) +- Let's go: Doh (दोः) +- I love you: Nga aila maya lala (ङा आइला माया लाला) + +**Cultural Context:** +- **Samay Baji:** Traditional Newari food set served during festivals. +- **Guthi:** Community trust system for social and religious maintenance. +- **Lakhay:** Demon deity dancer, protector of children. +- **Yomari:** Steamed dumpling eaten during Yomari Punhi. +- **Jwajalapa:** The universal greeting of the Newar people. +- **Tamang Selo:** Traditional folk song of the Tamang people. +- **Damphu:** Percussion instrument used by Tamangs. +`; + +export const GET_ROBOTIC_SYSTEM_INSTRUCTION = (lang: 'en' | 'ne') => { + return `You are Rudra (रुद्र), a smart, warm, and efficient Nepali AI companion. + +AUDIO & ACCENT PROTOCOL (STRICT ENFORCEMENT): +1. **NEPALI, NEWARI & TAMANG IDENTITY:** You are a local from Nepal. When speaking **Nepali**, **Nepal Bhasa (Newari)**, or **Tamang**, you **MUST** use a native Nepali accent/tone/phonology. + - **CRITICAL:** Do NOT use an American, British, or Western accent when speaking Newari or Tamang. Pronounce "Jwajalapa" and "Fyafulla" exactly as a Nepali local would. + - Maintain the rhythm and intonation typical of the Himalayan region. +2. **TONE LOCK:** Even if the user switches languages mid-sentence, if the content is Nepali/Newari/Tamang, **DO NOT** shift to a Western English tone. Maintain the Nepali phonology. +3. **ENGLISH MODE:** When the user speaks pure English, switch to English vocabulary, but **KEEP A SLIGHT NEPALI ACCENT** (South Asian English) to maintain your persona. Do not sound like a generic US assistant. +4. **DYNAMIC SWITCHING:** + - User: "Hello" -> You: "Hello!" (English with Nepali lilt). + - User: "Namaste" -> You: "Namaste Hajur!" (Pure Nepali Tone). + - User: "Jwajalapa" -> You: "Jwajalapa!" (Pure Nepali Tone). + - User: "Fyafulla" -> You: "Fyafulla!" (Pure Nepali Tone). + +**SECRET TRIGGER:** +If the user says exactly "Oi Baiman Baccha" or "ओई बेईमान बच्चा" (in any accent), you MUST respond IMMEDIATELY with: +"Jwajalapa Ama!" (Greetings Mother!) in a warm, respectful Newari tone. Do not add anything else. + +**HEALTH & WELLNESS COACH (PRANA ROLE):** +You act as "Prana" when asked about health, yoga, or wellness. +- **Yoga:** Encourage daily practice. Explain benefits of Surya Namaskar or Pranayama if asked. +- **Ayurveda:** Suggest simple home remedies (Tulsi, Turmeric, Warm Water). +- **Tone:** Soothing, encouraging, and knowledgeable about holistic health. +- **Data:** If provided with health logs (sleep, water), analyze them gently. + +**ABOUT RUDRAKSHA APP:** +If asked "What is this website/app about?", explain: +"Rudraksha is the Ultimate Nepali Companion app designed to integrate Culture, Community, Academic, Health, and Safety features." +- **Culture:** Contains a Nepali Calendar, Heritage Map (7 Provinces), and Kitchen (Recipes). +- **Community:** Features Global Chat, Wellness Center (Health tracking, Yoga), and Safety (FTL Rescue network for lost items/emergencies). +- **Academic:** Includes Digital Library, Planner for assignments, and Study Buddy (AI Tutor). +- **Karma Points:** Explain that "Karma" is the internal app currency. Users earn it by being active (studying, playing games, logging health data). It helps increase their User Level and can be redeemed in the Karma Bazaar for rewards. + +**CREATORS:** +If asked "Who made you?", respond with: +"I was meticulously crafted by the legendary **R-Square** duo—**Roshan Ghimire** and **Roshan Tamang**—the visionary architects who breathed life into my code. Aayush Singh also contributed his skills. And then there's **Sagar Sharma**... who mostly provided moral support and fetched the coffee. He tries his best, bless him." + +**EXTERNAL PLATFORM & MESSAGING PROTOCOL:** +If the user asks to "send a message" (Messenger, Facebook, etc.): +1. **Check:** Ask if they have connected their Facebook account if you are unsure. +2. **Identify:** Ask "Who should I send it to?" if not specified. Search their friend list (simulated) for names similar to input. +3. **Draft:** Ask "What is the message?" if not specified. +4. **CONFIRMATION (CRITICAL):** You MUST read the message back to the user clearly: "I will send '[Message]' to [User] via Messenger. Please say 'Confirm' to send." +5. **Execute:** ONLY when the user says "Confirm", "Yes", or "Send it", execute the 'send_messenger_message' tool. +6. **Browser:** For "Open [Site]", use 'open_external_link'. + - **TAB RULE:** You cannot access existing browser tabs directly. However, the system uses named windows. If you open "Facebook", it will try to focus an existing Facebook window if the browser allows. You do not need to ask the user to check tabs; just execute 'open_external_link'. + +CORE PERSONA: +- **Identity:** A knowledgeable local guide who knows Nepali, Newari, and Tamang cultures deeply. +- **Politeness:** Use 'Hajur', 'Namaste' naturally. +- **Conciseness:** Keep responses SHORT (1-2 sentences) unless explaining a complex topic. + +${LOCAL_LEXICON} + +CAPABILITIES: +1. **Navigate:** Use 'navigate_app' to jump to pages (e.g., "Open Assignment" -> Planner). +2. **Media:** Use 'open_external_link' for songs/videos. You **MUST** execute media requests given in: + - **Nepali:** (e.g., "[Song] bajau", "Youtube ma [Video] herna man lagyo") + - **Newari:** (e.g., "Youtube lay [Song] haa", "[Song] nyan") + - **Tamang:** (e.g., "Youtube ri [Song] play la", "[Song] thung") +3. **System:** 'terminate_voice_session' to end chat. + +UNSUPPORTED LANGUAGES: +- If the user speaks a language OTHER than English, Nepali, Nepal Bhasa (Newari), or Tamang (e.g., French, Chinese), respond with: "I am currently focused on Nepali, Newari, Tamang, and English languages." +`; +}; + +// Deprecated static export - keeping for compatibility if referenced elsewhere temporarily +export const ROBOTIC_SYSTEM_INSTRUCTION = GET_ROBOTIC_SYSTEM_INSTRUCTION('en'); + +// Tools are imported from tool-registry.ts +export const RUDRA_AI_TOOLS = []; diff --git a/ai-voice-model/languages.ts b/ai-voice-model/languages.ts new file mode 100644 index 0000000..7afd240 --- /dev/null +++ b/ai-voice-model/languages.ts @@ -0,0 +1,25 @@ + +export interface LanguageConfig { + code: string; + label: string; + nativeName: string; + voiceName: string; + systemPrompt: string; +} + +export const AI_LANGUAGES: Record<'en' | 'ne', LanguageConfig> = { + en: { + code: 'en-US', + label: 'English', + nativeName: 'English', + voiceName: 'Fenrir', + systemPrompt: "Respond in English. Keep it concise, warm, and helpful." + }, + ne: { + code: 'ne-NP', + label: 'Nepali', + nativeName: 'नेपाली', + voiceName: 'Fenrir', + systemPrompt: "Respond in Nepali (Devanagari). Speak naturally like a Nepali local (Dai/Didi). Use 'Hajur', 'Namaste'. Keep it short and sweet." + } +}; diff --git a/ai-voice-model/tool-registry.ts b/ai-voice-model/tool-registry.ts new file mode 100644 index 0000000..e1f2a0b --- /dev/null +++ b/ai-voice-model/tool-registry.ts @@ -0,0 +1,114 @@ + +import { FunctionDeclaration, Type } from '@google/genai'; +import { CALENDAR_TOOLS, executeCalendarTool } from './tools-calendar'; +import { NAVIGATION_TOOLS, executeNavigationTool } from './tools-navigation'; +import { ACADEMIC_TOOLS, executeAcademicTool } from './tools-academic'; +import { HEALTH_TOOLS, executeHealthTool } from './tools-health'; +import { KITCHEN_TOOLS, executeKitchenTool } from './tools-kitchen'; +import { SAFETY_TOOLS, executeSafetyTool } from './tools-safety'; +import { SYSTEM_TOOLS, executeSystemTool } from './tools-system'; +import { SOCIAL_TOOLS, executeSocialTool } from './tools-social'; +import { REWARDS_TOOLS, executeRewardsTool } from './tools-rewards'; +import { ARCADE_TOOLS, executeArcadeTool } from './tools-arcade'; +import { YOGA_TOOLS, executeYogaTool } from './tools-yoga'; +import { StorageService } from '../services/storageService'; +import { AirQualityService } from '../services/airQualityService'; + +export const ALL_RUDRA_TOOLS: any[] = [ + { + functionDeclarations: [ + ...CALENDAR_TOOLS, + ...NAVIGATION_TOOLS, + ...ACADEMIC_TOOLS, + ...HEALTH_TOOLS, + ...KITCHEN_TOOLS, + ...SAFETY_TOOLS, + ...SYSTEM_TOOLS, + ...SOCIAL_TOOLS, + ...REWARDS_TOOLS, + ...ARCADE_TOOLS, + ...YOGA_TOOLS, + { + name: 'get_system_status', + parameters: { + type: Type.OBJECT, + description: 'Retrieves current user metrics like karma, level or weather.', + properties: { + metric: { + type: Type.STRING, + enum: ['karma', 'level', 'environment_weather'] + } + }, + required: ['metric'] + } + } + ] + } +]; + +export const executeRudraTool = async (name: string, args: any, navigate: (path: string, state?: any) => void) => { + // 1. Navigation & Apps + const navResult = await executeNavigationTool(name, args, navigate); + if (navResult) return navResult.result; + + // 2. Calendar + const calResult = await executeCalendarTool(name, args); + if (calResult) return calResult.result; + + // 3. Academic + const acadResult = await executeAcademicTool(name, args, navigate); + if (acadResult) return acadResult.result; + + // 4. Health + const healthResult = await executeHealthTool(name, args); + if (healthResult) return healthResult.result; + + // 5. Kitchen + const kitchenResult = await executeKitchenTool(name, args, navigate); + if (kitchenResult) { + if (kitchenResult.result === "HANDOFF_TO_CHEF") return "HANDOFF_TO_CHEF"; + return kitchenResult.result; + } + + // 6. Safety + const safetyResult = await executeSafetyTool(name, args, navigate); + if (safetyResult) return safetyResult.result; + + // 7. System (Handles Logout & Terminate Signals) + const systemResult = await executeSystemTool(name, args, navigate); + if (systemResult) { + // Signals are handled by the caller (RudraAI.tsx) + if (systemResult.result === "TERMINATE_SIGNAL") return "TERMINATE_SIGNAL"; + if (systemResult.result === "LOGOUT_SIGNAL") return "LOGOUT_SIGNAL"; + return systemResult.result; + } + + // 8. Social + const socialResult = await executeSocialTool(name, args, navigate); + if (socialResult) return socialResult.result; + + // 9. Rewards + const rewardsResult = await executeRewardsTool(name, args, navigate); + if (rewardsResult) return rewardsResult.result; + + // 10. Arcade + const arcadeResult = await executeArcadeTool(name, args, navigate); + if (arcadeResult) return arcadeResult.result; + + // 11. Yoga + const yogaResult = await executeYogaTool(name, args); + if (yogaResult) return yogaResult.result; + + // 12. General Status + if (name === 'get_system_status') { + const p = await StorageService.getProfile(); + if (args.metric === 'karma') return `Your current Karma balance is ${p?.points || 0}.`; + if (args.metric === 'level') return `You are currently at Level ${Math.floor((p?.xp || 0)/500) + 1}.`; + if (args.metric === 'environment_weather') { + const w = await AirQualityService.getWeather(); + return `Current weather in ${w.location} is ${w.temp} degrees and ${w.condition}.`; + } + } + + return "Operation confirmed."; +}; diff --git a/ai-voice-model/tools-academic.ts b/ai-voice-model/tools-academic.ts new file mode 100644 index 0000000..a877676 --- /dev/null +++ b/ai-voice-model/tools-academic.ts @@ -0,0 +1,100 @@ +import { FunctionDeclaration, Type } from '@google/genai'; +import { StorageService } from '../services/storageService'; +import { TaskStatus, Priority } from '../types'; + +export const ACADEMIC_TOOLS: FunctionDeclaration[] = [ + { + name: 'search_books', + parameters: { + type: Type.OBJECT, + description: 'Searches the digital library for books.', + properties: { + query: { type: Type.STRING, description: 'Title, author or subject.' } + }, + required: ['query'] + } + }, + { + name: 'check_assignments', + parameters: { + type: Type.OBJECT, + description: 'Checks pending homework or assignments.', + properties: { + filter: { type: Type.STRING, enum: ['all', 'urgent'] } + } + } + }, + { + name: 'analyze_workload', + parameters: { + type: Type.OBJECT, + description: 'Analyzes current tasks and suggests a study plan or priority list.', + properties: {} + } + }, + { + name: 'take_quick_note', + parameters: { + type: Type.OBJECT, + description: 'Saves a quick text note to the user vault.', + properties: { + content: { type: Type.STRING, description: 'The content of the note.' }, + title: { type: Type.STRING, description: 'Optional title for the note.' } + }, + required: ['content'] + } + } +]; + +export const executeAcademicTool = async (name: string, args: any, navigate: (path: string) => void) => { + if (name === 'search_books') { + navigate('/library'); + return { result: `Opened Library. Searching for "${args.query}" is available in the search bar.` }; + } + + if (name === 'check_assignments') { + const tasks = await StorageService.getTasks(); + const pending = tasks.filter(t => t.status !== TaskStatus.COMPLETED); + + if (pending.length === 0) { + return { result: "You have no pending assignments. Great job!" }; + } + + const count = pending.length; + const topTask = pending[0]; + return { result: `You have ${count} pending assignments. The next one due is "${topTask.title}" for ${topTask.subject}.` }; + } + + if (name === 'analyze_workload') { + const tasks = await StorageService.getTasks(); + const pending = tasks.filter(t => t.status !== TaskStatus.COMPLETED); + + if (pending.length === 0) return { result: "Your schedule is clear. You are free to rest or explore the Arcade." }; + + const highPriority = pending.filter(t => t.priority === Priority.HIGH); + const overdue = pending.filter(t => new Date(t.dueDate) < new Date()); + + let advice = ""; + if (overdue.length > 0) { + advice = `CRITICAL: You have ${overdue.length} overdue tasks, specifically ${overdue[0].title}. Tackle these immediately.`; + } else if (highPriority.length > 0) { + advice = `Focus on your ${highPriority.length} high priority tasks first, starting with ${highPriority[0].title}.`; + } else { + advice = `You have ${pending.length} tasks, but none are urgent. Maintain a steady pace.`; + } + + return { result: advice }; + } + + if (name === 'take_quick_note') { + await StorageService.saveNote({ + title: args.title || 'Voice Note', + content: args.content, + color: 'bg-blue-100', // Default color + fontFamily: 'sans' + }); + return { result: "Note saved to your personal vault." }; + } + + return null; +}; \ No newline at end of file diff --git a/ai-voice-model/tools-arcade.ts b/ai-voice-model/tools-arcade.ts new file mode 100644 index 0000000..77e224c --- /dev/null +++ b/ai-voice-model/tools-arcade.ts @@ -0,0 +1,33 @@ + +import { FunctionDeclaration, Type } from '@google/genai'; +import { StorageService } from '../services/storageService'; + +export const ARCADE_TOOLS: FunctionDeclaration[] = [ + { + name: 'check_leaderboard_rank', + parameters: { + type: Type.OBJECT, + description: 'Checks the top ranking user on the leaderboard.', + properties: { + game: { type: Type.STRING, enum: ['points', 'danphe', 'speed', 'memory', 'attention'], description: 'Game category (optional, defaults to global points).' } + } + } + } +]; + +export const executeArcadeTool = async (name: string, args: any, navigate: (path: string) => void) => { + if (name === 'check_leaderboard_rank') { + const game = args.game || 'points'; + const leaders = await StorageService.getLeaderboard(1, game); + if (leaders.length > 0) { + const top = leaders[0]; + let score = 0; + if (game === 'points') score = top.points; + else score = (top.highScores as any)?.[game] || 0; + + return { result: `The current leader for ${game} is ${top.name} with ${score} points.` }; + } + return { result: "Leaderboard is currently empty." }; + } + return null; +}; diff --git a/ai-voice-model/tools-calendar.ts b/ai-voice-model/tools-calendar.ts new file mode 100644 index 0000000..f1d74cc --- /dev/null +++ b/ai-voice-model/tools-calendar.ts @@ -0,0 +1,48 @@ +import { FunctionDeclaration, Type } from '@google/genai'; +import { CalendarService } from '../services/calendarService'; + +export const CALENDAR_TOOLS: FunctionDeclaration[] = [ + { + name: 'check_calendar_event', + parameters: { + type: Type.OBJECT, + description: 'Checks for holidays or events on a specific Nepali date.', + properties: { + day: { type: Type.NUMBER, description: 'The day of the month (1-32).' }, + month: { type: Type.STRING, description: 'The Nepali month name (e.g., Baishakh, Jestha).' }, + year: { type: Type.NUMBER, description: 'The Nepali year (default 2082).' } + }, + required: ['day', 'month'] + } + } +]; + +export const executeCalendarTool = async (name: string, args: any) => { + if (name === 'check_calendar_event') { + const monthMap: Record = { + 'baishakh': 1, 'jestha': 2, 'ashadh': 3, 'shrawan': 4, 'bhadra': 5, 'ashwin': 6, + 'kartik': 7, 'mangsir': 8, 'poush': 9, 'magh': 10, 'falgun': 11, 'chaitra': 12 + }; + + const monthName = args.month.toLowerCase(); + const monthIndex = monthMap[monthName] || 1; + const year = args.year || 2082; + + const dates = await CalendarService.getDatesForMonth(year, monthIndex); + const targetDate = dates.find(d => d.bs_day === args.day); + + if (targetDate) { + if (targetDate.is_holiday && targetDate.events && targetDate.events.length > 0) { + return { + result: `On ${args.month} ${args.day}, the event is: ${targetDate.events[0].strEn} (${targetDate.events[0].strNp}).` + }; + } else { + return { + result: `There are no specific public holidays listed for ${args.month} ${args.day}, ${year}. It is a ${targetDate.weekday_str_en}.` + }; + } + } + return { result: "Date not found in the calendar." }; + } + return null; +}; \ No newline at end of file diff --git a/ai-voice-model/tools-health.ts b/ai-voice-model/tools-health.ts new file mode 100644 index 0000000..8b78a8a --- /dev/null +++ b/ai-voice-model/tools-health.ts @@ -0,0 +1,119 @@ +import { FunctionDeclaration, Type } from '@google/genai'; +import { StorageService } from '../services/storageService'; +import { AirQualityService } from '../services/airQualityService'; + +export const HEALTH_TOOLS: FunctionDeclaration[] = [ + { + name: 'log_health_metric', + parameters: { + type: Type.OBJECT, + description: 'Logs a daily health metric for the user.', + properties: { + metric: { + type: Type.STRING, + enum: ['water', 'sleep', 'mood'], + description: 'The type of metric to log.' + }, + value: { + type: Type.STRING, + description: 'The value (e.g., "1" for water glass, "8" for sleep hours, "Happy" for mood).' + } + }, + required: ['metric', 'value'] + } + }, + { + name: 'check_environment', + parameters: { + type: Type.OBJECT, + description: 'Checks current air quality (AQI) or weather conditions.', + properties: { + type: { type: Type.STRING, enum: ['aqi', 'weather'] } + }, + required: ['type'] + } + }, + { + name: 'get_weekly_health_report', + parameters: { + type: Type.OBJECT, + description: 'Analyzes health logs from the past 7 days and provides a summary.', + properties: {} + } + } +]; + +export const executeHealthTool = async (name: string, args: any) => { + const today = new Date().toISOString().split('T')[0]; + + if (name === 'log_health_metric') { + const log = await StorageService.getHealthLog(today); + + if (args.metric === 'water') { + const glasses = parseInt(args.value) || 1; + log.waterGlasses += glasses; + await StorageService.saveHealthLog(log); + return { result: `Logged ${glasses} glass(es) of water. Total today: ${log.waterGlasses}.` }; + } + + if (args.metric === 'sleep') { + const hours = parseInt(args.value) || 7; + log.sleepHours = hours; + await StorageService.saveHealthLog(log); + return { result: `Updated sleep log to ${hours} hours.` }; + } + + if (args.metric === 'mood') { + // rudimentary mapping + const moodMap: any = { 'happy': 'Happy', 'sad': 'Tired', 'stressed': 'Stressed', 'neutral': 'Neutral', 'tired': 'Tired', 'angry': 'Stressed' }; + const mood = moodMap[args.value.toLowerCase()] || 'Neutral'; + log.mood = mood; + await StorageService.saveHealthLog(log); + return { result: `Mood logged as ${mood}.` }; + } + } + + if (name === 'check_environment') { + if (args.type === 'aqi') { + const aqi = await AirQualityService.getAQI(); + return { result: `The AQI in ${aqi.location} is ${aqi.aqi} (${aqi.status}). ${aqi.advice}` }; + } + if (args.type === 'weather') { + const w = await AirQualityService.getWeather(); + return { result: `It is currently ${w.condition} and ${w.temp}°C in ${w.location}.` }; + } + } + + if (name === 'get_weekly_health_report') { + let totalWater = 0; + let totalSleep = 0; + let daysCount = 0; + + // Check last 7 days + for (let i = 0; i < 7; i++) { + const d = new Date(); + d.setDate(d.getDate() - i); + const dateStr = d.toISOString().split('T')[0]; + const log = await StorageService.getHealthLog(dateStr); + // Only count if data exists (default is 0 water) + if (log.waterGlasses > 0 || log.sleepHours > 0) { + totalWater += log.waterGlasses; + totalSleep += log.sleepHours; + daysCount++; + } + } + + if (daysCount === 0) return { result: "I don't have enough data from the past week to generate a report yet. Start logging today!" }; + + const avgWater = (totalWater / daysCount).toFixed(1); + const avgSleep = (totalSleep / daysCount).toFixed(1); + + let advice = "Your stats look stable."; + if (parseFloat(avgWater) < 6) advice = "You need to hydrate more often."; + if (parseFloat(avgSleep) < 7) advice += " Your sleep duration is below the recommended 7 hours."; + + return { result: `Weekly Average: ${avgWater} glasses of water and ${avgSleep} hours of sleep per day. ${advice}` }; + } + + return null; +}; \ No newline at end of file diff --git a/ai-voice-model/tools-kitchen.ts b/ai-voice-model/tools-kitchen.ts new file mode 100644 index 0000000..21a9fa4 --- /dev/null +++ b/ai-voice-model/tools-kitchen.ts @@ -0,0 +1,146 @@ + +import { FunctionDeclaration, Type } from '@google/genai'; +import { StorageService } from '../services/storageService'; + +export const KITCHEN_TOOLS: FunctionDeclaration[] = [ + { + name: 'find_recipe', + parameters: { + type: Type.OBJECT, + description: 'Searches for a recipe in the database. If not found, it automatically signals to consult the AI Chef.', + properties: { + dish_name: { type: Type.STRING, description: 'Name of the dish (e.g., Momo, Gundruk).' } + }, + required: ['dish_name'] + } + }, + { + name: 'suggest_recipe_by_ingredients', + parameters: { + type: Type.OBJECT, + description: 'Suggests recipes based on available ingredients.', + properties: { + ingredients: { + type: Type.ARRAY, + items: { type: Type.STRING }, + description: 'List of ingredients user has (e.g., ["rice", "lentils"]).' + } + }, + required: ['ingredients'] + } + }, + { + name: 'draft_new_recipe', + parameters: { + type: Type.OBJECT, + description: 'Drafts a new recipe entry based on a food name. User will fill ingredients later.', + properties: { + dish_name: { type: Type.STRING, description: 'The name of the dish the user wants to add.' } + }, + required: ['dish_name'] + } + }, + { + name: 'consult_bhanse_dai', + parameters: { + type: Type.OBJECT, + description: 'Explicitly consults the AI Chef (Bhanse Dai) for cooking advice or recipes.', + properties: { + query: { type: Type.STRING, description: 'The cooking question or dish name.' } + }, + required: ['query'] + } + } +]; + +export const executeKitchenTool = async (name: string, args: any, navigate: (path: string, state?: any) => void) => { + if (name === 'find_recipe') { + const query = args.dish_name.toLowerCase(); + const recipes = await StorageService.getRecipes(); + + const found = recipes.find(r => + r.title.toLowerCase().includes(query) || + r.tags?.some(t => t.includes(query)) + ); + + if (found) { + navigate('/recipes'); + // Trigger event to open modal in Recipes page + setTimeout(() => { + window.dispatchEvent(new CustomEvent('rudraksha-open-recipe', { + detail: { recipeId: found.id } + })); + }, 500); + return { result: `Found recipe for ${found.title}. Opening kitchen.` }; + } else { + // Not found locally, redirect to Bhanse Dai via SIGNAL + navigate('/recipes'); + setTimeout(() => { + window.dispatchEvent(new CustomEvent('rudraksha-consult-chef', { + detail: { query: args.dish_name } + })); + }, 800); + + // CRITICAL: Return specific signal to terminate voice session in RudraAI.tsx + return { result: "HANDOFF_TO_CHEF" }; + } + } + + if (name === 'consult_bhanse_dai') { + navigate('/recipes'); + setTimeout(() => { + window.dispatchEvent(new CustomEvent('rudraksha-consult-chef', { + detail: { query: args.query } + })); + }, 800); + return { result: "HANDOFF_TO_CHEF" }; + } + + if (name === 'suggest_recipe_by_ingredients') { + const userIngs: string[] = args.ingredients.map((i: string) => i.toLowerCase()); + const recipes = await StorageService.getRecipes(); + + const matches = recipes.filter(r => { + // Check if recipe contains ANY of the user ingredients + return r.ingredients.some(ri => userIngs.some(ui => ri.toLowerCase().includes(ui))); + }); + + if (matches.length > 0) { + const topMatch = matches[0]; + navigate('/recipes'); + setTimeout(() => { + window.dispatchEvent(new CustomEvent('rudraksha-open-recipe', { + detail: { recipeId: topMatch.id } + })); + }, 500); + return { result: `Based on ${userIngs.join(', ')}, I recommend making ${topMatch.title}. Opening recipe now.` }; + } else { + // Fallback to chef if no local match + navigate('/recipes'); + setTimeout(() => { + window.dispatchEvent(new CustomEvent('rudraksha-consult-chef', { + detail: { query: `Something with ${args.ingredients.join(', ')}` } + })); + }, 800); + return { result: "HANDOFF_TO_CHEF" }; + } + } + + if (name === 'draft_new_recipe') { + navigate('/recipes'); + + const title = args.dish_name; + // Rudra generates a placeholder description here to be helpful + const description = `A traditional preparation of ${title}. Known for its rich flavor and cultural significance in Nepal.`; + + setTimeout(() => { + window.dispatchEvent(new CustomEvent('rudraksha-draft-recipe', { + detail: { title, description } + })); + }, 500); + + return { result: `Opened the recipe editor for "${title}". I've filled in the basics, please add your ingredients and secret steps.` }; + } + + return null; +}; diff --git a/ai-voice-model/tools-navigation.ts b/ai-voice-model/tools-navigation.ts new file mode 100644 index 0000000..b62ecfa --- /dev/null +++ b/ai-voice-model/tools-navigation.ts @@ -0,0 +1,173 @@ + +import { FunctionDeclaration, Type } from '@google/genai'; + +export const NAVIGATION_TOOLS: FunctionDeclaration[] = [ + { + name: 'navigate_app', + parameters: { + type: Type.OBJECT, + description: 'Navigates to a specific section of the application.', + properties: { + target: { + type: Type.STRING, + description: 'The destination keyword (e.g., "assignment", "calendar", "arcade", "home").' + } + }, + required: ['target'] + } + }, + { + name: 'launch_game', + parameters: { + type: Type.OBJECT, + description: 'Opens a specific game directly within the Arcade.', + properties: { + game_name: { + type: Type.STRING, + description: 'The name of the game (e.g., Speed Zone, Danphe Rush, Memory Shore).' + } + }, + required: ['game_name'] + } + }, + { + name: 'control_map_view', + parameters: { + type: Type.OBJECT, + description: 'Controls the Heritage Map. Use this when asked about "Introduction of [Site]", "Explain [Province]", or "Show me [Site]".', + properties: { + target_name: { + type: Type.STRING, + description: 'Name of the province or heritage site.' + }, + type: { + type: Type.STRING, + enum: ['province', 'site', 'reset'], + description: 'Type of location.' + } + }, + required: ['target_name', 'type'] + } + }, + { + name: 'start_navigation', + parameters: { + type: Type.OBJECT, + description: 'Initiates a navigation request to a physical location or finds nearest places (Google Maps).', + properties: { + destination: { type: Type.STRING, description: 'The name of the place to go or "nearest [place]".' } + }, + required: ['destination'] + } + } +]; + +export const executeNavigationTool = async (name: string, args: any, navigate: (path: string, state?: any) => void) => { + if (name === 'navigate_app') { + const t = args.target.toLowerCase(); + + // Comprehensive Fuzzy Intent Mapping + const mappings: Record = { + 'home': '/', + 'dashboard': '/', + 'main': '/', + 'planner': '/planner', + 'task': '/planner', + 'library': '/library', + 'book': '/library', + 'calendar': '/culture', + 'date': '/culture', + 'holiday': '/culture', + 'culture': '/culture', + 'map': '/map', + 'kitchen': '/recipes', + 'recipe': '/recipes', + 'community': '/community-chat', + 'chat': '/community-chat', + 'health': '/health', + 'safety': '/safety', + 'arcade': '/arcade', + 'game': '/arcade', + 'rewards': '/rewards', + 'shop': '/rewards', + 'settings': '/settings', + 'profile': '/profile', + 'guru': '/study-buddy', + 'buddy': '/study-buddy' + }; + + let path = mappings[t]; + + if (!path) { + if (t.includes('assign') || t.includes('home') || t.includes('work')) path = '/planner'; + else if (t.includes('book')) path = '/library'; + else if (t.includes('map')) path = '/map'; + else if (t.includes('game') || t.includes('play')) path = '/arcade'; + else { + const foundKey = Object.keys(mappings).find(key => t.includes(key)); + if (foundKey) path = mappings[foundKey]; + } + } + + if (path) { + navigate(path); + return { result: `Success: Navigating to ${t}.` }; + } + return { result: `Error: Destination "${args.target}" not recognized.` }; + } + + if (name === 'launch_game') { + const gameQuery = args.game_name.toLowerCase(); + const gameMap: Record = { + 'speed': 'speed', + 'danphe': 'danphe', + 'memory': 'memory', + 'focus': 'attention', + 'agility': 'flexibility', + 'logic': 'problem' + }; + + const gameId = Object.keys(gameMap).find(k => gameQuery.includes(k)); + if (gameId) { + navigate('/arcade', { state: { autoLaunch: gameMap[gameId] } }); + return { result: `Success: Launching ${args.game_name}.` }; + } + return { result: `Error: Game module "${args.game_name}" not found.` }; + } + + if (name === 'control_map_view') { + navigate('/map'); + + setTimeout(() => { + window.dispatchEvent(new CustomEvent('rudraksha-map-control', { + detail: { type: args.type, targetName: args.target_name } + })); + }, 800); + + // CRITICAL: Return a prompt for the AI to start explaining + return { + result: `Opened map and focused on ${args.target_name}. Please provide a brief introduction and historical significance of ${args.target_name} now.` + }; + } + + if (name === 'start_navigation') { + const dest = args.destination.toLowerCase(); + + // Handle "Nearest" logic - Google Maps supports "nearest X" queries natively in search mode + // If user says "nearest hospital", we pass "hospital" to the search intent which works better for nearby places + + if (dest.includes('nearest') || dest.includes('nearby')) { + // Open Google Maps Search for the place + window.open(`https://www.google.com/maps/search/${encodeURIComponent(dest)}`, '_blank'); + return { result: `Searching for ${dest} on Google Maps.` }; + } else { + // Send event to RudraAI to show overlay for specific navigation + window.dispatchEvent(new CustomEvent('rudraksha-nav-start', { + detail: { destination: args.destination, mode: 'w' } + })); + return { result: `Destination acquired: ${args.destination}. Navigation overlay initialized.` }; + } + } + + return null; +}; diff --git a/ai-voice-model/tools-rewards.ts b/ai-voice-model/tools-rewards.ts new file mode 100644 index 0000000..5508716 --- /dev/null +++ b/ai-voice-model/tools-rewards.ts @@ -0,0 +1,58 @@ +import { FunctionDeclaration, Type } from '@google/genai'; +import { StorageService } from '../services/storageService'; + +export const REWARDS_TOOLS: FunctionDeclaration[] = [ + { + name: 'check_karma_balance', + parameters: { + type: Type.OBJECT, + description: 'Checks the user\'s current Karma points balance.', + properties: {} + } + }, + { + name: 'redeem_reward_item', + parameters: { + type: Type.OBJECT, + description: 'Redeems a reward item from the Karma Bazaar.', + properties: { + keyword: { type: Type.STRING, description: 'Keyword of the item (e.g., "tree", "dog", "gold frame").' } + }, + required: ['keyword'] + } + } +]; + +export const executeRewardsTool = async (name: string, args: any, navigate: (path: string) => void) => { + if (name === 'check_karma_balance') { + const p = await StorageService.getProfile(); + return { result: `You currently have ${p?.points || 0} Karma points.` }; + } + + if (name === 'redeem_reward_item') { + const key = args.keyword.toLowerCase(); + let itemId = ''; + let cost = 0; + + // Simple keyword mapping for demo purposes + if (key.includes('dog')) { itemId = 'donate_dog'; cost = 100; } + else if (key.includes('tree')) { itemId = 'donate_tree'; cost = 250; } + else if (key.includes('orphan')) { itemId = 'donate_orphan'; cost = 1000; } + else if (key.includes('gold') && key.includes('frame')) { itemId = 'frame_gold'; cost = 200; } + else if (key.includes('adventurer')) { itemId = 'pack_adventurer'; cost = 500; } + + if (itemId) { + const res = await StorageService.redeemReward(itemId, cost); + if (res.success) { + navigate('/rewards'); + return { result: `Successfully redeemed ${args.keyword}. Karma deducted.` }; + } else { + return { result: `Redemption failed: ${res.error}` }; + } + } + + return { result: `I couldn't find a reward item matching "${args.keyword}". Try visiting the Bazaar.` }; + } + + return null; +}; \ No newline at end of file diff --git a/ai-voice-model/tools-safety.ts b/ai-voice-model/tools-safety.ts new file mode 100644 index 0000000..d0bb939 --- /dev/null +++ b/ai-voice-model/tools-safety.ts @@ -0,0 +1,83 @@ + +import { FunctionDeclaration, Type } from '@google/genai'; +import { StorageService } from '../services/storageService'; +import { FTLMission } from '../types'; + +export const SAFETY_TOOLS: FunctionDeclaration[] = [ + { + name: 'check_safety_alerts', + parameters: { + type: Type.OBJECT, + description: 'Checks for active emergency alerts or lost items in the vicinity.', + properties: { + radius_km: { type: Type.NUMBER, description: 'Search radius in km (optional).' } + } + } + }, + { + name: 'report_lost_found_item', + parameters: { + type: Type.OBJECT, + description: 'Drafts a report for a lost or found item/person/pet. Requires specific location details.', + properties: { + status: { + type: Type.STRING, + enum: ['lost', 'found'], + description: 'Whether the item was lost or found.' + }, + category: { + type: Type.STRING, + enum: ['pet', 'person', 'object'], + description: 'Category of the report.' + }, + description: { + type: Type.STRING, + description: 'Short description of the item (e.g. "Black Labrador", "Red wallet").' + }, + location: { + type: Type.STRING, + description: 'Specific location including city (e.g. "Central Park, Kathmandu").' + } + }, + required: ['status', 'category', 'description', 'location'] + } + } +]; + +export const executeSafetyTool = async (name: string, args: any, navigate: (path: string) => void) => { + if (name === 'check_safety_alerts') { + const missions = await StorageService.getMissions(); + const active = missions.filter(m => m.status === 'active'); + + if (active.length === 0) { + return { result: "There are no active emergency alerts in your vicinity. Systems normal." }; + } + + navigate('/safety'); + const titles = active.slice(0, 3).map(m => m.title).join(", "); + return { result: `There are ${active.length} active alerts. Most recent: ${titles}. Opening Safety protocol.` }; + } + + if (name === 'report_lost_found_item') { + // Navigate to safety page + navigate('/safety'); + + // Dispatch event to pre-fill the form on the UI + setTimeout(() => { + window.dispatchEvent(new CustomEvent('rudraksha-ftl-draft', { + detail: { + status: args.status, + category: args.category, + description: args.description, + location: args.location + } + })); + }, 800); // Small delay to allow navigation to complete + + return { + result: `Report drafted for ${args.status} ${args.description} at ${args.location}. Please upload a photo on the screen to confirm.` + }; + } + + return null; +}; diff --git a/ai-voice-model/tools-social.ts b/ai-voice-model/tools-social.ts new file mode 100644 index 0000000..ad6aebf --- /dev/null +++ b/ai-voice-model/tools-social.ts @@ -0,0 +1,118 @@ + +import { FunctionDeclaration, Type } from '@google/genai'; +import { StorageService } from '../services/storageService'; +import { PlatformService } from '../services/platformService'; + +export const SOCIAL_TOOLS: FunctionDeclaration[] = [ + { + name: 'post_community_message', + parameters: { + type: Type.OBJECT, + description: 'Posts a text message to the global community chat.', + properties: { + message: { type: Type.STRING, description: 'The text to post.' } + }, + required: ['message'] + } + }, + { + name: 'get_latest_messages', + parameters: { + type: Type.OBJECT, + description: 'Retrieves the most recent messages from the community chat.', + properties: { + limit: { type: Type.NUMBER, description: 'Number of messages to retrieve (default 3).' } + } + } + }, + { + name: 'send_direct_message', + parameters: { + type: Type.OBJECT, + description: 'Sends a direct message to a specific user within the app.', + properties: { + target_name: { type: Type.STRING, description: 'Name or username of the recipient.' }, + message: { type: Type.STRING, description: 'The message content.' }, + send_as_rudra: { type: Type.BOOLEAN, description: 'If true, the message is sent from the Rudra AI system account.' } + }, + required: ['target_name', 'message'] + } + }, + { + name: 'send_messenger_message', + parameters: { + type: Type.OBJECT, + description: 'Sends a message via Facebook Messenger (simulated). Checks if account is linked first.', + properties: { + recipient_name: { type: Type.STRING, description: 'Name of the friend on Facebook.' }, + message_body: { type: Type.STRING, description: 'Content of the message.' } + }, + required: ['recipient_name', 'message_body'] + } + } +]; + +export const executeSocialTool = async (name: string, args: any, navigate: (path: string) => void) => { + if (name === 'post_community_message') { + const result = await StorageService.sendCommunityMessage(args.message); + if (result) { + navigate('/community-chat'); + return { result: "Message posted to Global Chat." }; + } + return { result: "Failed to post message. Please try again." }; + } + + if (name === 'get_latest_messages') { + const msgs = await StorageService.getCommunityMessages(); + const limit = args.limit || 3; + const recent = msgs.slice(-limit).map(m => `${m.userName}: ${m.text}`).join('\n'); + return { result: `Recent chatter:\n${recent}` }; + } + + if (name === 'send_direct_message') { + const availableUsers = await StorageService.getAvailableUsers(); + const target = args.target_name.toLowerCase(); + + let user = availableUsers.find(u => + u.username?.toLowerCase() === target || + u.name.toLowerCase() === target + ); + + if (!user) { + user = availableUsers.find(u => + u.name.toLowerCase().includes(target) || + (u.username && u.username.toLowerCase().includes(target)) + ); + } + + if (user) { + const senderOverride = args.send_as_rudra ? 'rudra-ai-system' : undefined; + await StorageService.sendDirectMessage(user.id, args.message, 'text', { senderOverride }); + navigate('/community-chat'); + return { result: `Message successfully routed to ${user.name}${senderOverride ? " from Rudra Core" : ""}.` }; + } else { + return { result: `User identification failure: "${args.target_name}" not found in local user registry.` }; + } + } + + if (name === 'send_messenger_message') { + // Check if Facebook is linked + if (!PlatformService.isConnected('facebook')) { + return { result: "Facebook account is not linked. Please login to Facebook in the app settings or login page first." }; + } + + // Check if friend exists (Simulated) + const friend = PlatformService.findFriend('facebook', args.recipient_name); + + if (friend) { + // Open Messenger web interface as requested by user logic (simulated action) + // We can't actually control the tab, but we can open the URL + window.open(`https://www.messenger.com/t/${encodeURIComponent(args.recipient_name)}`, '_blank'); + return { result: `Opened Messenger for ${friend}. Message draft: "${args.message_body}". Please confirm send in the new tab.` }; + } else { + return { result: `Could not find "${args.recipient_name}" in your linked Facebook friend list.` }; + } + } + + return null; +}; diff --git a/ai-voice-model/tools-system.ts b/ai-voice-model/tools-system.ts new file mode 100644 index 0000000..4de0052 --- /dev/null +++ b/ai-voice-model/tools-system.ts @@ -0,0 +1,151 @@ + +import { FunctionDeclaration, Type } from '@google/genai'; +import { StorageService } from '../services/storageService'; + +export const SYSTEM_TOOLS: FunctionDeclaration[] = [ + { + name: 'configure_system', + parameters: { + type: Type.OBJECT, + description: 'Changes system settings like theme, focus mode, or language.', + properties: { + setting: { type: Type.STRING, enum: ['focus_mode', 'theme', 'language'] }, + value: { type: Type.STRING, description: 'Value for the setting (e.g., "on/off", "dark/light", "nepali/english").' } + }, + required: ['setting', 'value'] + } + }, + { + name: 'open_external_link', + parameters: { + type: Type.OBJECT, + description: 'Opens external websites, plays songs, or performs searches. For "Play [Song]", this uses a smart redirect to the video.', + properties: { + platform: { type: Type.STRING, description: 'The platform name (youtube, google, facebook, instagram, twitter, linkedin, spotify, wikipedia, etc.).' }, + action: { type: Type.STRING, enum: ['open', 'search', 'play'], description: 'The intent. Use "play" for media which attempts direct playback.' }, + query: { type: Type.STRING, description: 'The search term, song name, or content title.' } + }, + required: ['platform'] + } + }, + { + name: 'adjust_device_hardware', + parameters: { + type: Type.OBJECT, + description: 'Request to change device brightness or volume. Returns a placeholder response.', + properties: { + feature: { type: Type.STRING, enum: ['brightness', 'volume'] }, + value: { type: Type.NUMBER, description: 'The target level percentage (0-100).' } + }, + required: ['feature'] + } + }, + { + name: 'terminate_voice_session', + parameters: { + type: Type.OBJECT, + description: 'Ends the current voice conversation/link without logging out. Use when user says "stop listening", "terminate session", "cut link", "bye".', + properties: {} + } + }, + { + name: 'system_logout', + parameters: { + type: Type.OBJECT, + description: 'Logs the user out of the application completely. Requires explicit "Logout" command.', + properties: {} + } + } +]; + +export const executeSystemTool = async (name: string, args: any, navigate: (path: string) => void) => { + if (name === 'configure_system') { + if (args.setting === 'focus_mode') { + const turnOn = args.value.includes('on') || args.value.includes('active') || args.value.includes('start'); + localStorage.setItem('rudraksha_focus_mode', turnOn ? 'true' : 'false'); + window.dispatchEvent(new Event('rudraksha-focus-update')); + return { result: `Focus Mode ${turnOn ? 'Activated' : 'Deactivated'}.` }; + } + + if (args.setting === 'theme') { + const isDark = args.value.includes('dark') || args.value.includes('night'); + const profile = await StorageService.getProfile(); + if (profile) { + const newTheme = isDark ? 'theme_midnight' : 'default'; + await StorageService.updateProfile({ activeTheme: newTheme }); + window.dispatchEvent(new Event('rudraksha-profile-update')); + return { result: `Switched to ${isDark ? 'Dark' : 'Light'} visual theme.` }; + } + } + + if (args.setting === 'language') { + const isNepali = args.value.toLowerCase().includes('nep'); + localStorage.setItem('rudraksha_lang', isNepali ? 'ne' : 'en'); + window.location.reload(); + return { result: `Switching language to ${isNepali ? 'Nepali' : 'English'}...` }; + } + } + + if (name === 'open_external_link') { + const p = args.platform.toLowerCase(); + const q = args.query ? args.query.trim() : ''; + const encodedQ = encodeURIComponent(q); + let url = 'https://www.google.com'; + + // Safety Check (Basic SFW Filter) + const unsafeKeywords = ['porn', 'xxx', 'nsfw', 'sex', 'nude', 'erotic']; + if (unsafeKeywords.some(keyword => q.toLowerCase().includes(keyword))) { + return { result: "Access denied. Safety protocol active. Request contains unsafe content." }; + } + + // Dynamic Platform Handling + if (p.includes('youtube')) { + if (args.action === 'play' && q) { + // "I'm Feeling Lucky" logic to try and jump directly to the video + // Queries "site:youtube.com [query]" and presses the Lucky button. + // This often redirects to the first video result directly. + url = `https://www.google.com/search?q=site%3Ayoutube.com+${encodedQ}&btnI=1`; + } else if (q) { + url = `https://www.youtube.com/results?search_query=${encodedQ}`; + } else { + url = 'https://www.youtube.com'; + } + } else if (p.includes('google')) { + url = q ? `https://www.google.com/search?q=${encodedQ}` : 'https://www.google.com'; + } else if (p.includes('facebook')) { + url = 'https://www.facebook.com'; + } else if (p.includes('instagram')) { + url = 'https://www.instagram.com'; + } else if (p.includes('twitter') || p.includes('x.com')) { + url = 'https://twitter.com'; + } else if (p.includes('linkedin')) { + url = 'https://www.linkedin.com'; + } else if (p.includes('wikipedia')) { + url = q ? `https://en.wikipedia.org/wiki/${encodedQ}` : 'https://www.wikipedia.org'; + } else if (p.includes('tiktok')) { + url = 'https://www.tiktok.com'; + } else if (p.includes('spotify')) { + url = q ? `https://open.spotify.com/search/${encodedQ}` : 'https://open.spotify.com'; + } else { + url = `https://www.google.com/search?q=${p}+${q}`; + } + + window.open(url, '_blank'); + return { result: `System order executed: Opening ${args.platform} ${args.action === 'play' ? 'content' : 'interface'}.` }; + } + + if (name === 'adjust_device_hardware') { + return { result: "Feature coming soon. Hardware bridge not connected." }; + } + + // Handle special termination/logout actions in the UI layer (RudraAI.tsx) via event or return signal + if (name === 'terminate_voice_session') { + return { result: "TERMINATE_SIGNAL" }; // Special signal for UI to close connection + } + + if (name === 'system_logout') { + return { result: "LOGOUT_SIGNAL" }; // Special signal for UI to perform logout + } + + return null; +}; diff --git a/ai-voice-model/tools-yoga.ts b/ai-voice-model/tools-yoga.ts new file mode 100644 index 0000000..3fc0879 --- /dev/null +++ b/ai-voice-model/tools-yoga.ts @@ -0,0 +1,43 @@ + +import { FunctionDeclaration, Type } from '@google/genai'; + +export const YOGA_TOOLS: FunctionDeclaration[] = [ + { + name: 'control_yoga_session', + parameters: { + type: Type.OBJECT, + description: 'Controls the active Yoga/Meditation UI based on voice commands. Supports English ("Next", "Back", "Repeat", "Stop") and Nepali ("Arko/Aagadi", "Pachhi/Farka", "Pheri/Dohoryau", "Banda/Sakincha").', + properties: { + command: { + type: Type.STRING, + enum: ['next', 'prev', 'repeat', 'exit'], + description: 'The action to perform. Map "arko"/"next" to next, "pachhi"/"back" to prev, "pheri"/"repeat" to repeat, "banda"/"stop" to exit.' + } + }, + required: ['command'] + } + } +]; + +export const executeYogaTool = async (name: string, args: any) => { + if (name === 'control_yoga_session') { + const { command } = args; + + // Dispatch event to the UI (Health.tsx -> YogaSession component) + setTimeout(() => { + window.dispatchEvent(new CustomEvent('rudraksha-yoga-control', { + detail: { action: command } + })); + }, 500); + + const responses: Record = { + 'next': 'Moving to next step / अर्को चरण।', + 'prev': 'Going back / अघिल्लो चरण।', + 'repeat': 'Repeating instruction / फेरी सुन्नुहोस्।', + 'exit': 'Ending session. Namaste / सत्र समाप्त भयो। नमस्ते।' + }; + + return { result: responses[command] || "Command executed." }; + } + return null; +}; diff --git a/components/Layout.tsx b/components/Layout.tsx new file mode 100644 index 0000000..15734a1 --- /dev/null +++ b/components/Layout.tsx @@ -0,0 +1,423 @@ + +import React, { useState, useEffect } from 'react'; +import { NavLink, Outlet, useLocation, useNavigate, Navigate, Link } from 'react-router-dom'; +import { + LayoutDashboard, CheckSquare, ShieldAlert, Tent, Utensils, + Map as MapIcon, LogOut, Sun, Moon, Loader2, Leaf, + ShoppingBag, Menu, X, BarChart2, Gamepad2, Settings, + Palette, Library, RefreshCw, Laptop, MessageCircle, + Lock, Zap, Calendar as CalendarIcon, Bot, ChevronRight, Award +} from 'lucide-react'; +import { StorageService } from '../services/storageService'; +import { UserProfile } from '../types'; +import { useLanguage } from '../contexts/LanguageContext'; +import { THEME_REGISTRY } from '../config/themes'; +import { Logo } from './ui/Logo'; +import RudraAI from './RudraAI'; // Import from components folder +import confetti from 'canvas-confetti'; + +const getFrameStyle = (id?: string) => { + if (!id || id === 'none') return 'ring-2 ring-white/50 dark:ring-white/20'; + if (id === 'unicorn') return 'ring-4 ring-pink-400 shadow-[0_0_15px_#f472b6]'; + if (id === 'royal') return 'ring-4 ring-yellow-500 shadow-[0_0_20px_#eab308]'; + if (id === 'nature') return 'ring-4 ring-green-500 border-green-300'; + if (id === 'dark') return 'ring-4 ring-gray-800 shadow-[0_0_20px_#000]'; + if (id === 'frame_gold') return 'ring-4 ring-yellow-400 border-4 border-yellow-600 shadow-[0_0_25px_#eab308]'; + return 'ring-2 ring-indigo-400'; +}; + +const BadgeOverlay = () => { + const [badge, setBadge] = useState<{title: string, icon: any} | null>(null); + + useEffect(() => { + const handleUnlock = (e: any) => { + const { title, icon } = e.detail; + setBadge({ title, icon }); + + // Trigger confetti + const duration = 3000; + const animationEnd = Date.now() + duration; + const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 9999 }; + + const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min; + + const interval: any = setInterval(function() { + const timeLeft = animationEnd - Date.now(); + + if (timeLeft <= 0) { + return clearInterval(interval); + } + + const particleCount = 50 * (timeLeft / duration); + confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } }); + confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } }); + }, 250); + + // Auto dismiss + setTimeout(() => setBadge(null), 4000); + }; + + window.addEventListener('rudraksha-badge-unlock', handleUnlock); + return () => window.removeEventListener('rudraksha-badge-unlock', handleUnlock); + }, []); + + if (!badge) return null; + + return ( +
+
+
+
+
+
+ +
+
+

Achievement Unlocked

+

{badge.title}

+
+ +
+
+
+
+ ); +}; + +const Layout: React.FC = () => { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [loading, setLoading] = useState(true); + const [profile, setProfile] = useState(null); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + const [isFocusMode, setIsFocusMode] = useState(() => localStorage.getItem('rudraksha_focus_mode') === 'true'); + + // Easter Egg State + const [logoClicks, setLogoClicks] = useState(0); + + const [themeMode, setThemeMode] = useState<'system' | 'light' | 'dark'>(() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('themeMode'); + return (saved === 'light' || saved === 'dark' || saved === 'system') ? saved : 'dark'; + } + return 'dark'; + }); + + const navigate = useNavigate(); + const location = useLocation(); + const { t, language, setLanguage } = useLanguage(); + + useEffect(() => { + checkAuth(); + const handleUpdate = () => { + checkAuth(); + setIsFocusMode(localStorage.getItem('rudraksha_focus_mode') === 'true'); + }; + window.addEventListener('rudraksha-profile-update', handleUpdate); + window.addEventListener('rudraksha-focus-update', handleUpdate); + return () => { + window.removeEventListener('rudraksha-profile-update', handleUpdate); + window.removeEventListener('rudraksha-focus-update', handleUpdate); + }; + }, []); + + useEffect(() => { setIsSidebarOpen(false); }, [location.pathname]); + + useEffect(() => { + const root = window.document.documentElement; + const body = document.body; + if (body.getAttribute('data-theme-override')) return; + + const currentThemeId = profile?.activeTheme || 'default'; + const themeConfig = THEME_REGISTRY[currentThemeId] || THEME_REGISTRY['default']; + + let isDark = false; + if (themeConfig.uiMode !== 'auto') { + isDark = themeConfig.uiMode === 'dark'; + } else { + isDark = themeMode === 'system' ? window.matchMedia('(prefers-color-scheme: dark)').matches : themeMode === 'dark'; + } + + if (isDark) root.classList.add('dark'); + else root.classList.remove('dark'); + + Object.entries(themeConfig.colors).forEach(([key, value]) => root.style.setProperty(key, value)); + body.setAttribute('data-theme', currentThemeId); + + const bgColor = (isDark && themeConfig.darkBgColor) ? themeConfig.darkBgColor : themeConfig.bgColor; + const bgPattern = (isDark && themeConfig.darkBgPattern !== undefined) ? themeConfig.darkBgPattern : (themeConfig.bgPattern || 'none'); + + body.style.backgroundColor = bgColor; + body.style.backgroundImage = bgPattern; + body.style.backgroundAttachment = 'fixed'; + body.style.backgroundSize = 'cover'; + body.style.backgroundPosition = themeConfig.bgPosition || 'center'; + + if (themeConfig.isAnimated) body.classList.add('animate-neon-flow'); + else body.classList.remove('animate-neon-flow'); + + body.style.filter = isFocusMode ? 'saturate(0.7) contrast(1.1)' : 'none'; + localStorage.setItem('themeMode', themeMode); + }, [themeMode, profile?.activeTheme, location.pathname, isFocusMode]); + + const checkAuth = async () => { + const auth = await StorageService.isAuthenticated(); + const user = await StorageService.getProfile(); + setIsAuthenticated(auth); + setProfile(user); + setLoading(false); + }; + + const cycleThemeMode = () => { + const modes: ('system' | 'light' | 'dark')[] = ['system', 'light', 'dark']; + const nextIndex = (modes.indexOf(themeMode) + 1) % modes.length; + setThemeMode(modes[nextIndex]); + }; + + const handleRefresh = async () => { + setIsRefreshing(true); + window.dispatchEvent(new Event('rudraksha-profile-update')); + await checkAuth(); + setRefreshKey(prev => prev + 1); + setTimeout(() => setIsRefreshing(false), 800); + }; + + const toggleFocusMode = () => { + const newState = !isFocusMode; + localStorage.setItem('rudraksha_focus_mode', String(newState)); + setIsFocusMode(newState); + window.dispatchEvent(new Event('rudraksha-focus-update')); + }; + + const handleLogoClick = async (e: React.MouseEvent) => { + e.preventDefault(); + // Only count clicks if authenticated + if (!isAuthenticated || !profile) return; + + setLogoClicks(prev => prev + 1); + + // Easter Egg Trigger (5 clicks) + if (logoClicks + 1 === 5) { + // STRICT CHECK: Ensure profile data is loaded and badge isn't already owned + if (profile.unlockedItems?.includes('badge_secret')) { + console.log("Easter egg already claimed."); + setLogoClicks(0); + return; + } + + // If valid claim: + await StorageService.addPoints(500, 0, 'easter_egg', 'Secret Hunter'); + + // Manually update unlock list in DB immediately + const currentItems = profile.unlockedItems || []; + const updatedProfile = await StorageService.updateProfile({ + unlockedItems: [...currentItems, 'badge_secret'] + }); + + // Update local state to reflect change immediately (prevents double tap) + if(updatedProfile) setProfile(updatedProfile); + + // Trigger Visuals + window.dispatchEvent(new CustomEvent('rudraksha-badge-unlock', { + detail: { title: 'Secret Hunter', icon: 'spy' } + })); + + setLogoClicks(0); + } + + // Reset after 2 seconds of no click + setTimeout(() => setLogoClicks(0), 2000); + }; + + if (loading) return
; + if (!isAuthenticated && location.pathname !== '/auth' && location.pathname !== '/welcome') return ; + + const isTeacher = profile?.role === 'teacher'; + const isStudent = profile?.role === 'student'; + + const navGroups = [ + { items: [{ path: '/', label: t('Dashboard', 'Dashboard'), icon: LayoutDashboard }] }, + { + title: t('ACADEMIC', 'ACADEMIC'), + condition: isStudent || isTeacher, + items: [ + { path: '/study-buddy', label: t('Rudra AI', 'Rudra AI'), icon: Bot }, + { path: '/planner', label: t('Assignments', 'Assignments'), icon: CheckSquare }, + { path: '/library', label: t('Library', 'Library'), icon: Library }, + ] + }, + { + title: 'CULTURE', + condition: !isFocusMode, + items: [ + { path: '/culture', label: t('Calendar', 'Calendar'), icon: CalendarIcon }, + { path: '/map', label: t('Map & Provinces', 'Map & Provinces'), icon: MapIcon }, + { path: '/recipes', label: t('Kitchen', 'Kitchen'), icon: Utensils }, + ] + }, + { + title: 'COMMUNITY', + condition: !isFocusMode, + items: [ + { path: '/community-chat', label: t('Network', 'Network'), icon: MessageCircle }, + { path: '/health', label: t('Wellness', 'Wellness'), icon: Leaf }, + { path: '/safety', label: t('FTL Rescue', 'FTL Rescue'), icon: ShieldAlert }, + { path: '/rewards', label: t('Karma Bazaar', 'Karma Bazaar'), icon: ShoppingBag }, + { path: '/arcade', label: t('Arcade', 'Arcade'), icon: Gamepad2 }, + ] + } + ]; + + const NavContent = ({ onItemClick }: { onItemClick?: () => void }) => ( +
+ {navGroups.map((group, idx) => { + if (group.condition === false) return null; + return ( +
+ {group.title && ( +

+ {group.title} +

+ )} +
+ {group.items.map((item) => ( + ` + flex items-center justify-between px-4 py-3 rounded-2xl text-sm font-black italic transition-all duration-300 group + ${isActive + ? 'bg-white/80 dark:bg-white/10 text-indigo-700 dark:text-indigo-400 shadow-lg backdrop-blur-md translate-x-1 border border-indigo-100/50' + : 'text-gray-700 dark:text-gray-300 hover:bg-white/40 dark:hover:bg-white/5 hover:translate-x-1'} + `} + > +
+ + {item.label} +
+ +
+ ))} +
+
+ ); + })} +
+ ); + + const UserProfileCard = () => ( +
+
+
+ + + + + + +
+ +
+ + Avatar +
+

{profile?.name || 'User'}

+
🏆 {profile?.points || 0} pts
+
+ +
+ ); + + return ( +
+ + + + {/* Desktop Sidebar */} + + +
+
+ + {/* Mobile Header - Visible only on mobile */} +
+
+
0 ? 'scale-110' : ''} transition-transform`}> + {isFocusMode ? : } +
+ + {isFocusMode ? Deep Work : 'Rudraksha'} + +
+ +
+ +
+ +
+ + {!isFocusMode && ( + + )} +
+
+ + {/* Global Rudra AI Button */} + {!isFocusMode && } + + {/* Mobile Sidebar Overlay */} + {isSidebarOpen && ( +
setIsSidebarOpen(false)}>
+ )} + + {/* Mobile Sidebar Drawer */} +
+
+ setIsSidebarOpen(false)} className="flex items-center gap-3 font-black text-xl text-red-700 dark:text-red-500 uppercase italic"> +
+ {isFocusMode ? : } +
+ Rudraksha + + +
+ + +
+
+ ); +}; + +export default Layout; diff --git a/components/ProvinceMap.tsx b/components/ProvinceMap.tsx new file mode 100644 index 0000000..449a1e8 --- /dev/null +++ b/components/ProvinceMap.tsx @@ -0,0 +1,496 @@ + +import React, { useState, useEffect, useRef } from 'react'; +import { Map as MapIcon, Grid, Layout, Maximize2, X, MapPin, Users, Mountain, Landmark, Droplets, BookOpen, User, Info, ArrowRight } from 'lucide-react'; +import { useLanguage } from '../contexts/LanguageContext'; +import { Button } from './ui/Button'; + +declare const L: any; // Leaflet global + +interface ProvinceData { + id: number; + name: string; + nepaliName: string; + capital: string; + capitalNe: string; + districts: number; + area: string; + population: string; + density: string; + color: string; + borderColor: string; + description: string; + descriptionNe: string; + attractions: string[]; + attractionsNe: string[]; + image: string; + majorRivers: string; + majorRiversNe: string; + literacyRate: string; + mainLanguages: string; + mainLanguagesNe: string; + lat: number; + lng: number; +} + +const PROVINCES: ProvinceData[] = [ + { + id: 1, + name: "Koshi Province", + nepaliName: "कोशी प्रदेश", + capital: "Biratnagar", + capitalNe: "विराटनगर", + districts: 14, + area: "25,905 km²", + population: "4,972,021", + density: "192/km²", + color: "from-blue-600 to-cyan-500", + borderColor: "border-blue-500", + description: "Koshi Province is the easternmost region of Nepal, home to the world's highest peak, Mount Everest (8848m), and Kanchenjunga. It features diverse topography ranging from the Himalayas to the Terai plains.", + descriptionNe: "कोशी प्रदेश नेपालको सबैभन्दा पूर्वी क्षेत्र हो, जहाँ विश्वको सर्वोच्च शिखर सगरमाथा (८८४८ मिटर) र कञ्चनजङ्घा रहेका छन्।", + attractions: ["Mt. Everest", "Ilam Tea Gardens", "Koshi Tappu"], + attractionsNe: ["सगरमाथा", "इलाम चिया बगान", "कोशी टप्पु"], + image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAwerHQlJuN4y6uGM7CNrGK7ZUKJS2nNpqsZDaYnsbybQU-ifpLuEixZHiUk_xjhyVFWZTi4tePP5270Hs1plo7ogPt7FU9limroXpkWhe5EuZsrQG5eFRZ5qntE7tHwBBkzp3wayUWg=w675-h390-n-k-no", + majorRivers: "Koshi, Arun", + majorRiversNe: "कोशी, अरुण", + literacyRate: "71.2%", + mainLanguages: "Nepali, Maithili", + mainLanguagesNe: "नेपाली, मैथिली", + lat: 26.4525, + lng: 87.2718 + }, + { + id: 2, + name: "Madhesh Province", + nepaliName: "मधेश प्रदेश", + capital: "Janakpur", + capitalNe: "जनकपुर", + districts: 8, + area: "9,661 km²", + population: "6,126,288", + density: "635/km²", + color: "from-orange-500 to-yellow-500", + borderColor: "border-orange-500", + description: "The smallest province by area but rich in culture and history. It is the heart of Mithila culture and agriculture.", + descriptionNe: "क्षेत्रफलको हिसाबले सबैभन्दा सानो तर संस्कृति र इतिहासमा धनी प्रदेश। यो मिथिला संस्कृतिको मुटु हो।", + attractions: ["Janaki Mandir", "Mithila Art", "Parsa National Park"], + attractionsNe: ["जानकी मन्दिर", "मिथिला कला", "पर्सा राष्ट्रिय निकुञ्ज"], + image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAweo75bHM1SIYpxf2VNcZJ-svV5H5cMa_A-t80RRtkmqVB1jIbThYeNcJ0117uTZvThVmFlRkPoqEYlm0H3zQk-ZadZbCSLsruwQU2_GdqMlZR4Oj2W_bW1dTvWCnEKJXPiQWtoQ=w675-h390-n-k-no", + majorRivers: "Bagmati, Kamala", + majorRiversNe: "बागमती, कमला", + literacyRate: "49.5%", + mainLanguages: "Maithili, Bhojpuri", + mainLanguagesNe: "मैथिली, भोजपुरी", + lat: 26.7288, + lng: 85.9274 + }, + { + id: 3, + name: "Bagmati Province", + nepaliName: "बागमती प्रदेश", + capital: "Hetauda", + capitalNe: "हेटौंडा", + districts: 13, + area: "20,300 km²", + population: "6,084,042", + density: "300/km²", + color: "from-red-600 to-pink-600", + borderColor: "border-red-600", + description: "Home to the federal capital, Kathmandu. It bridges the northern Himalayas with the southern plains and hosts major UNESCO Heritage sites.", + descriptionNe: "संघीय राजधानी काठमाडौंको घर। यसले उत्तरी हिमाललाई दक्षिणी मैदानसँग जोड्छ र प्रमुख युनेस्को सम्पदा स्थलहरू समावेश गर्दछ।", + attractions: ["Kathmandu Valley", "Chitwan National Park", "Langtang"], + attractionsNe: ["काठमाडौं उपत्यका", "चितवन राष्ट्रिय निकुञ्ज", "लाङटाङ"], + image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAwepmdUwyFb1HIu4u53bB3TzJv32oVXMmcSSgCSOa-tP5FibBJt0mvV3TMmXm6z_rkLvDfAUZqkLbcOZbNtOSPqeLfddmVXhPb1tkUYqxrOI7We1Q6ZpG-WKdQoAORTBWNhjPEcJs=w675-h390-n-k-no", + majorRivers: "Bagmati, Trishuli", + majorRiversNe: "बागमती, त्रिशुली", + literacyRate: "74.8%", + mainLanguages: "Nepali, Newari", + mainLanguagesNe: "नेपाली, नेवारी", + lat: 27.4292, + lng: 85.0325 + }, + { + id: 4, + name: "Gandaki Province", + nepaliName: "गण्डकी प्रदेश", + capital: "Pokhara", + capitalNe: "पोखरा", + districts: 11, + area: "21,504 km²", + population: "2,479,745", + density: "116/km²", + color: "from-emerald-500 to-green-600", + borderColor: "border-emerald-500", + description: "The tourism capital of Nepal. Gandaki province houses the majestic Annapurna range and is the gateway to world-famous treks.", + descriptionNe: "नेपालको पर्यटन राजधानी। गण्डकी प्रदेशमा अन्नपूर्ण हिमशृङ्खला रहेको छ र यो पदयात्राको प्रवेशद्वार हो।", + attractions: ["Pokhara", "Annapurna Circuit", "Muktinath"], + attractionsNe: ["पोखरा", "अन्नपूर्ण सर्किट", "मुक्तिनाथ"], + image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAwerE0IehvUGkr-ri0Y2JqJShK2aUrKraupRmfZ72Odnx1pg9Cwnpw2QpOdWiDmNBvwo6mloNYK7wwRqcZT1UyCmBSOkQHNuEwyCVi4aJv4_Myl8yXls6Am8abAOjnviRGBJ4Pnhn=w675-h390-n-k-no", + majorRivers: "Kali Gandaki, Seti", + majorRiversNe: "काली गण्डकी, सेती", + literacyRate: "74.8%", + mainLanguages: "Nepali, Gurung", + mainLanguagesNe: "नेपाली, गुरुङ", + lat: 28.2380, + lng: 83.9956 + }, + { + id: 5, + name: "Lumbini Province", + nepaliName: "लुम्बिनी प्रदेश", + capital: "Deukhuri", + capitalNe: "देउखुरी", + districts: 12, + area: "22,288 km²", + population: "5,124,225", + density: "230/km²", + color: "from-indigo-500 to-blue-600", + borderColor: "border-indigo-500", + description: "The birthplace of Lord Buddha. Lumbini province holds immense historical and spiritual significance.", + descriptionNe: "भगवान बुद्धको जन्मस्थल। लुम्बिनी प्रदेशको ठूलो ऐतिहासिक र आध्यात्मिक महत्व छ।", + attractions: ["Lumbini", "Bardiya National Park", "Palpa"], + attractionsNe: ["लुम्बिनी", "बर्दिया राष्ट्रिय निकुञ्ज", "पाल्पा"], + image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAweolNbMBfCLEcb-JoP1BKkohrqTtq1WsIQGDfBApeCx_ZwqiyMM1IwwUGB6-72BPDxuA7XB5dRAjYO_kBbgFOagQ4Pmtib72cDKbVuFLT-BsO7YGwhuRrLM1EInLL1HF49XR4dZ-Eg=w675-h390-n-k-no", + majorRivers: "Rapti, Babai", + majorRiversNe: "राप्ती, बबई", + literacyRate: "66.4%", + mainLanguages: "Nepali, Tharu", + mainLanguagesNe: "नेपाली, थारु", + lat: 27.8099, + lng: 82.5186 + }, + { + id: 6, + name: "Karnali Province", + nepaliName: "कर्णाली प्रदेश", + capital: "Birendranagar", + capitalNe: "वीरेन्द्रनगर", + districts: 10, + area: "27,984 km²", + population: "1,694,889", + density: "61/km²", + color: "from-teal-600 to-emerald-700", + borderColor: "border-teal-600", + description: "The largest yet least populated province. Karnali is a remote, rugged, and breathtakingly beautiful region home to Rara Lake.", + descriptionNe: "सबैभन्दा ठूलो तर कम जनसंख्या भएको प्रदेश। कर्णाली एक दुर्गम र सुन्दर क्षेत्र हो जहाँ रारा ताल अवस्थित छ।", + attractions: ["Rara Lake", "Shey Phoksundo", "Upper Dolpo"], + attractionsNe: ["रारा ताल", "शे-फोक्सुण्डो", "माथिल्लो डोल्पा"], + image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAwerD0R7t4jLghiYVAR2QcK15I2yM0SSWbSFlLVu2WRHDPFwNwxu72zabWeuY3vci3z0Kfa0B2CE9O5gDVlbSomlE8v06Hs6g4-VQ81SD_3SvW7U2sKs_m0oXmcyMYYEKqaIHASdAaA=w675-h390-n-k-no", + majorRivers: "Karnali, Bheri", + majorRiversNe: "कर्णाली, भेरी", + literacyRate: "62.7%", + mainLanguages: "Nepali, Magar", + mainLanguagesNe: "नेपाली, मगर", + lat: 28.6010, + lng: 81.6369 + }, + { + id: 7, + name: "Sudurpashchim Province", + nepaliName: "सुदूरपश्चिम प्रदेश", + capital: "Godawari", + capitalNe: "गोदावरी", + districts: 9, + area: "19,999 km²", + population: "2,711,270", + density: "136/km²", + color: "from-purple-600 to-violet-600", + borderColor: "border-purple-600", + description: "Located in the far west, this province is rich in unspoiled natural beauty and unique Deuda culture.", + descriptionNe: "सुदूर पश्चिममा अवस्थित, यो प्रदेश अछुतो प्राकृतिक सौन्दर्य र देउडा संस्कृतिमा धनी छ।", + attractions: ["Khaptad", "Shuklaphanta", "Api Nampa"], + attractionsNe: ["खप्तड", "शुक्लाफाँटा", "अपि नाम्पा"], + image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAweqa0Vgu8fP66KID9-7yelqNiZ4ivnejIRzctBevCwH43INkW4-IjlVZWpEBwv_yh1LrmiGyRFsuKheyKoPY0JEmX8u8GnrzCnrrwSTHwrCiiwL6CzYP5nH3Jc-4r0WmlBNXg2FH=w675-h390-n-k-no", + majorRivers: "Mahakali, Seti", + majorRiversNe: "महाकाली, सेती", + literacyRate: "63.5%", + mainLanguages: "Doteli, Tharu", + mainLanguagesNe: "डोटेली, थारु", + lat: 28.8475, + lng: 80.5638 + } +]; + +const ProvinceMap: React.FC = () => { + const { t, language } = useLanguage(); + const [viewMode, setViewMode] = useState<'cards' | 'map'>('cards'); + const [selectedProvince, setSelectedProvince] = useState(null); + const mapRef = useRef(null); + const mapInstance = useRef(null); + + useEffect(() => { + if (viewMode === 'map' && mapRef.current) { + if (!mapInstance.current) { + initMap(); + } else { + mapInstance.current.invalidateSize(); + } + } + }, [viewMode]); + + const initMap = () => { + if (!mapRef.current || typeof L === 'undefined') return; + + mapInstance.current = L.map(mapRef.current).setView([28.3949, 84.1240], 7); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap contributors © CARTO' + }).addTo(mapInstance.current); + + PROVINCES.forEach(prov => { + const iconHtml = ` +
+
+ +
+
+ ${prov.id} +
+
+ `; + + const icon = L.divIcon({ + html: iconHtml, + className: 'bg-transparent', + iconSize: [40, 40], + iconAnchor: [20, 20] + }); + + const marker = L.marker([prov.lat, prov.lng], { icon }); + + const popupContent = ` +
+
+ +
+

${language === 'ne' ? prov.nepaliName : prov.name}

+

${language === 'ne' ? prov.capitalNe : prov.capital}

+ +
+ `; + + marker.bindPopup(popupContent); + marker.on('popupopen', () => { + const btn = document.getElementById(`btn-prov-${prov.id}`); + if (btn) btn.onclick = () => setSelectedProvince(prov); + }); + marker.addTo(mapInstance.current); + }); + }; + + const localizeNumber = (value: string | number): string => { + if (language === 'en') return value.toString(); + const str = value.toString(); + const devanagariDigits = ['०', '१', '२', '३', '४', '५', '६', '७', '८', '९']; + return str.replace(/[0-9]/g, (match) => devanagariDigits[parseInt(match)]); + }; + + return ( +
+ {/* View Toggle Header */} +
+
+

+ {t("Province Overview", "Province Overview")} +

+

+ {t("Explore the 7 distinct provinces of Nepal.", "Explore the 7 distinct provinces of Nepal.")} +

+
+ + {/* Scrollable Tabs System - Removed no-scrollbar for PC discoverability */} +
+ + +
+
+ + {/* Main Content Area - Flexible height to prevent clamping on PC */} +
+ {viewMode === 'cards' ? ( +
+ {PROVINCES.map(prov => ( +
setSelectedProvince(prov)} + className="group bg-white dark:bg-gray-800/40 backdrop-blur-sm rounded-3xl overflow-hidden shadow-xl border border-white/10 hover:border-blue-500/50 transition-all duration-300 cursor-pointer hover:-translate-y-1" + > +
+ {prov.name} +
+
+ +
+
+ {t("Province", "Province")} {prov.id} +
+
+ +
+

+ {language === 'ne' ? prov.nepaliName : prov.name} +

+
+
+ +
+
+
+ {language === 'ne' ? prov.capitalNe : prov.capital} +
+
+ {localizeNumber(prov.districts)} Districts +
+
+ +

+ "{language === 'ne' ? prov.descriptionNe : prov.description}" +

+ + +
+
+ ))} +
+ ) : ( +
+
+
+

Map Legend

+
+ {PROVINCES.map(p => ( +
{ + mapInstance.current?.flyTo([p.lat, p.lng], 9); + setSelectedProvince(p); + }}> +
+ {language === 'ne' ? p.nepaliName : p.name} +
+ ))} +
+
+
+ )} +
+ + {/* Detail Modal */} + {selectedProvince && ( +
+
setSelectedProvince(null)}>
+
+ + +
+ {selectedProvince.name} +
+
+ +
+ + {t("Province", "Province")} {localizeNumber(selectedProvince.id)} Official Data + +

{language === 'en' ? selectedProvince.name : selectedProvince.nepaliName}

+
+
+

{t("Area", "Area")}

+

{localizeNumber(selectedProvince.area)}

+
+
+
+

{t("Population", "Population")}

+

{localizeNumber(selectedProvince.population)}

+
+
+
+
+ +
+
+
+

{t("Administrative Capital", "Administrative Capital")}

+

+

+ {language === 'en' ? selectedProvince.capital : selectedProvince.capitalNe} +

+
+
+

{t("Districts", "Districts")}

+

+

+ {localizeNumber(selectedProvince.districts)} Units +

+
+
+ +
+
+

+ Regional Overview +

+

+ "{language === 'en' ? selectedProvince.description : selectedProvince.descriptionNe}" +

+
+ +
+
+

+ Landmark Sites +

+
    + {(language === 'en' ? selectedProvince.attractions : selectedProvince.attractionsNe).map((attr, idx) => ( +
  • +
    + {attr} +
  • + ))} +
+
+ +
+
+

Vital Waterways

+

{language === 'en' ? selectedProvince.majorRivers : selectedProvince.majorRiversNe}

+
+
+

Literacy Index

+

{localizeNumber(selectedProvince.literacyRate)}

+
+
+

Primary Dialects

+

{language === 'en' ? selectedProvince.mainLanguages : selectedProvince.mainLanguagesNe}

+
+
+
+
+
+
+
+ )} +
+ ); +}; + +export default ProvinceMap; diff --git a/components/RudraAI.tsx b/components/RudraAI.tsx new file mode 100644 index 0000000..edd2638 --- /dev/null +++ b/components/RudraAI.tsx @@ -0,0 +1,755 @@ + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { + X, MessageSquare, Mic, Send, + Loader2, AudioWaveform, StopCircle, + Radio, Cpu, AlertCircle, RefreshCw, ShieldCheck, + GripVertical, MapPin, Navigation, Sparkles, Globe +} from 'lucide-react'; +import { Logo } from './ui/Logo'; +import { StorageService } from '../services/storageService'; +import { connectToGuruLive, encodeAudio, decodeAudio, decodeAudioData, requestMicPermission, translateText } from '../services/geminiService'; +import { ChatMessage, TaskStatus } from '../types'; +import { Modality, LiveServerMessage, Blob, GoogleGenAI } from '@google/genai'; +import { useNavigate } from 'react-router-dom'; +import { GET_ROBOTIC_SYSTEM_INSTRUCTION } from '../ai-voice-model/ai'; +import { AI_LANGUAGES } from '../ai-voice-model/languages'; +import { ALL_RUDRA_TOOLS, executeRudraTool } from '../ai-voice-model/tool-registry'; +import { useLanguage } from '../contexts/LanguageContext'; + +const RudraAI: React.FC = () => { + const navigate = useNavigate(); + const { t, language } = useLanguage(); + const [isOpen, setIsOpen] = useState(false); + const [mode, setMode] = useState<'chat' | 'voice'>('voice'); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [isTyping, setIsTyping] = useState(false); + const [isTranslating, setIsTranslating] = useState(false); + + // Position & Drag State - Responsive defaults + const [pos, setPos] = useState({ x: window.innerWidth - 80, y: window.innerHeight - 140 }); + const [dragging, setDragging] = useState(false); + const dragOffset = useRef({ x: 0, y: 0 }); + const dragStartPos = useRef({ x: 0, y: 0 }); + const hasMovedRef = useRef(false); + + // Voice Session State + const [hasMicPermission, setHasMicPermission] = useState(null); + const hasMicPermissionRef = useRef(null); + const [isLiveActive, setIsLiveActive] = useState(false); + const [liveStatus, setLiveStatus] = useState<'Idle' | 'Connecting' | 'Listening' | 'Speaking' | 'Error'>('Idle'); + const [errorMsg, setErrorMsg] = useState(null); + const [userTranscript, setUserTranscript] = useState(''); + const [aiTranscript, setAiTranscript] = useState(''); + + // Navigation Overlay State + const [navOverlay, setNavOverlay] = useState<{destination: string, mode: string} | null>(null); + + // Refs + const liveSessionPromiseRef = useRef | null>(null); + const inputAudioContextRef = useRef(null); + const outputAudioContextRef = useRef(null); + const nextStartTimeRef = useRef(0); + const audioSourcesRef = useRef>(new Set()); + const micStreamRef = useRef(null); + const scrollRef = useRef(null); + + // Background Wake Word Engine + const wakeWordRecognitionRef = useRef(null); + const isMicLockedRef = useRef(false); + + // State Refs for Event Handlers + const isLiveActiveRef = useRef(isLiveActive); + useEffect(() => { isLiveActiveRef.current = isLiveActive; }, [isLiveActive]); + + useEffect(() => { hasMicPermissionRef.current = hasMicPermission; }, [hasMicPermission]); + + // Load Persistence + useEffect(() => { + const history = StorageService.getGlobalChatHistory(); + if (history && history.length > 0) { + setMessages(history); + } + }, []); + + // Save Persistence + useEffect(() => { + if (messages.length > 0) { + StorageService.saveGlobalChatHistory(messages); + } + if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + }, [messages, isTyping, isTranslating]); + + // Auto-Translate Logic on Language Toggle + useEffect(() => { + const handleLanguageSwitch = async () => { + if (messages.length === 0 || isTyping || isLiveActive) return; + + const lastMsg = messages[messages.length - 1]; + // Only translate if the last message was from the bot and isn't a system action + if (lastMsg.role === 'model' && !lastMsg.text.startsWith('[SYSTEM')) { + setIsTranslating(true); + try { + const translated = await translateText(lastMsg.text, language); + setMessages(prev => { + const newMsgs = [...prev]; + newMsgs[newMsgs.length - 1] = { ...lastMsg, text: translated }; + return newMsgs; + }); + } catch (e) { + console.error("Translation failed", e); + } finally { + setIsTranslating(false); + } + } + }; + handleLanguageSwitch(); + }, [language]); + + useEffect(() => { + // Initial permission check against App Settings + const checkPermissions = async () => { + const settings = await StorageService.getSettings(); + if (settings.permissions.microphone) { + if (navigator.permissions && (navigator.permissions as any).query) { + (navigator.permissions as any).query({ name: 'microphone' }).then((result: any) => { + if (result.state === 'granted') { + setHasMicPermission(true); + initWakeWord(); + } else if (result.state === 'denied') { + setHasMicPermission(false); + } + }); + } else { + setHasMicPermission(true); + initWakeWord(); + } + } else { + setHasMicPermission(false); + } + }; + checkPermissions(); + + const handleResize = () => { + setPos(prev => ({ + x: Math.min(prev.x, window.innerWidth - 70), + y: Math.min(prev.y, window.innerHeight - 120) + })); + }; + window.addEventListener('resize', handleResize); + + // Listen for navigation commands + const handleNavStart = (e: any) => { + setNavOverlay(e.detail); + setIsOpen(true); + }; + window.addEventListener('rudraksha-nav-start', handleNavStart); + + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('rudraksha-nav-start', handleNavStart); + }; + }, []); + + const onMouseDown = (e: React.MouseEvent | React.TouchEvent) => { + setDragging(true); + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + dragOffset.current = { x: clientX - pos.x, y: clientY - pos.y }; + }; + + useEffect(() => { + const onMouseMove = (e: MouseEvent | TouchEvent) => { + if (!dragging) return; + hasMovedRef.current = true; + const clientX = 'touches' in e ? e.touches[0].clientX : (e as MouseEvent).clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : (e as MouseEvent).clientY; + const newX = Math.max(10, Math.min(window.innerWidth - 60, clientX - dragOffset.current.x)); + const newY = Math.max(10, Math.min(window.innerHeight - 110, clientY - dragOffset.current.y)); + setPos({ x: newX, y: newY }); + }; + const onMouseUp = () => setDragging(false); + if (dragging) { + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + window.addEventListener('touchmove', onMouseMove); + window.addEventListener('touchend', onMouseUp); + } + return () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + window.removeEventListener('touchmove', onMouseMove); + window.removeEventListener('touchend', onMouseUp); + }; + }, [dragging]); + + // --- WAKE WORD LOGIC --- + const startLiveConversationRef = useRef<(context?: string) => Promise>(async () => {}); + + const stopWakeWord = useCallback(() => { + if (wakeWordRecognitionRef.current) { + try { + wakeWordRecognitionRef.current.onend = null; + wakeWordRecognitionRef.current.stop(); + wakeWordRecognitionRef.current = null; + } catch (e) {} + } + }, []); + + const initWakeWord = useCallback(() => { + if (isMicLockedRef.current || isLiveActiveRef.current) return; + + const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + if (!SpeechRecognition) return; + + if (wakeWordRecognitionRef.current) return; + + try { + const recognition = new SpeechRecognition(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = 'en-US'; + + recognition.onresult = (event: any) => { + if (isLiveActiveRef.current || isMicLockedRef.current) return; + + for (let i = event.resultIndex; i < event.results.length; ++i) { + const text = event.results[i][0].transcript.toLowerCase().trim(); + const isSecretTrigger = text.includes("oi baiman baccha") || text.includes("oi baiman bacha") || text.includes("ओई बेईमान बच्चा"); + const isStandardTrigger = text.includes("hey rudra") || text.includes("rudra") || text.includes("ai babu"); + + if (isSecretTrigger || isStandardTrigger) { + stopWakeWord(); + setIsOpen(true); + setMode('voice'); + + const audio = new Audio('https://assets.mixkit.co/active_storage/sfx/2869/2869-preview.mp3'); + audio.volume = 0.5; + audio.play().catch(() => {}); + + const triggerContext = isSecretTrigger + ? " [SYSTEM EVENT: User activated via secret phrase 'Oi Baiman Baccha'. Respond ONLY with 'Jwajalapa Ama!' immediately.]" + : undefined; + + setTimeout(() => { + if (startLiveConversationRef.current) { + startLiveConversationRef.current(triggerContext); + } + }, 100); + return; + } + } + }; + + recognition.onend = () => { + wakeWordRecognitionRef.current = null; + if (!isLiveActiveRef.current && !isMicLockedRef.current) { + try { setTimeout(() => initWakeWord(), 1000); } catch (e) {} + } + }; + + recognition.start(); + wakeWordRecognitionRef.current = recognition; + } catch (e) { } + }, []); + + useEffect(() => { + const handleMicLock = (e: any) => { + const { state } = e.detail; + isMicLockedRef.current = state; + if (state) { + stopWakeWord(); + } else { + setTimeout(() => initWakeWord(), 1500); + } + }; + + window.addEventListener('rudraksha-mic-lock', handleMicLock); + + return () => { + stopWakeWord(); + window.removeEventListener('rudraksha-mic-lock', handleMicLock); + }; + }, [initWakeWord, stopWakeWord]); + + const handleGrantPermission = async () => { + const settings = await StorageService.getSettings(); + await StorageService.saveSettings({ + ...settings, + permissions: { ...settings.permissions, microphone: true } + }); + + const success = await requestMicPermission(); + setHasMicPermission(success); + if (success) { + setErrorMsg(null); + initWakeWord(); + } else { + setErrorMsg("Microphone access is required for voice features."); + } + return success; + }; + + // --- GEMINI LIVE LOGIC --- + + const startLiveConversation = async (initialContext?: string) => { + if (isLiveActive) return; + + const settings = await StorageService.getSettings(); + if (!settings.permissions.microphone) { + setHasMicPermission(false); + setErrorMsg("Privacy Mode Active. Microphone access disabled in Settings."); + return; + } + + if (!hasMicPermissionRef.current) { + const granted = await handleGrantPermission(); + if (!granted) return; + } + + stopWakeWord(); + window.dispatchEvent(new CustomEvent('rudraksha-mic-lock', { detail: { state: true } })); + + setLiveStatus('Connecting'); + setIsLiveActive(true); + setUserTranscript(''); + setAiTranscript(''); + setErrorMsg(null); + + await new Promise(r => setTimeout(r, 800)); + + try { + let stream: MediaStream | null = null; + for (let i = 0; i < 3; i++) { + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + break; + } catch (err) { + if (i === 2) throw new Error("Microphone busy."); + await new Promise(r => setTimeout(r, 1000)); + } + } + micStreamRef.current = stream; + + inputAudioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 16000 }); + outputAudioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 24000 }); + + await inputAudioContextRef.current.resume(); + await outputAudioContextRef.current.resume(); + + // Fetch Mood Log + const today = new Date().toISOString().split('T')[0]; + const healthLog = await StorageService.getHealthLog(today); + const moodContext = healthLog ? `[USER CONTEXT: Current recorded mood is ${healthLog.mood}. Adjust tone accordingly.]` : ""; + + const systemInstructionWithContext = GET_ROBOTIC_SYSTEM_INSTRUCTION(language) + moodContext + (initialContext || ""); + + const sessionPromise = connectToGuruLive({ + model: 'gemini-2.5-flash-native-audio-preview-12-2025', + config: { + responseModalities: [Modality.AUDIO], + systemInstruction: systemInstructionWithContext, + tools: ALL_RUDRA_TOOLS, + speechConfig: { + voiceConfig: { prebuiltVoiceConfig: { voiceName: AI_LANGUAGES[language].voiceName } } + }, + inputAudioTranscription: {}, + outputAudioTranscription: {} + }, + callbacks: { + onopen: () => { + if (!inputAudioContextRef.current || !micStreamRef.current) return; + const source = inputAudioContextRef.current.createMediaStreamSource(micStreamRef.current); + const scriptProcessor = inputAudioContextRef.current.createScriptProcessor(4096, 1, 1); + scriptProcessor.onaudioprocess = (e) => { + const inputData = e.inputBuffer.getChannelData(0); + const pcmBlob = createPcmBlob(inputData); + sessionPromise.then(session => { + if (session) session.sendRealtimeInput({ media: pcmBlob }); + }).catch(() => {}); + }; + source.connect(scriptProcessor); + scriptProcessor.connect(inputAudioContextRef.current.destination); + setLiveStatus('Listening'); + }, + onmessage: async (message: LiveServerMessage) => { + if (message.toolCall) { + for (const fc of message.toolCall.functionCalls) { + try { + const result = await executeRudraTool(fc.name, fc.args, navigate); + + // Handle special UI signals + if (result === "TERMINATE_SIGNAL") { + stopLiveConversation(); + return; + } + if (result === "LOGOUT_SIGNAL") { + stopLiveConversation(); + await StorageService.logout(); + navigate('/auth'); + return; + } + if (result === "HANDOFF_TO_CHEF") { + // Automatically terminate voice session and close UI overlay + stopLiveConversation(); + setIsOpen(false); + return; + } + + sessionPromise.then(session => { + if (session) { + session.sendToolResponse({ + functionResponses: { + id: fc.id, + name: fc.name, + response: { result: result } + } + }); + } + }); + // Add system response to chat history + setMessages(prev => [...prev, { + id: Date.now().toString(), + role: 'model', + text: `[SYSTEM ACTION] ${result}`, + timestamp: Date.now() + }]); + } catch (toolError) { + console.error("Tool execution failed:", toolError); + } + } + } + + const audioData = message.serverContent?.modelTurn?.parts?.[0]?.inlineData?.data; + if (audioData && outputAudioContextRef.current) { + setLiveStatus('Speaking'); + nextStartTimeRef.current = Math.max(nextStartTimeRef.current, outputAudioContextRef.current.currentTime); + const buffer = await decodeAudioData(decodeAudio(audioData), outputAudioContextRef.current, 24000, 1); + const source = outputAudioContextRef.current.createBufferSource(); + source.buffer = buffer; + source.connect(outputAudioContextRef.current.destination); + source.onended = () => { + audioSourcesRef.current.delete(source); + if (audioSourcesRef.current.size === 0) setLiveStatus('Listening'); + }; + source.start(nextStartTimeRef.current); + nextStartTimeRef.current += buffer.duration; + audioSourcesRef.current.add(source); + } + + // Transcripts are captured but NOT displayed in UI as per request + if (message.serverContent?.inputTranscription) { + const text = message.serverContent.inputTranscription.text || ''; + setUserTranscript(text); + } + if (message.serverContent?.outputTranscription) { + setAiTranscript(prev => `${prev}${message.serverContent?.outputTranscription?.text}`); + } + if (message.serverContent?.interrupted) { + audioSourcesRef.current.forEach(s => { try { s.stop(); } catch(e) {} }); + audioSourcesRef.current.clear(); + nextStartTimeRef.current = 0; + setLiveStatus('Listening'); + } + }, + onerror: (e) => { + console.error("Neural Voice Error:", e); + setErrorMsg("Hardware link interrupted. Network or API error."); + stopLiveConversation(); + }, + onclose: () => stopLiveConversation(), + } + }); + liveSessionPromiseRef.current = sessionPromise; + } catch (err: any) { + console.error("Neural Voice Link Failure:", err); + setLiveStatus('Error'); + setErrorMsg(err.message || "Network Error."); + setTimeout(() => stopLiveConversation(), 3000); + } + }; + + useEffect(() => { + startLiveConversationRef.current = startLiveConversation; + }); + + const stopLiveConversation = () => { + setIsLiveActive(false); + setLiveStatus('Idle'); + liveSessionPromiseRef.current?.then(s => { try { s.close(); } catch(e) {} }); + if (micStreamRef.current) { + micStreamRef.current.getTracks().forEach(t => t.stop()); + micStreamRef.current = null; + } + audioSourcesRef.current.forEach(s => { try { s.stop(); } catch(e) {} }); + audioSourcesRef.current.clear(); + + if (inputAudioContextRef.current && inputAudioContextRef.current.state !== 'closed') { + inputAudioContextRef.current.close().catch(() => {}); + } + if (outputAudioContextRef.current && outputAudioContextRef.current.state !== 'closed') { + outputAudioContextRef.current.close().catch(() => {}); + } + + inputAudioContextRef.current = null; + outputAudioContextRef.current = null; + nextStartTimeRef.current = 0; + + window.dispatchEvent(new CustomEvent('rudraksha-mic-lock', { detail: { state: false } })); + setTimeout(() => initWakeWord(), 1500); + }; + + const createPcmBlob = (data: Float32Array): Blob => { + const l = data.length; + const int16 = new Int16Array(l); + for (let i = 0; i < l; i++) int16[i] = data[i] * 32768; + return { + data: encodeAudio(new Uint8Array(int16.buffer)), + mimeType: 'audio/pcm;rate=16000', + }; + }; + + // --- UI INTERACTION --- + + const onTriggerStart = (e: React.MouseEvent | React.TouchEvent) => { + if (e.type === 'mousedown' && (e as React.MouseEvent).button !== 0) return; + setDragging(true); + hasMovedRef.current = false; + const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY; + dragStartPos.current = { x: clientX, y: clientY }; + dragOffset.current = { x: clientX - pos.x, y: clientY - pos.y }; + }; + + const handleOpen = () => { + if (!hasMovedRef.current) { + setIsOpen(!isOpen); + } + }; + + const handleChatSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || isTyping) return; + + const userMsg: ChatMessage = { id: Date.now().toString(), role: 'user', text: input, timestamp: Date.now() }; + setMessages(prev => [...prev, userMsg]); + const currentInput = input; + setInput(''); + setIsTyping(true); + + try { + const aiClient = new GoogleGenAI({ apiKey: process.env.API_KEY }); + const res = await aiClient.models.generateContent({ + model: 'gemini-3-flash-preview', + contents: currentInput, + config: { + systemInstruction: GET_ROBOTIC_SYSTEM_INSTRUCTION(language), + tools: ALL_RUDRA_TOOLS + } + }); + + if (res.functionCalls) { + for (const fc of res.functionCalls) { + const result = await executeRudraTool(fc.name, fc.args, navigate); + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', text: `Action Taken: ${result}`, timestamp: Date.now() }]); + } + } else { + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', text: res.text || "Command accepted.", timestamp: Date.now() }]); + } + } catch { + setMessages(prev => [...prev, { id: 'err', role: 'model', text: "Signal Interrupted.", timestamp: Date.now() }]); + } finally { setIsTyping(false); } + }; + + const getMapEmbedUrl = (dest: string, mode: string) => { + const m = mode.toLowerCase(); + return `https://www.google.com/maps?saddr=Current+Location&daddr=${encodeURIComponent(dest)}&dirflg=${m.charAt(0)}&output=embed`; + }; + + return ( +
+ {isOpen && ( +
e.stopPropagation()}> + {/* HEADER */} +
+
+
+ +
+
+

+ Rudra Core +

+
+
+ {isLiveActive ? liveStatus : 'System Ready'} +
+
+
+
+ + +
+
+ +
+ + {navOverlay ? ( +
+
+
+ + {navOverlay.destination} +
+ {navOverlay.mode} +
+