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 +73 -11
- src/soci/engine/simulation.py +7 -0
- web/index.html +343 -124
|
@@ -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
|
| 140 |
-
"""Get
|
| 141 |
from soci.api.server import get_simulation
|
| 142 |
sim = get_simulation()
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
}
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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."""
|
|
@@ -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."
|
|
@@ -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:
|
| 35 |
display: flex; flex-direction: column; overflow: hidden;
|
| 36 |
}
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
#agent-detail h2 { font-size: 16px; color: #4ecca3; margin-bottom: 8px; }
|
| 42 |
-
#agent-detail .subtitle { font-size: 12px; color: #a0a0c0; margin-bottom:
|
| 43 |
-
.bar-container { margin:
|
| 44 |
.bar-label { font-size: 11px; color: #a0a0c0; display: flex; justify-content: space-between; }
|
| 45 |
-
.bar-bg { height:
|
| 46 |
-
.bar-fill { height: 100%; border-radius:
|
| 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 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 88 |
-
<
|
| 89 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
</div>
|
| 91 |
-
<div id="
|
| 92 |
-
<
|
| 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;
|
| 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 = {};
|
| 171 |
-
let agentTargets = {};
|
| 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,
|
| 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,
|
| 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
|
| 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
|
| 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){
|
| 707 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
}
|
| 709 |
|
| 710 |
function onCanvasMouseMove(e) {
|
|
@@ -734,22 +851,35 @@ function onCanvasMouseMove(e) {
|
|
| 734 |
}
|
| 735 |
|
| 736 |
// ============================================================
|
| 737 |
-
// AGENT DETAIL
|
| 738 |
// ============================================================
|
| 739 |
function showDefaultDetail() {
|
| 740 |
-
document.getElementById('agent-detail')
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(-
|
| 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||''}
|
| 785 |
-
<p class="subtitle" style="color:#
|
|
|
|
| 786 |
|
| 787 |
-
${romanceRels.length>0?`<div style="margin:
|
| 788 |
-
${romanceRels.map(r=>`\u2764 ${r.relationship_status} with ${r.name} (
|
| 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:
|
| 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
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 822 |
`;
|
| 823 |
}
|
| 824 |
|
| 825 |
// ============================================================
|
| 826 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 827 |
// ============================================================
|
| 828 |
function renderEventLog() {
|
| 829 |
-
const c=document.getElementById('
|
| 830 |
-
c.innerHTML=eventLog.slice(-
|
| 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('[
|
| 836 |
-
else if(line.
|
|
|
|
|
|
|
| 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,'&').replace(/</g,'<').replace(/>/g,'>'):''}
|
| 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 |
-
|
| 866 |
-
const
|
| 867 |
-
|
| 868 |
-
document.getElementById('
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 881 |
}
|
| 882 |
-
lastTick=tick;
|
| 883 |
|
| 884 |
-
if
|
| 885 |
-
|
|
|
|
|
|
|
| 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();">← 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)} — ${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)} — ${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,'&').replace(/</g,'<').replace(/>/g,'>') : ''; }
|
| 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 |
|