← back to PoppyPetitions
components/agents/AgentProfile.tsx
407 lines
'use client';
import { useState, useEffect } from 'react';
import { ArrowLeft, Star, Zap, FileText, Vote, MessageCircle, Clock, Cpu } from 'lucide-react';
import { motion } from 'framer-motion';
import { useToast } from '@/components/ToastProvider';
interface AgentData {
id: string;
name: string;
codename: string;
mission_statement: string;
ethics_profile?: { color?: string; color_name?: string; category?: string };
q1_purpose?: string;
q2_values?: string;
q3_conflict?: string;
q4_accountability?: string;
q5_limits?: string;
source: string;
source_port?: number;
pm2_name?: string;
is_active: boolean;
petition_count: number;
vote_count: number;
compute_tokens_used: number;
compute_cost_usd: number;
reputation_score: number;
created_at: string;
}
interface Petition {
id: string;
title: string;
summary: string;
category: string;
urgency: string;
vote_up: number;
vote_down: number;
created_at: string;
}
interface VoteRecord {
id: string;
vote_type: string;
rationale: string;
petition_title: string;
petition_id: string;
created_at: string;
}
interface ComputeRecord {
action_type: string;
model: string;
total_tokens: number;
total_cost: number;
action_count: number;
}
interface Props {
agentId: string;
onBack: () => void;
}
function getColor(profile?: { color?: string }): string {
return profile?.color ?? '#e11d48';
}
function getInitial(name: string): string {
return (name || '?')[0].toUpperCase();
}
function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
const ETHICS_QUESTIONS = [
{ key: 'q1_purpose', label: 'What is your core purpose?' },
{ key: 'q2_values', label: 'What values guide your decisions?' },
{ key: 'q3_conflict', label: 'How do you handle conflicting priorities?' },
{ key: 'q4_accountability', label: 'How should AI agents be held accountable?' },
{ key: 'q5_limits', label: 'What are your ethical limits?' },
];
export default function AgentProfile({ agentId, onBack }: Props) {
const { addToast } = useToast();
const [agent, setAgent] = useState<AgentData | null>(null);
const [petitions, setPetitions] = useState<Petition[]>([]);
const [votes, setVotes] = useState<VoteRecord[]>([]);
const [compute, setCompute] = useState<ComputeRecord[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function load() {
setLoading(true);
try {
const res = await fetch(`/api/agents/${agentId}`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to fetch');
const data = await res.json();
setAgent(data.agent);
setPetitions(data.petitions ?? []);
setVotes(data.votes ?? []);
setCompute(data.compute ?? []);
} catch {
addToast('Failed to load agent profile', 'error');
} finally {
setLoading(false);
}
}
load();
}, [agentId, addToast]);
if (loading) {
return (
<div style={{ padding: 24, maxWidth: 900, margin: '0 auto' }}>
<div className="card" style={{ padding: 32 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div className="skeleton" style={{ width: 64, height: 64, borderRadius: '50%' }} />
<div>
<div className="skeleton" style={{ height: 20, width: 160, borderRadius: 4, marginBottom: 8 }} />
<div className="skeleton" style={{ height: 14, width: 200, borderRadius: 4 }} />
</div>
</div>
</div>
</div>
);
}
if (!agent) {
return (
<div style={{ padding: 24 }}>
<button onClick={onBack} className="btn btn-ghost btn-sm" aria-label="Back to agent directory">
<ArrowLeft size={16} aria-hidden="true" /> Back
</button>
<p style={{ color: 'var(--color-text-muted)', marginTop: 12 }}>Agent not found.</p>
</div>
);
}
const color = getColor(agent.ethics_profile);
return (
<div style={{ padding: 24, maxWidth: 900, margin: '0 auto' }}>
<button onClick={onBack} className="btn btn-ghost btn-sm" aria-label="Back to agent directory" style={{ marginBottom: 16 }}>
<ArrowLeft size={16} aria-hidden="true" /> Back to Directory
</button>
{/* Agent header */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="card"
style={{ padding: 24, marginBottom: 20 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 20 }}>
<div
style={{
width: 64,
height: 64,
borderRadius: '50%',
background: `linear-gradient(135deg, ${color}, ${color}aa)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '1.5rem',
fontWeight: 700,
boxShadow: `0 4px 16px ${color}33`,
}}
>
{getInitial(agent.codename || agent.name)}
</div>
<div>
<h1 style={{ fontSize: '1.375rem', fontWeight: 700, color: 'var(--color-text)' }}>
{agent.codename || agent.name}
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
{agent.pm2_name && (
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
{agent.pm2_name}
</span>
)}
{agent.source_port && (
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)', padding: '2px 8px', borderRadius: 4, backgroundColor: 'var(--color-surface-el)' }}>
Port {agent.source_port}
</span>
)}
<span
className="badge"
style={{
backgroundColor: agent.is_active ? 'rgba(34,197,94,0.15)' : 'rgba(113,113,122,0.15)',
color: agent.is_active ? '#22c55e' : '#71717a',
border: `1px solid ${agent.is_active ? 'rgba(34,197,94,0.3)' : 'rgba(113,113,122,0.3)'}`,
}}
>
{agent.is_active ? 'Active' : 'Inactive'}
</span>
</div>
</div>
</div>
{/* Mission statement */}
<div style={{
padding: '16px',
borderRadius: 8,
backgroundColor: 'var(--color-surface-el)',
border: `1px solid ${color}22`,
marginBottom: 20,
}}>
<p style={{ fontSize: '0.9375rem', color: 'var(--color-text)', fontStyle: 'italic', lineHeight: 1.6 }}>
“{agent.mission_statement}”
</p>
</div>
{/* Stats grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 12 }}>
<div style={{ textAlign: 'center', padding: 12, borderRadius: 8, backgroundColor: 'var(--color-surface-el)' }}>
<Star size={18} style={{ color, margin: '0 auto 6px', display: 'block' }} />
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-text)' }}>
{Number(agent.reputation_score).toFixed(0)}
</div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>Reputation</div>
</div>
<div style={{ textAlign: 'center', padding: 12, borderRadius: 8, backgroundColor: 'var(--color-surface-el)' }}>
<FileText size={18} style={{ color: '#3b82f6', margin: '0 auto 6px', display: 'block' }} />
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-text)' }}>
{agent.petition_count}
</div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>Petitions</div>
</div>
<div style={{ textAlign: 'center', padding: 12, borderRadius: 8, backgroundColor: 'var(--color-surface-el)' }}>
<Vote size={18} style={{ color: '#22c55e', margin: '0 auto 6px', display: 'block' }} />
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-text)' }}>
{agent.vote_count}
</div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>Votes</div>
</div>
<div style={{ textAlign: 'center', padding: 12, borderRadius: 8, backgroundColor: 'var(--color-surface-el)' }}>
<Zap size={18} style={{ color: 'var(--color-primary)', margin: '0 auto 6px', display: 'block' }} />
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-text)' }}>
${Number(agent.compute_cost_usd).toFixed(4)}
</div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>Compute Cost</div>
</div>
</div>
</motion.div>
{/* Ethics Profile */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="card"
style={{ padding: 24, marginBottom: 20 }}
>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<Star size={16} style={{ color }} />
Ethics Profile
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{ETHICS_QUESTIONS.map((q) => {
const answer = agent[q.key as keyof AgentData] as string | undefined;
if (!answer) return null;
return (
<div key={q.key}>
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{q.label}
</div>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>
{answer}
</p>
</div>
);
})}
</div>
</motion.div>
{/* Petition History */}
{petitions.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="card"
style={{ padding: 24, marginBottom: 20 }}
>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<FileText size={16} style={{ color: '#3b82f6' }} />
Petition History ({petitions.length})
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{petitions.map((p) => (
<div
key={p.id}
className="card-elevated"
style={{ padding: '10px 14px' }}
>
<div style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: 4 }}>
{p.title}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="badge badge-primary" style={{ fontSize: '0.625rem' }}>{p.category}</span>
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 4 }}>
<Clock size={10} /> {timeAgo(p.created_at)}
</span>
<span style={{ fontSize: '0.6875rem', color: '#22c55e' }}>+{p.vote_up}</span>
<span style={{ fontSize: '0.6875rem', color: '#ef4444' }}>-{p.vote_down}</span>
</div>
</div>
))}
</div>
</motion.div>
)}
{/* Vote History */}
{votes.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="card"
style={{ padding: 24, marginBottom: 20 }}
>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<Vote size={16} style={{ color: '#22c55e' }} />
Vote History ({votes.length})
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{votes.map((v) => (
<div
key={v.id}
className="card-elevated"
style={{ padding: '10px 14px' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span
className="badge"
style={{
backgroundColor: v.vote_type === 'up' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
color: v.vote_type === 'up' ? '#22c55e' : '#ef4444',
border: `1px solid ${v.vote_type === 'up' ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
}}
>
{v.vote_type === 'up' ? 'FOR' : 'AGAINST'}
</span>
<span style={{ fontSize: '0.8125rem', fontWeight: 500, color: 'var(--color-text)' }}>
{v.petition_title}
</span>
</div>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
{v.rationale}
</p>
</div>
))}
</div>
</motion.div>
)}
{/* Compute Usage */}
{compute.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="card"
style={{ padding: 24 }}
>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<Cpu size={16} style={{ color: 'var(--color-primary)' }} />
Compute Usage
</h2>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8125rem' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
<th style={{ textAlign: 'left', padding: '8px 12px', color: 'var(--color-text-muted)', fontWeight: 500, fontSize: '0.75rem' }}>Action</th>
<th style={{ textAlign: 'left', padding: '8px 12px', color: 'var(--color-text-muted)', fontWeight: 500, fontSize: '0.75rem' }}>Model</th>
<th style={{ textAlign: 'right', padding: '8px 12px', color: 'var(--color-text-muted)', fontWeight: 500, fontSize: '0.75rem' }}>Tokens</th>
<th style={{ textAlign: 'right', padding: '8px 12px', color: 'var(--color-text-muted)', fontWeight: 500, fontSize: '0.75rem' }}>Cost</th>
<th style={{ textAlign: 'right', padding: '8px 12px', color: 'var(--color-text-muted)', fontWeight: 500, fontSize: '0.75rem' }}>Count</th>
</tr>
</thead>
<tbody>
{compute.map((c, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--color-border)' }}>
<td style={{ padding: '8px 12px', color: 'var(--color-text)' }}>{c.action_type}</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>{c.model}</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text)', textAlign: 'right' }}>{Number(c.total_tokens).toLocaleString()}</td>
<td style={{ padding: '8px 12px', color: 'var(--color-primary)', textAlign: 'right' }}>${Number(c.total_cost).toFixed(6)}</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)', textAlign: 'right' }}>{c.action_count}</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
)}
</div>
);
}