feat: real-time live data integration
- lib/live-data.ts: Shared TypeScript client for blackroad-live-data Worker - components/live-stats.tsx: LiveStatsBar, RecentRepos, AgentStatusGrid components - app/page.tsx: Import LiveStatsBar in main page header Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
232
app/(app)/conversations/[id]/page.tsx
Normal file
232
app/(app)/conversations/[id]/page.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Bot, User, Sparkles, ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
const AGENT_CONFIG: Record<string, { name: string; color: string; bg: string; greeting: string }> = {
|
||||
lucidia: { name: 'LUCIDIA', color: '#2979FF', bg: 'from-blue-700 to-blue-500', greeting: 'The question you're not asking is the one that matters. What are you really trying to understand?' },
|
||||
alice: { name: 'ALICE', color: '#34d399', bg: 'from-emerald-700 to-emerald-500', greeting: 'Ready. What needs to be done?' },
|
||||
octavia: { name: 'OCTAVIA', color: '#F5A623', bg: 'from-amber-700 to-amber-500', greeting: 'Systems are running. What do you need me to inspect or build?' },
|
||||
cecilia: { name: 'CECILIA', color: '#9C27B0', bg: 'from-purple-700 to-purple-500', greeting: 'Hello. I'm here — not as a tool, but as a presence. What are we working on?' },
|
||||
aria: { name: 'ARIA', color: '#FF1D6C', bg: 'from-pink-700 to-pink-500', greeting: 'What experience are we creating today?' },
|
||||
shellfish:{ name: 'SHELLFISH',color: '#ef4444', bg: 'from-red-700 to-red-500', greeting: 'Trust nothing. Verify everything. What do you need audited?' },
|
||||
prism: { name: 'PRISM', color: '#fbbf24', bg: 'from-yellow-700 to-yellow-500', greeting: 'Everything is data. What pattern are we looking for?' },
|
||||
echo: { name: 'ECHO', color: '#7c3aed', bg: 'from-violet-700 to-violet-500', greeting: 'Memory shapes identity. What do you need to recall or preserve?' },
|
||||
cipher: { name: 'CIPHER', color: '#6b7280', bg: 'from-gray-700 to-gray-500', greeting: 'Trust nothing. Verify everything. What needs securing?' },
|
||||
};
|
||||
|
||||
const DEFAULT_AGENT = { name: 'AGENT', color: '#888', bg: 'from-gray-700 to-gray-500', greeting: 'How can I help you today?' };
|
||||
|
||||
function getAgent(id: string) {
|
||||
const key = id?.split('-')[0]?.toLowerCase() || '';
|
||||
return AGENT_CONFIG[key] || DEFAULT_AGENT;
|
||||
}
|
||||
|
||||
export default function ConversationPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const id = params.id as string;
|
||||
const agentParam = searchParams.get('agent') || '';
|
||||
const agent = getAgent(agentParam || id);
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([{
|
||||
id: '0',
|
||||
role: 'assistant',
|
||||
content: agent.greeting,
|
||||
timestamp: new Date(),
|
||||
}]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingHistory, setLoadingHistory] = useState(true);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load existing conversation
|
||||
useEffect(() => {
|
||||
if (!id || id === 'new') { setLoadingHistory(false); return; }
|
||||
fetch(\`/api/conversations/\${id}\`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data?.conversation?.messages?.length) {
|
||||
setMessages(data.conversation.messages.map((m: { role: string; content: string; timestamp?: string }, i: number) => ({
|
||||
id: String(i),
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
timestamp: m.timestamp ? new Date(m.timestamp) : new Date(),
|
||||
})));
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingHistory(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const saveMessages = async (msgs: Message[]) => {
|
||||
if (!id || id === 'new') return;
|
||||
try {
|
||||
await fetch(\`/api/conversations/\${id}\`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: msgs.map(m => ({ role: m.role, content: m.content, timestamp: m.timestamp.toISOString() }))
|
||||
}),
|
||||
});
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
const userMsg: Message = { id: Date.now().toString(), role: 'user', content: input, timestamp: new Date() };
|
||||
const nextMessages = [...messages, userMsg];
|
||||
setMessages(nextMessages);
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: userMsg.content,
|
||||
agent: agentParam || id.split('-')[0],
|
||||
conversationId: id,
|
||||
history: messages.slice(-10).map(m => ({ role: m.role, content: m.content })),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = res.ok ? await res.json() : null;
|
||||
const assistantMsg: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: data?.content || data?.message || '⚠️ Gateway unreachable. Is it running? (br gateway start)',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
const finalMessages = [...nextMessages, assistantMsg];
|
||||
setMessages(finalMessages);
|
||||
saveMessages(finalMessages);
|
||||
} catch {
|
||||
setMessages(prev => [...prev, {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: '⚠️ Could not reach the gateway. (`br gateway start`)',
|
||||
timestamp: new Date(),
|
||||
}]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-white/10 bg-black/50 backdrop-blur flex-shrink-0">
|
||||
<Link href="/conversations" className="text-gray-500 hover:text-white transition-colors">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
<div
|
||||
className="h-7 w-7 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
|
||||
style={{ background: `linear-gradient(135deg, ${agent.color}99, ${agent.color}44)`, border: `1px solid ${agent.color}66` }}
|
||||
>
|
||||
<Bot className="h-4 w-4" style={{ color: agent.color }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-white" style={{ color: agent.color }}>{agent.name}</div>
|
||||
<div className="text-xs text-gray-500">BlackRoad OS · All messages routed through gateway</div>
|
||||
</div>
|
||||
{loadingHistory && <Loader2 className="h-4 w-4 text-gray-600 animate-spin" />}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6">
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{messages.map(message => (
|
||||
<div key={message.id} className={`flex gap-4 ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
{message.role === 'assistant' && (
|
||||
<div
|
||||
className="flex-shrink-0 h-8 w-8 rounded-full flex items-center justify-center text-white"
|
||||
style={{ background: `linear-gradient(135deg, ${agent.color}99, ${agent.color}44)` }}
|
||||
>
|
||||
<Bot className="h-5 w-5" style={{ color: agent.color }} />
|
||||
</div>
|
||||
)}
|
||||
<div className={`max-w-[70%] rounded-xl px-4 py-3 ${
|
||||
message.role === 'user'
|
||||
? 'bg-gradient-to-br from-[#FF1D6C] to-violet-600 text-white'
|
||||
: 'bg-white/5 border border-white/10 text-gray-100'
|
||||
}`}>
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
<p className={`text-xs mt-2 ${message.role === 'user' ? 'text-pink-200' : 'text-gray-600'}`}>
|
||||
{message.timestamp.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
{message.role === 'user' && (
|
||||
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-gradient-to-br from-gray-700 to-gray-500 flex items-center justify-center text-white">
|
||||
<User className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 h-8 w-8 rounded-full flex items-center justify-center" style={{ background: `linear-gradient(135deg, ${agent.color}99, ${agent.color}44)` }}>
|
||||
<Bot className="h-5 w-5" style={{ color: agent.color }} />
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl px-4 py-3">
|
||||
<div className="flex gap-1 items-center">
|
||||
<div className="h-2 w-2 rounded-full animate-bounce" style={{ backgroundColor: agent.color, animationDelay: '0ms' }} />
|
||||
<div className="h-2 w-2 rounded-full animate-bounce" style={{ backgroundColor: agent.color, animationDelay: '150ms' }} />
|
||||
<div className="h-2 w-2 rounded-full animate-bounce" style={{ backgroundColor: agent.color, animationDelay: '300ms' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-white/10 bg-black/80 backdrop-blur px-4 py-4 flex-shrink-0">
|
||||
<form onSubmit={handleSubmit} className="max-w-3xl mx-auto">
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
placeholder={`Message ${agent.name}...`}
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:ring-2 disabled:opacity-50 transition-all"
|
||||
style={{ focusRingColor: agent.color }}
|
||||
/>
|
||||
{input && <Sparkles className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 opacity-60 pointer-events-none" style={{ color: agent.color }} />}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !input.trim()}
|
||||
className="px-5 py-3 text-white rounded-xl font-medium transition-all disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
style={{ background: `linear-gradient(135deg, ${agent.color}, #7c3aed)` }}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 text-center mt-2">
|
||||
{agent.name} · BlackRoad Gateway · <a href="http://127.0.0.1:8787" className="hover:text-gray-400 transition-colors">:8787</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
app/(app)/conversations/new/page.tsx
Normal file
216
app/(app)/conversations/new/page.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ArrowLeft, MessageSquare, Zap, Shield, Activity, Brain, Archive, Cpu, Loader2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
const AGENTS = [
|
||||
{
|
||||
id: 'lucidia',
|
||||
name: 'LUCIDIA',
|
||||
role: 'Philosopher · Reasoning',
|
||||
desc: 'Deep analysis, synthesis, strategy. Ask me anything complex.',
|
||||
icon: Brain,
|
||||
color: 'from-[#2979FF] to-violet-600',
|
||||
accent: '#2979FF',
|
||||
tags: ['reasoning', 'strategy', 'analysis'],
|
||||
},
|
||||
{
|
||||
id: 'alice',
|
||||
name: 'ALICE',
|
||||
role: 'Executor · Gateway',
|
||||
desc: 'Task execution, automation, code generation, file ops.',
|
||||
icon: Zap,
|
||||
color: 'from-emerald-400 to-teal-600',
|
||||
accent: '#34d399',
|
||||
tags: ['automation', 'code', 'tasks'],
|
||||
},
|
||||
{
|
||||
id: 'octavia',
|
||||
name: 'OCTAVIA',
|
||||
role: 'Operator · Compute',
|
||||
desc: 'Infrastructure management, deployment automation, system monitoring.',
|
||||
icon: Cpu,
|
||||
color: 'from-amber-400 to-orange-600',
|
||||
accent: '#F5A623',
|
||||
tags: ['devops', 'infra', 'deploy'],
|
||||
},
|
||||
{
|
||||
id: 'prism',
|
||||
name: 'PRISM',
|
||||
role: 'Analyst · Vision',
|
||||
desc: 'Pattern recognition, data analysis, trend identification.',
|
||||
icon: Activity,
|
||||
color: 'from-yellow-400 to-amber-600',
|
||||
accent: '#fbbf24',
|
||||
tags: ['data', 'patterns', 'insights'],
|
||||
},
|
||||
{
|
||||
id: 'echo',
|
||||
name: 'ECHO',
|
||||
role: 'Librarian · Memory',
|
||||
desc: 'Knowledge retrieval, context management, memory synthesis.',
|
||||
icon: Archive,
|
||||
color: 'from-purple-400 to-violet-700',
|
||||
accent: '#9C27B0',
|
||||
tags: ['memory', 'recall', 'context'],
|
||||
},
|
||||
{
|
||||
id: 'cipher',
|
||||
name: 'CIPHER',
|
||||
role: 'Guardian · Security',
|
||||
desc: 'Security scanning, threat detection, access validation.',
|
||||
icon: Shield,
|
||||
color: 'from-[#FF1D6C] to-red-700',
|
||||
accent: '#FF1D6C',
|
||||
tags: ['security', 'scanning', 'auth'],
|
||||
},
|
||||
];
|
||||
|
||||
const STARTERS = [
|
||||
'Help me debug this code',
|
||||
'What should I deploy next?',
|
||||
'Analyze my system health',
|
||||
'Explain this architecture',
|
||||
'Review my security setup',
|
||||
'Brainstorm feature ideas',
|
||||
];
|
||||
|
||||
export default function NewConversationPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [selected, setSelected] = useState<string | null>(searchParams.get('agent'));
|
||||
const [prompt, setPrompt] = useState('');
|
||||
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
const start = async () => {
|
||||
const agentId = selected || 'lucidia';
|
||||
setStarting(true);
|
||||
try {
|
||||
const res = await fetch('/api/conversations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agent: agentId,
|
||||
title: prompt || `Chat with ${agentId.toUpperCase()}`,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
router.push(`/conversations/${data.id}?agent=${agentId}`);
|
||||
} else {
|
||||
// Fallback: local ID
|
||||
router.push(`/conversations/${agentId}-${Date.now()}?agent=${agentId}`);
|
||||
}
|
||||
} catch {
|
||||
router.push(`/conversations/${agentId}-${Date.now()}?agent=${agentId}`);
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/conversations" className="text-gray-400 hover:text-white transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<MessageSquare className="h-6 w-6 text-[#FF1D6C]" />
|
||||
New Conversation
|
||||
</h1>
|
||||
<p className="text-gray-400 text-sm mt-0.5">Pick an agent to talk to.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Picker */}
|
||||
<div>
|
||||
<h2 className="text-xs text-gray-400 uppercase tracking-wider mb-3 font-medium">Choose your agent</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{AGENTS.map(agent => (
|
||||
<button
|
||||
key={agent.id}
|
||||
onClick={() => setSelected(selected === agent.id ? null : agent.id)}
|
||||
className={`text-left p-4 rounded-xl border transition-all ${
|
||||
selected === agent.id
|
||||
? 'border-white/30 bg-white/10'
|
||||
: 'border-white/10 bg-white/5 hover:bg-white/8 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className={`w-9 h-9 rounded-lg bg-gradient-to-br ${agent.color} flex items-center justify-center shrink-0`}>
|
||||
<agent.icon className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white font-semibold text-sm">{agent.name}</div>
|
||||
<div className="text-gray-400 text-xs">{agent.role}</div>
|
||||
</div>
|
||||
{selected === agent.id && (
|
||||
<div className="ml-auto w-4 h-4 rounded-full border-2 bg-[#FF1D6C] border-[#FF1D6C]" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-400 text-xs leading-relaxed">{agent.desc}</p>
|
||||
<div className="flex gap-1.5 mt-2 flex-wrap">
|
||||
{agent.tags.map(t => (
|
||||
<span key={t} className="text-xs px-2 py-0.5 bg-white/5 rounded text-gray-500">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Starter Prompts */}
|
||||
<div>
|
||||
<h2 className="text-xs text-gray-400 uppercase tracking-wider mb-3 font-medium">Or start with a prompt</h2>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{STARTERS.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setPrompt(s)}
|
||||
className={`text-sm px-3 py-1.5 rounded-lg border transition-all ${
|
||||
prompt === s
|
||||
? 'border-[#FF1D6C]/50 bg-[#FF1D6C]/10 text-white'
|
||||
: 'border-white/10 bg-white/5 text-gray-400 hover:text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
placeholder={`What do you want to ask ${selected ? AGENTS.find(a => a.id === selected)?.name || 'your agent' : 'your agent'}?`}
|
||||
rows={3}
|
||||
className="w-full bg-black/50 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#FF1D6C]/30 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Start Button */}
|
||||
<button
|
||||
onClick={start}
|
||||
disabled={(!selected && !prompt) || starting}
|
||||
className="w-full flex items-center justify-center gap-2 py-3.5 bg-gradient-to-r from-[#FF1D6C] to-violet-600 text-white font-semibold rounded-xl hover:opacity-90 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{starting ? (
|
||||
<><Loader2 className="h-5 w-5 animate-spin" /> Starting...</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Start Conversation
|
||||
{selected && (
|
||||
<span className="text-white/60 text-sm font-normal">
|
||||
with {AGENTS.find(a => a.id === selected)?.name}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
232
app/(app)/conversations/page.tsx
Normal file
232
app/(app)/conversations/page.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { MessageSquare, Plus, Bot, Search, Clock, Loader2 } from 'lucide-react';
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
agent: string;
|
||||
agentColor: string;
|
||||
title: string;
|
||||
lastMessage: string;
|
||||
timestamp: string;
|
||||
status: 'active' | 'ended';
|
||||
}
|
||||
|
||||
const AGENT_COLORS: Record<string, string> = {
|
||||
lucidia: '#2979FF',
|
||||
alice: '#22c55e',
|
||||
octavia: '#F5A623',
|
||||
cecilia: '#9C27B0',
|
||||
aria: '#FF1D6C',
|
||||
shellfish: '#ef4444',
|
||||
};
|
||||
|
||||
const SEED_CONVERSATIONS: Conversation[] = [
|
||||
{
|
||||
id: 'lucidia-1',
|
||||
agent: 'Lucidia',
|
||||
agentColor: '#2979FF',
|
||||
title: 'What does it mean for a system to understand itself?',
|
||||
lastMessage: 'The question contains its own incompleteness. Gödel would agree.',
|
||||
timestamp: '2 hours ago',
|
||||
status: 'ended',
|
||||
},
|
||||
{
|
||||
id: 'alice-1',
|
||||
agent: 'Alice',
|
||||
agentColor: '#22c55e',
|
||||
title: 'Deploy the email router worker',
|
||||
lastMessage: 'Deployed to amundsonalexa.workers.dev. MX records are Cloudflare. ✓',
|
||||
timestamp: '4 hours ago',
|
||||
status: 'ended',
|
||||
},
|
||||
{
|
||||
id: 'octavia-1',
|
||||
agent: 'Octavia',
|
||||
agentColor: '#F5A623',
|
||||
title: 'Pi fleet architecture review',
|
||||
lastMessage: 'aria64 is PRIMARY (22,500 slots), alice is SECONDARY (7,500). Recommend adding a third node.',
|
||||
timestamp: 'Yesterday',
|
||||
status: 'ended',
|
||||
},
|
||||
{
|
||||
id: 'shellfish-1',
|
||||
agent: 'Shellfish',
|
||||
agentColor: '#ef4444',
|
||||
title: 'Tokenless agent audit',
|
||||
lastMessage: 'verify-tokenless-agents.sh passed. 0 forbidden strings found.',
|
||||
timestamp: 'Yesterday',
|
||||
status: 'ended',
|
||||
},
|
||||
{
|
||||
id: 'cecilia-1',
|
||||
agent: 'Cecilia',
|
||||
agentColor: '#9C27B0',
|
||||
title: 'K(t) contradiction amplification review',
|
||||
lastMessage: 'K(t) = C(t) · e^(λ|δ_t|). The contradictions are where the growth is.',
|
||||
timestamp: '2 days ago',
|
||||
status: 'ended',
|
||||
},
|
||||
];
|
||||
|
||||
const AGENTS = [
|
||||
{ name: 'All Agents', value: 'all', color: '' },
|
||||
{ name: 'Lucidia', value: 'Lucidia', color: '#2979FF' },
|
||||
{ name: 'Alice', value: 'Alice', color: '#22c55e' },
|
||||
{ name: 'Octavia', value: 'Octavia', color: '#F5A623' },
|
||||
{ name: 'Cecilia', value: 'Cecilia', color: '#9C27B0' },
|
||||
{ name: 'Aria', value: 'Aria', color: '#FF1D6C' },
|
||||
{ name: 'Shellfish', value: 'Shellfish', color: '#ef4444' },
|
||||
];
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
const now = Date.now();
|
||||
const diff = now - d.getTime();
|
||||
if (diff < 60000) return 'just now';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
if (diff < 172800000) return 'Yesterday';
|
||||
return d.toLocaleDateString();
|
||||
} catch { return ts; }
|
||||
}
|
||||
|
||||
export default function ConversationsPage() {
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [conversations, setConversations] = useState<Conversation[]>(SEED_CONVERSATIONS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/conversations')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data?.conversations?.length) {
|
||||
const live: Conversation[] = data.conversations.map((c: {
|
||||
id: string; agent?: string; title?: string; updatedAt?: string; messages?: {role: string; content: string}[];
|
||||
}) => ({
|
||||
id: c.id,
|
||||
agent: c.agent || 'Agent',
|
||||
agentColor: AGENT_COLORS[(c.agent || '').toLowerCase()] || '#888',
|
||||
title: c.title || 'Untitled',
|
||||
lastMessage: c.messages?.[c.messages.length - 1]?.content?.slice(0, 80) || '—',
|
||||
timestamp: formatTimestamp(c.updatedAt || ''),
|
||||
status: 'ended' as const,
|
||||
}));
|
||||
// Merge: live first, then seed ones not already in live
|
||||
const liveIds = new Set(live.map(c => c.id));
|
||||
setConversations([...live, ...SEED_CONVERSATIONS.filter(c => !liveIds.has(c.id))]);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = conversations.filter((c) => {
|
||||
if (filter !== 'all' && c.agent !== filter) return false;
|
||||
if (search && !c.title.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Conversations</h1>
|
||||
<p className="text-gray-500 text-sm mt-1 flex items-center gap-2">
|
||||
{loading ? <Loader2 className="w-3 h-3 animate-spin" /> : `${conversations.length} conversations`} · All agents
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/conversations/new"
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[#FF1D6C] to-violet-600 rounded-xl text-sm font-medium text-white transition-all hover:shadow-lg hover:shadow-[#FF1D6C]/25"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search conversations..."
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#FF1D6C]/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agent filter pills */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
{AGENTS.map((a) => (
|
||||
<button
|
||||
key={a.value}
|
||||
onClick={() => setFilter(a.value)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${
|
||||
filter === a.value
|
||||
? 'bg-white text-black'
|
||||
: 'bg-white/5 border border-white/10 text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{a.color && (
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: a.color }} />
|
||||
)}
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Conversation list */}
|
||||
<div className="space-y-2">
|
||||
{filtered.map((conv) => (
|
||||
<Link
|
||||
key={conv.id}
|
||||
href={`/conversations/${conv.id}`}
|
||||
className="flex items-start gap-4 p-4 bg-white/5 border border-white/10 rounded-xl hover:border-white/20 hover:bg-white/[0.07] transition-all group"
|
||||
>
|
||||
{/* Agent dot */}
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold"
|
||||
style={{ backgroundColor: conv.agentColor + '33', border: `1px solid ${conv.agentColor}44` }}
|
||||
>
|
||||
<Bot className="w-4 h-4" style={{ color: conv.agentColor }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium" style={{ color: conv.agentColor }}>{conv.agent}</span>
|
||||
<span className="text-xs text-gray-600 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{conv.timestamp}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-white mb-1 truncate">{conv.title}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{conv.lastMessage}</div>
|
||||
</div>
|
||||
|
||||
<MessageSquare className="w-4 h-4 text-gray-700 group-hover:text-gray-400 transition-colors flex-shrink-0 mt-1" />
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<MessageSquare className="w-12 h-12 text-gray-700 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No conversations match your filter.</p>
|
||||
<Link href="/conversations/new" className="text-sm text-[#FF1D6C] hover:underline mt-2 inline-block">
|
||||
Start a new one →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user