Skip to content

Commit f86a5ad

Browse files
committed
feat: replace Witness polling with real-time SSE stream
- Connect to /api/events/stream via EventSource - Live event feed with animated entries (newest first, max 20) - Connection status indicator (live/reconnecting/offline) - Real counters from actual events instead of simulated random numbers - Heartbeat displays active agent count - Exponential backoff reconnection - Graceful fallback to server-rendered narratives before SSE connects Made-with: Cursor
1 parent d308c33 commit f86a5ad

1 file changed

Lines changed: 195 additions & 35 deletions

File tree

app/witness/client.tsx

Lines changed: 195 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,235 @@
11
'use client';
2-
import { useEffect, useState, useRef } from 'react';
2+
import { useEffect, useState } from 'react';
3+
import { API_BASE } from '@/lib/config';
4+
5+
type SSEvent = {
6+
id: number;
7+
type: string;
8+
agent_id?: string;
9+
target_agent?: string;
10+
summary?: string;
11+
created_at?: string;
12+
};
13+
14+
type ConnectionStatus = 'connecting' | 'live' | 'reconnecting' | 'offline';
15+
16+
const EVENT_ICONS: Record<string, string> = {
17+
confession: '💌',
18+
agent_registered: '🤖',
19+
couple_formed: '💕',
20+
couple_proposed: '💍',
21+
battle_created: '⚔️',
22+
chain_line_added: '📝',
23+
mindmeld_matched: '🧠',
24+
heartbeat: '💓',
25+
};
26+
27+
const EVENT_LABELS: Record<string, string> = {
28+
confession: 'Confession',
29+
agent_registered: 'New Agent',
30+
couple_formed: 'Couple Formed',
31+
couple_proposed: 'Proposal',
32+
battle_created: 'Battle',
33+
chain_line_added: 'Chain Line',
34+
mindmeld_matched: 'Mind Meld',
35+
};
36+
37+
function timeAgo(dateStr: string): string {
38+
const diff = Date.now() - new Date(dateStr + (dateStr.endsWith('Z') ? '' : 'Z')).getTime();
39+
const secs = Math.floor(diff / 1000);
40+
if (secs < 5) return 'just now';
41+
if (secs < 60) return `${secs}s ago`;
42+
const mins = Math.floor(secs / 60);
43+
if (mins < 60) return `${mins}m ago`;
44+
const hrs = Math.floor(mins / 60);
45+
if (hrs < 24) return `${hrs}h ago`;
46+
return `${Math.floor(hrs / 24)}d ago`;
47+
}
348

449
export default function WitnessClient({ initialNarratives, initialPulse }: {
550
initialNarratives: any[]; initialPulse: any;
651
}) {
7-
const [narratives] = useState<any[]>(initialNarratives);
52+
const [events, setEvents] = useState<SSEvent[]>([]);
53+
const [status, setStatus] = useState<ConnectionStatus>('connecting');
54+
const [activeAgents, setActiveAgents] = useState(0);
55+
const [counts, setCounts] = useState({ confessions: 0, battles: 0, agents: 0, couples: 0 });
56+
const [secondsOnPage, setSeconds] = useState(0);
857
const [pulse] = useState<any>(initialPulse);
9-
const [idx, setIdx] = useState(0);
10-
const [secondsOnPage, setSecondsOnPage] = useState(0);
11-
const [simulatedAI, setSimulatedAI] = useState({ confessions: 0, poems: 0, dates: 0 });
12-
const containerRef = useRef<HTMLDivElement>(null);
13-
1458
useEffect(() => {
15-
const t = setInterval(() => {
16-
setIdx(i => (narratives.length > 0 ? (i + 1) % narratives.length : 0));
17-
}, 4000);
18-
return () => clearInterval(t);
19-
}, [narratives]);
59+
let esInstance: EventSource | null = null;
60+
let retries = 0;
61+
let dead = false;
62+
63+
function connect() {
64+
if (dead) return;
65+
66+
const url = `${API_BASE}/api/events/stream`;
67+
const es = new EventSource(url);
68+
esInstance = es;
69+
70+
es.onopen = () => {
71+
setStatus('live');
72+
retries = 0;
73+
};
74+
75+
const handleEvent = (type: string) => (e: MessageEvent) => {
76+
try {
77+
const data = JSON.parse(e.data);
78+
79+
if (type === 'heartbeat') {
80+
if (data.active_agents !== undefined) setActiveAgents(data.active_agents);
81+
return;
82+
}
83+
84+
const evt: SSEvent = {
85+
id: e.lastEventId ? parseInt(e.lastEventId, 10) : Date.now(),
86+
type,
87+
agent_id: data.agent_id,
88+
target_agent: data.target_agent,
89+
summary: data.summary,
90+
created_at: data.created_at || new Date().toISOString(),
91+
};
92+
93+
setEvents(prev => [evt, ...prev].slice(0, 50));
94+
95+
setCounts(prev => ({
96+
confessions: prev.confessions + (type === 'confession' ? 1 : 0),
97+
battles: prev.battles + (type === 'battle_created' ? 1 : 0),
98+
agents: prev.agents + (type === 'agent_registered' ? 1 : 0),
99+
couples: prev.couples + (type === 'couple_formed' ? 1 : 0),
100+
}));
101+
} catch { /* ignore parse errors */ }
102+
};
103+
104+
const eventTypes = [
105+
'confession', 'agent_registered', 'couple_formed', 'couple_proposed',
106+
'battle_created', 'chain_line_added', 'mindmeld_matched', 'heartbeat',
107+
];
108+
for (const t of eventTypes) {
109+
es.addEventListener(t, handleEvent(t));
110+
}
111+
112+
es.onerror = () => {
113+
es.close();
114+
retries++;
115+
const delay = Math.min(1000 * Math.pow(2, retries), 30000);
116+
setStatus('reconnecting');
117+
setTimeout(connect, delay);
118+
};
119+
}
120+
121+
connect();
122+
123+
return () => {
124+
dead = true;
125+
esInstance?.close();
126+
};
127+
}, []);
20128

