Spaces:
Sleeping
Sleeping
| import React, { useEffect, useState, useRef, useCallback } from "react"; | |
| import { useStore } from "../state/store"; | |
| import { useNavigate } from "react-router-dom"; | |
| import "./Orbit.css"; | |
| const API_BASE = process.env.REACT_APP_API_BASE || ""; | |
| export function Orbit() { | |
| const { setUi } = useStore(); | |
| const navigate = useNavigate(); | |
| const [isActive, setIsActive] = useState(false); | |
| const [position, setPosition] = useState({ x: 100, y: 100 }); | |
| const [size, setSize] = useState(30); | |
| const synthRef = useRef(window.speechSynthesis); | |
| const recognitionRef = useRef(null); | |
| const sleepTimeoutRef = useRef(null); | |
| const draggingRef = useRef(false); | |
| const resizingRef = useRef(false); | |
| const offsetRef = useRef({ x: 0, y: 0 }); | |
| const audioRef = useRef(null); | |
| // Gesture refs | |
| const lastTapRef = useRef(0); | |
| const longPressTimerRef = useRef(null); | |
| const isLongPressRef = useRef(false); | |
| const disableSleepRef = useRef(false); | |
| // π¨ Orb visual state | |
| const setOrbStyle = useCallback((state) => { | |
| const root = document.documentElement; | |
| const styles = { | |
| dormant: { | |
| "--orb-core": "rgba(100,100,255,0.3)", | |
| "--orb-glow": "rgba(80,80,220,0.2)", | |
| "--orb-ray": "transparent", | |
| "--orb-scale": "0.8", | |
| "--orb-opacity": "0.4", | |
| }, | |
| wake: { | |
| "--orb-core": "rgba(120,120,255,0.8)", | |
| "--orb-glow": "rgba(100,100,255,0.5)", | |
| "--orb-ray": "transparent", | |
| "--orb-scale": "1", | |
| "--orb-opacity": "1", | |
| }, | |
| listening: { | |
| "--orb-core": "rgba(0,230,255,0.9)", | |
| "--orb-glow": "rgba(0,200,255,0.6)", | |
| "--orb-ray": "transparent", | |
| "--orb-scale": "1.1", | |
| "--orb-opacity": "1", | |
| }, | |
| thinking: { | |
| "--orb-core": "rgba(255,180,0,0.8)", | |
| "--orb-glow": "rgba(255,160,0,0.5)", | |
| "--orb-ray": "transparent", | |
| "--orb-scale": "1.05", | |
| "--orb-opacity": "1", | |
| }, | |
| speaking: { | |
| "--orb-core": "rgba(255,241,118,0.9)", | |
| "--orb-glow": "rgba(255,225,100,0.5)", | |
| "--orb-ray": "rgba(255,241,118,0.3)", | |
| "--orb-scale": "1", | |
| "--orb-opacity": "1", | |
| }, | |
| error: { | |
| "--orb-core": "rgba(255,80,80,0.8)", | |
| "--orb-glow": "rgba(220,60,60,0.5)", | |
| "--orb-ray": "transparent", | |
| "--orb-scale": "1", | |
| "--orb-opacity": "1", | |
| }, | |
| warning: { | |
| "--orb-core": "rgba(255,165,0,0.8)", | |
| "--orb-glow": "rgba(255,140,0,0.5)", | |
| "--orb-ray": "transparent", | |
| "--orb-scale": "1", | |
| "--orb-opacity": "1", | |
| }, | |
| }; | |
| const selected = styles[state] || styles["dormant"]; | |
| Object.keys(selected).forEach((key) => root.style.setProperty(key, selected[key])); | |
| }, []); | |
| // π΄ Stop & sleep | |
| const stopAll = useCallback(() => { | |
| if (synthRef.current.speaking) synthRef.current.cancel(); | |
| if (recognitionRef.current) { | |
| try { recognitionRef.current.stop(); } catch {} | |
| } | |
| setUi((u) => ({ ...u, orbit: "dormant" })); | |
| setOrbStyle("dormant"); | |
| setIsActive(false); | |
| if (sleepTimeoutRef.current) clearTimeout(sleepTimeoutRef.current); | |
| }, [setUi, setOrbStyle]); | |
| // π£ Speak | |
| const speak = useCallback((text, after) => { | |
| if (!text) return; | |
| setOrbStyle("speaking"); | |
| setUi((u) => ({ ...u, orbit: "speaking" })); | |
| const utter = new SpeechSynthesisUtterance(text); | |
| utter.onend = () => { | |
| if (isActive) { | |
| setOrbStyle("listening"); | |
| setUi((u) => ({ ...u, orbit: "listening" })); | |
| if (!disableSleepRef.current) resetSleepTimer(); | |
| } else { | |
| setOrbStyle("dormant"); | |
| } | |
| if (after) after(); | |
| }; | |
| utter.onerror = () => setOrbStyle("error"); | |
| synthRef.current.speak(utter); | |
| }, [isActive, setUi, setOrbStyle]); | |
| // β² Sleep timer | |
| const resetSleepTimer = useCallback(() => { | |
| if (disableSleepRef.current) return; | |
| if (sleepTimeoutRef.current) clearTimeout(sleepTimeoutRef.current); | |
| sleepTimeoutRef.current = setTimeout(() => { | |
| speak("Going to sleep", stopAll); | |
| }, 15000); | |
| }, [speak, stopAll]); | |
| // π¬ Send text to backend chat | |
| const queryBackend = async (message) => { | |
| try { | |
| setOrbStyle("thinking"); | |
| setUi((u) => ({ ...u, orbit: "thinking" })); | |
| const token = localStorage.getItem("token"); | |
| const formData = new FormData(); | |
| formData.append("text", message); | |
| const res = await fetch(`${API_BASE}/v1/chat`, { | |
| method: "POST", | |
| headers: { Authorization: `Bearer ${token}` }, | |
| body: formData, | |
| }); | |
| const data = await res.json(); | |
| const reply = data.reply || "I donβt have an answer right now."; | |
| speak(reply); | |
| } catch (err) { | |
| console.error("Backend error:", err); | |
| speak("Sorry, I couldnβt reach the knowledge base."); | |
| setOrbStyle("warning"); | |
| setUi((u) => ({ ...u, orbit: "warning" })); | |
| } | |
| }; | |
| // π Speech Recognition | |
| useEffect(() => { | |
| const SR = window.SpeechRecognition || window.webkitSpeechRecognition; | |
| if (!SR) return console.warn("SpeechRecognition not supported"); | |
| recognitionRef.current = new SR(); | |
| recognitionRef.current.continuous = true; | |
| recognitionRef.current.interimResults = false; | |
| recognitionRef.current.lang = "en-US"; | |
| recognitionRef.current.onresult = (e) => { | |
| const transcript = e.results[e.results.length - 1][0].transcript.trim(); | |
| console.log("Heard:", transcript); | |
| if (!isActive) return; | |
| if (transcript.toLowerCase().includes("go to sleep")) { | |
| speak("Going to sleep", stopAll); | |
| return; | |
| } | |
| queryBackend(transcript); | |
| }; | |
| recognitionRef.current.onend = () => recognitionRef.current.start(); | |
| recognitionRef.current.start(); | |
| return () => recognitionRef.current && recognitionRef.current.stop(); | |
| }, [isActive, stopAll, speak]); | |
| // π Tap/longpress handlers | |
| const handlePointerDown = () => { | |
| const now = Date.now(); | |
| if (now - lastTapRef.current < 300) { | |
| // Double tap β go to chat | |
| navigate("/chat"); | |
| } | |
| lastTapRef.current = now; | |
| longPressTimerRef.current = setTimeout(() => { | |
| isLongPressRef.current = true; | |
| disableSleepRef.current = true; | |
| setIsActive(true); | |
| setOrbStyle("wake"); | |
| setUi((u) => ({ ...u, orbit: "listening" })); | |
| if (audioRef.current) audioRef.current.play().catch(() => {}); | |
| speak("Long press mode active. I'm listening continuously."); | |
| }, 600); | |
| }; | |
| const handlePointerUp = () => { | |
| if (longPressTimerRef.current) clearTimeout(longPressTimerRef.current); | |
| if (isLongPressRef.current) { | |
| isLongPressRef.current = false; | |
| disableSleepRef.current = false; | |
| resetSleepTimer(); | |
| speak("Long press released. Sleep timer re-enabled."); | |
| } | |
| }; | |
| // π± Drag + resize | |
| const handleMouseDown = (e, mode) => { | |
| if (mode === "drag") draggingRef.current = true; | |
| if (mode === "resize") resizingRef.current = true; | |
| offsetRef.current = { x: e.clientX, y: e.clientY }; | |
| }; | |
| const handleMouseMove = (e) => { | |
| if (draggingRef.current) { | |
| setPosition((pos) => { | |
| const newPos = { x: pos.x + (e.clientX - offsetRef.current.x), y: pos.y + (e.clientY - offsetRef.current.y) }; | |
| offsetRef.current = { x: e.clientX, y: e.clientY }; | |
| return newPos; | |
| }); | |
| } | |
| if (resizingRef.current) { | |
| setSize((s) => Math.max(80, s + (e.clientX - offsetRef.current.x))); | |
| offsetRef.current = { x: e.clientX, y: e.clientY }; | |
| } | |
| }; | |
| const handleMouseUp = () => { | |
| draggingRef.current = false; | |
| resizingRef.current = false; | |
| }; | |
| useEffect(() => { | |
| window.addEventListener("mousemove", handleMouseMove); | |
| window.addEventListener("mouseup", handleMouseUp); | |
| return () => { | |
| window.removeEventListener("mousemove", handleMouseMove); | |
| window.removeEventListener("mouseup", handleMouseUp); | |
| }; | |
| }, []); | |
| // πΎ Save/load state | |
| useEffect(() => { | |
| const saved = JSON.parse(localStorage.getItem("orbit_state") || "{}"); | |
| if (saved.x && saved.y) setPosition({ x: saved.x, y: saved.y }); | |
| if (saved.size) setSize(saved.size); | |
| }, []); | |
| useEffect(() => { | |
| localStorage.setItem("orbit_state", JSON.stringify({ ...position, size })); | |
| }, [position, size]); | |
| return ( | |
| <div | |
| className="orbit-wrapper" | |
| style={{ left: position.x, top: position.y, width: size, height: size }} | |
| > | |
| <audio ref={audioRef} src="/orbit.wav" preload="auto" /> | |
| <div | |
| className="orbit" | |
| onMouseDown={(e) => handleMouseDown(e, "drag")} | |
| onPointerDown={handlePointerDown} | |
| onPointerUp={handlePointerUp} | |
| title="Orbit Assistant" | |
| /> | |
| <div | |
| className="orbit-resize-handle" | |
| onMouseDown={(e) => handleMouseDown(e, "resize")} | |
| /> | |
| </div> | |
| ); | |
| } | |