5e9f3efeec
The execCommand fallback returned true but copied nothing because the textarea was readOnly=false. iOS only copies from a readOnly + contentEditable field with a real Range selection + setSelectionRange — fixed that. Also skip the async Clipboard API unless window.isSecureContext (on the plain-HTTP LAN PWA it could resolve without copying, showing a false checkmark). If programmatic copy still fails, fall back to a prompt() with the text so it can be copied by hand, and only show the ✓ on real success. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1192 lines
48 KiB
HTML
1192 lines
48 KiB
HTML
<!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">
|
||
<textarea id="userInput" rows="1" placeholder="Type a message…" autofocus></textarea>
|
||
<button id="sendBtn" aria-label="Send" title="Send (or ⌘/Ctrl+Enter)">↑</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 = "";
|
||
|
||
autoGrow(inputEl); // collapse the box back to one line after clearing
|
||
|
||
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); };
|
||
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);
|
||
bar.appendChild(makeCopyBtn(() => div.dataset.raw || div.textContent || ""));
|
||
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);
|
||
}
|
||
|
||
// Copy text to the clipboard. Uses the async Clipboard API when available
|
||
// (HTTPS / localhost), and falls back to a hidden-textarea + execCommand for
|
||
// iOS over plain-HTTP LAN (where navigator.clipboard is undefined).
|
||
function copyToClipboard(text) {
|
||
text = text == null ? "" : String(text);
|
||
// Only trust the async Clipboard API in a secure context; on the LAN PWA
|
||
// (plain HTTP) it's either absent or resolves without actually copying, so
|
||
// we go straight to the iOS-tuned execCommand path there.
|
||
if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) {
|
||
return navigator.clipboard.writeText(text).catch(() => legacyCopy(text));
|
||
}
|
||
return legacyCopy(text);
|
||
}
|
||
function legacyCopy(text) {
|
||
return new Promise((resolve, reject) => {
|
||
const ta = document.createElement("textarea");
|
||
ta.value = text;
|
||
// iOS will only copy from a readOnly + contentEditable field with a real
|
||
// Range selection; readOnly also stops the keyboard from popping.
|
||
ta.readOnly = true;
|
||
ta.contentEditable = "true";
|
||
ta.style.position = "fixed";
|
||
ta.style.top = "0";
|
||
ta.style.left = "0";
|
||
ta.style.width = "1px";
|
||
ta.style.height = "1px";
|
||
ta.style.fontSize = "16px"; // avoid iOS zoom side-effects
|
||
document.body.appendChild(ta);
|
||
ta.focus();
|
||
const range = document.createRange();
|
||
range.selectNodeContents(ta);
|
||
const sel = window.getSelection();
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
ta.setSelectionRange(0, text.length); // the bit iOS actually needs
|
||
let ok = false;
|
||
try { ok = document.execCommand("copy"); } catch (e) { ok = false; }
|
||
sel.removeAllRanges();
|
||
document.body.removeChild(ta);
|
||
ok ? resolve() : reject(new Error("copy failed"));
|
||
});
|
||
}
|
||
// A small per-message copy button. getText is read at click time.
|
||
function makeCopyBtn(getText) {
|
||
const b = document.createElement("button");
|
||
b.className = "copy-btn";
|
||
b.type = "button";
|
||
b.textContent = "⧉";
|
||
b.title = "Copy message";
|
||
b.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
const text = typeof getText === "function" ? getText() : getText;
|
||
copyToClipboard(text)
|
||
.then(() => {
|
||
b.textContent = "✓"; b.classList.add("copied");
|
||
setTimeout(() => { b.textContent = "⧉"; b.classList.remove("copied"); }, 1200);
|
||
})
|
||
.catch(() => {
|
||
// Last resort (some iOS configs block programmatic copy): surface the
|
||
// text in a prompt so it can be selected + copied by hand.
|
||
window.prompt("Copy this message:", text);
|
||
b.textContent = "⧉";
|
||
});
|
||
});
|
||
return b;
|
||
}
|
||
|
||
// Grow the input textarea to fit its content (up to a cap, then it scrolls).
|
||
function autoGrow(el) {
|
||
if (!el) return;
|
||
el.style.height = "auto";
|
||
el.style.height = Math.min(el.scrollHeight, 140) + "px";
|
||
}
|
||
|
||
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;
|
||
if (role === "user") {
|
||
const bar = document.createElement("div");
|
||
bar.className = "rate-bar";
|
||
bar.appendChild(makeCopyBtn(() => text));
|
||
msgDiv.appendChild(bar);
|
||
}
|
||
}
|
||
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. Enter inserts a newline and grows the box (like the Claude
|
||
// app) — you tap the arrow to send. ⌘/Ctrl+Enter sends from the keyboard.
|
||
document.getElementById("sendBtn").addEventListener("click", sendMessage);
|
||
const inputBox = document.getElementById("userInput");
|
||
inputBox.addEventListener("input", () => autoGrow(inputBox));
|
||
inputBox.addEventListener("keydown", e => {
|
||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendMessage(); }
|
||
});
|
||
autoGrow(inputBox);
|
||
|
||
// ========== 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>
|