855 lines
26 KiB
JavaScript
855 lines
26 KiB
JavaScript
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("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
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();
|
||
});
|