feat: plans - longrunning tasks
This commit is contained in:
+184
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user