feat: persona chat loop, web UI, and local (Ollama) embeddings

Phase 1 — persona + persistent memory chat loop:
- lyra/persona.py + personas/lyra.md: editable identity/voice (friend-first,
  honest, never invents poker math)
- lyra/chat.py: turn loop assembling persona + cross-session recall + recent
  context, persisting both sides to SQLite
- lyra/session.py, lyra/__main__.py: session lifecycle + `lyra` REPL

Phase 1.25 — reuse the old web UI:
- vendored the prior single-page UI into lyra/web/static, repointed to
  same-origin
- lyra/web/server.py (FastAPI): serves the UI and backs its endpoint contract
  (/v1/chat/completions, session CRUD, health, inert thinking-stream) with the
  new chat loop + memory; SQLite stays the single source of truth
- `lyra-web` console script

Local backends — test for free, no OpenAI key:
- llm.embed routes via EMBED_BACKEND (cloud=OpenAI, local=Ollama /api/embed)
- simplified UI backend selector to Local (Ollama) / Cloud (OpenAI), default local
- memory connection opened check_same_thread=False for the threaded server

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 18:36:31 +00:00
parent 6d88505697
commit 3b9e0bb1e0
17 changed files with 2973 additions and 4 deletions
View File
+127
View File
@@ -0,0 +1,127 @@
"""Web server for the vendored chat UI.
Serves the static single-page UI and implements the small endpoint contract it
expects (originally provided by the old Node relay), backed by the new Python
chat loop and SQLite memory. SQLite is the single source of truth for messages:
`/v1/chat/completions` persists via `chat.respond`, so the UI's `POST /sessions`
saves are accepted but treated as no-ops (the row is ensured, messages are not
re-stored).
"""
from __future__ import annotations
import asyncio
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.llm import Backend
_STATIC = Path(__file__).parent / "static"
# UI backend labels -> our two backends. Cloud is the default.
_CLOUD = {"OPENAI", "cloud", "custom"}
def _backend_for(label: str | None) -> Backend:
if label and label.upper() in {"PRIMARY", "SECONDARY", "FALLBACK", "LOCAL"}:
return "local"
return "cloud"
def _last_user_message(messages: list[dict]) -> str:
for m in reversed(messages):
if m.get("role") == "user":
return m.get("content", "")
return messages[-1].get("content", "") if messages else ""
def create_app() -> FastAPI:
app = FastAPI(title="Lyra Web")
@app.get("/_health")
async def health() -> dict:
return {"ok": True}
@app.get("/sessions")
async def list_sessions() -> list[dict]:
return memory.list_sessions()
@app.get("/sessions/{session_id}")
async def get_session(session_id: str) -> list[dict]:
return [{"role": ex.role, "content": ex.content} for ex in memory.history(session_id)]
@app.post("/sessions/{session_id}")
async def save_session(session_id: str, request: Request) -> dict:
# Messages are already persisted by chat.respond; just ensure the row exists.
await request.body() # drain the history payload we intentionally ignore
memory.ensure_session(session_id)
return {"ok": True}
@app.patch("/sessions/{session_id}/metadata")
async def rename_session(session_id: str, request: Request) -> dict:
body = await request.json()
memory.ensure_session(session_id, name=body.get("name"))
return {"ok": True}
@app.delete("/sessions/{session_id}")
async def delete_session(session_id: str) -> dict:
memory.delete_session(session_id)
return {"ok": True}
@app.post("/v1/chat/completions")
async def chat_completions(request: Request) -> dict:
body = await request.json()
session_id = body.get("sessionId") or "default"
backend = _backend_for(body.get("backend"))
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)
return {
"object": "chat.completion",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": reply},
"finish_reason": "stop",
}
],
}
@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.
async def gen():
yield ": connected\n\n"
while True:
await asyncio.sleep(25)
yield ": keep-alive\n\n"
return StreamingResponse(gen(), media_type="text/event-stream")
# Static UI last, so the API routes above take precedence. html=True serves
# index.html at "/" and assets (style.css, manifest.json) at their paths.
app.mount("/", StaticFiles(directory=str(_STATIC), html=True), name="ui")
return app
app = create_app()
def serve() -> None:
"""Console-script entry: `lyra-web`."""
import os
import uvicorn
host = os.getenv("LYRA_WEB_HOST", "0.0.0.0")
port = int(os.getenv("LYRA_WEB_PORT", "7078"))
uvicorn.run(app, host=host, port=port)
if __name__ == "__main__":
serve()
+897
View File
@@ -0,0 +1,897 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Lyra Core Chat</title>
<link rel="stylesheet" href="style.css" />
<!-- PWA -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="manifest.json" />
</head>
<body>
<!-- Mobile Menu Overlay -->
<div class="mobile-menu-overlay" id="mobileMenuOverlay"></div>
<!-- Mobile Slide-out Menu -->
<div class="mobile-menu" id="mobileMenu">
<div class="mobile-menu-section">
<h4>Mode</h4>
<select id="mobileMode">
<option value="standard">Standard</option>
<option value="cortex">Cortex</option>
</select>
</div>
<div class="mobile-menu-section">
<h4>Session</h4>
<select id="mobileSessions"></select>
<button id="mobileNewSessionBtn"> New Session</button>
<button id="mobileRenameSessionBtn">✏️ Rename Session</button>
</div>
<div class="mobile-menu-section">
<h4>Actions</h4>
<button id="mobileThinkingStreamBtn">🧠 Show Work</button>
<button id="mobileSettingsBtn">⚙ Settings</button>
<button id="mobileToggleThemeBtn">🌙 Toggle Theme</button>
<button id="mobileForceReloadBtn">🔄 Force Reload</button>
</div>
</div>
<div id="chat">
<!-- Mode selector -->
<div id="model-select">
<!-- Hamburger menu (mobile only) -->
<button class="hamburger-menu" id="hamburgerMenu" aria-label="Menu">
<span></span>
<span></span>
<span></span>
</button>
<label for="mode">Mode:</label>
<select id="mode">
<option value="standard">Standard</option>
<option value="cortex">Cortex</option>
</select>
<button id="settingsBtn" style="margin-left: auto;">⚙ Settings</button>
<div id="theme-toggle">
<button id="toggleThemeBtn">🌙 Dark Mode</button>
</div>
</div>
<!-- Session selector -->
<div id="session-select">
<label for="sessions">Session:</label>
<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>
</div>
<!-- Status -->
<div id="status">
<span id="status-dot"></span>
<span id="status-text">Checking Relay...</span>
</div>
<!-- Chat messages -->
<div id="messages"></div>
<!-- Thinking Stream Panel (collapsible) -->
<div id="thinkingPanel" class="thinking-panel collapsed">
<div class="thinking-header" id="thinkingHeader">
<span>🧠 Thinking Stream</span>
<div class="thinking-controls">
<span class="thinking-status-dot" id="thinkingStatusDot"></span>
<button class="thinking-clear-btn" id="thinkingClearBtn" title="Clear events">🗑️</button>
<button class="thinking-toggle-btn" id="thinkingToggleBtn"></button>
</div>
</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>
</div>
</div>
<!-- Input box -->
<div id="input">
<input id="userInput" type="text" placeholder="Type a message..." autofocus />
<button id="sendBtn">Send</button>
</div>
</div>
<!-- Settings Modal (outside chat container) -->
<div id="settingsModal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<h3>Settings</h3>
<button id="closeModalBtn" class="close-btn"></button>
</div>
<div class="modal-body">
<div class="settings-section">
<h4>Chat Backend</h4>
<p class="settings-desc">Which model generates Lyra's replies. (Embeddings are set separately, via EMBED_BACKEND.)</p>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="backend" value="local" checked>
<span>Local — Ollama</span>
<small>Free, private, runs on your home lab (LOCAL_MODEL)</small>
</label>
<label class="radio-label">
<input type="radio" name="backend" value="cloud">
<span>Cloud — OpenAI</span>
<small>Higher quality, costs money (CLOUD_MODEL)</small>
</label>
</div>
</div>
<div class="settings-section" style="margin-top: 24px;">
<h4>Session Management</h4>
<p class="settings-desc">Manage your saved chat sessions:</p>
<div id="sessionList" class="session-list">
<p style="color: var(--text-fade); font-size: 0.85rem;">Loading sessions...</p>
</div>
</div>
</div>
<div class="modal-footer">
<button id="saveSettingsBtn" class="primary-btn">Save</button>
<button id="cancelSettingsBtn">Cancel</button>
</div>
</div>
</div>
<script>
const RELAY_BASE = ""; // same-origin: served by lyra.web.server
const API_URL = `${RELAY_BASE}/v1/chat/completions`;
function generateSessionId() {
return "sess-" + Math.random().toString(36).substring(2, 10);
}
let history = [];
let currentSession = localStorage.getItem("currentSession") || null;
let sessions = []; // Now loaded from server
async function loadSessionsFromServer() {
try {
const resp = await fetch(`${RELAY_BASE}/sessions`);
const serverSessions = await resp.json();
sessions = serverSessions;
return sessions;
} catch (e) {
console.error("Failed to load sessions from server:", e);
return [];
}
}
async function renderSessions() {
const select = document.getElementById("sessions");
const mobileSelect = document.getElementById("mobileSessions");
select.innerHTML = "";
mobileSelect.innerHTML = "";
sessions.forEach(s => {
const opt = document.createElement("option");
opt.value = s.id;
opt.textContent = s.name || s.id;
if (s.id === currentSession) opt.selected = true;
select.appendChild(opt);
// Clone for mobile menu
const mobileOpt = opt.cloneNode(true);
mobileSelect.appendChild(mobileOpt);
});
}
function getSessionName(id) {
const s = sessions.find(s => s.id === id);
return s ? (s.name || s.id) : id;
}
async function saveSessionMetadata(sessionId, name) {
try {
await fetch(`${RELAY_BASE}/sessions/${sessionId}/metadata`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name })
});
return true;
} catch (e) {
console.error("Failed to save session metadata:", e);
return false;
}
}
async function loadSession(id) {
try {
const res = await fetch(`${RELAY_BASE}/sessions/${id}`);
const data = await res.json();
history = Array.isArray(data) ? data : [];
const messagesEl = document.getElementById("messages");
messagesEl.innerHTML = "";
history.forEach(m => addMessage(m.role, m.content, false)); // Don't auto-scroll for each message
addMessage("system", `📂 Loaded session: ${getSessionName(id)}${history.length} message(s)`, false);
// Scroll to bottom after all messages are loaded
messagesEl.scrollTo({ top: messagesEl.scrollHeight, behavior: "smooth" });
} catch (e) {
addMessage("system", `Failed to load session: ${e.message}`);
}
}
async function saveSession() {
if (!currentSession) return;
try {
await fetch(`${RELAY_BASE}/sessions/${currentSession}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(history)
});
} catch (e) {
addMessage("system", `Failed to save session: ${e.message}`);
}
}
async function sendMessage() {
const inputEl = document.getElementById("userInput");
const msg = inputEl.value.trim();
if (!msg) return;
inputEl.value = "";
addMessage("user", msg);
history.push({ role: "user", content: msg });
await saveSession(); // ✅ persist both user + assistant messages
const mode = document.getElementById("mode").value;
// make sure we always include a stable user_id
let userId = localStorage.getItem("userId");
if (!userId) {
userId = "brian"; // use whatever ID you seeded Mem0 with
localStorage.setItem("userId", userId);
}
// Which chat backend to use (local Ollama vs cloud OpenAI).
let backend = localStorage.getItem("standardModeBackend") || "local";
const body = {
mode: mode,
messages: history,
sessionId: currentSession
};
// Only add backend if in standard mode
if (backend) {
body.backend = backend;
}
try {
const resp = await fetch(API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const data = await resp.json();
const reply = data.choices?.[0]?.message?.content || "(no reply)";
addMessage("assistant", reply);
history.push({ role: "assistant", content: reply });
await saveSession();
} catch (err) {
addMessage("system", "Error: " + err.message);
}
}
function addMessage(role, text, autoScroll = true) {
const messagesEl = document.getElementById("messages");
const msgDiv = document.createElement("div");
msgDiv.className = `msg ${role}`;
msgDiv.textContent = text;
messagesEl.appendChild(msgDiv);
// Auto-scroll to bottom if enabled
if (autoScroll) {
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
messagesEl.scrollTo({ top: messagesEl.scrollHeight, behavior: "smooth" });
});
}
}
async function checkHealth() {
try {
const resp = await fetch(API_URL.replace("/v1/chat/completions", "/_health"));
if (resp.ok) {
document.getElementById("status-dot").className = "dot ok";
document.getElementById("status-text").textContent = "Relay Online";
} else {
throw new Error("Bad status");
}
} catch (err) {
document.getElementById("status-dot").className = "dot fail";
document.getElementById("status-text").textContent = "Relay Offline";
}
}
document.addEventListener("DOMContentLoaded", () => {
// Mobile Menu Toggle
const hamburgerMenu = document.getElementById("hamburgerMenu");
const mobileMenu = document.getElementById("mobileMenu");
const mobileMenuOverlay = document.getElementById("mobileMenuOverlay");
function toggleMobileMenu() {
mobileMenu.classList.toggle("open");
mobileMenuOverlay.classList.toggle("show");
hamburgerMenu.classList.toggle("active");
}
function closeMobileMenu() {
mobileMenu.classList.remove("open");
mobileMenuOverlay.classList.remove("show");
hamburgerMenu.classList.remove("active");
}
hamburgerMenu.addEventListener("click", toggleMobileMenu);
mobileMenuOverlay.addEventListener("click", closeMobileMenu);
// Sync mobile menu controls with desktop
const mobileMode = document.getElementById("mobileMode");
const desktopMode = document.getElementById("mode");
// Sync mode selection
mobileMode.addEventListener("change", (e) => {
desktopMode.value = e.target.value;
desktopMode.dispatchEvent(new Event("change"));
});
desktopMode.addEventListener("change", (e) => {
mobileMode.value = e.target.value;
});
// Mobile theme toggle
document.getElementById("mobileToggleThemeBtn").addEventListener("click", () => {
document.getElementById("toggleThemeBtn").click();
updateMobileThemeButton();
});
function updateMobileThemeButton() {
const isDark = document.body.classList.contains("dark");
document.getElementById("mobileToggleThemeBtn").textContent = isDark ? "☀️ Light Mode" : "🌙 Dark Mode";
}
// Mobile settings button
document.getElementById("mobileSettingsBtn").addEventListener("click", () => {
closeMobileMenu();
document.getElementById("settingsBtn").click();
});
// Mobile thinking stream button
document.getElementById("mobileThinkingStreamBtn").addEventListener("click", () => {
closeMobileMenu();
document.getElementById("thinkingStreamBtn").click();
});
// Mobile new session button
document.getElementById("mobileNewSessionBtn").addEventListener("click", () => {
closeMobileMenu();
document.getElementById("newSessionBtn").click();
});
// Mobile rename session button
document.getElementById("mobileRenameSessionBtn").addEventListener("click", () => {
closeMobileMenu();
document.getElementById("renameSessionBtn").click();
});
// Sync mobile session selector with desktop
document.getElementById("mobileSessions").addEventListener("change", async (e) => {
closeMobileMenu();
const desktopSessions = document.getElementById("sessions");
desktopSessions.value = e.target.value;
desktopSessions.dispatchEvent(new Event("change"));
});
// Mobile force reload button
document.getElementById("mobileForceReloadBtn").addEventListener("click", async () => {
if (confirm("Force reload the app? This will clear cache and reload.")) {
// Clear all caches if available
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(name => caches.delete(name)));
}
// Force reload from server (bypass cache)
window.location.reload(true);
}
});
// Dark mode toggle - defaults to dark
const btn = document.getElementById("toggleThemeBtn");
// Set dark mode by default if no preference saved
const savedTheme = localStorage.getItem("theme");
if (!savedTheme || savedTheme === "dark") {
document.body.classList.add("dark");
btn.textContent = "☀️ Light Mode";
localStorage.setItem("theme", "dark");
} else {
btn.textContent = "🌙 Dark Mode";
}
btn.addEventListener("click", () => {
document.body.classList.toggle("dark");
const isDark = document.body.classList.contains("dark");
btn.textContent = isDark ? "☀️ Light Mode" : "🌙 Dark Mode";
localStorage.setItem("theme", isDark ? "dark" : "light");
updateMobileThemeButton();
});
// Initialize mobile theme button
updateMobileThemeButton();
// Sessions - Load from server
(async () => {
await loadSessionsFromServer();
await renderSessions();
// Ensure we have at least one session
if (sessions.length === 0) {
const id = generateSessionId();
const name = "default";
currentSession = id;
history = [];
await saveSession(); // Create empty session on server
await saveSessionMetadata(id, name);
await loadSessionsFromServer();
await renderSessions();
localStorage.setItem("currentSession", currentSession);
} else {
// If no current session or current session doesn't exist, use first one
if (!currentSession || !sessions.find(s => s.id === currentSession)) {
currentSession = sessions[0].id;
localStorage.setItem("currentSession", currentSession);
}
}
// Load current session history
if (currentSession) {
await loadSession(currentSession);
}
})();
// Switch session
document.getElementById("sessions").addEventListener("change", async e => {
currentSession = e.target.value;
history = [];
localStorage.setItem("currentSession", currentSession);
addMessage("system", `Switched to session: ${getSessionName(currentSession)}`);
await loadSession(currentSession);
});
// Create new session
document.getElementById("newSessionBtn").addEventListener("click", async () => {
const name = prompt("Enter new session name:");
if (!name) return;
const id = generateSessionId();
currentSession = id;
history = [];
localStorage.setItem("currentSession", currentSession);
// Create session on server
await saveSession();
await saveSessionMetadata(id, name);
await loadSessionsFromServer();
await renderSessions();
addMessage("system", `Created session: ${name}`);
});
// Rename session
document.getElementById("renameSessionBtn").addEventListener("click", async () => {
const session = sessions.find(s => s.id === currentSession);
if (!session) return;
const newName = prompt("Rename session:", session.name || currentSession);
if (!newName) return;
// Update metadata on server
await saveSessionMetadata(currentSession, newName);
await loadSessionsFromServer();
await renderSessions();
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");
const closeModalBtn = document.getElementById("closeModalBtn");
const saveSettingsBtn = document.getElementById("saveSettingsBtn");
const cancelSettingsBtn = document.getElementById("cancelSettingsBtn");
const modalOverlay = document.querySelector(".modal-overlay");
// Load saved backend preference (default: local/free)
const savedBackend = localStorage.getItem("standardModeBackend") || "local";
// Set initial radio button state
const initialRadio = document.querySelector(`input[name="backend"][value="${savedBackend}"]`);
if (initialRadio) initialRadio.checked = true;
// Session management functions
async function loadSessionList() {
try {
// Reload from server to get latest
await loadSessionsFromServer();
const sessionListEl = document.getElementById("sessionList");
if (sessions.length === 0) {
sessionListEl.innerHTML = '<p style="color: var(--text-fade); font-size: 0.85rem;">No saved sessions found</p>';
return;
}
sessionListEl.innerHTML = "";
sessions.forEach(sess => {
const sessionItem = document.createElement("div");
sessionItem.className = "session-item";
const sessionInfo = document.createElement("div");
sessionInfo.className = "session-info";
const sessionName = sess.name || sess.id;
const lastModified = new Date(sess.lastModified).toLocaleString();
sessionInfo.innerHTML = `
<strong>${sessionName}</strong>
<small>${sess.messageCount} messages • ${lastModified}</small>
`;
const deleteBtn = document.createElement("button");
deleteBtn.className = "session-delete-btn";
deleteBtn.textContent = "🗑️";
deleteBtn.title = "Delete session";
deleteBtn.onclick = async () => {
if (!confirm(`Delete session "${sessionName}"?`)) return;
try {
await fetch(`${RELAY_BASE}/sessions/${sess.id}`, { method: "DELETE" });
// Reload sessions from server
await loadSessionsFromServer();
// If we deleted the current session, switch to another or create new
if (currentSession === sess.id) {
if (sessions.length > 0) {
currentSession = sessions[0].id;
localStorage.setItem("currentSession", currentSession);
history = [];
await loadSession(currentSession);
} else {
const id = generateSessionId();
const name = "default";
currentSession = id;
localStorage.setItem("currentSession", currentSession);
history = [];
await saveSession();
await saveSessionMetadata(id, name);
await loadSessionsFromServer();
}
}
// Refresh both the dropdown and the settings list
await renderSessions();
await loadSessionList();
addMessage("system", `Deleted session: ${sessionName}`);
} catch (e) {
alert("Failed to delete session: " + e.message);
}
};
sessionItem.appendChild(sessionInfo);
sessionItem.appendChild(deleteBtn);
sessionListEl.appendChild(sessionItem);
});
} catch (e) {
const sessionListEl = document.getElementById("sessionList");
sessionListEl.innerHTML = '<p style="color: #ff3333; font-size: 0.85rem;">Failed to load sessions</p>';
}
}
// Show modal and load session list
settingsBtn.addEventListener("click", () => {
settingsModal.classList.add("show");
loadSessionList(); // Refresh session list when opening settings
});
// Hide modal functions
const hideModal = () => {
settingsModal.classList.remove("show");
};
closeModalBtn.addEventListener("click", hideModal);
cancelSettingsBtn.addEventListener("click", hideModal);
modalOverlay.addEventListener("click", hideModal);
// ESC key to close
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && settingsModal.classList.contains("show")) {
hideModal();
}
});
// Save settings
saveSettingsBtn.addEventListener("click", () => {
const selectedRadio = document.querySelector('input[name="backend"]:checked');
const backendValue = selectedRadio ? selectedRadio.value : "local";
localStorage.setItem("standardModeBackend", backendValue);
addMessage("system", `Backend changed to: ${backendValue}`);
hideModal();
});
// Health check
checkHealth();
setInterval(checkHealth, 10000);
// Input events
document.getElementById("sendBtn").addEventListener("click", sendMessage);
document.getElementById("userInput").addEventListener("keypress", e => {
if (e.key === "Enter") sendMessage();
});
// ========== THINKING STREAM INTEGRATION ==========
const thinkingPanel = document.getElementById("thinkingPanel");
const thinkingHeader = document.getElementById("thinkingHeader");
const thinkingToggleBtn = document.getElementById("thinkingToggleBtn");
const thinkingClearBtn = document.getElementById("thinkingClearBtn");
const thinkingContent = document.getElementById("thinkingContent");
const thinkingStatusDot = document.getElementById("thinkingStatusDot");
const thinkingEmpty = document.getElementById("thinkingEmpty");
let thinkingEventSource = null;
let thinkingEventCount = 0;
const CORTEX_BASE = ""; // same-origin; thinking stream is inert until cognitive layers exist
// Load thinking panel state from localStorage
const isPanelCollapsed = localStorage.getItem("thinkingPanelCollapsed") === "true";
if (!isPanelCollapsed) {
thinkingPanel.classList.remove("collapsed");
}
// Toggle thinking panel
thinkingHeader.addEventListener("click", (e) => {
if (e.target === thinkingClearBtn) return; // Don't toggle if clicking clear
thinkingPanel.classList.toggle("collapsed");
localStorage.setItem("thinkingPanelCollapsed", thinkingPanel.classList.contains("collapsed"));
});
// Clear thinking events
thinkingClearBtn.addEventListener("click", (e) => {
e.stopPropagation();
clearThinkingEvents();
});
function clearThinkingEvents() {
thinkingContent.innerHTML = '';
thinkingContent.appendChild(thinkingEmpty);
thinkingEventCount = 0;
// Clear from localStorage
if (currentSession) {
localStorage.removeItem(`thinkingEvents_${currentSession}`);
}
}
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);
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
} catch (e) {
console.error('Failed to parse thinking event:', e);
}
};
thinkingEventSource.onerror = (error) => {
console.error('Thinking stream error:', error);
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);
};
}
function addThinkingEvent(event) {
// Remove empty state if present
if (thinkingEventCount === 0 && thinkingEmpty.parentNode) {
thinkingContent.removeChild(thinkingEmpty);
}
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.innerHTML = `
<span class="thinking-event-icon">${icon}</span>
<span>${message}</span>
${details ? `<div class="thinking-event-details">${details}</div>` : ''}
`;
thinkingContent.appendChild(eventDiv);
thinkingContent.scrollTop = thinkingContent.scrollHeight;
thinkingEventCount++;
}
// Persist thinking events to localStorage
function saveThinkingEvent(event) {
if (!currentSession) return;
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
document.getElementById("thinkingStreamBtn").addEventListener("click", () => {
thinkingPanel.classList.remove("collapsed");
localStorage.setItem("thinkingPanelCollapsed", "false");
});
// Mobile thinking stream button
document.getElementById("mobileThinkingStreamBtn").addEventListener("click", () => {
closeMobileMenu();
thinkingPanel.classList.remove("collapsed");
localStorage.setItem("thinkingPanelCollapsed", "false");
});
// Connect thinking stream when session loads
if (currentSession) {
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
});
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (thinkingEventSource) {
thinkingEventSource.close();
}
});
});
</script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
{
"name": "Lyra Chat",
"short_name": "Lyra",
"start_url": "./index.html",
"display": "standalone",
"background_color": "#181818",
"theme_color": "#181818",
"icons": [
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+909
View File
@@ -0,0 +1,909 @@
:root {
--bg-dark: #0a0a0a;
--bg-panel: rgba(255, 115, 0, 0.1);
--accent: #ff6600;
--accent-glow: 0 0 12px #ff6600cc;
--text-main: #e6e6e6;
--text-fade: #999;
--font-console: "IBM Plex Mono", monospace;
}
/* Light mode variables */
body {
--bg-dark: #f5f5f5;
--bg-panel: rgba(255, 115, 0, 0.05);
--accent: #ff6600;
--accent-glow: 0 0 12px #ff6600cc;
--text-main: #1a1a1a;
--text-fade: #666;
}
/* Dark mode variables */
body.dark {
--bg-dark: #0a0a0a;
--bg-panel: rgba(255, 115, 0, 0.1);
--accent: #ff6600;
--accent-glow: 0 0 12px #ff6600cc;
--text-main: #e6e6e6;
--text-fade: #999;
}
body {
margin: 0;
background: var(--bg-dark);
color: var(--text-main);
font-family: var(--font-console);
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
#chat {
width: 95%;
max-width: 900px;
height: 95vh;
display: flex;
flex-direction: column;
border: 1px solid var(--accent);
border-radius: 10px;
box-shadow: var(--accent-glow);
background: var(--bg-dark);
overflow: hidden;
}
/* Header sections */
#model-select, #session-select, #status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--accent);
background-color: rgba(255, 102, 0, 0.05);
}
#status {
justify-content: flex-start;
border-top: 1px solid var(--accent);
}
label, select, button {
font-family: var(--font-console);
font-size: 0.9rem;
color: var(--text-main);
background: transparent;
border: 1px solid var(--accent);
border-radius: 4px;
padding: 4px 8px;
}
button:hover, select:hover {
box-shadow: 0 0 8px var(--accent);
cursor: pointer;
}
#thinkingStreamBtn {
background: rgba(138, 43, 226, 0.2);
border-color: #8a2be2;
}
#thinkingStreamBtn:hover {
box-shadow: 0 0 8px #8a2be2;
background: rgba(138, 43, 226, 0.3);
}
/* Chat area */
#messages {
flex: 1;
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
scroll-behavior: smooth;
}
/* Messages */
.msg {
max-width: 80%;
padding: 10px 14px;
border-radius: 8px;
line-height: 1.4;
word-wrap: break-word;
box-shadow: 0 0 8px rgba(255,102,0,0.2);
}
.msg.user {
align-self: flex-end;
background: rgba(255,102,0,0.15);
border: 1px solid var(--accent);
}
.msg.assistant {
align-self: flex-start;
background: rgba(255,102,0,0.08);
border: 1px solid rgba(255,102,0,0.5);
}
.msg.system {
align-self: center;
font-size: 0.8rem;
color: var(--text-fade);
}
/* Input bar */
#input {
display: flex;
border-top: 1px solid var(--accent);
background: rgba(255, 102, 0, 0.05);
padding: 10px;
}
#userInput {
flex: 1;
background: transparent;
color: var(--text-main);
border: 1px solid var(--accent);
border-radius: 4px;
padding: 8px;
}
#sendBtn {
margin-left: 8px;
}
/* Relay status dot */
#status {
display: flex;
align-items: center;
margin: 10px 0;
gap: 8px;
font-family: monospace;
color: #f5f5f5;
}
#status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
@keyframes pulseGreen {
0% { box-shadow: 0 0 5px #00ff66; opacity: 0.9; }
50% { box-shadow: 0 0 20px #00ff99; opacity: 1; }
100% { box-shadow: 0 0 5px #00ff66; opacity: 0.9; }
}
.dot.ok {
background: #00ff66;
animation: pulseGreen 2s infinite ease-in-out;
}
/* Offline state stays solid red */
.dot.fail {
background: #ff3333;
box-shadow: 0 0 10px #ff3333;
}
/* Dropdown (session selector) styling */
select {
background-color: var(--bg-dark);
color: var(--text-main);
border: 1px solid #b84a12;
border-radius: 6px;
padding: 4px 6px;
font-size: 14px;
}
select option {
background-color: var(--bg-dark);
color: var(--text-main);
}
/* Hover/focus for better visibility */
select:focus,
select:hover {
outline: none;
border-color: #ff7a33;
background-color: var(--bg-panel);
}
/* Settings Modal */
.modal {
display: none !important;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
}
.modal.show {
display: block !important;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
z-index: 999;
}
.modal-content {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: linear-gradient(180deg, rgba(255,102,0,0.1) 0%, rgba(10,10,10,0.95) 100%);
border: 2px solid var(--accent);
border-radius: 12px;
box-shadow: var(--accent-glow), 0 0 40px rgba(255,102,0,0.3);
min-width: 400px;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
z-index: 1001;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--accent);
background: rgba(255,102,0,0.1);
}
.modal-header h3 {
margin: 0;
font-size: 1.2rem;
color: var(--accent);
}
.close-btn {
background: transparent;
border: none;
color: var(--accent);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.close-btn:hover {
background: rgba(255,102,0,0.2);
box-shadow: 0 0 8px var(--accent);
}
.modal-body {
padding: 20px;
}
.settings-section h4 {
margin: 0 0 8px 0;
color: var(--accent);
font-size: 1rem;
}
.settings-desc {
margin: 0 0 16px 0;
color: var(--text-fade);
font-size: 0.85rem;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.radio-label {
display: flex;
flex-direction: column;
padding: 12px;
border: 1px solid rgba(255,102,0,0.3);
border-radius: 6px;
background: rgba(255,102,0,0.05);
cursor: pointer;
transition: all 0.2s;
}
.radio-label:hover {
border-color: var(--accent);
background: rgba(255,102,0,0.1);
box-shadow: 0 0 8px rgba(255,102,0,0.3);
}
.radio-label input[type="radio"] {
margin-right: 8px;
accent-color: var(--accent);
}
.radio-label span {
font-weight: 500;
margin-bottom: 4px;
}
.radio-label small {
color: var(--text-fade);
font-size: 0.8rem;
margin-left: 24px;
}
.radio-label input[type="text"] {
margin-top: 8px;
margin-left: 24px;
padding: 6px;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,102,0,0.5);
border-radius: 4px;
color: var(--text-main);
font-family: var(--font-console);
}
.radio-label input[type="text"]:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 8px rgba(255,102,0,0.3);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid var(--accent);
background: rgba(255,102,0,0.05);
}
.primary-btn {
background: var(--accent);
color: #000;
font-weight: bold;
}
.primary-btn:hover {
background: #ff7a33;
box-shadow: var(--accent-glow);
}
/* Session List */
.session-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 300px;
overflow-y: auto;
}
.session-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border: 1px solid rgba(255,102,0,0.3);
border-radius: 6px;
background: rgba(255,102,0,0.05);
transition: all 0.2s;
}
.session-item:hover {
border-color: var(--accent);
background: rgba(255,102,0,0.1);
}
.session-info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.session-info strong {
color: var(--text-main);
font-size: 0.95rem;
}
.session-info small {
color: var(--text-fade);
font-size: 0.75rem;
}
.session-delete-btn {
background: transparent;
border: 1px solid rgba(255,102,0,0.5);
color: var(--accent);
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s;
}
.session-delete-btn:hover {
background: rgba(255,0,0,0.2);
border-color: #ff3333;
color: #ff3333;
box-shadow: 0 0 8px rgba(255,0,0,0.3);
}
/* Thinking Stream Panel */
.thinking-panel {
border-top: 1px solid var(--accent);
background: rgba(255, 102, 0, 0.02);
display: flex;
flex-direction: column;
transition: max-height 0.3s ease;
max-height: 300px;
}
.thinking-panel.collapsed {
max-height: 40px;
}
.thinking-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: rgba(255, 102, 0, 0.08);
cursor: pointer;
user-select: none;
border-bottom: 1px solid rgba(255, 102, 0, 0.2);
font-size: 0.9rem;
font-weight: 500;
}
.thinking-header:hover {
background: rgba(255, 102, 0, 0.12);
}
.thinking-controls {
display: flex;
align-items: center;
gap: 8px;
}
.thinking-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
display: inline-block;
}
.thinking-status-dot.connected {
background: #00ff66;
box-shadow: 0 0 8px #00ff66;
}
.thinking-status-dot.disconnected {
background: #ff3333;
}
.thinking-clear-btn,
.thinking-toggle-btn {
background: transparent;
border: 1px solid rgba(255, 102, 0, 0.5);
color: var(--text-main);
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.thinking-clear-btn:hover,
.thinking-toggle-btn:hover {
background: rgba(255, 102, 0, 0.2);
box-shadow: 0 0 6px rgba(255, 102, 0, 0.3);
}
.thinking-toggle-btn {
transition: transform 0.3s ease;
}
.thinking-panel.collapsed .thinking-toggle-btn {
transform: rotate(-90deg);
}
.thinking-content {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.thinking-panel.collapsed .thinking-content {
display: none;
}
.thinking-empty {
text-align: center;
padding: 40px 20px;
color: var(--text-fade);
font-size: 0.85rem;
}
.thinking-empty-icon {
font-size: 2rem;
margin-bottom: 10px;
}
.thinking-event {
padding: 8px 12px;
border-radius: 6px;
font-size: 0.85rem;
font-family: 'Courier New', monospace;
animation: thinkingSlideIn 0.3s ease-out;
border-left: 3px solid;
word-wrap: break-word;
}
@keyframes thinkingSlideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.thinking-event-connected {
background: rgba(0, 255, 102, 0.1);
border-color: #00ff66;
color: #00ff66;
}
.thinking-event-thinking {
background: rgba(138, 43, 226, 0.1);
border-color: #8a2be2;
color: #c79cff;
}
.thinking-event-tool_call {
background: rgba(255, 165, 0, 0.1);
border-color: #ffa500;
color: #ffb84d;
}
.thinking-event-tool_result {
background: rgba(0, 191, 255, 0.1);
border-color: #00bfff;
color: #7dd3fc;
}
.thinking-event-done {
background: rgba(168, 85, 247, 0.1);
border-color: #a855f7;
color: #e9d5ff;
font-weight: bold;
}
.thinking-event-error {
background: rgba(255, 51, 51, 0.1);
border-color: #ff3333;
color: #fca5a5;
}
.thinking-event-icon {
display: inline-block;
margin-right: 8px;
}
.thinking-event-details {
font-size: 0.75rem;
color: var(--text-fade);
margin-top: 4px;
padding-left: 20px;
white-space: pre-wrap;
max-height: 100px;
overflow-y: auto;
}
/* ========== MOBILE RESPONSIVE STYLES ========== */
/* Hamburger Menu */
.hamburger-menu {
display: none;
flex-direction: column;
gap: 4px;
cursor: pointer;
padding: 8px;
border: 1px solid var(--accent);
border-radius: 4px;
background: transparent;
z-index: 100;
}
.hamburger-menu span {
width: 20px;
height: 2px;
background: var(--accent);
transition: all 0.3s;
display: block;
}
.hamburger-menu.active span:nth-child(1) {
transform: rotate(45deg) translate(5px, 5px);
}
.hamburger-menu.active span:nth-child(2) {
opacity: 0;
}
.hamburger-menu.active span:nth-child(3) {
transform: rotate(-45deg) translate(5px, -5px);
}
/* Mobile Menu Container */
.mobile-menu {
display: none;
position: fixed;
top: 0;
left: -100%;
width: 280px;
height: 100vh;
background: var(--bg-dark);
border-right: 2px solid var(--accent);
box-shadow: var(--accent-glow);
z-index: 999;
transition: left 0.3s ease;
overflow-y: auto;
padding: 20px;
flex-direction: column;
gap: 16px;
}
.mobile-menu.open {
left: 0;
}
.mobile-menu-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 998;
}
.mobile-menu-overlay.show {
display: block;
}
.mobile-menu-section {
display: flex;
flex-direction: column;
gap: 8px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 102, 0, 0.3);
}
.mobile-menu-section:last-child {
border-bottom: none;
}
.mobile-menu-section h4 {
margin: 0;
color: var(--accent);
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.mobile-menu button,
.mobile-menu select {
width: 100%;
padding: 10px;
font-size: 0.95rem;
text-align: left;
}
/* Mobile Breakpoints */
@media screen and (max-width: 768px) {
body {
padding: 0;
}
#chat {
width: 100%;
max-width: 100%;
height: 100vh;
border-radius: 0;
border-left: none;
border-right: none;
}
/* Show hamburger, hide desktop header controls */
.hamburger-menu {
display: flex;
}
#model-select {
padding: 12px;
justify-content: space-between;
}
/* Hide all controls except hamburger on mobile */
#model-select > *:not(.hamburger-menu) {
display: none;
}
#session-select {
display: none;
}
/* Show mobile menu */
.mobile-menu {
display: flex;
}
/* Messages - more width on mobile */
.msg {
max-width: 90%;
font-size: 0.95rem;
}
/* Status bar */
#status {
padding: 10px 12px;
font-size: 0.85rem;
}
/* Input area - bigger touch targets */
#input {
padding: 12px;
}
#userInput {
font-size: 16px; /* Prevents zoom on iOS */
padding: 12px;
}
#sendBtn {
padding: 12px 16px;
font-size: 1rem;
}
/* Modal - full width on mobile */
.modal-content {
width: 95%;
min-width: unset;
max-width: unset;
max-height: 90vh;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.modal-header {
padding: 12px 16px;
}
.modal-body {
padding: 16px;
}
.modal-footer {
padding: 12px 16px;
flex-wrap: wrap;
}
.modal-footer button {
flex: 1;
min-width: 120px;
}
/* Radio labels - stack better on mobile */
.radio-label {
padding: 10px;
}
.radio-label small {
margin-left: 20px;
font-size: 0.75rem;
}
/* Session list */
.session-item {
padding: 10px;
}
.session-info strong {
font-size: 0.9rem;
}
.session-info small {
font-size: 0.7rem;
}
/* Settings button in header */
#settingsBtn {
padding: 8px 12px;
}
/* Thinking panel adjustments for mobile */
.thinking-panel {
max-height: 250px;
}
.thinking-panel.collapsed {
max-height: 38px;
}
.thinking-header {
padding: 8px 10px;
font-size: 0.85rem;
}
.thinking-event {
font-size: 0.8rem;
padding: 6px 10px;
}
.thinking-event-details {
font-size: 0.7rem;
max-height: 80px;
}
}
/* Extra small devices (phones in portrait) */
@media screen and (max-width: 480px) {
.mobile-menu {
width: 240px;
}
.msg {
max-width: 95%;
font-size: 0.9rem;
padding: 8px 12px;
}
#userInput {
font-size: 16px;
padding: 10px;
}
#sendBtn {
padding: 10px 14px;
font-size: 0.95rem;
}
.modal-header h3 {
font-size: 1.1rem;
}
.settings-section h4 {
font-size: 0.95rem;
}
.radio-label span {
font-size: 0.9rem;
}
}
/* Tablet landscape and desktop */
@media screen and (min-width: 769px) {
/* Ensure mobile menu is hidden on desktop */
.mobile-menu,
.mobile-menu-overlay {
display: none !important;
}
.hamburger-menu {
display: none !important;
}
}
+362
View File
@@ -0,0 +1,362 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🧠 Thinking Stream</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #0d0d0d;
color: #e0e0e0;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
background: #1a1a1a;
padding: 15px 20px;
border-bottom: 2px solid #333;
display: flex;
align-items: center;
justify-content: space-between;
}
.header h1 {
font-size: 18px;
font-weight: bold;
}
.status {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #666;
}
.status-dot.connected {
background: #90ee90;
box-shadow: 0 0 10px #90ee90;
}
.status-dot.disconnected {
background: #ff6b6b;
}
.events-container {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.event {
margin-bottom: 12px;
padding: 10px 15px;
border-radius: 6px;
font-size: 14px;
font-family: 'Courier New', monospace;
animation: slideIn 0.3s ease-out;
border-left: 3px solid;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.event-connected {
background: #1a2a1a;
border-color: #4a7c59;
color: #90ee90;
}
.event-thinking {
background: #1a3a1a;
border-color: #5a9c69;
color: #a0f0a0;
}
.event-tool_call {
background: #3a2a1a;
border-color: #d97706;
color: #fbbf24;
}
.event-tool_result {
background: #1a2a3a;
border-color: #0ea5e9;
color: #7dd3fc;
}
.event-done {
background: #2a1a3a;
border-color: #a855f7;
color: #e9d5ff;
font-weight: bold;
}
.event-error {
background: #3a1a1a;
border-color: #dc2626;
color: #fca5a5;
}
.event-icon {
display: inline-block;
margin-right: 8px;
}
.event-details {
font-size: 12px;
color: #999;
margin-top: 5px;
padding-left: 25px;
}
.footer {
background: #1a1a1a;
padding: 10px 20px;
border-top: 1px solid #333;
text-align: center;
font-size: 12px;
color: #666;
}
.clear-btn {
background: #333;
border: 1px solid #444;
color: #e0e0e0;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.clear-btn:hover {
background: #444;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="header">
<h1>🧠 Thinking Stream</h1>
<div class="status">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Connecting...</span>
</div>
</div>
<div class="events-container" id="events">
<div class="empty-state">
<div class="empty-state-icon">🤔</div>
<p>Waiting for thinking events...</p>
<p style="font-size: 12px; margin-top: 10px;">Events will appear here when Lyra uses tools</p>
</div>
</div>
<div class="footer">
<button class="clear-btn" onclick="clearEvents()">Clear Events</button>
<span style="margin: 0 20px;">|</span>
<span id="sessionInfo">Session: <span id="sessionId">-</span></span>
</div>
<script>
console.log('🧠 Thinking stream page loaded!');
// Get session ID from URL
const urlParams = new URLSearchParams(window.location.search);
const SESSION_ID = urlParams.get('session');
const CORTEX_BASE = "http://10.0.0.41:7081"; // Direct to cortex
console.log('Session ID:', SESSION_ID);
console.log('Cortex base:', CORTEX_BASE);
// Declare variables first
let eventSource = null;
let eventCount = 0;
if (!SESSION_ID) {
document.getElementById('events').innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<p>No session ID provided</p>
<p style="font-size: 12px; margin-top: 10px;">Please open this from the main chat interface</p>
</div>
`;
} else {
document.getElementById('sessionId').textContent = SESSION_ID;
connectStream();
}
function connectStream() {
if (eventSource) {
eventSource.close();
}
const url = `${CORTEX_BASE}/stream/thinking/${SESSION_ID}`;
console.log('Connecting to:', url);
eventSource = new EventSource(url);
eventSource.onopen = () => {
console.log('EventSource onopen fired');
updateStatus(true, 'Connected');
};
eventSource.onmessage = (event) => {
console.log('Received message:', event.data);
try {
const data = JSON.parse(event.data);
// Update status to connected when first message arrives
if (data.type === 'connected') {
updateStatus(true, 'Connected');
}
addEvent(data);
} catch (e) {
console.error('Failed to parse event:', e, event.data);
}
};
eventSource.onerror = (error) => {
console.error('Stream error:', error, 'readyState:', eventSource.readyState);
updateStatus(false, 'Disconnected');
// Try to reconnect after 2 seconds
setTimeout(() => {
if (eventSource.readyState === EventSource.CLOSED) {
console.log('Attempting to reconnect...');
connectStream();
}
}, 2000);
};
}
function updateStatus(connected, text) {
const dot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
dot.className = 'status-dot ' + (connected ? 'connected' : 'disconnected');
statusText.textContent = text;
}
function addEvent(event) {
const container = document.getElementById('events');
// Remove empty state if present
if (eventCount === 0) {
container.innerHTML = '';
}
const eventDiv = document.createElement('div');
eventDiv.className = `event 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;
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;
details = event.data.final_answer;
break;
case 'error':
icon = '❌';
message = event.data.message;
break;
default:
icon = '•';
message = JSON.stringify(event.data);
}
eventDiv.innerHTML = `
<span class="event-icon">${icon}</span>
<span>${message}</span>
${details ? `<div class="event-details">${details}</div>` : ''}
`;
container.appendChild(eventDiv);
container.scrollTop = container.scrollHeight;
eventCount++;
}
function clearEvents() {
const container = document.getElementById('events');
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🤔</div>
<p>Waiting for thinking events...</p>
<p style="font-size: 12px; margin-top: 10px;">Events will appear here when Lyra uses tools</p>
</div>
`;
eventCount = 0;
}
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (eventSource) {
eventSource.close();
}
});
</script>
</body>
</html>