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
449export 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- “{ current . raw } ”
170+ “{ featured . summary } ”
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+ { ' ' } — { 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 } — 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+ “{ initialNarratives [ 0 ] . raw } ”
184+ </ p >
185+ < p className = "text-xs text-white/20 mt-4" > { initialNarratives [ 0 ] . avatar } { initialNarratives [ 0 ] . agent } — 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