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"); const configForm = document.getElementById("config-form"); const configRefreshButton = document.getElementById("config-refresh"); const configStatusEl = document.getElementById("config-status"); const configPathsEl = document.getElementById("config-paths"); const settingsToggle = document.getElementById("settings-toggle"); const memoryToggle = document.getElementById("memory-toggle"); const ollamaToggle = document.getElementById("ollama-toggle"); const settingsPanel = document.getElementById("settings-panel"); const memoryPanel = document.getElementById("memory-panel"); const ollamaPanel = document.getElementById("ollama-panel"); const ollamaForm = document.getElementById("ollama-config-form"); const ollamaRefreshButton = document.getElementById("ollama-refresh"); const ollamaDownloadButton = document.getElementById("ollama-download"); const ollamaInstallButton = document.getElementById("ollama-install"); const ollamaLaunchButton = document.getElementById("ollama-launch"); const ollamaPullButton = document.getElementById("ollama-pull"); const ollamaStatusEl = document.getElementById("ollama-status"); const ollamaMessageEl = document.getElementById("ollama-message"); const updateCheckButton = document.getElementById("update-check"); const updateInstallButton = document.getElementById("update-install"); const updateOpenReleasesButton = document.getElementById("update-open-releases"); const updateStatusEl = document.getElementById("update-status"); let ollamaOnline = true; let latestUpdate = null; if (window.lucide) { window.lucide.createIcons(); } 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; if (text) appendThinkingStep(node, reasoningSummaryForStatus(text), { fallback: true }); 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 appendThinkingStep(node, text, options = {}) { const steps = node.querySelector(".thinking-steps"); if (!steps || !text) return; const previous = steps.lastElementChild?.textContent; if (previous === text) return; const item = document.createElement("li"); if (options.fallback) item.dataset.fallback = "true"; item.textContent = text; steps.appendChild(item); } function appendThinkingText(node, text) { const steps = node.querySelector(".thinking-steps"); if (!steps || !text) return; node.querySelectorAll(".thinking-steps [data-fallback='true']").forEach((item) => item.remove()); node.dataset.hasModelThinking = "true"; let item = steps.querySelector(".thinking-raw-step"); if (!item) { item = document.createElement("li"); item.className = "thinking-raw-step"; steps.appendChild(item); } item.textContent += text; } function createThinkTagParser(node) { let buffer = ""; let inThinking = false; const partialTagLength = (text) => { const lower = text.toLowerCase(); const tags = ["", ""]; for (const tag of tags) { for (let length = tag.length - 1; length > 0; length -= 1) { if (lower.endsWith(tag.slice(0, length))) return length; } } return 0; }; const consume = (content, flush = false) => { buffer += content; let visible = ""; while (buffer) { const lower = buffer.toLowerCase(); if (inThinking) { const closeIndex = lower.indexOf(""); if (closeIndex === -1) { if (flush) { appendThinkingText(node, buffer); buffer = ""; } else { const keep = partialTagLength(buffer); appendThinkingText(node, buffer.slice(0, buffer.length - keep)); buffer = buffer.slice(buffer.length - keep); } break; } appendThinkingText(node, buffer.slice(0, closeIndex)); buffer = buffer.slice(closeIndex + "".length); inThinking = false; continue; } const openIndex = lower.indexOf(""); if (openIndex === -1) { const keep = flush ? 0 : partialTagLength(buffer); visible += buffer.slice(0, buffer.length - keep); buffer = buffer.slice(buffer.length - keep); break; } visible += buffer.slice(0, openIndex); buffer = buffer.slice(openIndex + "".length); inThinking = true; } return visible; }; return { consume, flush: () => consume("", true), }; } function reasoningSummaryForStatus(text) { const summaries = { Thinking: "Reading your request and deciding whether I need current UEX data, memory, or a draft action before answering.", "Searching UEX listings": "Checking current UEX marketplace listings so the answer is grounded in live item data instead of stale memory.", "Fetching listing details": "Opening the specific listing details to avoid guessing about price, seller, quantity, or status.", "Checking negotiations": "Looking through active negotiations because replies and offers can change what the next move should be.", "Reading negotiation messages": "Reading the negotiation thread so any drafted reply matches the actual conversation.", "Drafting message for approval": "Preparing the exact message as a pending action because marketplace writes need your approval first.", "Drafting listing for approval": "Preparing the listing payload as a pending action so you can review it before anything is posted.", "Checking UEX notifications": "Checking notifications for fresh replies or alerts that could change the recommendation.", "Writing response": "Turning the gathered context into a concise response with the relevant details and next action.", }; if (summaries[text]) return summaries[text]; if (text.startsWith("Running ")) { return `Using ${text.replace(/^Running\s+/, "")} to gather the missing context before answering.`; } return text; } function finishThinking(node) { const thinking = node.querySelector(".thinking-log"); const label = node.querySelector(".thinking-summary-label"); if (!thinking || !label) return; const startedAt = Number(thinking.dataset.startedAt || Date.now()); const elapsedSeconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000)); label.textContent = `Thought for ${elapsedSeconds}s`; thinking.classList.remove("thinking-active"); } 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 thinking = document.createElement("details"); thinking.className = "thinking-log"; thinking.classList.add("thinking-active"); thinking.dataset.startedAt = String(Date.now()); const thinkingSummary = document.createElement("summary"); const thinkingLabel = document.createElement("span"); thinkingLabel.className = "thinking-summary-label"; thinkingLabel.textContent = "Thinking..."; const thinkingSteps = document.createElement("ol"); thinkingSteps.className = "thinking-steps"; const body = document.createElement("div"); body.className = "message-body"; activity.append(phase, metrics); thinkingSummary.appendChild(thinkingLabel); thinking.append(thinkingSummary, thinkingSteps); node.append(activity, thinking, 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 || ""; } function fetchErrorMessage(error) { if (error instanceof TypeError && /fetch/i.test(error.message)) { return "TraderAI backend is not reachable. Close this app window and launch TraderAI.exe again."; } return error.message; } const configFieldIds = { uex_base_url: "config-uex-base-url", uex_secret_key: "config-uex-secret-key", uex_bearer_token: "config-uex-bearer-token", traderai_user_name: "config-traderai-user-name", traderai_memory_path: "config-traderai-memory-path", uex_notification_poll_seconds: "config-uex-notification-poll-seconds", require_write_approval: "config-require-write-approval", }; const ollamaFieldIds = { ollama_base_url: "ollama-base-url", ollama_model: "ollama-model", ollama_num_ctx: "ollama-num-ctx", }; async function refreshConfig() { try { const response = await fetch("/api/config"); const config = await response.json(); renderConfig(config); } catch (error) { configStatusEl.textContent = `Config load failed: ${fetchErrorMessage(error)}`; } } function renderConfig(config) { const values = config.values || {}; const secretsConfigured = config.secrets_configured || {}; for (const [key, id] of Object.entries(configFieldIds)) { const field = document.getElementById(id); if (!field) continue; if (field.type === "checkbox") { field.checked = Boolean(values[key]); } else if (field.type === "password") { field.value = ""; field.placeholder = secretsConfigured[key] ? "Configured" : ""; } else { field.value = values[key] ?? ""; } } for (const [key, id] of Object.entries(ollamaFieldIds)) { const field = document.getElementById(id); if (!field) continue; field.value = values[key] ?? ""; } configPathsEl.textContent = `App data: ${config.app_data_dir}\nConfig: ${config.config_path}\nLog: ${config.log_path}\nEdge profile: ${config.edge_profile_dir}`; configStatusEl.textContent = ""; } async function saveConfig(event) { event.preventDefault(); const values = {}; for (const [key, id] of Object.entries(configFieldIds)) { const field = document.getElementById(id); if (!field) continue; values[key] = field.type === "checkbox" ? field.checked : field.value; } configStatusEl.textContent = "Saving"; try { const response = await fetch("/api/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values }), }); const result = await response.json(); renderConfig(result); configStatusEl.textContent = result.message || "Saved"; addMessage("assistant", "Config saved. Restart TraderAI for the new settings to fully apply."); } catch (error) { configStatusEl.textContent = `Config save failed: ${fetchErrorMessage(error)}`; } } async function saveOllamaConfig(event) { event.preventDefault(); const values = {}; for (const [key, id] of Object.entries(ollamaFieldIds)) { const field = document.getElementById(id); if (!field) continue; values[key] = field.value; } setOllamaMessage("Saving Ollama config"); try { const response = await fetch("/api/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values }), }); const result = await response.json(); renderConfig(result); setOllamaMessage(result.message || "Saved"); await refreshOllamaStatus(); } catch (error) { setOllamaMessage(`Ollama config save failed: ${fetchErrorMessage(error)}`); } } async function refreshOllamaStatus() { if (!ollamaStatusEl) return; ollamaStatusEl.textContent = "Checking Ollama"; try { const response = await fetch("/api/ollama/status"); const status = await response.json(); renderOllamaStatus(status); } catch (error) { ollamaStatusEl.textContent = `Ollama status failed: ${error.message}`; } } function renderOllamaStatus(status) { if (!ollamaStatusEl) return; const models = status.models?.length ? status.models.join(", ") : "None detected"; const pillClass = status.installed && status.running && status.model_available ? "status-pill" : "status-pill warning"; ollamaStatusEl.innerHTML = `
    ${escapeHtml(status.message || "Unknown")}
    ${ollamaStatusItem("Installed", status.installed ? "Yes" : "No")} ${ollamaStatusItem("Running", status.running ? "Yes" : "No")} ${ollamaStatusItem("Model", status.configured_model || "")} ${ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No")} ${ollamaStatusItem("URL", status.base_url || "")} ${ollamaStatusItem("Auto Install", status.can_auto_install ? "Available" : "Unavailable")}
    ${ollamaStatusItem("Installed Models", models)} ${status.detail ? ollamaStatusItem("Detail", status.detail) : ""} `; if (ollamaInstallButton) ollamaInstallButton.disabled = Boolean(status.installed); if (ollamaLaunchButton) ollamaLaunchButton.disabled = !status.installed || Boolean(status.running); if (ollamaPullButton) ollamaPullButton.disabled = !status.running || Boolean(status.model_available); } function ollamaStatusItem(label, value) { return `
    ${escapeHtml(label)}${escapeHtml(String(value ?? ""))}
    `; } function setOllamaMessage(message) { if (ollamaMessageEl) ollamaMessageEl.textContent = message || ""; } async function postOllamaAction(endpoint, options = {}) { setOllamaMessage("Working"); try { const response = await fetch(endpoint, { method: "POST", headers: options.body ? { "Content-Type": "application/json" } : undefined, body: options.body ? JSON.stringify(options.body) : undefined, }); const result = await response.json(); if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); setOllamaMessage(result.message || "Done"); await refreshOllamaStatus(); } catch (error) { setOllamaMessage(error.message); } } function configuredOllamaModel() { return document.getElementById("ollama-model")?.value || ""; } async function checkForUpdate() { if (!updateStatusEl) return; updateStatusEl.textContent = "Checking releases"; try { const response = await fetch("/api/update/check"); const result = await response.json(); latestUpdate = result; renderUpdateStatus(result); } catch (error) { updateStatusEl.textContent = `Update check failed: ${error.message}`; if (updateInstallButton) updateInstallButton.disabled = true; } } function renderUpdateStatus(update) { if (!updateStatusEl) return; const lines = [ `Current: ${update.current_version || "unknown"}`, `Latest: ${update.latest_version || "unknown"}`, update.message || "", ].filter(Boolean); if (update.available && !update.asset_download_url) { lines.push("The release needs a TraderAI.exe attachment before the app can self-update."); } if (update.available && update.asset_download_url && !update.packaged) { lines.push("Self-update runs from the packaged desktop exe."); } updateStatusEl.textContent = lines.join("\n"); if (updateInstallButton) { updateInstallButton.disabled = !update.available || !update.asset_download_url || !update.packaged; } } async function installUpdate() { if (!updateStatusEl) return; updateStatusEl.textContent = "Downloading update"; try { const response = await fetch("/api/update/install", { method: "POST" }); const result = await response.json(); latestUpdate = result; renderUpdateStatus(result); } catch (error) { updateStatusEl.textContent = `Update failed: ${error.message}`; } } function openReleasesPage() { const url = latestUpdate?.release_url || "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"; window.open(url, "_blank", "noreferrer"); } function toggleSidebarPanel(panelName) { const panels = { settings: { panel: settingsPanel, button: settingsToggle }, memory: { panel: memoryPanel, button: memoryToggle }, ollama: { panel: ollamaPanel, button: ollamaToggle }, }; const target = panels[panelName]; if (!target?.panel || !target?.button) return; const shouldOpen = target.panel.hidden; for (const item of Object.values(panels)) { if (!item.panel || !item.button) continue; item.panel.hidden = true; item.button.classList.remove("active"); item.button.setAttribute("aria-expanded", "false"); } if (shouldOpen) { target.panel.hidden = false; target.button.classList.add("active"); target.button.setAttribute("aria-expanded", "true"); if (panelName === "settings") { refreshConfig(); checkForUpdate(); } if (panelName === "memory") refreshMemory(); if (panelName === "ollama") { refreshConfig(); refreshOllamaStatus(); } } } 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)); const decline = document.createElement("button"); decline.className = "decline-button"; decline.textContent = "Decline"; decline.addEventListener("click", () => declineAction(action.id)); const controls = document.createElement("div"); controls.className = "pending-controls"; controls.append(decline, approve); card.append(title, endpoint, payload, controls); 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 declineAction(id) { statusEl.textContent = "Declining"; try { const response = await fetch(`/api/decline/${id}`, { method: "POST" }); const result = await response.json(); if (result.error) { addMessage("assistant warning-message", `Decline failed: ${result.error}`); } else { addMessage("assistant", `Declined pending action: ${result.pending_action?.label || id}`); } await refreshPending(); } catch (error) { addMessage("assistant warning-message", `Decline 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); configRefreshButton?.addEventListener("click", refreshConfig); configForm?.addEventListener("submit", saveConfig); settingsToggle?.addEventListener("click", () => toggleSidebarPanel("settings")); memoryToggle?.addEventListener("click", () => toggleSidebarPanel("memory")); ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama")); ollamaForm?.addEventListener("submit", saveOllamaConfig); ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus); ollamaDownloadButton?.addEventListener("click", () => postOllamaAction("/api/ollama/download")); ollamaInstallButton?.addEventListener("click", () => postOllamaAction("/api/ollama/install")); ollamaLaunchButton?.addEventListener("click", () => postOllamaAction("/api/ollama/launch")); ollamaPullButton?.addEventListener("click", () => postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } })); updateCheckButton?.addEventListener("click", checkForUpdate); updateInstallButton?.addEventListener("click", installUpdate); updateOpenReleasesButton?.addEventListener("click", openReleasesPage); 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 = ""; const thinkParser = createThinkTagParser(assistantNode); 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") { const visibleContent = thinkParser.consume(event.content); if (visibleContent) { assistantText += visibleContent; setMessageMarkdown(assistantNode, assistantText); } messages.scrollTop = messages.scrollHeight; } else if (event.type === "done") { const visibleContent = thinkParser.flush(); if (visibleContent) { assistantText += visibleContent; setMessageMarkdown(assistantNode, assistantText); } 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"; finishThinking(assistantNode); setMessageActivity(assistantNode, ""); } } addMessage("assistant", "Tell me what to find or draft on UEX. I will ask for approval before sending anything."); refreshPending(); refreshMemory(); refreshConfig(); refreshOllamaStatus(); checkForUpdate(); pollNotifications(); checkHealth(); setInterval(checkHealth, 30000); setInterval(pollNotifications, 15000);