feat: chat sidebar and inbox, feat: saved chats, fix: wake jobs, fix: sandbox sends, ux: negotiation replies and draft box
This commit is contained in:
+329
-7
@@ -29,9 +29,29 @@ 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");
|
||||
|
||||
let ollamaOnline = true;
|
||||
let latestUpdate = null;
|
||||
let currentThreadId = "default";
|
||||
let currentNegotiationId = null;
|
||||
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons();
|
||||
@@ -49,6 +69,7 @@ function addMessage(role, text) {
|
||||
function setMessageMarkdown(node, text) {
|
||||
const body = node.querySelector(".message-body") || node;
|
||||
body.innerHTML = renderMarkdown(text);
|
||||
enhanceNegotiationLinks(body);
|
||||
}
|
||||
|
||||
function setMessageActivity(node, text, active = false) {
|
||||
@@ -342,6 +363,43 @@ function inlineMarkdown(text) {
|
||||
.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);
|
||||
@@ -576,7 +634,7 @@ function configuredOllamaModel() {
|
||||
return document.getElementById("ollama-model")?.value || "";
|
||||
}
|
||||
|
||||
async function checkForUpdate() {
|
||||
async function checkForUpdate(promptUser = false) {
|
||||
if (!updateStatusEl) return;
|
||||
updateStatusEl.textContent = "Checking releases";
|
||||
try {
|
||||
@@ -584,6 +642,7 @@ async function checkForUpdate() {
|
||||
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;
|
||||
@@ -627,6 +686,22 @@ function openReleasesPage() {
|
||||
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 },
|
||||
@@ -658,6 +733,245 @@ function toggleSidebarPanel(panelName) {
|
||||
}
|
||||
}
|
||||
|
||||
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 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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const response = await fetch("/api/health");
|
||||
@@ -833,9 +1147,7 @@ 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);
|
||||
}
|
||||
if ((result.notifications || []).length) await refreshInbox();
|
||||
} catch {
|
||||
// Notification polling should never interrupt chat.
|
||||
}
|
||||
@@ -869,6 +1181,13 @@ ollamaPullButton?.addEventListener("click", () => postOllamaAction("/api/ollama/
|
||||
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();
|
||||
@@ -892,7 +1211,7 @@ async function sendMessage() {
|
||||
const response = await fetch("/api/chat/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message }),
|
||||
body: JSON.stringify({ message, thread_id: currentThreadId }),
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
@@ -926,6 +1245,7 @@ async function sendMessage() {
|
||||
}
|
||||
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;
|
||||
@@ -948,15 +1268,17 @@ async function sendMessage() {
|
||||
statusEl.textContent = "Ready";
|
||||
finishThinking(assistantNode);
|
||||
setMessageActivity(assistantNode, "");
|
||||
await refreshChats();
|
||||
}
|
||||
}
|
||||
|
||||
addMessage("assistant", "Tell me what to find or draft on UEX. I will ask for approval before sending anything.");
|
||||
refreshPending();
|
||||
refreshMemory();
|
||||
refreshConfig();
|
||||
refreshOllamaStatus();
|
||||
checkForUpdate();
|
||||
refreshChats().then(() => loadChatMessages(currentThreadId));
|
||||
refreshInbox();
|
||||
checkForUpdate(true);
|
||||
pollNotifications();
|
||||
checkHealth();
|
||||
setInterval(checkHealth, 30000);
|
||||
|
||||
Reference in New Issue
Block a user