feat: in-app live log (SSE activity feed)

Turn the inert "Show Work" thinking panel into a real live activity log:
- lyra/logbus.py: thread-safe in-memory ring buffer other modules publish to
- chat.respond logs backend/model/embed per turn, recall counts, reply size;
  web layer logs chat errors
- server: replace the keep-alive /stream/thinking stub with /stream/logs, an
  SSE endpoint that replays the recent buffer then streams new events
- UI: repurpose the panel as a global "Live Log" — connects on load, renders
  level/time/msg/fields, drops the old per-session localStorage + dead popup

Every turn now shows its backend + model in-app, so local-vs-cloud (free vs
paid) is visible at a glance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 18:45:05 +00:00
parent 3b9e0bb1e0
commit 84c4f75e03
5 changed files with 143 additions and 160 deletions
+39 -151
View File
@@ -35,7 +35,7 @@
<div class="mobile-menu-section">
<h4>Actions</h4>
<button id="mobileThinkingStreamBtn">🧠 Show Work</button>
<button id="mobileThinkingStreamBtn">📜 Live Log</button>
<button id="mobileSettingsBtn">⚙ Settings</button>
<button id="mobileToggleThemeBtn">🌙 Toggle Theme</button>
<button id="mobileForceReloadBtn">🔄 Force Reload</button>
@@ -68,7 +68,7 @@
<select id="sessions"></select>
<button id="newSessionBtn"> New</button>
<button id="renameSessionBtn">✏️ Rename</button>
<button id="thinkingStreamBtn" title="Show thinking stream panel">🧠 Show Work</button>
<button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button>
</div>
<!-- Status -->
@@ -80,10 +80,10 @@
<!-- Chat messages -->
<div id="messages"></div>
<!-- Thinking Stream Panel (collapsible) -->
<!-- Live Log Panel (collapsible) -->
<div id="thinkingPanel" class="thinking-panel collapsed">
<div class="thinking-header" id="thinkingHeader">
<span>🧠 Thinking Stream</span>
<span>📜 Live Log</span>
<div class="thinking-controls">
<span class="thinking-status-dot" id="thinkingStatusDot"></span>
<button class="thinking-clear-btn" id="thinkingClearBtn" title="Clear events">🗑️</button>
@@ -92,8 +92,8 @@
</div>
<div class="thinking-content" id="thinkingContent">
<div class="thinking-empty" id="thinkingEmpty">
<div class="thinking-empty-icon">🤔</div>
<p>Waiting for thinking events...</p>
<div class="thinking-empty-icon">📡</div>
<p>Waiting for activity...</p>
</div>
</div>
</div>
@@ -509,22 +509,6 @@
addMessage("system", `Session renamed to: ${newName}`);
});
// Thinking Stream button
document.getElementById("thinkingStreamBtn").addEventListener("click", () => {
if (!currentSession) {
alert("Please select a session first");
return;
}
// Open thinking stream in new window
const streamUrl = `http://10.0.0.41:8081/thinking-stream.html?session=${currentSession}`;
const windowFeatures = "width=600,height=800,menubar=no,toolbar=no,location=no,status=no";
window.open(streamUrl, `thinking_${currentSession}`, windowFeatures);
addMessage("system", "🧠 Opened thinking stream in new window");
});
// Settings Modal
const settingsModal = document.getElementById("settingsModal");
const settingsBtn = document.getElementById("settingsBtn");
@@ -705,113 +689,63 @@
}
function connectThinkingStream() {
if (!currentSession) return;
// Close existing connection
if (thinkingEventSource) {
thinkingEventSource.close();
}
// Load persisted events
loadThinkingEvents();
const url = `${CORTEX_BASE}/stream/thinking/${currentSession}`;
console.log('Connecting thinking stream:', url);
// The server replays its recent buffer on connect, so start from a clean panel.
thinkingContent.innerHTML = '';
thinkingEventCount = 0;
thinkingContent.appendChild(thinkingEmpty);
const url = `${RELAY_BASE}/stream/logs`; // global server activity feed
thinkingEventSource = new EventSource(url);
thinkingEventSource.onopen = () => {
console.log('Thinking stream connected');
thinkingStatusDot.className = 'thinking-status-dot connected';
};
thinkingEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
addThinkingEvent(data);
saveThinkingEvent(data); // Persist event
addLogEvent(JSON.parse(event.data));
} catch (e) {
console.error('Failed to parse thinking event:', e);
console.error('Failed to parse log event:', e);
}
};
thinkingEventSource.onerror = (error) => {
console.error('Thinking stream error:', error);
thinkingEventSource.onerror = () => {
thinkingStatusDot.className = 'thinking-status-dot disconnected';
// Retry connection after 2 seconds
setTimeout(() => {
if (thinkingEventSource && thinkingEventSource.readyState === EventSource.CLOSED) {
console.log('Reconnecting thinking stream...');
connectThinkingStream();
}
}, 2000);
// EventSource auto-reconnects; nothing to do here.
};
}
function addThinkingEvent(event) {
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function addLogEvent(event) {
// Remove empty state if present
if (thinkingEventCount === 0 && thinkingEmpty.parentNode) {
thinkingContent.removeChild(thinkingEmpty);
}
const level = event.level || 'info';
const time = new Date((event.ts || 0) * 1000).toLocaleTimeString();
const fields = event.fields || {};
const fieldStr = Object.keys(fields).length
? Object.entries(fields).map(([k, v]) => `${k}=${v}`).join(' ')
: '';
const eventDiv = document.createElement('div');
eventDiv.className = `thinking-event thinking-event-${event.type}`;
let icon = '';
let message = '';
let details = '';
switch (event.type) {
case 'connected':
icon = '✓';
message = 'Stream connected';
details = `Session: ${event.session_id}`;
break;
case 'thinking':
icon = '🤔';
message = event.data.message;
break;
case 'tool_call':
icon = '🔧';
message = event.data.message;
if (event.data.args) {
details = JSON.stringify(event.data.args, null, 2);
}
break;
case 'tool_result':
icon = '📊';
message = event.data.message;
if (event.data.result && event.data.result.stdout) {
details = `stdout: ${event.data.result.stdout}`;
}
break;
case 'done':
icon = '✅';
message = event.data.message;
if (event.data.final_answer) {
details = event.data.final_answer;
}
break;
case 'error':
icon = '❌';
message = event.data.message;
break;
default:
icon = '•';
message = JSON.stringify(event.data);
}
eventDiv.className = `log-line log-${level}`;
eventDiv.innerHTML = `
<span class="thinking-event-icon">${icon}</span>
<span>${message}</span>
${details ? `<div class="thinking-event-details">${details}</div>` : ''}
<span class="log-time">${escapeHtml(time)}</span>
<span class="log-level log-level-${level}">${escapeHtml(level)}</span>
<span class="log-msg">${escapeHtml(event.msg || '')}</span>
${fieldStr ? `<span class="log-fields">${escapeHtml(fieldStr)}</span>` : ''}
`;
thinkingContent.appendChild(eventDiv);
@@ -819,47 +753,9 @@
thinkingEventCount++;
}
// Persist thinking events to localStorage
function saveThinkingEvent(event) {
if (!currentSession) return;
// (Log events are server-side and replayed on connect; no localStorage needed.)
const key = `thinkingEvents_${currentSession}`;
let events = JSON.parse(localStorage.getItem(key) || '[]');
// Keep only last 50 events to avoid bloating localStorage
if (events.length >= 50) {
events = events.slice(-49);
}
events.push({
...event,
timestamp: Date.now()
});
localStorage.setItem(key, JSON.stringify(events));
}
// Load persisted thinking events
function loadThinkingEvents() {
if (!currentSession) return;
const key = `thinkingEvents_${currentSession}`;
const events = JSON.parse(localStorage.getItem(key) || '[]');
// Clear current display
thinkingContent.innerHTML = '';
thinkingEventCount = 0;
// Replay events
events.forEach(event => addThinkingEvent(event));
// Show empty state if no events
if (events.length === 0) {
thinkingContent.appendChild(thinkingEmpty);
}
}
// Update the old thinking stream button to toggle panel instead
// Live Log toggle button
document.getElementById("thinkingStreamBtn").addEventListener("click", () => {
thinkingPanel.classList.remove("collapsed");
localStorage.setItem("thinkingPanelCollapsed", "false");
@@ -872,18 +768,10 @@
localStorage.setItem("thinkingPanelCollapsed", "false");
});
// Connect thinking stream when session loads
if (currentSession) {
connectThinkingStream();
}
// Connect to the global live log on page load.
connectThinkingStream();
// Reconnect thinking stream when session changes
const originalSessionChange = document.getElementById("sessions").onchange;
document.getElementById("sessions").addEventListener("change", () => {
setTimeout(() => {
connectThinkingStream();
}, 500); // Wait for session to load
});
// The live log is global (server-wide), so it does not reconnect on session change.
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
+34
View File
@@ -907,3 +907,37 @@ select:hover {
display: none !important;
}
}
/* ---- Live Log lines ---- */
.log-line {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-family: 'Courier New', monospace;
border-left: 3px solid var(--text-fade);
animation: thinkingSlideIn 0.25s ease-out;
word-break: break-word;
}
.log-time { color: var(--text-fade); flex-shrink: 0; }
.log-level {
flex-shrink: 0;
text-transform: uppercase;
font-size: 0.7rem;
font-weight: bold;
letter-spacing: 0.05em;
}
.log-msg { color: var(--text); }
.log-fields { color: var(--text-fade); width: 100%; padding-left: 4px; }
.log-info { border-left-color: #00bfff; }
.log-info .log-level { color: #7dd3fc; }
.log-debug { border-left-color: #8a2be2; }
.log-debug .log-level { color: #c79cff; }
.log-error { border-left-color: #ff3333; background: rgba(255,51,51,0.08); }
.log-error .log-level, .log-error .log-msg { color: #fca5a5; }
.log-system { border-left-color: #00ff66; }
.log-system .log-level { color: #00ff66; }