Files
boss/public/app.js
2026-03-23 12:43:39 +08:00

334 lines
11 KiB
JavaScript

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"),
sessionTitleDisplay: document.querySelector("#session-title-display"),
sessionSummary: document.querySelector("#session-summary"),
createSessionForm: document.querySelector("#create-session-form"),
sessionTitleInput: document.querySelector("#session-title"),
messageForm: document.querySelector("#message-form"),
messageInput: document.querySelector("#message-input"),
resetDemo: document.querySelector("#reset-demo"),
};
function escapeHtml(input) {
return 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,
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return response.json();
}
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() {
elements.sessionList.innerHTML = state.sessions
.map((session) => {
const active = session.id === state.selectedSessionId ? "active" : "";
return `
<button class="session-item ${active}" data-session-id="${session.id}">
<strong>${escapeHtml(session.title)}</strong>
<span>${escapeHtml(session.activeObjective || "暂无目标")}</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() {
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="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 = "创建会话后,在这里持续对话并观察任务状态。";
return;
}
elements.sessionTitleDisplay.textContent = session.title;
elements.sessionSummary.textContent =
session.lastPlannerSummary || session.activeObjective || "等待用户输入。";
}
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 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="actions">
<button data-action="pause" data-task-id="${task.id}" class="ghost">暂停</button>
<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);
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);
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.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.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;
});