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
+10 -1
View File
@@ -6,7 +6,7 @@ session, then asks the model for a reply and persists both sides.
""" """
from __future__ import annotations from __future__ import annotations
from lyra import llm, memory, persona from lyra import config, llm, logbus, memory, persona
from lyra.llm import Backend, Message from lyra.llm import Backend, Message
RECALL_K = 5 RECALL_K = 5
@@ -34,6 +34,7 @@ def build_messages(session_id: str, user_msg: str) -> list[Message]:
recalled = [ recalled = [
ex for ex in memory.recall(user_msg, k=RECALL_K) if ex.id not in recent_ids ex for ex in memory.recall(user_msg, k=RECALL_K) if ex.id not in recent_ids
] ]
logbus.log("debug", "context built", recent=len(recent), recalled=len(recalled))
if recalled: if recalled:
messages.append(_memory_note(recalled)) messages.append(_memory_note(recalled))
@@ -46,8 +47,16 @@ def build_messages(session_id: str, user_msg: str) -> list[Message]:
def respond(session_id: str, user_msg: str, backend: Backend = "cloud") -> str: def respond(session_id: str, user_msg: str, backend: Backend = "cloud") -> str:
"""Produce Lyra's reply to a single user message and persist the exchange.""" """Produce Lyra's reply to a single user message and persist the exchange."""
cfg = config.load()
model = cfg.local_model if backend == "local" else cfg.cloud_model
logbus.log(
"info", "chat request", session=session_id, backend=backend,
model=model, embed=cfg.embed_backend,
)
messages = build_messages(session_id, user_msg) messages = build_messages(session_id, user_msg)
reply = llm.complete(messages, backend=backend) reply = llm.complete(messages, backend=backend)
logbus.log("info", "reply", session=session_id, chars=len(reply))
memory.remember(session_id, "user", user_msg) memory.remember(session_id, "user", user_msg)
memory.remember(session_id, "assistant", reply) memory.remember(session_id, "assistant", reply)
+31
View File
@@ -0,0 +1,31 @@
"""In-memory live log bus.
A thread-safe ring buffer that any part of Lyra can publish to and the web
server streams to the browser over SSE. Deliberately process-local and
ephemeral — it's an activity feed, not durable logging.
"""
from __future__ import annotations
import threading
import time
from collections import deque
_LOCK = threading.Lock()
_EVENTS: deque[dict] = deque(maxlen=500)
_SEQ = 0
def log(level: str, msg: str, **fields) -> None:
"""Publish an event. `level` is info/debug/error/system; fields are extras."""
global _SEQ
with _LOCK:
_SEQ += 1
_EVENTS.append(
{"seq": _SEQ, "ts": time.time(), "level": level, "msg": msg, "fields": fields}
)
def since(seq: int) -> list[dict]:
"""All buffered events with seq greater than `seq` (for SSE catch-up/polling)."""
with _LOCK:
return [e for e in _EVENTS if e["seq"] > seq]
+29 -8
View File
@@ -10,15 +10,21 @@ re-stored).
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import time
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from lyra import chat, memory from lyra import chat, logbus, memory
from lyra.llm import Backend from lyra.llm import Backend
def _sse(event: dict) -> str:
return f"data: {json.dumps(event)}\n\n"
_STATIC = Path(__file__).parent / "static" _STATIC = Path(__file__).parent / "static"
# UI backend labels -> our two backends. Cloud is the default. # UI backend labels -> our two backends. Cloud is the default.
@@ -79,7 +85,11 @@ def create_app() -> FastAPI:
user_msg = _last_user_message(body.get("messages", [])) user_msg = _last_user_message(body.get("messages", []))
memory.ensure_session(session_id) memory.ensure_session(session_id)
reply = await asyncio.to_thread(chat.respond, session_id, user_msg, backend) try:
reply = await asyncio.to_thread(chat.respond, session_id, user_msg, backend)
except Exception as exc:
logbus.log("error", "chat failed", session=session_id, error=str(exc))
reply = f"[error] {exc}"
return { return {
"object": "chat.completion", "object": "chat.completion",
@@ -92,14 +102,25 @@ def create_app() -> FastAPI:
], ],
} }
@app.get("/stream/thinking/{session_id}") @app.get("/stream/logs")
async def thinking_stream(session_id: str) -> StreamingResponse: async def stream_logs(request: Request) -> StreamingResponse:
# Inert until cognitive layers exist: open the stream, emit keep-alives only. """Live activity feed: replay the recent buffer, then stream new events."""
async def gen(): async def gen():
yield ": connected\n\n" backlog = logbus.since(0)
last = backlog[-1]["seq"] if backlog else 0
for e in backlog:
yield _sse(e)
yield _sse(
{"seq": last, "ts": time.time(), "level": "system",
"msg": "live log connected", "fields": {}}
)
while True: while True:
await asyncio.sleep(25) if await request.is_disconnected():
yield ": keep-alive\n\n" break
for e in logbus.since(last):
last = e["seq"]
yield _sse(e)
await asyncio.sleep(0.5)
return StreamingResponse(gen(), media_type="text/event-stream") return StreamingResponse(gen(), media_type="text/event-stream")
+39 -151
View File
@@ -35,7 +35,7 @@
<div class="mobile-menu-section"> <div class="mobile-menu-section">
<h4>Actions</h4> <h4>Actions</h4>
<button id="mobileThinkingStreamBtn">🧠 Show Work</button> <button id="mobileThinkingStreamBtn">📜 Live Log</button>
<button id="mobileSettingsBtn">⚙ Settings</button> <button id="mobileSettingsBtn">⚙ Settings</button>
<button id="mobileToggleThemeBtn">🌙 Toggle Theme</button> <button id="mobileToggleThemeBtn">🌙 Toggle Theme</button>
<button id="mobileForceReloadBtn">🔄 Force Reload</button> <button id="mobileForceReloadBtn">🔄 Force Reload</button>
@@ -68,7 +68,7 @@
<select id="sessions"></select> <select id="sessions"></select>
<button id="newSessionBtn"> New</button> <button id="newSessionBtn"> New</button>
<button id="renameSessionBtn">✏️ Rename</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> </div>
<!-- Status --> <!-- Status -->
@@ -80,10 +80,10 @@
<!-- Chat messages --> <!-- Chat messages -->
<div id="messages"></div> <div id="messages"></div>
<!-- Thinking Stream Panel (collapsible) --> <!-- Live Log Panel (collapsible) -->
<div id="thinkingPanel" class="thinking-panel collapsed"> <div id="thinkingPanel" class="thinking-panel collapsed">
<div class="thinking-header" id="thinkingHeader"> <div class="thinking-header" id="thinkingHeader">
<span>🧠 Thinking Stream</span> <span>📜 Live Log</span>
<div class="thinking-controls"> <div class="thinking-controls">
<span class="thinking-status-dot" id="thinkingStatusDot"></span> <span class="thinking-status-dot" id="thinkingStatusDot"></span>
<button class="thinking-clear-btn" id="thinkingClearBtn" title="Clear events">🗑️</button> <button class="thinking-clear-btn" id="thinkingClearBtn" title="Clear events">🗑️</button>
@@ -92,8 +92,8 @@
</div> </div>
<div class="thinking-content" id="thinkingContent"> <div class="thinking-content" id="thinkingContent">
<div class="thinking-empty" id="thinkingEmpty"> <div class="thinking-empty" id="thinkingEmpty">
<div class="thinking-empty-icon">🤔</div> <div class="thinking-empty-icon">📡</div>
<p>Waiting for thinking events...</p> <p>Waiting for activity...</p>
</div> </div>
</div> </div>
</div> </div>
@@ -509,22 +509,6 @@
addMessage("system", `Session renamed to: ${newName}`); 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 // Settings Modal
const settingsModal = document.getElementById("settingsModal"); const settingsModal = document.getElementById("settingsModal");
const settingsBtn = document.getElementById("settingsBtn"); const settingsBtn = document.getElementById("settingsBtn");
@@ -705,113 +689,63 @@
} }
function connectThinkingStream() { function connectThinkingStream() {
if (!currentSession) return;
// Close existing connection // Close existing connection
if (thinkingEventSource) { if (thinkingEventSource) {
thinkingEventSource.close(); thinkingEventSource.close();
} }
// Load persisted events // The server replays its recent buffer on connect, so start from a clean panel.
loadThinkingEvents(); thinkingContent.innerHTML = '';
thinkingEventCount = 0;
const url = `${CORTEX_BASE}/stream/thinking/${currentSession}`; thinkingContent.appendChild(thinkingEmpty);
console.log('Connecting thinking stream:', url);
const url = `${RELAY_BASE}/stream/logs`; // global server activity feed
thinkingEventSource = new EventSource(url); thinkingEventSource = new EventSource(url);
thinkingEventSource.onopen = () => { thinkingEventSource.onopen = () => {
console.log('Thinking stream connected');
thinkingStatusDot.className = 'thinking-status-dot connected'; thinkingStatusDot.className = 'thinking-status-dot connected';
}; };
thinkingEventSource.onmessage = (event) => { thinkingEventSource.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); addLogEvent(JSON.parse(event.data));
addThinkingEvent(data);
saveThinkingEvent(data); // Persist event
} catch (e) { } catch (e) {
console.error('Failed to parse thinking event:', e); console.error('Failed to parse log event:', e);
} }
}; };
thinkingEventSource.onerror = (error) => { thinkingEventSource.onerror = () => {
console.error('Thinking stream error:', error);
thinkingStatusDot.className = 'thinking-status-dot disconnected'; thinkingStatusDot.className = 'thinking-status-dot disconnected';
// EventSource auto-reconnects; nothing to do here.
// Retry connection after 2 seconds
setTimeout(() => {
if (thinkingEventSource && thinkingEventSource.readyState === EventSource.CLOSED) {
console.log('Reconnecting thinking stream...');
connectThinkingStream();
}
}, 2000);
}; };
} }
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 // Remove empty state if present
if (thinkingEventCount === 0 && thinkingEmpty.parentNode) { if (thinkingEventCount === 0 && thinkingEmpty.parentNode) {
thinkingContent.removeChild(thinkingEmpty); 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'); const eventDiv = document.createElement('div');
eventDiv.className = `thinking-event thinking-event-${event.type}`; eventDiv.className = `log-line log-${level}`;
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.innerHTML = ` eventDiv.innerHTML = `
<span class="thinking-event-icon">${icon}</span> <span class="log-time">${escapeHtml(time)}</span>
<span>${message}</span> <span class="log-level log-level-${level}">${escapeHtml(level)}</span>
${details ? `<div class="thinking-event-details">${details}</div>` : ''} <span class="log-msg">${escapeHtml(event.msg || '')}</span>
${fieldStr ? `<span class="log-fields">${escapeHtml(fieldStr)}</span>` : ''}
`; `;
thinkingContent.appendChild(eventDiv); thinkingContent.appendChild(eventDiv);
@@ -819,47 +753,9 @@
thinkingEventCount++; thinkingEventCount++;
} }
// Persist thinking events to localStorage // (Log events are server-side and replayed on connect; no localStorage needed.)
function saveThinkingEvent(event) {
if (!currentSession) return;
const key = `thinkingEvents_${currentSession}`; // Live Log toggle button
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
document.getElementById("thinkingStreamBtn").addEventListener("click", () => { document.getElementById("thinkingStreamBtn").addEventListener("click", () => {
thinkingPanel.classList.remove("collapsed"); thinkingPanel.classList.remove("collapsed");
localStorage.setItem("thinkingPanelCollapsed", "false"); localStorage.setItem("thinkingPanelCollapsed", "false");
@@ -872,18 +768,10 @@
localStorage.setItem("thinkingPanelCollapsed", "false"); localStorage.setItem("thinkingPanelCollapsed", "false");
}); });
// Connect thinking stream when session loads // Connect to the global live log on page load.
if (currentSession) { connectThinkingStream();
connectThinkingStream();
}
// Reconnect thinking stream when session changes // The live log is global (server-wide), so it does not reconnect on session change.
const originalSessionChange = document.getElementById("sessions").onchange;
document.getElementById("sessions").addEventListener("change", () => {
setTimeout(() => {
connectThinkingStream();
}, 500); // Wait for session to load
});
// Cleanup on page unload // Cleanup on page unload
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
+34
View File
@@ -907,3 +907,37 @@ select:hover {
display: none !important; 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; }