RayMelius Claude Opus 4.6 commited on
Commit
03c082d
·
1 Parent(s): a4397fb

Upgrade web UI with tabbed sidebar and conversation history

Browse files

- Add tabbed sidebar: Agents list, Chat (conversations), Events log
- Conversations panel shows live and recent dialogues with full turns
- Agent list with sorting (in-conversation first), state icons, partner info
- Enhanced agent detail: relationship bars (familiarity/trust/sentiment/romance),
back navigation, clickable relationships to navigate between agents
- Canvas conversation bubbles showing last spoken message above agents
- Add /api/relationships endpoint for full relationship graph data
- Enhanced /api/conversations: returns full dialogue history (active + recent)
- Enhanced /api/agents/{id}: includes romance, trust, familiarity, interaction count
- Add conversation_history to simulation for preserving finished conversations
- Color-coded event log: moves, reflections, romance, conversations, events

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

src/soci/api/routes.py CHANGED
@@ -62,6 +62,7 @@ async def get_agents():
62
  "state": a.state.value,
63
  "mood": round(a.mood, 2),
64
  "action": a.current_action.detail if a.current_action else "idle",
 
65
  "is_player": a.is_player,
66
  }
67
  for aid, a in sim.agents.items()
@@ -92,11 +93,18 @@ async def get_agent(agent_id: str):
92
  "needs_description": agent.needs.describe(),
93
  "action": agent.current_action.detail if agent.current_action else "idle",
94
  "daily_plan": agent.daily_plan,
 
95
  "relationships": [
96
  {
97
  "agent_id": rel.agent_id,
98
  "name": rel.agent_name,
99
  "closeness": round(rel.closeness, 2),
 
 
 
 
 
 
100
  "description": rel.describe(),
101
  }
102
  for rel in agent.relationships.get_closest(10)
@@ -136,21 +144,42 @@ async def get_agent_memories(agent_id: str, limit: int = 20):
136
 
137
 
138
  @router.get("/conversations")
139
- async def get_active_conversations():
140
- """Get all active conversations."""
141
  from soci.api.server import get_simulation
142
  sim = get_simulation()
143
- return {
144
- cid: {
145
- "participants": [
146
- sim.agents[p].name for p in c.participants if p in sim.agents
147
- ],
148
- "topic": c.topic,
149
- "turns": len(c.turns),
150
- "latest": c.turns[-1].message if c.turns else "",
 
 
 
 
 
 
 
 
 
 
151
  }
152
- for cid, c in sim.active_conversations.items()
 
 
 
 
 
 
153
  }
 
 
 
 
 
154
 
155
 
156
  @router.get("/stats")
@@ -229,6 +258,39 @@ async def player_action(player_id: str, request: PlayerActionRequest):
229
  }
230
 
231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  @router.get("/events")
233
  async def get_events(limit: int = 50):
234
  """Get recent simulation events for the event log."""
 
62
  "state": a.state.value,
63
  "mood": round(a.mood, 2),
64
  "action": a.current_action.detail if a.current_action else "idle",
65
+ "partner_id": a.partner_id,
66
  "is_player": a.is_player,
67
  }
68
  for aid, a in sim.agents.items()
 
93
  "needs_description": agent.needs.describe(),
94
  "action": agent.current_action.detail if agent.current_action else "idle",
95
  "daily_plan": agent.daily_plan,
96
+ "partner_id": agent.partner_id,
97
  "relationships": [
98
  {
99
  "agent_id": rel.agent_id,
100
  "name": rel.agent_name,
101
  "closeness": round(rel.closeness, 2),
102
+ "romantic_interest": round(rel.romantic_interest, 2),
103
+ "relationship_status": rel.relationship_status,
104
+ "trust": round(rel.trust, 2),
105
+ "sentiment": round(rel.sentiment, 2),
106
+ "familiarity": round(rel.familiarity, 2),
107
+ "interaction_count": rel.interaction_count,
108
  "description": rel.describe(),
109
  }
110
  for rel in agent.relationships.get_closest(10)
 
144
 
145
 
146
  @router.get("/conversations")
147
+ async def get_conversations(include_history: bool = True, limit: int = 20):
148
+ """Get active and recent conversations with full dialogue."""
149
  from soci.api.server import get_simulation
150
  sim = get_simulation()
151
+
152
+ def format_conv(conv_data, active=False):
153
+ """Format a conversation (dict or Conversation object)."""
154
+ if hasattr(conv_data, 'to_dict'):
155
+ d = conv_data.to_dict()
156
+ else:
157
+ d = conv_data
158
+ participant_names = [
159
+ sim.agents[p].name for p in d.get("participants", []) if p in sim.agents
160
+ ]
161
+ return {
162
+ "id": d.get("id", ""),
163
+ "participants": d.get("participants", []),
164
+ "participant_names": participant_names,
165
+ "topic": d.get("topic", ""),
166
+ "location": d.get("location", ""),
167
+ "turns": d.get("turns", []),
168
+ "is_active": active,
169
  }
170
+
171
+ result = {
172
+ "active": [
173
+ format_conv(c, active=True)
174
+ for c in sim.active_conversations.values()
175
+ ],
176
+ "recent": [],
177
  }
178
+ if include_history:
179
+ result["recent"] = [
180
+ format_conv(c) for c in sim.conversation_history[-limit:]
181
+ ][::-1] # Most recent first
182
+ return result
183
 
184
 
185
  @router.get("/stats")
 
258
  }
259
 
260
 
261
+ @router.get("/relationships")
262
+ async def get_relationships():
263
+ """Get the full relationship graph — all agent-to-agent connections."""
264
+ from soci.api.server import get_simulation
265
+ sim = get_simulation()
266
+ edges = []
267
+ seen = set()
268
+ for aid, agent in sim.agents.items():
269
+ for rel in agent.relationships.get_closest(20):
270
+ pair = tuple(sorted([aid, rel.agent_id]))
271
+ if pair in seen:
272
+ continue
273
+ seen.add(pair)
274
+ other_rel = None
275
+ other = sim.agents.get(rel.agent_id)
276
+ if other:
277
+ other_rel = other.relationships.get(aid)
278
+ edges.append({
279
+ "source": aid,
280
+ "target": rel.agent_id,
281
+ "source_name": agent.name,
282
+ "target_name": rel.agent_name,
283
+ "familiarity": round(rel.familiarity, 2),
284
+ "trust": round(rel.trust, 2),
285
+ "sentiment": round(rel.sentiment, 2),
286
+ "romantic_interest": round(rel.romantic_interest, 2),
287
+ "relationship_status": rel.relationship_status,
288
+ "mutual_romantic": round(other_rel.romantic_interest, 2) if other_rel else 0,
289
+ "interaction_count": rel.interaction_count,
290
+ })
291
+ return {"edges": edges}
292
+
293
+
294
  @router.get("/events")
295
  async def get_events(limit: int = 50):
296
  """Get recent simulation events for the event log."""
src/soci/engine/simulation.py CHANGED
@@ -47,6 +47,8 @@ class Simulation:
47
  self.events = EventSystem()
48
  self.entropy = EntropyManager()
49
  self.active_conversations: dict[str, Conversation] = {}
 
 
50
  self._conversation_counter: int = 0
51
  self._max_concurrent = max_concurrent_llm
52
  self._tick_log: list[str] = [] # Log of events this tick
@@ -411,6 +413,11 @@ class Simulation:
411
  involved_agents=other_ids,
412
  )
