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 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) { const body = node.querySelector(".message-body") || node; body.innerHTML = renderMarkdown(text); } function setMessageActivity(node, text, active = false) { const activity = node.querySelector(".message-activity"); if (!activity) return; const phase = activity.querySelector(".message-phase"); phase.innerHTML = ""; if (text) { const label = document.createElement("span"); label.textContent = text; phase.appendChild(label); } if (active) { const dots = document.createElement("span"); dots.className = "working-dots"; dots.innerHTML = ""; phase.appendChild(dots); } } function setMessageMetrics(node, metrics) { const metricsEl = node.querySelector(".message-metrics"); if (!metricsEl) return; metricsEl.textContent = metrics || ""; } function ensureStreamingChrome(node) { if (node.querySelector(".message-activity")) return; node.innerHTML = ""; const activity = document.createElement("div"); activity.className = "message-activity"; const phase = document.createElement("span"); phase.className = "message-phase"; const metrics = document.createElement("span"); metrics.className = "message-metrics"; const body = document.createElement("div"); body.className = "message-body"; activity.append(phase, metrics); node.append(activity, body); } function renderMarkdown(text) { const lines = text.replace(/\r\n/g, "\n").split(/\n/); const output = []; let inList = false; let inOrderedList = false; let inCode = false; let codeLines = []; const closeLists = () => { if (inList) { output.push(""); inList = false; } if (inOrderedList) { output.push(""); inOrderedList = false; } }; const flushCode = () => { output.push(`
${escapeHtml(codeLines.join("\n"))}
`); codeLines = []; inCode = false; }; const isTableAt = (index) => { if (index + 1 >= lines.length) return false; return isTableRow(lines[index]) && isTableDivider(lines[index + 1]); }; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; const trimmed = line.trim(); if (/^```/.test(trimmed)) { if (inCode) { flushCode(); } else { closeLists(); inCode = true; } continue; } if (inCode) { codeLines.push(line); continue; } if (isTableAt(index)) { closeLists(); const headers = splitTableRow(lines[index]); const aligns = splitTableRow(lines[index + 1]).map((cell) => tableAlignment(cell)); const rows = []; index += 2; while (index < lines.length && isTableRow(lines[index])) { rows.push(splitTableRow(lines[index])); index += 1; } index -= 1; output.push(renderTable(headers, aligns, rows)); continue; } const unorderedItem = line.match(/^(\s*)[-*+]\s+(.+)$/); if (unorderedItem) { if (inOrderedList) { output.push(""); inOrderedList = false; } if (!inList) { output.push(""); inList = false; } if (!inOrderedList) { output.push("
    "); inOrderedList = true; } const nestedClass = orderedItem[1].length >= 2 ? ' class="nested"' : ""; output.push(`${inlineMarkdown(orderedItem[2])}`); continue; } closeLists(); if (/^>\s?/.test(trimmed)) { output.push(`
    ${inlineMarkdown(trimmed.replace(/^>\s?/, ""))}
    `); } else if (/^---+$/.test(trimmed)) { output.push("
    "); } else if (/^#{1,6}\s+/.test(trimmed)) { const level = Math.min(4, trimmed.match(/^#+/)[0].length + 2); output.push(`${inlineMarkdown(trimmed.replace(/^#{1,6}\s+/, ""))}`); } else if (trimmed) { output.push(`

    ${inlineMarkdown(line)}

    `); } else { output.push("
    "); } } if (inCode) flushCode(); closeLists(); return output.join(""); } function inlineMarkdown(text) { return escapeHtml(text) .replace(/`([^`]+)`/g, "$1") .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/\*([^*]+)\*/g, "$1") .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1'); } function isTableRow(line) { const trimmed = line.trim(); return trimmed.includes("|") && /^\|?.+\|.+\|?$/.test(trimmed); } function isTableDivider(line) { return splitTableRow(line).every((cell) => /^:?-{3,}:?$/.test(cell.trim())); } function splitTableRow(line) { return line .trim() .replace(/^\|/, "") .replace(/\|$/, "") .split("|") .map((cell) => cell.trim()); } function tableAlignment(cell) { const trimmed = cell.trim(); if (trimmed.startsWith(":") && trimmed.endsWith(":")) return "center"; if (trimmed.endsWith(":")) return "right"; return "left"; } function renderTable(headers, aligns, rows) { const head = headers .map((cell, index) => `${inlineMarkdown(cell)}`) .join(""); const body = rows .map((row) => { const cells = headers .map((_, index) => `${inlineMarkdown(row[index] || "")}`) .join(""); return `${cells}`; }) .join(""); return `
    ${head}${body}
    `; } function escapeHtml(text) { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function formatMetrics(event) { const read = formatTokenMetric(event.reading_tokens, event.reading_tokens_per_second); const wrote = formatTokenMetric(event.writing_tokens, event.writing_tokens_per_second); return [read && `read ${read}`, wrote && `wrote ${wrote}`].filter(Boolean).join(" | "); } function formatTokenMetric(tokens, speed) { if (!tokens) return ""; const speedText = typeof speed === "number" ? ` @ ${speed.toFixed(1)}/s` : ""; return `${tokens} tok${speedText}`; } 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", ""); ensureStreamingChrome(assistantNode); let assistantText = ""; statusEl.textContent = "Working"; setMessageActivity(assistantNode, "Thinking", true); setMessageMetrics(assistantNode, ""); 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") { setMessageActivity(assistantNode, event.message, true); } else if (event.type === "metrics") { setMessageMetrics(assistantNode, formatMetrics(event)); } 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"; setMessageActivity(assistantNode, ""); } } 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);