Files
boss/public/app.js

427 lines
14 KiB
JavaScript
Raw 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,
};
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"),
};
function escapeHtml(input) {
return String(input)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
async function request(url, options = {}) {
const response = await fetch(url, {
headers: { "Content-Type": "application/json" },
...options,
});
let payload = {};
try {
payload = await response.json();
} catch {
payload = {};
}
if (!response.ok) {
throw new Error(payload.error || `${response.status} ${response.statusText}`);
}
return payload;
}
function selectedSession() {
return state.sessions.find((session) => session.id === state.selectedSessionId) ?? 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 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
.map(
(worker) => `
<div class="card">
<div class="row between">
<strong>${escapeHtml(worker.name)}</strong>
<span class="badge ${worker.status}">${escapeHtml(worker.status)}</span>
</div>
<div class="muted">${escapeHtml(worker.os)}</div>
<div class="muted">current: ${escapeHtml(worker.currentTaskId || "idle")}</div>
<div class="muted">last seen: ${new Date(worker.lastSeenAt).toLocaleTimeString()}</div>
<div class="tags">
${worker.capabilities.map((capability) => `<span>${escapeHtml(capability)}</span>`).join("")}
</div>
</div>
`,
)
.join("");
}
function renderSessionHeader() {
const session = selectedSession();
if (!session) {
elements.sessionTitleDisplay.textContent = "选择一个项目会话";
elements.sessionSummary.textContent = "创建会话后,在这里持续对话并观察任务状态。";
elements.archiveSession.disabled = true;
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 = 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>${new Date(message.createdAt).toLocaleTimeString()}</span>
</header>
<p>${escapeHtml(message.content)}</p>
</article>
`,
)
.join("")
: `<p class="muted">当前没有消息。</p>`;
}
function renderTasks() {
const latestPlan = [...state.events]
.reverse()
.find((event) => event.sessionId === state.selectedSessionId && event.type === "plan.created");
if (latestPlan) {
const pausedCount = Array.isArray(latestPlan.payload.pausedTaskIds)
? latestPlan.payload.pausedTaskIds.length
: 0;
elements.planHint.textContent = `最新计划创建了 ${latestPlan.payload.taskIds.length} 个任务,自动暂停旧任务 ${pausedCount} 个。`;
} else {
elements.planHint.textContent = "";
}
const tasks = tasksForSelectedSession();
elements.taskList.innerHTML = tasks.length
? tasks
.map(
(task) => `
<div 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="muted">worker: ${escapeHtml(task.assignedWorkerId || "未分配")}</div>
<div class="muted">progress: ${task.progressPercent}%</div>
<div class="muted">summary: ${escapeHtml(task.summary || "暂无")}</div>
<div class="muted">next: ${escapeHtml(task.nextStep || "暂无")}</div>
<div class="actions">
${
["queued", "assigned", "running"].includes(task.status)
? `<button data-action="pause" data-task-id="${task.id}" class="ghost">暂停</button>`
: ""
}
${
["paused", "blocked"].includes(task.status)
? `<button data-action="resume" 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>
</div>
`,
)
.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 request(`/api/tasks/${taskId}/${action}`, { method: "POST", body: "{}" });
await loadSession(state.selectedSessionId);
await loadBootstrap();
render();
});
});
}
function renderApprovals() {
const approvals = approvalsForSelectedSession();
elements.approvalList.innerHTML = approvals.length
? approvals
.map(
(approval) => `
<div class="card">
<div class="row between">
<strong>${escapeHtml(approval.summary)}</strong>
<span class="badge ${approval.status}">${escapeHtml(approval.status)}</span>
</div>
<div class="muted">risk: ${escapeHtml(approval.riskLevel)}</div>
<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 request(`/api/approvals/${approvalId}/respond`, {
method: "POST",
body: JSON.stringify({ approved, responder: "web-user" }),
});
await loadSession(state.selectedSessionId);
await loadBootstrap();
render();
});
});
}
function renderEvents() {
const events = eventsForSelectedSession();
elements.eventList.innerHTML = events.length
? events
.map(
(event) => `
<article class="event">
<header>
<strong>${escapeHtml(event.type)}</strong>
<span>${new Date(event.timestamp).toLocaleTimeString()}</span>
</header>
<pre>${escapeHtml(JSON.stringify(event.payload, null, 2))}</pre>
</article>
`,
)
.join("")
: `<p class="muted">当前没有事件。</p>`;
}
function render() {
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,
];
}
elements.createSessionForm.addEventListener("submit", async (event) => {
event.preventDefault();
const title = elements.sessionTitleInput.value.trim();
const details = await request("/api/sessions", {
method: "POST",
body: JSON.stringify({ title }),
});
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);
await request("/api/workers/register", {
method: "POST",
body: JSON.stringify({ name, os, capabilities }),
});
elements.workerName.value = "";
elements.workerCapabilities.value = "terminal";
await loadBootstrap();
render();
});
elements.messageForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedSessionId) return;
const content = elements.messageInput.value.trim();
if (!content) return;
await request(`/api/sessions/${state.selectedSessionId}/messages`, {
method: "POST",
body: JSON.stringify({ content, channel: "web" }),
});
elements.messageInput.value = "";
await loadSession(state.selectedSessionId);
await loadBootstrap();
render();
});
elements.archiveSession.addEventListener("click", async () => {
if (!state.selectedSessionId) return;
await request(`/api/sessions/${state.selectedSessionId}/archive`, {
method: "POST",
body: "{}",
});
await loadSession(state.selectedSessionId);
await loadBootstrap();
render();
});
elements.resetDemo.addEventListener("click", async () => {
await request("/api/demo/reset", { method: "POST", body: "{}" });
state.sessions = [];
state.messages = [];
state.tasks = [];
state.workers = [];
state.approvals = [];
state.events = [];
state.selectedSessionId = null;
render();
});
const stream = new EventSource("/api/events/stream");
stream.onmessage = async (event) => {
const payload = JSON.parse(event.data);
state.events.push(payload);
if (payload.sessionId) {
await loadSession(payload.sessionId);
}
await loadBootstrap();
render();
};
loadBootstrap().then(render).catch((error) => {
console.error(error);
elements.sessionSummary.textContent = error.message;
});