413
 
 
 
 
 
 
414
  self._emit(
415
  f" [CONV END] Conversation about '{conv.topic}' between "
416
  f"{', '.join(self.agents[p].name for p in conv.participants if p in self.agents)} ended."
 
47
  self.events = EventSystem()
48
  self.entropy = EntropyManager()
49
  self.active_conversations: dict[str, Conversation] = {}
50
+ self.conversation_history: list[dict] = [] # Finished conversations for API
51
+ self._max_conversation_history: int = 50
52
  self._conversation_counter: int = 0
53
  self._max_concurrent = max_concurrent_llm
54
  self._tick_log: list[str] = [] # Log of events this tick
 
413
  involved_agents=other_ids,
414
  )
415
 
416
+ # Store in conversation history for API
417
+ self.conversation_history.append(conv.to_dict())
418
+ if len(self.conversation_history) > self._max_conversation_history:
419
+ self.conversation_history = self.conversation_history[-self._max_conversation_history:]
420
+
421
  self._emit(
422
  f" [CONV END] Conversation about '{conv.topic}' between "
423
  f"{', '.join(self.agents[p].name for p in conv.participants if p in self.agents)} ended."
web/index.html CHANGED
@@ -30,37 +30,107 @@
30
  #main { display: flex; height: calc(100vh - 50px); }
31
  #canvas-container { flex: 1; position: relative; min-width: 0; }
32
  #cityCanvas { width: 100%; height: 100%; display: block; }
 
 
33
  #sidebar {
34
- width: 340px; background: #16213e; border-left: 2px solid #0f3460;
35
  display: flex; flex-direction: column; overflow: hidden;
36
  }
37
- #agent-detail {
38
- padding: 12px; border-bottom: 1px solid #0f3460;
39
- min-height: 240px; max-height: 50%; overflow-y: auto;
 
 
 
 
40
  }
 
 
 
 
 
 
 
41
  #agent-detail h2 { font-size: 16px; color: #4ecca3; margin-bottom: 8px; }