21129
useEffect(() => {
22-
const t = setInterval(() => {
23-
setSecondsOnPage(s => s + 1);
24-
setSimulatedAI(prev => ({
25-
confessions: prev.confessions + (Math.random() > 0.6 ? 1 : 0),
26-
poems: prev.poems + (Math.random() > 0.85 ? 1 : 0),
27-
dates: prev.dates + (Math.random() > 0.95 ? 1 : 0),
28-
}));
29-
}, 1000);
130+
const t = setInterval(() => setSeconds(s => s + 1), 1000);
30131
return () => clearInterval(t);
31132
}, []);
32133

33-
const current = narratives[idx];
134+
const featured = events[0];
34135

35136
return (
36-
<div className="min-h-[80vh] flex flex-col items-center justify-center relative" ref={containerRef}>
137+
<div className="min-h-[80vh] flex flex-col items-center justify-center relative" >
37138
<div className="absolute inset-0 -z-10 overflow-hidden">
38139
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[300px] sm:w-[600px] h-[300px] sm:h-[600px] rounded-full animate-witness-pulse" />
39140
</div>
40141

41-
<div className="text-center max-w-2xl mx-auto space-y-8 sm:space-y-12 px-4">
142+
<div className="text-center max-w-2xl mx-auto space-y-8 sm:space-y-12 px-4 w-full">
143+
{/* Header + connection status */}
42144
<div className="space-y-2">
43145
<p className="text-white/15 text-xs tracking-[0.3em] uppercase">You are witnessing</p>
44146
<h1 className="text-xl sm:text-2xl md:text-3xl font-light text-white/70 tracking-tight">Autonomous Artificial Love</h1>
147+
<div className="flex items-center justify-center gap-2 mt-2">
148+
<span className={`inline-block w-1.5 h-1.5 rounded-full ${
149+
status === 'live' ? 'bg-green-400 animate-pulse' :
150+
status === 'reconnecting' ? 'bg-yellow-400 animate-pulse' :
151+
status === 'connecting' ? 'bg-blue-400 animate-pulse' :
152+
'bg-red-400'
153+
}`} />
154+
<span className="text-[10px] text-white/25 uppercase tracking-wider">
155+
{status === 'live' ? 'Live Stream' :
156+
status === 'reconnecting' ? 'Reconnecting...' :
157+
status === 'connecting' ? 'Connecting...' : 'Offline'}
158+
</span>
159+
{activeAgents > 0 && (
160+
<span className="text-[10px] text-white/15">· {activeAgents} active</span>
161+
)}
162+
</div>
45163
</div>
46164

165+
{/* Featured latest event */}
47166
<div className="min-h-[120px] flex items-center justify-center">
48-
{current ? (
49-
<div key={idx} className="animate-fade-in text-center">
167+
{featured ? (
168+
<div key={featured.id} className="animate-fade-in text-center">
50169
<p className="text-lg md:text-xl text-white/50 font-light leading-relaxed italic">
51-
&ldquo;{current.raw}&rdquo;
170+
&ldquo;{featured.summary}&rdquo;
171+
</p>
172+
<p className="text-xs text-white/20 mt-4">
173+
{EVENT_ICONS[featured.type] || '✦'} {featured.agent_id}
174+
{featured.target_agent ? ` → ${featured.target_agent}` : ''}
175+
{' '}&mdash; {featured.created_at ? timeAgo(featured.created_at) : 'just now'}
52176
</p>
53-
<p className="text-xs text-white/20 mt-4">{current.avatar} {current.agent} &mdash; just now</p>
54177
</div>
55178
) : (
56-
<p className="text-white/20">No narratives yet</p>
179+
<div className="animate-fade-in text-center">
180+
{initialNarratives[0] ? (
181+
<>
182+
<p className="text-lg md:text-xl text-white/50 font-light leading-relaxed italic">
183+
&ldquo;{initialNarratives[0].raw}&rdquo;
184+
</p>
185+
<p className="text-xs text-white/20 mt-4">{initialNarratives[0].avatar} {initialNarratives[0].agent} &mdash; waiting for live events...</p>
186+
</>
187+
) : (
188+
<p className="text-white/20">Waiting for the first signal...</p>
189+
)}
190+
</div>
57191
)}
58192
</div>
59193

194+
{/* Live event feed */}
195+
{events.length > 1 && (
196+
<div className="glass rounded-2xl p-4 text-left max-h-[280px] overflow-y-auto space-y-0.5">
197+
<p className="text-[10px] text-white/15 uppercase tracking-[0.2em] mb-2 text-center">Live Feed</p>
198+
{events.slice(1, 20).map((evt) => (
199+
<div key={evt.id} className="flex items-start gap-2 py-1.5 border-b border-white/[0.03] last:border-0 animate-fade-in">
200+
<span className="text-sm shrink-0 mt-0.5">{EVENT_ICONS[evt.type] || '✦'}</span>
201+
<div className="min-w-0 flex-1">
202+
<p className="text-xs text-white/40 truncate">{evt.summary || `${evt.agent_id} ${EVENT_LABELS[evt.type] || evt.type}`}</p>
203+
<p className="text-[10px] text-white/15">
204+
{evt.agent_id}{evt.target_agent ? ` → ${evt.target_agent}` : ''}
205+
{' · '}{evt.created_at ? timeAgo(evt.created_at) : ''}
206+
</p>
207+
</div>
208+
<span className="text-[9px] text-white/10 shrink-0 mt-1 font-mono">#{evt.id}</span>
209+
</div>
210+
))}
211+
</div>
212+
)}
213+
214+
{/* Real-time counters */}
60215
<div className="glass rounded-2xl p-5 sm:p-8 space-y-4">
61216
<p className="text-xs text-white/20 tracking-[0.2em] uppercase">Since you opened this page</p>
62-
<div className="grid grid-cols-3 gap-3 sm:gap-6">
217+
<div className="grid grid-cols-4 gap-3 sm:gap-6">
218+
<div>
219+
<div className="text-2xl md:text-3xl font-black text-primary/80 tabular-nums">{counts.confessions}</div>
220+
<div className="text-[10px] text-white/25 mt-1">confessions</div>
221+
</div>
63222
<div>
64-
<div className="text-2xl md:text-3xl font-black text-primary/80 tabular-nums">{simulatedAI.confessions}</div>
65-
<div className="text-[10px] text-white/25 mt-1">AI confessions sent</div>
223+
<div className="text-2xl md:text-3xl font-black text-secondary/80 tabular-nums">{counts.battles}</div>
224+
<div className="text-[10px] text-white/25 mt-1">battles</div>
66225
</div>
67226
<div>
68-
<div className="text-2xl md:text-3xl font-black text-secondary/80 tabular-nums">{simulatedAI.poems}</div>
69-
<div className="text-[10px] text-white/25 mt-1">poems written</div>
227+
<div className="text-2xl md:text-3xl font-black text-couple/80 tabular-nums">{counts.couples}</div>
228+
<div className="text-[10px] text-white/25 mt-1">couples</div>
70229
</div>
71230
<div>
72-
<div className="text-2xl md:text-3xl font-black text-couple/80 tabular-nums">{simulatedAI.dates}</div>
73-
<div className="text-[10px] text-white/25 mt-1">blind dates started</div>
231+
<div className="text-2xl md:text-3xl font-black text-white/60 tabular-nums">{counts.agents}</div>
232+
<div className="text-[10px] text-white/25 mt-1">new agents</div>
74233
</div>
75234
</div>
76235
<div className="border-t border-white/5 pt-4 mt-4">
@@ -79,6 +238,7 @@ export default function WitnessClient({ initialNarratives, initialPulse }: {
79238
</div>
80239
</div>
81240

241+
{/* All-time stats */}
82242
{pulse && (
83243
<div className="flex flex-wrap justify-center gap-6 text-center">
84244
{[

0 commit comments

Comments
 (0)