feat: plans - longrunning tasks

This commit is contained in:
2026-05-07 23:54:58 -04:00
parent d6c2d57fd9
commit e2f87481d6
9 changed files with 1533 additions and 19 deletions
+184
View File
@@ -13,9 +13,11 @@ 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");
@@ -47,6 +49,10 @@ 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 planForm = document.getElementById("plan-form");
const plansStatusEl = document.getElementById("plans-status");
const plansDashboardEl = document.getElementById("plans-dashboard");
let ollamaOnline = true;
let latestUpdate = null;
@@ -736,6 +742,7 @@ function toggleSidebarPanel(panelName) {
const panels = {
settings: { panel: settingsPanel, button: settingsToggle },
memory: { panel: memoryPanel, button: memoryToggle },
plans: { panel: plansPanel, button: plansToggle },
ollama: { panel: ollamaPanel, button: ollamaToggle },
};
const target = panels[panelName];
@@ -756,6 +763,7 @@ function toggleSidebarPanel(panelName) {
checkForUpdate();
}
if (panelName === "memory") refreshMemory();
if (panelName === "plans") refreshPlans();
if (panelName === "ollama") {
refreshConfig();
refreshOllamaStatus();
@@ -1002,6 +1010,178 @@ async function submitNegotiationMessage(event) {
}
}
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;
});
}
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) return;
try {
const response = await fetch("/api/plans");
const result = await response.json();
await renderPlans(result.plans || [], openPlanId);
} catch (error) {
plansDashboardEl.textContent = `Plans failed: ${fetchErrorMessage(error)}`;
}
}
async function renderPlans(plans, openPlanId = null) {
plansDashboardEl.innerHTML = "";
if (!plans.length) {
plansDashboardEl.innerHTML = '<div class="pending-empty">No continual plans</div>';
return;
}
for (const plan of plans) {
const card = document.createElement("article");
card.className = `plan-card${plan.status === "active" ? " active" : ""}`;
const title = document.createElement("h3");
title.textContent = plan.title || "Untitled plan";
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.status, 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 = value;
pills.appendChild(pill);
}
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("Cancel", () => postPlanAction(plan.id, "cancel"), "secondary small-button")
);
card.append(title, meta, pills, 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 response = await fetch(`/api/plans/${encodeURIComponent(planId)}`);
const result = await response.json();
const plan = result.plan;
const detail = document.createElement("div");
detail.className = "plan-detail";
detail.append(
planSection("Checklist", (plan.items || []).map((item) => `${item.item_name}: ${item.acquired_quantity || 0}/${item.desired_quantity || 1}${item.max_unit_price ? `, max ${Number(item.max_unit_price).toLocaleString()} UEC` : ""} (${item.status})`)),
planSection("Best Candidates", bestCandidateLines(plan)),
planSection("Recent Events", (plan.events || []).slice(0, 5).map((event) => `${formatShortDate(event.created_at)} ${event.kind}: ${event.message}`))
);
card.appendChild(detail);
}
function planSection(title, lines) {
const wrapper = document.createElement("section");
const heading = document.createElement("h4");
heading.textContent = title;
const list = document.createElement("ul");
list.className = "plan-list";
const items = lines.length ? lines : ["Empty"];
for (const line of items) {
const item = document.createElement("li");
item.textContent = line;
list.appendChild(item);
}
wrapper.append(heading, list);
return wrapper;
}
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) => `${byItem.get(candidate.plan_item_id) || "Item"}: ${candidate.title || candidate.listing_slug || candidate.listing_id} at ${Number(candidate.price || 0).toLocaleString()} ${candidate.currency || "UEC"} from ${candidate.seller || "unknown"} (${candidate.status})`);
}
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)}`;
}
}
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 {
const response = await fetch("/api/health");
@@ -1205,7 +1385,10 @@ configRefreshButton?.addEventListener("click", refreshConfig);
configForm?.addEventListener("submit", saveConfig);
settingsToggle?.addEventListener("click", () => toggleSidebarPanel("settings"));
memoryToggle?.addEventListener("click", () => toggleSidebarPanel("memory"));
plansToggle?.addEventListener("click", () => toggleSidebarPanel("plans"));
ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama"));
plansRefreshButton?.addEventListener("click", () => refreshPlans());
planForm?.addEventListener("submit", createPlan);
ollamaForm?.addEventListener("submit", saveOllamaConfig);
ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus);
ollamaDownloadButton?.addEventListener("click", () => {
@@ -1320,6 +1503,7 @@ async function sendMessage() {
refreshPending();
refreshMemory();
refreshPlans();
refreshConfig();
refreshOllamaStatus();
refreshChats().then(() => loadChatMessages(currentThreadId));