RayMelius Claude Opus 4.6 commited on
Commit
316428d
·
1 Parent(s): fb40e92

Add WebSocket real-time updates to web UI

Browse files

- Connect to /ws/stream for live tick updates instead of polling
- Process state updates from WebSocket messages in real-time
- Automatic reconnection on WebSocket disconnect (3s retry)
- Polling fallback only activates when WebSocket is disconnected
- Status indicator shows "Live (WS)" when WebSocket connected
- Refactored data processing into reusable processStateData()

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

Files changed (1) hide show
  1. web/index.html +110 -57
web/index.html CHANGED
@@ -1058,72 +1058,123 @@ function renderEventLog() {
1058
  function esc(s) { return s ? s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') : ''; }
1059
 
1060
  // ============================================================
1061
- // DATA FETCHING
1062
  // ============================================================
1063
  let lastTick = -1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1064
 
1065
- async function fetchState() {
1066
- try {
1067
- const res = await fetch(`${API_BASE}/city`);
1068
- if (!res.ok) throw new Error();
1069
- const data = await res.json();
1070
- if (!connected) { connected=true; document.getElementById('status').innerHTML='<span class="dot green"></span> Connected'; }
1071
-
1072
- const clock = data.clock || {};
1073
- currentTimeOfDay = clock.time_of_day || 'morning';
1074
- currentWeather = (data.weather || 'sunny').toLowerCase();
1075
-
1076
- document.getElementById('clock').textContent = `Day ${clock.day||1}, ${clock.time_str||'??:??'} (${currentTimeOfDay})`;
1077
- document.getElementById('weather-icon').textContent = WEATHER_ICONS[currentWeather] || '\u2600\uFE0F';
1078
- document.getElementById('weather').textContent = currentWeather;
1079
- document.getElementById('agent-count').innerHTML = `<span class="dot green"></span> ${Object.keys(data.agents||{}).length} agents`;
1080
- document.getElementById('conv-count').textContent = `${data.active_conversations||0} convos`;
1081
-
1082
- const usage = data.llm_usage || '';
1083
- const cm = usage.match(/calls:\s*(\d+)/i), $m = usage.match(/\$([0-9.]+)/);
1084
- document.getElementById('api-calls').textContent = `API: ${cm?cm[1]:'0'}`;
1085
- document.getElementById('cost').textContent = $m ? `$${$m[1]}` : '$0.00';
1086
-
1087
- agents = data.agents || {};
1088
 
 
 
 
1089
  const locRes = await fetch(`${API_BASE}/city/locations`);
1090
  if (locRes.ok) locations = await locRes.json();
 
1091
 
1092
- const tick = clock.total_ticks || 0;
1093
- if (tick !== lastTick) {
1094
- // Fetch events
1095
- try {
1096
- const er = await fetch(`${API_BASE}/events`);
1097
- if (er.ok) {
1098
- const d2 = await er.json();
1099
- eventLog = (d2.events||[]).map(e => e.message||'').filter(m => m.trim());
1100
- if (activeTab === 'events') renderEventLog();
1101
- }
1102
- } catch(e) {}
1103
-
1104
- // Fetch conversations
1105
- if (activeTab === 'conversations') {
1106
- fetchConversations();
1107
- } else {
1108
- // Still fetch for canvas bubbles
1109
- try {
1110
- const cr = await fetch(`${API_BASE}/conversations?include_history=false`);
1111
- if (cr.ok) {
1112
- const cd = await cr.json();
1113
- conversationData.active = cd.active || [];
1114
- }
1115
- } catch(e) {}
1116
- }
1117
  }
1118
- lastTick = tick;
1119
 
1120
- if (activeTab === 'agents') {
1121
- if (selectedAgentId) fetchAgentDetail(selectedAgentId);
1122
- else showDefaultDetail();
1123
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1124
 
 
 
 
 
 
 
 
 
 
 
 
1125
  } catch(e) {
1126
- if (connected) { connected=false; document.getElementById('status').innerHTML='<span class="dot red"></span> Disconnected'; }
 
 
 
1127
  }
1128
  }
1129
 
@@ -1186,9 +1237,11 @@ async function fetchControls() {
1186
  // ============================================================
1187
  initCanvas();
1188
  showDefaultDetail();
1189
- fetchState();
1190
  fetchControls();
1191
- setInterval(fetchState, POLL_INTERVAL);
 
 
1192
  </script>
1193
  </body>
1194
  </html>
 
1058
  function esc(s) { return s ? s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') : ''; }
1059
 
1060
  // ============================================================
1061
+ // DATA FETCHING — WebSocket + polling fallback
1062
  // ============================================================
1063
  let lastTick = -1;
1064
+ let ws = null;
1065
+ let wsRetryTimer = null;
1066
+
1067
+ function processStateData(data) {
1068
+ const clock = data.clock || {};
1069
+ currentTimeOfDay = clock.time_of_day || 'morning';
1070
+ currentWeather = (data.weather || 'sunny').toLowerCase();
1071
+
1072
+ document.getElementById('clock').textContent = `Day ${clock.day||1}, ${clock.time_str||'??:??'} (${currentTimeOfDay})`;
1073
+ document.getElementById('weather-icon').textContent = WEATHER_ICONS[currentWeather] || '\u2600\uFE0F';
1074
+ document.getElementById('weather').textContent = currentWeather;
1075
+ document.getElementById('agent-count').innerHTML = `<span class="dot green"></span> ${Object.keys(data.agents||{}).length} agents`;
1076
+ document.getElementById('conv-count').textContent = `${data.active_conversations||0} convos`;
1077
+
1078
+ const usage = data.llm_usage || '';
1079
+ const cm = usage.match(/calls:\s*(\d+)/i), $m = usage.match(/\$([0-9.]+)/);
1080
+ document.getElementById('api-calls').textContent = `API: ${cm?cm[1]:'0'}`;
1081
+ document.getElementById('cost').textContent = $m ? `$${$m[1]}` : '$0.00';
1082
+
1083
+ agents = data.agents || {};
1084
+
1085
+ const tick = clock.total_ticks || 0;
1086
+ if (tick !== lastTick) {
1087
+ fetchSecondaryData();
1088
+ }
1089
+ lastTick = tick;
1090
 
1091
+ if (activeTab === 'agents') {
1092
+ if (selectedAgentId) fetchAgentDetail(selectedAgentId);
1093
+ else showDefaultDetail();
1094
+ }
1095
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1096
 
1097
+ async function fetchSecondaryData() {
1098
+ // Locations
1099
+ try {
1100
  const locRes = await fetch(`${API_BASE}/city/locations`);
1101
  if (locRes.ok) locations = await locRes.json();
1102
+ } catch(e) {}
1103
 
1104
+ // Events
1105
+ try {
1106
+ const er = await fetch(`${API_BASE}/events`);
1107
+ if (er.ok) {
1108
+ const d2 = await er.json();
1109
+ eventLog = (d2.events||[]).map(e => e.message||'').filter(m => m.trim());
1110
+ if (activeTab === 'events') renderEventLog();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1111
  }
1112
+ } catch(e) {}
1113
 
1114
+ // Conversations
1115
+ if (activeTab === 'conversations') {
1116
+ fetchConversations();
1117
+ } else {
1118
+ try {
1119
+ const cr = await fetch(`${API_BASE}/conversations?include_history=false`);
1120
+ if (cr.ok) {
1121
+ const cd = await cr.json();
1122
+ conversationData.active = cd.active || [];
1123
+ }
1124
+ } catch(e) {}
1125
+ }
1126
+ }
1127
+
1128
+ function connectWebSocket() {
1129
+ const wsUrl = `ws://${window.location.host}/ws/stream`;
1130
+ ws = new WebSocket(wsUrl);
1131
+
1132
+ ws.onopen = () => {
1133
+ connected = true;
1134
+ document.getElementById('status').innerHTML = '<span class="dot green"></span> Live (WS)';
1135
+ };
1136
+
1137
+ ws.onmessage = (evt) => {
1138
+ try {
1139
+ const msg = JSON.parse(evt.data);
1140
+ if (msg.type === 'tick' && msg.state) {
1141
+ processStateData(msg.state);
1142
+ } else if (msg.type === 'event') {
1143
+ eventLog.push(msg.message);
1144
+ if (eventLog.length > 200) eventLog = eventLog.slice(-200);
1145
+ if (activeTab === 'events') renderEventLog();
1146
+ }
1147
+ } catch(e) {}
1148
+ };
1149
+
1150
+ ws.onclose = () => {
1151
+ connected = false;
1152
+ document.getElementById('status').innerHTML = '<span class="dot red"></span> Disconnected';
1153
+ // Retry after 3 seconds
1154
+ wsRetryTimer = setTimeout(connectWebSocket, 3000);
1155
+ };
1156
+
1157
+ ws.onerror = () => {
1158
+ ws.close();
1159
+ };
1160
+ }
1161
 
1162
+ // Polling fallback (also used for initial load)
1163
+ async function fetchState() {
1164
+ try {
1165
+ const res = await fetch(`${API_BASE}/city`);
1166
+ if (!res.ok) throw new Error();
1167
+ const data = await res.json();
1168
+ if (!connected) {
1169
+ connected = true;
1170
+ document.getElementById('status').innerHTML = '<span class="dot green"></span> Connected';
1171
+ }
1172
+ processStateData(data);
1173
  } catch(e) {
1174
+ if (connected) {
1175
+ connected = false;
1176
+ document.getElementById('status').innerHTML = '<span class="dot red"></span> Disconnected';
1177
+ }
1178
  }
1179
  }
1180
 
 
1237
  // ============================================================
1238
  initCanvas();
1239
  showDefaultDetail();
1240
+ fetchState(); // Initial load via REST
1241
  fetchControls();
1242
+ connectWebSocket(); // Try WebSocket for real-time
1243
+ // Polling as fallback — only active when WS is disconnected
1244
+ setInterval(() => { if (!ws || ws.readyState !== WebSocket.OPEN) fetchState(); }, POLL_INTERVAL);
1245
  </script>
1246
  </body>
1247
  </html>