const form = document.getElementById("chat-form"); const input = document.getElementById("message-input"); const composerImagesEl = document.getElementById("composer-images"); 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 plansToggle = document.getElementById("plans-toggle"); const ollamaToggle = document.getElementById("ollama-toggle"); const settingsPanel = document.getElementById("settings-panel"); const memoryPanel = document.getElementById("memory-panel"); const plansPanel = document.getElementById("plans-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 providerModelSelect = document.getElementById("provider-model-select"); const providerModelLabel = document.getElementById("provider-model-label"); const modelReasoningEffortSelect = document.getElementById("model-reasoning-effort"); 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"); const shellEl = document.querySelector(".shell"); const chatRailEl = document.getElementById("chat-rail"); const chatSidebarToggle = document.getElementById("chat-sidebar-toggle"); const newChatButton = document.getElementById("new-chat"); const chatListEl = document.getElementById("chat-list"); const inboxListEl = document.getElementById("inbox-list"); const negotiationPanel = document.getElementById("negotiation-panel"); const negotiationTitle = document.getElementById("negotiation-title"); const negotiationMessagesEl = document.getElementById("negotiation-messages"); const negotiationForm = document.getElementById("negotiation-form"); const negotiationInput = document.getElementById("negotiation-input"); const negotiationStatusEl = document.getElementById("negotiation-status"); const negotiationCloseButton = document.getElementById("negotiation-close"); const updateModal = document.getElementById("update-modal"); const updateModalCopy = document.getElementById("update-modal-copy"); const updateModalClose = document.getElementById("update-modal-close"); const updateModalInstall = document.getElementById("update-modal-install"); const updateModalReleases = document.getElementById("update-modal-releases"); const plansRefreshButton = document.getElementById("plans-refresh"); const plansCloseButton = document.getElementById("plans-close"); const planForm = document.getElementById("plan-form"); const planAutofillButton = document.getElementById("plan-autofill"); const plansStatusEl = document.getElementById("plans-status"); const plansDashboardEl = document.getElementById("plans-dashboard"); const plansRailListEl = document.getElementById("plans-rail-list"); const providerScopedFields = Array.from(document.querySelectorAll("[data-provider-scope]")); let ollamaOnline = true; let latestUpdate = null; let currentThreadId = "default"; let currentNegotiationId = null; let latestOllamaStatus = null; let composerImages = []; const clickedOllamaActions = new Set(); if (window.lucide) { window.lucide.createIcons(); } function addMessage(role, text, options = {}) { const node = document.createElement("div"); node.className = `message ${role}`; setMessageMarkdown(node, text, options); messages.appendChild(node); messages.scrollTop = messages.scrollHeight; return node; } function setMessageMarkdown(node, text, options = {}) { const body = node.querySelector(".message-body") || node; body.innerHTML = ""; const attachedImages = options.images || []; if (attachedImages.length) { body.appendChild(renderImageGallery(attachedImages)); } if (text) { const markdown = document.createElement("div"); markdown.innerHTML = renderMarkdown(text); body.appendChild(markdown); enhanceNegotiationLinks(markdown); } } function renderImageGallery(images) { const gallery = document.createElement("div"); gallery.className = "message-images"; for (const image of images) { const card = document.createElement("div"); card.className = "message-image"; const preview = document.createElement("img"); preview.src = image.preview_url || `data:${image.content_type || "image/png"};base64,${image.image_data}`; preview.alt = image.name || "Attached image"; const label = document.createElement("span"); label.className = "message-image-label"; label.textContent = image.name || "Attached image"; card.append(preview, label); gallery.appendChild(card); } return gallery; } 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; const thinking = node.querySelector(".thinking-log"); if (thinking && !thinking.open) thinking.open = true; } 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 enhanceNegotiationLinks(root) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { if (node.parentElement?.closest("a, button, code, pre")) return NodeFilter.FILTER_REJECT; return /(?:negotiation|id_negotiation|marketplace\/(?:negotiations|negotiate\/hash))/i.test(node.textContent || "") ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; }, }); const textNodes = []; while (walker.nextNode()) textNodes.push(walker.currentNode); const pattern = /((?:negotiation|id_negotiation)\s*(?:#|id|:)?\s*)([A-Za-z0-9_-]{3,})|(\/marketplace\/(?:negotiations|negotiate\/hash)\/)([A-Za-z0-9_-]+)/gi; for (const textNode of textNodes) { const text = textNode.textContent || ""; let lastIndex = 0; const fragment = document.createDocumentFragment(); for (const match of text.matchAll(pattern)) { const matchIndex = match.index || 0; const identifier = match[2] || match[4]; if (!identifier) continue; fragment.appendChild(document.createTextNode(text.slice(lastIndex, matchIndex))); if (match[1]) fragment.appendChild(document.createTextNode(match[1])); if (match[3]) fragment.appendChild(document.createTextNode(match[3])); const button = document.createElement("button"); button.type = "button"; button.className = "negotiation-link"; button.dataset.negotiationId = identifier; button.textContent = identifier; button.addEventListener("click", () => openNegotiationPanel(identifier)); fragment.appendChild(button); lastIndex = matchIndex + match[0].length; } fragment.appendChild(document.createTextNode(text.slice(lastIndex))); textNode.replaceWith(fragment); } } 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 composerImageId() { if (window.crypto?.randomUUID) return window.crypto.randomUUID(); return `image-${Date.now()}-${Math.random().toString(16).slice(2)}`; } function readFileAsDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || "")); reader.onerror = () => reject(reader.error || new Error(`Could not read ${file.name || "image"}`)); reader.readAsDataURL(file); }); } async function addComposerImages(files) { const additions = []; for (const file of files) { if (!file || !String(file.type || "").startsWith("image/")) continue; const previewUrl = await readFileAsDataUrl(file); const [, imageData = ""] = previewUrl.split(",", 2); if (!imageData) continue; additions.push({ id: composerImageId(), name: file.name || `pasted-image-${composerImages.length + additions.length + 1}.png`, content_type: file.type || "image/png", image_data: imageData, preview_url: previewUrl, }); } if (!additions.length) return; composerImages = [...composerImages, ...additions]; renderComposerImages(); } function removeComposerImage(imageId) { composerImages = composerImages.filter((image) => image.id !== imageId); renderComposerImages(); } function clearComposerImages() { composerImages = []; renderComposerImages(); } function renderComposerImages() { if (!composerImagesEl) return; composerImagesEl.innerHTML = ""; composerImagesEl.hidden = !composerImages.length; for (const image of composerImages) { const card = document.createElement("div"); card.className = "composer-image"; const preview = document.createElement("img"); preview.src = image.preview_url; preview.alt = image.name || "Pasted image"; const remove = document.createElement("button"); remove.type = "button"; remove.className = "composer-image-remove"; remove.textContent = "×"; remove.title = "Remove image"; remove.addEventListener("click", () => removeComposerImage(image.id)); const label = document.createElement("span"); label.className = "composer-image-name"; label.textContent = image.name || "Pasted image"; card.append(preview, remove, label); composerImagesEl.appendChild(card); } } 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); const cache = formatCacheMetric(event.cache_hit_tokens, event.cache_miss_tokens); return [read && `read ${read}`, wrote && `wrote ${wrote}`, cache].filter(Boolean).join(" | "); } function formatTokenMetric(tokens, speed) { if (!tokens) return ""; const speedText = typeof speed === "number" ? ` @ ${speed.toFixed(1)}/s` : ""; return `${tokens} tok${speedText}`; } function formatCacheMetric(hitTokens, missTokens) { if (!hitTokens && !missTokens) return ""; const hit = Number(hitTokens || 0).toLocaleString(); const miss = Number(missTokens || 0).toLocaleString(); return `cache ${hit} hit / ${miss} miss`; } 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 = { model_provider: "model-provider", deepseek_base_url: "deepseek-base-url", deepseek_api_key: "deepseek-api-key", deepseek_model: "deepseek-model", ollama_base_url: "ollama-base-url", ollama_model: "ollama-model", ollama_num_ctx: "ollama-num-ctx", model_reasoning_effort: "model-reasoning-effort", }; 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; if (field.type === "password") { field.value = ""; field.placeholder = secretsConfigured[key] ? "Configured" : ""; } else { field.value = values[key] ?? ""; } } renderReasoningEffortOptions(["none", "minimal", "low", "medium", "high", "xhigh"], values.model_reasoning_effort || "medium"); updateProviderFieldVisibility(values.model_provider || "ollama"); 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", result.message || "Config saved."); } 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 provider 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(`Provider config save failed: ${fetchErrorMessage(error)}`); } } async function refreshOllamaStatus() { if (!ollamaStatusEl) return; ollamaStatusEl.textContent = "Checking provider"; try { const response = await fetch("/api/ollama/status"); const status = await response.json(); renderOllamaStatus(status); } catch (error) { ollamaStatusEl.textContent = `Provider status failed: ${error.message}`; } } function renderOllamaStatus(status) { if (!ollamaStatusEl) return; latestOllamaStatus = status; updateProviderFieldVisibility(status.provider || "ollama"); const provider = providerDisplayName(status.provider); const models = status.models?.length ? status.models.join(", ") : "None detected"; const isDeepSeekProvider = status.provider === "deepseek"; const isCloudProvider = isDeepSeekProvider; const ready = isCloudProvider ? Boolean(status.online && status.model_available) : Boolean(status.installed && status.running && status.model_available); const pillClass = ready ? "status-pill" : "status-pill warning"; const detailItems = [ ollamaStatusItem("Provider", provider), ollamaStatusItem("Model", status.configured_model || ""), ollamaStatusItem("URL", status.base_url || ""), ]; if (!isCloudProvider) { detailItems.splice(1, 0, ollamaStatusItem("Installed", status.installed ? "Yes" : "No")); detailItems.splice(2, 0, ollamaStatusItem("Running", status.running ? "Yes" : "No")); detailItems.push(ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No")); if (status.can_auto_install) detailItems.push(ollamaStatusItem("Auto Install", "Available")); if (status.num_ctx) detailItems.push(ollamaStatusItem("Context", status.num_ctx)); } else { detailItems.splice(1, 0, ollamaStatusItem("Connected", status.online ? "Yes" : "No")); } ollamaStatusEl.innerHTML = `
    ${escapeHtml(status.message || "Unknown")}
    ${detailItems.join("")}
    ${ollamaStatusItem(isCloudProvider ? "Available Models" : "Installed Models", models)} ${status.detail ? ollamaStatusItem("Detail", status.detail) : ""} `; if (ollamaDownloadButton) ollamaDownloadButton.hidden = isCloudProvider; if (ollamaInstallButton) { ollamaInstallButton.hidden = isCloudProvider || !status.can_auto_install; ollamaInstallButton.disabled = Boolean(status.installed) || !status.can_auto_install; } if (ollamaLaunchButton) { ollamaLaunchButton.hidden = isCloudProvider; ollamaLaunchButton.disabled = !status.installed || Boolean(status.running); } if (ollamaPullButton) { ollamaPullButton.hidden = isCloudProvider; ollamaPullButton.disabled = !status.running || Boolean(status.model_available); } renderProviderModelOptions(status.models || [], status); renderReasoningEffortOptions(status.reasoning_efforts || [], status.configured_reasoning_effort || "medium"); updateOllamaAttention(status); } 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 markOllamaActionClicked(action) { if (action) clickedOllamaActions.add(action); updateOllamaAttention(); } function setOllamaButtonAttention(button, action, active) { if (!button) return; const shouldPulse = active && !clickedOllamaActions.has(action) && !button.disabled && !button.hidden; button.classList.toggle("attention-pulse", shouldPulse); } function updateOllamaAttention(status = null) { const currentStatus = status || latestOllamaStatus; if (!currentStatus) return; const isDeepSeekProvider = currentStatus.provider === "deepseek"; const isCloudProvider = isDeepSeekProvider; const ready = isCloudProvider ? Boolean(currentStatus.online && currentStatus.model_available) : Boolean(currentStatus.installed && currentStatus.running && currentStatus.model_available); ollamaToggle?.classList.toggle("attention-pulse", !ready); setOllamaButtonAttention(ollamaDownloadButton, "download", !isCloudProvider && !currentStatus.installed); setOllamaButtonAttention(ollamaInstallButton, "install", !isCloudProvider && !currentStatus.installed && currentStatus.can_auto_install); setOllamaButtonAttention(ollamaLaunchButton, "launch", !isCloudProvider && currentStatus.installed && !currentStatus.running); setOllamaButtonAttention(ollamaPullButton, "pull", !isCloudProvider && currentStatus.running && !currentStatus.model_available); if (ready) clickedOllamaActions.clear(); } function configuredOllamaModel() { return document.getElementById("ollama-model")?.value || ""; } function updateProviderFieldVisibility(provider) { for (const field of providerScopedFields) { const scope = field.dataset.providerScope; const hiddenManualModel = field.dataset.manualModel === "true" && provider !== "ollama"; field.hidden = scope !== provider || hiddenManualModel; } if (providerModelLabel) { providerModelLabel.textContent = provider === "ollama" ? "Available Models" : "Model"; } } function renderProviderModelOptions(models, status = latestOllamaStatus) { const datalist = document.getElementById("provider-models"); if (datalist) datalist.innerHTML = ""; for (const model of models) { if (datalist) { const option = document.createElement("option"); option.value = model; datalist.appendChild(option); } } if (!providerModelSelect) return; const provider = status?.provider || document.getElementById("model-provider")?.value || "ollama"; const configuredModel = configuredProviderModel(provider); providerModelSelect.innerHTML = ""; const allModels = [...new Set([configuredModel, ...models].filter(Boolean))]; if (!allModels.length) { const option = document.createElement("option"); option.value = ""; option.textContent = "No models detected"; providerModelSelect.appendChild(option); providerModelSelect.disabled = true; return; } providerModelSelect.disabled = false; for (const model of allModels) { const option = document.createElement("option"); option.value = model; option.textContent = model; if (model === configuredModel) option.selected = true; providerModelSelect.appendChild(option); } } function renderReasoningEffortOptions(efforts, configured) { if (!modelReasoningEffortSelect) return; const options = [...new Set([...(efforts || []), configured || "medium"].filter(Boolean))]; modelReasoningEffortSelect.innerHTML = ""; for (const effort of options) { const option = document.createElement("option"); option.value = effort; option.textContent = effort; if (effort === configured) option.selected = true; modelReasoningEffortSelect.appendChild(option); } } function configuredProviderModel(provider) { if (provider === "deepseek") return document.getElementById("deepseek-model")?.value || ""; return document.getElementById("ollama-model")?.value || ""; } function syncSelectedProviderModel() { const provider = document.getElementById("model-provider")?.value || "ollama"; const selectedModel = providerModelSelect?.value || ""; if (!selectedModel) return; if (provider === "deepseek") { const field = document.getElementById("deepseek-model"); if (field) field.value = selectedModel; return; } const field = document.getElementById("ollama-model"); if (field) field.value = selectedModel; } function providerDisplayName(provider) { if (provider === "deepseek") return "DeepSeek"; return "Local Ollama"; } async function checkForUpdate(promptUser = false) { if (!updateStatusEl) return; updateStatusEl.textContent = "Checking releases"; try { const response = await fetch("/api/update/check"); const result = await response.json(); latestUpdate = result; renderUpdateStatus(result); if (promptUser) maybeShowUpdatePrompt(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 maybeShowUpdatePrompt(update) { if (!update?.available || !updateModal) return; const dismissedVersion = localStorage.getItem("traderai.dismissedUpdateVersion"); if (dismissedVersion === update.latest_version) return; updateModalCopy.textContent = update.message || `TraderAI ${update.latest_version} is available.`; updateModalInstall.disabled = !update.asset_download_url || !update.packaged; updateModal.hidden = false; } function closeUpdatePrompt() { if (latestUpdate?.latest_version) { localStorage.setItem("traderai.dismissedUpdateVersion", latestUpdate.latest_version); } updateModal.hidden = true; } 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(); } } } function toggleChatRail() { const isCollapsed = chatRailEl?.classList.toggle("collapsed"); shellEl?.classList.toggle("chat-open", !isCollapsed); chatSidebarToggle?.setAttribute("aria-expanded", String(!isCollapsed)); } async function refreshChats() { if (!chatListEl) return; try { const response = await fetch("/api/chats"); const result = await response.json(); const chats = result.chats || []; if (!chats.length) { const created = await createChat(false); currentThreadId = created?.id || "default"; return; } if (!chats.some((chat) => chat.id === currentThreadId)) { currentThreadId = chats[0].id; } renderChats(chats); } catch (error) { chatListEl.textContent = `Chats failed: ${fetchErrorMessage(error)}`; } } function renderChats(chats) { chatListEl.innerHTML = ""; for (const chat of chats) { const item = document.createElement("div"); item.className = `chat-item${chat.id === currentThreadId ? " active" : ""}`; const title = document.createElement("button"); title.type = "button"; title.className = "chat-title"; title.textContent = chat.title || "New chat"; title.addEventListener("click", () => switchChat(chat.id)); const rename = document.createElement("button"); rename.type = "button"; rename.className = "icon-button"; rename.title = "Rename chat"; rename.innerHTML = ''; rename.addEventListener("click", (event) => { event.stopPropagation(); renameChat(chat.id, chat.title || "New chat"); }); const remove = document.createElement("button"); remove.type = "button"; remove.className = "icon-button"; remove.title = "Delete chat"; remove.innerHTML = ''; remove.addEventListener("click", (event) => { event.stopPropagation(); deleteChat(chat.id); }); item.append(title, rename, remove); chatListEl.appendChild(item); } if (window.lucide) window.lucide.createIcons(); } async function createChat(shouldSwitch = true) { const response = await fetch("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "New chat" }), }); const result = await response.json(); const chat = result.chat; if (shouldSwitch && chat?.id) { currentThreadId = chat.id; await loadChatMessages(chat.id); } await refreshChats(); return chat; } async function switchChat(threadId) { if (!threadId || threadId === currentThreadId) return; currentThreadId = threadId; await loadChatMessages(threadId); await refreshChats(); } async function deleteChat(threadId) { await fetch(`/api/chats/${encodeURIComponent(threadId)}`, { method: "DELETE" }); if (threadId === currentThreadId) currentThreadId = ""; await refreshChats(); if (currentThreadId) await loadChatMessages(currentThreadId); } async function renameChat(threadId, currentTitle) { const title = window.prompt("Rename chat", currentTitle || "New chat"); if (title === null) return; const cleanTitle = title.trim(); if (!cleanTitle) return; await fetch(`/api/chats/${encodeURIComponent(threadId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: cleanTitle }), }); await refreshChats(); } async function loadChatMessages(threadId) { messages.innerHTML = ""; try { const response = await fetch(`/api/chats/${encodeURIComponent(threadId)}/messages`); const result = await response.json(); const rows = result.messages || []; if (!rows.length) { addMessage("assistant", "Tell me what to find or draft on UEX. I will ask for approval before sending anything."); return; } for (const row of rows) { if (row.role === "user" || row.role === "assistant") addMessage(row.role, row.content || ""); } } catch (error) { addMessage("assistant warning-message", `Could not load chat: ${fetchErrorMessage(error)}`); } } async function refreshInbox() { if (!inboxListEl) return; try { const response = await fetch("/api/inbox"); const result = await response.json(); renderInbox(result.inbox || []); } catch (error) { inboxListEl.textContent = `Inbox failed: ${fetchErrorMessage(error)}`; } } function renderInbox(items) { inboxListEl.innerHTML = ""; if (!items.length) { inboxListEl.innerHTML = '
    No inbox messages
    '; return; } for (const item of items) { const row = document.createElement("div"); row.className = "inbox-item"; const title = document.createElement("div"); title.className = "inbox-title"; title.textContent = item.content || ""; enhanceNegotiationLinks(title); const open = document.createElement("button"); open.type = "button"; open.className = "icon-button"; open.title = "Continue in new chat"; open.innerHTML = ''; open.addEventListener("click", () => continueInbox(item.id)); const remove = document.createElement("button"); remove.type = "button"; remove.className = "icon-button"; remove.title = "Delete inbox alert"; remove.innerHTML = ''; remove.addEventListener("click", () => deleteInboxItem(item.id)); row.append(title, open, remove); inboxListEl.appendChild(row); } if (window.lucide) window.lucide.createIcons(); } async function continueInbox(id) { const response = await fetch(`/api/inbox/${id}/continue`, { method: "POST" }); const result = await response.json(); if (result.chat?.id) { currentThreadId = result.chat.id; await loadChatMessages(currentThreadId); await refreshChats(); } } async function deleteInboxItem(id) { await fetch(`/api/inbox/${id}`, { method: "DELETE" }); await refreshInbox(); } async function openNegotiationPanel(identifier) { currentNegotiationId = identifier; negotiationPanel.hidden = false; negotiationTitle.textContent = `Negotiation ${identifier}`; negotiationStatusEl.textContent = ""; negotiationMessagesEl.textContent = "Loading"; try { const response = await fetch(`/api/negotiations/${encodeURIComponent(identifier)}/messages`); const result = await response.json(); renderNegotiationMessages(result.data || result.messages || result.notifications || []); } catch (error) { negotiationMessagesEl.textContent = `Could not load negotiation: ${fetchErrorMessage(error)}`; } } function closeNegotiationPanel() { negotiationPanel.hidden = true; currentNegotiationId = null; negotiationInput.value = ""; negotiationStatusEl.textContent = ""; } function openPlansPanel(openPlanId = null) { if (!plansPanel) return; plansPanel.hidden = false; plansToggle?.setAttribute("aria-expanded", "true"); refreshPlans(openPlanId); } function closePlansPanel() { if (!plansPanel) return; plansPanel.hidden = true; plansToggle?.setAttribute("aria-expanded", "false"); } function renderNegotiationMessages(data) { negotiationMessagesEl.innerHTML = ""; const items = Array.isArray(data) ? data : [data].filter(Boolean); if (!items.length) { negotiationMessagesEl.textContent = "No messages returned for this negotiation."; return; } for (const item of items) { const card = document.createElement("div"); card.className = "negotiation-message"; const author = item.user_username || item.username || item.author || item.sender || "UEX"; const body = item.message || item.content || item.text || JSON.stringify(item, null, 2); card.innerHTML = `${escapeHtml(String(author))}
    ${inlineMarkdown(String(body))}`; negotiationMessagesEl.appendChild(card); } negotiationMessagesEl.scrollTop = negotiationMessagesEl.scrollHeight; } async function submitNegotiationMessage(event) { event.preventDefault(); const text = negotiationInput.value.trim(); if (!text || !currentNegotiationId) return; negotiationStatusEl.textContent = "Sending"; try { const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: text }), }); const result = await response.json(); if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); negotiationInput.value = ""; negotiationStatusEl.textContent = result.message || "Sent"; await openNegotiationPanel(currentNegotiationId); } catch (error) { negotiationStatusEl.textContent = `Send failed: ${fetchErrorMessage(error)}`; } } function parsePlanItems(text) { return text .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map((line) => { const [name, quantity, maxPrice] = line.split("|").map((part) => part.trim()); const item = { item_name: name }; if (quantity) item.desired_quantity = Math.max(1, Number.parseInt(quantity, 10) || 1); if (maxPrice) item.max_unit_price = Number(maxPrice.replace(/,/g, "")); return item; }); } function formatPlanItems(items) { return (items || []) .map((item) => { const name = String(item.item_name || item.name || "").trim(); if (!name) return ""; const quantity = Number(item.desired_quantity || item.quantity || 1); const maxUnitPrice = item.max_unit_price ?? item.max_price; const parts = [name]; if (Number.isFinite(quantity) && quantity > 1) parts.push(String(quantity)); else if (maxUnitPrice !== null && maxUnitPrice !== undefined && maxUnitPrice !== "") parts.push("1"); if (maxUnitPrice !== null && maxUnitPrice !== undefined && maxUnitPrice !== "") parts.push(String(maxUnitPrice)); return parts.join(" | "); }) .filter(Boolean) .join("\n"); } function applyPlanDraft(draft) { if (!draft) return; document.getElementById("plan-title").value = draft.title || ""; document.getElementById("plan-objective").value = draft.objective || ""; document.getElementById("plan-kind").value = draft.kind || "buying"; document.getElementById("plan-tone").value = draft.constraints?.message_tone || ""; document.getElementById("plan-instructions").value = draft.constraints?.instructions || ""; document.getElementById("plan-cadence").value = draft.cadence || ""; document.getElementById("plan-items").value = formatPlanItems(draft.items || []); } async function autofillPlanDraft() { const title = document.getElementById("plan-title").value.trim(); const objective = document.getElementById("plan-objective").value.trim(); if (!title && !objective) { plansStatusEl.textContent = "Add at least a title or objective first"; return; } const tone = document.getElementById("plan-tone").value.trim(); const instructions = document.getElementById("plan-instructions").value.trim(); const constraints = {}; if (tone) constraints.message_tone = tone; if (instructions) constraints.instructions = instructions; const payload = { title, objective, kind: document.getElementById("plan-kind").value || "buying", constraints, items: parsePlanItems(document.getElementById("plan-items").value || ""), }; plansStatusEl.textContent = "Drafting plan"; if (planAutofillButton) planAutofillButton.disabled = true; try { const response = await fetch("/api/plans/draft", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const result = await response.json(); if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); applyPlanDraft(result.draft || {}); plansStatusEl.textContent = "Draft filled in. Review and edit anything you want."; } catch (error) { plansStatusEl.textContent = `Plan draft failed: ${fetchErrorMessage(error)}`; } finally { if (planAutofillButton) planAutofillButton.disabled = false; } } async function createPlan(event) { event.preventDefault(); const title = document.getElementById("plan-title").value.trim(); const objective = document.getElementById("plan-objective").value.trim(); if (!title || !objective) return; const tone = document.getElementById("plan-tone").value.trim(); const instructions = document.getElementById("plan-instructions").value.trim(); const constraints = {}; if (tone) constraints.message_tone = tone; if (instructions) constraints.instructions = instructions; const payload = { title, objective, kind: document.getElementById("plan-kind").value || "buying", cadence: document.getElementById("plan-cadence").value.trim() || null, constraints, items: parsePlanItems(document.getElementById("plan-items").value || ""), }; plansStatusEl.textContent = "Creating plan"; try { const response = await fetch("/api/plans", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const result = await response.json(); if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); planForm.reset(); plansStatusEl.textContent = result.plan?.status === "needs_input" ? "Plan created, but it needs an item checklist." : "Plan created"; await refreshPlans(result.plan?.id); } catch (error) { plansStatusEl.textContent = `Plan create failed: ${fetchErrorMessage(error)}`; } } async function refreshPlans(openPlanId = null) { if (!plansDashboardEl && !plansRailListEl) return; try { const response = await fetch("/api/plans"); const result = await response.json(); const plans = result.plans || []; renderPlansRail(plans); if (plansDashboardEl) await renderPlans(plans, openPlanId); } catch (error) { if (plansDashboardEl) plansDashboardEl.textContent = `Plans failed: ${fetchErrorMessage(error)}`; if (plansRailListEl) plansRailListEl.textContent = `Plans failed: ${fetchErrorMessage(error)}`; } } function renderPlansRail(plans) { if (!plansRailListEl) return; plansRailListEl.innerHTML = ""; if (!plans.length) { plansRailListEl.innerHTML = '
    No plans
    '; return; } for (const plan of plans.slice(0, 5)) { const row = document.createElement("button"); row.type = "button"; row.className = "plan-rail-item"; const title = document.createElement("span"); title.className = "plan-rail-title"; title.textContent = plan.title || "Untitled plan"; const status = document.createElement("span"); status.className = "plan-rail-status"; status.textContent = plan.status || "plan"; row.append(title, status); row.addEventListener("click", () => openPlansPanel(plan.id)); plansRailListEl.appendChild(row); } if (plans.length > 5) { const more = document.createElement("button"); more.type = "button"; more.className = "plan-rail-item"; more.textContent = `${plans.length - 5} more`; more.addEventListener("click", () => openPlansPanel()); plansRailListEl.appendChild(more); } } async function renderPlans(plans, openPlanId = null) { plansDashboardEl.innerHTML = ""; if (!plans.length) { plansDashboardEl.innerHTML = `

    Plan board

    No plans yet

    Create a buying watchlist or a custom follow-up routine to start tracking work over time.

    Nothing is running

    Your continual plans will appear here with status, timing, and recent activity.

    `; return; } const activeCount = plans.filter((plan) => plan.status === "active").length; const attentionCount = plans.filter((plan) => plan.status === "needs_input" || plan.status === "paused").length; const overview = document.createElement("section"); overview.className = "plans-overview"; overview.innerHTML = `

    Plan board

    ${plans.length} continual ${plans.length === 1 ? "plan" : "plans"}

    Monitor recurring work, keep candidate leads in view, and jump into details when something needs attention.

    ${activeCount} active
    ${attentionCount} needs eyes
    `; plansDashboardEl.appendChild(overview); for (const plan of plans) { const card = document.createElement("article"); card.className = `plan-card plan-status-${slugifyPlanValue(plan.status)}${plan.status === "active" ? " active" : ""}`; const heading = document.createElement("div"); heading.className = "plan-card-heading"; const title = document.createElement("h3"); title.textContent = plan.title || "Untitled plan"; const statusBadge = document.createElement("span"); statusBadge.className = `plan-status-badge plan-status-${slugifyPlanValue(plan.status)}`; statusBadge.textContent = humanizePlanValue(plan.status || "unknown"); const meta = document.createElement("div"); meta.className = "plan-meta"; meta.textContent = plan.objective || ""; const pills = document.createElement("div"); pills.className = "plan-pill-row"; for (const value of [plan.kind, plan.next_run_at ? `next ${formatShortDate(plan.next_run_at)}` : "not scheduled"]) { const pill = document.createElement("span"); pill.className = "plan-pill"; pill.textContent = humanizePlanValue(value); pills.appendChild(pill); } const metrics = document.createElement("div"); metrics.className = "plan-metrics"; metrics.append( planMetric("Checklist", String((plan.items || []).length)), planMetric("Cadence", summarizeCadence(plan.cadence)), planMetric("Updated", formatShortDate(plan.updated_at || plan.created_at)) ); const controls = document.createElement("div"); controls.className = "plan-controls"; controls.append( planButton("Details", () => loadPlanDetail(plan.id, card)), planButton("Run", () => postPlanAction(plan.id, "run")), planButton(plan.status === "active" ? "Pause" : "Resume", () => postPlanAction(plan.id, plan.status === "active" ? "pause" : "resume")), planButton("Delete", () => deletePlan(plan.id), "secondary small-button") ); heading.append(title, statusBadge); card.append(heading, meta, pills, metrics, controls); plansDashboardEl.appendChild(card); if (openPlanId && plan.id === openPlanId) await loadPlanDetail(plan.id, card); } } function planButton(label, onClick, className = "small-button") { const button = document.createElement("button"); button.type = "button"; button.className = className; button.textContent = label; button.addEventListener("click", onClick); return button; } async function loadPlanDetail(planId, card) { const existing = card.querySelector(".plan-detail"); if (existing) { existing.remove(); return; } const loading = document.createElement("div"); loading.className = "plan-detail plan-detail-loading"; loading.textContent = "Loading plan details..."; card.appendChild(loading); const response = await fetch(`/api/plans/${encodeURIComponent(planId)}`); const result = await response.json(); const plan = result.plan; loading.remove(); const detail = document.createElement("div"); detail.className = "plan-detail"; detail.append( planSection("Checklist", checklistLines(plan), "checklist"), planSection("Best Candidates", bestCandidateLines(plan), "candidates"), planSection("Recent Events", recentEventLines(plan), "events") ); card.appendChild(detail); } function planSection(title, lines, sectionClass = "") { const wrapper = document.createElement("section"); wrapper.className = `plan-section${sectionClass ? ` plan-section-${sectionClass}` : ""}`; const heading = document.createElement("h4"); heading.textContent = title; const list = document.createElement("ul"); list.className = "plan-list"; const items = lines.length ? lines : [planListItemData("Empty", "Nothing to show right now.")]; for (const line of items) { const item = document.createElement("li"); if (typeof line === "string") { item.textContent = line; } else { item.className = line.className || ""; const titleEl = document.createElement("div"); titleEl.className = "plan-list-title"; titleEl.textContent = line.title; const bodyEl = document.createElement("div"); bodyEl.className = "plan-list-body"; bodyEl.textContent = line.body; item.append(titleEl, bodyEl); } list.appendChild(item); } wrapper.append(heading, list); return wrapper; } function planMetric(label, value) { const metric = document.createElement("div"); metric.className = "plan-metric"; const metricLabel = document.createElement("span"); metricLabel.className = "plan-metric-label"; metricLabel.textContent = label; const metricValue = document.createElement("span"); metricValue.className = "plan-metric-value"; metricValue.textContent = value; metric.append(metricLabel, metricValue); return metric; } function summarizeCadence(cadence) { if (!cadence) return "manual"; return cadence.replace(/\s+/g, " ").trim(); } function slugifyPlanValue(value) { return String(value || "unknown") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, ""); } function humanizePlanValue(value) { return String(value || "") .replace(/_/g, " ") .replace(/\b\w/g, (char) => char.toUpperCase()); } function planListItemData(title, body, className = "") { return { title, body, className }; } function checklistLines(plan) { return (plan.items || []).map((item) => { const quantity = `${item.acquired_quantity || 0}/${item.desired_quantity || 1}`; const price = item.max_unit_price ? `Max ${Number(item.max_unit_price).toLocaleString()} UEC` : "No price cap"; return planListItemData(item.item_name, `${quantity} acquired • ${price} • ${humanizePlanValue(item.status || "pending")}`); }); } function bestCandidateLines(plan) { const byItem = new Map((plan.items || []).map((item) => [item.id, item.item_name])); return (plan.candidates || []) .filter((candidate) => candidate.status === "current" || candidate.status === "drafted") .slice(0, 6) .map((candidate) => { const title = byItem.get(candidate.plan_item_id) || "Item"; const listing = candidate.title || candidate.listing_slug || candidate.listing_id || "Unnamed listing"; const price = `${Number(candidate.price || 0).toLocaleString()} ${candidate.currency || "UEC"}`; const seller = candidate.seller || "unknown seller"; return planListItemData(title, `${listing} • ${price} • ${seller} • ${humanizePlanValue(candidate.status || "current")}`); }); } function recentEventLines(plan) { return (plan.events || []) .slice(0, 5) .map((event) => planListItemData(`${formatShortDate(event.created_at)} • ${humanizePlanValue(event.kind || "event")}`, event.message || "No details.")); } async function postPlanAction(planId, action) { plansStatusEl.textContent = `${action} requested`; try { const response = await fetch(`/api/plans/${encodeURIComponent(planId)}/${action}`, { method: "POST" }); const result = await response.json(); if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); plansStatusEl.textContent = result.summary || `Plan ${action} complete`; await refreshPlans(planId); await refreshPending(); await refreshInbox(); } catch (error) { plansStatusEl.textContent = `Plan ${action} failed: ${fetchErrorMessage(error)}`; } } async function deletePlan(planId) { if (!window.confirm("Delete this plan and its stored history?")) return; plansStatusEl.textContent = "delete requested"; try { const response = await fetch(`/api/plans/${encodeURIComponent(planId)}`, { method: "DELETE" }); const result = await response.json(); if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); plansStatusEl.textContent = result.summary || "Plan deleted"; await refreshPlans(); await refreshPending(); await refreshInbox(); } catch (error) { plansStatusEl.textContent = `Plan delete failed: ${fetchErrorMessage(error)}`; } } function formatShortDate(value) { if (!value) return ""; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); } async function checkHealth() { try { let health = {}; try { const response = await fetch("/api/health"); if (!response.ok) throw new Error(`HTTP ${response.status}`); const result = await response.json(); health = result.inference || result.ollama || {}; } catch (primaryError) { const fallbackResponse = await fetch("/api/ollama/status"); if (!fallbackResponse.ok) throw primaryError; health = await fallbackResponse.json(); } const provider = providerDisplayName(health.provider); const isDeepSeekProvider = health.provider === "deepseek"; const isCloudProvider = isDeepSeekProvider; ollamaOnline = Boolean(health.online); if (!ollamaOnline) { statusEl.textContent = "Offline"; setWarning(`${provider} needs attention. Open the model provider tab and use the pulsing action button.`); ollamaToggle?.classList.add("attention-pulse"); return false; } if (health.model_available === false) { const action = isCloudProvider ? "Save a working DeepSeek model." : "Install Model."; setWarning(`${provider} needs the configured model "${health.model}". Open the model provider tab and use ${action}`); ollamaToggle?.classList.add("attention-pulse"); } else { setWarning(""); ollamaToggle?.classList.remove("attention-pulse"); } statusEl.textContent = "Ready"; return true; } catch (error) { ollamaOnline = false; statusEl.textContent = "Offline"; setWarning("Could not check the active model provider. Open the model provider tab and use the pulsing action button."); ollamaToggle?.classList.add("attention-pulse"); 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(); if ((result.notifications || []).length) await refreshInbox(); } 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(); } }); input.addEventListener("paste", async (event) => { const clipboardItems = [...(event.clipboardData?.items || [])]; const imageFiles = clipboardItems .filter((item) => item.kind === "file" && String(item.type || "").startsWith("image/")) .map((item) => item.getAsFile()) .filter(Boolean); if (!imageFiles.length) return; if (!event.clipboardData?.getData("text/plain")) { event.preventDefault(); } try { await addComposerImages(imageFiles); } catch (error) { setWarning(`Image paste failed: ${fetchErrorMessage(error)}`); } }); 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")); plansToggle?.addEventListener("click", () => { if (plansPanel?.hidden) openPlansPanel(); else closePlansPanel(); }); ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama")); plansRefreshButton?.addEventListener("click", () => refreshPlans()); plansCloseButton?.addEventListener("click", closePlansPanel); planForm?.addEventListener("submit", createPlan); planAutofillButton?.addEventListener("click", autofillPlanDraft); ollamaForm?.addEventListener("submit", saveOllamaConfig); ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus); ollamaDownloadButton?.addEventListener("click", () => { markOllamaActionClicked("download"); postOllamaAction("/api/ollama/download"); }); ollamaInstallButton?.addEventListener("click", () => { markOllamaActionClicked("install"); postOllamaAction("/api/ollama/install"); }); ollamaLaunchButton?.addEventListener("click", () => { markOllamaActionClicked("launch"); postOllamaAction("/api/ollama/launch"); }); ollamaPullButton?.addEventListener("click", () => { markOllamaActionClicked("pull"); postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } }); }); providerModelSelect?.addEventListener("change", syncSelectedProviderModel); document.getElementById("model-provider")?.addEventListener("change", () => { const provider = document.getElementById("model-provider")?.value || "ollama"; updateProviderFieldVisibility(provider); renderProviderModelOptions(latestOllamaStatus?.models || [], { ...latestOllamaStatus, provider }); }); updateCheckButton?.addEventListener("click", checkForUpdate); updateInstallButton?.addEventListener("click", installUpdate); updateOpenReleasesButton?.addEventListener("click", openReleasesPage); chatSidebarToggle?.addEventListener("click", toggleChatRail); newChatButton?.addEventListener("click", () => createChat(true)); negotiationCloseButton?.addEventListener("click", closeNegotiationPanel); negotiationForm?.addEventListener("submit", submitNegotiationMessage); updateModalClose?.addEventListener("click", closeUpdatePrompt); updateModalReleases?.addEventListener("click", openReleasesPage); updateModalInstall?.addEventListener("click", installUpdate); async function sendMessage() { const message = input.value.trim(); const attachedImages = composerImages.map(({ name, content_type, image_data, preview_url }) => ({ name, content_type, image_data, preview_url, })); if ((!message && !attachedImages.length) || input.disabled) return; const healthy = await checkHealth(); if (!healthy) { addMessage("assistant warning-message", "The active model provider needs attention before chat can continue. Open the model provider tab and press the pulsing action button, then try again."); return; } input.value = ""; clearComposerImages(); input.disabled = true; addMessage("user", message, { images: attachedImages }); 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, thread_id: currentThreadId, images: attachedImages }), }); 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 === "reasoning") { appendThinkingText(assistantNode, event.content || ""); } 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") { if (event.thread_id) currentThreadId = event.thread_id; const visibleContent = thinkParser.flush(); if (visibleContent) { assistantText += visibleContent; setMessageMarkdown(assistantNode, assistantText); } renderPending(event.pending_actions || []); } } } } catch (error) { const message = error.message.includes("503") ? "The active model provider needs attention before chat can continue. Open the model provider tab and press the pulsing action button, 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, ""); await refreshChats(); } } refreshPending(); refreshMemory(); refreshPlans(); refreshConfig(); refreshOllamaStatus(); refreshChats().then(() => loadChatMessages(currentThreadId)); refreshInbox(); checkForUpdate(true); pollNotifications(); checkHealth(); setInterval(checkHealth, 30000); setInterval(pollNotifications, 15000);