// U-008: hash-based client-side router (no History API / server catch-all
// needed). U-022 adds the first real page, #/setup (v_project_setup, 02-V
// §2.1); U-023 adds #/goals/new and #/bugs/new; U-025 adds the real #/
// (v_dashboard), #/tasks (v_task_board), #/tasks/:task_id (v_task_detail).
// 02-V's console has no multi-project switcher UI (decision 2), but
// c_task_create/c_bug_report_create both require project_id (03-C §3.1;
// §3.1.1 doesn't list it, but needs it too -- see bug_record.py's
// create_bug_report docstring). Until v_dashboard carries "current project"
// state through some richer UI, the id picked/created in v_project_setup is
// stashed here and read back by the pages that need it.
const CURRENT_PROJECT_KEY = "graphlemon_project_id";
function getCurrentProjectId() {
return localStorage.getItem(CURRENT_PROJECT_KEY);
}
function setCurrentProjectId(projectId) {
localStorage.setItem(CURRENT_PROJECT_KEY, projectId);
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text == null ? "" : String(text);
return div.innerHTML;
}
// c_ws_subscribe (03-C §3.14, U-024): opens a live event feed for a project
// (optionally scoped to one task_id) and auto-reconnects on drop -- 02-V
// §2.2's "实时连接已断开,正在重连" exception state. REQ-12's deeper
// reconnect-catch-up semantics are U-024.5's job (03-C §3.14 itself routes
// that through REST snapshots, not WS replay); this only re-opens the
// socket and reports connection status back to the caller.
function connectEventSocket(projectId, taskId, { onEvent, onStatusChange }) {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
let url = wsProtocol + "//" + window.location.host + "/api/ws/projects/" + encodeURIComponent(projectId);
if (taskId) url += "?task_id=" + encodeURIComponent(taskId);
let closedByCaller = false;
let socket = null;
function open() {
socket = new WebSocket(url);
socket.onopen = () => onStatusChange("connected");
socket.onmessage = (event) => onEvent(JSON.parse(event.data));
socket.onclose = () => {
if (closedByCaller) return;
onStatusChange("disconnected");
setTimeout(open, 2000);
};
socket.onerror = () => socket.close();
}
open();
return {
close: () => {
closedByCaller = true;
if (socket) socket.close();
},
};
}
const routes = {
"#/": { render: renderDashboard, mount: mountDashboard },
"#/tasks": { render: renderTaskBoard, mount: mountTaskBoard },
"#/settings": { render: () => "
Settings placeholder -- real content is PH3 (U-030)
" },
"#/setup": { render: renderProjectSetup, mount: mountProjectSetup },
"#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer },
"#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback },
};
// #/tasks/:task_id (v_task_detail) is the one dynamic route -- not worth a
// full path-matching router for a single pattern.
const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/;
let currentRouteCleanup = null;
function registerRouteCleanup(fn) {
currentRouteCleanup = fn;
}
function render() {
if (currentRouteCleanup) {
currentRouteCleanup();
currentRouteCleanup = null;
}
const hash = window.location.hash || "#/";
let route = routes[hash];
let routeArgs = [];
if (!route) {
const match = hash.match(TASK_DETAIL_RE);
if (match) {
route = { render: renderTaskDetail, mount: mountTaskDetail };
routeArgs = [match[1]];
}
}
if (!route) route = { render: () => "Not found " + hash + "
" };
document.getElementById("app").innerHTML = route.render(...routeArgs);
if (route.mount) route.mount(...routeArgs);
for (const a of document.querySelectorAll("#nav a")) {
a.classList.toggle("active", a.getAttribute("href") === hash);
}
}
window.addEventListener("hashchange", render);
window.addEventListener("DOMContentLoaded", render);
// ---- v_project_setup (02-V §2.1): create/switch project ----
function renderProjectSetup() {
return `
创建 / 切换项目
选择一个本地文件夹作为目标项目的代码空间
📁 选择文件夹
项目名称(project_name,非必填)
创建项目
该文件夹还不是 Git 仓库,是否代为初始化(
git init)?确认前不写入任何文件。
取消
确认初始化并创建项目
`;
}
function mountProjectSetup() {
const folderInput = document.getElementById("folder_path");
const folderHint = document.getElementById("folder_path_hint");
const nameInput = document.getElementById("project_name");
const errorBox = document.getElementById("setup_error");
const confirmBanner = document.getElementById("confirm_banner");
function showError(message) {
errorBox.textContent = message;
errorBox.style.display = "block";
}
function clearError() {
errorBox.style.display = "none";
}
document.getElementById("btn_pick_folder").addEventListener("click", async () => {
clearError();
const resp = await fetch("/api/system/pick-folder", { method: "POST", body: "{}" });
const body = await resp.json();
if (resp.status === 422 && body.error === "DIALOG_UNAVAILABLE") {
folderInput.removeAttribute("readonly");
folderHint.textContent = "当前环境不支持弹窗选择,请手动输入路径";
return;
}
if (body.selected_path) {
folderInput.value = body.selected_path;
}
// user canceled the native dialog -> folder_path stays as it was (02-V §2.1)
});
async function createProject(initGitIfMissing) {
clearError();
confirmBanner.style.display = "none";
const folderPath = folderInput.value.trim();
if (!folderPath) {
showError("请先选择目标文件夹");
return;
}
const resp = await fetch("/api/projects", {
method: "POST",
body: JSON.stringify({
target_folder_path: folderPath,
name: nameInput.value.trim() || undefined,
init_git_if_missing: initGitIfMissing,
}),
});
const body = await resp.json();
if (resp.status === 200) {
setCurrentProjectId(body.project_id);
window.location.hash = "#/";
return;
}
if (body.error === "GIT_INIT_DECLINED") {
confirmBanner.style.display = "block";
return;
}
showError(body.error + (body.detail ? ":" + body.detail : ""));
}
document.getElementById("btn_create").addEventListener("click", () => createProject(false));
document.getElementById("btn_confirm_init").addEventListener("click", () => createProject(true));
document.getElementById("btn_cancel_init").addEventListener("click", () => {
confirmBanner.style.display = "none";
});
}
// ---- shared: pages that need a "current project" to already exist ----
function renderNoProjectSelected() {
return `
`;
}
// ---- v_goal_composer (02-V §2.3): propose a new goal ----
const GOAL_TEXT_MAX_LEN = 4000;
function renderGoalComposer() {
if (!getCurrentProjectId()) return renderNoProjectSelected();
return `
提出新目标
用自然语言描述你想让 graphlemon 完成的目标
`;
}
function mountGoalComposer() {
if (!getCurrentProjectId()) return;
const textArea = document.getElementById("goal_text");
const errorBox = document.getElementById("goal_error");
const form = document.getElementById("goal_form");
const confirmBanner = document.getElementById("goal_confirm_banner");
const confirmSummary = document.getElementById("goal_confirm_summary");
document.getElementById("btn_submit_goal").addEventListener("click", () => {
errorBox.style.display = "none";
const goalText = textArea.value.trim();
if (!goalText) {
errorBox.textContent = "目标描述不能为空";
errorBox.style.display = "block";
return;
}
confirmSummary.textContent = "即将创建任务:" + goalText;
form.style.display = "none";
confirmBanner.style.display = "block";
});
document.getElementById("btn_edit_goal").addEventListener("click", () => {
confirmBanner.style.display = "none";
form.style.display = "block";
});
document.getElementById("btn_confirm_goal").addEventListener("click", async () => {
const resp = await fetch("/api/tasks", {
method: "POST",
body: JSON.stringify({
project_id: getCurrentProjectId(),
goal_text: textArea.value.trim(),
task_type: "goal",
created_via: "human_console",
}),
});
const body = await resp.json();
if (resp.status !== 200) {
confirmBanner.style.display = "none";
form.style.display = "block";
errorBox.textContent = body.error + (body.detail ? ":" + body.detail : "");
errorBox.style.display = "block";
return;
}
// 02-V §2.3: 成功跳转 v_task_detail
window.location.hash = "#/tasks/" + body.task_id;
});
}
// ---- v_bug_feedback (02-V §2.4): report a bug ----
function renderBugFeedback() {
if (!getCurrentProjectId()) return renderNoProjectSelected();
return `
反馈 Bug
人工反馈,与 Validator 自动生成的 bug_record 复用同一张表(source=human)
`;
}
function mountBugFeedback() {
if (!getCurrentProjectId()) return;
const descriptionArea = document.getElementById("bug_description");
const reproStepsArea = document.getElementById("bug_repro_steps");
const relatedTaskIdSelect = document.getElementById("bug_related_task_id");
const errorBox = document.getElementById("bug_error");
const successBox = document.getElementById("bug_success");
const form = document.getElementById("bug_form");
fetch("/api/tasks?project_id=" + encodeURIComponent(getCurrentProjectId()))
.then((resp) => resp.json())
.then((tasks) => {
for (const task of tasks) {
const option = document.createElement("option");
option.value = task.task_id;
option.textContent = task.goal_text.slice(0, 40) + " (" + task.task_id.slice(0, 8) + ")";
relatedTaskIdSelect.appendChild(option);
}
});
document.getElementById("btn_submit_bug").addEventListener("click", async () => {
errorBox.style.display = "none";
const description = descriptionArea.value.trim();
if (!description) {
errorBox.textContent = "问题描述不能为空";
errorBox.style.display = "block";
return;
}
const relatedTaskId = relatedTaskIdSelect.value || undefined;
const resp = await fetch("/api/bug-reports", {
method: "POST",
body: JSON.stringify({
project_id: getCurrentProjectId(),
description: description,
repro_steps: reproStepsArea.value.trim() || undefined,
related_task_id: relatedTaskId,
}),
});
const body = await resp.json();
if (resp.status !== 200) {
errorBox.textContent = body.error + (body.detail ? ":" + body.detail : "");
errorBox.style.display = "block";
return;
}
// 02-V §2.4: 跳转到该 bug 的详情视图,MVP 复用 v_task_detail 的 bug 展示区
// -- only possible when the report was tied to a task; otherwise there's
// no task detail page to land on, so show the confirmation inline.
if (relatedTaskId) {
window.location.hash = "#/tasks/" + relatedTaskId;
return;
}
form.style.display = "none";
successBox.innerHTML = "已提交,Validator 将进行归因确认(bug " + body.bug_id + ")。"
+ '返回仪表盘 ';
successBox.style.display = "block";
});
}
// ---- v_dashboard (02-V §2.2): real-time landing page ----
const ORG_NODE_ORDER = ["planner", "coder", "validator"];
function renderDashboard() {
if (!getCurrentProjectId()) return renderNoProjectSelected();
return `
`;
}
function mountDashboard() {
const projectId = getCurrentProjectId();
if (!projectId) return;
const orgStrip = document.getElementById("org_strip");
const taskSummary = document.getElementById("task_summary");
const budgetSummary = document.getElementById("budget_summary");
const eventTimeline = document.getElementById("event_timeline");
const quotaBanner = document.getElementById("quota_info_banner");
const wsBanner = document.getElementById("ws_disconnected_banner");
const recentEvents = [];
function renderOrgStrip(activeOrgNode, runStatusByOrgNode) {
orgStrip.innerHTML = ORG_NODE_ORDER.map((node, i) => {
const isActive = activeOrgNode === node;
const runStatus = runStatusByOrgNode[node];
const hint = isActive ? "当前节点" : runStatus === "done" ? "已完成" : runStatus || "未开始";
const arrow = i < ORG_NODE_ORDER.length - 1 ? '→
' : "";
return ''
+ node + '
' + escapeHtml(hint) + "
" + arrow;
}).join("");
}
async function refreshTasksAndOrgGraph() {
const resp = await fetch("/api/tasks?project_id=" + encodeURIComponent(projectId));
const tasks = await resp.json();
const active = tasks.find((t) => t.status !== "done") || tasks[0];
if (!active) {
orgStrip.innerHTML = '暂无运行中任务
';
taskSummary.innerHTML = '';
quotaBanner.style.display = "none";
return;
}
taskSummary.innerHTML = (
'Goal ' + escapeHtml(active.goal_text) + "
"
+ 'Loop Round ' + active.loop_round + "
"
+ '"
);
const detailResp = await fetch("/api/tasks/" + active.task_id);
const detail = await detailResp.json();
const runStatusByOrgNode = {};
let waitingNode = null;
for (const run of detail.work_graph_runs) {
runStatusByOrgNode[run.org_node] = run.status;
if (!waitingNode) waitingNode = run.nodes.find((n) => n.status === "waiting_for_quota");
}
renderOrgStrip(active.assigned_org_node, runStatusByOrgNode);
if (waitingNode) {
quotaBanner.innerHTML = "🕐 有任务正在等待 Claude Code 额度恢复 · "
+ '查看详情 ';
quotaBanner.style.display = "flex";
} else {
quotaBanner.style.display = "none";
}
}
async function refreshBudget() {
const resp = await fetch("/api/settings/budget");
const configs = await resp.json();
if (configs.length === 0) {
budgetSummary.innerHTML = '预算护栏未配置
';
return;
}
const c = configs[0];
const pct = c.limit_value > 0 ? Math.min(100, Math.round((c.current_usage / c.limit_value) * 100)) : 0;
budgetSummary.innerHTML = (
'' + c.current_usage.toLocaleString() + " / "
+ c.limit_value.toLocaleString() + " " + escapeHtml(c.limit_type) + "
"
+ ''
);
}
function prependEvent(event) {
recentEvents.unshift(event);
recentEvents.length = Math.min(recentEvents.length, 20);
eventTimeline.innerHTML = recentEvents.map((e) =>
'' + escapeHtml(e.event_type) + '
'
+ escapeHtml(e.created_at || "") + "
"
).join("") || '暂无事件
';
}
refreshTasksAndOrgGraph();
refreshBudget();
eventTimeline.innerHTML = '暂无事件
';
const socket = connectEventSocket(projectId, null, {
onEvent: (event) => {
prependEvent(event);
refreshTasksAndOrgGraph();
if (event.event_type === "budget_updated") refreshBudget();
},
onStatusChange: (status) => {
wsBanner.style.display = status === "disconnected" ? "block" : "none";
},
});
registerRouteCleanup(() => socket.close());
}
// ---- v_task_board (02-V §2.5): task list with filters ----
const TASK_STATUSES = [
"queued", "planning_brd_prd_sdd", "planning_failed", "coding", "coding_failed",
"validating", "routed_fast_lane", "routed_full_replan", "blocked_pending_human", "done",
];
function renderTaskBoard() {
if (!getCurrentProjectId()) return renderNoProjectSelected();
return `
`;
}
function mountTaskBoard() {
const projectId = getCurrentProjectId();
if (!projectId) return;
const rowsEl = document.getElementById("tb_rows");
const keywordEl = document.getElementById("tb_keyword");
const statusEl = document.getElementById("tb_status");
const orgNodeEl = document.getElementById("tb_org_node");
async function refresh() {
const params = new URLSearchParams({ project_id: projectId });
if (keywordEl.value.trim()) params.set("keyword", keywordEl.value.trim());
if (statusEl.value) params.set("status", statusEl.value);
if (orgNodeEl.value) params.set("org_node", orgNodeEl.value);
const resp = await fetch("/api/tasks?" + params.toString());
const tasks = await resp.json();
if (tasks.length === 0) {
rowsEl.innerHTML = '暂无任务 ';
return;
}
rowsEl.innerHTML = tasks.map((t) => (
''
+ "" + t.task_id + " "
+ "" + escapeHtml(t.goal_text) + " "
+ '' + t.status + " "
+ "" + t.assigned_org_node + " "
+ "" + t.loop_round + " "
+ "" + escapeHtml(t.updated_at) + " "
+ " "
)).join("");
for (const row of rowsEl.querySelectorAll("tr[data-task-id]")) {
row.addEventListener("click", () => {
window.location.hash = "#/tasks/" + row.dataset.taskId;
});
}
}
keywordEl.addEventListener("input", refresh);
statusEl.addEventListener("change", refresh);
orgNodeEl.addEventListener("change", refresh);
refresh();
const socket = connectEventSocket(projectId, null, {
onEvent: (event) => {
if (event.event_type === "task_status_changed") refresh();
},
onStatusChange: () => {},
});
registerRouteCleanup(() => socket.close());
}
// ---- v_graph_visualizer (02-V §2.7): embedded Work Graph DAG, layered by
// execution depth (computed from graph_manifest's depends_on edges) and
// colored by work_node_execution.status. Not an independent route --
// embedded into v_task_detail below. The Org Graph layer from the same
// spec section is skipped here since v_task_detail already renders an
// equivalent org-strip panel just above (same classes/data); duplicating
// it a second time immediately below would just be redundant scroll.
function computeGraphLayers(manifestNodes) {
const nodesById = {};
for (const n of manifestNodes) nodesById[n.node_id] = n;
const layerById = {};
function layerOf(nodeId, seen) {
if (nodeId in layerById) return layerById[nodeId];
if (seen.has(nodeId)) return 0; // dependency cycle -- shouldn't happen, don't loop forever
seen.add(nodeId);
const deps = (nodesById[nodeId] && nodesById[nodeId].depends_on) || [];
const layer = deps.length === 0 ? 0 : 1 + Math.max(...deps.map((d) => layerOf(d, seen)));
layerById[nodeId] = layer;
return layer;
}
for (const n of manifestNodes) layerOf(n.node_id, new Set());
const maxLayer = manifestNodes.length ? Math.max(...manifestNodes.map((n) => layerById[n.node_id])) : -1;
const rows = Array.from({ length: maxLayer + 1 }, () => []);
for (const n of manifestNodes) rows[layerById[n.node_id]].push(n);
return rows;
}
const GRAPH_LEGEND_HTML = [
["pending", "status-pending"], ["running", "status-running"],
["reflecting / retrying", "status-reflecting"], ["waiting_for_quota(信息态,非告警)", "status-waiting_for_quota"],
["blocked / failed", "status-blocked"], ["done", "status-done"],
].map(([label, cls]) =>
' ' + label + "
"
).join("");
function renderGraphVisualizerPanel(run) {
if (!run.graph_manifest || run.graph_manifest.nodes.length === 0) return "";
const nodesById = {};
for (const n of run.nodes) nodesById[n.node_id] = n;
const rowsHtml = computeGraphLayers(run.graph_manifest.nodes).map((row) => {
const cellsHtml = row.map((gn) => {
const wn = nodesById[gn.node_id];
const status = wn ? wn.status : "pending";
const quotaAttrs = wn && wn.status === "waiting_for_quota"
? ' data-quota-limit-type="' + escapeHtml(wn.quota_limit_type || "")
+ '" data-quota-wait-since="' + escapeHtml(wn.quota_wait_started_at || "") + '"'
: "";
return (
'"
+ '
' + escapeHtml(gn.node_id) + "
"
+ '
' + escapeHtml(wn ? wn.subagent_type : "尚未开始") + "
"
+ "
"
);
}).join("");
return '' + cellsHtml + "
";
}).join("");
return (
'' + run.org_node + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ") "
+ '
' + rowsHtml + "
"
+ '
' + GRAPH_LEGEND_HTML + "
"
+ "
"
);
}
function mountGraphVisualizerTooltips(container) {
const tooltip = document.getElementById("graph-tooltip");
if (!tooltip) return;
for (const node of container.querySelectorAll(".work-node")) {
node.addEventListener("mousemove", (evt) => {
const lines = ["skill: " + (node.querySelector(".node-skill").textContent || "")];
if (node.dataset.quotaLimitType) {
lines.push("quota_limit_type: " + node.dataset.quotaLimitType);
lines.push("已等待:" + formatWaitDuration(node.dataset.quotaWaitSince));
}
tooltip.innerHTML = "" + escapeHtml(node.dataset.nodeExecId || node.querySelector(".node-id").textContent) + " "
+ lines.map(escapeHtml).join(" ");
tooltip.style.left = evt.pageX + 12 + "px";
tooltip.style.top = evt.pageY + 12 + "px";
tooltip.classList.add("show");
});
node.addEventListener("mouseout", () => tooltip.classList.remove("show"));
}
}
function formatWaitDuration(sqliteTimestamp) {
if (!sqliteTimestamp) return "未知时长";
const startedAt = new Date(sqliteTimestamp.replace(" ", "T") + "Z").getTime();
const minutes = Math.max(0, Math.round((Date.now() - startedAt) / 60000));
return minutes < 60 ? minutes + " 分钟" : Math.round(minutes / 60) + " 小时";
}
// ---- v_task_detail (02-V §2.6): task detail / process inspection ----
// Embeds a to-do tree built from graph_manifest (01-M 1.3's "补充说明")
// and the v_graph_visualizer DAG view above. The raw-log v_log_viewer is a
// separate Unit (U-027) and shows as a clearly-labeled placeholder here
// until it lands, rather than faking its content.
function renderTaskDetail(taskId) {
if (!getCurrentProjectId()) return renderNoProjectSelected();
return `
`;
}
function mountTaskDetail(taskId) {
const projectId = getCurrentProjectId();
if (!projectId) return;
const content = document.getElementById("td_content");
function renderTodoTree(run) {
if (!run.graph_manifest) return "";
const nodesById = {};
for (const n of run.nodes) nodesById[n.node_id] = n;
const items = run.graph_manifest.nodes.map((gn) => {
const wn = nodesById[gn.node_id];
const status = wn ? wn.status : "pending";
const detail = wn ? (
''
+ "retry_count: " + wn.retry_count
+ (wn.reflection_notes ? "
reflection_notes: " + escapeHtml(wn.reflection_notes) : "")
+ '
原始日志查看器(v_log_viewer)随 U-027 补上
'
+ "
"
) : "";
return (
''
+ ''
+ ' '
+ 'to-do: ' + escapeHtml(gn.node_id) + " "
+ '' + status + (wn ? " · node_exec_id=" + wn.node_exec_id : "") + " "
+ "
" + detail + " "
);
}).join("");
return (
'' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ") "
+ '
"
+ '
DAG 视图(同一份 graph_manifest 的另一种呈现)见下方 v_graph_visualizer
'
+ "
"
);
}
function renderDetail(task) {
const activeSet = new Set([task.assigned_org_node]);
for (const run of task.work_graph_runs) {
if (run.status !== "done" && run.status !== "failed") activeSet.add(run.org_node);
}
const orgStripHtml = ORG_NODE_ORDER.map((node, i) => {
const arrow = i < ORG_NODE_ORDER.length - 1 ? '→
' : "";
return '' + node + "
" + arrow;
}).join("");
const alertHtml = task.status !== "blocked_pending_human" ? "" : (
'⚠ 已阻塞(' + escapeHtml(task.blocked_reason || "") + '),需要人工干预'
+ '人工干预(manual_resume)
'
);
const todoTreesHtml = task.work_graph_runs.map(renderTodoTree).join("");
const graphVisualizerHtml = task.work_graph_runs.map(renderGraphVisualizerPanel).join("");
const artifactsHtml = task.artifacts.length ? (
'' + task.artifacts.map((a) =>
"" + escapeHtml(a.file_path) + " "
+ escapeHtml(a.artifact_type) + " · " + escapeHtml(a.validation_status) + " "
).join("") + " "
) : '暂无 Artifact
';
const bugsHtml = task.bug_records.length ? (
'' + task.bug_records.map((b) =>
"" + escapeHtml(b.description) + " "
+ escapeHtml(b.root_cause_category || "待归因") + " · " + escapeHtml(b.status) + " "
).join("") + " "
) : '暂无 Bug 记录
';
content.innerHTML = (
"" + escapeHtml(task.goal_text) + " "
+ alertHtml
+ '"
+ 'Org Graph ' + orgStripHtml + "
"
+ todoTreesHtml
+ graphVisualizerHtml
+ '
'
+ '
关联 Artifact ' + artifactsHtml + ""
+ '
关联 Bug 记录 ' + bugsHtml + ""
+ '原始执行日志 v_log_viewer 随 U-027 补上
'
);
for (const row of content.querySelectorAll(".todo-row[data-node-exec-id]")) {
row.addEventListener("click", () => {
const nodeExecId = row.dataset.nodeExecId;
if (!nodeExecId) return;
const detail = document.getElementById("todo-detail-" + nodeExecId);
if (detail) detail.classList.toggle("open");
});
}
mountGraphVisualizerTooltips(content);
const resumeBtn = document.getElementById("btn_manual_resume");
if (resumeBtn) {
resumeBtn.addEventListener("click", async () => {
resumeBtn.disabled = true;
await fetch("/api/tasks/" + taskId + "/route", {
method: "POST",
body: JSON.stringify({ decided_route: "manual_resume" }),
});
refresh();
});
}
}
async function refresh() {
const resp = await fetch("/api/tasks/" + taskId);
if (resp.status === 404) {
content.innerHTML = '任务不存在
';
return;
}
renderDetail(await resp.json());
}
refresh();
const socket = connectEventSocket(projectId, taskId, {
onEvent: () => refresh(),
onStatusChange: () => {},
});
registerRouteCleanup(() => socket.close());
}