const form = document.getElementById("chat-form"); const input = document.getElementById("message-input"); const messages = document.getElementById("messages"); const statusEl = document.getElementById("status"); const pendingEl = document.getElementById("pending-actions"); const activityEl = document.getElementById("activity"); const warningEl = document.getElementById("warning"); const memoryInspectorEl = document.getElementById("memory-inspector"); const memoryRefreshButton = document.getElementById("memory-refresh"); const memoryClearButton = document.getElementById("memory-clear"); let ollamaOnline = true; function addMessage(role, text) { const node = document.createElement("div"); node.className = `message ${role}`; setMessageMarkdown(node, text); messages.appendChild(node); messages.scrollTop = messages.scrollHeight; return node; } function setMessageMarkdown(node, text) { node.innerHTML = renderMarkdown(text); } function renderMarkdown(text) { const escaped = escapeHtml(text); const formatted = escaped .replace(/`([^`]+)`/g, "$1") .replace(/\*\*(.+?)\*\*/g, "$1"); const lines = formatted.split(/\n/); const output = []; let inList = false; for (const line of lines) { const trimmed = line.trim(); const item = line.match(/^(\s*)-\s+(.+)$/); if (item) { if (!inList) { output.push(""); inList = false; } if (/^---+$/.test(trimmed)) { output.push("
"); } else if (/^###\s+/.test(trimmed)) { output.push(`

