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>
- web/index.html +110 -57
web/index.html
CHANGED
|
@@ -1058,72 +1058,123 @@ function renderEventLog() {
|
|
| 1058 |
function esc(s) { return s ? s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') : ''; }
|
| 1059 |
|
| 1060 |
// ============================================================
|
| 1061 |
-
// DATA FETCHING
|
| 1062 |
// ============================================================
|
| 1063 |
let lastTick = -1;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1064 |
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
| 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 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
| 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 |
-
|
| 1119 |
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
|
| 1123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1125 |
} catch(e) {
|
| 1126 |
-
if (connected) {
|
|
|
|
|
|
|
|
|
|
| 1127 |
}
|
| 1128 |
}
|
| 1129 |
|
|
@@ -1186,9 +1237,11 @@ async function fetchControls() {
|
|
| 1186 |
// ============================================================
|
| 1187 |
initCanvas();
|
| 1188 |
showDefaultDetail();
|
| 1189 |
-
fetchState();
|
| 1190 |
fetchControls();
|
| 1191 |
-
|
|
|
|
|
|
|
| 1192 |
</script>
|
| 1193 |
</body>
|
| 1194 |
</html>
|
|
|
|
| 1058 |
function esc(s) { return s ? s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') : ''; }
|
| 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>
|