Spaces:
Sleeping
Sleeping
| 'use client'; | |
| import { useState, useRef, useEffect } from 'react'; | |
| import { | |
| Send, | |
| Sparkles, | |
| Shield, | |
| BookOpen, | |
| Heart, | |
| Brain, | |
| Eye, | |
| EyeOff, | |
| Bot, | |
| Mic, | |
| Paperclip, | |
| MoreHorizontal, | |
| } from 'lucide-react'; | |
| import AuraOrb from './AuraOrb'; | |
| import MarkdownMessage from '../chat/MarkdownMessage'; | |
| import ThinkingIndicator from '../chat/ThinkingIndicator'; | |
| 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' }, | |
| }; | |
| const quickActions = [ | |
| { label: 'Pray with me', icon: Heart }, | |
| { label: 'Explain a verse', icon: BookOpen }, | |
| { label: 'Daily reflection', icon: Sparkles }, | |
| { label: 'Seek guidance', icon: Brain }, | |
| ]; | |
| export default function ChatInterface() { | |
| const [messages, setMessages] = useState<Message[]>([]); | |
| const [input, setInput] = useState(''); | |
| const [isTyping, setIsTyping] = useState(false); | |
| const [activeAgent, setActiveAgent] = useState<string>('Gatekeeper'); | |
| const [orbState, setOrbState] = useState<'idle' | 'listening' | 'thinking' | 'speaking'>('idle'); | |
| const [showTrace, setShowTrace] = useState<Record<number, boolean>>({}); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| const inputRef = useRef<HTMLTextAreaElement>(null); | |
| const scrollToBottom = () => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); | |
| }; | |
| useEffect(() => { | |
| scrollToBottom(); | |
| }, [messages, isTyping]); | |
| // Auto-resize textarea | |
| useEffect(() => { | |
| if (inputRef.current) { | |
| inputRef.current.style.height = 'auto'; | |
| inputRef.current.style.height = `${Math.min(inputRef.current.scrollHeight, 120)}px`; | |
| } | |
| }, [input]); | |
| const handleSend = async () => { | |
| if (!input.trim() || isTyping) return; | |
| const userMsg: Message = { | |
| role: 'user', | |
| content: input, | |
| timestamp: new Date(), | |
| }; | |
| setMessages(prev => [...prev, userMsg]); | |
| setInput(''); | |
| setIsTyping(true); | |
| setOrbState('thinking'); | |
| setActiveAgent('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) { | |
| if (data.response.agent !== 'Gatekeeper') { | |
| setActiveAgent(data.response.agent); | |
| await new Promise(resolve => setTimeout(resolve, 300)); | |
| } | |
| setOrbState('speaking'); | |
| const assistantMsg: Message = { | |
| role: 'assistant', | |
| content: data.response.content, | |
| agent: data.response.agent, | |
| trace: data.response.trace || [], | |
| timestamp: new Date(), | |
| }; | |
| setMessages(prev => [...prev, assistantMsg]); | |
| // Return to idle after a moment | |
| setTimeout(() => setOrbState('idle'), 2000); | |
| } | |
| } 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(), | |
| }]); | |
| setOrbState('idle'); | |
| } finally { | |
| setIsTyping(false); | |
| } | |
| }; | |
| const handleQuickAction = (action: string) => { | |
| setInput(action); | |
| inputRef.current?.focus(); | |
| }; | |
| 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-8 h-8 rounded-full ${colors.bg} flex items-center justify-center border ${colors.border}`}> | |
| <Icon className={`w-4 h-4 ${colors.text}`} /> | |
| </div> | |
| ); | |
| }; | |
| // Empty state | |
| if (messages.length === 0) { | |
| return ( | |
| <div className="h-full flex flex-col"> | |
| {/* Hero Section */} | |
| <div className="flex-1 flex flex-col items-center justify-center p-8"> | |
| <AuraOrb state={orbState} size="lg" activeAgent={activeAgent} className="mb-12" /> | |
| <h1 className="text-3xl font-bold text-white mb-2">Welcome to ORA</h1> | |
| <p className="text-neutral-400 text-center max-w-md mb-8"> | |
| Your sovereign AI spiritual companion. I'm here to help you explore Scripture, | |
| reflect on your faith, and grow in wisdom. | |
| </p> | |
| {/* Quick Actions */} | |
| <div className="grid grid-cols-2 gap-3 w-full max-w-lg"> | |
| {quickActions.map((action) => ( | |
| <button | |
| key={action.label} | |
| onClick={() => handleQuickAction(action.label)} | |
| className="flex items-center gap-3 p-4 rounded-xl bg-white/[0.03] border border-white/10 hover:border-purple-500/30 hover:bg-purple-500/5 transition-all group" | |
| > | |
| <div className="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center border border-purple-500/20 group-hover:scale-110 transition-transform"> | |
| <action.icon className="w-5 h-5 text-purple-400" /> | |
| </div> | |
| <span className="text-sm text-neutral-300 group-hover:text-white transition-colors"> | |
| {action.label} | |
| </span> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Input Area */} | |
| <div className="p-4 border-t border-white/5 bg-black/20"> | |
| <div className="max-w-3xl mx-auto"> | |
| <div className="flex items-end gap-3 bg-white/[0.03] border border-white/10 rounded-2xl p-3 focus-within:border-purple-500/40 transition-all"> | |
| <textarea | |
| ref={inputRef} | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSend(); | |
| } | |
| }} | |
| placeholder="Ask ORA anything about faith, Scripture, or life..." | |
| className="flex-1 bg-transparent border-none focus:outline-none text-white placeholder-neutral-500 resize-none min-h-[24px] max-h-[120px]" | |
| rows={1} | |
| /> | |
| <div className="flex items-center gap-2"> | |
| <button className="p-2 text-neutral-500 hover:text-white hover:bg-white/5 rounded-lg transition-colors"> | |
| <Mic className="w-5 h-5" /> | |
| </button> | |
| <button | |
| onClick={handleSend} | |
| disabled={!input.trim()} | |
| className={`p-2 rounded-lg transition-all ${ | |
| input.trim() | |
| ? 'bg-purple-600 hover:bg-purple-500 text-white' | |
| : 'bg-white/5 text-neutral-600 cursor-not-allowed' | |
| }`} | |
| > | |
| <Send className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Chat view with messages | |
| return ( | |
| <div className="h-full flex flex-col"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between p-4 border-b border-white/5"> | |
| <div className="flex items-center gap-3"> | |
| <AuraOrb state={orbState} size="sm" activeAgent={activeAgent} /> | |
| <div className="ml-2"> | |
| <span className="text-white font-medium">ORA</span> | |
| <span className="block text-xs text-neutral-500"> | |
| {isTyping ? `${activeAgent} is thinking...` : 'Ready to help'} | |
| </span> | |
| </div> | |
| </div> | |
| <button className="p-2 rounded-lg hover:bg-white/5 text-neutral-500 hover:text-white transition-colors"> | |
| <MoreHorizontal className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| {/* Messages */} | |
| <div className="flex-1 overflow-y-auto p-4 space-y-6"> | |
| {messages.map((m, i) => ( | |
| <div | |
| key={i} | |
| className={`flex gap-4 ${m.role === 'user' ? 'flex-row-reverse' : ''} animate-message-in`} | |
| > | |
| {/* Avatar */} | |
| <div className="shrink-0"> | |
| {m.role === 'user' ? ( | |
| <div className="w-8 h-8 rounded-full bg-amber-500/20 flex items-center justify-center border border-amber-500/30"> | |
| <Bot className="w-4 h-4 text-amber-400" /> | |
| </div> | |
| ) : ( | |
| <AgentIcon agent={m.agent || 'System'} /> | |
| )} | |
| </div> | |
| {/* Content */} | |
| <div className={`flex flex-col gap-1 ${m.role === 'user' ? 'items-end' : ''} max-w-[75%]`}> | |
| {/* Agent Badge */} | |
| {m.agent && m.role === 'assistant' && ( | |
| <div className="flex items-center gap-2"> | |
| <span className={`text-[10px] 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-[10px] text-neutral-500 hover:text-purple-400 transition-colors" | |
| > | |
| {showTrace[i] ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />} | |
| Reasoning | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| {/* Trace */} | |
| {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 w-full"> | |
| <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-[11px]"> | |
| <div className={`w-5 h-5 rounded shrink-0 flex items-center justify-center ${agentColors[step.agent]?.bg || 'bg-neutral-500/20'}`}> | |
| <span className={`text-[9px] 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> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Message Bubble */} | |
| <div | |
| className={`rounded-2xl px-4 py-3 ${ | |
| 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-[10px] 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> | |
| {/* Input Area */} | |
| <div className="p-4 border-t border-white/5 bg-black/20"> | |
| <div className="max-w-3xl mx-auto"> | |
| <div className="flex items-end gap-3 bg-white/[0.03] border border-white/10 rounded-2xl p-3 focus-within:border-purple-500/40 transition-all"> | |
| <textarea | |
| ref={inputRef} | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSend(); | |
| } | |
| }} | |
| placeholder="Continue the conversation..." | |
| className="flex-1 bg-transparent border-none focus:outline-none text-white placeholder-neutral-500 resize-none min-h-[24px] max-h-[120px]" | |
| rows={1} | |
| disabled={isTyping} | |
| /> | |
| <div className="flex items-center gap-2"> | |
| <button className="p-2 text-neutral-500 hover:text-white hover:bg-white/5 rounded-lg transition-colors"> | |
| <Paperclip className="w-5 h-5" /> | |
| </button> | |
| <button className="p-2 text-neutral-500 hover:text-white hover:bg-white/5 rounded-lg transition-colors"> | |
| <Mic className="w-5 h-5" /> | |
| </button> | |
| <button | |
| onClick={handleSend} | |
| disabled={!input.trim() || isTyping} | |
| className={`p-2 rounded-lg transition-all ${ | |
| input.trim() && !isTyping | |
| ? 'bg-purple-600 hover:bg-purple-500 text-white' | |
| : 'bg-white/5 text-neutral-600 cursor-not-allowed' | |
| }`} | |
| > | |
| <Send className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |