mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-10-19 15:33:54 +00:00
Run model/profile, context-usage and goal display refreshes independently of snapshot rendering so chat switches do not wait for their API calls. Guard refreshes and save completions by request sequence and selected chat. Pending saves still finish for their original chat, while late responses cannot replace the current selector state or finish a newer loading cycle. Add deterministic pending-request and save-race regressions and update the owning plugin contracts. Validation: 94 focused tests passed for the loading change, followed by 47 focused tests after the model/profile race fixes. Live checks at localhost:32081 covered delayed API responses, rapid chat switching, late profile/preset/clear saves, and desktop/mobile behavior.
212 lines
6.2 KiB
JavaScript
212 lines
6.2 KiB
JavaScript
import { createStore } from "/js/AlpineStore.js";
|
|
import { callJsonApi } from "/js/api.js";
|
|
import { formatDuration } from "/js/time-utils.js";
|
|
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
|
|
import {
|
|
toastFrontendError,
|
|
toastFrontendSuccess,
|
|
} from "/components/notifications/notification-store.js";
|
|
|
|
const GOAL_API_PATH = "/plugins/_goal/goal";
|
|
|
|
const model = {
|
|
goal: null,
|
|
loading: false,
|
|
loadSeq: 0,
|
|
saving: false,
|
|
editing: false,
|
|
draft: "",
|
|
lastContextId: "",
|
|
clockIntervalId: null,
|
|
goalChangedHandler: null,
|
|
now: Date.now(),
|
|
|
|
get visible() {
|
|
return Boolean(this.goal?.objective && this.goal.status !== "complete");
|
|
},
|
|
|
|
get contextId() {
|
|
return chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
|
|
},
|
|
|
|
get statusLabel() {
|
|
const status = this.goal?.status || "active";
|
|
if (status === "paused") return "Goal paused";
|
|
if (status === "complete") return "Goal complete";
|
|
if (status === "blocked") return "Goal blocked";
|
|
return "Pursuing goal";
|
|
},
|
|
|
|
get statusIcon() {
|
|
const status = this.goal?.status || "active";
|
|
if (status === "paused") return "pause_circle";
|
|
if (status === "complete") return "check_circle";
|
|
if (status === "blocked") return "error";
|
|
return "track_changes";
|
|
},
|
|
|
|
get elapsedSeconds() {
|
|
if (!this.goal) return 0;
|
|
const status = this.goal.status || "active";
|
|
const storedSeconds = Number.parseInt(this.goal.elapsed_seconds, 10);
|
|
let seconds = Number.isNaN(storedSeconds) ? 0 : storedSeconds;
|
|
if (Number.isNaN(storedSeconds) && status !== "active") {
|
|
seconds = this.secondsBetween(this.goal.created_at, this.goal.updated_at);
|
|
}
|
|
if (status === "active") {
|
|
seconds += this.secondsBetween(
|
|
this.goal.active_since || this.goal.created_at || this.goal.updated_at,
|
|
this.now,
|
|
);
|
|
}
|
|
return Math.max(0, seconds);
|
|
},
|
|
|
|
secondsBetween(start, end) {
|
|
const startMs = Date.parse(start || "");
|
|
const endMs = typeof end === "number" ? end : Date.parse(end || "");
|
|
if (Number.isNaN(startMs) || Number.isNaN(endMs)) return 0;
|
|
return Math.max(0, Math.floor((endMs - startMs) / 1000));
|
|
},
|
|
|
|
get elapsedLabel() {
|
|
return formatDuration(this.elapsedSeconds * 1000);
|
|
},
|
|
|
|
onMount() {
|
|
document.getElementById("progress-bar-box")?.classList.add("has-goal-bar");
|
|
this.goalChangedHandler = (event) => {
|
|
const detail = event?.detail || {};
|
|
if (detail.context_id && detail.context_id !== this.contextId) return;
|
|
this.goal = detail.goal || null;
|
|
this.now = Date.now();
|
|
if (!this.goal) this.editing = false;
|
|
};
|
|
window.addEventListener("goal:changed", this.goalChangedHandler);
|
|
this.clockIntervalId = window.setInterval(() => {
|
|
this.now = Date.now();
|
|
}, 1000);
|
|
},
|
|
|
|
cleanup() {
|
|
document.getElementById("progress-bar-box")?.classList.remove("has-goal-bar");
|
|
if (this.goalChangedHandler) {
|
|
window.removeEventListener("goal:changed", this.goalChangedHandler);
|
|
}
|
|
if (this.clockIntervalId) {
|
|
window.clearInterval(this.clockIntervalId);
|
|
}
|
|
this.goalChangedHandler = null;
|
|
this.clockIntervalId = null;
|
|
},
|
|
|
|
async refresh(force = false) {
|
|
const contextId = this.contextId;
|
|
if (!force && this.loading && contextId === this.lastContextId) return;
|
|
const requestSeq = ++this.loadSeq;
|
|
if (contextId !== this.lastContextId) {
|
|
this.goal = null;
|
|
this.editing = false;
|
|
}
|
|
this.lastContextId = contextId;
|
|
if (!contextId) {
|
|
this.goal = null;
|
|
this.loading = false;
|
|
return;
|
|
}
|
|
|
|
this.loading = true;
|
|
try {
|
|
const response = await callJsonApi(GOAL_API_PATH, {
|
|
action: "get",
|
|
context_id: contextId,
|
|
});
|
|
if (requestSeq !== this.loadSeq || contextId !== this.contextId) return;
|
|
this.goal = response?.goal || null;
|
|
this.now = Date.now();
|
|
if (!this.goal) this.editing = false;
|
|
} catch (error) {
|
|
if (requestSeq !== this.loadSeq || contextId !== this.contextId) return;
|
|
console.error("Failed to load goal:", error);
|
|
this.goal = null;
|
|
} finally {
|
|
if (requestSeq === this.loadSeq) this.loading = false;
|
|
}
|
|
},
|
|
|
|
startEdit() {
|
|
if (!this.goal) return;
|
|
this.draft = this.goal.objective || "";
|
|
this.editing = true;
|
|
requestAnimationFrame(() => {
|
|
document.querySelector(".goal-strip-input")?.focus?.();
|
|
document.querySelector(".goal-strip-input")?.select?.();
|
|
});
|
|
},
|
|
|
|
cancelEdit() {
|
|
this.editing = false;
|
|
this.draft = "";
|
|
},
|
|
|
|
async saveEdit() {
|
|
const objective = (this.draft || "").trim();
|
|
if (!objective) {
|
|
void toastFrontendError("Goal objective is required.", "Goal");
|
|
return;
|
|
}
|
|
const response = await this.update(
|
|
{ action: "update", objective, status: "active" },
|
|
"Goal updated.",
|
|
);
|
|
this.editing = false;
|
|
if (response?.reactivated) {
|
|
await globalThis.sendMessage?.({
|
|
message: response.goal?.objective || objective,
|
|
context: response.goal?.context_id || this.contextId,
|
|
});
|
|
}
|
|
},
|
|
|
|
async pauseOrResume() {
|
|
if (!this.goal) return;
|
|
const action = this.goal.status === "active" ? "pause" : "resume";
|
|
await this.update(
|
|
{ action },
|
|
action === "pause" ? "Goal paused." : "Goal resumed.",
|
|
);
|
|
},
|
|
|
|
async deleteGoal() {
|
|
await this.update({ action: "delete" }, "Goal deleted.");
|
|
},
|
|
|
|
async update(payload, successMessage) {
|
|
const contextId = this.contextId;
|
|
if (!contextId || this.saving) return;
|
|
|
|
this.saving = true;
|
|
try {
|
|
const response = await callJsonApi(GOAL_API_PATH, {
|
|
...payload,
|
|
context_id: contextId,
|
|
});
|
|
this.goal = response?.goal || null;
|
|
this.now = Date.now();
|
|
this.lastContextId = contextId;
|
|
window.dispatchEvent(new CustomEvent("goal:changed", {
|
|
detail: { goal: this.goal, context_id: contextId },
|
|
}));
|
|
void toastFrontendSuccess(successMessage, "Goal");
|
|
return response;
|
|
} catch (error) {
|
|
console.error("Failed to update goal:", error);
|
|
void toastFrontendError(error?.message || "Failed to update goal.", "Goal");
|
|
return null;
|
|
} finally {
|
|
this.saving = false;
|
|
}
|
|
},
|
|
};
|
|
|
|
export const store = createStore("goalBar", model);
|