// 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();
},
};
}
// c_ws_log_tail (03-C §3.6.2, U-027): live tail for one Work Node's raw log.
// No auto-reconnect here, unlike connectEventSocket -- 03-C is explicit that
// the server closes this connection on purpose once the node reaches a
// terminal status, and the caller (mountLogViewer) is expected to switch to
// c_execution_log_get's static mode on close, not treat it as a dropped
// connection to retry.
function connectLogTailSocket(nodeExecId, { onChunk, onClose }) {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = wsProtocol + "//" + window.location.host + "/api/ws/work-nodes/" + encodeURIComponent(nodeExecId) + "/log";
const socket = new WebSocket(url);
socket.onmessage = (event) => onChunk(JSON.parse(event.data).appended_content);
socket.onclose = () => onClose();
socket.onerror = () => socket.close();
return { close: () => socket.close() };
}
const routes = {
"#/": { render: renderDashboard, mount: mountDashboard },
"#/tasks": { render: renderTaskBoard, mount: mountTaskBoard },
"#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser },
"#/skills": { render: renderSkillLibrary, mount: mountSkillLibrary },
"#/deployment": { render: renderDeploymentStatus, mount: mountDeploymentStatus },
"#/settings/concurrency": { render: renderConcurrencySettings, mount: mountConcurrencySettings },
"#/settings/budget": { render: renderBudgetSettings, mount: mountBudgetSettings },
"#/settings/log-retention": { render: renderLogRetentionSettings, mount: mountLogRetentionSettings },
"#/setup": { render: renderProjectSetup, mount: mountProjectSetup },
"#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer },
"#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback },
};
// #/tasks/:task_id (v_task_detail) and #/skills/:skill_id/audit
// (v_skill_audit_log) are the only dynamic routes -- not worth a full
// path-matching router for two patterns.
const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/;
const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/;
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 taskMatch = hash.match(TASK_DETAIL_RE);
const skillMatch = hash.match(SKILL_AUDIT_RE);
if (taskMatch) {
route = { render: renderTaskDetail, mount: mountTaskDetail };
routeArgs = [taskMatch[1]];
} else if (skillMatch) {
route = { render: renderSkillAuditLog, mount: mountSkillAuditLog };
routeArgs = [skillMatch[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 = '暂无事件
';
let wasDisconnected = false;
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";
if (status === "disconnected") {
wasDisconnected = true;
} else if (status === "connected" && wasDisconnected) {
// REQ-12 boundary: reconnect doesn't replay every missed event, but
// must at least catch up on current state via a fresh snapshot
// fetch (03-C §3.14's own documented reconnect story).
wasDisconnected = false;
refreshTasksAndOrgGraph();
refreshBudget();
}
},
});
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();
let wasDisconnected = false;
const socket = connectEventSocket(projectId, null, {
onEvent: (event) => {
if (event.event_type === "task_status_changed") refresh();
},
onStatusChange: (status) => {
if (status === "disconnected") {
wasDisconnected = true;
} else if (status === "connected" && wasDisconnected) {
// REQ-12 boundary: catch up via a fresh snapshot on reconnect.
wasDisconnected = false;
refresh();
}
},
});
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_log_viewer (02-V §2.7.1): embedded raw execution log ----
// Non-terminal node status -> realtime scroll mode via c_ws_log_tail;
// terminal status -> one-shot static fetch via c_execution_log_get.
// Embedded per node_exec_id inside v_task_detail's to-do tree, mounted
// lazily the first time a to-do row is expanded (mountTaskDetail wires the
// click handler and tracks cleanup for whichever ones got opened).
function renderLogViewerHtml(nodeExecId) {
return (
'"
+ ''
);
}
function mountLogViewer(nodeExecId, initialStatus) {
const viewerEl = document.getElementById("log-viewer-" + nodeExecId);
const autoScrollEl = document.querySelector('#todo-detail-' + nodeExecId + " .log-autoscroll");
if (!viewerEl) return () => {};
function appendLine(text) {
const line = document.createElement("div");
line.className = "line";
line.textContent = text;
viewerEl.appendChild(line);
if (autoScrollEl && autoScrollEl.checked) viewerEl.scrollTop = viewerEl.scrollHeight;
}
async function loadStatic() {
try {
const resp = await fetch("/api/work-nodes/" + encodeURIComponent(nodeExecId) + "/log");
if (resp.status === 404) {
viewerEl.innerHTML = '日志暂不可用
';
return;
}
if (resp.status === 410) {
viewerEl.innerHTML = '该日志已超过保留期限被清理,保留天数可在设置中调整
';
return;
}
const body = await resp.json();
viewerEl.innerHTML = "";
if (!body.content) {
viewerEl.innerHTML = '暂无日志内容
';
return;
}
for (const line of body.content.split("\n")) appendLine(line);
} catch (e) {
viewerEl.innerHTML = '日志暂不可用
';
}
}
if (isTerminalNodeStatus(initialStatus)) {
loadStatic();
return () => {};
}
// live mode still needs to show everything written so far, not just
// chunks appended after this connection opens -- backfill via the same
// static read before tailing new content (mounting is re-triggered on
// every task-level refresh, e.g. from v_task_detail's own WS feed, so
// this also re-populates content a reconnect would otherwise have wiped)
loadStatic();
const socket = connectLogTailSocket(nodeExecId, {
onChunk: (chunk) => { for (const line of chunk.split("\n")) if (line) appendLine(line); },
onClose: () => loadStatic(), // server closes on terminal status (03-C §3.6.2) -- switch to static mode
});
return () => socket.close();
}
const TERMINAL_NODE_STATUSES = new Set(["done", "blocked", "failed"]);
function isTerminalNodeStatus(status) {
return TERMINAL_NODE_STATUSES.has(status);
}
// ---- 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");
// v_log_viewer instances (02-V §2.7.1) mounted lazily on first to-do
// expand -- tracked here so a re-render (WS-triggered refresh) or leaving
// the page closes any live c_ws_log_tail connections instead of leaking them.
const activeLogViewers = new Map();
function closeAllLogViewers() {
for (const close of activeLogViewers.values()) close();
activeLogViewers.clear();
}
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) : "")
+ '
'
+ "
"
) : "";
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) {
closeAllLogViewers();
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 + ""
);
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) return;
detail.classList.toggle("open");
if (detail.classList.contains("open") && !activeLogViewers.has(nodeExecId)) {
const slot = document.getElementById("log-slot-" + nodeExecId);
slot.innerHTML = renderLogViewerHtml(nodeExecId);
activeLogViewers.set(nodeExecId, mountLogViewer(nodeExecId, detail.dataset.status));
}
});
}
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();
let wasDisconnected = false;
const socket = connectEventSocket(projectId, taskId, {
onEvent: () => refresh(),
onStatusChange: (status) => {
if (status === "disconnected") {
wasDisconnected = true;
} else if (status === "connected" && wasDisconnected) {
// REQ-12 boundary: catch up via a fresh snapshot on reconnect.
wasDisconnected = false;
refresh();
}
},
});
registerRouteCleanup(() => {
socket.close();
closeAllLogViewers();
});
}
// ---- v_artifact_browser (02-V §2.8): artifact list + read-only preview ----
// Preview renders content in a plain block for every artifact_type --
// 02-V asks for Markdown rendering / syntax highlighting, but pulling in a
// markdown/highlighting library would be this frontend's first external
// dependency (05 §5.7's "no build step" ethos extends to keeping the
// console vanilla-JS-only); a monospace raw-text preview is a deliberate,
// documented simplification, not a missed requirement.
const ARTIFACT_TYPES = ["brd", "prd", "sdd", "benchmark", "bug_record", "code_diff", "test_report"];
function renderArtifactBrowser() {
if (!getCurrentProjectId()) return renderNoProjectSelected();
return `
Artifact 浏览器
全部类型(artifact_type_filter)
${ARTIFACT_TYPES.map((t) => '' + t + " ").join("")}
全部任务(task_id_filter)
`;
}
function mountArtifactBrowser() {
const projectId = getCurrentProjectId();
if (!projectId) return;
const listEl = document.getElementById("ab_list");
const previewEl = document.getElementById("ab_preview");
const typeFilterEl = document.getElementById("ab_type_filter");
const taskFilterEl = document.getElementById("ab_task_filter");
let selectedArtifactId = null;
fetch("/api/tasks?project_id=" + encodeURIComponent(projectId))
.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) + ")";
taskFilterEl.appendChild(option);
}
});
async function showPreview(artifactId) {
selectedArtifactId = artifactId;
for (const li of listEl.querySelectorAll("li")) {
li.classList.toggle("active", li.dataset.artifactId === artifactId);
}
previewEl.innerHTML = '加载中…
';
const artifact = await fetch("/api/artifacts/" + encodeURIComponent(artifactId)).then((r) => r.json());
const lineageHtml = artifact.lineage.map((a, i) => {
const isCurrent = a.artifact_id === artifactId;
const label = escapeHtml(a.artifact_type) + (isCurrent ? "(当前)" : "");
const arrow = i < artifact.lineage.length - 1 ? " → " : "";
return '' + label + " " + arrow;
}).join("");
previewEl.innerHTML = (
'血缘链:' + lineageHtml + "
"
+ '' + (artifact.content == null ? "(文件不可用)" : escapeHtml(artifact.content)) + " "
);
}
async function refreshList() {
const params = new URLSearchParams({ project_id: projectId });
if (typeFilterEl.value) params.set("artifact_type", typeFilterEl.value);
if (taskFilterEl.value) params.set("task_id", taskFilterEl.value);
const artifacts = await fetch("/api/artifacts?" + params.toString()).then((r) => r.json());
if (artifacts.length === 0) {
listEl.innerHTML = ' 暂无产物 ';
previewEl.innerHTML = '暂无产物
';
return;
}
listEl.innerHTML = artifacts.map((a) =>
'' + escapeHtml(a.artifact_type) + "
"
+ escapeHtml(a.file_path.split("/").pop()) + " "
).join("");
for (const li of listEl.querySelectorAll("li[data-artifact-id]")) {
li.addEventListener("click", () => showPreview(li.dataset.artifactId));
}
if (selectedArtifactId && artifacts.some((a) => a.artifact_id === selectedArtifactId)) {
showPreview(selectedArtifactId);
} else {
showPreview(artifacts[0].artifact_id);
}
}
typeFilterEl.addEventListener("change", refreshList);
taskFilterEl.addEventListener("change", refreshList);
refreshList();
const socket = connectEventSocket(projectId, null, {
onEvent: (event) => { if (event.event_type === "artifact_created") refreshList(); },
onStatusChange: () => {},
});
registerRouteCleanup(() => socket.close());
}
// ---- v_skill_library (02-V §2.9) + v_skill_audit_log (02-V §2.10) ----
// Skills are a platform-wide asset living in graphlemon's own repo (see
// skill_query.py), not scoped to any target project -- these two pages
// deliberately don't check getCurrentProjectId(), unlike every other real
// page in this console.
const SOURCE_TAG_LABELS = {
migrated_from_hermes: "migrated_from_hermes", new: "new", auto_iterated: "auto_iterated",
};
function renderSkillLibrary() {
return `
`;
}
function mountSkillLibrary() {
const groupsEl = document.getElementById("skill_groups");
fetch("/api/skills").then((r) => r.json()).then((skills) => {
if (skills.length === 0) {
groupsEl.innerHTML = '暂无 Skill
';
return;
}
const byOrgNode = { planner: [], coder: [], validator: [] };
for (const s of skills) (byOrgNode[s.owner_org_node] || []).push(s);
groupsEl.innerHTML = ORG_NODE_ORDER.map((node) => {
const rows = byOrgNode[node];
if (!rows || rows.length === 0) return "";
const rowsHtml = rows.map((s) =>
''
+ '' + escapeHtml(s.skill_id) + " "
+ ''
+ escapeHtml(SOURCE_TAG_LABELS[s.source] || s.source) + " "
+ "
"
).join("");
return (
'' + node.charAt(0).toUpperCase() + node.slice(1) + " "
+ '
' + rowsHtml + "
"
);
}).join("");
for (const row of groupsEl.querySelectorAll(".skill-row")) {
row.addEventListener("click", () => {
window.location.hash = "#/skills/" + row.dataset.skillId + "/audit";
});
}
});
}
function renderSkillAuditLog(skillId) {
return `
技能库 / ${escapeHtml(skillId)}
${escapeHtml(skillId)} 变更审计
`;
}
function mountSkillAuditLog(skillId) {
const listEl = document.getElementById("skill_audit_list");
const errorEl = document.getElementById("skill_audit_error");
async function refresh() {
const resp = await fetch("/api/skills/" + encodeURIComponent(skillId) + "/audit");
if (resp.status === 404) {
listEl.innerHTML = "";
errorEl.textContent = "该 Skill 不存在";
errorEl.style.display = "block";
return;
}
const iterations = await resp.json();
if (iterations.length === 0) {
listEl.innerHTML = '该 Skill 尚无自主迭代记录
';
return;
}
// newest first for display, oldest-first order from the API gives each its #N
listEl.innerHTML = iterations.slice().reverse().map((it, i) => {
const number = iterations.length - i;
return (
''
+ '
迭代 #' + number + " applied_at: " + escapeHtml(it.applied_at) + "
"
+ '
triggered_by_bug_id: ' + escapeHtml(it.triggered_by_bug_id) + " · " + escapeHtml(it.change_summary) + "
"
+ '
'
+ '查看 diff '
+ '回滚到此版本前 '
+ "
"
+ '
'
+ "
"
);
}).join("");
for (const el of listEl.querySelectorAll(".iteration")) {
const diffOutput = el.querySelector(".diff-output");
el.querySelector(".btn-diff").addEventListener("click", async () => {
if (diffOutput.style.display === "block") {
diffOutput.style.display = "none";
return;
}
diffOutput.textContent = "加载中…";
diffOutput.style.display = "block";
const resp = await fetch(
"/api/skills/diff?before=" + encodeURIComponent(el.dataset.before) + "&after=" + encodeURIComponent(el.dataset.after)
);
const body = await resp.json();
diffOutput.textContent = body.diff_text || "(无差异)";
});
el.querySelector(".btn-rollback").addEventListener("click", async () => {
const resp = await fetch("/api/skills/" + encodeURIComponent(skillId) + "/rollback", {
method: "POST",
body: JSON.stringify({ iteration_id: el.dataset.iterationId }),
});
if (resp.status !== 200) {
const body = await resp.json();
errorEl.textContent = body.error + (body.detail ? ":" + body.detail : "");
errorEl.style.display = "block";
return;
}
errorEl.style.display = "none";
refresh();
});
}
}
refresh();
}
// ---- v_deployment_status (U-043, PH5 -- no 02-V spec exists for this view,
// it's a PH5-only addition; DOM/class conventions below follow the existing
// pages' own patterns rather than a wireframe that was never written) ----
// Not project-scoped (there's exactly one graphlemon self-target, not one
// per project, same reasoning v_skill_library's pages already apply).
// deployment.status changes aren't pushed over c_ws_subscribe (Deploy
// Supervisor writes deployment/deploy_health_signal rows directly, no
// execution_event of its own) -- this page polls on an interval instead of
// reusing connectEventSocket, and cleans the interval up via
// registerRouteCleanup like every other page cleans up its own sockets.
const DEPLOYMENT_POLL_INTERVAL_MS = 5000;
const DEPLOYMENT_STATUS_LABELS = {
staged: "已暂存", candidate_starting: "候选启动中", health_check_running: "健康检查中",
health_check_passed: "健康检查通过", health_check_failed: "健康检查失败",
cutover_in_progress: "切换中", live: "已上线", monitoring: "监控中",
anomaly_detected: "检测到异常", rollback_in_progress: "回滚中", rolled_back: "已回滚",
rollback_failed: "回滚失败", blocked_pending_human: "需要人工介入",
monitoring_window_clean: "监控窗口正常", finalized: "已完成",
};
function renderDeploymentStatus() {
return `
`;
}
function mountDeploymentStatus() {
const currentEl = document.getElementById("deployment_current");
const historyEl = document.getElementById("deployment_history");
function renderSignals(signals) {
if (!signals || signals.length === 0) return '尚无健康信号采样
';
return '' + signals.map((s) =>
'
'
+ '' + escapeHtml(s.signal_type) + ' '
+ '' + escapeHtml(s.status) + ' '
+ '' + escapeHtml(s.sampled_at) + ' '
+ (s.detail ? '' + escapeHtml(s.detail) + ' ' : '')
+ '
'
).join('') + '
';
}
function renderCurrent(current) {
if (!current) {
currentEl.innerHTML = '';
return;
}
const label = DEPLOYMENT_STATUS_LABELS[current.status] || current.status;
currentEl.innerHTML = (
''
+ '
deployment_id: ' + escapeHtml(current.deployment_id) + ' '
+ '' + escapeHtml(label) + '
'
+ '
commit ' + escapeHtml((current.git_commit_hash || '').slice(0, 12))
+ ' · started_at ' + escapeHtml(current.started_at || '-') + '
'
+ '
'
);
}
function renderHistory(deployments) {
if (deployments.length === 0) {
historyEl.innerHTML = '暂无历史部署记录
';
return;
}
historyEl.innerHTML = deployments.map((d) => {
const label = DEPLOYMENT_STATUS_LABELS[d.status] || d.status;
return (
''
+ '' + escapeHtml(d.deployment_id.slice(0, 8)) + ' '
+ '' + escapeHtml(label) + ' '
+ '' + escapeHtml(d.cutover_at || d.started_at || '-') + ' '
+ '
'
);
}).join('');
for (const row of historyEl.querySelectorAll('.deployment-row')) {
row.addEventListener('click', async () => {
const detail = await fetch('/api/deployments/' + encodeURIComponent(row.dataset.deploymentId)).then((r) => r.json());
currentEl.innerHTML = (
''
+ '
deployment_id: ' + escapeHtml(detail.deployment_id) + ' '
+ ''
+ escapeHtml(DEPLOYMENT_STATUS_LABELS[detail.status] || detail.status) + '
'
+ '
commit ' + escapeHtml((detail.git_commit_hash || '').slice(0, 12)) + '
'
+ '
最近健康信号 ' + renderSignals(detail.health_signals)
+ '
'
);
});
}
}
async function refresh() {
const [current, history] = await Promise.all([
fetch('/api/deployments/current').then((r) => r.json()),
fetch('/api/deployments').then((r) => r.json()),
]);
renderCurrent(current);
renderHistory(history);
}
refresh();
const intervalId = setInterval(refresh, DEPLOYMENT_POLL_INTERVAL_MS);
registerRouteCleanup(() => clearInterval(intervalId));
}
// ---- v_concurrency_settings / v_budget_settings / v_log_retention_settings
// (02-V §2.11/§2.12/§2.13) -- three small config pages, none of them
// project-scoped (all three tables are platform-wide singletons, config_id
// ='default'), so like the Skill pages they skip the getCurrentProjectId()
// guard. renderSettingsSubnav() stands in for 02-V's nav-dropdown, which
// this shell's single-level #nav bar doesn't have room for.
function renderSettingsSubnav(active) {
const items = [["concurrency", "并发数"], ["budget", "预算护栏"], ["log-retention", "日志保留"]];
return '";
}
function renderConcurrencySettings() {
return `
并发数配置
${renderSettingsSubnav("concurrency")}
`;
}
function mountConcurrencySettings() {
const input = document.getElementById("cs_max_concurrent_goals");
const errorEl = document.getElementById("cs_error");
const successEl = document.getElementById("cs_success");
fetch("/api/settings/concurrency").then((r) => r.json()).then((config) => {
if (config) input.value = config.max_concurrent_goals;
});
document.getElementById("btn_save_concurrency").addEventListener("click", async () => {
errorEl.style.display = "none";
successEl.style.display = "none";
const resp = await fetch("/api/settings/concurrency", {
method: "PUT",
body: JSON.stringify({ max_concurrent_goals: Number(input.value) }),
});
const body = await resp.json();
if (resp.status !== 200) {
errorEl.textContent = body.error + (body.detail ? ":" + body.detail : "");
errorEl.style.display = "block";
return;
}
successEl.textContent = "已更新,下一个新 Goal 起生效";
successEl.style.display = "block";
});
}
function renderBudgetSettings() {
return `
预算护栏配置
${renderSettingsSubnav("budget")}
人工配置层,与 Claude Code plan 额度耗尽是两套独立机制
作用范围(scope)
global
per_task
per_day
限额类型(limit_type)
token_count
call_count
限额值(limit_value)
保存
`;
}
function mountBudgetSettings() {
const scopeEl = document.getElementById("bs_scope");
const limitTypeEl = document.getElementById("bs_limit_type");
const limitValueEl = document.getElementById("bs_limit_value");
const usageEl = document.getElementById("bs_usage");
const usageFillEl = document.getElementById("bs_usage_fill");
const alertEl = document.getElementById("bs_alert");
const errorEl = document.getElementById("bs_error");
const successEl = document.getElementById("bs_success");
async function refresh() {
const configs = await fetch("/api/settings/budget").then((r) => r.json());
const config = configs[0];
if (config) {
scopeEl.value = config.scope;
limitTypeEl.value = config.limit_type;
limitValueEl.value = config.limit_value;
usageEl.textContent = "当前用量:" + config.current_usage.toLocaleString() + " / " + config.limit_value.toLocaleString();
const pct = config.limit_value > 0 ? Math.min(100, Math.round((config.current_usage / config.limit_value) * 100)) : 0;
usageFillEl.style.width = pct + "%";
} else {
usageEl.textContent = "尚未配置";
}
const projectId = getCurrentProjectId();
const blocked = projectId
? await fetch("/api/tasks?project_id=" + encodeURIComponent(projectId) + "&status=blocked_pending_human")
.then((r) => (r.ok ? r.json() : []))
: [];
if (blocked.length > 0) {
alertEl.innerHTML = "已有 " + blocked.length + " 个任务处于 blocked_pending_human · " + '查看已暂停任务 ';
alertEl.style.display = "flex";
} else {
alertEl.style.display = "none";
}
}
document.getElementById("btn_save_budget").addEventListener("click", async () => {
errorEl.style.display = "none";
successEl.style.display = "none";
const resp = await fetch("/api/settings/budget", {
method: "PUT",
body: JSON.stringify({ scope: scopeEl.value, limit_type: limitTypeEl.value, limit_value: Number(limitValueEl.value) }),
});
const body = await resp.json();
if (resp.status !== 200) {
errorEl.textContent = body.error + (body.detail ? ":" + body.detail : "");
errorEl.style.display = "block";
return;
}
successEl.textContent = "已更新" + (body.resumed_task_ids.length ? ",已恢复 " + body.resumed_task_ids.length + " 个因超限阻塞的任务" : "");
successEl.style.display = "block";
refresh();
});
refresh();
}
function renderLogRetentionSettings() {
return `
日志保留策略配置
${renderSettingsSubnav("log-retention")}
`;
}
function mountLogRetentionSettings() {
const daysEl = document.getElementById("lrs_retention_days");
const sizeEl = document.getElementById("lrs_max_total_size_mb");
const errorEl = document.getElementById("lrs_error");
const successEl = document.getElementById("lrs_success");
fetch("/api/settings/log-retention").then((r) => r.json()).then((config) => {
if (config) {
daysEl.value = config.retention_days;
if (config.max_total_size_mb != null) sizeEl.value = config.max_total_size_mb;
}
});
document.getElementById("btn_save_log_retention").addEventListener("click", async () => {
errorEl.style.display = "none";
successEl.style.display = "none";
const resp = await fetch("/api/settings/log-retention", {
method: "PUT",
body: JSON.stringify({
retention_days: Number(daysEl.value),
max_total_size_mb: sizeEl.value ? Number(sizeEl.value) : undefined,
}),
});
const body = await resp.json();
if (resp.status !== 200) {
errorEl.textContent = body.error + (body.detail ? ":" + body.detail : "");
errorEl.style.display = "block";
return;
}
successEl.textContent = "已更新";
successEl.style.display = "block";
});
}