diff --git a/lyra/chat.py b/lyra/chat.py
index 6b9a0ff..67d876b 100644
--- a/lyra/chat.py
+++ b/lyra/chat.py
@@ -6,7 +6,7 @@ session, then asks the model for a reply and persists both sides.
"""
from __future__ import annotations
-from lyra import llm, memory, persona
+from lyra import config, llm, logbus, memory, persona
from lyra.llm import Backend, Message
RECALL_K = 5
@@ -34,6 +34,7 @@ def build_messages(session_id: str, user_msg: str) -> list[Message]:
recalled = [
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:
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:
"""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)
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, "assistant", reply)
diff --git a/lyra/logbus.py b/lyra/logbus.py
new file mode 100644
index 0000000..08aefd4
--- /dev/null
+++ b/lyra/logbus.py
@@ -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]
diff --git a/lyra/web/server.py b/lyra/web/server.py
index fe61c7d..2688613 100644
--- a/lyra/web/server.py
+++ b/lyra/web/server.py
@@ -10,15 +10,21 @@ re-stored).
from __future__ import annotations
import asyncio
+import json
+import time
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles
-from lyra import chat, memory
+from lyra import chat, logbus, memory
from lyra.llm import Backend
+
+def _sse(event: dict) -> str:
+ return f"data: {json.dumps(event)}\n\n"
+
_STATIC = Path(__file__).parent / "static"
# 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", []))
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 {
"object": "chat.completion",
@@ -92,14 +102,25 @@ def create_app() -> FastAPI:
],
}
- @app.get("/stream/thinking/{session_id}")
- async def thinking_stream(session_id: str) -> StreamingResponse:
- # Inert until cognitive layers exist: open the stream, emit keep-alives only.
+ @app.get("/stream/logs")
+ async def stream_logs(request: Request) -> StreamingResponse:
+ """Live activity feed: replay the recent buffer, then stream new events."""
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:
- await asyncio.sleep(25)
- yield ": keep-alive\n\n"
+ if await request.is_disconnected():
+ 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")
diff --git a/lyra/web/static/index.html b/lyra/web/static/index.html
index f668797..3cbb822 100644
--- a/lyra/web/static/index.html
+++ b/lyra/web/static/index.html
@@ -35,7 +35,7 @@
@@ -80,10 +80,10 @@
-
+
@@ -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 = `
-
${icon}
-
${message}
- ${details ? `
${details}
` : ''}
+
${escapeHtml(time)}
+
${escapeHtml(level)}
+
${escapeHtml(event.msg || '')}
+ ${fieldStr ? `
${escapeHtml(fieldStr)}` : ''}
`;
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', () => {
diff --git a/lyra/web/static/style.css b/lyra/web/static/style.css
index c921d0d..bdfbb46 100644
--- a/lyra/web/static/style.css
+++ b/lyra/web/static/style.css
@@ -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; }