Spaces:
Sleeping
Sleeping
| 'use client'; | |
| import { useState, useRef, useEffect } from 'react'; | |
| import { X, Bot, Sparkles, Send, Shield, BookOpen, Heart, Brain, ChevronDown, Eye, EyeOff } from 'lucide-react'; | |
| import ThinkingIndicator from './chat/ThinkingIndicator'; | |
| import MarkdownMessage from './chat/MarkdownMessage'; | |
| interface Message { | |
| role: 'user' | 'assistant'; | |
| content: string; | |
| agent?: string; | |
| trace?: TraceStep[]; | |
| timestamp?: Date; | |
| } | |
| interface TraceStep { | |
| agent: string; | |
| action: string; | |
| result?: string; | |
| } | |
| const agentIcons: Record<string, typeof Shield> = { | |
| Gatekeeper: Shield, | |
| Theologian: BookOpen, | |
| Healer: Heart, | |
| Orchestrator: Brain, | |
| System: Sparkles, | |
| }; | |
| const agentColors: Record<string, { bg: string; border: string; text: string }> = { | |
| Gatekeeper: { bg: 'bg-purple-500/20', border: 'border-purple-500/30', text: 'text-purple-400' }, | |
| Theologian: { bg: 'bg-blue-500/20', border: 'border-blue-500/30', text: 'text-blue-400' }, | |
| Healer: { bg: 'bg-rose-500/20', border: 'border-rose-500/30', text: 'text-rose-400' }, | |
| Orchestrator: { bg: 'bg-amber-500/20', border: 'border-amber-500/30', text: 'text-amber-400' }, | |
| System: { bg: 'bg-neutral-500/20', border: 'border-neutral-500/30', text: 'text-neutral-400' }, | |
| }; | |
| export default function ChatWidget() { | |
| const [chatOpen, setChatOpen] = useState(false); | |
| const [messages, setMessages] = useState<Message[]>([ | |
| { | |
| role: 'assistant', | |
| content: "Hello, I'm **ORA**, your AI spiritual companion. I'm here to help you explore Scripture, reflect on your faith, and grow in wisdom. How may I serve you today?", | |
| agent: 'Gatekeeper', | |
| timestamp: new Date(), | |
| } | |
| ]); | |
| const [input, setInput] = useState(''); | |
| const [isTyping, setIsTyping] = useState(false); | |
| const [activeAgent, setActiveAgent] = useState<string>('Gatekeeper'); | |
| const [showTrace, setShowTrace] = useState<Record<number, boolean>>({}); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| const scrollToBottom = () => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); | |
| }; | |
| useEffect(() => { | |
| scrollToBottom(); | |
| }, [messages, isTyping]); | |
| const handleSend = async () => { | |
| if (!input.trim()) return; | |
| const userMsg: Message = { | |
| role: 'user', | |
| content: input, | |
| timestamp: new Date(), | |
| }; | |
| setMessages(prev => [...prev, userMsg]); | |
| setInput(''); | |
| setIsTyping(true); | |
| setActiveAgent('Gatekeeper'); // Start with Gatekeeper | |
| try { | |
| const response = await fetch('http://localhost:6000/api/chat', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ message: input }) | |
| }); | |
| const data = await response.json(); | |
| if (data.success) { | |
| // Simulate agent handoff animation | |
| if (data.response.agent !== 'Gatekeeper') { | |
| setActiveAgent(data.response.agent); | |
| await new Promise(resolve => setTimeout(resolve, 500)); | |
| } | |
| const assistantMsg: Message = { | |
| role: 'assistant', | |
| content: data.response.content, | |
| agent: data.response.agent, | |
| trace: data.response.trace || [], | |
| timestamp: new Date(), | |
| }; | |
| setMessages(prev => [...prev, assistantMsg]); | |
| } | |
| } catch (error) { | |
| console.error('Chat failed:', error); | |
| setMessages(prev => [...prev, { | |
| role: 'assistant', | |
| content: "I apologize, but I'm having trouble connecting to my reasoning core. Please ensure the ORA backend is running on port 6000.", | |
| agent: 'System', | |
| timestamp: new Date(), | |
| }]); | |
| } finally { | |
| setIsTyping(false); | |
| } | |
| }; | |
| const toggleTrace = (index: number) => { | |
| setShowTrace(prev => ({ ...prev, [index]: !prev[index] })); | |
| }; | |
| const AgentIcon = ({ agent }: { agent: string }) => { | |
| const Icon = agentIcons[agent] || Sparkles; | |
| const colors = agentColors[agent] || agentColors.System; | |
| return ( | |
| <div className={`w-7 h-7 rounded-full ${colors.bg} flex items-center justify-center border ${colors.border}`}> | |
| <Icon className={`w-3.5 h-3.5 ${colors.text}`} /> | |
| </div> | |
| ); | |
| }; | |
| return ( | |
| <div className="fixed bottom-6 right-6 z-50 hidden md:block"> | |
| <div className="relative"> | |
| {/* Chat Button */} | |
| <button | |
| onClick={() => setChatOpen(!chatOpen)} | |
| className="relative w-14 h-14 bg-gradient-to-br from-purple-600 to-purple-800 hover:from-purple-500 hover:to-purple-700 text-white rounded-full flex items-center justify-center shadow-[0_4px_30px_rgba(168,85,247,0.4)] hover:shadow-[0_4px_40px_rgba(168,85,247,0.6)] hover:scale-105 active:scale-95 transition-all duration-300 z-50" | |
| > | |
| {chatOpen ? ( | |
| <X className="w-6 h-6" /> | |
| ) : ( | |
| <div className="relative"> | |
| <Sparkles className="w-6 h-6 animate-pulse-fast" /> | |
| </div> | |
| )} | |
| {!chatOpen && ( | |
| <span className="absolute top-0 right-0 w-3.5 h-3.5 bg-emerald-500 border-2 border-[#0a0a0a] rounded-full"> | |
| <span className="absolute inset-0 rounded-full bg-emerald-400 animate-ping" /> | |
| </span> | |
| )} | |
| </button> | |
| {/* Chat Box */} | |
| <div | |
| className={`absolute bottom-[calc(100%+16px)] right-0 w-[420px] bg-[#0a0a0a]/98 backdrop-blur-xl border border-purple-500/20 rounded-2xl shadow-2xl shadow-purple-500/10 transition-all duration-300 overflow-hidden flex flex-col ${ | |
| chatOpen | |
| ? 'visible opacity-100 translate-y-0 scale-100' | |
| : 'invisible opacity-0 translate-y-4 scale-95' | |
| }`} | |
| style={{ maxHeight: 'calc(100vh - 150px)' }} | |
| > | |
| {/* Header */} | |
| <div className="bg-gradient-to-r from-purple-900/60 to-purple-800/40 p-4 border-b border-white/5 flex items-center justify-between"> | |
| <div className="flex items-center gap-3"> | |
| <div className="relative"> | |
| <div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500/30 to-purple-700/30 flex items-center justify-center border border-purple-500/40 animate-orb-glow-purple"> | |
| <Sparkles className="w-5 h-5 text-purple-300" /> | |
| </div> | |
| <span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-500 border-2 border-[#0a0a0a] rounded-full shadow-[0_0_8px_rgba(16,185,129,0.6)]" /> | |
| </div> | |
| <div> | |
| <span className="block text-white text-sm font-semibold tracking-wide">ORA</span> | |
| <span className="block text-[10px] text-emerald-400 font-medium"> | |
| Sovereign AI Companion | |
| </span> | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => setChatOpen(false)} | |
| className="p-1.5 rounded-full hover:bg-white/10 text-neutral-400 hover:text-white transition-all" | |
| > | |
| <X className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| {/* Messages */} | |
| <div className="flex-1 p-4 space-y-4 min-h-[300px] max-h-[450px] overflow-y-auto bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-purple-500/5 via-transparent to-transparent scrollbar-thin scrollbar-thumb-purple-500/20 scrollbar-track-transparent"> | |
| {messages.map((m, i) => ( | |
| <div | |
| key={i} | |
| className={`flex gap-3 items-start animate-message-in ${ | |
| m.role === 'user' ? 'flex-row-reverse' : '' | |
| }`} | |
| > | |
| {/* Avatar */} | |
| <div className="shrink-0"> | |
| {m.role === 'user' ? ( | |
| <div className="w-7 h-7 rounded-full bg-amber-500/20 flex items-center justify-center border border-amber-500/30"> | |
| <Bot className="w-3.5 h-3.5 text-amber-400" /> | |
| </div> | |
| ) : ( | |
| <AgentIcon agent={m.agent || 'System'} /> | |
| )} | |
| </div> | |
| {/* Message Content */} | |
| <div className="flex flex-col gap-1 max-w-[85%]"> | |
| {/* Agent Badge */} | |
| {m.agent && m.role === 'assistant' && ( | |
| <div className="flex items-center gap-2"> | |
| <span className={`text-[9px] font-mono uppercase tracking-wider ${agentColors[m.agent]?.text || 'text-neutral-400'}`}> | |
| {m.agent} | |
| </span> | |
| {m.trace && m.trace.length > 0 && ( | |
| <button | |
| onClick={() => toggleTrace(i)} | |
| className="flex items-center gap-1 text-[9px] text-neutral-500 hover:text-purple-400 transition-colors" | |
| > | |
| {showTrace[i] ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />} | |
| {showTrace[i] ? 'Hide' : 'Show'} Reasoning | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| {/* Trace Steps */} | |
| {showTrace[i] && m.trace && m.trace.length > 0 && ( | |
| <div className="animate-trace-expand mb-2 p-3 rounded-xl bg-white/[0.02] border border-white/5"> | |
| <div className="text-[9px] text-neutral-500 uppercase tracking-wider mb-2"> | |
| Reasoning Trace | |
| </div> | |
| <div className="space-y-2"> | |
| {m.trace.map((step, j) => ( | |
| <div key={j} className="flex items-start gap-2 text-[10px]"> | |
| <div className={`w-4 h-4 rounded shrink-0 flex items-center justify-center ${agentColors[step.agent]?.bg || 'bg-neutral-500/20'}`}> | |
| <span className={`text-[8px] font-bold ${agentColors[step.agent]?.text || 'text-neutral-400'}`}> | |
| {j + 1} | |
| </span> | |
| </div> | |
| <div> | |
| <span className={`font-medium ${agentColors[step.agent]?.text || 'text-neutral-400'}`}> | |
| {step.agent}: | |
| </span> | |
| <span className="text-neutral-400 ml-1">{step.action}</span> | |
| {step.result && ( | |
| <div className="text-neutral-500 mt-0.5 italic">{step.result}</div> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Message Bubble */} | |
| <div | |
| className={`rounded-2xl p-3.5 text-sm leading-relaxed ${ | |
| m.role === 'user' | |
| ? 'bg-gradient-to-br from-amber-500/15 to-amber-600/10 border border-amber-500/20 rounded-tr-sm' | |
| : `bg-gradient-to-br from-purple-500/10 to-purple-600/5 border ${agentColors[m.agent || 'System']?.border || 'border-purple-500/20'} rounded-tl-sm` | |
| }`} | |
| > | |
| <MarkdownMessage | |
| content={m.content} | |
| variant={m.role} | |
| /> | |
| </div> | |
| {/* Timestamp */} | |
| {m.timestamp && ( | |
| <span className="text-[9px] text-neutral-600 px-1"> | |
| {m.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| {/* Thinking Indicator */} | |
| {isTyping && ( | |
| <ThinkingIndicator activeAgent={activeAgent} stage="thinking" /> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| {/* Quick Actions */} | |
| <div className="px-4 py-2 border-t border-white/5 bg-black/20"> | |
| <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-none"> | |
| {['Pray with me', 'Explain a verse', 'Daily reflection'].map((action) => ( | |
| <button | |
| key={action} | |
| onClick={() => setInput(action)} | |
| className="shrink-0 px-3 py-1.5 text-[10px] font-medium text-purple-300 bg-purple-500/10 hover:bg-purple-500/20 border border-purple-500/20 rounded-full transition-all hover:scale-105" | |
| > | |
| {action} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Input */} | |
| <div className="p-3 border-t border-white/5 bg-black/30"> | |
| <div className="flex items-center gap-2 bg-white/5 border border-white/10 rounded-full px-4 py-2.5 focus-within:border-purple-500/40 focus-within:bg-white/[0.07] transition-all"> | |
| <input | |
| type="text" | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()} | |
| placeholder="Ask ORA anything..." | |
| className="bg-transparent border-none focus:outline-none text-sm text-white w-full placeholder-neutral-500" | |
| disabled={isTyping} | |
| /> | |
| <button | |
| onClick={handleSend} | |
| disabled={isTyping || !input.trim()} | |
| className={`p-2 rounded-full transition-all ${ | |
| isTyping || !input.trim() | |
| ? 'text-neutral-600 cursor-not-allowed' | |
| : 'text-purple-400 hover:text-purple-300 hover:bg-purple-500/20' | |
| }`} | |
| > | |
| <Send className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |