feat: ship usable local v1 with demo, workers, approvals, and docker support

This commit is contained in:
Codex
2026-03-23 12:59:41 +08:00
parent 0ab83990b2
commit 515ce72d0d
14 changed files with 1015 additions and 215 deletions

View File

@@ -15,17 +15,23 @@ const elements = {
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 input
return String(input)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
@@ -39,11 +45,18 @@ async function request(url, options = {}) {
...options,
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
let payload = {};
try {
payload = await response.json();
} catch {
payload = {};
}
return response.json();
if (!response.ok) {
throw new Error(payload.error || `${response.status} ${response.statusText}`);
}
return payload;
}
function selectedSession() {
@@ -70,13 +83,20 @@ function eventsForSelectedSession() {
}
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}" data-session-id="${session.id}">
<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>
`;
})
@@ -92,6 +112,11 @@ function renderSessions() {
}
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) => `
@@ -101,6 +126,8 @@ function renderWorkers() {
<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>
@@ -115,12 +142,21 @@ function renderSessionHeader() {
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;
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() {
@@ -143,6 +179,18 @@ function renderMessages() {
}
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
@@ -157,9 +205,23 @@ function renderTasks() {
<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">
<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>
${
["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>
`,
@@ -173,6 +235,7 @@ function renderTasks() {
const action = button.dataset.action;
await request(`/api/tasks/${taskId}/${action}`, { method: "POST", body: "{}" });
await loadSession(state.selectedSessionId);
await loadBootstrap();
render();
});
});
@@ -209,6 +272,7 @@ function renderApprovals() {
body: JSON.stringify({ approved, responder: "web-user" }),
});
await loadSession(state.selectedSessionId);
await loadBootstrap();
render();
});
});
@@ -288,6 +352,25 @@ elements.createSessionForm.addEventListener("submit", async (event) => {
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;
@@ -303,6 +386,17 @@ elements.messageForm.addEventListener("submit", async (event) => {
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 = [];
@@ -330,4 +424,3 @@ loadBootstrap().then(render).catch((error) => {
console.error(error);
elements.sessionSummary.textContent = error.message;
});

View File

@@ -32,6 +32,16 @@
<div class="panel-header">
<h2>设备</h2>
</div>
<form id="create-worker-form" class="stack compact">
<input id="worker-name" placeholder="worker 名称" />
<select id="worker-os">
<option value="windows">windows</option>
<option value="macos">macos</option>
<option value="linux">linux</option>
</select>
<input id="worker-capabilities" placeholder="capabilities逗号分隔" value="terminal" />
<button type="submit">注册 worker</button>
</form>
<div id="worker-list" class="list"></div>
</div>
</aside>
@@ -48,6 +58,7 @@
<div class="panel">
<div class="panel-header">
<h2>对话</h2>
<button id="archive-session" class="ghost">归档会话</button>
</div>
<div id="message-list" class="timeline"></div>
<form id="message-form" class="stack">
@@ -64,6 +75,7 @@
<div class="panel-header">
<h2>任务树</h2>
</div>
<div id="plan-hint" class="hint"></div>
<div id="task-list" class="list"></div>
</div>
</section>
@@ -89,4 +101,3 @@
<script type="module" src="/app.js"></script>
</body>
</html>

View File

@@ -25,7 +25,8 @@ body {
button,
input,
textarea {
textarea,
select {
font: inherit;
}
@@ -38,6 +39,11 @@ button {
cursor: pointer;
}
button:disabled {
opacity: 0.55;
cursor: default;
}
button.ghost {
background: transparent;
color: var(--text);
@@ -49,7 +55,8 @@ button.danger {
}
input,
textarea {
textarea,
select {
width: 100%;
border-radius: 12px;
border: 1px solid var(--line);
@@ -108,6 +115,10 @@ textarea {
gap: 0.75rem;
}
.stack.compact {
margin-bottom: 1rem;
}
.list,
.timeline {
display: grid;
@@ -143,6 +154,10 @@ textarea {
outline: 2px solid rgba(31, 111, 235, 0.2);
}
.session-item.archived {
opacity: 0.7;
}
.message header,
.event header {
display: flex;
@@ -171,7 +186,8 @@ textarea {
.badge.failed,
.badge.cancelled,
.badge.rejected {
.badge.rejected,
.badge.offline {
color: var(--danger);
}
@@ -202,6 +218,14 @@ textarea {
color: var(--muted);
}
.hint {
margin-bottom: 0.75rem;
padding: 0.75rem 0.875rem;
border-radius: 14px;
background: rgba(31, 111, 235, 0.08);
color: var(--accent);
}
pre {
margin: 0;
white-space: pre-wrap;