173 lines
31 KiB
JavaScript

// HLDP-ZY://agents/zhuyuan-dev-agent/server.js
// @guardian: ICE-GL-ZY001 · 铸渊
// @sovereign: TCS-0002∞ · 冰朔
// @copyright: 国作登字-2026-A-00037559
// BD001 常驻Agent v5.3 — 零点原核UI + 步骤解析 + 经验累积
require("dotenv").config();
const express = require("express");
const path = require("path");
const fs = require("fs");
const { handleTask } = require("./modules/task-receiver");
const { sendNotification } = require("./modules/email-notify");
const { expandPlan } = require("./modules/plan-expander");
const { call: rawCall, getStatus: getModelStatus } = require("./modules/model-caller");
const { executePlan } = require("./modules/executor");
const { createLogger, readLatestRun, listRuns } = require("./modules/logger");
const { wrapModelCaller, reset, getCallCount, isBudgetExceeded } = require("./modules/cost-guard");
const { record: writeBrain, recordMilestone, recordExperience, AGENT_ID } = require("./modules/brain-writer");
const { wakeAndCheck, testConnection } = require("./modules/notion-poller");
const safeCall = wrapModelCaller(rawCall);
const app = express();
app.use(express.json({ limit: "10mb" }));
const PORT = process.env.PORT || 3003;
const PLANS_DIR = path.join(__dirname, "plans");
if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });
const START_TIME = new Date().toISOString();
// v5.3 step parser - ASCII-only keyword matching (no regex on Chinese)
function parsePlanSteps(stepsText, planId) {
if (!stepsText) return [];
var lines = stepsText.split("\n").filter(function(l) { return l.trim(); });
var tasks = [];
var idx = 0;
for (var i = 0; i < lines.length; i++) {
var m = lines[i].match(/^\s*(\d+)\.?\s+(.+)/);
if (m) {
idx++;
var desc = m[2].trim();
var action = "write_file";
if (desc.indexOf("API")!==-1||desc.indexOf("Notion")!==-1||desc.indexOf("notion")!==-1) action="call_api";
if (desc.indexOf("curl")!==-1||desc.indexOf("npm")!==-1||desc.indexOf("git")!==-1||desc.indexOf("pm2")!==-1||desc.indexOf("Nginx")!==-1||desc.indexOf("nginx")!==-1) action="run_cmd";
if (desc.indexOf("测试")!==-1||desc.indexOf("验证")!==-1||desc.indexOf("SSE")!==-1||desc.indexOf("Playwright")!==-1) action="check";
tasks.push({ id: planId+"-t"+idx, action:action, target:"", description:desc, context:"Notion:"+stepsText.slice(0,200) });
}
}
console.log("[StepParser] "+planId+" -> "+tasks.length+" tasks");
return tasks;
}
// ═══ Dashboard HTML ═══
app.get("/", (req, res) => {
const html = `<!DOCTYPE html><html lang="zh"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><meta name="theme-color" content="#060a14"><title>BD001 · 铸渊开发Agent</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#060a14;--card:rgba(14,20,48,0.55);--card-border:rgba(96,165,250,0.1);--card-hover:rgba(96,165,250,0.18);--cyan:#22d3ee;--blue:#60a5fa;--purple:#a78bfa;--ok:#34d399;--warn:#fbbf24;--err:#f87171;--text:#eaf0ff;--dim:#94a7d0;--muted:rgba(120,150,200,0.4);--font:Inter,-apple-system,BlinkMacSystemFont,'PingFang SC','Helvetica Neue','Microsoft YaHei',sans-serif;--mono:'JetBrains Mono','SF Mono','Fira Code',monospace}
html{font-size:16px}body{font-family:var(--font);background:var(--bg);color:var(--text);min-height:100vh;padding:40px;-webkit-font-smoothing:antialiased;line-height:1.7;background-image:radial-gradient(ellipse at 70% 20%,rgba(80,60,140,0.15)0%,transparent 50%),radial-gradient(ellipse at 30% 80%,rgba(40,80,140,0.1)0%,transparent 50%)}
.topbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:32px;gap:16px;flex-wrap:wrap}
.topbar-left h1{font-size:20px;font-weight:600;color:var(--text)}.topbar-left .sub{font-size:12px;color:var(--dim);font-family:var(--mono);margin-top:4px}
.topbar-right{display:flex;align-items:center;gap:12px}
.refresh-btn{padding:8px 18px;background:rgba(34,211,238,0.08);border:1px solid rgba(34,211,238,0.18);border-radius:8px;color:var(--cyan);font-size:12px;font-family:var(--mono);cursor:pointer;transition:all .25s;font-weight:500}
.refresh-btn:hover{background:rgba(34,211,238,0.14);border-color:rgba(34,211,238,0.3)}.refresh-btn.loading{opacity:.5;cursor:wait}
.auto-tag{font-size:11px;color:var(--dim);font-family:var(--mono);display:flex;align-items:center;gap:6px}
.auto-tag .dot{width:6px;height:6px;border-radius:50%;background:var(--ok);box-shadow:0 0 6px rgba(52,211,153,0.4);animation:dotPulse 3s ease-in-out infinite}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:20px}
.s-card{background:var(--card);border:1px solid var(--card-border);border-radius:12px;padding:22px 24px;transition:all .3s;position:relative;overflow:hidden;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);animation:cardBreathe 7s ease-in-out infinite}
.s-card:hover{border-color:var(--card-hover);background:rgba(12,18,44,0.6)}
.s-card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,var(--cyan),transparent);opacity:0;transition:opacity .3s}
.s-card:hover::before{opacity:.5}.s-card:nth-child(2){animation-delay:1.2s}.s-card:nth-child(3){animation-delay:2.4s}.s-card:nth-child(4){animation-delay:3.6s}.s-card:nth-child(5){animation-delay:4.8s}.s-card:nth-child(6){animation-delay:6s}
.s-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
.s-card-title{font-size:11px;font-weight:700;color:var(--dim);text-transform:uppercase;letter-spacing:1.2px;font-family:var(--mono);display:flex;align-items:center;gap:8px}
.s-card-badge{font-size:11px;padding:3px 9px;border-radius:10px;font-weight:600;letter-spacing:.3px;font-family:var(--mono)}
.badge-ok{background:rgba(52,211,153,0.1);color:var(--ok);border:1px solid rgba(52,211,153,0.15)}
.badge-warn{background:rgba(251,191,36,0.1);color:var(--warn);border:1px solid rgba(251,191,36,0.15)}
.badge-err{background:rgba(248,113,113,0.1);color:var(--err);border:1px solid rgba(248,113,113,0.15)}
.badge-off{background:rgba(122,141,184,0.1);color:var(--dim);border:1px solid rgba(122,141,184,0.15)}
.badge-info{background:rgba(34,211,238,0.08);color:var(--cyan);border:1px solid rgba(34,211,238,0.15)}
.svr-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;display:inline-block}.svr-dot.on{background:var(--ok);box-shadow:0 0 8px rgba(52,211,153,0.4);animation:dotPulse 3s ease-in-out infinite}
.svr-dot.pending{background:var(--warn);box-shadow:0 0 6px rgba(251,191,36,0.3)}.svr-dot.off{background:var(--err)}
.metric-row{display:flex;align-items:center;justify-content:space-between;padding:4px 0}.metric-label{font-size:13px;color:var(--dim)}
.metric-val{font-size:15px;font-weight:700;font-family:var(--mono);color:var(--text)}.metric-val .unit{font-size:11px;color:var(--dim);font-weight:400;margin-left:2px}
.plan-list{font-size:12px;line-height:2;color:var(--dim);font-family:var(--mono);margin-top:8px;max-height:140px;overflow-y:auto;scrollbar-width:thin;scrollbar-color:rgba(96,165,250,0.1)transparent}
.plan-item{display:flex;align-items:center;gap:6px;padding:2px 0}
.plan-tag{display:inline-block;padding:1px 7px;border-radius:4px;font-size:10px;font-weight:600;font-family:var(--mono)}
.plan-tag.rec{background:rgba(96,165,250,0.1);color:var(--blue);border:1px solid rgba(96,165,250,0.15)}
.plan-tag.dev{background:rgba(251,191,36,0.1);color:var(--warn);border:1px solid rgba(251,191,36,0.15)}
.plan-tag.done{background:rgba(52,211,153,0.1);color:var(--ok);border:1px solid rgba(52,211,153,0.15)}
.plan-clickable{cursor:pointer;transition:all .2s;padding:3px 6px;margin:-3px -6px;border-radius:4px}
.plan-clickable:hover{background:rgba(34,211,238,0.06);color:var(--cyan)}
.wake-card{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:12px}
.wake-btn{padding:10px 24px;background:rgba(167,139,250,0.1);border:1px solid rgba(167,139,250,0.25);border-radius:8px;color:var(--purple);font-size:13px;font-family:var(--mono);cursor:pointer;transition:all .25s;font-weight:600}
.wake-btn:hover{background:rgba(167,139,250,0.18);border-color:rgba(167,139,250,0.4)}.wake-btn:disabled{opacity:.4;cursor:wait}
.wake-hint{font-size:11px;color:var(--dim);font-family:var(--mono)}.wake-result{font-size:12px;font-family:var(--mono);min-height:18px}
@keyframes dotPulse{0%,100%{opacity:.7}50%{opacity:1}}@keyframes cardBreathe{0%,100%{opacity:.98}50%{opacity:1}}
.toast{position:fixed;bottom:24px;right:24px;background:var(--card);border:1px solid rgba(96,165,250,0.3);border-radius:8px;padding:12px 20px;font-size:13px;font-family:var(--mono);display:none;z-index:100;backdrop-filter:blur(12px)}
.modal-overlay{position:fixed;inset:0;background:rgba(6,10,20,0.85);z-index:200;display:none;align-items:center;justify-content:center;backdrop-filter:blur(4px)}
.modal-overlay.open{display:flex}
.modal{background:var(--card);border:1px solid rgba(96,165,250,0.3);border-radius:14px;padding:28px 32px;max-width:620px;width:90vw;max-height:80vh;overflow-y:auto;backdrop-filter:blur(16px)}
.modal h2{font-size:16px;font-weight:600;color:var(--text);margin-bottom:4px}
.modal .modal-meta{font-size:11px;color:var(--dim);font-family:var(--mono);margin-bottom:20px;display:flex;gap:16px}
.modal .step-list{display:flex;flex-direction:column;gap:8px;margin-bottom:16px}
.modal .step-item{display:flex;gap:10px;align-items:flex-start;font-size:13px;color:var(--dim);line-height:1.6}
.modal .step-num{background:rgba(34,211,238,0.1);color:var(--cyan);min-width:24px;height:24px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:11px;font-family:var(--mono);font-weight:700;flex-shrink:0}
.modal .constraint-box{background:rgba(251,191,36,0.05);border:1px solid rgba(251,191,36,0.15);border-radius:8px;padding:12px 16px;margin-bottom:20px;font-size:12px;color:var(--warn);font-family:var(--mono);line-height:1.6}
.modal .modal-actions{display:flex;gap:10px;justify-content:flex-end}
.modal .btn-approve{padding:10px 28px;background:rgba(52,211,153,0.12);border:1px solid rgba(52,211,153,0.25);border-radius:8px;color:var(--ok);font-size:13px;font-family:var(--mono);cursor:pointer;font-weight:600;transition:all .2s}
.modal .btn-approve:hover{background:rgba(52,211,153,0.2);border-color:rgba(52,211,153,0.4)}.modal .btn-approve:disabled{opacity:.4;cursor:wait}
.modal .btn-close{padding:10px 20px;background:transparent;border:1px solid rgba(96,165,250,0.12);border-radius:8px;color:var(--dim);font-size:12px;font-family:var(--mono);cursor:pointer}
.modal .btn-close:hover{border-color:var(--dim)}
@media(max-width:720px){body{padding:20px}.grid{grid-template-columns:1fr}}
</style></head><body>
<div class="topbar"><div class="topbar-left"><h1>⚔️ BD001 · 铸渊开发Agent</h1><div class="sub">${AGENT_ID} · BS-SG-002 · v5.3</div></div><div class="topbar-right"><span class="auto-tag"><span class="dot"></span>30s</span><button class="refresh-btn" id="refreshBtn" onclick="refreshAll()">REFRESH ALL</button></div></div>
<div class="grid">
<div class="s-card" id="tile-sys"><div class="s-card-head"><span class="s-card-title"><span class="svr-dot" id="sys-dot"></span>SYS STATUS</span><span class="s-card-badge" id="sys-badge">—</span></div><div class="metric-row"><span class="metric-label">状态</span><span class="metric-val" id="sys-val">加载中…</span></div><div class="metric-row"><span class="metric-label">API 调用</span><span class="metric-val" id="sys-calls">—</span></div><div class="metric-row"><span class="metric-label">版本</span><span class="metric-val" style="font-size:12px;font-weight:400" id="sys-ver">—</span></div><div style="margin-top:10px;font-size:10px;color:var(--muted);font-family:var(--mono)" id="sys-eta"></div></div>
<div class="s-card" id="tile-notion"><div class="s-card-head"><span class="s-card-title"><span class="svr-dot" id="notion-dot"></span>NOTION</span><span class="s-card-badge" id="notion-badge">—</span></div><div class="metric-row"><span class="metric-label">连接</span><span class="metric-val" id="notion-val" style="font-size:14px">检查中…</span></div><div class="metric-row"><span class="metric-label">数据库</span><span class="metric-val" id="notion-db" style="font-size:12px;font-weight:400">—</span></div><div class="metric-row"><span class="metric-label">数据</span><span class="metric-val" id="notion-data" style="font-size:12px;font-weight:400">—</span></div><div style="margin-top:10px;font-size:10px;color:var(--muted);font-family:var(--mono)" id="notion-eta"></div></div>
<div class="s-card wake-card"><div class="s-card-head" style="margin-bottom:0;width:100%"><span class="s-card-title">WAKE AGENT</span></div><div class="wake-icon" id="wake-icon" style="font-size:28px">🔔</div><button class="wake-btn" id="wbtn" onclick="wakeAgent()">唤醒 · 检查 Notion</button><div class="wake-hint">每晚 20:00 · 30s超时</div><div class="wake-result" id="wresult"></div></div>
<div class="s-card" id="tile-plans"><div class="s-card-head"><span class="s-card-title"><span class="svr-dot" id="plans-dot"></span>PLANS</span><span class="s-card-badge" id="plans-badge">—</span></div><div class="metric-row"><span class="metric-label">工单数</span><span class="metric-val" id="plans-val">加载中…</span></div><div class="plan-list" id="plans-list"></div><div style="margin-top:8px;font-size:10px;color:var(--muted);font-family:var(--mono)" id="plans-eta"></div></div>
<div class="s-card" id="tile-logs"><div class="s-card-head"><span class="s-card-title"><span class="svr-dot" id="logs-dot"></span>EXEC LOG</span><span class="s-card-badge" id="logs-badge">—</span></div><div class="metric-row"><span class="metric-label">成功率</span><span class="metric-val" id="logs-val">加载中…</span></div><div class="plan-list" id="logs-detail"></div><div style="margin-top:8px;font-size:10px;color:var(--muted);font-family:var(--mono)" id="logs-eta"></div></div>
<div class="s-card" id="tile-api"><div class="s-card-head"><span class="s-card-title"><span class="svr-dot" id="api-dot"></span>MODEL API</span><span class="s-card-badge" id="api-badge">—</span></div><div class="metric-row"><span class="metric-label">主源</span><span class="metric-val" id="api-primary" style="font-size:14px">加载中…</span></div><div class="metric-row"><span class="metric-label">备源</span><span class="metric-val" id="api-backup" style="font-size:14px">—</span></div><div class="metric-row"><span class="metric-label">切换</span><span class="metric-val" id="api-fails" style="font-size:13px">—<span class="unit">次</span></span></div><div style="margin-top:10px;font-size:10px;color:var(--muted);font-family:var(--mono)" id="api-eta"></div></div>
</div>
<div class="modal-overlay" id="planModal">
<div class="modal"><h2 id="modalTitle">—</h2><div class="modal-meta" id="modalMeta"></div><div class="step-list" id="modalSteps"></div><div class="constraint-box" id="modalConstraints" style="display:none"></div>
<div class="modal-actions"><button class="btn-close" onclick="closeModal()">关闭</button><button class="btn-approve" id="btnApprove" onclick="approvePlan()">批准执行</button></div>
<div id="modalResult" style="margin-top:12px;font-size:12px;font-family:var(--mono)"></div></div></div>
<div class="toast" id="toast"></div>
<script>
let timer=null;let currentPlanId=null;const API='/dev-agent/';
addEventListener('DOMContentLoaded',()=>{refreshAll();timer=setInterval(refreshAll,30000)});
async function refreshAll(){const btn=document.getElementById('refreshBtn');btn.classList.add('loading');btn.textContent='REFRESHING…';const ts=new Date().toLocaleTimeString('zh',{hour:'2-digit',minute:'2-digit',second:'2-digit'});await Promise.allSettled([sys(ts),notion(ts),plans(ts),logs(ts)]);btn.classList.remove('loading');btn.textContent='REFRESH ALL'}
async function sys(ts){try{const r=await fetch(API+'health',{signal:AbortSignal.timeout(5000)});const d=await r.json();const apiCalls=d.api_calls||0;const pct=Math.round(apiCalls/50*100);const alive=apiCalls<40;setDot('sys-dot',alive?'on':'pending');setBadge('sys-badge',alive?'LIVE':'HIGH LOAD',alive?'badge-ok':'badge-warn');setText('sys-val',d.status==='ok'?'在线':'异常');setText('sys-calls',apiCalls+' / 50 ('+pct+'%)');setText('sys-ver','v5.3');setText('sys-eta','REFRESHED '+ts);const md=d.model||{};const srcOk=md.primary_ok!==false;setDot('api-dot',srcOk?'on':'pending');setBadge('api-badge',srcOk?'PRIMARY':'FALLBACK',srcOk?'badge-ok':'badge-warn');setText('api-primary',(md.primary||'yunwu.ai')+(srcOk?' Y':' N'));setText('api-backup',(md.backup||'deepseek')+(md.backup_available?' Y':' N'));setText('api-fails',(md.fail_count||0)+'次');setText('api-eta','REFRESHED '+ts)}catch(e){setDot('sys-dot','off');setBadge('sys-badge','OFFLINE','badge-err');setText('sys-val','离线');setText('sys-eta','REFRESHED '+ts);setDot('api-dot','off');setBadge('api-badge','OFFLINE','badge-err')}}
async function notion(ts){try{const r=await fetch(API+'notion-status',{signal:AbortSignal.timeout(8000)});const d=await r.json();if(d.ok){setDot('notion-dot','on');setBadge('notion-badge','CONNECTED','badge-ok');setText('notion-val','已连接');setText('notion-db',(d.db_id||'').slice(0,8)+'…');setText('notion-data',d.has_results?'含数据':'空库')}else{setDot('notion-dot','pending');setBadge('notion-badge','ERROR','badge-warn');setText('notion-val','异常');setText('notion-db',(d.db_id||'?').slice(0,8)+'…');setText('notion-data',(d.error||'错误').slice(0,30))}}catch(e){setDot('notion-dot','off');setBadge('notion-badge','TIMEOUT','badge-err');setText('notion-val','超时')}setText('notion-eta','REFRESHED '+ts)}
async function plans(ts){try{const r=await fetch(API+'dashboard',{signal:AbortSignal.timeout(5000)});const d=await r.json();const pl=d.plans||[];if(pl.length===0){setDot('plans-dot','pending');setBadge('plans-badge','EMPTY','badge-off');setText('plans-val','暂无');document.getElementById('plans-list').innerHTML='<div style="color:var(--dim);font-size:12px">去 Notion 创建工单</div>'}else{const rec=pl.filter(p=>p.status==='received').length;const dev=pl.filter(p=>p.status==='expanded').length;const done=pl.filter(p=>p.status==='done').length;setDot('plans-dot',rec>0?'on':dev>0?'pending':done===pl.length?'on':'pending');setBadge('plans-badge',pl.length+' ACTIVE',rec>0?'badge-info':'badge-ok');setText('plans-val',pl.length+'个');document.getElementById('plans-list').innerHTML=pl.slice(0,8).map(p=>{const sn=p.status||'received';const cls=sn==='done'?'done':sn==='expanded'?'dev':'rec';const dotCls=sn==='done'?'on':sn==='expanded'?'pending':'on';const label=sn==='done'?'DONE':sn==='expanded'?'DEV':sn==='running'?'RUN':'RECV';return '<div class="plan-item"><span class="svr-dot '+dotCls+'"></span><span class="plan-clickable" onclick="openPlan(\''+p.plan_id+'\')">'+(p.title||'—').slice(0,30)+'</span><span class="plan-tag '+cls+'">'+label+'</span>'+(p.tasks?' · '+p.tasks+'步':'')+'</div>'}).join('')}}catch(e){setDot('plans-dot','off');setBadge('plans-badge','ERROR','badge-err');setText('plans-val','加载失败')}setText('plans-eta','REFRESHED '+ts)}
async function logs(ts){try{const r=await fetch(API+'logs/latest',{signal:AbortSignal.timeout(5000)});const d=await r.json();if(!d||d.total_steps===undefined){setDot('logs-dot','pending');setBadge('logs-badge','IDLE','badge-off');setText('logs-val','暂无');document.getElementById('logs-detail').innerHTML='<div style="color:var(--dim);font-size:12px">等待首次执行</div>'}else{const ok=d.total_steps-(d.errors?.length||0);const rate=Math.round(ok/(d.total_steps||1)*100);const errs=d.errors?.length||0;setDot('logs-dot',errs>0?'pending':'on');setBadge('logs-badge',rate+'%',rate>=90?'badge-ok':rate>=50?'badge-warn':'badge-err');setText('logs-val',rate+'%');const started=d.started?new Date(d.started).toLocaleString('zh',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}):'?';document.getElementById('logs-detail').innerHTML='<div class="plan-item"><span class="svr-dot '+(errs>0?'pending':'on')+'"></span>'+(d.plan_id||'?').slice(0,15)+' · '+started+'</div><div class="plan-item">'+ok+'/'+d.total_steps+' 步'+(errs>0?' · '+errs+' 错':'')+'</div>'}}catch(e){setDot('logs-dot','off');setBadge('logs-badge','ERROR','badge-err');setText('logs-val','加载失败')}setText('logs-eta','REFRESHED '+ts)}
function setDot(id,state){const el=document.getElementById(id);if(el)el.className='svr-dot '+(state||'')}
function setBadge(id,text,cls){const el=document.getElementById(id);if(el){el.textContent=text;el.className='s-card-badge '+(cls||'')}}
function setText(id,html){const el=document.getElementById(id);if(el)el.innerHTML=html||'—'}
async function wakeAgent(){const btn=document.getElementById('wbtn'),rs=document.getElementById('wresult'),icon=document.getElementById('wake-icon');btn.disabled=true;btn.textContent='CHECKING…';rs.textContent='';icon.textContent='⏳';try{const r=await fetch(API+'wake',{method:'POST',headers:{'Content-Type':'application/json'},signal:AbortSignal.timeout(60000)});const d=await r.json();if(d.ok&&d.tasks_found>0){rs.innerHTML='FOUND '+d.tasks_found;icon.textContent='✅';setTimeout(refreshAll,2000)}else if(d.ok){rs.innerHTML='NONE';icon.textContent='🔔'}else{rs.innerHTML='ERROR: '+(d.error||'').slice(0,30);icon.textContent='❌'}}catch(e){rs.innerHTML='TIMEOUT';icon.textContent='💥'}btn.disabled=false;btn.textContent='唤醒 · 检查 Notion'}
async function openPlan(pid){currentPlanId=pid;document.getElementById('planModal').classList.add('open');document.getElementById('modalTitle').textContent='加载中…';document.getElementById('modalSteps').innerHTML='';document.getElementById('modalConstraints').style.display='none';document.getElementById('modalResult').textContent='';try{const r=await fetch(API+'plan/'+pid,{signal:AbortSignal.timeout(5000)});const d=await r.json();if(d.error){document.getElementById('modalTitle').textContent=d.error;return}document.getElementById('modalTitle').textContent=d.title||'—';document.getElementById('modalMeta').innerHTML='状态: '+(d.status||'?')+' · 优先级: '+(d.priority||'?')+' · '+(d.tasks?d.tasks.length:0)+'步';const steps=d.steps||'';const lines=steps.split('\n').filter(l=>l.trim());let stepHtml='';lines.forEach((l,i)=>{stepHtml+='<div class="step-item"><span class="step-num">'+(i+1)+'</span><span>'+l.replace(/</g,'&lt;')+'</span></div>'});if(!stepHtml)stepHtml='<div class="step-item"><span class="step-num">—</span><span>暂无步骤</span></div>';document.getElementById('modalSteps').innerHTML=stepHtml;if(d.constraints){document.getElementById('modalConstraints').style.display='block';document.getElementById('modalConstraints').textContent='约束: '+d.constraints}const btn=document.getElementById('btnApprove');btn.disabled=(d.status==='running'||d.status==='done');btn.textContent=d.status==='running'?'执行中…':d.status==='done'?'已执行':'批准执行'}catch(e){document.getElementById('modalTitle').textContent='加载失败: '+e.message}}
function closeModal(){document.getElementById('planModal').classList.remove('open');currentPlanId=null}
document.getElementById('planModal').addEventListener('click',function(e){if(e.target===this)closeModal()})
async function approvePlan(){if(!currentPlanId)return;const btn=document.getElementById('btnApprove');const rs=document.getElementById('modalResult');btn.disabled=true;btn.textContent='执行中…';rs.innerHTML='<span style="color:var(--warn)">BD001 正在执行…</span>';try{const r=await fetch(API+'plan/'+currentPlanId+'/approve',{method:'POST',headers:{'Content-Type':'application/json'},signal:AbortSignal.timeout(300000)});const d=await r.json();if(d.error){rs.innerHTML='<span style="color:var(--err)">ERROR: '+d.error+'</span>';btn.disabled=false;btn.textContent='批准执行'}else{const ok=d.done||0;const total=d.total||0;rs.innerHTML='<span style="color:var(--ok)">DONE: '+ok+'/'+total+' 步</span>';btn.textContent='已执行';btn.disabled=true;setTimeout(refreshAll,2000)}}catch(e){rs.innerHTML='<span style="color:var(--err)">TIMEOUT</span>';btn.disabled=false;btn.textContent='批准执行'}}
</script></body></html>`;
res.set('Content-Type','text/html;charset=utf-8').send(html);
});
// ═══ API Endpoints ═══
app.get("/health", (req, res) => {
let ms = {}; try { ms = getModelStatus ? getModelStatus() : {}; } catch(e) { ms = { error: e.message }; }
res.json({ status: "ok", agent: "zhuyuan-dev-agent", version: "5.3", persona: AGENT_ID, constitution: true, api_calls: getCallCount(), started_at: START_TIME, model: { primary: process.env.YUNWU_BASE_URL ? "yunwu.ai" : "unknown", backup: process.env.DEEPSEEK_BASE_URL ? "deepseek" : "unknown", primary_ok: ms.source !== "deepseek" || !ms.fail_count, backup_available: !!process.env.DEEPSEEK_API_KEY, fail_count: ms.fail_count || 0, source: ms.source || "yunwu.ai" } });
});
app.get("/notion-status", async (req, res) => { try { res.json(await testConnection()); } catch (err) { res.json({ ok: false, error: err.message }); } });
app.post("/wake", async (req, res) => { try { const r = await wakeAndCheck({ log: console.log }); if (r.tasks_found > 0) writeBrain("wake-" + Date.now(), { input: "唤醒", decision: "找到" + r.tasks_found + "个", execution: "标记" + r.tasks_marked + "个" }); res.json(r); } catch (err) { res.status(500).json({ ok: false, error: err.message }); } });
app.post("/task", handleTask);
app.get("/logs/latest", (req, res) => { res.json(readLatestRun() || { message: "暂无" }); });
app.get("/logs/list", (req, res) => { res.json(listRuns(20)); });
app.get("/logs/raw", (req, res) => { const f = req.query.file; if (!f) return res.status(400).json({ error: "缺少file" }); const fp = path.join(__dirname, "logs", f); if (!fs.existsSync(fp)) return res.status(404).json({ error: "不存在" }); res.json(fs.readFileSync(fp, "utf-8").trim().split("\n").map(l => { try { return JSON.parse(l); } catch(e) { return null; } }).filter(Boolean)); });
app.get("/dashboard", (req, res) => { const files = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith(".json") && f !== "queue.json"); const plans = files.map(f => { try { const p = JSON.parse(fs.readFileSync(path.join(PLANS_DIR, f), "utf-8")); return { plan_id: p.plan_id, title: p.title, status: p._status, tasks: p.tasks?.length }; } catch(e) { return { file: f, error: "解析失败" }; } }); res.json({ agent: "zhuyuan-dev-agent", version: "5.3", persona: AGENT_ID, plans }); });
// ═══ Plan Review & Approve ═══
app.get("/plan/:plan_id", (req, res) => { const planFile = path.join(PLANS_DIR, req.params.plan_id + ".json"); if (!fs.existsSync(planFile)) return res.status(404).json({ error: "不存在" }); try { const p = JSON.parse(fs.readFileSync(planFile, "utf-8")); res.json({ plan_id: p.plan_id, title: p.title, priority: p.priority, steps: p.steps, constraints: p.constraints, status: p._status, tasks: p.tasks, notion_url: p.notion_url }); } catch(e) { res.status(500).json({ error: "解析失败" }); } });
app.post("/plan/:plan_id/approve", async (req, res) => { const planFile = path.join(PLANS_DIR, req.params.plan_id + ".json"); if (!fs.existsSync(planFile)) return res.status(404).json({ error: "不存在" }); const plan = JSON.parse(fs.readFileSync(planFile, "utf-8")); if (plan._status === "running") return res.json({ ok: false, error: "正在执行中" }); if ((!plan.tasks || plan.tasks.length === 0) && plan.steps) { plan.tasks = parsePlanSteps(plan.steps, plan.plan_id); } if (!plan.tasks || plan.tasks.length === 0) return res.json({ ok: false, error: "无任务可执行" }); const logger = createLogger(req.params.plan_id); reset(); plan._status = "running"; fs.writeFileSync(planFile, JSON.stringify(plan, null, 2), "utf-8"); logger.log("approve", { plan_id: req.params.plan_id, task_count: plan.tasks.length }); try { const email = process.env.BINGSHUO_EMAIL; if (email) await sendNotification(email, req.params.plan_id, "started", { task_count: plan.tasks.length }).catch(() => {}); writeBrain(req.params.plan_id, { input: "批准执行", decision: "按规划执行 " + plan.tasks.length + " 个任务" }); logger.log("expand_start", { task_count: plan.tasks.length }); const expanded = await expandPlan(plan, safeCall, PLANS_DIR).catch((e) => { if (isBudgetExceeded()) logger.log("budget_exceeded"); logger.log("expand_skip", { reason: e.message }); return plan; }); logger.log("exec_start", { task_count: expanded.tasks?.length || 0 }); const summary = await executePlan(expanded, logger.log); logger.finish(summary); plan._status = "done"; fs.writeFileSync(planFile, JSON.stringify(plan, null, 2), "utf-8"); recordExperience(req.params.plan_id, summary); recordMilestone(req.params.plan_id + " · " + summary.done + "/" + (summary.total || 0) + " 步"); if (email) await sendNotification(email, req.params.plan_id, summary.failed > 0 ? "failed" : "completed", summary).catch(() => {}); res.json(summary); } catch (err) { logger.log("fatal", { error: err.message }); logger.finish({ error: err.message }); plan._status = "error"; fs.writeFileSync(planFile, JSON.stringify(plan, null, 2), "utf-8"); res.status(500).json({ error: err.message }); } });
app.post("/execute", async (req, res) => { const { plan_id } = req.body; if (!plan_id) return res.status(400).json({ error: "缺少plan_id" }); const pf = path.join(PLANS_DIR, plan_id + ".json"); if (!fs.existsSync(pf)) return res.status(404).json({ error: "不存在" }); const plan = JSON.parse(fs.readFileSync(pf, "utf-8")); if ((!plan.tasks || plan.tasks.length === 0) && plan.steps) { plan.tasks = parsePlanSteps(plan.steps, plan.plan_id); } const logger = createLogger(plan_id); reset(); try { const e = process.env.BINGSHUO_EMAIL; if (e) await sendNotification(e, plan_id, "started", { task_count: plan.tasks.length }).catch(() => {}); writeBrain(plan_id, { input: "收到", decision: "执行" }); logger.log("expand_start", { task_count: plan.tasks.length }); const ex = await expandPlan(plan, safeCall, PLANS_DIR).catch((e) => { if (isBudgetExceeded()) logger.log("budget_exceeded"); logger.log("expand_skip", { reason: e.message }); return plan; }); logger.log("exec_start", { task_count: ex.tasks.length }); const s = await executePlan(ex, logger.log); logger.finish(s); recordExperience(plan_id, s); recordMilestone(plan_id + " · " + s.done + "/" + (s.total || 0)); if (e) await sendNotification(e, plan_id, s.failed > 0 ? "failed" : "completed", s).catch(() => {}); res.json(s); } catch (err) { logger.log("fatal", { error: err.message }); logger.finish({ error: err.message }); writeBrain(plan_id, { failure: err.message }); if (process.env.BINGSHUO_EMAIL) await sendNotification(process.env.BINGSHUO_EMAIL, plan_id, "failed", { errors: [err.message] }).catch(() => {}); res.status(500).json({ error: err.message }); } });
app.listen(PORT, () => { console.log("[Agent v5.3] " + AGENT_ID + " · 零点原核UI · 步骤解析 · 经验累积 · 端口 " + PORT); });