Files
boss/public/app.js
2026-03-23 13:31:23 +08:00

855 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const state = {
sessions: [],
messages: [],
tasks: [],
approvals: [],
workers: [],
events: [],
selectedSessionId: null,
connection: "connecting",
banner: null,
};
function normalizeBasePath(pathname) {
if (!pathname || pathname === "/") {
return "";
}
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
}
const appBasePath = normalizeBasePath(new URL(".", import.meta.url).pathname);
function resolveUrl(path) {
if (/^https?:\/\//.test(path)) {
return path;
}
return `${appBasePath}${path}`;
}
const TASK_GROUPS = [
{ title: "进行中", statuses: ["assigned", "running"] },
{ title: "等待处理", statuses: ["planning", "queued"] },
{ title: "等待审批或阻塞", statuses: ["waiting_approval", "blocked", "paused"] },
{ title: "已完成", statuses: ["completed"] },
{ title: "已结束", statuses: ["failed", "cancelled"] },
];
const elements = {
sessionList: document.querySelector("#session-list"),
workerList: document.querySelector("#worker-list"),
messageList: document.querySelector("#message-list"),
taskList: document.querySelector("#task-list"),
approvalList: document.querySelector("#approval-list"),
eventList: document.querySelector("#event-list"),
planHint: document.querySelector("#plan-hint"),
sessionTitleDisplay: document.querySelector("#session-title-display"),
sessionSummary: document.querySelector("#session-summary"),
createSessionForm: document.querySelector("#create-session-form"),
sessionTitleInput: document.querySelector("#session-title"),
createWorkerForm: document.querySelector("#create-worker-form"),
workerName: document.querySelector("#worker-name"),
workerOs: document.querySelector("#worker-os"),
workerCapabilities: document.querySelector("#worker-capabilities"),
messageForm: document.querySelector("#message-form"),
messageInput: document.querySelector("#message-input"),
resetDemo: document.querySelector("#reset-demo"),
archiveSession: document.querySelector("#archive-session"),
streamStatus: document.querySelector("#stream-status"),
appBanner: document.querySelector("#app-banner"),
onboarding: document.querySelector("#onboarding"),
};
let bannerTimer = null;
let fallbackPoll = null;
function escapeHtml(input) {
return String(input)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function setBanner(message, tone = "info", persistent = false) {
state.banner = { message, tone };
renderBanner();
if (bannerTimer) {
window.clearTimeout(bannerTimer);
bannerTimer = null;
}
if (!persistent) {
bannerTimer = window.setTimeout(() => {
if (state.banner?.message === message) {
clearBanner();
}
}, 3200);
}
}
function clearBanner() {
state.banner = null;
renderBanner();
}
function renderBanner() {
if (!state.banner) {
elements.appBanner.className = "banner hidden";
elements.appBanner.textContent = "";
return;
}
elements.appBanner.className = `banner ${state.banner.tone}`;
elements.appBanner.textContent = state.banner.message;
}
async function request(url, options = {}) {
const response = await fetch(resolveUrl(url), {
headers: { "Content-Type": "application/json" },
...options,
});
let payload = {};
try {
payload = await response.json();
} catch {
payload = {};
}
if (!response.ok) {
const message = payload.message || payload.error || `${response.status} ${response.statusText}`;
throw new Error(message);
}
return payload;
}
async function runAction(label, operation, successMessage, options = {}) {
const {
refreshBootstrap = true,
refreshSession = Boolean(state.selectedSessionId),
persistent = false,
} = options;
setBanner(`${label}中...`, "info", true);
try {
const result = await operation();
if (refreshBootstrap) {
await loadBootstrap();
}
if (refreshSession && state.selectedSessionId) {
await loadSession(state.selectedSessionId);
}
render();
if (successMessage) {
setBanner(successMessage, "success", persistent);
} else {
clearBanner();
}
return result;
} catch (error) {
console.error(error);
setBanner(`${label}失败:${error.message}`, "error", true);
render();
return null;
}
}
function selectedSession() {
return state.sessions.find((session) => session.id === state.selectedSessionId) ?? null;
}
function findTask(taskId) {
return state.tasks.find((task) => task.id === taskId) ?? null;
}
function findWorker(workerId) {
return state.workers.find((worker) => worker.id === workerId) ?? null;
}
function tasksForSelectedSession() {
return state.tasks.filter((task) => task.sessionId === state.selectedSessionId);
}
function messagesForSelectedSession() {
return state.messages.filter((message) => message.sessionId === state.selectedSessionId);
}
function approvalsForSelectedSession() {
return state.approvals.filter((approval) => approval.sessionId === state.selectedSessionId);
}
function eventsForSelectedSession() {
return state.events
.filter((event) => event.sessionId === null || event.sessionId === state.selectedSessionId)
.slice(-50)
.reverse();
}
function formatClock(value) {
return new Date(value).toLocaleTimeString();
}
function formatAge(value) {
const diffMs = Date.now() - new Date(value).getTime();
const seconds = Math.max(0, Math.round(diffMs / 1000));
if (seconds < 5) {
return "刚刚";
}
if (seconds < 60) {
return `${seconds} 秒前`;
}
const minutes = Math.round(seconds / 60);
if (minutes < 60) {
return `${minutes} 分钟前`;
}
const hours = Math.round(minutes / 60);
return `${hours} 小时前`;
}
function workerHealth(worker) {
if (worker.status === "offline") {
return {
tone: "offline",
label: "离线",
description: "已手动下线或被系统判定为离线。",
};
}
const ageMs = Date.now() - new Date(worker.lastSeenAt).getTime();
if (ageMs > 30_000) {
return {
tone: "stale",
label: "疑似掉线",
description: `最近心跳 ${formatAge(worker.lastSeenAt)}`,
};
}
if (ageMs > 10_000) {
return {
tone: "lagging",
label: "连接抖动",
description: `最近心跳 ${formatAge(worker.lastSeenAt)}`,
};
}
return {
tone: "live",
label: "在线",
description: `最近心跳 ${formatAge(worker.lastSeenAt)}`,
};
}
function renderSessions() {
if (state.sessions.length === 0) {
elements.sessionList.innerHTML = `<p class="muted">先创建一个项目会话。</p>`;
return;
}
elements.sessionList.innerHTML = state.sessions
.map((session) => {
const active = session.id === state.selectedSessionId ? "active" : "";
const archived = session.status === "archived" ? "archived" : "";
return `
<button class="session-item ${active} ${archived}" data-session-id="${session.id}">
<strong>${escapeHtml(session.title)}</strong>
<span>${escapeHtml(session.activeObjective || "暂无目标")}</span>
<span class="muted">${escapeHtml(session.status)}</span>
</button>
`;
})
.join("");
elements.sessionList.querySelectorAll("[data-session-id]").forEach((button) => {
button.addEventListener("click", async () => {
state.selectedSessionId = button.dataset.sessionId;
await loadSession(state.selectedSessionId);
render();
});
});
}
function renderWorkers() {
if (state.workers.length === 0) {
elements.workerList.innerHTML = `<p class="muted">还没有 worker。可以手动注册或直接运行 \`npm run demo\`。</p>`;
return;
}
elements.workerList.innerHTML = [...state.workers]
.sort((left, right) => left.name.localeCompare(right.name))
.map((worker) => {
const currentTask = findTask(worker.currentTaskId);
const health = workerHealth(worker);
return `
<div class="card">
<div class="row between">
<strong>${escapeHtml(worker.name)}</strong>
<div class="row tight">
<span class="badge ${worker.status}">${escapeHtml(worker.status)}</span>
<span class="badge ${health.tone}">${escapeHtml(health.label)}</span>
</div>
</div>
<div class="muted">${escapeHtml(worker.os)}</div>
<div class="muted">当前任务:${escapeHtml(currentTask?.title || "空闲")}</div>
<div class="muted">${escapeHtml(health.description)}</div>
<div class="muted">负载:${escapeHtml(worker.load)}</div>
<div class="tags">
${worker.capabilities.map((capability) => `<span>${escapeHtml(capability)}</span>`).join("")}
</div>
<div class="actions">
${
worker.status !== "offline"
? `<button data-worker-action="offline" data-worker-id="${worker.id}" class="ghost danger">标记离线</button>`
: ""
}
</div>
</div>
`;
})
.join("");
elements.workerList.querySelectorAll("[data-worker-action]").forEach((button) => {
button.addEventListener("click", async () => {
const workerId = button.dataset.workerId;
await runAction(
"更新 worker 状态",
() => request(`/api/workers/${workerId}/offline`, { method: "POST", body: "{}" }),
"worker 已标记离线。",
{ refreshSession: false },
);
});
});
}
function renderStreamStatus() {
const labels = {
connecting: "连接中",
live: "实时同步",
reconnecting: "重连中",
};
elements.streamStatus.className = `badge ${state.connection}`;
elements.streamStatus.textContent = labels[state.connection] ?? "未知状态";
}
function renderOnboarding() {
const needsOnboarding = state.workers.length === 0 || state.sessions.length === 0;
elements.onboarding.classList.toggle("hidden", !needsOnboarding);
if (!needsOnboarding) {
elements.onboarding.innerHTML = "";
return;
}
const steps = [
{
done: state.workers.length > 0,
title: "接入至少一台设备 worker",
detail:
state.workers.length > 0
? `当前已登记 ${state.workers.length} 台设备,可以开始接单。`
: "最快路径是运行 `npm run demo`,它会同时起服务和 3 台示例 worker。",
},
{
done: state.sessions.length > 0,
title: "创建一个项目会话",
detail:
state.sessions.length > 0
? "会话已经创建,可以持续对话和改需求。"
: "左侧填写项目标题创建会话,之后右侧就能开始对话式调度。",
},
{
done: Boolean(state.selectedSessionId && messagesForSelectedSession().length > 0),
title: "给主控发送第一条需求",
detail:
state.selectedSessionId && messagesForSelectedSession().length > 0
? "系统已经收到了需求,正在持续调度子任务。"
: "示例:先调研登录失败根因,不要急着改代码。",
},
];
elements.onboarding.innerHTML = `
<div class="panel-header">
<h2>首次上手</h2>
<span class="muted">按这 3 步就能跑通一轮协同开发</span>
</div>
<div class="checklist">
${steps
.map(
(step) => `
<article class="check-item ${step.done ? "done" : ""}">
<strong>${step.done ? "已完成" : "待完成"} · ${escapeHtml(step.title)}</strong>
<p class="muted">${escapeHtml(step.detail)}</p>
</article>
`,
)
.join("")}
</div>
`;
}
function renderSessionHeader() {
const session = selectedSession();
if (!session) {
elements.sessionTitleDisplay.textContent = "选择一个项目会话";
elements.sessionSummary.textContent = "创建会话后,在这里持续对话并观察任务状态。";
elements.archiveSession.disabled = true;
elements.archiveSession.textContent = "归档会话";
elements.messageInput.disabled = true;
elements.messageInput.placeholder = "先创建或选择一个项目会话。";
return;
}
elements.sessionTitleDisplay.textContent = `${session.title} (${session.status})`;
elements.sessionSummary.textContent =
session.lastPlannerSummary || session.activeObjective || "等待用户输入。";
elements.archiveSession.disabled = false;
elements.archiveSession.textContent = session.status === "archived" ? "恢复会话" : "归档会话";
elements.messageInput.disabled = session.status === "archived";
elements.messageInput.placeholder =
session.status === "archived"
? "当前会话已归档。先恢复会话,再继续发送消息。"
: "输入需求。示例:先调研登录失败根因,不要急着改代码。";
}
function renderMessages() {
const messages = messagesForSelectedSession();
elements.messageList.innerHTML = messages.length
? messages
.map(
(message) => `
<article class="message ${message.role}">
<header>
<strong>${escapeHtml(message.role)}</strong>
<span>${formatClock(message.createdAt)}</span>
</header>
<p>${escapeHtml(message.content)}</p>
</article>
`,
)
.join("")
: `<p class="muted">当前没有消息。</p>`;
}
function taskGroupSections() {
const tasks = tasksForSelectedSession();
return TASK_GROUPS.map((group) => ({
...group,
tasks: tasks.filter((task) => group.statuses.includes(task.status)),
})).filter((group) => group.tasks.length > 0);
}
function renderTaskCard(task) {
const worker = findWorker(task.assignedWorkerId);
const dependencyTitles = task.dependencyIds
.map((taskId) => findTask(taskId)?.title)
.filter(Boolean);
const pendingApproval = approvalsForSelectedSession().find(
(approval) => approval.taskId === task.id && approval.status === "pending",
);
return `
<article class="card">
<div class="row between">
<strong>${escapeHtml(task.title)}</strong>
<span class="badge ${task.status}">${escapeHtml(task.status)}</span>
</div>
<p>${escapeHtml(task.description)}</p>
<div class="meta-list">
<span>worker${escapeHtml(worker?.name || "未分配")}</span>
<span>进度:${escapeHtml(task.progressPercent)}%</span>
<span>下一步:${escapeHtml(task.nextStep || "暂无")}</span>
<span>依赖:${escapeHtml(dependencyTitles.join("、") || "无")}</span>
<span>审批:${escapeHtml(task.approvalStatus === "pending" ? "等待批准" : task.approvalStatus)}</span>
</div>
<div class="muted">状态摘要:${escapeHtml(task.summary || "暂无")}</div>
${
pendingApproval
? `<div class="hint subtle">当前被审批卡住:${escapeHtml(pendingApproval.summary)}</div>`
: ""
}
<div class="actions">
${
["queued", "assigned", "running"].includes(task.status)
? `<button data-action="pause" data-task-id="${task.id}" class="ghost">暂停</button>`
: ""
}
${
["paused", "blocked", "waiting_approval"].includes(task.status)
? `<button data-action="resume" data-task-id="${task.id}" class="ghost">续跑</button>`
: ""
}
${
["paused", "failed", "cancelled"].includes(task.status)
? `<button data-action="requeue" data-task-id="${task.id}" class="ghost">重新排队</button>`
: ""
}
${
!["completed", "cancelled", "failed"].includes(task.status)
? `<button data-action="cancel" data-task-id="${task.id}" class="ghost danger">取消</button>`
: ""
}
</div>
</article>
`;
}
function renderTasks() {
const latestPlan = [...state.events]
.reverse()
.find((event) => event.sessionId === state.selectedSessionId && event.type === "plan.created");
if (latestPlan) {
const createdCount = Array.isArray(latestPlan.payload.taskIds) ? latestPlan.payload.taskIds.length : 0;
const pausedCount = Array.isArray(latestPlan.payload.pausedTaskIds)
? latestPlan.payload.pausedTaskIds.length
: 0;
elements.planHint.textContent =
pausedCount > 0
? `本轮重规划新增 ${createdCount} 个任务,并暂停了 ${pausedCount} 个旧任务。`
: `本轮计划新增 ${createdCount} 个任务。`;
} else {
elements.planHint.textContent = "";
}
const sections = taskGroupSections();
elements.taskList.innerHTML = sections.length
? sections
.map(
(section) => `
<section class="task-group">
<div class="row between">
<h3>${escapeHtml(section.title)}</h3>
<span class="muted">${section.tasks.length} 项</span>
</div>
<div class="stack">
${section.tasks.map((task) => renderTaskCard(task)).join("")}
</div>
</section>
`,
)
.join("")
: `<p class="muted">当前没有任务。</p>`;
elements.taskList.querySelectorAll("[data-action]").forEach((button) => {
button.addEventListener("click", async () => {
const taskId = button.dataset.taskId;
const action = button.dataset.action;
await runAction(
"更新任务",
() => request(`/api/tasks/${taskId}/${action}`, { method: "POST", body: "{}" }),
"任务状态已更新。",
);
});
});
}
function renderApprovals() {
const approvals = approvalsForSelectedSession();
elements.approvalList.innerHTML = approvals.length
? approvals
.map((approval) => {
const task = findTask(approval.taskId);
const followUpText =
approval.status === "pending"
? "批准后任务会重新进入队列;拒绝后任务会被取消。"
: approval.status === "approved"
? "这条审批已经通过,对应任务可继续执行。"
: "这条审批已被拒绝,对应任务已停止。";
return `
<div class="card">
<div class="row between">
<strong>${escapeHtml(task?.title || approval.summary)}</strong>
<span class="badge ${approval.status}">${escapeHtml(approval.status)}</span>
</div>
<p>${escapeHtml(approval.summary)}</p>
<div class="meta-list">
<span>风险等级:${escapeHtml(approval.riskLevel)}</span>
<span>触发类型:${escapeHtml(approval.kind)}</span>
<span>关联任务:${escapeHtml(task?.title || approval.taskId)}</span>
</div>
<div class="muted">${escapeHtml(followUpText)}</div>
${
approval.status === "pending"
? `
<div class="actions">
<button data-approval-id="${approval.id}" data-approved="true">批准</button>
<button data-approval-id="${approval.id}" data-approved="false" class="ghost danger">拒绝</button>
</div>
`
: ""
}
</div>
`;
})
.join("")
: `<p class="muted">当前没有待审批项。</p>`;
elements.approvalList.querySelectorAll("[data-approval-id]").forEach((button) => {
button.addEventListener("click", async () => {
const approvalId = button.dataset.approvalId;
const approved = button.dataset.approved === "true";
await runAction(
approved ? "批准审批" : "拒绝审批",
() =>
request(`/api/approvals/${approvalId}/respond`, {
method: "POST",
body: JSON.stringify({ approved, responder: "web-user" }),
}),
approved ? "审批已通过。" : "审批已拒绝。",
);
});
});
}
function renderEvents() {
const events = eventsForSelectedSession();
elements.eventList.innerHTML = events.length
? events
.map(
(event) => `
<article class="event">
<header>
<strong>${escapeHtml(event.type)}</strong>
<span>${formatClock(event.timestamp)}</span>
</header>
<pre>${escapeHtml(JSON.stringify(event.payload, null, 2))}</pre>
</article>
`,
)
.join("")
: `<p class="muted">当前没有事件。</p>`;
}
function render() {
renderBanner();
renderStreamStatus();
renderOnboarding();
renderSessions();
renderWorkers();
renderSessionHeader();
renderMessages();
renderTasks();
renderApprovals();
renderEvents();
}
async function loadBootstrap() {
const bootstrap = await request("/api/bootstrap");
state.sessions = bootstrap.sessions;
state.messages = bootstrap.messages;
state.tasks = bootstrap.tasks;
state.workers = bootstrap.workers;
state.approvals = bootstrap.approvals;
state.events = bootstrap.events;
if (!state.selectedSessionId && state.sessions[0]) {
state.selectedSessionId = state.sessions[0].id;
}
}
async function loadSession(sessionId) {
if (!sessionId) return;
const details = await request(`/api/sessions/${sessionId}`);
state.sessions = state.sessions.map((session) => (session.id === sessionId ? details.session : session));
state.messages = [...state.messages.filter((message) => message.sessionId !== sessionId), ...details.messages];
state.tasks = [...state.tasks.filter((task) => task.sessionId !== sessionId), ...details.tasks];
state.approvals = [
...state.approvals.filter((approval) => approval.sessionId !== sessionId),
...details.approvals,
];
}
function startFallbackPolling() {
if (fallbackPoll) {
return;
}
fallbackPoll = window.setInterval(async () => {
try {
await loadBootstrap();
if (state.selectedSessionId) {
await loadSession(state.selectedSessionId);
}
render();
} catch (error) {
console.error(error);
}
}, 5000);
}
function stopFallbackPolling() {
if (!fallbackPoll) {
return;
}
window.clearInterval(fallbackPoll);
fallbackPoll = null;
}
elements.createSessionForm.addEventListener("submit", async (event) => {
event.preventDefault();
const title = elements.sessionTitleInput.value.trim();
const details = await runAction(
"创建会话",
() =>
request("/api/sessions", {
method: "POST",
body: JSON.stringify({ title }),
}),
"项目会话已创建。",
{ refreshBootstrap: false, refreshSession: false },
);
if (!details) {
return;
}
state.sessions.unshift(details.session);
state.selectedSessionId = details.session.id;
await loadSession(details.session.id);
elements.sessionTitleInput.value = "";
render();
});
elements.createWorkerForm.addEventListener("submit", async (event) => {
event.preventDefault();
const name = elements.workerName.value.trim();
if (!name) return;
const os = elements.workerOs.value;
const capabilities = elements.workerCapabilities.value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
const result = await runAction(
"注册 worker",
() =>
request("/api/workers/register", {
method: "POST",
body: JSON.stringify({ name, os, capabilities }),
}),
"worker 已注册。",
{ refreshSession: false },
);
if (!result) {
return;
}
elements.workerName.value = "";
elements.workerCapabilities.value = "terminal";
});
elements.messageForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedSessionId) return;
const content = elements.messageInput.value.trim();
if (!content) return;
const result = await runAction(
"发送需求",
() =>
request(`/api/sessions/${state.selectedSessionId}/messages`, {
method: "POST",
body: JSON.stringify({ content, channel: "web" }),
}),
"需求已发送给主控。",
);
if (!result) {
return;
}
elements.messageInput.value = "";
});
elements.archiveSession.addEventListener("click", async () => {
const session = selectedSession();
if (!session) return;
const archived = session.status === "archived";
await runAction(
archived ? "恢复会话" : "归档会话",
() =>
request(`/api/sessions/${state.selectedSessionId}/${archived ? "restore" : "archive"}`, {
method: "POST",
body: "{}",
}),
archived ? "会话已恢复。" : "会话已归档。",
);
});
elements.resetDemo.addEventListener("click", async () => {
const result = await runAction(
"重置 Demo",
() =>
request("/api/demo/reset", {
method: "POST",
body: JSON.stringify({ preserveWorkers: true }),
}),
"Demo 数据已重置。",
{ refreshSession: false },
);
if (!result) {
return;
}
state.sessions = [];
state.messages = [];
state.tasks = [];
state.approvals = [];
state.events = [];
state.selectedSessionId = null;
await loadBootstrap();
render();
});
const stream = new EventSource(resolveUrl("/api/events/stream"));
stream.onopen = () => {
const wasReconnecting = state.connection === "reconnecting";
state.connection = "live";
stopFallbackPolling();
render();
if (wasReconnecting) {
setBanner("实时事件流已恢复。", "success");
}
};
stream.onmessage = async (event) => {
const payload = JSON.parse(event.data);
state.events.push(payload);
if (payload.sessionId) {
await loadSession(payload.sessionId);
}
await loadBootstrap();
render();
};
stream.onerror = () => {
if (state.connection !== "reconnecting") {
state.connection = "reconnecting";
setBanner("实时事件流暂时断开,正在重连并降级轮询。", "warn", true);
startFallbackPolling();
render();
}
};
loadBootstrap()
.then(async () => {
if (state.selectedSessionId) {
await loadSession(state.selectedSessionId);
}
render();
})
.catch((error) => {
console.error(error);
setBanner(`初始化失败:${error.message}`, "error", true);
render();
});