ORA / frontend /components /dashboard /ChatInterface.tsx
Abdalkaderdev's picture
Initial ORA deployment
5e0532d
'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>
);
}