// 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 `
← 返回仪表盘

创建 / 切换项目

选择一个本地文件夹作为目标项目的代码空间
点击"选择文件夹"会由后端代为弹出系统原生对话框,不支持手动输入路径(除非返回 DIALOG_UNAVAILABLE)
`; } 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 `

仪表盘

Org Graph 三节点状态

当前任务概要

预算用量

最近事件

`; } 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 + "
" + '
Status' + escapeHtml(active.status) + "
" ); 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 `

任务看板

task_idgoal_textstatusassigned_org_nodeloop_roundupdated_at
`; } 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_task_detail (02-V §2.6): task detail / process inspection ---- // Embeds a to-do tree built from graph_manifest (01-M 1.3's "补充说明") -- // the DAG-layout v_graph_visualizer and the raw-log v_log_viewer are each // separate Units (U-026/U-027) and show as clearly-labeled placeholders // here until they land, rather than faking their 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 视图(v_graph_visualizer)随 U-026 补上
    ' + "
    " ); } 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 || "") + '),需要人工干预' + '
    ' ); const todoTreesHtml = task.work_graph_runs.map(renderTodoTree).join(""); const artifactsHtml = task.artifacts.length ? ( '" ) : '
    暂无 Artifact
    '; const bugsHtml = task.bug_records.length ? ( '" ) : '
    暂无 Bug 记录
    '; content.innerHTML = ( "

    " + escapeHtml(task.goal_text) + "

    " + alertHtml + '
    ' + "
    STATUS" + escapeHtml(task.status) + "
    " + "
    LOOP_ROUND" + task.loop_round + "
    " + "
    ASSIGNED_ORG_NODE" + task.assigned_org_node + "
    " + "
    CREATED_VIA" + escapeHtml(task.created_via) + "
    " + "
    " + '

    Org Graph

    ' + orgStripHtml + "
    " + todoTreesHtml + '

    关联 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"); }); } 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()); }