2046 lines
77 KiB
JavaScript
2046 lines
77 KiB
JavaScript
const form = document.getElementById("chat-form");
|
||
const input = document.getElementById("message-input");
|
||
const composerImagesEl = document.getElementById("composer-images");
|
||
const messages = document.getElementById("messages");
|
||
const statusEl = document.getElementById("status");
|
||
const pendingEl = document.getElementById("pending-actions");
|
||
const warningEl = document.getElementById("warning");
|
||
const memoryInspectorEl = document.getElementById("memory-inspector");
|
||
const memoryRefreshButton = document.getElementById("memory-refresh");
|
||
const memoryClearButton = document.getElementById("memory-clear");
|
||
const configForm = document.getElementById("config-form");
|
||
const configRefreshButton = document.getElementById("config-refresh");
|
||
const configStatusEl = document.getElementById("config-status");
|
||
const configPathsEl = document.getElementById("config-paths");
|
||
const settingsToggle = document.getElementById("settings-toggle");
|
||
const memoryToggle = document.getElementById("memory-toggle");
|
||
const plansToggle = document.getElementById("plans-toggle");
|
||
const ollamaToggle = document.getElementById("ollama-toggle");
|
||
const settingsPanel = document.getElementById("settings-panel");
|
||
const memoryPanel = document.getElementById("memory-panel");
|
||
const plansPanel = document.getElementById("plans-panel");
|
||
const ollamaPanel = document.getElementById("ollama-panel");
|
||
const ollamaForm = document.getElementById("ollama-config-form");
|
||
const ollamaRefreshButton = document.getElementById("ollama-refresh");
|
||
const ollamaDownloadButton = document.getElementById("ollama-download");
|
||
const ollamaInstallButton = document.getElementById("ollama-install");
|
||
const ollamaLaunchButton = document.getElementById("ollama-launch");
|
||
const ollamaPullButton = document.getElementById("ollama-pull");
|
||
const providerModelSelect = document.getElementById("provider-model-select");
|
||
const providerModelLabel = document.getElementById("provider-model-label");
|
||
const modelReasoningEffortSelect = document.getElementById("model-reasoning-effort");
|
||
const ollamaStatusEl = document.getElementById("ollama-status");
|
||
const ollamaMessageEl = document.getElementById("ollama-message");
|
||
const updateCheckButton = document.getElementById("update-check");
|
||
const updateInstallButton = document.getElementById("update-install");
|
||
const updateOpenReleasesButton = document.getElementById("update-open-releases");
|
||
const updateStatusEl = document.getElementById("update-status");
|
||
const shellEl = document.querySelector(".shell");
|
||
const chatRailEl = document.getElementById("chat-rail");
|
||
const chatSidebarToggle = document.getElementById("chat-sidebar-toggle");
|
||
const newChatButton = document.getElementById("new-chat");
|
||
const chatListEl = document.getElementById("chat-list");
|
||
const inboxListEl = document.getElementById("inbox-list");
|
||
const negotiationPanel = document.getElementById("negotiation-panel");
|
||
const negotiationTitle = document.getElementById("negotiation-title");
|
||
const negotiationMessagesEl = document.getElementById("negotiation-messages");
|
||
const negotiationForm = document.getElementById("negotiation-form");
|
||
const negotiationInput = document.getElementById("negotiation-input");
|
||
const negotiationStatusEl = document.getElementById("negotiation-status");
|
||
const negotiationCloseButton = document.getElementById("negotiation-close");
|
||
const updateModal = document.getElementById("update-modal");
|
||
const updateModalCopy = document.getElementById("update-modal-copy");
|
||
const updateModalClose = document.getElementById("update-modal-close");
|
||
const updateModalInstall = document.getElementById("update-modal-install");
|
||
const updateModalReleases = document.getElementById("update-modal-releases");
|
||
const plansRefreshButton = document.getElementById("plans-refresh");
|
||
const plansCloseButton = document.getElementById("plans-close");
|
||
const planForm = document.getElementById("plan-form");
|
||
const planAutofillButton = document.getElementById("plan-autofill");
|
||
const plansStatusEl = document.getElementById("plans-status");
|
||
const plansDashboardEl = document.getElementById("plans-dashboard");
|
||
const plansRailListEl = document.getElementById("plans-rail-list");
|
||
const providerScopedFields = Array.from(document.querySelectorAll("[data-provider-scope]"));
|
||
|
||
let ollamaOnline = true;
|
||
let latestUpdate = null;
|
||
let currentThreadId = "default";
|
||
let currentNegotiationId = null;
|
||
let latestOllamaStatus = null;
|
||
let composerImages = [];
|
||
const clickedOllamaActions = new Set();
|
||
|
||
if (window.lucide) {
|
||
window.lucide.createIcons();
|
||
}
|
||
|
||
function addMessage(role, text, options = {}) {
|
||
const node = document.createElement("div");
|
||
node.className = `message ${role}`;
|
||
setMessageMarkdown(node, text, options);
|
||
messages.appendChild(node);
|
||
messages.scrollTop = messages.scrollHeight;
|
||
return node;
|
||
}
|
||
|
||
function setMessageMarkdown(node, text, options = {}) {
|
||
const body = node.querySelector(".message-body") || node;
|
||
body.innerHTML = "";
|
||
const attachedImages = options.images || [];
|
||
if (attachedImages.length) {
|
||
body.appendChild(renderImageGallery(attachedImages));
|
||
}
|
||
if (text) {
|
||
const markdown = document.createElement("div");
|
||
markdown.innerHTML = renderMarkdown(text);
|
||
body.appendChild(markdown);
|
||
enhanceNegotiationLinks(markdown);
|
||
}
|
||
}
|
||
|
||
function renderImageGallery(images) {
|
||
const gallery = document.createElement("div");
|
||
gallery.className = "message-images";
|
||
for (const image of images) {
|
||
const card = document.createElement("div");
|
||
card.className = "message-image";
|
||
const preview = document.createElement("img");
|
||
preview.src = image.preview_url || `data:${image.content_type || "image/png"};base64,${image.image_data}`;
|
||
preview.alt = image.name || "Attached image";
|
||
const label = document.createElement("span");
|
||
label.className = "message-image-label";
|
||
label.textContent = image.name || "Attached image";
|
||
card.append(preview, label);
|
||
gallery.appendChild(card);
|
||
}
|
||
return gallery;
|
||
}
|
||
|
||
function setMessageActivity(node, text, active = false) {
|
||
const activity = node.querySelector(".message-activity");
|
||
if (!activity) return;
|
||
if (text) appendThinkingStep(node, reasoningSummaryForStatus(text), { fallback: true });
|
||
const phase = activity.querySelector(".message-phase");
|
||
phase.innerHTML = "";
|
||
if (text) {
|
||
const label = document.createElement("span");
|
||
label.textContent = text;
|
||
phase.appendChild(label);
|
||
}
|
||
if (active) {
|
||
const dots = document.createElement("span");
|
||
dots.className = "working-dots";
|
||
dots.innerHTML = "<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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function composerImageId() {
|
||
if (window.crypto?.randomUUID) return window.crypto.randomUUID();
|
||
return `image-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
}
|
||
|
||
function readFileAsDataUrl(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(String(reader.result || ""));
|
||
reader.onerror = () => reject(reader.error || new Error(`Could not read ${file.name || "image"}`));
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
async function addComposerImages(files) {
|
||
const additions = [];
|
||
for (const file of files) {
|
||
if (!file || !String(file.type || "").startsWith("image/")) continue;
|
||
const previewUrl = await readFileAsDataUrl(file);
|
||
const [, imageData = ""] = previewUrl.split(",", 2);
|
||
if (!imageData) continue;
|
||
additions.push({
|
||
id: composerImageId(),
|
||
name: file.name || `pasted-image-${composerImages.length + additions.length + 1}.png`,
|
||
content_type: file.type || "image/png",
|
||
image_data: imageData,
|
||
preview_url: previewUrl,
|
||
});
|
||
}
|
||
if (!additions.length) return;
|
||
composerImages = [...composerImages, ...additions];
|
||
renderComposerImages();
|
||
}
|
||
|
||
function removeComposerImage(imageId) {
|
||
composerImages = composerImages.filter((image) => image.id !== imageId);
|
||
renderComposerImages();
|
||
}
|
||
|
||
function clearComposerImages() {
|
||
composerImages = [];
|
||
renderComposerImages();
|
||
}
|
||
|
||
function renderComposerImages() {
|
||
if (!composerImagesEl) return;
|
||
composerImagesEl.innerHTML = "";
|
||
composerImagesEl.hidden = !composerImages.length;
|
||
for (const image of composerImages) {
|
||
const card = document.createElement("div");
|
||
card.className = "composer-image";
|
||
const preview = document.createElement("img");
|
||
preview.src = image.preview_url;
|
||
preview.alt = image.name || "Pasted image";
|
||
const remove = document.createElement("button");
|
||
remove.type = "button";
|
||
remove.className = "composer-image-remove";
|
||
remove.textContent = "×";
|
||
remove.title = "Remove image";
|
||
remove.addEventListener("click", () => removeComposerImage(image.id));
|
||
const label = document.createElement("span");
|
||
label.className = "composer-image-name";
|
||
label.textContent = image.name || "Pasted image";
|
||
card.append(preview, remove, label);
|
||
composerImagesEl.appendChild(card);
|
||
}
|
||
}
|
||
|
||
function formatMetrics(event) {
|
||
const read = formatTokenMetric(event.reading_tokens, event.reading_tokens_per_second);
|
||
const wrote = formatTokenMetric(event.writing_tokens, event.writing_tokens_per_second);
|
||
const cache = formatCacheMetric(event.cache_hit_tokens, event.cache_miss_tokens);
|
||
return [read && `read ${read}`, wrote && `wrote ${wrote}`, cache].filter(Boolean).join(" | ");
|
||
}
|
||
|
||
function formatTokenMetric(tokens, speed) {
|
||
if (!tokens) return "";
|
||
const speedText = typeof speed === "number" ? ` @ ${speed.toFixed(1)}/s` : "";
|
||
return `${tokens} tok${speedText}`;
|
||
}
|
||
|
||
function formatCacheMetric(hitTokens, missTokens) {
|
||
if (!hitTokens && !missTokens) return "";
|
||
const hit = Number(hitTokens || 0).toLocaleString();
|
||
const miss = Number(missTokens || 0).toLocaleString();
|
||
return `cache ${hit} hit / ${miss} miss`;
|
||
}
|
||
|
||
function setWarning(text) {
|
||
warningEl.hidden = !text;
|
||
warningEl.textContent = text || "";
|
||
}
|
||
|
||
function fetchErrorMessage(error) {
|
||
if (error instanceof TypeError && /fetch/i.test(error.message)) {
|
||
return "TraderAI backend is not reachable. Close this app window and launch TraderAI.exe again.";
|
||
}
|
||
return error.message;
|
||
}
|
||
|
||
const configFieldIds = {
|
||
uex_base_url: "config-uex-base-url",
|
||
uex_secret_key: "config-uex-secret-key",
|
||
uex_bearer_token: "config-uex-bearer-token",
|
||
traderai_user_name: "config-traderai-user-name",
|
||
traderai_memory_path: "config-traderai-memory-path",
|
||
uex_notification_poll_seconds: "config-uex-notification-poll-seconds",
|
||
require_write_approval: "config-require-write-approval",
|
||
};
|
||
|
||
const ollamaFieldIds = {
|
||
model_provider: "model-provider",
|
||
deepseek_base_url: "deepseek-base-url",
|
||
deepseek_api_key: "deepseek-api-key",
|
||
deepseek_model: "deepseek-model",
|
||
ollama_base_url: "ollama-base-url",
|
||
ollama_model: "ollama-model",
|
||
ollama_num_ctx: "ollama-num-ctx",
|
||
model_reasoning_effort: "model-reasoning-effort",
|
||
};
|
||
|
||
async function refreshConfig() {
|
||
try {
|
||
const response = await fetch("/api/config");
|
||
const config = await response.json();
|
||
renderConfig(config);
|
||
} catch (error) {
|
||
configStatusEl.textContent = `Config load failed: ${fetchErrorMessage(error)}`;
|
||
}
|
||
}
|
||
|
||
function renderConfig(config) {
|
||
const values = config.values || {};
|
||
const secretsConfigured = config.secrets_configured || {};
|
||
for (const [key, id] of Object.entries(configFieldIds)) {
|
||
const field = document.getElementById(id);
|
||
if (!field) continue;
|
||
if (field.type === "checkbox") {
|
||
field.checked = Boolean(values[key]);
|
||
} else if (field.type === "password") {
|
||
field.value = "";
|
||
field.placeholder = secretsConfigured[key] ? "Configured" : "";
|
||
} else {
|
||
field.value = values[key] ?? "";
|
||
}
|
||
}
|
||
for (const [key, id] of Object.entries(ollamaFieldIds)) {
|
||
const field = document.getElementById(id);
|
||
if (!field) continue;
|
||
if (field.type === "password") {
|
||
field.value = "";
|
||
field.placeholder = secretsConfigured[key] ? "Configured" : "";
|
||
} else {
|
||
field.value = values[key] ?? "";
|
||
}
|
||
}
|
||
renderReasoningEffortOptions(["none", "minimal", "low", "medium", "high", "xhigh"], values.model_reasoning_effort || "medium");
|
||
updateProviderFieldVisibility(values.model_provider || "ollama");
|
||
configPathsEl.textContent = `App data: ${config.app_data_dir}\nConfig: ${config.config_path}\nLog: ${config.log_path}\nEdge profile: ${config.edge_profile_dir}`;
|
||
configStatusEl.textContent = "";
|
||
}
|
||
|
||
async function saveConfig(event) {
|
||
event.preventDefault();
|
||
const values = {};
|
||
for (const [key, id] of Object.entries(configFieldIds)) {
|
||
const field = document.getElementById(id);
|
||
if (!field) continue;
|
||
values[key] = field.type === "checkbox" ? field.checked : field.value;
|
||
}
|
||
configStatusEl.textContent = "Saving";
|
||
try {
|
||
const response = await fetch("/api/config", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ values }),
|
||
});
|
||
const result = await response.json();
|
||
renderConfig(result);
|
||
configStatusEl.textContent = result.message || "Saved";
|
||
addMessage("assistant", result.message || "Config saved.");
|
||
} catch (error) {
|
||
configStatusEl.textContent = `Config save failed: ${fetchErrorMessage(error)}`;
|
||
}
|
||
}
|
||
|
||
async function saveOllamaConfig(event) {
|
||
event.preventDefault();
|
||
const values = {};
|
||
for (const [key, id] of Object.entries(ollamaFieldIds)) {
|
||
const field = document.getElementById(id);
|
||
if (!field) continue;
|
||
values[key] = field.value;
|
||
}
|
||
setOllamaMessage("Saving provider config");
|
||
try {
|
||
const response = await fetch("/api/config", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ values }),
|
||
});
|
||
const result = await response.json();
|
||
renderConfig(result);
|
||
setOllamaMessage(result.message || "Saved");
|
||
await refreshOllamaStatus();
|
||
} catch (error) {
|
||
setOllamaMessage(`Provider config save failed: ${fetchErrorMessage(error)}`);
|
||
}
|
||
}
|
||
|
||
async function refreshOllamaStatus() {
|
||
if (!ollamaStatusEl) return;
|
||
ollamaStatusEl.textContent = "Checking provider";
|
||
try {
|
||
const response = await fetch("/api/ollama/status");
|
||
const status = await response.json();
|
||
renderOllamaStatus(status);
|
||
} catch (error) {
|
||
ollamaStatusEl.textContent = `Provider status failed: ${error.message}`;
|
||
}
|
||
}
|
||
|
||
function renderOllamaStatus(status) {
|
||
if (!ollamaStatusEl) return;
|
||
latestOllamaStatus = status;
|
||
updateProviderFieldVisibility(status.provider || "ollama");
|
||
const provider = providerDisplayName(status.provider);
|
||
const models = status.models?.length ? status.models.join(", ") : "None detected";
|
||
const isDeepSeekProvider = status.provider === "deepseek";
|
||
const isCloudProvider = isDeepSeekProvider;
|
||
const ready = isCloudProvider
|
||
? Boolean(status.online && status.model_available)
|
||
: Boolean(status.installed && status.running && status.model_available);
|
||
const pillClass = ready ? "status-pill" : "status-pill warning";
|
||
const detailItems = [
|
||
ollamaStatusItem("Provider", provider),
|
||
ollamaStatusItem("Model", status.configured_model || ""),
|
||
ollamaStatusItem("URL", status.base_url || ""),
|
||
];
|
||
if (!isCloudProvider) {
|
||
detailItems.splice(1, 0, ollamaStatusItem("Installed", status.installed ? "Yes" : "No"));
|
||
detailItems.splice(2, 0, ollamaStatusItem("Running", status.running ? "Yes" : "No"));
|
||
detailItems.push(ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No"));
|
||
if (status.can_auto_install) detailItems.push(ollamaStatusItem("Auto Install", "Available"));
|
||
if (status.num_ctx) detailItems.push(ollamaStatusItem("Context", status.num_ctx));
|
||
} else {
|
||
detailItems.splice(1, 0, ollamaStatusItem("Connected", status.online ? "Yes" : "No"));
|
||
}
|
||
ollamaStatusEl.innerHTML = `
|
||
<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 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;
|
||
});
|
||
}
|
||
|
||
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();
|
||
} catch {
|
||
// Notification polling should never interrupt chat.
|
||
}
|
||
}
|
||
|
||
form.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
await sendMessage();
|
||
});
|
||
|
||
input.addEventListener("keydown", async (event) => {
|
||
if (event.key === "Enter" && !event.shiftKey) {
|
||
event.preventDefault();
|
||
await sendMessage();
|
||
}
|
||
});
|
||
|
||
input.addEventListener("paste", async (event) => {
|
||
const clipboardItems = [...(event.clipboardData?.items || [])];
|
||
const imageFiles = clipboardItems
|
||
.filter((item) => item.kind === "file" && String(item.type || "").startsWith("image/"))
|
||
.map((item) => item.getAsFile())
|
||
.filter(Boolean);
|
||
if (!imageFiles.length) return;
|
||
if (!event.clipboardData?.getData("text/plain")) {
|
||
event.preventDefault();
|
||
}
|
||
try {
|
||
await addComposerImages(imageFiles);
|
||
} catch (error) {
|
||
setWarning(`Image paste failed: ${fetchErrorMessage(error)}`);
|
||
}
|
||
});
|
||
|
||
memoryRefreshButton?.addEventListener("click", refreshMemory);
|
||
memoryClearButton?.addEventListener("click", clearMemory);
|
||
configRefreshButton?.addEventListener("click", refreshConfig);
|
||
configForm?.addEventListener("submit", saveConfig);
|
||
settingsToggle?.addEventListener("click", () => toggleSidebarPanel("settings"));
|
||
memoryToggle?.addEventListener("click", () => toggleSidebarPanel("memory"));
|
||
plansToggle?.addEventListener("click", () => {
|
||
if (plansPanel?.hidden) openPlansPanel();
|
||
else closePlansPanel();
|
||
});
|
||
ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama"));
|
||
plansRefreshButton?.addEventListener("click", () => refreshPlans());
|
||
plansCloseButton?.addEventListener("click", closePlansPanel);
|
||
planForm?.addEventListener("submit", createPlan);
|
||
planAutofillButton?.addEventListener("click", autofillPlanDraft);
|
||
ollamaForm?.addEventListener("submit", saveOllamaConfig);
|
||
ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus);
|
||
ollamaDownloadButton?.addEventListener("click", () => {
|
||
markOllamaActionClicked("download");
|
||
postOllamaAction("/api/ollama/download");
|
||
});
|
||
ollamaInstallButton?.addEventListener("click", () => {
|
||
markOllamaActionClicked("install");
|
||
postOllamaAction("/api/ollama/install");
|
||
});
|
||
ollamaLaunchButton?.addEventListener("click", () => {
|
||
markOllamaActionClicked("launch");
|
||
postOllamaAction("/api/ollama/launch");
|
||
});
|
||
ollamaPullButton?.addEventListener("click", () => {
|
||
markOllamaActionClicked("pull");
|
||
postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } });
|
||
});
|
||
providerModelSelect?.addEventListener("change", syncSelectedProviderModel);
|
||
document.getElementById("model-provider")?.addEventListener("change", () => {
|
||
const provider = document.getElementById("model-provider")?.value || "ollama";
|
||
updateProviderFieldVisibility(provider);
|
||
renderProviderModelOptions(latestOllamaStatus?.models || [], { ...latestOllamaStatus, provider });
|
||
});
|
||
updateCheckButton?.addEventListener("click", checkForUpdate);
|
||
updateInstallButton?.addEventListener("click", installUpdate);
|
||
updateOpenReleasesButton?.addEventListener("click", openReleasesPage);
|
||
chatSidebarToggle?.addEventListener("click", toggleChatRail);
|
||
newChatButton?.addEventListener("click", () => createChat(true));
|
||
negotiationCloseButton?.addEventListener("click", closeNegotiationPanel);
|
||
negotiationForm?.addEventListener("submit", submitNegotiationMessage);
|
||
updateModalClose?.addEventListener("click", closeUpdatePrompt);
|
||
updateModalReleases?.addEventListener("click", openReleasesPage);
|
||
updateModalInstall?.addEventListener("click", installUpdate);
|
||
|
||
async function sendMessage() {
|
||
const message = input.value.trim();
|
||
const attachedImages = composerImages.map(({ name, content_type, image_data, preview_url }) => ({
|
||
name,
|
||
content_type,
|
||
image_data,
|
||
preview_url,
|
||
}));
|
||
if ((!message && !attachedImages.length) || input.disabled) return;
|
||
const healthy = await checkHealth();
|
||
if (!healthy) {
|
||
addMessage("assistant warning-message", "The active model provider needs attention before chat can continue. Open the model provider tab and press the pulsing action button, then try again.");
|
||
return;
|
||
}
|
||
input.value = "";
|
||
clearComposerImages();
|
||
input.disabled = true;
|
||
addMessage("user", message, { images: attachedImages });
|
||
const assistantNode = addMessage("assistant streaming", "");
|
||
ensureStreamingChrome(assistantNode);
|
||
let assistantText = "";
|
||
const thinkParser = createThinkTagParser(assistantNode);
|
||
statusEl.textContent = "Working";
|
||
setMessageActivity(assistantNode, "Thinking", true);
|
||
setMessageMetrics(assistantNode, "");
|
||
try {
|
||
const response = await fetch("/api/chat/stream", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ message, thread_id: currentThreadId, images: attachedImages }),
|
||
});
|
||
if (!response.ok || !response.body) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = "";
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const events = buffer.split("\n\n");
|
||
buffer = events.pop() || "";
|
||
for (const rawEvent of events) {
|
||
const line = rawEvent.split("\n").find((entry) => entry.startsWith("data: "));
|
||
if (!line) continue;
|
||
const event = JSON.parse(line.slice(6));
|
||
if (event.type === "status") {
|
||
setMessageActivity(assistantNode, event.message, true);
|
||
} else if (event.type === "reasoning") {
|
||
appendThinkingText(assistantNode, event.content || "");
|
||
} else if (event.type === "metrics") {
|
||
setMessageMetrics(assistantNode, formatMetrics(event));
|
||
} else if (event.type === "warning") {
|
||
setWarning(event.message);
|
||
assistantText += event.message;
|
||
setMessageMarkdown(assistantNode, assistantText);
|
||
} else if (event.type === "token") {
|
||
const visibleContent = thinkParser.consume(event.content);
|
||
if (visibleContent) {
|
||
assistantText += visibleContent;
|
||
setMessageMarkdown(assistantNode, assistantText);
|
||
}
|
||
messages.scrollTop = messages.scrollHeight;
|
||
} else if (event.type === "done") {
|
||
if (event.thread_id) currentThreadId = event.thread_id;
|
||
const visibleContent = thinkParser.flush();
|
||
if (visibleContent) {
|
||
assistantText += visibleContent;
|
||
setMessageMarkdown(assistantNode, assistantText);
|
||
}
|
||
renderPending(event.pending_actions || []);
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
const message = error.message.includes("503")
|
||
? "The active model provider needs attention before chat can continue. Open the model provider tab and press the pulsing action button, then try again."
|
||
: `Chat failed: ${error.message}`;
|
||
setWarning(message);
|
||
setMessageMarkdown(assistantNode, message);
|
||
} finally {
|
||
assistantNode.classList.remove("streaming");
|
||
input.disabled = false;
|
||
input.focus();
|
||
statusEl.textContent = "Ready";
|
||
finishThinking(assistantNode);
|
||
setMessageActivity(assistantNode, "");
|
||
await refreshChats();
|
||
}
|
||
}
|
||
|
||
refreshPending();
|
||
refreshMemory();
|
||
refreshPlans();
|
||
refreshConfig();
|
||
refreshOllamaStatus();
|
||
refreshChats().then(() => loadChatMessages(currentThreadId));
|
||
refreshInbox();
|
||
checkForUpdate(true);
|
||
pollNotifications();
|
||
checkHealth();
|
||
setInterval(checkHealth, 30000);
|
||
setInterval(pollNotifications, 15000);
|