/* ════════════════════════════════════════════════════════════ MAESTRO 调度台 · 前端逻辑(无框架,直连 daemon REST + WS) ════════════════════════════════════════════════════════════ */ 'use strict'; // ── 模型常量(与 src/model/status.ts / complexity.ts 对齐) ── const STATUS_LABEL = { init: '新建', analyzing: '分析拆解中', plan_review: '待确认拆解', decomposed: '已拆解', speccing: '写方案中', spec_review: '待确认方案', ready: '可执行', blocked: '被依赖阻塞', queued: '排队中', executing: '执行中', exec_review: '待审/合', failed: '失败', needs_attention: '需人工', done: '完成', paused: '暂停', cancelled: '取消', }; const STATUS_GROUP = { init: 'idle', analyzing: 'work', speccing: 'work', plan_review: 'gate', spec_review: 'gate', exec_review: 'gate', decomposed: 'container', ready: 'go', queued: 'go', executing: 'run', blocked: 'hold', paused: 'hold', failed: 'bad', needs_attention: 'bad', done: 'done', cancelled: 'dead', }; const GATE_OF = { plan_review: 'plan', spec_review: 'spec', exec_review: 'exec' }; const GATE_LABEL = { plan: '拆解评审', spec: '方案评审', exec: '结果评审' }; const CPLX_LABEL = { hard: 'HARD', medium: 'MED', easy: 'EASY' }; const EVENT_LABEL = { 'task.created': '任务创建', 'task.updated': '任务更新', 'status.changed': '状态变更', 'approval.requested': '请求审批', 'approval.granted': '审批通过', 'approval.rejected': '审批驳回', 'run.started': '运行开始', 'run.finished': '运行结束', }; const EVENT_CLASS = { 'task.created': 'ev-created', 'task.updated': 'ev-updated', 'status.changed': 'ev-status', 'approval.requested': 'ev-gatewait', 'approval.granted': 'ev-approve', 'approval.rejected': 'ev-rejected', 'run.started': 'ev-run', 'run.finished': 'ev-run', }; // ── 全局状态 ── const S = { projects: [], currentProjectId: null, tasks: [], approvals: [], events: [], // 新→旧 collapsed: new Set(), expanded: new Set(), rejectOpen: new Set(), }; const $ = (sel) => document.querySelector(sel); function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); } function fmtTime(iso) { try { return new Date(iso).toLocaleTimeString('zh-CN', { hour12: false }); } catch { return iso; } } // ── API ── async function api(path, opts = {}) { let res; try { res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...opts, }); } catch (e) { throw new Error('无法连接 daemon:' + e.message); } let data = null; try { data = await res.json(); } catch { /* 非 JSON 响应 */ } if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`); return data; } // ── Toast ── function toast(msg, kind = 'err') { const el = document.createElement('div'); el.className = `toast ${kind}`; el.textContent = msg; $('#toastRoot').appendChild(el); setTimeout(() => { el.classList.add('out'); setTimeout(() => el.remove(), 350); }, 4200); } // ── 数据加载 ── async function loadProjects() { S.projects = await api('/api/projects'); if (!S.currentProjectId && S.projects.length) S.currentProjectId = S.projects[0].id; if (S.currentProjectId && !S.projects.some((p) => p.id === S.currentProjectId)) { S.currentProjectId = S.projects.length ? S.projects[0].id : null; } } async function loadProjectData() { const pid = S.currentProjectId; if (!pid) { S.tasks = []; S.events = []; S.approvals = []; return; } const [tasks, events, approvals] = await Promise.all([ api(`/api/projects/${pid}/tasks`), api(`/api/projects/${pid}/events`), api(`/api/approvals?projectId=${encodeURIComponent(pid)}`), ]); S.tasks = tasks; S.events = events.slice().reverse(); // 接口升序 → 展示新在前 S.approvals = approvals; } async function refresh() { try { await loadProjectData(); renderAll(); } catch (e) { toast(e.message); } } async function fullRefresh() { try { await loadProjects(); await loadProjectData(); renderAll(); } catch (e) { toast(e.message); } } // ── 渲染:草稿保护(重绘前保存输入框内容与焦点) ── function snapshotDrafts() { const map = new Map(); document.querySelectorAll('textarea[id], input[id][type=text]').forEach((el) => { if (el.value) map.set(el.id, el.value); }); const focusId = document.activeElement && document.activeElement.id; return { map, focusId }; } function restoreDrafts(snap) { snap.map.forEach((v, id) => { const el = document.getElementById(id); if (el && !el.value) el.value = v; }); if (snap.focusId) { const el = document.getElementById(snap.focusId); if (el) { el.focus(); try { el.selectionStart = el.value.length; } catch { /* 非文本 */ } } } } function renderAll() { const snap = snapshotDrafts(); renderSidebar(); renderTopbar(); renderGates(); renderTree(); renderEvents(); renderParentOptions(); restoreDrafts(snap); } // ── 渲染:侧栏 ── function renderSidebar() { const ul = $('#projectList'); if (!S.projects.length) { ul.innerHTML = `
  • 暂无项目
    点「+ 新建」登记第一个仓库
  • `; return; } ul.innerHTML = S.projects.map((p) => `
  • ${esc(p.name)} ${esc(p.repoPath)}
  • `).join(''); } function renderTopbar() { const p = S.projects.find((x) => x.id === S.currentProjectId); $('#projTitle').textContent = p ? p.name : '未选择项目'; $('#projMeta').textContent = p ? `${p.repoPath} · 分支 ${p.defaultBranch} · ${p.status === 'active' ? '活跃' : '暂停'} · 任务 ${S.tasks.length}` : ''; const gc = $('#gateCount'); if (S.approvals.length) { gc.hidden = false; gc.textContent = `⚠ ${S.approvals.length} 项待审批`; } else gc.hidden = true; } // ── 渲染:审批闸 ── function gateDocs(t) { const gate = GATE_OF[t.status]; const blocks = []; const doc = (label, text) => blocks.push( `
    ${label}
    ` + (text ? `
    ${esc(text)}
    ` : `
    (未填写)
    `)); if (gate === 'plan') doc('PLAN · 分析与拆解', t.plan); if (gate === 'spec') doc('SPEC · 改动方案', t.spec); if (gate === 'exec') { if (t.result) { const r = t.result; blocks.push(`
    RESULT · 执行结果
    ${r.branch ? `
    分支
    ${esc(r.branch)}
    ` : ''} ${r.worktree ? `
    worktree
    ${esc(r.worktree)}
    ` : ''} ${r.prUrl ? `
    PR
    ${esc(r.prUrl)}
    ` : ''}
    `); if (r.diffSummary) blocks.push(`
    DIFF 摘要
    ${esc(r.diffSummary)}
    `); } else { blocks.push(`
    (无执行结果记录)
    `); } if (t.operations) doc('OPERATIONS · 执行的操作', t.operations); else if (t.spec) doc('SPEC · 改动方案', t.spec); } return blocks.join(''); } function renderGates() { const sec = $('#gateSection'); if (!S.approvals.length) { sec.hidden = true; return; } sec.hidden = false; $('#gateList').innerHTML = S.approvals.map((t) => { const gate = GATE_OF[t.status]; const rejOpen = S.rejectOpen.has(t.id); return `
    ${GATE_LABEL[gate]} ${esc(t.title)} ${cplxBadge(t.complexity)} ${statusChip(t.status)}
    ${gateDocs(t)}
    ${gate === 'exec' ? `接受 = 认可改动并标记完成(合并不自动执行)` : ''}
    ${rejOpen ? `
    ` : ''}
    `; }).join(''); } // ── 渲染:任务树 ── function cplxBadge(c) { return `${CPLX_LABEL[c] || esc(c)}`; } function statusChip(st) { return `${STATUS_LABEL[st] || esc(st)}`; } function childrenMap() { const m = new Map(); for (const t of S.tasks) { const key = t.parentId || '__root__'; if (!m.has(key)) m.set(key, []); m.get(key).push(t); } return m; } function renderTree() { const root = $('#taskTree'); if (!S.currentProjectId) { root.innerHTML = `
    NO PROJECT先在左侧新建或选择一个项目
    `; return; } if (!S.tasks.length) { root.innerHTML = `
    EMPTY还没有任务 —— 点上方「+ 新建任务」开始
    `; return; } const m = childrenMap(); const renderNode = (t) => { const kids = m.get(t.id) || []; const collapsed = S.collapsed.has(t.id); const expanded = S.expanded.has(t.id); const isGate = !!GATE_OF[t.status]; const caret = kids.length ? `` : `·`; return `
    ${caret} ${esc(t.title)}${esc(t.id.slice(-6))} P${t.priority} ${cplxBadge(t.complexity)} ${statusChip(t.status)}
    ${expanded ? renderDetail(t) : ''} ${kids.length && !collapsed ? `
    ${kids.map(renderNode).join('')}
    ` : ''}
    `; }; root.innerHTML = (m.get('__root__') || []).map(renderNode).join(''); } // ── 渲染:任务详情 ── function writableField(t) { if (t.status === 'analyzing') return { field: 'plan', label: '分析与拆解(plan)', next: 'plan_review' }; if (t.status === 'speccing') return { field: 'spec', label: '改动方案(spec)', next: 'spec_review' }; if (t.status === 'ready') return { field: 'operations', label: '将执行的操作(operations)', next: null }; return null; } function renderDetail(t) { const parts = []; // 已有产出 const docs = [['plan', 'PLAN · 分析拆解'], ['spec', 'SPEC · 方案'], ['operations', 'OPERATIONS · 操作']]; for (const [f, label] of docs) { if (t[f]) parts.push(`
    ${label}
    ${esc(t[f])}
    `); } // 写产出入口 const w = writableField(t); if (w) { parts.push(`
    填写 ${w.label}
    ${w.next ? `` : ''}
    `); } // result if (t.result) { const r = t.result; parts.push(`
    RESULT · 执行结果
    分支
    ${esc(r.branch || '—')}
    worktree
    ${esc(r.worktree || '—')}
    ${r.prUrl ? `
    PR
    ${esc(r.prUrl)}
    ` : ''} ${r.commits && r.commits.length ? `
    commits
    ${r.commits.map(esc).join('
    ')}
    ` : ''}
    ${r.diffSummary ? `
    ${esc(r.diffSummary)}
    ` : ''}
    `); } // 审批历史 if (t.approvals && t.approvals.length) { parts.push(`
    审批历史
    ${t.approvals.map((a) => `
    ${a.action === 'accept' ? '✓ 通过' : '✗ 驳回'} ${GATE_LABEL[a.gate] || esc(a.gate)} · ${esc(a.actor)} ${fmtTime(a.at)} ${a.reason ? `${esc(a.reason)}` : ''}
    `).join('')}
    `); } if (!parts.length) parts.push(`
    暂无产出与历史 —— 状态:${STATUS_LABEL[t.status]}
    `); parts.push(`
    id ${esc(t.id)} · 深度 ${t.depth} · 创建 ${fmtTime(t.createdAt)} · 更新 ${fmtTime(t.updatedAt)}${t.deps && t.deps.length ? ' · 依赖 ' + t.deps.map(esc).join(', ') : ''}
    `); return `
    ${parts.join('')}
    `; } // ── 渲染:事件流 ── function eventDetail(e) { const t = S.tasks.find((x) => x.id === e.taskId); const name = t ? t.title : (e.taskId ? e.taskId.slice(-6) : ''); const p = e.payload || {}; if (e.type === 'status.changed') { return `${name}:${STATUS_LABEL[p.from] || p.from} → ${STATUS_LABEL[p.to] || p.to}`; } if (e.type === 'approval.granted' || e.type === 'approval.rejected') { const r = p.reason ? `(${p.reason})` : ''; return `${name} · ${GATE_LABEL[p.gate] || p.gate}${r}`; } if (e.type === 'task.created') { if (p.kind === 'project') return `项目「${p.name || ''}」已登记`; return `${p.title || name}(${CPLX_LABEL[p.complexity] || ''})`; } if (e.type === 'task.updated') return `${name} · 字段 ${p.field || ''}`; if (e.type === 'run.started' || e.type === 'run.finished') { return `${name} · ${p.kind || ''} ${p.status || ''}`; } return name; } function renderEvents() { const ul = $('#eventList'); if (!S.events.length) { ul.innerHTML = `
  • 暂无事件
  • `; return; } ul.innerHTML = S.events.slice(0, 80).map((e) => `
  • ${EVENT_LABEL[e.type] || esc(e.type)} ${fmtTime(e.at)}
    ${eventDetail(e)}
  • `).join(''); } // ── 渲染:父任务下拉 ── function renderParentOptions() { const sel = document.querySelector('#newTaskPanel select[name=parentId]'); const cur = sel.value; sel.innerHTML = `` + S.tasks.map((t) => ``).join(''); sel.value = cur; } // ── 动作 ── async function act(fn, okMsg) { try { await fn(); if (okMsg) toast(okMsg, 'ok'); await refresh(); } catch (e) { toast(e.message); } } document.addEventListener('click', (ev) => { const el = ev.target.closest('[data-action]'); if (!el) return; const action = el.dataset.action; const id = el.dataset.id; switch (action) { case 'select-project': if (S.currentProjectId !== id) { S.currentProjectId = id; S.expanded.clear(); S.collapsed.clear(); S.rejectOpen.clear(); refresh(); } break; case 'open-new-project': $('#modalRoot').hidden = false; document.querySelector('#newProjectForm input[name=name]').focus(); break; case 'close-modal': $('#modalRoot').hidden = true; break; case 'toggle-new-task': { const p = $('#newTaskPanel'); p.hidden = !p.hidden; if (!p.hidden) p.querySelector('input[name=title]').focus(); break; } case 'toggle-collapse': ev.stopPropagation(); S.collapsed.has(id) ? S.collapsed.delete(id) : S.collapsed.add(id); renderTree(); break; case 'toggle-detail': { // 点的是 caret 时由上面分支处理(stopPropagation);输入区内点击不折叠 if (ev.target.closest('.task-detail')) break; S.expanded.has(id) ? S.expanded.delete(id) : S.expanded.add(id); renderTree(); break; } case 'gate-accept': act(() => api(`/api/tasks/${id}/decide`, { method: 'POST', body: JSON.stringify({ action: 'accept' }), }), '已接受'); break; case 'gate-reject-toggle': S.rejectOpen.has(id) ? S.rejectOpen.delete(id) : S.rejectOpen.add(id); renderGates(); if (S.rejectOpen.has(id)) { const ta = document.getElementById(`rej-${id}`); if (ta) ta.focus(); } break; case 'gate-reject-confirm': { const ta = document.getElementById(`rej-${id}`); const reason = ta ? ta.value.trim() : ''; if (!reason) { toast('驳回意见不能为空'); if (ta) ta.focus(); return; } S.rejectOpen.delete(id); act(() => api(`/api/tasks/${id}/decide`, { method: 'POST', body: JSON.stringify({ action: 'reject', reason }), }), '已驳回,任务退回返工'); break; } case 'save-output': { const field = el.dataset.field; const ta = document.getElementById(`out-${id}`); const value = ta ? ta.value.trim() : ''; if (!value) { toast(`${field} 内容不能为空`); return; } act(() => api(`/api/tasks/${id}/${field}`, { method: 'POST', body: JSON.stringify({ [field]: value }), }), `${field} 已保存`); break; } case 'submit-review': { const to = el.dataset.to; const ta = document.getElementById(`out-${id}`); const task = S.tasks.find((t) => t.id === id); const field = task && writableField(task) ? writableField(task).field : null; const value = ta ? ta.value.trim() : ''; act(async () => { // 先保存当前草稿(有内容才保存),再流转进评审闸 if (field && value) { await api(`/api/tasks/${id}/${field}`, { method: 'POST', body: JSON.stringify({ [field]: value }), }); } await api(`/api/tasks/${id}/transition`, { method: 'POST', body: JSON.stringify({ to }), }); }, '已提交评审'); break; } } }); // 阻止详情区域内点击冒泡触发折叠 document.addEventListener('click', (ev) => { if (ev.target.closest('.task-detail') && !ev.target.closest('[data-action]')) { ev.stopPropagation(); } }, true); // ── 表单提交 ── $('#newProjectForm').addEventListener('submit', (ev) => { ev.preventDefault(); const f = ev.target; const name = f.name.value.trim(); const repoPath = f.repoPath.value.trim(); if (!name || !repoPath) { toast('名称与仓库路径必填'); return; } const body = { name, repoPath }; if (f.defaultBranch.value.trim()) body.defaultBranch = f.defaultBranch.value.trim(); if (f.verifyCmd.value.trim()) body.verifyCmd = f.verifyCmd.value.trim(); act(async () => { const p = await api('/api/projects', { method: 'POST', body: JSON.stringify(body) }); S.currentProjectId = p.id; f.reset(); $('#modalRoot').hidden = true; await loadProjects(); }, `项目「${name}」已创建`); }); $('#newTaskPanel').addEventListener('submit', (ev) => { ev.preventDefault(); const f = ev.target; if (!S.currentProjectId) { toast('请先选择项目'); return; } const title = f.title.value.trim(); if (!title) { toast('标题必填'); return; } const body = { title, complexity: f.complexity.value, priority: Number(f.priority.value || 0), }; if (f.parentId.value) body.parentId = f.parentId.value; act(async () => { await api(`/api/projects/${S.currentProjectId}/tasks`, { method: 'POST', body: JSON.stringify(body), }); f.title.value = ''; }, '任务已创建'); }); // Esc 关模态 document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') $('#modalRoot').hidden = true; }); // ── WebSocket 实时刷新(指数退避重连) ── let wsAttempt = 0; let refreshTimer = null; function setWsState(on, text) { $('#wsDot').className = `ws-dot ${on ? 'on' : 'off'}`; $('#wsText').textContent = text; } function scheduleRefresh() { if (refreshTimer) return; refreshTimer = setTimeout(() => { refreshTimer = null; refresh(); }, 200); } function connectWs() { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const ws = new WebSocket(`${proto}://${location.host}/ws`); ws.onopen = () => { wsAttempt = 0; setWsState(true, '实时连接'); fullRefresh(); // 重连后补齐错过的状态 }; ws.onmessage = (msg) => { let evt; try { evt = JSON.parse(msg.data); } catch { return; } if (evt.projectId === S.currentProjectId) { scheduleRefresh(); } else if (evt.type === 'task.created' && evt.payload && evt.payload.kind === 'project') { loadProjects().then(renderSidebar).catch(() => {}); } }; const retry = () => { wsAttempt += 1; const delay = Math.min(30000, 1000 * Math.pow(2, wsAttempt - 1)); setWsState(false, `已断开 · ${Math.round(delay / 1000)}s 后重连`); setTimeout(connectWs, delay); }; ws.onclose = retry; ws.onerror = () => { try { ws.close(); } catch { /* noop */ } }; } // ── 启动 ── fullRefresh().then(connectWs);