feat: chat

This commit is contained in:
2026-06-09 11:24:15 -04:00
parent 454bb57484
commit 8fac3d2bae
15 changed files with 2015 additions and 38 deletions
+305 -10
View File
@@ -15,6 +15,7 @@ const configPathsEl = document.getElementById("config-paths");
const settingsToggle = document.getElementById("settings-toggle");
const memoryToggle = document.getElementById("memory-toggle");
const plansToggle = document.getElementById("plans-toggle");
const negotiationsToggle = document.getElementById("negotiations-toggle");
const ollamaToggle = document.getElementById("ollama-toggle");
const settingsPanel = document.getElementById("settings-panel");
const memoryPanel = document.getElementById("memory-panel");
@@ -48,6 +49,32 @@ const negotiationForm = document.getElementById("negotiation-form");
const negotiationInput = document.getElementById("negotiation-input");
const negotiationStatusEl = document.getElementById("negotiation-status");
const negotiationCloseButton = document.getElementById("negotiation-close");
const negotiationListEl = document.getElementById("negotiation-list");
const negotiationsRefreshAllButton = document.getElementById("negotiations-refresh-all");
const negotiationPanelListEl = document.getElementById("negotiation-panel-list");
const negotiationSearchEl = document.getElementById("negotiation-search");
const negotiationFilterEl = document.getElementById("negotiation-filter");
const negotiationThreadHeaderEl = document.getElementById("negotiation-thread-header");
const negotiationMetaCardEl = document.getElementById("negotiation-meta-card");
const negotiationUserCardEl = document.getElementById("negotiation-user-card");
const negotiationRefreshButton = document.getElementById("negotiation-refresh-button");
const negotiationDraftButton = document.getElementById("negotiation-draft-button");
const negotiationOpenChatButton = document.getElementById("negotiation-open-chat");
const negotiationEndDealButton = document.getElementById("negotiation-end-deal");
const negotiationSyncPillEl = document.getElementById("negotiation-sync-pill");
const negotiationCloseModal = document.getElementById("negotiation-close-modal");
const negotiationCloseModalClose = document.getElementById("negotiation-close-modal-close");
const negotiationCloseForm = document.getElementById("negotiation-close-form");
const negotiationCloseStatusEl = document.getElementById("negotiation-close-status");
const closeDealClosedEl = document.getElementById("close-deal-closed");
const closeDealValueEl = document.getElementById("close-deal-value");
const closeCurrencyEl = document.getElementById("close-currency");
const closeClarityEl = document.getElementById("close-clarity");
const closeSpeedEl = document.getElementById("close-speed");
const closeRespectEl = document.getElementById("close-respect");
const closeFairnessEl = document.getElementById("close-fairness");
const closeCommentEl = document.getElementById("close-comment");
const closeDraftButton = document.getElementById("close-draft-button");
const updateModal = document.getElementById("update-modal");
const updateModalCopy = document.getElementById("update-modal-copy");
const updateModalClose = document.getElementById("update-modal-close");
@@ -66,6 +93,7 @@ let ollamaOnline = true;
let latestUpdate = null;
let currentThreadId = "default";
let currentNegotiationId = null;
let negotiationRows = [];
let latestOllamaStatus = null;
let composerImages = [];
const clickedOllamaActions = new Set();
@@ -1182,16 +1210,114 @@ async function deleteInboxItem(id) {
await refreshInbox();
}
async function refreshNegotiations(preserveCurrent = true) {
const status = negotiationFilterEl?.value || "open";
const search = negotiationSearchEl?.value?.trim() || "";
try {
const response = await fetch(`/api/negotiations?status=${encodeURIComponent(status)}&search=${encodeURIComponent(search)}&limit=100`);
const result = await response.json();
negotiationRows = result.negotiations || [];
renderNegotiationLists(negotiationRows);
if (!preserveCurrent) return;
if (!currentNegotiationId && negotiationRows.length) currentNegotiationId = negotiationRows[0].hash;
if (currentNegotiationId && negotiationRows.some((item) => item.hash === currentNegotiationId) && !negotiationPanel.hidden) {
await loadNegotiationDetail(currentNegotiationId, false);
}
} catch (error) {
const message = `Negotiations failed: ${fetchErrorMessage(error)}`;
if (negotiationListEl) negotiationListEl.textContent = message;
if (negotiationPanelListEl) negotiationPanelListEl.textContent = message;
}
}
async function refreshAllNegotiations() {
const previous = negotiationsRefreshAllButton?.disabled;
if (negotiationsRefreshAllButton) negotiationsRefreshAllButton.disabled = true;
if (negotiationStatusEl) negotiationStatusEl.textContent = "Refreshing all negotiations";
try {
const response = await fetch("/api/negotiations/refresh-all", { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
await refreshNegotiations(true);
if (negotiationStatusEl) {
negotiationStatusEl.textContent = `Refreshed ${result.count || 0} negotiations`;
}
} catch (error) {
if (negotiationStatusEl) negotiationStatusEl.textContent = `Refresh all failed: ${fetchErrorMessage(error)}`;
} finally {
if (negotiationsRefreshAllButton) negotiationsRefreshAllButton.disabled = Boolean(previous);
}
}
function renderNegotiationLists(items) {
renderNegotiationListInto(negotiationListEl, items.slice(0, 8));
renderNegotiationListInto(negotiationPanelListEl, items);
}
function renderNegotiationListInto(container, items) {
if (!container) return;
container.innerHTML = "";
if (!items.length) {
container.innerHTML = '<div class="pending-empty">No negotiations</div>';
return;
}
for (const item of items) {
const row = document.createElement("button");
row.type = "button";
row.className = `negotiation-row${item.hash === currentNegotiationId ? " active" : ""}`;
row.addEventListener("click", () => openNegotiationPanel(item.hash));
const top = document.createElement("div");
top.className = "negotiation-row-top";
const title = document.createElement("div");
title.className = "negotiation-row-title";
title.textContent = item.title || item.counterparty_username || item.hash;
const badge = document.createElement("span");
badge.className = `negotiation-row-badge ${item.status === "closed" ? "closed" : ""}`;
badge.textContent = item.status || "open";
top.append(title, badge);
const meta = document.createElement("div");
meta.className = "negotiation-row-meta";
meta.textContent = [
item.counterparty_username || "Unknown user",
item.last_message_at ? formatShortDate(item.last_message_at) : "No messages",
].join(" • ");
row.append(top, meta);
if (Number(item.unread_count || 0) > 0) {
const unread = document.createElement("span");
unread.className = "negotiation-row-unread";
unread.textContent = String(item.unread_count);
row.appendChild(unread);
}
container.appendChild(row);
}
}
async function openNegotiationPanel(identifier) {
if (!identifier) {
negotiationPanel.hidden = false;
negotiationsToggle?.setAttribute("aria-expanded", "true");
return;
}
currentNegotiationId = identifier;
negotiationPanel.hidden = false;
negotiationTitle.textContent = `Negotiation ${identifier}`;
negotiationsToggle?.setAttribute("aria-expanded", "true");
negotiationStatusEl.textContent = "";
negotiationSyncPillEl.textContent = "Local sync";
await loadNegotiationDetail(identifier, true);
}
async function loadNegotiationDetail(identifier, refreshList = true) {
negotiationTitle.textContent = `Negotiation ${identifier}`;
negotiationMessagesEl.textContent = "Loading";
negotiationThreadHeaderEl.innerHTML = '<div class="muted">Loading local thread...</div>';
negotiationMetaCardEl.innerHTML = "<h3>Deal</h3><div class='muted'>Loading</div>";
negotiationUserCardEl.innerHTML = "<h3>User</h3><div class='muted'>Loading</div>";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(identifier)}/messages`);
const response = await fetch(`/api/negotiations/${encodeURIComponent(identifier)}`);
const result = await response.json();
renderNegotiationMessages(result.data || result.messages || result.notifications || []);
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
renderNegotiationDetail(result.negotiation);
if (refreshList) await refreshNegotiations(false);
} catch (error) {
negotiationMessagesEl.textContent = `Could not load negotiation: ${fetchErrorMessage(error)}`;
}
@@ -1202,6 +1328,7 @@ function closeNegotiationPanel() {
currentNegotiationId = null;
negotiationInput.value = "";
negotiationStatusEl.textContent = "";
negotiationsToggle?.setAttribute("aria-expanded", "false");
}
function openPlansPanel(openPlanId = null) {
@@ -1217,6 +1344,37 @@ function closePlansPanel() {
plansToggle?.setAttribute("aria-expanded", "false");
}
function renderNegotiationDetail(negotiation) {
if (!negotiation) return;
negotiationTitle.textContent = negotiation.title || negotiation.counterparty_username || negotiation.hash;
negotiationSyncPillEl.textContent = negotiation.last_synced_at ? `Synced ${formatShortDate(negotiation.last_synced_at)}` : "Local sync";
negotiationThreadHeaderEl.innerHTML = `
<div><strong>${escapeHtml(negotiation.title || "Negotiation")}</strong></div>
<div class="muted">${escapeHtml(negotiation.counterparty_username || "Unknown user")}${escapeHtml(negotiation.status || "open")}${escapeHtml(negotiation.hash || "")}</div>
`;
renderNegotiationMessages(negotiation.messages || []);
const raw = negotiation.metadata?.raw || {};
negotiationMetaCardEl.innerHTML = `
<h3>Deal</h3>
<div class="negotiation-detail-kv">
<div><strong>Listing</strong> ${escapeHtml(negotiation.title || raw.listing_title || negotiation.hash)}</div>
<div><strong>Status</strong> ${escapeHtml(negotiation.status || "open")}</div>
<div><strong>Slug</strong> ${escapeHtml(negotiation.listing_slug || raw.listing_slug || "Unknown")}</div>
<div><strong>Price</strong> ${escapeHtml(String(raw.price || raw.deal_value || "Unknown"))} ${escapeHtml(String(raw.currency || raw.deal_value_currency || ""))}</div>
<div><strong>Last message</strong> ${escapeHtml(negotiation.last_message_at ? formatShortDate(negotiation.last_message_at) : "Unknown")}</div>
</div>
`;
negotiationUserCardEl.innerHTML = `
<h3>User</h3>
<div class="negotiation-detail-kv">
<div><strong>Counterparty</strong> ${escapeHtml(negotiation.counterparty_username || raw.client_username || raw.advertiser_username || "Unknown")}</div>
<div><strong>Advertiser</strong> ${escapeHtml(String(raw.advertiser_username || raw.advertiser_name || "Unknown"))}</div>
<div><strong>Client</strong> ${escapeHtml(String(raw.client_username || raw.client_name || "Unknown"))}</div>
<div><strong>Unread</strong> ${escapeHtml(String(negotiation.unread_count || 0))}</div>
</div>
`;
}
function renderNegotiationMessages(data) {
negotiationMessagesEl.innerHTML = "";
const items = Array.isArray(data) ? data : [data].filter(Boolean);
@@ -1226,10 +1384,15 @@ function renderNegotiationMessages(data) {
}
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))}`;
card.className = `negotiation-message${item.is_me ? " self" : ""}`;
const author = item.author_username || item.user_username || item.username || item.author || item.sender || "UEX";
const body = item.body || item.message || item.content || item.text || JSON.stringify(item, null, 2);
const meta = document.createElement("div");
meta.className = "negotiation-message-meta";
meta.innerHTML = `<strong>${escapeHtml(String(author))}</strong><span>${escapeHtml(item.sent_at ? formatShortDate(item.sent_at) : "")}</span>`;
const text = document.createElement("div");
text.innerHTML = inlineMarkdown(String(body));
card.append(meta, text);
negotiationMessagesEl.appendChild(card);
}
negotiationMessagesEl.scrollTop = negotiationMessagesEl.scrollHeight;
@@ -1241,7 +1404,7 @@ async function submitNegotiationMessage(event) {
if (!text || !currentNegotiationId) return;
negotiationStatusEl.textContent = "Sending";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages`, {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages/manual`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
@@ -1250,12 +1413,125 @@ async function submitNegotiationMessage(event) {
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
negotiationInput.value = "";
negotiationStatusEl.textContent = result.message || "Sent";
await openNegotiationPanel(currentNegotiationId);
if (result.negotiation) renderNegotiationDetail(result.negotiation);
await refreshNegotiations(false);
} catch (error) {
negotiationStatusEl.textContent = `Send failed: ${fetchErrorMessage(error)}`;
}
}
async function draftNegotiationMessage() {
const text = negotiationInput.value.trim();
if (!text || !currentNegotiationId) return;
negotiationStatusEl.textContent = "Drafting";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages/draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
negotiationStatusEl.textContent = "Draft ready for approval";
await refreshPending();
} catch (error) {
negotiationStatusEl.textContent = `Draft failed: ${fetchErrorMessage(error)}`;
}
}
async function refreshActiveNegotiation() {
if (!currentNegotiationId) return;
negotiationStatusEl.textContent = "Refreshing";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/refresh`, { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
if (result.negotiation) renderNegotiationDetail(result.negotiation);
negotiationStatusEl.textContent = "Refreshed";
await refreshNegotiations(false);
} catch (error) {
negotiationStatusEl.textContent = `Refresh failed: ${fetchErrorMessage(error)}`;
}
}
async function openNegotiationInChat() {
if (!currentNegotiationId) return;
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/open-chat`, { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
if (result.chat?.id) {
currentThreadId = result.chat.id;
await loadChatMessages(currentThreadId);
await refreshChats();
}
} catch (error) {
negotiationStatusEl.textContent = `Open chat failed: ${fetchErrorMessage(error)}`;
}
}
function openNegotiationCloseModal() {
if (!currentNegotiationId) return;
negotiationCloseStatusEl.textContent = "";
negotiationCloseModal.hidden = false;
}
function closeNegotiationCloseModal() {
negotiationCloseModal.hidden = true;
}
function negotiationClosePayload() {
return {
deal_closed: closeDealClosedEl.value !== "false",
deal_value: closeDealValueEl.value ? Number(closeDealValueEl.value) : null,
currency: closeCurrencyEl.value.trim() || null,
clarity_rating: closeClarityEl.value ? Number(closeClarityEl.value) : null,
speed_rating: closeSpeedEl.value ? Number(closeSpeedEl.value) : null,
respect_rating: closeRespectEl.value ? Number(closeRespectEl.value) : null,
fairness_rating: closeFairnessEl.value ? Number(closeFairnessEl.value) : null,
comment: closeCommentEl.value.trim() || null,
};
}
async function submitNegotiationClose(event) {
event.preventDefault();
if (!currentNegotiationId) return;
negotiationCloseStatusEl.textContent = "Submitting";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/close/manual`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(negotiationClosePayload()),
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
negotiationCloseStatusEl.textContent = result.message || "Submitted";
if (result.negotiation) renderNegotiationDetail(result.negotiation);
await refreshNegotiations(false);
closeNegotiationCloseModal();
} catch (error) {
negotiationCloseStatusEl.textContent = `Close failed: ${fetchErrorMessage(error)}`;
}
}
async function draftNegotiationClose() {
if (!currentNegotiationId) return;
negotiationCloseStatusEl.textContent = "Drafting";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/close/draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(negotiationClosePayload()),
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
negotiationCloseStatusEl.textContent = "Draft ready for approval";
await refreshPending();
} catch (error) {
negotiationCloseStatusEl.textContent = `Draft failed: ${fetchErrorMessage(error)}`;
}
}
function parsePlanItems(text) {
return text
.split(/\r?\n/)
@@ -1854,7 +2130,10 @@ async function pollNotifications() {
try {
const response = await fetch("/api/notifications");
const result = await response.json();
if ((result.notifications || []).length) await refreshInbox();
if ((result.notifications || []).length) {
await refreshInbox();
await refreshNegotiations(true);
}
} catch {
// Notification polling should never interrupt chat.
}
@@ -1899,6 +2178,11 @@ plansToggle?.addEventListener("click", () => {
if (plansPanel?.hidden) openPlansPanel();
else closePlansPanel();
});
negotiationsToggle?.addEventListener("click", () => {
if (negotiationPanel?.hidden) openNegotiationPanel(currentNegotiationId || negotiationRows[0]?.hash || "");
else closeNegotiationPanel();
});
negotiationsRefreshAllButton?.addEventListener("click", refreshAllNegotiations);
ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama"));
plansRefreshButton?.addEventListener("click", () => refreshPlans());
plansCloseButton?.addEventListener("click", closePlansPanel);
@@ -1935,6 +2219,15 @@ chatSidebarToggle?.addEventListener("click", toggleChatRail);
newChatButton?.addEventListener("click", () => createChat(true));
negotiationCloseButton?.addEventListener("click", closeNegotiationPanel);
negotiationForm?.addEventListener("submit", submitNegotiationMessage);
negotiationDraftButton?.addEventListener("click", draftNegotiationMessage);
negotiationRefreshButton?.addEventListener("click", refreshActiveNegotiation);
negotiationOpenChatButton?.addEventListener("click", openNegotiationInChat);
negotiationEndDealButton?.addEventListener("click", openNegotiationCloseModal);
negotiationSearchEl?.addEventListener("input", () => refreshNegotiations(false));
negotiationFilterEl?.addEventListener("change", () => refreshNegotiations(false));
negotiationCloseModalClose?.addEventListener("click", closeNegotiationCloseModal);
negotiationCloseForm?.addEventListener("submit", submitNegotiationClose);
closeDraftButton?.addEventListener("click", draftNegotiationClose);
updateModalClose?.addEventListener("click", closeUpdatePrompt);
updateModalReleases?.addEventListener("click", openReleasesPage);
updateModalInstall?.addEventListener("click", installUpdate);
@@ -2038,8 +2331,10 @@ refreshConfig();
refreshOllamaStatus();
refreshChats().then(() => loadChatMessages(currentThreadId));
refreshInbox();
refreshNegotiations(false);
checkForUpdate(true);
pollNotifications();
checkHealth();
setInterval(checkHealth, 30000);
setInterval(pollNotifications, 15000);
setInterval(() => refreshNegotiations(true), 15000);