feat: ship usable local v1 with demo, workers, approvals, and docker support
This commit is contained in:
111
public/app.js
111
public/app.js
@@ -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("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user