Files
project-lyra/lyra/web/static/index.html
T
serversdown e1e89c07e4 feat: poker session history — browse, delete, and Lyra lookup
Answers three gaps: no way to delete a single poker session (only clear_all),
no way to browse past sessions, and Lyra could only see aggregate stats.

- poker.list_sessions() (per-session summary + hand count + recap flag) and
  poker.delete_session() (removes a session + its hands/reads/observations/
  stacks/rituals; keeps the persistent villain file).
- /history page (date, stakes, venue, net, hours, recap link, per-row delete with
  confirm) + /history/data + DELETE /history/{id}. Nav links from chat + HUD.
- recent_sessions read tool, added to the shared lookups so Lyra can answer
  "how'd my last few sessions go?" in either mode.
- Delete is UI/CLI only — deliberately not a Lyra tool.
- test_modes.py +2 (list/delete, recent_sessions); 44 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:21:48 +00:00

1105 lines
45 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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, viewport-fit=cover" />
<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" />
<meta name="apple-mobile-web-app-title" content="Lyra" />
<meta name="theme-color" content="#070707" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<link rel="icon" type="image/png" href="icon-192.png" />
<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="conversation">💬 Talk</option>
<option value="poker_cash">♠ Cash</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="mobileSessionBtn">🎬 Session HUD</button>
<button id="mobileHistoryBtn">📚 Past Sessions</button>
<button id="mobileThinkingStreamBtn">📜 Live Log (inline)</button>
<button id="mobileFullLogBtn">⛶ Full Log</button>
<button id="mobileJournalBtn">📔 Journal</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>
<span class="brand">Lyra</span>
<span class="brand-dot" id="brandDot" title="Relay status"></span>
<button class="mode-badge" id="modeBadge" type="button" title="Tap to toggle Talk / Cash mode">💬 Talk</button>
<label for="mode">Mode:</label>
<select id="mode">
<option value="conversation">💬 Talk</option>
<option value="poker_cash">♠ Cash</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 live activity log">📜 Live Log</button>
<a id="fullLogBtn" href="/logs" target="_blank" rel="noopener" title="Open the full-page log" role="button">⛶ Full Log</a>
<a id="sessionBtn" href="/session" target="_blank" rel="noopener" title="Live session HUD" role="button">🎬 Session</a>
<a id="historyBtn" href="/history" target="_blank" rel="noopener" title="Past sessions" role="button">📚 Sessions</a>
<a id="mindBtn" href="/self" target="_blank" rel="noopener" title="Read her mind — Lyra's current self-state" role="button">🧠 Mind</a>
<a id="handsBtn" href="/hands" target="_blank" rel="noopener" title="Recorded poker hands" role="button">🃏 Hands</a>
</div>
<!-- Status -->
<div id="status">
<span id="status-dot"></span>
<span id="status-text">Checking Relay...</span>
</div>
<!-- Chat messages -->
<div id="messages"></div>
<!-- Live Log Panel (collapsible) -->
<div id="thinkingPanel" class="thinking-panel collapsed">
<div class="thinking-header" id="thinkingHeader">
<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>
<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 activity...</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>
<!-- Bottom tab bar (mobile only; hides while the keyboard is open) -->
<nav id="tabbar" aria-label="Primary navigation">
<a class="tab active" href="/" aria-current="page"><span class="ti">💬</span><span class="tl">Chat</span></a>
<a class="tab" href="/session"><span class="ti">🎬</span><span class="tl">Session</span></a>
<a class="tab" href="/hands"><span class="ti">🃏</span><span class="tl">Hands</span></a>
<a class="tab" href="/self"><span class="ti">🧠</span><span class="tl">Mind</span></a>
<button class="tab" id="moreTab" type="button"><span class="ti"></span><span class="tl">More</span></button>
</nav>
</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="mi50">
<span>MI50 — local GPU</span>
<small>Free, llama.cpp on the MI50 box (MI50_BASE_URL)</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>Chat Model (Cloud)</h4>
<p class="settings-desc">Which OpenAI model answers on the Cloud backend. Tools (poker, equity, journaling) require Cloud.</p>
<select id="cloudModel">
<option value="">Default (gpt-4o)</option>
<option value="gpt-4o">gpt-4o — best persona</option>
<option value="gpt-4o-mini">gpt-4o-mini — cheap/fast</option>
<option value="gpt-4.1">gpt-4.1</option>
<option value="gpt-4.1-mini">gpt-4.1-mini</option>
<option value="o4-mini">o4-mini — reasoning</option>
</select>
</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`;
const STREAM_URL = `${RELAY_BASE}/v1/chat/stream`;
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";
// Cash mode is useless without tools, and tools only fire on cloud — so a
// live poker session forces the cloud backend regardless of the saved pick.
if (mode === "poker_cash") backend = "cloud";
const body = {
mode: mode,
messages: history,
sessionId: currentSession
};
// Only add backend if in standard mode
if (backend) {
body.backend = backend;
}
// Cloud chat-model override (ignored server-side unless backend is cloud)
const cloudModel = localStorage.getItem("cloudModel");
if (cloudModel) {
body.model = cloudModel;
}
// Stream the reply token-by-token (SSE). Fall back to the blocking
// endpoint only if nothing streamed (e.g. streaming unavailable).
const div = createAssistantBubble();
let full = "";
try {
const resp = await fetch(STREAM_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (!resp.ok || !resp.body) throw new Error("HTTP " + resp.status);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) !== -1) {
const frame = buf.slice(0, i).trim();
buf = buf.slice(i + 2);
if (!frame.startsWith("data:")) continue;
let evt;
try { evt = JSON.parse(frame.slice(5).trim()); } catch (e) { continue; }
if (evt.type === "delta") {
full += evt.payload;
updateAssistantBubble(div, full);
} else if (evt.type === "done") {
if (evt.payload) full = evt.payload;
} else if (evt.type === "error") {
throw new Error(evt.payload);
}
}
}
} catch (err) {
if (!full) {
div.remove();
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 (err2) {
addMessage("system", "Error: " + err2.message);
}
return;
}
// Partial content arrived before the error — keep what we streamed.
}
finalizeAssistantBubble(div, full || "(no reply)");
history.push({ role: "assistant", content: full || "(no reply)" });
await saveSession();
// If she opened a session this turn, the server auto-flips to Cash mode —
// reflect that here so the badge/HUD follow without a manual switch.
if (document.getElementById("mode").value !== "poker_cash") {
loadModeFor(currentSession);
}
}
function createAssistantBubble() {
const messagesEl = document.getElementById("messages");
const div = document.createElement("div");
div.className = "msg assistant streaming";
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight; // instant — no smooth chasing
return div;
}
// Coalesce token updates to one render per animation frame (avoids re-parsing
// the whole message on every token, and the iOS ghosting from rapid repaints).
function updateAssistantBubble(div, text) {
div._pending = text;
if (div._raf) return;
div._raf = requestAnimationFrame(() => {
div._raf = 0;
const messagesEl = document.getElementById("messages");
const stick = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 90;
div.innerHTML = renderMarkdown(div._pending);
div.dataset.raw = div._pending;
if (stick) messagesEl.scrollTop = messagesEl.scrollHeight; // follow only if near bottom
});
}
function finalizeAssistantBubble(div, text) {
if (div._raf) { cancelAnimationFrame(div._raf); div._raf = 0; } // drop any queued render
div.classList.remove("streaming");
div.innerHTML = renderMarkdown(text);
div.dataset.raw = text;
addRateBar(div);
const messagesEl = document.getElementById("messages");
requestAnimationFrame(() => messagesEl.scrollTo({ top: messagesEl.scrollHeight, behavior: "smooth" }));
}
function renderMarkdown(text) {
var bt = String.fromCharCode(96);
var esc = function (s) { return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); };
var src = String(text == null ? "" : text).replace(/\r\n/g, "\n");
var blocks = [];
var fenceRe = new RegExp(bt + bt + bt + "[^\\n]*\\n?([\\s\\S]*?)" + bt + bt + bt, "g");
src = src.replace(fenceRe, function (_, code) { blocks.push(code.replace(/\n+$/, "")); return "@@CB" + (blocks.length - 1) + "@@"; });
var codeRe = new RegExp(bt + "([^" + bt + "]+)" + bt, "g");
var inline = function (s) {
return esc(s)
.replace(codeRe, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
.replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
};
var lines = src.split("\n");
var out = [], para = [], list = null;
var flushPara = function () { if (para.length) { out.push("<p>" + para.map(inline).join("<br>") + "</p>"); para = []; } };
var flushList = function () { if (list) { out.push("<" + list.t + ">" + list.items.map(function (it) { return "<li>" + inline(it) + "</li>"; }).join("") + "</" + list.t + ">"); list = null; } };
var flushAll = function () { flushPara(); flushList(); };
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/\s+$/, ""); var t = line.trim(); var m;
if ((m = t.match(/^@@CB(\d+)@@$/))) { flushAll(); out.push("<pre><code>" + esc(blocks[+m[1]]) + "</code></pre>"); continue; }
if (!t) { flushAll(); continue; }
if ((m = line.match(/^(#{1,4})\s+(.*)$/))) { flushAll(); out.push("<h" + m[1].length + ">" + inline(m[2]) + "</h" + m[1].length + ">"); continue; }
if ((m = line.match(/^\s*\d+[.)]\s+(.*)$/))) { flushPara(); if (!list || list.t !== "ol") { flushList(); list = { t: "ol", items: [] }; } list.items.push(m[1]); continue; }
if ((m = line.match(/^\s*[-*+]\s+(.*)$/))) { flushPara(); if (!list || list.t !== "ul") { flushList(); list = { t: "ul", items: [] }; } list.items.push(m[1]); continue; }
flushList(); para.push(line);
}
flushAll();
return out.join("\n");
}
function addRateBar(div) {
const bar = document.createElement("div");
bar.className = "rate-bar";
const up = document.createElement("button");
up.className = "rate-btn"; up.textContent = "👍"; up.title = "Good — more like this";
const down = document.createElement("button");
down.className = "rate-btn"; down.textContent = "👎"; down.title = "Off — less like this";
up.addEventListener("click", () => rateMessage(div, 1, up, down));
down.addEventListener("click", () => rateMessage(div, -1, up, down));
bar.appendChild(up); bar.appendChild(down);
div.appendChild(bar);
}
function rateMessage(div, value, up, down) {
// context = the nearest preceding user message
let ctx = "", p = div.previousElementSibling;
while (p) {
if (p.classList && p.classList.contains("user")) { ctx = p.textContent; break; }
p = p.previousElementSibling;
}
fetch(`${RELAY_BASE}/rate`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "chat", rating: value, content: div.dataset.raw || "", context: ctx, session_id: currentSession })
}).catch(() => {});
up.classList.toggle("rated", value === 1);
down.classList.toggle("rated", value === -1);
}
function addMessage(role, text, autoScroll = true) {
const messagesEl = document.getElementById("messages");
const msgDiv = document.createElement("div");
msgDiv.className = `msg ${role}`;
if (role === "assistant") {
msgDiv.innerHTML = renderMarkdown(text);
msgDiv.dataset.raw = text;
addRateBar(msgDiv);
} else {
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" });
});
}
}
// ----- Conversation mode (Talk / Cash) -----
const MODE_LABELS = { conversation: "💬 Talk", poker_cash: "♠ Cash" };
// Reflect a mode value across the controls + header accent (no network call).
function applyMode(value) {
if (!MODE_LABELS[value]) value = "conversation";
const desk = document.getElementById("mode");
const mob = document.getElementById("mobileMode");
const badge = document.getElementById("modeBadge");
if (desk) desk.value = value;
if (mob) mob.value = value;
if (badge) badge.textContent = MODE_LABELS[value];
document.body.classList.toggle("cash-mode", value === "poker_cash");
localStorage.setItem("lyraMode", value);
}
// User picked a mode: apply locally + persist it to this session on the server.
async function chooseMode(value) {
applyMode(value);
if (!currentSession) return;
try {
await fetch(`${RELAY_BASE}/sessions/${currentSession}/mode`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: value })
});
} catch (e) { /* non-fatal: the mode still rides along in the chat body */ }
}
// Pull the active mode for a session from the server (fallback: last local choice).
async function loadModeFor(sessionId) {
let value = localStorage.getItem("lyraMode") || "conversation";
try {
const r = await fetch(`${RELAY_BASE}/sessions/${sessionId}/mode`);
if (r.ok) { const d = await r.json(); if (d.mode) value = d.mode; }
} catch (e) { /* keep the local fallback */ }
applyMode(value);
}
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";
document.getElementById("brandDot").className = "brand-dot ok";
} else {
throw new Error("Bad status");
}
} catch (err) {
document.getElementById("status-dot").className = "dot fail";
document.getElementById("status-text").textContent = "Relay Offline";
document.getElementById("brandDot").className = "brand-dot fail";
}
}
document.addEventListener("DOMContentLoaded", () => {
// --- PWA: track the *visible* viewport height so the layout follows the
// iOS keyboard and the dynamic Safari toolbars (keeps the input bar visible
// instead of hiding behind the keyboard). Falls back to 100dvh via CSS.
function setAppHeight() {
const vv = window.visualViewport;
const h = (vv && vv.height) || window.innerHeight;
const off = (vv && vv.offsetTop) || 0;
const root = document.documentElement.style;
root.setProperty("--app-height", h + "px");
// iOS pans the visual viewport when the keyboard opens; follow its top
// edge so the pinned #chat sits exactly in the visible area.
root.setProperty("--app-offset", off + "px");
// Keyboard open ⇒ hide the bottom tab bar so the input pins to the keyboard.
document.body.classList.toggle("kb", (window.innerHeight - h) > 150);
}
// Re-measure across the keyboard animation: iOS reports a stale (too-short)
// height mid-animation, so sample a few times until it settles.
function nudgeAppHeight() {
setAppHeight();
[50, 150, 300, 550].forEach((t) => setTimeout(setAppHeight, t));
}
setAppHeight();
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", nudgeAppHeight);
window.visualViewport.addEventListener("scroll", setAppHeight);
}
window.addEventListener("resize", nudgeAppHeight);
window.addEventListener("orientationchange", nudgeAppHeight);
// Keep the latest message in view when the keyboard opens/closes.
const userInputEl = document.getElementById("userInput");
userInputEl.addEventListener("focus", () => {
nudgeAppHeight();
setTimeout(() => {
const m = document.getElementById("messages");
m.scrollTo({ top: m.scrollHeight, behavior: "smooth" });
}, 350);
});
userInputEl.addEventListener("blur", nudgeAppHeight);
// 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);
document.getElementById("moreTab").addEventListener("click", toggleMobileMenu);
// Mode controls (Talk / Cash): the desktop select, the mobile-menu select,
// and the always-visible header badge all funnel through chooseMode.
const mobileMode = document.getElementById("mobileMode");
const desktopMode = document.getElementById("mode");
const modeBadge = document.getElementById("modeBadge");
desktopMode.addEventListener("change", (e) => chooseMode(e.target.value));
mobileMode.addEventListener("change", (e) => { closeMobileMenu(); chooseMode(e.target.value); });
modeBadge.addEventListener("click", () =>
chooseMode(desktopMode.value === "poker_cash" ? "conversation" : "poker_cash"));
// Reflect the last-used mode immediately; the per-session value loads once
// the current session is known (below).
applyMode(localStorage.getItem("lyraMode") || "conversation");
// 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);
await loadModeFor(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);
await loadModeFor(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}`);
});
// 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;
// Restore saved cloud-model choice
const savedModelSel = document.getElementById("cloudModel");
if (savedModelSel) savedModelSel.value = localStorage.getItem("cloudModel") || "";
// 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);
const modelSel = document.getElementById("cloudModel");
const modelValue = modelSel ? modelSel.value : "";
localStorage.setItem("cloudModel", modelValue);
const modelLabel = modelValue || "default (gpt-4o)";
addMessage("system", `Backend: ${backendValue} · cloud model: ${modelLabel}`);
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() {
// Close existing connection
if (thinkingEventSource) {
thinkingEventSource.close();
}
// 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 = () => {
thinkingStatusDot.className = 'thinking-status-dot connected';
};
thinkingEventSource.onmessage = (event) => {
try {
addLogEvent(JSON.parse(event.data));
} catch (e) {
console.error('Failed to parse log event:', e);
}
};
thinkingEventSource.onerror = () => {
thinkingStatusDot.className = 'thinking-status-dot disconnected';
// EventSource auto-reconnects; nothing to do here.
};
}
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 = Object.assign({}, event.fields || {});
// `detail` is rendered as an expandable block, not an inline field.
const detail = fields.detail;
delete fields.detail;
const fieldStr = Object.keys(fields).length
? Object.entries(fields).map(([k, v]) => `${k}=${v}`).join(' ')
: '';
const eventDiv = document.createElement('div');
eventDiv.className = `log-line log-${level}`;
eventDiv.innerHTML = `
<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>` : ''}
${detail ? `<details class="log-detail"><summary>view details</summary><pre>${escapeHtml(detail)}</pre></details>` : ''}
`;
thinkingContent.appendChild(eventDiv);
thinkingContent.scrollTop = thinkingContent.scrollHeight;
thinkingEventCount++;
}
// (Log events are server-side and replayed on connect; no localStorage needed.)
// Live Log toggle button
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");
});
// Mobile nav to the full-page views (log / mind / journal).
document.getElementById("mobileFullLogBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/logs";
});
document.getElementById("mobileJournalBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/journal";
});
document.getElementById("mobileSessionBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/session";
});
document.getElementById("mobileHistoryBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/history";
});
// Connect to the global live log on page load.
connectThinkingStream();
// The live log is global (server-wide), so it does not reconnect on session change.
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (thinkingEventSource) {
thinkingEventSource.close();
}
});
});
</script>
</body>
</html>