Files
TraderAI/web/app.js
T
2026-05-08 14:48:51 -04:00

1757 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 openaiModelsRefreshButton = document.getElementById("openai-models-refresh");
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 plansStatusEl = document.getElementById("plans-status");
const plansDashboardEl = document.getElementById("plans-dashboard");
const plansRailListEl = document.getElementById("plans-rail-list");
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 = "<i></i><i></i><i></i>";
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 = ["<think>", "</think>"];
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("</think>");
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 + "</think>".length);
inThinking = false;
continue;
}
const openIndex = lower.indexOf("<think>");
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 + "<think>".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("</ul>");
inList = false;
}
if (inOrderedList) {
output.push("</ol>");
inOrderedList = false;
}
};
const flushCode = () => {
output.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
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("</ol>");
inOrderedList = false;
}
if (!inList) {
output.push("<ul>");
inList = true;
}
const nestedClass = unorderedItem[1].length >= 2 ? ' class="nested"' : "";
output.push(`<li${nestedClass}>${inlineMarkdown(unorderedItem[2])}</li>`);
continue;
}
const orderedItem = line.match(/^(\s*)\d+\.\s+(.+)$/);
if (orderedItem) {
if (inList) {
output.push("</ul>");
inList = false;
}
if (!inOrderedList) {
output.push("<ol>");
inOrderedList = true;
}
const nestedClass = orderedItem[1].length >= 2 ? ' class="nested"' : "";
output.push(`<li${nestedClass}>${inlineMarkdown(orderedItem[2])}</li>`);
continue;
}
closeLists();
if (/^>\s?/.test(trimmed)) {
output.push(`<blockquote>${inlineMarkdown(trimmed.replace(/^>\s?/, ""))}</blockquote>`);
} else if (/^---+$/.test(trimmed)) {
output.push("<hr>");
} else if (/^#{1,6}\s+/.test(trimmed)) {
const level = Math.min(4, trimmed.match(/^#+/)[0].length + 2);
output.push(`<h${level}>${inlineMarkdown(trimmed.replace(/^#{1,6}\s+/, ""))}</h${level}>`);
} else if (trimmed) {
output.push(`<p>${inlineMarkdown(line)}</p>`);
} else {
output.push("<br>");
}
}
if (inCode) flushCode();
closeLists();
return output.join("");
}
function inlineMarkdown(text) {
return escapeHtml(text)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
}
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) => `<th style="text-align:${aligns[index] || "left"}">${inlineMarkdown(cell)}</th>`)
.join("");
const body = rows
.map((row) => {
const cells = headers
.map((_, index) => `<td style="text-align:${aligns[index] || "left"}">${inlineMarkdown(row[index] || "")}</td>`)
.join("");
return `<tr>${cells}</tr>`;
})
.join("");
return `<div class="table-wrap"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function escapeHtml(text) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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);
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 = {
model_provider: "model-provider",
ollama_base_url: "ollama-base-url",
ollama_model: "ollama-model",
ollama_num_ctx: "ollama-num-ctx",
openai_base_url: "openai-base-url",
openai_api_key: "openai-api-key",
openai_model: "openai-model",
};
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] ?? "";
}
}
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 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;
const provider = status.provider === "openai" ? "OpenAI" : "Ollama";
const models = status.models?.length ? status.models.join(", ") : "None detected";
const ready = status.provider === "openai"
? 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 (status.provider !== "openai") {
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 = `
<div class="${pillClass}">${escapeHtml(status.message || "Unknown")}</div>
<div class="ollama-status-grid">
${detailItems.join("")}
</div>
${ollamaStatusItem(status.provider === "openai" ? "Available Models" : "Installed Models", models)}
${status.detail ? ollamaStatusItem("Detail", status.detail) : ""}
`;
if (ollamaDownloadButton) ollamaDownloadButton.hidden = status.provider === "openai";
if (ollamaInstallButton) {
ollamaInstallButton.hidden = status.provider === "openai" || !status.can_auto_install;
ollamaInstallButton.disabled = Boolean(status.installed) || !status.can_auto_install;
}
if (ollamaLaunchButton) {
ollamaLaunchButton.hidden = status.provider === "openai";
ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
}
if (ollamaPullButton) {
ollamaPullButton.hidden = status.provider === "openai";
ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
}
if (openaiModelsRefreshButton) {
openaiModelsRefreshButton.hidden = status.provider !== "openai";
openaiModelsRefreshButton.disabled = false;
}
renderProviderModelOptions(status.models || []);
updateOllamaAttention(status);
}
function ollamaStatusItem(label, value) {
return `<div class="ollama-status-item"><strong>${escapeHtml(label)}</strong><span>${escapeHtml(String(value ?? ""))}</span></div>`;
}
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 ready = currentStatus.provider === "openai"
? Boolean(currentStatus.online && currentStatus.model_available)
: Boolean(currentStatus.installed && currentStatus.running && currentStatus.model_available);
ollamaToggle?.classList.toggle("attention-pulse", !ready);
setOllamaButtonAttention(ollamaDownloadButton, "download", currentStatus.provider !== "openai" && !currentStatus.installed);
setOllamaButtonAttention(ollamaInstallButton, "install", currentStatus.provider !== "openai" && !currentStatus.installed && currentStatus.can_auto_install);
setOllamaButtonAttention(ollamaLaunchButton, "launch", currentStatus.provider !== "openai" && currentStatus.installed && !currentStatus.running);
setOllamaButtonAttention(ollamaPullButton, "pull", currentStatus.provider !== "openai" && currentStatus.running && !currentStatus.model_available);
setOllamaButtonAttention(openaiModelsRefreshButton, "openai-models", currentStatus.provider === "openai" && !currentStatus.model_available);
if (ready) clickedOllamaActions.clear();
}
function configuredOllamaModel() {
return document.getElementById("ollama-model")?.value || "";
}
function renderProviderModelOptions(models) {
const datalist = document.getElementById("provider-models");
if (!datalist) return;
datalist.innerHTML = "";
for (const model of models) {
const option = document.createElement("option");
option.value = model;
datalist.appendChild(option);
}
}
async function refreshOpenAIModels() {
setOllamaMessage("Loading OpenAI models");
try {
const response = await fetch("/api/openai/models");
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
renderProviderModelOptions(result.models || []);
setOllamaMessage(result.message || "Loaded OpenAI models");
await refreshOllamaStatus();
} catch (error) {
setOllamaMessage(`OpenAI models failed: ${fetchErrorMessage(error)}`);
}
}
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 = '<i data-lucide="pen" aria-hidden="true"></i>';
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 = '<i data-lucide="trash-2" aria-hidden="true"></i>';
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 = '<div class="pending-empty">No inbox messages</div>';
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 = '<i data-lucide="message-square-plus" aria-hidden="true"></i>';
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 = '<i data-lucide="trash-2" aria-hidden="true"></i>';
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 = `<strong>${escapeHtml(String(author))}</strong><br>${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;
});
}
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 = '<div class="pending-empty">No plans</div>';
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 = '<div class="pending-empty">No continual plans</div>';
return;
}
for (const plan of plans) {
const card = document.createElement("article");
card.className = `plan-card${plan.status === "active" ? " active" : ""}`;
const title = document.createElement("h3");
title.textContent = plan.title || "Untitled plan";
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.status, 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 = value;
pills.appendChild(pill);
}
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("Cancel", () => postPlanAction(plan.id, "cancel"), "secondary small-button")
);
card.append(title, meta, pills, 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 response = await fetch(`/api/plans/${encodeURIComponent(planId)}`);
const result = await response.json();
const plan = result.plan;
const detail = document.createElement("div");
detail.className = "plan-detail";
detail.append(
planSection("Checklist", (plan.items || []).map((item) => `${item.item_name}: ${item.acquired_quantity || 0}/${item.desired_quantity || 1}${item.max_unit_price ? `, max ${Number(item.max_unit_price).toLocaleString()} UEC` : ""} (${item.status})`)),
planSection("Best Candidates", bestCandidateLines(plan)),
planSection("Recent Events", (plan.events || []).slice(0, 5).map((event) => `${formatShortDate(event.created_at)} ${event.kind}: ${event.message}`))
);
card.appendChild(detail);
}
function planSection(title, lines) {
const wrapper = document.createElement("section");
const heading = document.createElement("h4");
heading.textContent = title;
const list = document.createElement("ul");
list.className = "plan-list";
const items = lines.length ? lines : ["Empty"];
for (const line of items) {
const item = document.createElement("li");
item.textContent = line;
list.appendChild(item);
}
wrapper.append(heading, list);
return wrapper;
}
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) => `${byItem.get(candidate.plan_item_id) || "Item"}: ${candidate.title || candidate.listing_slug || candidate.listing_id} at ${Number(candidate.price || 0).toLocaleString()} ${candidate.currency || "UEC"} from ${candidate.seller || "unknown"} (${candidate.status})`);
}
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)}`;
}
}
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 {
const response = await fetch("/api/health");
const result = await response.json();
const health = result.ollama || {};
const provider = health.provider === "openai" ? "OpenAI" : "Ollama";
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 = health.provider === "openai" ? "Load OpenAI Models." : "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);
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() } });
});
openaiModelsRefreshButton?.addEventListener("click", () => {
markOllamaActionClicked("openai-models");
refreshOpenAIModels();
});
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 === "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);