Files
TraderAI/web/app.js
T
2026-06-09 11:24:15 -04:00

2341 lines
91 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 negotiationsToggle = document.getElementById("negotiations-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 negotiationListEl = document.getElementById("negotiation-list");
const negotiationsRefreshAllButton = document.getElementById("negotiations-refresh-all");
const negotiationPanelListEl = document.getElementById("negotiation-panel-list");
const negotiationSearchEl = document.getElementById("negotiation-search");
const negotiationFilterEl = document.getElementById("negotiation-filter");
const negotiationThreadHeaderEl = document.getElementById("negotiation-thread-header");
const negotiationMetaCardEl = document.getElementById("negotiation-meta-card");
const negotiationUserCardEl = document.getElementById("negotiation-user-card");
const negotiationRefreshButton = document.getElementById("negotiation-refresh-button");
const negotiationDraftButton = document.getElementById("negotiation-draft-button");
const negotiationOpenChatButton = document.getElementById("negotiation-open-chat");
const negotiationEndDealButton = document.getElementById("negotiation-end-deal");
const negotiationSyncPillEl = document.getElementById("negotiation-sync-pill");
const negotiationCloseModal = document.getElementById("negotiation-close-modal");
const negotiationCloseModalClose = document.getElementById("negotiation-close-modal-close");
const negotiationCloseForm = document.getElementById("negotiation-close-form");
const negotiationCloseStatusEl = document.getElementById("negotiation-close-status");
const closeDealClosedEl = document.getElementById("close-deal-closed");
const closeDealValueEl = document.getElementById("close-deal-value");
const closeCurrencyEl = document.getElementById("close-currency");
const closeClarityEl = document.getElementById("close-clarity");
const closeSpeedEl = document.getElementById("close-speed");
const closeRespectEl = document.getElementById("close-respect");
const closeFairnessEl = document.getElementById("close-fairness");
const closeCommentEl = document.getElementById("close-comment");
const closeDraftButton = document.getElementById("close-draft-button");
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 negotiationRows = [];
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;
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 = ["<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);
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 = `
<div class="${pillClass}">${escapeHtml(status.message || "Unknown")}</div>
<div class="ollama-status-grid">
${detailItems.join("")}
</div>
${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 `<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 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 = '<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 refreshNegotiations(preserveCurrent = true) {
const status = negotiationFilterEl?.value || "open";
const search = negotiationSearchEl?.value?.trim() || "";
try {
const response = await fetch(`/api/negotiations?status=${encodeURIComponent(status)}&search=${encodeURIComponent(search)}&limit=100`);
const result = await response.json();
negotiationRows = result.negotiations || [];
renderNegotiationLists(negotiationRows);
if (!preserveCurrent) return;
if (!currentNegotiationId && negotiationRows.length) currentNegotiationId = negotiationRows[0].hash;
if (currentNegotiationId && negotiationRows.some((item) => item.hash === currentNegotiationId) && !negotiationPanel.hidden) {
await loadNegotiationDetail(currentNegotiationId, false);
}
} catch (error) {
const message = `Negotiations failed: ${fetchErrorMessage(error)}`;
if (negotiationListEl) negotiationListEl.textContent = message;
if (negotiationPanelListEl) negotiationPanelListEl.textContent = message;
}
}
async function refreshAllNegotiations() {
const previous = negotiationsRefreshAllButton?.disabled;
if (negotiationsRefreshAllButton) negotiationsRefreshAllButton.disabled = true;
if (negotiationStatusEl) negotiationStatusEl.textContent = "Refreshing all negotiations";
try {
const response = await fetch("/api/negotiations/refresh-all", { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
await refreshNegotiations(true);
if (negotiationStatusEl) {
negotiationStatusEl.textContent = `Refreshed ${result.count || 0} negotiations`;
}
} catch (error) {
if (negotiationStatusEl) negotiationStatusEl.textContent = `Refresh all failed: ${fetchErrorMessage(error)}`;
} finally {
if (negotiationsRefreshAllButton) negotiationsRefreshAllButton.disabled = Boolean(previous);
}
}
function renderNegotiationLists(items) {
renderNegotiationListInto(negotiationListEl, items.slice(0, 8));
renderNegotiationListInto(negotiationPanelListEl, items);
}
function renderNegotiationListInto(container, items) {
if (!container) return;
container.innerHTML = "";
if (!items.length) {
container.innerHTML = '<div class="pending-empty">No negotiations</div>';
return;
}
for (const item of items) {
const row = document.createElement("button");
row.type = "button";
row.className = `negotiation-row${item.hash === currentNegotiationId ? " active" : ""}`;
row.addEventListener("click", () => openNegotiationPanel(item.hash));
const top = document.createElement("div");
top.className = "negotiation-row-top";
const title = document.createElement("div");
title.className = "negotiation-row-title";
title.textContent = item.title || item.counterparty_username || item.hash;
const badge = document.createElement("span");
badge.className = `negotiation-row-badge ${item.status === "closed" ? "closed" : ""}`;
badge.textContent = item.status || "open";
top.append(title, badge);
const meta = document.createElement("div");
meta.className = "negotiation-row-meta";
meta.textContent = [
item.counterparty_username || "Unknown user",
item.last_message_at ? formatShortDate(item.last_message_at) : "No messages",
].join(" • ");
row.append(top, meta);
if (Number(item.unread_count || 0) > 0) {
const unread = document.createElement("span");
unread.className = "negotiation-row-unread";
unread.textContent = String(item.unread_count);
row.appendChild(unread);
}
container.appendChild(row);
}
}
async function openNegotiationPanel(identifier) {
if (!identifier) {
negotiationPanel.hidden = false;
negotiationsToggle?.setAttribute("aria-expanded", "true");
return;
}
currentNegotiationId = identifier;
negotiationPanel.hidden = false;
negotiationsToggle?.setAttribute("aria-expanded", "true");
negotiationStatusEl.textContent = "";
negotiationSyncPillEl.textContent = "Local sync";
await loadNegotiationDetail(identifier, true);
}
async function loadNegotiationDetail(identifier, refreshList = true) {
negotiationTitle.textContent = `Negotiation ${identifier}`;
negotiationMessagesEl.textContent = "Loading";
negotiationThreadHeaderEl.innerHTML = '<div class="muted">Loading local thread...</div>';
negotiationMetaCardEl.innerHTML = "<h3>Deal</h3><div class='muted'>Loading</div>";
negotiationUserCardEl.innerHTML = "<h3>User</h3><div class='muted'>Loading</div>";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(identifier)}`);
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
renderNegotiationDetail(result.negotiation);
if (refreshList) await refreshNegotiations(false);
} catch (error) {
negotiationMessagesEl.textContent = `Could not load negotiation: ${fetchErrorMessage(error)}`;
}
}
function closeNegotiationPanel() {
negotiationPanel.hidden = true;
currentNegotiationId = null;
negotiationInput.value = "";
negotiationStatusEl.textContent = "";
negotiationsToggle?.setAttribute("aria-expanded", "false");
}
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 renderNegotiationDetail(negotiation) {
if (!negotiation) return;
negotiationTitle.textContent = negotiation.title || negotiation.counterparty_username || negotiation.hash;
negotiationSyncPillEl.textContent = negotiation.last_synced_at ? `Synced ${formatShortDate(negotiation.last_synced_at)}` : "Local sync";
negotiationThreadHeaderEl.innerHTML = `
<div><strong>${escapeHtml(negotiation.title || "Negotiation")}</strong></div>
<div class="muted">${escapeHtml(negotiation.counterparty_username || "Unknown user")}${escapeHtml(negotiation.status || "open")}${escapeHtml(negotiation.hash || "")}</div>
`;
renderNegotiationMessages(negotiation.messages || []);
const raw = negotiation.metadata?.raw || {};
negotiationMetaCardEl.innerHTML = `
<h3>Deal</h3>
<div class="negotiation-detail-kv">
<div><strong>Listing</strong> ${escapeHtml(negotiation.title || raw.listing_title || negotiation.hash)}</div>
<div><strong>Status</strong> ${escapeHtml(negotiation.status || "open")}</div>
<div><strong>Slug</strong> ${escapeHtml(negotiation.listing_slug || raw.listing_slug || "Unknown")}</div>
<div><strong>Price</strong> ${escapeHtml(String(raw.price || raw.deal_value || "Unknown"))} ${escapeHtml(String(raw.currency || raw.deal_value_currency || ""))}</div>
<div><strong>Last message</strong> ${escapeHtml(negotiation.last_message_at ? formatShortDate(negotiation.last_message_at) : "Unknown")}</div>
</div>
`;
negotiationUserCardEl.innerHTML = `
<h3>User</h3>
<div class="negotiation-detail-kv">
<div><strong>Counterparty</strong> ${escapeHtml(negotiation.counterparty_username || raw.client_username || raw.advertiser_username || "Unknown")}</div>
<div><strong>Advertiser</strong> ${escapeHtml(String(raw.advertiser_username || raw.advertiser_name || "Unknown"))}</div>
<div><strong>Client</strong> ${escapeHtml(String(raw.client_username || raw.client_name || "Unknown"))}</div>
<div><strong>Unread</strong> ${escapeHtml(String(negotiation.unread_count || 0))}</div>
</div>
`;
}
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${item.is_me ? " self" : ""}`;
const author = item.author_username || item.user_username || item.username || item.author || item.sender || "UEX";
const body = item.body || item.message || item.content || item.text || JSON.stringify(item, null, 2);
const meta = document.createElement("div");
meta.className = "negotiation-message-meta";
meta.innerHTML = `<strong>${escapeHtml(String(author))}</strong><span>${escapeHtml(item.sent_at ? formatShortDate(item.sent_at) : "")}</span>`;
const text = document.createElement("div");
text.innerHTML = inlineMarkdown(String(body));
card.append(meta, text);
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/manual`, {
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";
if (result.negotiation) renderNegotiationDetail(result.negotiation);
await refreshNegotiations(false);
} catch (error) {
negotiationStatusEl.textContent = `Send failed: ${fetchErrorMessage(error)}`;
}
}
async function draftNegotiationMessage() {
const text = negotiationInput.value.trim();
if (!text || !currentNegotiationId) return;
negotiationStatusEl.textContent = "Drafting";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages/draft`, {
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}`);
negotiationStatusEl.textContent = "Draft ready for approval";
await refreshPending();
} catch (error) {
negotiationStatusEl.textContent = `Draft failed: ${fetchErrorMessage(error)}`;
}
}
async function refreshActiveNegotiation() {
if (!currentNegotiationId) return;
negotiationStatusEl.textContent = "Refreshing";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/refresh`, { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
if (result.negotiation) renderNegotiationDetail(result.negotiation);
negotiationStatusEl.textContent = "Refreshed";
await refreshNegotiations(false);
} catch (error) {
negotiationStatusEl.textContent = `Refresh failed: ${fetchErrorMessage(error)}`;
}
}
async function openNegotiationInChat() {
if (!currentNegotiationId) return;
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/open-chat`, { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
if (result.chat?.id) {
currentThreadId = result.chat.id;
await loadChatMessages(currentThreadId);
await refreshChats();
}
} catch (error) {
negotiationStatusEl.textContent = `Open chat failed: ${fetchErrorMessage(error)}`;
}
}
function openNegotiationCloseModal() {
if (!currentNegotiationId) return;
negotiationCloseStatusEl.textContent = "";
negotiationCloseModal.hidden = false;
}
function closeNegotiationCloseModal() {
negotiationCloseModal.hidden = true;
}
function negotiationClosePayload() {
return {
deal_closed: closeDealClosedEl.value !== "false",
deal_value: closeDealValueEl.value ? Number(closeDealValueEl.value) : null,
currency: closeCurrencyEl.value.trim() || null,
clarity_rating: closeClarityEl.value ? Number(closeClarityEl.value) : null,
speed_rating: closeSpeedEl.value ? Number(closeSpeedEl.value) : null,
respect_rating: closeRespectEl.value ? Number(closeRespectEl.value) : null,
fairness_rating: closeFairnessEl.value ? Number(closeFairnessEl.value) : null,
comment: closeCommentEl.value.trim() || null,
};
}
async function submitNegotiationClose(event) {
event.preventDefault();
if (!currentNegotiationId) return;
negotiationCloseStatusEl.textContent = "Submitting";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/close/manual`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(negotiationClosePayload()),
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
negotiationCloseStatusEl.textContent = result.message || "Submitted";
if (result.negotiation) renderNegotiationDetail(result.negotiation);
await refreshNegotiations(false);
closeNegotiationCloseModal();
} catch (error) {
negotiationCloseStatusEl.textContent = `Close failed: ${fetchErrorMessage(error)}`;
}
}
async function draftNegotiationClose() {
if (!currentNegotiationId) return;
negotiationCloseStatusEl.textContent = "Drafting";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/close/draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(negotiationClosePayload()),
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
negotiationCloseStatusEl.textContent = "Draft ready for approval";
await refreshPending();
} catch (error) {
negotiationCloseStatusEl.textContent = `Draft 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 = '<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 = `
<section class="plans-overview">
<div>
<p class="eyebrow">Plan board</p>
<h3>No plans yet</h3>
<p class="plan-overview-copy">Create a buying watchlist or a custom follow-up routine to start tracking work over time.</p>
</div>
</section>
<div class="plan-empty-state">
<h4>Nothing is running</h4>
<p>Your continual plans will appear here with status, timing, and recent activity.</p>
</div>
`;
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 = `
<div>
<p class="eyebrow">Plan board</p>
<h3>${plans.length} continual ${plans.length === 1 ? "plan" : "plans"}</h3>
<p class="plan-overview-copy">Monitor recurring work, keep candidate leads in view, and jump into details when something needs attention.</p>
</div>
<div class="plan-overview-stats">
<div class="plan-overview-stat">
<span class="plan-overview-stat-value">${activeCount}</span>
<span class="plan-overview-stat-label">active</span>
</div>
<div class="plan-overview-stat">
<span class="plan-overview-stat-value">${attentionCount}</span>
<span class="plan-overview-stat-label">needs eyes</span>
</div>
</div>
`;
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();
await refreshNegotiations(true);
}
} 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();
});
negotiationsToggle?.addEventListener("click", () => {
if (negotiationPanel?.hidden) openNegotiationPanel(currentNegotiationId || negotiationRows[0]?.hash || "");
else closeNegotiationPanel();
});
negotiationsRefreshAllButton?.addEventListener("click", refreshAllNegotiations);
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);
negotiationDraftButton?.addEventListener("click", draftNegotiationMessage);
negotiationRefreshButton?.addEventListener("click", refreshActiveNegotiation);
negotiationOpenChatButton?.addEventListener("click", openNegotiationInChat);
negotiationEndDealButton?.addEventListener("click", openNegotiationCloseModal);
negotiationSearchEl?.addEventListener("input", () => refreshNegotiations(false));
negotiationFilterEl?.addEventListener("change", () => refreshNegotiations(false));
negotiationCloseModalClose?.addEventListener("click", closeNegotiationCloseModal);
negotiationCloseForm?.addEventListener("submit", submitNegotiationClose);
closeDraftButton?.addEventListener("click", draftNegotiationClose);
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();
refreshNegotiations(false);
checkForUpdate(true);
pollNotifications();
checkHealth();
setInterval(checkHealth, 30000);
setInterval(pollNotifications, 15000);
setInterval(() => refreshNegotiations(true), 15000);