feat: bootstrap boss control plane prototype
This commit is contained in:
333
public/app.js
Normal file
333
public/app.js
Normal file
@@ -0,0 +1,333 @@
|
||||
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("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
92
public/index.html
Normal file
92
public/index.html
Normal file
@@ -0,0 +1,92 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Boss Control Plane</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h1>Boss</h1>
|
||||
<button id="reset-demo" class="ghost">重置 Demo</button>
|
||||
</div>
|
||||
<p class="caption">多设备开发代理控制台</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>项目会话</h2>
|
||||
</div>
|
||||
<form id="create-session-form" class="stack">
|
||||
<input id="session-title" placeholder="新项目标题" />
|
||||
<button type="submit">创建会话</button>
|
||||
</form>
|
||||
<div id="session-list" class="list"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>设备</h2>
|
||||
</div>
|
||||
<div id="worker-list" class="list"></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
<section class="panel hero">
|
||||
<div>
|
||||
<h2 id="session-title-display">选择一个项目会话</h2>
|
||||
<p id="session-summary" class="muted">创建会话后,在这里持续对话并观察任务状态。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>对话</h2>
|
||||
</div>
|
||||
<div id="message-list" class="timeline"></div>
|
||||
<form id="message-form" class="stack">
|
||||
<textarea
|
||||
id="message-input"
|
||||
rows="4"
|
||||
placeholder="输入需求。示例:先调研登录失败根因,不要急着改代码。"
|
||||
></textarea>
|
||||
<button type="submit">发送消息</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>任务树</h2>
|
||||
</div>
|
||||
<div id="task-list" class="list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>审批</h2>
|
||||
</div>
|
||||
<div id="approval-list" class="list"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>事件流</h2>
|
||||
</div>
|
||||
<div id="event-list" class="timeline compact"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
219
public/styles.css
Normal file
219
public/styles.css
Normal file
@@ -0,0 +1,219 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f3efe4;
|
||||
--panel: rgba(255, 252, 245, 0.88);
|
||||
--line: rgba(43, 40, 35, 0.12);
|
||||
--text: #1d1c19;
|
||||
--muted: #716c61;
|
||||
--accent: #1f6feb;
|
||||
--danger: #b42318;
|
||||
--shadow: 0 18px 48px rgba(31, 28, 25, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans", "Source Han Sans SC", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(31, 111, 235, 0.12), transparent 24rem),
|
||||
linear-gradient(180deg, #f8f5ec 0%, var(--bg) 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: white;
|
||||
padding: 0.75rem 0.875rem;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.list,
|
||||
.timeline {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
max-height: 32rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.timeline.compact {
|
||||
max-height: 24rem;
|
||||
}
|
||||
|
||||
.session-item,
|
||||
.card,
|
||||
.message,
|
||||
.event {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
text-align: left;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
background: white;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.session-item.active {
|
||||
outline: 2px solid rgba(31, 111, 235, 0.2);
|
||||
}
|
||||
|
||||
.message header,
|
||||
.event header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.message.manager {
|
||||
background: rgba(31, 111, 235, 0.08);
|
||||
}
|
||||
|
||||
.badge {
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--line);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.badge.running,
|
||||
.badge.busy {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge.failed,
|
||||
.badge.cancelled,
|
||||
.badge.rejected {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.badge.completed,
|
||||
.badge.approved,
|
||||
.badge.idle {
|
||||
color: #0f7b55;
|
||||
}
|
||||
|
||||
.tags,
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.tags span {
|
||||
border-radius: 999px;
|
||||
padding: 0.18rem 0.5rem;
|
||||
background: rgba(31, 111, 235, 0.1);
|
||||
color: var(--accent);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.caption,
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user