963 lines
34 KiB
JavaScript
963 lines
34 KiB
JavaScript
const form = document.getElementById("chat-form");
|
|
const input = document.getElementById("message-input");
|
|
const messages = document.getElementById("messages");
|
|
const statusEl = document.getElementById("status");
|
|
const pendingEl = document.getElementById("pending-actions");
|
|
const warningEl = document.getElementById("warning");
|
|
const memoryInspectorEl = document.getElementById("memory-inspector");
|
|
const memoryRefreshButton = document.getElementById("memory-refresh");
|
|
const memoryClearButton = document.getElementById("memory-clear");
|
|
const configForm = document.getElementById("config-form");
|
|
const configRefreshButton = document.getElementById("config-refresh");
|
|
const configStatusEl = document.getElementById("config-status");
|
|
const configPathsEl = document.getElementById("config-paths");
|
|
const settingsToggle = document.getElementById("settings-toggle");
|
|
const memoryToggle = document.getElementById("memory-toggle");
|
|
const ollamaToggle = document.getElementById("ollama-toggle");
|
|
const settingsPanel = document.getElementById("settings-panel");
|
|
const memoryPanel = document.getElementById("memory-panel");
|
|
const ollamaPanel = document.getElementById("ollama-panel");
|
|
const ollamaForm = document.getElementById("ollama-config-form");
|
|
const ollamaRefreshButton = document.getElementById("ollama-refresh");
|
|
const ollamaDownloadButton = document.getElementById("ollama-download");
|
|
const ollamaInstallButton = document.getElementById("ollama-install");
|
|
const ollamaLaunchButton = document.getElementById("ollama-launch");
|
|
const ollamaPullButton = document.getElementById("ollama-pull");
|
|
const ollamaStatusEl = document.getElementById("ollama-status");
|
|
const ollamaMessageEl = document.getElementById("ollama-message");
|
|
const updateCheckButton = document.getElementById("update-check");
|
|
const updateInstallButton = document.getElementById("update-install");
|
|
const updateOpenReleasesButton = document.getElementById("update-open-releases");
|
|
const updateStatusEl = document.getElementById("update-status");
|
|
|
|
let ollamaOnline = true;
|
|
let latestUpdate = null;
|
|
|
|
if (window.lucide) {
|
|
window.lucide.createIcons();
|
|
}
|
|
|
|
function addMessage(role, text) {
|
|
const node = document.createElement("div");
|
|
node.className = `message ${role}`;
|
|
setMessageMarkdown(node, text);
|
|
messages.appendChild(node);
|
|
messages.scrollTop = messages.scrollHeight;
|
|
return node;
|
|
}
|
|
|
|
function setMessageMarkdown(node, text) {
|
|
const body = node.querySelector(".message-body") || node;
|
|
body.innerHTML = renderMarkdown(text);
|
|
}
|
|
|
|
function setMessageActivity(node, text, active = false) {
|
|
const activity = node.querySelector(".message-activity");
|
|
if (!activity) return;
|
|
if (text) appendThinkingStep(node, reasoningSummaryForStatus(text), { fallback: true });
|
|
const phase = activity.querySelector(".message-phase");
|
|
phase.innerHTML = "";
|
|
if (text) {
|
|
const label = document.createElement("span");
|
|
label.textContent = text;
|
|
phase.appendChild(label);
|
|
}
|
|
if (active) {
|
|
const dots = document.createElement("span");
|
|
dots.className = "working-dots";
|
|
dots.innerHTML = "<i></i><i></i><i></i>";
|
|
phase.appendChild(dots);
|
|
}
|
|
}
|
|
|
|
function setMessageMetrics(node, metrics) {
|
|
const metricsEl = node.querySelector(".message-metrics");
|
|
if (!metricsEl) return;
|
|
metricsEl.textContent = metrics || "";
|
|
}
|
|
|
|
function appendThinkingStep(node, text, options = {}) {
|
|
const steps = node.querySelector(".thinking-steps");
|
|
if (!steps || !text) return;
|
|
const previous = steps.lastElementChild?.textContent;
|
|
if (previous === text) return;
|
|
const item = document.createElement("li");
|
|
if (options.fallback) item.dataset.fallback = "true";
|
|
item.textContent = text;
|
|
steps.appendChild(item);
|
|
}
|
|
|
|
function appendThinkingText(node, text) {
|
|
const steps = node.querySelector(".thinking-steps");
|
|
if (!steps || !text) return;
|
|
node.querySelectorAll(".thinking-steps [data-fallback='true']").forEach((item) => item.remove());
|
|
node.dataset.hasModelThinking = "true";
|
|
let item = steps.querySelector(".thinking-raw-step");
|
|
if (!item) {
|
|
item = document.createElement("li");
|
|
item.className = "thinking-raw-step";
|
|
steps.appendChild(item);
|
|
}
|
|
item.textContent += text;
|
|
}
|
|
|
|
function createThinkTagParser(node) {
|
|
let buffer = "";
|
|
let inThinking = false;
|
|
|
|
const partialTagLength = (text) => {
|
|
const lower = text.toLowerCase();
|
|
const tags = ["<think>", "</think>"];
|
|
for (const tag of tags) {
|
|
for (let length = tag.length - 1; length > 0; length -= 1) {
|
|
if (lower.endsWith(tag.slice(0, length))) return length;
|
|
}
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
const consume = (content, flush = false) => {
|
|
buffer += content;
|
|
let visible = "";
|
|
|
|
while (buffer) {
|
|
const lower = buffer.toLowerCase();
|
|
if (inThinking) {
|
|
const closeIndex = lower.indexOf("</think>");
|
|
if (closeIndex === -1) {
|
|
if (flush) {
|
|
appendThinkingText(node, buffer);
|
|
buffer = "";
|
|
} else {
|
|
const keep = partialTagLength(buffer);
|
|
appendThinkingText(node, buffer.slice(0, buffer.length - keep));
|
|
buffer = buffer.slice(buffer.length - keep);
|
|
}
|
|
break;
|
|
}
|
|
appendThinkingText(node, buffer.slice(0, closeIndex));
|
|
buffer = buffer.slice(closeIndex + "</think>".length);
|
|
inThinking = false;
|
|
continue;
|
|
}
|
|
|
|
const openIndex = lower.indexOf("<think>");
|
|
if (openIndex === -1) {
|
|
const keep = flush ? 0 : partialTagLength(buffer);
|
|
visible += buffer.slice(0, buffer.length - keep);
|
|
buffer = buffer.slice(buffer.length - keep);
|
|
break;
|
|
}
|
|
|
|
visible += buffer.slice(0, openIndex);
|
|
buffer = buffer.slice(openIndex + "<think>".length);
|
|
inThinking = true;
|
|
}
|
|
|
|
return visible;
|
|
};
|
|
|
|
return {
|
|
consume,
|
|
flush: () => consume("", true),
|
|
};
|
|
}
|
|
|
|
function reasoningSummaryForStatus(text) {
|
|
const summaries = {
|
|
Thinking: "Reading your request and deciding whether I need current UEX data, memory, or a draft action before answering.",
|
|
"Searching UEX listings": "Checking current UEX marketplace listings so the answer is grounded in live item data instead of stale memory.",
|
|
"Fetching listing details": "Opening the specific listing details to avoid guessing about price, seller, quantity, or status.",
|
|
"Checking negotiations": "Looking through active negotiations because replies and offers can change what the next move should be.",
|
|
"Reading negotiation messages": "Reading the negotiation thread so any drafted reply matches the actual conversation.",
|
|
"Drafting message for approval": "Preparing the exact message as a pending action because marketplace writes need your approval first.",
|
|
"Drafting listing for approval": "Preparing the listing payload as a pending action so you can review it before anything is posted.",
|
|
"Checking UEX notifications": "Checking notifications for fresh replies or alerts that could change the recommendation.",
|
|
"Writing response": "Turning the gathered context into a concise response with the relevant details and next action.",
|
|
};
|
|
if (summaries[text]) return summaries[text];
|
|
if (text.startsWith("Running ")) {
|
|
return `Using ${text.replace(/^Running\s+/, "")} to gather the missing context before answering.`;
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function finishThinking(node) {
|
|
const thinking = node.querySelector(".thinking-log");
|
|
const label = node.querySelector(".thinking-summary-label");
|
|
if (!thinking || !label) return;
|
|
const startedAt = Number(thinking.dataset.startedAt || Date.now());
|
|
const elapsedSeconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
|
label.textContent = `Thought for ${elapsedSeconds}s`;
|
|
thinking.classList.remove("thinking-active");
|
|
}
|
|
|
|
function ensureStreamingChrome(node) {
|
|
if (node.querySelector(".message-activity")) return;
|
|
node.innerHTML = "";
|
|
const activity = document.createElement("div");
|
|
activity.className = "message-activity";
|
|
const phase = document.createElement("span");
|
|
phase.className = "message-phase";
|
|
const metrics = document.createElement("span");
|
|
metrics.className = "message-metrics";
|
|
const thinking = document.createElement("details");
|
|
thinking.className = "thinking-log";
|
|
thinking.classList.add("thinking-active");
|
|
thinking.dataset.startedAt = String(Date.now());
|
|
const thinkingSummary = document.createElement("summary");
|
|
const thinkingLabel = document.createElement("span");
|
|
thinkingLabel.className = "thinking-summary-label";
|
|
thinkingLabel.textContent = "Thinking...";
|
|
const thinkingSteps = document.createElement("ol");
|
|
thinkingSteps.className = "thinking-steps";
|
|
const body = document.createElement("div");
|
|
body.className = "message-body";
|
|
activity.append(phase, metrics);
|
|
thinkingSummary.appendChild(thinkingLabel);
|
|
thinking.append(thinkingSummary, thinkingSteps);
|
|
node.append(activity, thinking, body);
|
|
}
|
|
|
|
function renderMarkdown(text) {
|
|
const lines = text.replace(/\r\n/g, "\n").split(/\n/);
|
|
const output = [];
|
|
let inList = false;
|
|
let inOrderedList = false;
|
|
let inCode = false;
|
|
let codeLines = [];
|
|
|
|
const closeLists = () => {
|
|
if (inList) {
|
|
output.push("</ul>");
|
|
inList = false;
|
|
}
|
|
if (inOrderedList) {
|
|
output.push("</ol>");
|
|
inOrderedList = false;
|
|
}
|
|
};
|
|
|
|
const flushCode = () => {
|
|
output.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
|
codeLines = [];
|
|
inCode = false;
|
|
};
|
|
|
|
const isTableAt = (index) => {
|
|
if (index + 1 >= lines.length) return false;
|
|
return isTableRow(lines[index]) && isTableDivider(lines[index + 1]);
|
|
};
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const line = lines[index];
|
|
const trimmed = line.trim();
|
|
|
|
if (/^```/.test(trimmed)) {
|
|
if (inCode) {
|
|
flushCode();
|
|
} else {
|
|
closeLists();
|
|
inCode = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (inCode) {
|
|
codeLines.push(line);
|
|
continue;
|
|
}
|
|
|
|
if (isTableAt(index)) {
|
|
closeLists();
|
|
const headers = splitTableRow(lines[index]);
|
|
const aligns = splitTableRow(lines[index + 1]).map((cell) => tableAlignment(cell));
|
|
const rows = [];
|
|
index += 2;
|
|
while (index < lines.length && isTableRow(lines[index])) {
|
|
rows.push(splitTableRow(lines[index]));
|
|
index += 1;
|
|
}
|
|
index -= 1;
|
|
output.push(renderTable(headers, aligns, rows));
|
|
continue;
|
|
}
|
|
|
|
const unorderedItem = line.match(/^(\s*)[-*+]\s+(.+)$/);
|
|
if (unorderedItem) {
|
|
if (inOrderedList) {
|
|
output.push("</ol>");
|
|
inOrderedList = false;
|
|
}
|
|
if (!inList) {
|
|
output.push("<ul>");
|
|
inList = true;
|
|
}
|
|
const nestedClass = unorderedItem[1].length >= 2 ? ' class="nested"' : "";
|
|
output.push(`<li${nestedClass}>${inlineMarkdown(unorderedItem[2])}</li>`);
|
|
continue;
|
|
}
|
|
|
|
const orderedItem = line.match(/^(\s*)\d+\.\s+(.+)$/);
|
|
if (orderedItem) {
|
|
if (inList) {
|
|
output.push("</ul>");
|
|
inList = false;
|
|
}
|
|
if (!inOrderedList) {
|
|
output.push("<ol>");
|
|
inOrderedList = true;
|
|
}
|
|
const nestedClass = orderedItem[1].length >= 2 ? ' class="nested"' : "";
|
|
output.push(`<li${nestedClass}>${inlineMarkdown(orderedItem[2])}</li>`);
|
|
continue;
|
|
}
|
|
|
|
closeLists();
|
|
|
|
if (/^>\s?/.test(trimmed)) {
|
|
output.push(`<blockquote>${inlineMarkdown(trimmed.replace(/^>\s?/, ""))}</blockquote>`);
|
|
} else if (/^---+$/.test(trimmed)) {
|
|
output.push("<hr>");
|
|
} else if (/^#{1,6}\s+/.test(trimmed)) {
|
|
const level = Math.min(4, trimmed.match(/^#+/)[0].length + 2);
|
|
output.push(`<h${level}>${inlineMarkdown(trimmed.replace(/^#{1,6}\s+/, ""))}</h${level}>`);
|
|
} else if (trimmed) {
|
|
output.push(`<p>${inlineMarkdown(line)}</p>`);
|
|
} else {
|
|
output.push("<br>");
|
|
}
|
|
}
|
|
|
|
if (inCode) flushCode();
|
|
closeLists();
|
|
return output.join("");
|
|
}
|
|
|
|
function inlineMarkdown(text) {
|
|
return escapeHtml(text)
|
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
|
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
|
|
}
|
|
|
|
function 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 formatMetrics(event) {
|
|
const read = formatTokenMetric(event.reading_tokens, event.reading_tokens_per_second);
|
|
const wrote = formatTokenMetric(event.writing_tokens, event.writing_tokens_per_second);
|
|
return [read && `read ${read}`, wrote && `wrote ${wrote}`].filter(Boolean).join(" | ");
|
|
}
|
|
|
|
function formatTokenMetric(tokens, speed) {
|
|
if (!tokens) return "";
|
|
const speedText = typeof speed === "number" ? ` @ ${speed.toFixed(1)}/s` : "";
|
|
return `${tokens} tok${speedText}`;
|
|
}
|
|
|
|
function setWarning(text) {
|
|
warningEl.hidden = !text;
|
|
warningEl.textContent = text || "";
|
|
}
|
|
|
|
function fetchErrorMessage(error) {
|
|
if (error instanceof TypeError && /fetch/i.test(error.message)) {
|
|
return "TraderAI backend is not reachable. Close this app window and launch TraderAI.exe again.";
|
|
}
|
|
return error.message;
|
|
}
|
|
|
|
const configFieldIds = {
|
|
ollama_base_url: "config-ollama-base-url",
|
|
ollama_model: "config-ollama-model",
|
|
ollama_num_ctx: "config-ollama-num-ctx",
|
|
uex_base_url: "config-uex-base-url",
|
|
uex_secret_key: "config-uex-secret-key",
|
|
uex_bearer_token: "config-uex-bearer-token",
|
|
traderai_user_name: "config-traderai-user-name",
|
|
traderai_memory_path: "config-traderai-memory-path",
|
|
uex_notification_poll_seconds: "config-uex-notification-poll-seconds",
|
|
require_write_approval: "config-require-write-approval",
|
|
};
|
|
|
|
const ollamaFieldIds = {
|
|
ollama_base_url: "ollama-base-url",
|
|
ollama_model: "ollama-model",
|
|
ollama_num_ctx: "ollama-num-ctx",
|
|
};
|
|
|
|
async function refreshConfig() {
|
|
try {
|
|
const response = await fetch("/api/config");
|
|
const config = await response.json();
|
|
renderConfig(config);
|
|
} catch (error) {
|
|
configStatusEl.textContent = `Config load failed: ${fetchErrorMessage(error)}`;
|
|
}
|
|
}
|
|
|
|
function renderConfig(config) {
|
|
const values = config.values || {};
|
|
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 {
|
|
field.value = values[key] ?? "";
|
|
}
|
|
}
|
|
for (const [key, id] of Object.entries(ollamaFieldIds)) {
|
|
const field = document.getElementById(id);
|
|
if (!field) continue;
|
|
field.value = values[key] ?? "";
|
|
}
|
|
configPathsEl.textContent = `App data: ${config.app_data_dir}\nConfig: ${config.config_path}\nLog: ${config.log_path}\nEdge profile: ${config.edge_profile_dir}`;
|
|
configStatusEl.textContent = "";
|
|
}
|
|
|
|
async function saveConfig(event) {
|
|
event.preventDefault();
|
|
const values = {};
|
|
for (const [key, id] of Object.entries(configFieldIds)) {
|
|
const field = document.getElementById(id);
|
|
if (!field) continue;
|
|
values[key] = field.type === "checkbox" ? field.checked : field.value;
|
|
}
|
|
configStatusEl.textContent = "Saving";
|
|
try {
|
|
const response = await fetch("/api/config", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ values }),
|
|
});
|
|
const result = await response.json();
|
|
renderConfig(result);
|
|
configStatusEl.textContent = result.message || "Saved";
|
|
addMessage("assistant", "Config saved. Restart TraderAI for the new settings to fully apply.");
|
|
} catch (error) {
|
|
configStatusEl.textContent = `Config save failed: ${fetchErrorMessage(error)}`;
|
|
}
|
|
}
|
|
|
|
async function saveOllamaConfig(event) {
|
|
event.preventDefault();
|
|
const values = {};
|
|
for (const [key, id] of Object.entries(ollamaFieldIds)) {
|
|
const field = document.getElementById(id);
|
|
if (!field) continue;
|
|
values[key] = field.value;
|
|
}
|
|
setOllamaMessage("Saving Ollama config");
|
|
try {
|
|
const response = await fetch("/api/config", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ values }),
|
|
});
|
|
const result = await response.json();
|
|
renderConfig(result);
|
|
setOllamaMessage(result.message || "Saved");
|
|
await refreshOllamaStatus();
|
|
} catch (error) {
|
|
setOllamaMessage(`Ollama config save failed: ${fetchErrorMessage(error)}`);
|
|
}
|
|
}
|
|
|
|
async function refreshOllamaStatus() {
|
|
if (!ollamaStatusEl) return;
|
|
ollamaStatusEl.textContent = "Checking Ollama";
|
|
try {
|
|
const response = await fetch("/api/ollama/status");
|
|
const status = await response.json();
|
|
renderOllamaStatus(status);
|
|
} catch (error) {
|
|
ollamaStatusEl.textContent = `Ollama status failed: ${error.message}`;
|
|
}
|
|
}
|
|
|
|
function renderOllamaStatus(status) {
|
|
if (!ollamaStatusEl) return;
|
|
const models = status.models?.length ? status.models.join(", ") : "None detected";
|
|
const pillClass = status.installed && status.running && status.model_available ? "status-pill" : "status-pill warning";
|
|
ollamaStatusEl.innerHTML = `
|
|
<div class="${pillClass}">${escapeHtml(status.message || "Unknown")}</div>
|
|
<div class="ollama-status-grid">
|
|
${ollamaStatusItem("Installed", status.installed ? "Yes" : "No")}
|
|
${ollamaStatusItem("Running", status.running ? "Yes" : "No")}
|
|
${ollamaStatusItem("Model", status.configured_model || "")}
|
|
${ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No")}
|
|
${ollamaStatusItem("URL", status.base_url || "")}
|
|
${ollamaStatusItem("Auto Install", status.can_auto_install ? "Available" : "Unavailable")}
|
|
</div>
|
|
${ollamaStatusItem("Installed Models", models)}
|
|
${status.detail ? ollamaStatusItem("Detail", status.detail) : ""}
|
|
`;
|
|
if (ollamaInstallButton) ollamaInstallButton.disabled = Boolean(status.installed);
|
|
if (ollamaLaunchButton) ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
|
|
if (ollamaPullButton) ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
|
|
}
|
|
|
|
function ollamaStatusItem(label, value) {
|
|
return `<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 configuredOllamaModel() {
|
|
return document.getElementById("ollama-model")?.value || document.getElementById("config-ollama-model")?.value || "";
|
|
}
|
|
|
|
async function checkForUpdate() {
|
|
if (!updateStatusEl) return;
|
|
updateStatusEl.textContent = "Checking releases";
|
|
try {
|
|
const response = await fetch("/api/update/check");
|
|
const result = await response.json();
|
|
latestUpdate = result;
|
|
renderUpdateStatus(result);
|
|
} catch (error) {
|
|
updateStatusEl.textContent = `Update check failed: ${error.message}`;
|
|
if (updateInstallButton) updateInstallButton.disabled = true;
|
|
}
|
|
}
|
|
|
|
function renderUpdateStatus(update) {
|
|
if (!updateStatusEl) return;
|
|
const lines = [
|
|
`Current: ${update.current_version || "unknown"}`,
|
|
`Latest: ${update.latest_version || "unknown"}`,
|
|
update.message || "",
|
|
].filter(Boolean);
|
|
if (update.available && !update.asset_download_url) {
|
|
lines.push("The release needs a TraderAI.exe attachment before the app can self-update.");
|
|
}
|
|
if (update.available && update.asset_download_url && !update.packaged) {
|
|
lines.push("Self-update runs from the packaged desktop exe.");
|
|
}
|
|
updateStatusEl.textContent = lines.join("\n");
|
|
if (updateInstallButton) {
|
|
updateInstallButton.disabled = !update.available || !update.asset_download_url || !update.packaged;
|
|
}
|
|
}
|
|
|
|
async function installUpdate() {
|
|
if (!updateStatusEl) return;
|
|
updateStatusEl.textContent = "Downloading update";
|
|
try {
|
|
const response = await fetch("/api/update/install", { method: "POST" });
|
|
const result = await response.json();
|
|
latestUpdate = result;
|
|
renderUpdateStatus(result);
|
|
} catch (error) {
|
|
updateStatusEl.textContent = `Update failed: ${error.message}`;
|
|
}
|
|
}
|
|
|
|
function openReleasesPage() {
|
|
const url = latestUpdate?.release_url || "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases";
|
|
window.open(url, "_blank", "noreferrer");
|
|
}
|
|
|
|
function toggleSidebarPanel(panelName) {
|
|
const panels = {
|
|
settings: { panel: settingsPanel, button: settingsToggle },
|
|
memory: { panel: memoryPanel, button: memoryToggle },
|
|
ollama: { panel: ollamaPanel, button: ollamaToggle },
|
|
};
|
|
const target = panels[panelName];
|
|
if (!target?.panel || !target?.button) return;
|
|
const shouldOpen = target.panel.hidden;
|
|
for (const item of Object.values(panels)) {
|
|
if (!item.panel || !item.button) continue;
|
|
item.panel.hidden = true;
|
|
item.button.classList.remove("active");
|
|
item.button.setAttribute("aria-expanded", "false");
|
|
}
|
|
if (shouldOpen) {
|
|
target.panel.hidden = false;
|
|
target.button.classList.add("active");
|
|
target.button.setAttribute("aria-expanded", "true");
|
|
if (panelName === "settings") {
|
|
refreshConfig();
|
|
checkForUpdate();
|
|
}
|
|
if (panelName === "memory") refreshMemory();
|
|
if (panelName === "ollama") {
|
|
refreshConfig();
|
|
refreshOllamaStatus();
|
|
}
|
|
}
|
|
}
|
|
|
|
async function checkHealth() {
|
|
try {
|
|
const response = await fetch("/api/health");
|
|
const result = await response.json();
|
|
const health = result.ollama || {};
|
|
ollamaOnline = Boolean(health.online);
|
|
if (!ollamaOnline) {
|
|
statusEl.textContent = "Offline";
|
|
setWarning(health.message || "Ollama is offline. Start Ollama before chatting.");
|
|
return false;
|
|
}
|
|
if (health.model_available === false) {
|
|
setWarning(`Ollama is online, but model "${health.model}" is not pulled. Run: ollama pull ${health.model}`);
|
|
} else {
|
|
setWarning("");
|
|
}
|
|
statusEl.textContent = "Ready";
|
|
return true;
|
|
} catch (error) {
|
|
ollamaOnline = false;
|
|
statusEl.textContent = "Offline";
|
|
setWarning(`Could not check Ollama health: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function renderPending(actions) {
|
|
pendingEl.innerHTML = "";
|
|
if (!actions.length) {
|
|
pendingEl.className = "pending-empty";
|
|
pendingEl.textContent = "No pending actions";
|
|
return;
|
|
}
|
|
pendingEl.className = "";
|
|
for (const action of actions) {
|
|
const card = document.createElement("div");
|
|
card.className = "pending-card";
|
|
const title = document.createElement("strong");
|
|
title.textContent = action.label;
|
|
const endpoint = document.createElement("p");
|
|
endpoint.className = "muted";
|
|
endpoint.textContent = action.endpoint;
|
|
const payload = document.createElement("pre");
|
|
payload.textContent = JSON.stringify(action.payload, null, 2);
|
|
const approve = document.createElement("button");
|
|
approve.textContent = "Approve";
|
|
approve.addEventListener("click", () => approveAction(action.id));
|
|
const decline = document.createElement("button");
|
|
decline.className = "decline-button";
|
|
decline.textContent = "Decline";
|
|
decline.addEventListener("click", () => declineAction(action.id));
|
|
const controls = document.createElement("div");
|
|
controls.className = "pending-controls";
|
|
controls.append(decline, approve);
|
|
card.append(title, endpoint, payload, controls);
|
|
pendingEl.appendChild(card);
|
|
}
|
|
}
|
|
|
|
async function approveAction(id) {
|
|
statusEl.textContent = "Sending";
|
|
try {
|
|
const response = await fetch(`/api/approve/${id}`, { method: "POST" });
|
|
const result = await response.json();
|
|
addMessage("assistant", `Approval result:\n${JSON.stringify(result, null, 2)}`);
|
|
await refreshPending();
|
|
} catch (error) {
|
|
addMessage("assistant", `Approval failed: ${error.message}`);
|
|
} finally {
|
|
statusEl.textContent = "Ready";
|
|
}
|
|
}
|
|
|
|
async function declineAction(id) {
|
|
statusEl.textContent = "Declining";
|
|
try {
|
|
const response = await fetch(`/api/decline/${id}`, { method: "POST" });
|
|
const result = await response.json();
|
|
if (result.error) {
|
|
addMessage("assistant warning-message", `Decline failed: ${result.error}`);
|
|
} else {
|
|
addMessage("assistant", `Declined pending action: ${result.pending_action?.label || id}`);
|
|
}
|
|
await refreshPending();
|
|
} catch (error) {
|
|
addMessage("assistant warning-message", `Decline failed: ${error.message}`);
|
|
} finally {
|
|
statusEl.textContent = "Ready";
|
|
}
|
|
}
|
|
|
|
async function refreshPending() {
|
|
const response = await fetch("/api/pending-actions");
|
|
const result = await response.json();
|
|
renderPending(result.pending_actions || []);
|
|
}
|
|
|
|
function renderMemory(data) {
|
|
memoryInspectorEl.innerHTML = "";
|
|
const counts = document.createElement("div");
|
|
counts.className = "memory-counts";
|
|
counts.textContent = `${data.memories.length} memories | ${data.conversations.length} chat rows | ${data.profile.length} profile keys | ${data.scheduled_jobs.length} jobs`;
|
|
memoryInspectorEl.appendChild(counts);
|
|
|
|
const path = document.createElement("div");
|
|
path.className = "memory-path";
|
|
path.textContent = data.path;
|
|
memoryInspectorEl.appendChild(path);
|
|
|
|
memoryInspectorEl.appendChild(memoryGroup("Memories", data.memories, (item) => `${item.kind} (${item.importance})\n${item.content}`));
|
|
memoryInspectorEl.appendChild(memoryGroup("Profile", data.profile, (item) => `${item.key}\n${JSON.stringify(item.value, null, 2)}`));
|
|
memoryInspectorEl.appendChild(memoryGroup("Recent Chat", data.conversations, (item) => `${item.created_at} ${item.role}\n${item.content}`));
|
|
memoryInspectorEl.appendChild(memoryGroup("Wake Jobs", data.scheduled_jobs, (item) => `${item.id}\n${item.trigger_type}: ${item.trigger_value}\n${item.prompt}`));
|
|
}
|
|
|
|
function memoryGroup(title, items, formatter) {
|
|
const group = document.createElement("details");
|
|
group.className = "memory-group";
|
|
const summary = document.createElement("summary");
|
|
summary.textContent = `${title} (${items.length})`;
|
|
group.appendChild(summary);
|
|
if (!items.length) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "muted";
|
|
empty.textContent = "Empty";
|
|
group.appendChild(empty);
|
|
return group;
|
|
}
|
|
for (const item of items.slice(0, 20)) {
|
|
const entry = document.createElement("pre");
|
|
entry.textContent = formatter(item);
|
|
group.appendChild(entry);
|
|
}
|
|
return group;
|
|
}
|
|
|
|
async function refreshMemory() {
|
|
try {
|
|
const response = await fetch("/api/memory?limit=50");
|
|
const data = await response.json();
|
|
renderMemory(data);
|
|
} catch (error) {
|
|
memoryInspectorEl.textContent = `Memory load failed: ${error.message}`;
|
|
}
|
|
}
|
|
|
|
async function clearMemory() {
|
|
const payload = {
|
|
include_memories: document.getElementById("clear-memories").checked,
|
|
include_conversations: document.getElementById("clear-conversations").checked,
|
|
include_profile: document.getElementById("clear-profile").checked,
|
|
include_jobs: document.getElementById("clear-jobs").checked,
|
|
include_outbox: document.getElementById("clear-outbox").checked,
|
|
};
|
|
if (!Object.values(payload).some(Boolean)) return;
|
|
const confirmed = window.confirm("Clear the selected TraderAI memory? This cannot be undone.");
|
|
if (!confirmed) return;
|
|
try {
|
|
const response = await fetch("/api/memory/clear", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const result = await response.json();
|
|
renderMemory(result.memory);
|
|
addMessage("assistant", `Memory cleared:\n${JSON.stringify(result.deleted, null, 2)}`);
|
|
} catch (error) {
|
|
addMessage("assistant warning-message", `Memory clear failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function pollNotifications() {
|
|
try {
|
|
const response = await fetch("/api/notifications");
|
|
const result = await response.json();
|
|
for (const notification of result.notifications || []) {
|
|
addMessage("assistant", notification.content);
|
|
}
|
|
} catch {
|
|
// Notification polling should never interrupt chat.
|
|
}
|
|
}
|
|
|
|
form.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
await sendMessage();
|
|
});
|
|
|
|
input.addEventListener("keydown", async (event) => {
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
await sendMessage();
|
|
}
|
|
});
|
|
|
|
memoryRefreshButton?.addEventListener("click", refreshMemory);
|
|
memoryClearButton?.addEventListener("click", clearMemory);
|
|
configRefreshButton?.addEventListener("click", refreshConfig);
|
|
configForm?.addEventListener("submit", saveConfig);
|
|
settingsToggle?.addEventListener("click", () => toggleSidebarPanel("settings"));
|
|
memoryToggle?.addEventListener("click", () => toggleSidebarPanel("memory"));
|
|
ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama"));
|
|
ollamaForm?.addEventListener("submit", saveOllamaConfig);
|
|
ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus);
|
|
ollamaDownloadButton?.addEventListener("click", () => postOllamaAction("/api/ollama/download"));
|
|
ollamaInstallButton?.addEventListener("click", () => postOllamaAction("/api/ollama/install"));
|
|
ollamaLaunchButton?.addEventListener("click", () => postOllamaAction("/api/ollama/launch"));
|
|
ollamaPullButton?.addEventListener("click", () => postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } }));
|
|
updateCheckButton?.addEventListener("click", checkForUpdate);
|
|
updateInstallButton?.addEventListener("click", installUpdate);
|
|
updateOpenReleasesButton?.addEventListener("click", openReleasesPage);
|
|
|
|
async function sendMessage() {
|
|
const message = input.value.trim();
|
|
if (!message || input.disabled) return;
|
|
const healthy = await checkHealth();
|
|
if (!healthy) {
|
|
addMessage("assistant warning-message", "Ollama is offline. Start Ollama, then try again.");
|
|
return;
|
|
}
|
|
input.value = "";
|
|
input.disabled = true;
|
|
addMessage("user", message);
|
|
const assistantNode = addMessage("assistant streaming", "");
|
|
ensureStreamingChrome(assistantNode);
|
|
let assistantText = "";
|
|
const thinkParser = createThinkTagParser(assistantNode);
|
|
statusEl.textContent = "Working";
|
|
setMessageActivity(assistantNode, "Thinking", true);
|
|
setMessageMetrics(assistantNode, "");
|
|
try {
|
|
const response = await fetch("/api/chat/stream", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ message }),
|
|
});
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const events = buffer.split("\n\n");
|
|
buffer = events.pop() || "";
|
|
for (const rawEvent of events) {
|
|
const line = rawEvent.split("\n").find((entry) => entry.startsWith("data: "));
|
|
if (!line) continue;
|
|
const event = JSON.parse(line.slice(6));
|
|
if (event.type === "status") {
|
|
setMessageActivity(assistantNode, event.message, true);
|
|
} else if (event.type === "metrics") {
|
|
setMessageMetrics(assistantNode, formatMetrics(event));
|
|
} else if (event.type === "warning") {
|
|
setWarning(event.message);
|
|
assistantText += event.message;
|
|
setMessageMarkdown(assistantNode, assistantText);
|
|
} else if (event.type === "token") {
|
|
const visibleContent = thinkParser.consume(event.content);
|
|
if (visibleContent) {
|
|
assistantText += visibleContent;
|
|
setMessageMarkdown(assistantNode, assistantText);
|
|
}
|
|
messages.scrollTop = messages.scrollHeight;
|
|
} else if (event.type === "done") {
|
|
const visibleContent = thinkParser.flush();
|
|
if (visibleContent) {
|
|
assistantText += visibleContent;
|
|
setMessageMarkdown(assistantNode, assistantText);
|
|
}
|
|
renderPending(event.pending_actions || []);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const message = error.message.includes("503")
|
|
? "Ollama is offline or unreachable. Start Ollama, then try again."
|
|
: `Chat failed: ${error.message}`;
|
|
setWarning(message);
|
|
setMessageMarkdown(assistantNode, message);
|
|
} finally {
|
|
assistantNode.classList.remove("streaming");
|
|
input.disabled = false;
|
|
input.focus();
|
|
statusEl.textContent = "Ready";
|
|
finishThinking(assistantNode);
|
|
setMessageActivity(assistantNode, "");
|
|
}
|
|
}
|
|
|
|
addMessage("assistant", "Tell me what to find or draft on UEX. I will ask for approval before sending anything.");
|
|
refreshPending();
|
|
refreshMemory();
|
|
refreshConfig();
|
|
refreshOllamaStatus();
|
|
checkForUpdate();
|
|
pollNotifications();
|
|
checkHealth();
|
|
setInterval(checkHealth, 30000);
|
|
setInterval(pollNotifications, 15000);
|