42
- #agent-detail .subtitle { font-size: 12px; color: #a0a0c0; margin-bottom: 10px; }
43
- .bar-container { margin: 4px 0; }
44
  .bar-label { font-size: 11px; color: #a0a0c0; display: flex; justify-content: space-between; }
45
- .bar-bg { height: 8px; background: #0f3460; border-radius: 4px; overflow: hidden; margin-top: 2px; }
46
- .bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
47
  .bar-fill.green { background: #4ecca3; } .bar-fill.yellow { background: #f0c040; }
48
  .bar-fill.red { background: #e94560; } .bar-fill.blue { background: #4e9eca; }
49
  .bar-fill.purple { background: #9b59b6; } .bar-fill.orange { background: #e67e22; }
50
  .bar-fill.pink { background: #e91e90; }
51
  .memory-item { font-size: 11px; color: #c0c0d0; padding: 3px 0; border-bottom: 1px solid #0f346030; }
52
  .memory-time { color: #666; font-size: 10px; }
53
- #event-log { flex: 1; padding: 8px 12px; overflow-y: auto; font-size: 11px; line-height: 1.5; }
54
- #event-log h3 { font-size: 13px; color: #e94560; margin-bottom: 6px; position: sticky; top: 0; background: #16213e; padding: 4px 0; z-index: 1; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  .event-line { padding: 2px 0; color: #b0b0c0; border-bottom: 1px solid #0f346020; }
56
  .event-line.plan { color: #4ecca3; } .event-line.conv { color: #f0c040; }
57
  .event-line.event { color: #e94560; } .event-line.time { color: #666; font-weight: bold; margin-top: 6px; }
58
  .event-line.romance { color: #e91e90; }
 
 
 
 
59
  #tooltip {
60
  position: absolute; background: #16213eee; border: 1px solid #4ecca3;
61
  border-radius: 6px; padding: 8px 12px; font-size: 12px;
62
  pointer-events: none; display: none; z-index: 100; max-width: 250px;
63
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  ::-webkit-scrollbar { width: 6px; }
65
  ::-webkit-scrollbar-track { background: #1a1a2e; }
66
  ::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
@@ -73,6 +143,7 @@
73
  <span id="clock">Day 1, 06:00</span>
74
  <span><span id="weather-icon"></span> <span id="weather">Sunny</span></span>
75
  <span id="agent-count"><span class="dot green"></span> 0 agents</span>
 
76
  <span id="api-calls">API: 0</span>
77
  <span id="cost">$0.00</span>
78
  <span id="status"><span class="dot yellow"></span> Connecting...</span>
@@ -84,13 +155,19 @@
84
  <div id="tooltip"></div>
85
  </div>
86
  <div id="sidebar">
87
- <div id="agent-detail">
88
- <h2>Select an Agent</h2>
89
- <p class="subtitle">Click on any agent in the city to see their details</p>
 
 
 
 
 
 
 
90
  </div>
91
- <div id="event-log">
92
- <h3>Event Log</h3>
93
- <div id="events-container"></div>
94
  </div>
95
  </div>
96
  </div>
@@ -100,9 +177,8 @@
100
  // ============================================================
101
  const API_BASE = window.location.origin + '/api';
102
  const POLL_INTERVAL = 2000;
103
- const HORIZON = 0.18; // Sky is top 18%
104
 
105
- // All locations below the horizon
106
  const LOCATION_POSITIONS = {
107
  home_north: { x: 0.07, y: 0.28 },
108
  park: { x: 0.40, y: 0.22 },
@@ -152,7 +228,6 @@ const SKY = {
152
  night: { top:'#060610', bot:'#101028', stars:true, sun:'moon' },
153
  };
154
 
155
- // Ground tint per time (r,g,b multipliers)
156
  const GROUND_TINT = {
157
  dawn: { base:'#3a5a2a', shade: 0.7 },
158
  morning: { base:'#4a7a38', shade: 1.0 },
@@ -167,10 +242,11 @@ const GROUND_TINT = {
167
  let canvas, ctx;
168
  let locations = {};
169
  let agents = {};
170
- let agentPositions = {}; // current animated {x,y}
171
- let agentTargets = {}; // target {x,y} from data
172
  let selectedAgentId = null;
173
  let eventLog = [];
 
174
  let connected = false;
175
  let hoveredAgent = null;
176
  let currentTimeOfDay = 'morning';
@@ -179,6 +255,8 @@ let animFrame = 0;
179
  let raindrops = [];
180
  let clouds = [];
181
  let stars = [];
 
 
182
 
183
  function initParticles() {
184
  for (let i = 0; i < 120; i++)
@@ -189,6 +267,16 @@ function initParticles() {
189
  stars.push({x:Math.random(), y:Math.random()*HORIZON, size:0.5+Math.random()*1.5, tw:Math.random()*6.28});
190
  }
191
 
 
 
 
 
 
 
 
 
 
 
192
  // ============================================================
193
  // CANVAS
194
  // ============================================================
@@ -210,7 +298,6 @@ function resizeCanvas() {
210
 
211
  function animate() {
212
  animFrame++;
213
- // Lerp agent positions toward targets
214
  for (const [id, target] of Object.entries(agentTargets)) {
215
  if (!agentPositions[id]) { agentPositions[id] = {...target}; continue; }
216
  const p = agentPositions[id];
@@ -235,35 +322,36 @@ function draw() {
235
 
236
  for (const [id, loc] of Object.entries(locations)) drawBuilding(id, loc, W, H);
237
 
238
- // Compute agent layout targets
239
  const byLoc = {};
240
  for (const [id, a] of Object.entries(agents)) {
241
  const loc = a.location || 'home_north';
242
  if (!byLoc[loc]) byLoc[loc] = [];
243
  byLoc[loc].push({id, ...a});
244
  }
245
- let idx = 0;
246
  for (const [id, a] of Object.entries(agents)) {
247
- computeAgentTarget(id, a, idx, byLoc, W, H);
248
- idx++;
249
  }
250
 
251
- // Draw couple lines first (behind agents)
252
  drawCoupleLines(W, H);
 
253
 
254
- idx = 0;
255
  for (const [id, a] of Object.entries(agents)) {
256
- drawPerson(id, a, idx, W, H);
257
- idx++;
258
  }
259
 
260
- // Fog overlay
261
  if (currentWeather === 'foggy') {
262
  ctx.fillStyle = `rgba(180,190,200,${0.30 + Math.sin(animFrame*0.015)*0.08})`;
263
  ctx.fillRect(0, 0, W, H);
264
  }
265
  }
266
 
 
 
 
 
 
 
 
267
  // ============================================================
268
  // SKY
269
  // ============================================================
@@ -311,14 +399,11 @@ function drawMoon(x, y, r) {
311
  }
312
 
313
  // ============================================================
314
- // GROUND — color changes with time & weather
315
  // ============================================================
316
  function drawGround(W, H) {
317
  const hLine = H * HORIZON;
318
  const gt = GROUND_TINT[currentTimeOfDay] || GROUND_TINT.morning;
319
- const shade = gt.shade;
320
-
321
- // Base ground gradient
322
  const grad = ctx.createLinearGradient(0, hLine, 0, H);
323
  const bc = hexToRgb(gt.base);
324
  grad.addColorStop(0, `rgb(${bc.r+20},${bc.g+30},${bc.b+10})`);
@@ -326,13 +411,11 @@ function drawGround(W, H) {
326
  ctx.fillStyle = grad;
327
  ctx.fillRect(0, hLine, W, H - hLine);
328
 
329
- // Weather tint overlay
330
  if (currentWeather === 'rainy' || currentWeather === 'stormy') {
331
  ctx.fillStyle = `rgba(40, 50, 70, ${currentWeather === 'stormy' ? 0.35 : 0.2})`;
332
  ctx.fillRect(0, hLine, W, H - hLine);
333
  }
334
 
335
- // Subtle grass dots
336
  ctx.fillStyle = `rgba(${60+bc.r*0.3},${90+bc.g*0.3},${40+bc.b*0.2}, 0.15)`;
337
  for (let i = 0; i < 80; i++) {
338
  const gx = (i*37+13)%W;
@@ -340,7 +423,6 @@ function drawGround(W, H) {
340
  ctx.fillRect(gx, gy, 2, 3);
341
  }
342
 
343
- // Horizon line — soft blend
344
  const horizGrad = ctx.createLinearGradient(0, hLine-4, 0, hLine+6);
345
  const s = SKY[currentTimeOfDay] || SKY.morning;
346
  horizGrad.addColorStop(0, s.bot);
@@ -405,7 +487,6 @@ function drawRoads(W, H) {
405
  ctx.beginPath(); ctx.moveTo(p.x*W,p.y*H); ctx.lineTo(cp.x*W,cp.y*H); ctx.stroke();
406
  }
407
  }
408
- // Center dashes
409
  ctx.strokeStyle=isNight?'rgba(80,70,55,0.25)':'rgba(200,190,150,0.30)';
410
  ctx.lineWidth=1; ctx.setLineDash([5,7]);
411
  drawn.clear();
@@ -437,7 +518,6 @@ function drawBuilding(id, loc, W, H) {
437
  else if (type==='shop') drawShop(x,y,colors,isDark);
438
  else drawOffice(x,y,colors,isDark);
439
 
440
- // Label
441
  if (type!=='park'&&type!=='street') {
442
  const name=loc.name||id;
443
  const short=name.length>18?name.slice(0,16)+'..':name;
@@ -447,7 +527,6 @@ function drawBuilding(id, loc, W, H) {
447
  ctx.fillStyle=isDark?'#a0a8c0':'#fff'; ctx.fillText(short,x,ly);
448
  }
449
 
450
- // Occupant badge
451
  const occ=(loc.occupants||[]).length;
452
  if (occ>0) {
453
  const bx=x+32, by=y-20;
@@ -532,12 +611,10 @@ function drawCoupleLines(W, H) {
532
  drawn.add(id); drawn.add(a.partner_id);
533
  const p1 = agentPositions[id], p2 = agentPositions[a.partner_id];
534
  if (!p1 || !p2) continue;
535
- // Pink dashed line between partners
536
  ctx.strokeStyle = 'rgba(233, 30, 144, 0.4)';
537
  ctx.lineWidth = 1.5; ctx.setLineDash([4,4]);
538
  ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke();
539
  ctx.setLineDash([]);
540
- // Heart at midpoint
541
  const mx = (p1.x+p2.x)/2, my = (p1.y+p2.y)/2 - 10;
542
  drawHeart(mx, my + Math.sin(animFrame*0.05)*3, 6, 'rgba(233,30,144,0.7)');
543
  }
@@ -554,6 +631,42 @@ function drawHeart(x, y, s, color) {
554
  ctx.fill();
555
  }
556
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
  // ============================================================
558
  // AGENT TARGET COMPUTATION
559
  // ============================================================
@@ -591,7 +704,6 @@ function drawPerson(id, agent, globalIdx, W, H) {
591
  const isMoving = agent.state === 'moving';
592
  const isSleeping = agent.state === 'sleeping';
593
 
594
- // Check if actually animating (position differs from target)
595
  const tgt = agentTargets[id];
596
  const moving = tgt && Math.hypot(ax-tgt.x, ay-tgt.y) > 3;
597
 
@@ -605,7 +717,7 @@ function drawPerson(id, agent, globalIdx, W, H) {
605
  const armSwing = (isMoving||moving) ? Math.sin(animFrame*0.15)*8 : 0;
606
  const legSwing = (isMoving||moving) ? Math.sin(animFrame*0.15)*5 : 0;
607
 
608
- // Shadow on ground
609
  ctx.fillStyle = 'rgba(0,0,0,0.15)';
610
  ctx.beginPath(); ctx.ellipse(0, 13, 7, 3, 0, 0, 6.28); ctx.fill();
611
 
@@ -670,7 +782,6 @@ function drawPerson(id, agent, globalIdx, W, H) {
670
  ctx.globalAlpha=1;
671
  }
672
 
673
- // Heart above head if has partner
674
  if (agent.partner_id) {
675
  drawHeart(0, -30+bounce+Math.sin(animFrame*0.04)*2, 4, 'rgba(233,30,144,0.7)');
676
  }
@@ -703,8 +814,14 @@ function onCanvasClick(e) {
703
  const d=Math.hypot(mx-pos.x,my-pos.y);
704
  if(d<minD){minD=d;clicked=id;}
705
  }
706
- if(clicked){selectedAgentId=clicked;fetchAgentDetail(clicked);}
707
- else{selectedAgentId=null;showDefaultDetail();}
 
 
 
 
 
 
708
  }
709
 
710
  function onCanvasMouseMove(e) {
@@ -734,22 +851,35 @@ function onCanvasMouseMove(e) {
734
  }
735
 
736
  // ============================================================
737
- // AGENT DETAIL PANEL
738
  // ============================================================
739
  function showDefaultDetail() {
740
- document.getElementById('agent-detail').innerHTML = `
741
- <h2>Select an Agent</h2>
742
- <p class="subtitle">Click on any agent in the city to see their details</p>
743
- <div style="margin-top:12px">
744
- <h3 style="font-size:13px;color:#a0a0c0;margin-bottom:8px">Population</h3>
745
- ${Object.entries(agents).map(([aid,a],i) => `
746
- <div style="font-size:11px;padding:3px 0;cursor:pointer;color:${AGENT_COLORS[i%AGENT_COLORS.length]};display:flex;align-items:center;gap:6px"
747
- onclick="selectedAgentId='${aid}';fetchAgentDetail(selectedAgentId);">
748
- <span style="font-size:14px">${a.gender==='female'?'\uD83D\uDC69':a.gender==='male'?'\uD83D\uDC68':'\uD83E\uDDD1'}</span>
749
- <span>${a.name}${a.partner_id&&agents[a.partner_id]?' \u2764 '+agents[a.partner_id].name:''} &mdash; <span style="color:#888">${a.action||'idle'}</span></span>
750
- </div>
751
- `).join('')}
752
- </div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
753
  }
754
 
755
  async function fetchAgentDetail(agentId) {
@@ -774,18 +904,21 @@ function renderAgentDetail(data) {
774
  const moodColor=moodPct>60?'green':(moodPct>30?'yellow':'red');
775
  const moodLabel=mood>0.3?'Happy':(mood>-0.3?'Okay':'Unhappy');
776
  const gi=data.gender==='female'?'\uD83D\uDC69':data.gender==='male'?'\uD83D\uDC68':'\uD83E\uDDD1';
777
- const memories=(data.recent_memories||[]).slice(-5).reverse();
778
-
779
- // Find romance info from relationships
780
  const romanceRels=(data.relationships||[]).filter(r=>r.relationship_status&&r.relationship_status!=='none');
 
781
 
782
  document.getElementById('agent-detail').innerHTML=`
 
 
 
783
  <h2>${gi} ${data.name||'?'}</h2>
784
- <p class="subtitle">${data.age||'?'} y/o ${data.occupation||''} &mdash; ${data.traits||''}</p>
785
- <p class="subtitle" style="color:#e0e0e0">${esc(data.action||'idle')}</p>
 
786
 
787
- ${romanceRels.length>0?`<div style="margin:4px 0;padding:4px 8px;background:rgba(233,30,144,0.1);border-radius:4px;font-size:11px;color:#e91e90">
788
- ${romanceRels.map(r=>`\u2764 ${r.relationship_status} with ${r.name} (love: ${(r.romantic_interest*100).toFixed(0)}%)`).join('<br>')}
789
  </div>`:''}
790
 
791
  <div class="bar-container">
@@ -801,45 +934,109 @@ function renderAgentDetail(data) {
801
  </div>`;
802
  }).join('')}
803
 
804
- <div style="margin-top:8px">
805
- <div style="font-size:11px;color:#a0a0c0">Plan: ${esc((data.daily_plan||[]).slice(0,3).join('; ')||'None yet')}</div>
806
- </div>
807
-
808
  <div style="margin-top:6px">
809
- <div style="font-size:12px;color:#4ecca3;margin-bottom:4px">Recent Memories</div>
810
- ${memories.map(m=>`<div class="memory-item"><span class="memory-time">${m.time||''}</span> ${esc(m.content||'')}</div>`).join('')||'<div class="memory-item">No memories yet</div>'}
811
  </div>
812
 
813
  ${(data.relationships||[]).length>0?`
814
- <div style="margin-top:6px">
815
- <div style="font-size:12px;color:#4ecca3;margin-bottom:4px">Relationships</div>
816
- ${data.relationships.slice(0,5).map(r=>`
817
- <div style="font-size:11px;color:#b0b0c0;padding:1px 0">
818
- ${esc(r.name)}: closeness ${(r.closeness*100).toFixed(0)}%${r.relationship_status&&r.relationship_status!=='none'?' <span style="color:#e91e90">\u2764 '+r.relationship_status+'</span>':''}
819
- </div>
820
- `).join('')}
821
- </div>`:''}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
822
  `;
823
  }
824
 
825
  // ============================================================
826
- // EVENT LOG
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
827
  // ============================================================
828
  function renderEventLog() {
829
- const c=document.getElementById('events-container');
830
- c.innerHTML=eventLog.slice(-60).map(line=>{
831
- let cls='event-line';
832
- if(line.includes('[PLAN]')) cls+=' plan';
833
- else if(line.includes('[CONV]')) cls+=' conv';
834
- else if(line.includes('[ROMANCE]')) cls+=' romance';
835
- else if(line.includes('[EVENT]')||line.includes('[ENTROPY]')) cls+=' event';
836
- else if(line.startsWith('---')||line.startsWith('\n---')) cls+=' time';
 
 
837
  return `<div class="${cls}">${esc(line)}</div>`;
838
  }).join('');
839
- c.scrollTop=c.scrollHeight;
840
  }
841
 
842
- function esc(s){return s?s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'):''}
843
 
844
  // ============================================================
845
  // DATA FETCHING
@@ -853,39 +1050,61 @@ async function fetchState() {
853
  const data = await res.json();
854
  if (!connected) { connected=true; document.getElementById('status').innerHTML='<span class="dot green"></span> Connected'; }
855
 
856
- const clock=data.clock||{};
857
- currentTimeOfDay=clock.time_of_day||'morning';
858
- currentWeather=(data.weather||'sunny').toLowerCase();
859
-
860
- document.getElementById('clock').textContent=`Day ${clock.day||1}, ${clock.time_str||'??:??'} (${currentTimeOfDay})`;
861
- document.getElementById('weather-icon').textContent=WEATHER_ICONS[currentWeather]||'\u2600\uFE0F';
862
- document.getElementById('weather').textContent=currentWeather;
863
- document.getElementById('agent-count').innerHTML=`<span class="dot green"></span> ${Object.keys(data.agents||{}).length} agents`;
864
-
865
- const usage=data.llm_usage||'';
866
- const cm=usage.match(/calls:\s*(\d+)/i), $m=usage.match(/\$([0-9.]+)/);
867
- document.getElementById('api-calls').textContent=`API: ${cm?cm[1]:'0'}`;
868
- document.getElementById('cost').textContent=$m?`$${$m[1]}`:'$0.00';
869
-
870
- agents=data.agents||{};
871
-
872
- const locRes=await fetch(`${API_BASE}/city/locations`);
873
- if(locRes.ok) locations=await locRes.json();
874
-
875
- const tick=clock.total_ticks||0;
876
- if(tick!==lastTick){
877
- try{
878
- const er=await fetch(`${API_BASE}/events`);
879
- if(er.ok){const d2=await er.json(); eventLog=(d2.events||[]).map(e=>e.message||'').filter(m=>m.trim()); renderEventLog();}
880
- }catch(e){}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
881
  }
882
- lastTick=tick;
883
 
884
- if(selectedAgentId) fetchAgentDetail(selectedAgentId);
885
- else showDefaultDetail();
 
 
886
 
887
- } catch(e){
888
- if(connected){connected=false;document.getElementById('status').innerHTML='<span class="dot red"></span> Disconnected';}
889
  }
890
  }
891
 
 
30
  #main { display: flex; height: calc(100vh - 50px); }
31
  #canvas-container { flex: 1; position: relative; min-width: 0; }
32
  #cityCanvas { width: 100%; height: 100%; display: block; }
33
+
34
+ /* SIDEBAR */
35
  #sidebar {
36
+ width: 380px; background: #16213e; border-left: 2px solid #0f3460;
37
  display: flex; flex-direction: column; overflow: hidden;
38
  }
39
+ .sidebar-tabs {
40
+ display: flex; border-bottom: 2px solid #0f3460; flex-shrink: 0;
41
+ }
42
+ .sidebar-tab {
43
+ flex: 1; padding: 8px 4px; text-align: center; font-size: 11px; font-weight: bold;
44
+ color: #666; cursor: pointer; border-bottom: 2px solid transparent;
45
+ transition: all 0.2s; text-transform: uppercase; letter-spacing: 0.5px;
46
  }
47
+ .sidebar-tab:hover { color: #a0a0c0; background: rgba(255,255,255,0.02); }
48
+ .sidebar-tab.active { color: #4ecca3; border-bottom-color: #4ecca3; }
49
+ .tab-content { display: none; flex: 1; overflow-y: auto; }
50
+ .tab-content.active { display: flex; flex-direction: column; }
51
+
52
+ /* AGENT DETAIL TAB */
53
+ #agent-detail { padding: 12px; flex: 1; overflow-y: auto; }
54
  #agent-detail h2 { font-size: 16px; color: #4ecca3; margin-bottom: 8px; }
55
+ #agent-detail .subtitle { font-size: 12px; color: #a0a0c0; margin-bottom: 6px; }
56
+ .bar-container { margin: 3px 0; }
57
  .bar-label { font-size: 11px; color: #a0a0c0; display: flex; justify-content: space-between; }
58
+ .bar-bg { height: 6px; background: #0f3460; border-radius: 3px; overflow: hidden; margin-top: 1px; }
59
+ .bar-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; }
60
  .bar-fill.green { background: #4ecca3; } .bar-fill.yellow { background: #f0c040; }
61
  .bar-fill.red { background: #e94560; } .bar-fill.blue { background: #4e9eca; }
62
  .bar-fill.purple { background: #9b59b6; } .bar-fill.orange { background: #e67e22; }
63
  .bar-fill.pink { background: #e91e90; }
64
  .memory-item { font-size: 11px; color: #c0c0d0; padding: 3px 0; border-bottom: 1px solid #0f346030; }
65
  .memory-time { color: #666; font-size: 10px; }
66
+ .agent-list-item {
67
+ font-size: 11px; padding: 5px 6px; cursor: pointer; display: flex; align-items: center; gap: 6px;
68
+ border-bottom: 1px solid #0f346020; transition: background 0.15s;
69
+ }
70
+ .agent-list-item:hover { background: rgba(78,204,163,0.08); }
71
+ .agent-list-item .agent-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
72
+ .agent-list-item .agent-info { flex: 1; min-width: 0; }
73
+ .agent-list-item .agent-name { font-weight: 600; }
74
+ .agent-list-item .agent-action { color: #666; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
75
+
76
+ /* CONVERSATIONS TAB */
77
+ #conversations-panel { padding: 0; flex: 1; overflow-y: auto; }
78
+ .conv-card {
79
+ margin: 8px; padding: 10px; background: #1a1a2e; border-radius: 6px;
80
+ border: 1px solid #0f3460;
81
+ }
82
+ .conv-card.active-conv { border-color: #4ecca3; }
83
+ .conv-header {
84
+ display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;
85
+ }
86
+ .conv-topic { font-size: 12px; color: #4ecca3; font-weight: 600; }
87
+ .conv-badge {
88
+ font-size: 9px; padding: 2px 6px; border-radius: 3px; font-weight: bold;
89
+ text-transform: uppercase;
90
+ }
91
+ .conv-badge.live { background: #4ecca3; color: #1a1a2e; }
92
+ .conv-badge.ended { background: #333; color: #888; }
93
+ .conv-participants { font-size: 10px; color: #888; margin-bottom: 6px; }
94
+ .conv-turn {
95
+ padding: 4px 0; font-size: 11px; line-height: 1.4;
96
+ }
97
+ .conv-speaker { font-weight: 600; }
98
+ .conv-message { color: #d0d0e0; }
99
+ .conv-empty { padding: 20px; text-align: center; color: #555; font-size: 12px; }
100
+
101
+ /* EVENT LOG TAB */
102
+ #event-log-panel { padding: 8px 12px; flex: 1; overflow-y: auto; font-size: 11px; line-height: 1.5; }
103
  .event-line { padding: 2px 0; color: #b0b0c0; border-bottom: 1px solid #0f346020; }
104
  .event-line.plan { color: #4ecca3; } .event-line.conv { color: #f0c040; }
105
  .event-line.event { color: #e94560; } .event-line.time { color: #666; font-weight: bold; margin-top: 6px; }
106
  .event-line.romance { color: #e91e90; }
107
+ .event-line.move { color: #4e9eca; }
108
+ .event-line.reflect { color: #9b59b6; }
109
+
110
+ /* TOOLTIP */
111
  #tooltip {
112
  position: absolute; background: #16213eee; border: 1px solid #4ecca3;
113
  border-radius: 6px; padding: 8px 12px; font-size: 12px;
114
  pointer-events: none; display: none; z-index: 100; max-width: 250px;
115
  }
116
+ /* Section headers */
117
+ .section-header {
118
+ font-size: 12px; color: #4ecca3; margin: 10px 0 4px 0; font-weight: 600;
119
+ display: flex; align-items: center; gap: 6px;
120
+ }
121
+ .section-header::after {
122
+ content: ''; flex: 1; height: 1px; background: #0f3460;
123
+ }
124
+ /* Relationship bars */
125
+ .rel-item {
126
+ font-size: 11px; padding: 4px 0; border-bottom: 1px solid #0f346020; cursor: pointer;
127
+ }
128
+ .rel-item:hover { background: rgba(78,204,163,0.05); }
129
+ .rel-name { font-weight: 600; color: #d0d0e0; }
130
+ .rel-bars { display: flex; gap: 4px; margin-top: 2px; }
131
+ .rel-mini-bar { flex: 1; height: 4px; background: #0f3460; border-radius: 2px; overflow: hidden; }
132
+ .rel-mini-fill { height: 100%; border-radius: 2px; }
133
+
134
  ::-webkit-scrollbar { width: 6px; }
135
  ::-webkit-scrollbar-track { background: #1a1a2e; }
136
  ::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
 
143
  <span id="clock">Day 1, 06:00</span>
144
  <span><span id="weather-icon"></span> <span id="weather">Sunny</span></span>
145
  <span id="agent-count"><span class="dot green"></span> 0 agents</span>
146
+ <span id="conv-count">0 convos</span>
147
  <span id="api-calls">API: 0</span>
148
  <span id="cost">$0.00</span>
149
  <span id="status"><span class="dot yellow"></span> Connecting...</span>
 
155
  <div id="tooltip"></div>
156
  </div>
157
  <div id="sidebar">
158
+ <div class="sidebar-tabs">
159
+ <div class="sidebar-tab active" data-tab="agents" onclick="switchTab('agents')">Agents</div>
160
+ <div class="sidebar-tab" data-tab="conversations" onclick="switchTab('conversations')">Chat</div>
161
+ <div class="sidebar-tab" data-tab="events" onclick="switchTab('events')">Events</div>
162
+ </div>
163
+ <div id="tab-agents" class="tab-content active">
164
+ <div id="agent-detail"></div>
165
+ </div>
166
+ <div id="tab-conversations" class="tab-content">
167
+ <div id="conversations-panel"></div>
168
  </div>
169
+ <div id="tab-events" class="tab-content">
170
+ <div id="event-log-panel"></div>
 
171
  </div>
172
  </div>
173
  </div>
 
177
  // ============================================================
178
  const API_BASE = window.location.origin + '/api';
179
  const POLL_INTERVAL = 2000;
180
+ const HORIZON = 0.18;
181
 
 
182
  const LOCATION_POSITIONS = {
183
  home_north: { x: 0.07, y: 0.28 },
184
  park: { x: 0.40, y: 0.22 },
 
228
  night: { top:'#060610', bot:'#101028', stars:true, sun:'moon' },
229
  };
230
 
 
231
  const GROUND_TINT = {
232
  dawn: { base:'#3a5a2a', shade: 0.7 },
233
  morning: { base:'#4a7a38', shade: 1.0 },
 
242
  let canvas, ctx;
243
  let locations = {};
244
  let agents = {};
245
+ let agentPositions = {};
246
+ let agentTargets = {};
247
  let selectedAgentId = null;
248
  let eventLog = [];
249
+ let conversationData = { active: [], recent: [] };
250
  let connected = false;
251
  let hoveredAgent = null;
252
  let currentTimeOfDay = 'morning';
 
255
  let raindrops = [];
256
  let clouds = [];
257
  let stars = [];
258
+ let activeTab = 'agents';
259
+ let agentIdxMap = {}; // id -> stable index for consistent coloring
260
 
261
  function initParticles() {
262
  for (let i = 0; i < 120; i++)
 
267
  stars.push({x:Math.random(), y:Math.random()*HORIZON, size:0.5+Math.random()*1.5, tw:Math.random()*6.28});
268
  }
269
 
270
+ // ============================================================
271
+ // TABS
272
+ // ============================================================
273
+ function switchTab(tab) {
274
+ activeTab = tab;
275
+ document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
276
+ document.querySelectorAll('.tab-content').forEach(t => t.classList.toggle('active', t.id === 'tab-' + tab));
277
+ if (tab === 'conversations') fetchConversations();
278
+ }
279
+
280
  // ============================================================
281
  // CANVAS
282
  // ============================================================
 
298
 
299
  function animate() {
300
  animFrame++;
 
301
  for (const [id, target] of Object.entries(agentTargets)) {
302
  if (!agentPositions[id]) { agentPositions[id] = {...target}; continue; }
303
  const p = agentPositions[id];
 
322
 
323
  for (const [id, loc] of Object.entries(locations)) drawBuilding(id, loc, W, H);
324
 
 
325
  const byLoc = {};
326
  for (const [id, a] of Object.entries(agents)) {
327
  const loc = a.location || 'home_north';
328
  if (!byLoc[loc]) byLoc[loc] = [];
329
  byLoc[loc].push({id, ...a});
330
  }
 
331
  for (const [id, a] of Object.entries(agents)) {
332
+ computeAgentTarget(id, a, getAgentIdx(id), byLoc, W, H);
 
333
  }
334
 
 
335
  drawCoupleLines(W, H);
336
+ drawConversationBubbles(W, H);
337
 
 
338
  for (const [id, a] of Object.entries(agents)) {
339
+ drawPerson(id, a, getAgentIdx(id), W, H);
 
340
  }
341
 
 
342
  if (currentWeather === 'foggy') {
343
  ctx.fillStyle = `rgba(180,190,200,${0.30 + Math.sin(animFrame*0.015)*0.08})`;
344
  ctx.fillRect(0, 0, W, H);
345
  }
346
  }
347
 
348
+ function getAgentIdx(id) {
349
+ if (!(id in agentIdxMap)) {
350
+ agentIdxMap[id] = Object.keys(agentIdxMap).length;
351
+ }
352
+ return agentIdxMap[id];
353
+ }
354
+
355
  // ============================================================
356
  // SKY
357
  // ============================================================
 
399
  }
400
 
401
  // ============================================================
402
+ // GROUND
403
  // ============================================================
404
  function drawGround(W, H) {
405
  const hLine = H * HORIZON;
406
  const gt = GROUND_TINT[currentTimeOfDay] || GROUND_TINT.morning;
 
 
 
407
  const grad = ctx.createLinearGradient(0, hLine, 0, H);
408
  const bc = hexToRgb(gt.base);
409
  grad.addColorStop(0, `rgb(${bc.r+20},${bc.g+30},${bc.b+10})`);
 
411
  ctx.fillStyle = grad;
412
  ctx.fillRect(0, hLine, W, H - hLine);
413
 
 
414
  if (currentWeather === 'rainy' || currentWeather === 'stormy') {
415
  ctx.fillStyle = `rgba(40, 50, 70, ${currentWeather === 'stormy' ? 0.35 : 0.2})`;
416
  ctx.fillRect(0, hLine, W, H - hLine);
417
  }
418
 
 
419
  ctx.fillStyle = `rgba(${60+bc.r*0.3},${90+bc.g*0.3},${40+bc.b*0.2}, 0.15)`;
420
  for (let i = 0; i < 80; i++) {
421
  const gx = (i*37+13)%W;
 
423
  ctx.fillRect(gx, gy, 2, 3);
424
  }
425
 
 
426
  const horizGrad = ctx.createLinearGradient(0, hLine-4, 0, hLine+6);
427
  const s = SKY[currentTimeOfDay] || SKY.morning;
428
  horizGrad.addColorStop(0, s.bot);
 
487
  ctx.beginPath(); ctx.moveTo(p.x*W,p.y*H); ctx.lineTo(cp.x*W,cp.y*H); ctx.stroke();
488
  }
489
  }
 
490
  ctx.strokeStyle=isNight?'rgba(80,70,55,0.25)':'rgba(200,190,150,0.30)';
491
  ctx.lineWidth=1; ctx.setLineDash([5,7]);
492
  drawn.clear();
 
518
  else if (type==='shop') drawShop(x,y,colors,isDark);
519
  else drawOffice(x,y,colors,isDark);
520
 
 
521
  if (type!=='park'&&type!=='street') {
522
  const name=loc.name||id;
523
  const short=name.length>18?name.slice(0,16)+'..':name;
 
527
  ctx.fillStyle=isDark?'#a0a8c0':'#fff'; ctx.fillText(short,x,ly);
528
  }
529
 
 
530
  const occ=(loc.occupants||[]).length;
531
  if (occ>0) {
532
  const bx=x+32, by=y-20;
 
611
  drawn.add(id); drawn.add(a.partner_id);
612
  const p1 = agentPositions[id], p2 = agentPositions[a.partner_id];
613
  if (!p1 || !p2) continue;
 
614
  ctx.strokeStyle = 'rgba(233, 30, 144, 0.4)';
615
  ctx.lineWidth = 1.5; ctx.setLineDash([4,4]);
616
  ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke();
617
  ctx.setLineDash([]);
 
618
  const mx = (p1.x+p2.x)/2, my = (p1.y+p2.y)/2 - 10;
619
  drawHeart(mx, my + Math.sin(animFrame*0.05)*3, 6, 'rgba(233,30,144,0.7)');
620
  }
 
631
  ctx.fill();
632
  }
633
 
634
+ // ============================================================
635
+ // CONVERSATION BUBBLES on canvas
636
+ // ============================================================
637
+ function drawConversationBubbles(W, H) {
638
+ if (!conversationData.active) return;
639
+ for (const conv of conversationData.active) {
640
+ if (!conv.turns || conv.turns.length === 0) continue;
641
+ const lastTurn = conv.turns[conv.turns.length - 1];
642
+ const speakerId = lastTurn.speaker_id;
643
+ const pos = agentPositions[speakerId];
644
+ if (!pos) continue;
645
+
646
+ const msg = (lastTurn.message || '').slice(0, 40) + (lastTurn.message.length > 40 ? '...' : '');
647
+ ctx.font = '9px Segoe UI';
648
+ const tw = ctx.measureText(msg).width;
649
+ const bw = tw + 14, bh = 18;
650
+ const bx = pos.x - bw/2, by = pos.y - 48;
651
+
652
+ // Bubble background
653
+ ctx.fillStyle = 'rgba(240,192,64,0.9)';
654
+ ctx.beginPath();
655
+ ctx.roundRect(bx, by, bw, bh, 4);
656
+ ctx.fill();
657
+ // Pointer
658
+ ctx.beginPath();
659
+ ctx.moveTo(pos.x - 4, by + bh);
660
+ ctx.lineTo(pos.x, by + bh + 6);
661
+ ctx.lineTo(pos.x + 4, by + bh);
662
+ ctx.fill();
663
+ // Text
664
+ ctx.fillStyle = '#1a1a2e';
665
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
666
+ ctx.fillText(msg, pos.x, by + bh/2);
667
+ }
668
+ }
669
+
670
  // ============================================================
671
  // AGENT TARGET COMPUTATION
672
  // ============================================================
 
704
  const isMoving = agent.state === 'moving';
705
  const isSleeping = agent.state === 'sleeping';
706
 
 
707
  const tgt = agentTargets[id];
708
  const moving = tgt && Math.hypot(ax-tgt.x, ay-tgt.y) > 3;
709
 
 
717
  const armSwing = (isMoving||moving) ? Math.sin(animFrame*0.15)*8 : 0;
718
  const legSwing = (isMoving||moving) ? Math.sin(animFrame*0.15)*5 : 0;
719
 
720
+ // Shadow
721
  ctx.fillStyle = 'rgba(0,0,0,0.15)';
722
  ctx.beginPath(); ctx.ellipse(0, 13, 7, 3, 0, 0, 6.28); ctx.fill();
723
 
 
782
  ctx.globalAlpha=1;
783
  }
784
 
 
785
  if (agent.partner_id) {
786
  drawHeart(0, -30+bounce+Math.sin(animFrame*0.04)*2, 4, 'rgba(233,30,144,0.7)');
787
  }
 
814
  const d=Math.hypot(mx-pos.x,my-pos.y);
815
  if(d<minD){minD=d;clicked=id;}
816
  }
817
+ if(clicked){
818
+ selectedAgentId=clicked;
819
+ switchTab('agents');
820
+ fetchAgentDetail(clicked);
821
+ } else {
822
+ selectedAgentId=null;
823
+ showDefaultDetail();
824
+ }
825
  }
826
 
827
  function onCanvasMouseMove(e) {
 
851
  }
852
 
853
  // ============================================================
854
+ // AGENT DETAIL (Agents Tab)
855
  // ============================================================
856
  function showDefaultDetail() {
857
+ const el = document.getElementById('agent-detail');
858
+ // Sort agents: those in conversation first, then by name
859
+ const sorted = Object.entries(agents).sort((a,b) => {
860
+ const aConv = a[1].state === 'in_conversation' ? 0 : 1;
861
+ const bConv = b[1].state === 'in_conversation' ? 0 : 1;
862
+ if (aConv !== bConv) return aConv - bConv;
863
+ return (a[1].name || '').localeCompare(b[1].name || '');
864
+ });
865
+
866
+ el.innerHTML = `
867
+ <div class="section-header">Population (${Object.keys(agents).length})</div>
868
+ ${sorted.map(([aid,a]) => {
869
+ const color = AGENT_COLORS[getAgentIdx(aid) % AGENT_COLORS.length];
870
+ const gi = a.gender==='female'?'\uD83D\uDC69':a.gender==='male'?'\uD83D\uDC68':'\uD83E\uDDD1';
871
+ const stateIcon = a.state==='in_conversation'?' \uD83D\uDCAC':(a.state==='sleeping'?' \uD83D\uDCA4':(a.state==='moving'?' \uD83D\uDEB6':''));
872
+ const partner = a.partner_id && agents[a.partner_id] ? ` \u2764\uFE0F ${agents[a.partner_id].name.split(' ')[0]}` : '';
873
+ return `
874
+ <div class="agent-list-item" onclick="selectedAgentId='${aid}';fetchAgentDetail('${aid}');">
875
+ <span class="agent-dot" style="background:${color}"></span>
876
+ <span style="font-size:13px">${gi}</span>
877
+ <div class="agent-info">
878
+ <div class="agent-name" style="color:${color}">${a.name}${partner}${stateIcon}</div>
879
+ <div class="agent-action">${esc(a.action||'idle')}</div>
880
+ </div>
881
+ </div>`;
882
+ }).join('')}`;
883
  }
884
 
885
  async function fetchAgentDetail(agentId) {
 
904
  const moodColor=moodPct>60?'green':(moodPct>30?'yellow':'red');
905
  const moodLabel=mood>0.3?'Happy':(mood>-0.3?'Okay':'Unhappy');
906
  const gi=data.gender==='female'?'\uD83D\uDC69':data.gender==='male'?'\uD83D\uDC68':'\uD83E\uDDD1';
907
+ const memories=(data.recent_memories||[]).slice(-6).reverse();
 
 
908
  const romanceRels=(data.relationships||[]).filter(r=>r.relationship_status&&r.relationship_status!=='none');
909
+ const locName = data.location ? (typeof data.location === 'object' ? data.location.name : data.location) : '?';
910
 
911
  document.getElementById('agent-detail').innerHTML=`
912
+ <div style="margin-bottom:6px">
913
+ <span style="cursor:pointer;color:#888;font-size:11px" onclick="selectedAgentId=null;showDefaultDetail();">&larr; All Agents</span>
914
+ </div>
915
  <h2>${gi} ${data.name||'?'}</h2>
916
+ <p class="subtitle">${data.age||'?'} y/o ${data.occupation||''}</p>
917
+ <p class="subtitle" style="color:#888;font-size:10px">${data.traits||''}</p>
918
+ <p class="subtitle" style="color:#e0e0e0">@ ${esc(locName)} &mdash; ${esc(data.action||'idle')}</p>
919
 
920
+ ${romanceRels.length>0?`<div style="margin:6px 0;padding:4px 8px;background:rgba(233,30,144,0.1);border:1px solid rgba(233,30,144,0.2);border-radius:4px;font-size:11px;color:#e91e90">
921
+ ${romanceRels.map(r=>`\u2764 ${r.relationship_status} with ${r.name} (${(r.romantic_interest*100).toFixed(0)}%)`).join('<br>')}
922
  </div>`:''}
923
 
924
  <div class="bar-container">
 
934
  </div>`;
935
  }).join('')}
936
 
 
 
 
 
937
  <div style="margin-top:6px">
938
+ <div style="font-size:10px;color:#888">Plan: ${esc((data.daily_plan||[]).slice(0,3).join('; ')||'None yet')}</div>
 
939
  </div>
940
 
941
  ${(data.relationships||[]).length>0?`
942
+ <div class="section-header">Relationships</div>
943
+ ${data.relationships.slice(0,8).map(r=>{
944
+ const fam = (r.familiarity||0)*100;
945
+ const trust = (r.trust||0)*100;
946
+ const sent = (r.sentiment||0)*100;
947
+ const rom = (r.romantic_interest||0)*100;
948
+ const statusBadge = r.relationship_status && r.relationship_status !== 'none'
949
+ ? `<span style="color:#e91e90;font-size:9px"> \u2764 ${r.relationship_status}</span>` : '';
950
+ return `
951
+ <div class="rel-item" onclick="selectedAgentId='${r.agent_id}';fetchAgentDetail('${r.agent_id}');">
952
+ <div><span class="rel-name">${esc(r.name)}</span>${statusBadge}
953
+ <span style="color:#555;font-size:9px">(${r.interaction_count||0} talks)</span></div>
954
+ <div class="rel-bars">
955
+ <div class="rel-mini-bar" title="Familiarity ${fam.toFixed(0)}%"><div class="rel-mini-fill" style="width:${fam}%;background:#4ecca3"></div></div>
956
+ <div class="rel-mini-bar" title="Trust ${trust.toFixed(0)}%"><div class="rel-mini-fill" style="width:${trust}%;background:#4e9eca"></div></div>
957
+ <div class="rel-mini-bar" title="Sentiment ${sent.toFixed(0)}%"><div class="rel-mini-fill" style="width:${sent}%;background:#f0c040"></div></div>
958
+ ${rom > 0 ? `<div class="rel-mini-bar" title="Romance ${rom.toFixed(0)}%"><div class="rel-mini-fill" style="width:${rom}%;background:#e91e90"></div></div>` : ''}
959
+ </div>
960
+ </div>`;
961
+ }).join('')}`:''}
962
+
963
+ <div class="section-header">Recent Memories</div>
964
+ ${memories.map(m=>`<div class="memory-item"><span class="memory-time">${m.time||''}</span> ${esc(m.content||'')}</div>`).join('')||'<div class="memory-item" style="color:#555">No memories yet</div>'}
965
  `;
966
  }
967
 
968
  // ============================================================
969
+ // CONVERSATIONS TAB
970
+ // ============================================================
971
+ async function fetchConversations() {
972
+ try {
973
+ const res = await fetch(`${API_BASE}/conversations?include_history=true&limit=15`);
974
+ if (!res.ok) return;
975
+ conversationData = await res.json();
976
+ renderConversations();
977
+ } catch(e) {}
978
+ }
979
+
980
+ function renderConversations() {
981
+ const panel = document.getElementById('conversations-panel');
982
+ const all = [
983
+ ...(conversationData.active||[]).map(c => ({...c, _active: true})),
984
+ ...(conversationData.recent||[]).map(c => ({...c, _active: false})),
985
+ ];
986
+
987
+ if (all.length === 0) {
988
+ panel.innerHTML = '<div class="conv-empty">No conversations yet.<br>Agents will start talking when they meet!</div>';
989
+ return;
990
+ }
991
+
992
+ panel.innerHTML = all.map(conv => {
993
+ const turns = conv.turns || [];
994
+ const badge = conv._active
995
+ ? '<span class="conv-badge live">LIVE</span>'
996
+ : '<span class="conv-badge ended">ended</span>';
997
+ const names = (conv.participant_names || []).join(' & ');
998
+
999
+ return `
1000
+ <div class="conv-card ${conv._active ? 'active-conv' : ''}">
1001
+ <div class="conv-header">
1002
+ <span class="conv-topic">${esc(conv.topic || 'Chat')}</span>
1003
+ ${badge}
1004
+ </div>
1005
+ <div class="conv-participants">${esc(names)} &mdash; ${esc(conv.location || '')}</div>
1006
+ ${turns.slice(-6).map(t => `
1007
+ <div class="conv-turn">
1008
+ <span class="conv-speaker" style="color:${getAgentColor(t.speaker_id)}">${esc(t.speaker_name)}:</span>
1009
+ <span class="conv-message">${esc(t.message)}</span>
1010
+ </div>
1011
+ `).join('')}
1012
+ </div>`;
1013
+ }).join('');
1014
+ }
1015
+
1016
+ function getAgentColor(agentId) {
1017
+ return AGENT_COLORS[getAgentIdx(agentId) % AGENT_COLORS.length];
1018
+ }
1019
+
1020
+ // ============================================================
1021
+ // EVENT LOG TAB
1022
  // ============================================================
1023
  function renderEventLog() {
1024
+ const c = document.getElementById('event-log-panel');
1025
+ c.innerHTML = eventLog.slice(-80).map(line => {
1026
+ let cls = 'event-line';
1027
+ if (line.includes('[PLAN]')) cls += ' plan';
1028
+ else if (line.includes('[CONV]')) cls += ' conv';
1029
+ else if (line.includes('[ROMANCE]')) cls += ' romance';
1030
+ else if (line.includes('[MOVE]')) cls += ' move';
1031
+ else if (line.includes('[REFLECT]')) cls += ' reflect';
1032
+ else if (line.includes('[EVENT]') || line.includes('[ENTROPY]')) cls += ' event';
1033
+ else if (line.startsWith('---') || line.startsWith('\n---')) cls += ' time';
1034
  return `<div class="${cls}">${esc(line)}</div>`;
1035
  }).join('');
1036
+ c.scrollTop = c.scrollHeight;
1037
  }
1038
 
1039
+ function esc(s) { return s ? s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') : ''; }
1040
 
1041
  // ============================================================
1042
  // DATA FETCHING
 
1050
  const data = await res.json();
1051
  if (!connected) { connected=true; document.getElementById('status').innerHTML='<span class="dot green"></span> Connected'; }
1052
 
1053
+ const clock = data.clock || {};
1054
+ currentTimeOfDay = clock.time_of_day || 'morning';
1055
+ currentWeather = (data.weather || 'sunny').toLowerCase();
1056
+
1057
+ document.getElementById('clock').textContent = `Day ${clock.day||1}, ${clock.time_str||'??:??'} (${currentTimeOfDay})`;
1058
+ document.getElementById('weather-icon').textContent = WEATHER_ICONS[currentWeather] || '\u2600\uFE0F';
1059
+ document.getElementById('weather').textContent = currentWeather;
1060
+ document.getElementById('agent-count').innerHTML = `<span class="dot green"></span> ${Object.keys(data.agents||{}).length} agents`;
1061
+ document.getElementById('conv-count').textContent = `${data.active_conversations||0} convos`;
1062
+
1063
+ const usage = data.llm_usage || '';
1064
+ const cm = usage.match(/calls:\s*(\d+)/i), $m = usage.match(/\$([0-9.]+)/);
1065
+ document.getElementById('api-calls').textContent = `API: ${cm?cm[1]:'0'}`;
1066
+ document.getElementById('cost').textContent = $m ? `$${$m[1]}` : '$0.00';
1067
+
1068
+ agents = data.agents || {};
1069
+
1070
+ const locRes = await fetch(`${API_BASE}/city/locations`);
1071
+ if (locRes.ok) locations = await locRes.json();
1072
+
1073
+ const tick = clock.total_ticks || 0;
1074
+ if (tick !== lastTick) {
1075
+ // Fetch events
1076
+ try {
1077
+ const er = await fetch(`${API_BASE}/events`);
1078
+ if (er.ok) {
1079
+ const d2 = await er.json();
1080
+ eventLog = (d2.events||[]).map(e => e.message||'').filter(m => m.trim());
1081
+ if (activeTab === 'events') renderEventLog();
1082
+ }
1083
+ } catch(e) {}
1084
+
1085
+ // Fetch conversations
1086
+ if (activeTab === 'conversations') {
1087
+ fetchConversations();
1088
+ } else {
1089
+ // Still fetch for canvas bubbles
1090
+ try {
1091
+ const cr = await fetch(`${API_BASE}/conversations?include_history=false`);
1092
+ if (cr.ok) {
1093
+ const cd = await cr.json();
1094
+ conversationData.active = cd.active || [];
1095
+ }
1096
+ } catch(e) {}
1097
+ }
1098
  }
1099
+ lastTick = tick;
1100
 
1101
+ if (activeTab === 'agents') {
1102
+ if (selectedAgentId) fetchAgentDetail(selectedAgentId);
1103
+ else showDefaultDetail();
1104
+ }
1105
 
1106
+ } catch(e) {
1107
+ if (connected) { connected=false; document.getElementById('status').innerHTML='<span class="dot red"></span> Disconnected'; }
1108
  }
1109
  }
1110