${trimmed.replace(/^###\s+/, "")}

`); } else if (/^##\s+/.test(trimmed)) { output.push(`

${trimmed.replace(/^##\s+/, "")}

`); } else if (trimmed) { output.push(`

${line}

`); } else { output.push("
"); } } if (inList) output.push(""); return output.join(""); } function escapeHtml(text) { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function setActivity(text, active = false) { activityEl.innerHTML = ""; if (!text) return; const label = document.createElement("span"); label.textContent = text; activityEl.appendChild(label); if (active) { const dots = document.createElement("span"); dots.className = "working-dots"; dots.innerHTML = ""; activityEl.appendChild(dots); } } function setWarning(text) { warningEl.hidden = !text; warningEl.textContent = text || ""; } async function checkHealth() { try { const response = await fetch("/api/health"); const result = await response.json(); const health = result.ollama || {}; ollamaOnline = Boolean(health.online); if (!ollamaOnline) { statusEl.textContent = "Offline"; setWarning(health.message || "Ollama is offline. Start Ollama before chatting."); return false; } if (health.model_available === false) { setWarning(`Ollama is online, but model "${health.model}" is not pulled. Run: ollama pull ${health.model}`); } else { setWarning(""); } statusEl.textContent = "Ready"; return true; } catch (error) { ollamaOnline = false; statusEl.textContent = "Offline"; setWarning(`Could not check Ollama health: ${error.message}`); return false; } } function renderPending(actions) { pendingEl.innerHTML = ""; if (!actions.length) { pendingEl.className = "pending-empty"; pendingEl.textContent = "No pending actions"; return; } pendingEl.className = ""; for (const action of actions) { const card = document.createElement("div"); card.className = "pending-card"; const title = document.createElement("strong"); title.textContent = action.label; const endpoint = document.createElement("p"); endpoint.className = "muted"; endpoint.textContent = action.endpoint; const payload = document.createElement("pre"); payload.textContent = JSON.stringify(action.payload, null, 2); const approve = document.createElement("button"); approve.textContent = "Approve"; approve.addEventListener("click", () => approveAction(action.id)); card.append(title, endpoint, payload, approve); pendingEl.appendChild(card); } } async function approveAction(id) { statusEl.textContent = "Sending"; try { const response = await fetch(`/api/approve/${id}`, { method: "POST" }); const result = await response.json(); addMessage("assistant", `Approval result:\n${JSON.stringify(result, null, 2)}`); await refreshPending(); } catch (error) { addMessage("assistant", `Approval failed: ${error.message}`); } finally { statusEl.textContent = "Ready"; } } async function refreshPending() { const response = await fetch("/api/pending-actions"); const result = await response.json(); renderPending(result.pending_actions || []); } function renderMemory(data) { memoryInspectorEl.innerHTML = ""; const counts = document.createElement("div"); counts.className = "memory-counts"; counts.textContent = `${data.memories.length} memories | ${data.conversations.length} chat rows | ${data.profile.length} profile keys | ${data.scheduled_jobs.length} jobs`; memoryInspectorEl.appendChild(counts); const path = document.createElement("div"); path.className = "memory-path"; path.textContent = data.path; memoryInspectorEl.appendChild(path); memoryInspectorEl.appendChild(memoryGroup("Memories", data.memories, (item) => `${item.kind} (${item.importance})\n${item.content}`)); memoryInspectorEl.appendChild(memoryGroup("Profile", data.profile, (item) => `${item.key}\n${JSON.stringify(item.value, null, 2)}`)); memoryInspectorEl.appendChild(memoryGroup("Recent Chat", data.conversations, (item) => `${item.created_at} ${item.role}\n${item.content}`)); memoryInspectorEl.appendChild(memoryGroup("Wake Jobs", data.scheduled_jobs, (item) => `${item.id}\n${item.trigger_type}: ${item.trigger_value}\n${item.prompt}`)); } function memoryGroup(title, items, formatter) { const group = document.createElement("details"); group.className = "memory-group"; const summary = document.createElement("summary"); summary.textContent = `${title} (${items.length})`; group.appendChild(summary); if (!items.length) { const empty = document.createElement("p"); empty.className = "muted"; empty.textContent = "Empty"; group.appendChild(empty); return group; } for (const item of items.slice(0, 20)) { const entry = document.createElement("pre"); entry.textContent = formatter(item); group.appendChild(entry); } return group; } async function refreshMemory() { try { const response = await fetch("/api/memory?limit=50"); const data = await response.json(); renderMemory(data); } catch (error) { memoryInspectorEl.textContent = `Memory load failed: ${error.message}`; } } async function clearMemory() { const payload = { include_memories: document.getElementById("clear-memories").checked, include_conversations: document.getElementById("clear-conversations").checked, include_profile: document.getElementById("clear-profile").checked, include_jobs: document.getElementById("clear-jobs").checked, include_outbox: document.getElementById("clear-outbox").checked, }; if (!Object.values(payload).some(Boolean)) return; const confirmed = window.confirm("Clear the selected TraderAI memory? This cannot be undone."); if (!confirmed) return; try { const response = await fetch("/api/memory/clear", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const result = await response.json(); renderMemory(result.memory); addMessage("assistant", `Memory cleared:\n${JSON.stringify(result.deleted, null, 2)}`); } catch (error) { addMessage("assistant warning-message", `Memory clear failed: ${error.message}`); } } async function pollNotifications() { try { const response = await fetch("/api/notifications"); const result = await response.json(); for (const notification of result.notifications || []) { addMessage("assistant", notification.content); } } catch { // Notification polling should never interrupt chat. } } form.addEventListener("submit", async (event) => { event.preventDefault(); await sendMessage(); }); input.addEventListener("keydown", async (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); await sendMessage(); } }); memoryRefreshButton.addEventListener("click", refreshMemory); memoryClearButton.addEventListener("click", clearMemory); async function sendMessage() { const message = input.value.trim(); if (!message || input.disabled) return; const healthy = await checkHealth(); if (!healthy) { addMessage("assistant warning-message", "Ollama is offline. Start Ollama, then try again."); return; } input.value = ""; input.disabled = true; addMessage("user", message); const assistantNode = addMessage("assistant streaming", ""); let assistantText = ""; statusEl.textContent = "Working"; setActivity("Thinking", true); try { const response = await fetch("/api/chat/stream", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message }), }); if (!response.ok || !response.body) { throw new Error(`HTTP ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const events = buffer.split("\n\n"); buffer = events.pop() || ""; for (const rawEvent of events) { const line = rawEvent.split("\n").find((entry) => entry.startsWith("data: ")); if (!line) continue; const event = JSON.parse(line.slice(6)); if (event.type === "status") { setActivity(event.message, true); } else if (event.type === "warning") { setWarning(event.message); assistantText += event.message; setMessageMarkdown(assistantNode, assistantText); } else if (event.type === "token") { assistantText += event.content; setMessageMarkdown(assistantNode, assistantText); messages.scrollTop = messages.scrollHeight; } else if (event.type === "done") { renderPending(event.pending_actions || []); } } } } catch (error) { const message = error.message.includes("503") ? "Ollama is offline or unreachable. Start Ollama, then try again." : `Chat failed: ${error.message}`; setWarning(message); setMessageMarkdown(assistantNode, message); } finally { assistantNode.classList.remove("streaming"); input.disabled = false; input.focus(); statusEl.textContent = "Ready"; setActivity(""); } } addMessage("assistant", "Tell me what to find or draft on UEX. I will ask for approval before sending anything."); refreshPending(); refreshMemory(); pollNotifications(); checkHealth(); setInterval(checkHealth, 30000); setInterval(pollNotifications, 15000);