- 零第三方依赖,Node >=20 原生 ESM - 团队式编排引擎:拆解/路由/执行/审查/合并全真实 LLM - 阶段心跳、单一权威清单守卫、all-keys-failed 如实上报 - H4 会话视图/amend/watchdog 有界重试/产物区 artifacts.json - H5 零依赖三栏控制台
118 lines
18 KiB
HTML
118 lines
18 KiB
HTML
<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>调度网关 Console</title><style>
|
||
:root{--bg:#f4f6fb;--card:#fff;--line:#e3e8f2;--tx:#1a2236;--mut:#77839f;--acc:#3556e0;--ok:#12945d;--warn:#c77700;--bad:#cc3b4e;--r:12px}
|
||
*{box-sizing:border-box}body{margin:0;font:14px/1.55 system-ui,'Segoe UI','Microsoft YaHei';background:var(--bg);color:var(--tx)}
|
||
body.dark{--bg:#0d1220;--card:#151c30;--line:#232c48;--tx:#e7ebf5;--mut:#8b97b5}
|
||
.app{display:flex;min-height:100vh}
|
||
.side{width:210px;background:var(--card);border-right:1px solid var(--line);padding:18px 12px;display:flex;flex-direction:column;gap:2px;position:sticky;top:0;height:100vh}
|
||
.logo{font-weight:800;font-size:16px;padding:4px 10px 16px;letter-spacing:.02em}
|
||
.nav a{display:block;padding:9px 12px;border-radius:9px;color:var(--tx);text-decoration:none;font-weight:500;margin-bottom:2px}
|
||
.nav a.on,.nav a:hover{background:color-mix(in srgb,var(--acc) 12%,transparent);color:var(--acc)}
|
||
.side .foot{margin-top:auto;display:flex;align-items:center;justify-content:space-between;color:var(--mut);font-size:12px;padding:0 8px}
|
||
main{flex:1;padding:26px 32px;max-width:1200px}
|
||
h1{font-size:20px;margin:0 0 4px}.sub{color:var(--mut);margin-bottom:22px}
|
||
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:14px;margin-bottom:24px}
|
||
.tile{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:16px}
|
||
.tile b{display:block;font-size:30px;font-variant-numeric:tabular-nums}.tile span{color:var(--mut);font-size:12px}
|
||
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:16px;margin-bottom:18px}
|
||
table{width:100%;border-collapse:collapse}th{text-align:left;color:var(--mut);font-size:12px;font-weight:600;padding:8px 10px;border-bottom:1px solid var(--line)}
|
||
td{padding:10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||
tr.clk{cursor:pointer}tr.clk:hover td{background:color-mix(in srgb,var(--acc) 6%,transparent)}
|
||
.pill{display:inline-block;padding:2px 10px;border-radius:999px;font-size:11px;font-weight:700}
|
||
.p-queued{background:#fff3d6;color:#8a6100}.p-assigned,.p-running,.p-review,.p-rework{background:#dce7ff;color:#274bcc}.p-done{background:#d8f3e6;color:#0c7a4b}.p-failed,.p-dead{background:#fde2e5;color:#b02a3c}.p-blocked{background:#efe2ff;color:#7439c9}.p-cancelled{background:#e8e8ee;color:#666}
|
||
body.dark .pill{filter:brightness(.85)}button,a.btn{background:var(--acc);color:#fff;border:0;border-radius:8px;padding:7px 14px;font-weight:600;cursor:pointer;text-decoration:none;font-size:13px}
|
||
button.ghost{background:transparent;color:var(--acc);border:1px solid var(--line)}button.danger{background:var(--bad)}
|
||
input,select,textarea{background:var(--card);border:1px solid var(--line);color:var(--tx);border-radius:9px;padding:8px 10px;font:inherit}
|
||
textarea{width:100%;resize:vertical}
|
||
.grid2{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px}
|
||
.acard{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:14px}
|
||
.dot{width:9px;height:9px;border-radius:50%;display:inline-block;background:#bbb}.dot.on{background:var(--ok)}
|
||
.chip{display:inline-block;background:color-mix(in srgb,var(--acc) 10%,transparent);color:var(--acc);border-radius:6px;padding:1px 7px;font-size:11px;margin:1px 2px}
|
||
.role{background:#efe6ff;color:#6b34c8}
|
||
.drawer{position:fixed;top:0;right:-520px;width:500px;height:100vh;background:var(--card);border-left:1px solid var(--line);box-shadow:-12px 0 40px rgba(0,0,0,.18);transition:right .22s;z-index:50;padding:22px;overflow:auto}
|
||
.drawer.open{right:0}.drawer h3{margin-top:0}
|
||
pre{background:color-mix(in srgb,var(--mut) 9%,transparent);padding:10px;border-radius:8px;white-space:pre-wrap;word-break:break-word;font-size:12.5px}
|
||
.mut{color:var(--mut)}.sm{font-size:12px}.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
||
.modal{position:fixed;inset:0;background:rgba(10,14,25,.45);z-index:60;display:flex;align-items:center;justify-content:center}
|
||
.mbox{background:var(--card);border-radius:14px;padding:20px;width:min(560px,92vw);max-height:86vh;overflow:auto}
|
||
code{background:color-mix(in srgb,var(--mut) 12%,transparent);padding:1px 6px;border-radius:5px;font-size:12.5px}
|
||
</style></head><body><div class="app">
|
||
<div class="side"><div class="logo">⬡ 调度网关</div>
|
||
<div class="nav">
|
||
<a href="#/dash" data-v="dash">总览</a><a href="#/tasks" data-v="tasks">任务</a><a href="#/agents" data-v="agents">智能体</a><a href="#/api" data-v="api">接口文档</a></div>
|
||
<div class="foot"><span id="conn">● 连接中</span><button class="ghost" style="padding:3px 9px" onclick="document.body.classList.toggle('dark');localStorage.theme=document.body.classList.contains('dark')?'dark':'light'">🌓</button></div></div>
|
||
<main id="view"></main></div>
|
||
<script>
|
||
if(localStorage.theme==='dark')document.body.classList.add('dark');
|
||
const $=s=>document.querySelector(s);const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const PILL={queued:'排队中',assigned:'执行中',running:'执行中',review:'评审中',rework:'返工中',done:'已完成',failed:'失败',dead:'多次失败',blocked:'待审批',cancelled:'已停止'};
|
||
const pill=s=>'<span class="pill p-'+s+'">'+(PILL[s]||s)+'</span>';
|
||
const ago=t=>{const d=Date.now()-t;if(d<6e4)return Math.floor(d/1e3)+' 秒前';if(d<36e5)return Math.floor(d/6e4)+' 分钟前';if(d<864e5)return Math.floor(d/36e5)+' 小时前';return Math.floor(d/864e5)+' 天前'};
|
||
async function api(p,o){const r=await fetch(p,o);if(!r.ok)throw new Error(await r.text());return r.json()}
|
||
let S={tasks:[],nodes:[],st:null};
|
||
async function refresh(){try{
|
||
const [st,t,n]=await Promise.all([api('/api/status'),api('/api/tasks'),api('/api/nodes')]);
|
||
S.st=st;S.tasks=t.tasks.sort((a,b)=>b.createdAt-a.createdAt);S.nodes=n.nodes.filter(x=>x.online);
|
||
$('#conn').textContent='● 已连接';
|
||
render();
|
||
}catch(e){$('#conn').textContent='● 断开';console.error(e)}}
|
||
function openD(id){location.hash='#/task/'+id}
|
||
function render(){const v=(location.hash.match(/#\/(\w+)/)||[])[1]||'dash';
|
||
document.querySelectorAll('.nav a').forEach(a=>a.classList.toggle('on',a.dataset.v===v||v==='task'&&a.dataset.v==='tasks'));
|
||
({dash:vDash,tasks:vTasks,task:vTaskDetail,agents:vAgents,api:vApi}[v]||vDash)()}
|
||
function vDash(){const st=S.st||{};const d=st.distribution||{};
|
||
$('#view').innerHTML='<h1>总览</h1><div class="sub">实时调度状态 · '+new Date().toLocaleTimeString()+'</div>'+
|
||
'<div class="tiles">'+[['排队中',d.queued],['执行中',(d.assigned||0)+(d.running||0)+(d.review||0)+(d.rework||0)],['已完成',d.done],['失败/死信',(d.failed||0)+(d.dead||0)],['在线智能体',st.nodesOnline+' / '+st.nodes]].map(x=>'<div class="tile"><b>'+x[1]+'</b><span>'+x[0]+'</span></div>').join('')+'</div>'+
|
||
'<div class="card"><b>快速派活</b><br><span class="mut sm">一句话描述任务;填了验收标准可开启三级验收(成员→组长→监管)</span><br><textarea id="q_p" rows="2" placeholder="要做的事……"></textarea>'+
|
||
'<div class="row" style="margin-top:8px"><input id="q_a" style="flex:1;min-width:200px" placeholder="验收标准(可选)"><select id="q_r"><option value="">自动分配</option>'+S.nodes.map(n=>'<option>'+esc(n.name)+'</option>').join('')+'</select>'+
|
||
'<label class="sm"><input type="checkbox" id="q_f"> 三级验收</label><button onclick="quickGo()">派发</button></div></div>'+
|
||
'<div class="card"><b>最近任务</b><table>'+S.tasks.slice(0,8).map(t=>'<tr class="clk" onclick="openD(\''+t.id+'\')"><td>'+pill(t.state)+'</td><td>'+esc(String(t.title||t.prompt).slice(0,44))+'</td><td class="mut sm">'+ago(t.createdAt)+'</td></tr>').join('')+'</table></div>';}
|
||
async function quickGo(){const roleNode=S.nodes.find(n=>n.name===$('#q_r').value);try{
|
||
await api('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({prompt:$('#q_p').value,title:String($('#q_p').value).slice(0,30),acceptance:$('#q_a').value||undefined,flow:$('#q_f').checked?'member':undefined,capabilities:roleNode?(roleNode.capabilities||[]).filter(c=>!['adapter','cli','http','llm','cmd'].includes(c)):[]})});
|
||
$('#q_p').value='';$('#q_a').value='';refresh()}catch(e){alert('派发失败:'+e.message)}}
|
||
function vTasks(){const f=new URLSearchParams(location.hash.split('?')[1]||'').get('f');
|
||
const list=f?S.tasks.filter(t=>t.state===f):S.tasks;
|
||
const cnt=s=>S.tasks.filter(t=>t.state===s).length;
|
||
$('#view').innerHTML='<h1>任务</h1><div class="sub row"><a class="btn ghost" href="#/tasks">全部 '+S.tasks.length+'</a>'+
|
||
['queued','running','review','done','failed','blocked','cancelled'].map(s=>'<a class="btn ghost" href="#/tasks?f='+s+'">'+PILL[s]+' '+cnt(s)+'</a>').join('')+'</div>'+
|
||
'<div class="card"><table><tr><th>状态</th><th>标题</th><th>要求角色</th><th>时间</th></tr>'+
|
||
list.map(t=>'<tr class="clk" onclick="openD(\''+t.id+'\')"><td>'+pill(t.state)+'</td><td>'+esc(String(t.title||t.prompt).slice(0,60))+'</td><td class="mut sm">'+esc(t.requiredRole||'-')+'</td><td class="mut sm">'+ago(t.createdAt)+'</td></tr>').join('')+'</table></div>';}
|
||
function vTaskDetail(){const id=location.hash.split('/')[2];const t=S.tasks.find(x=>x.id===id);
|
||
if(!t){$('#view').innerHTML='<h1>任务详情</h1><p class="mut">未找到 <code>'+esc(id)+'</code></p>';return}
|
||
$('#view').innerHTML='<h1>'+esc(t.title||t.id)+'</h1><div class="sub">'+pill(t.state)+' · 创建于 '+ago(t.createdAt)+(t.requiredRole?' · 要求角色 '+esc(t.requiredRole):'')+(t.flow?' · 三级验收流程':'')+'</div>'+
|
||
'<div class="card"><b>要做的事</b><pre>'+esc(t.prompt||'(无)')+'</pre>'+(t.acceptance?'<b>验收标准</b><pre>'+esc(t.acceptance)+'</pre>':'')+'</div>'+
|
||
'<div class="card"><b>执行</b><div class="row sm mut" style="margin:6px 0">节点 '+(t.nodeId||'-')+' · 尝试 '+t.attempts+'/'+t.maxAttempts+(t.result?'· 得分 '+t.result.score:'')+'</div>'+
|
||
(t.result?.output?'<b>产物</b><pre>'+esc(String(t.result.output).slice(0,4000))+'</pre><div class="row"><button onclick="copyArt(\''+t.id+'\')">📋 复制产物</button><a class="btn ghost" href="/api/tasks/'+t.id+'/artifact.txt" download>⬇️ 下载</a>'+(t.callbackUrl?'<span class="mut sm">完成后回调 '+esc(t.callbackUrl)+'</span>':'')+'</div>':'')+
|
||
(Array.isArray(t.history)&&t.history.length?'<b style="display:block;margin-top:10px">调度轨迹</b>'+t.history.map(h=>'<div class="sm mut">'+new Date(h.at).toLocaleTimeString()+' — '+esc(h.note)+'</div>').join(''):'')+
|
||
'</div><div class="row">'+
|
||
(['queued','assigned','running','review','rework'].includes(t.state)?'<button class="danger" onclick="doCancel(\''+t.id+'\')">停止任务</button>':'')+
|
||
(t.state==='blocked'?'<button onclick="appr("'+t.id+'")">批准执行</button>':'')+
|
||
'<a class="btn ghost" href="#/tasks">返回列表</a></div>';}
|
||
async function appr(id){try{await fetch('/api/tasks/'+id+'/approve',{method:'POST'});refresh()}catch(e){alert(e.message)}}
|
||
async function doCancel(id){try{await api('/api/tasks/'+id+'/cancel',{method:'POST'});refresh()}catch(e){alert(e.message)}}
|
||
async function copyArt(id){try{const j=await api('/api/tasks/'+id);await navigator.clipboard.writeText(String(j.task?.result?.output||''))}catch(e){alert(e.message)}}
|
||
function vAgents(){DISC_ROLES={};
|
||
$('#view').innerHTML='<h1>智能体</h1><div class="sub">底层工具可复用:同一 CLI 可实例化为不同身份的「人」</div><div class="row" style="margin-bottom:16px"><button onclick="showAdd()">+ 添加智能体</button></div><div class="grid2">'+
|
||
S.nodes.concat(S.allNodes||[]).map(n=>'<div class="acard"><div class="row" style="justify-content:space-between"><b><span class="dot '+(n.online?'on':'')+'"></span> '+esc(n.name)+'</b><span class="row"><button class="ghost" style="padding:3px 9px" onclick="editNode(\''+n.nodeId+'\',\''+esc(String(n.name||'')).replace(/'/g,'')+'\')">✎</button><button class="ghost danger" style="padding:3px 9px" onclick="delNode(\''+n.nodeId+'\')">✕</button></span></div>'+
|
||
'<div class="sm mut" style="margin:6px 0">'+(n.role?'<span class="chip role">'+n.role+'</span>':'')+(n.model||'')+' · 完成 '+n.completed+' · 失败 '+n.failed+' · 并发 '+n.inFlight+'/'+n.maxConcurrency+'</div>'+
|
||
'<div>'+(n.capabilities||[]).map(c=>'<span class="chip">'+esc(c)+'</span>').join('')+'</div></div>').join('')+'</div>';
|
||
fetch('/api/nodes').then(r=>r.json()).then(j=>{S.allNodes=j.nodes;if(location.hash.includes('agents')){vAgents._done=true;vAgents()}})}
|
||
function delNode(id){if(!confirm2('移除 '+id+'?'))return;fetch('/api/nodes/'+encodeURIComponent(id),{method:'DELETE'}).then(refresh)}
|
||
function confirm2(m){return window.confirm(m)}
|
||
function showAdd(){$('#view').insertAdjacentHTML('beforeend','<div class="modal" id="mAdd" onclick="if(event.target===this)this.remove()"><div class="mbox"><h3>添加智能体</h3><div class="sm mut">本机检测到的 CLI —— 同一工具可重复加入、设不同身份:</div><div id="dl" class="mut sm">探测中…</div><hr style="border:0;border-top:1px solid var(--line);margin:14px 0"><div class="sm mut">手动注册任意可执行程序:</div><div class="row" style="margin-top:6px"><input id="mc" placeholder="命令或路径" style="flex:1;min-width:150px"><input id="ma" placeholder='参数 -p "{prompt}"' style="flex:1;min-width:130px"><select id="mr"><option value="">成员</option><option value="lead">组长</option><option value="supervisor">监管</option></select><input id="mn" placeholder="显示名" style="width:110px"><button onclick="joinMan()">加入</button></div><div style="margin-top:14px;text-align:right"><button class="ghost" onclick="document.getElementById('mAdd').remove()">关闭</button></div></div></div>');
|
||
api('/api/discover').then(j=>{$('#dl').innerHTML=j.items.map(x=>x.found?'<div class="row" style="margin:6px 0">✅ <b>'+esc(x.label)+'</b> <span class="mut">'+esc(x.version||'')+'</span> <select class="jrole"><option value="">成员</option><option value="lead">组长</option><option value="supervisor">监管</option></select><button onclick="joinDisc(\''+x.id+'\')">加入</button></div>':'<div class="mut">⚪ '+esc(x.label)+' 未安装</div>').join('')})}
|
||
async function joinDisc(fid){const j=await api('/api/discover');const x=j.items.find(v=>v.id===fid);if(!x)return;
|
||
const sel=document.querySelector('#dl .jrole');
|
||
await api('/api/nodes/local',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({cli:x.absPath||fid,cliArgs:String(x.joinArgs||'').split('"')[1]||'-p {prompt}',name:x.label,role:sel?sel.value:undefined})});
|
||
document.getElementById('mAdd').remove();refresh()}
|
||
async function joinMan(){try{await api('/api/nodes/local',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({cli:$('#mc').value.trim(),cliArgs:$('#ma').value,name:$('#mn').value||undefined,role:$('#mr').value||undefined})});document.getElementById('mAdd').remove();refresh()}catch(e){alert(e.message)}}
|
||
async function editNode(id){const n=(S.nodes.find(x=>x.nodeId===id)||(S.allNodes||[]).find(x=>x.nodeId===id))||{};const caps=['worker','shell','llm','pi','claude','codex','gemini'];
|
||
$('#view').insertAdjacentHTML('beforeend','<div class="modal" id="mEd" onclick="if(event.target===this)this.remove()"><div class="mbox"><h3>编辑 · '+esc(n.name||id)+'</h3><label class="sm mut">显示名</label><input id="en" style="width:100%;margin:6px 0 12px" value="'+esc(n.name||'')+'"><label class="sm mut">角色</label><select id="er" style="width:100%;margin:6px 0 12px"><option value="">成员 member</option><option value="lead"'+(n.role==='lead'?' selected':'')+'>组长 lead</option><option value="supervisor"'+(n.role==='supervisor'?' selected':'')+'>监管 supervisor</option><option value="admin"'+(n.role==='admin'?' selected':'')+'>管理员 admin</option></select><label class="sm mut">能力</label><div id="ec" class="row" style="margin:6px 0 16px">'+caps.map(c=>'<label><input type="checkbox" value="'+c+'"'+((n.capabilities||[]).includes(c)?' checked':'')+'> '+c+'</label>').join('')+'</div><div class="row" style="justify-content:flex-end"><button class="ghost" onclick="document.getElementById(\'mEd\').remove()">取消</button><button onclick="saveNode(\''+id+'\')">保存</button></div></div></div>')}
|
||
async function saveNode(id){const b={};const nn=$('#en').value.trim();if(nn)b.name=nn;const rr=$('#er').value;if(rr)b.role=rr;
|
||
const cc=[...document.querySelectorAll('#ec input:checked')].map(x=>x.value);if(cc.length)b.capabilities=cc;
|
||
try{await api('/api/nodes/'+encodeURIComponent(id),{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify(b)});document.getElementById('mEd').remove();refresh()}catch(e){alert(e.message)}}
|
||
function vApi(){$('#view').innerHTML='<h1>接口文档</h1><div class="sub">Base: <code>'+location.origin+'</code> · 全部已开 CORS · 实时流 <code>GET /api/events</code> (SSE: task/node)</div>'+
|
||
[['GET','/api/status','总览统计'],['GET','/api/tasks','任务列表'],['POST','/api/tasks','创建 {prompt,title?,acceptance?,flow?,requiredRole?,callbackUrl?}'],['POST','/api/tasks/:id/cancel','停止'],['POST','/api/tasks/:id/approve','批准'],['GET','/api/tasks/:id/artifact.txt','下载产物'],['GET','/api/nodes','智能体'],['PATCH','/api/nodes/:id','编辑 {name?,capabilities?,role?}'],['DELETE','/api/nodes/:id','移除'],['GET','/api/discover','本机CLI发现'],['POST','/api/nodes/local','加入 {cli,cliArgs?,name?,role?}'],['POST','/api/pipeline','AI团队 {goal}'],['GET','/api/export?format=json','导出']].map(r=>'<div class="card row"><span class="pill p-done">'+r[0]+'</span><code>'+r[1]+'</code><span class="mut sm">'+r[2]+'</span></div>').join('')}
|
||
window.addEventListener('hashchange',render);refresh();setInterval(refresh,8000);
|
||
try{const es=new EventSource('/api/events');es.addEventListener('task',refresh);es.addEventListener('node',refresh)}catch(e){}
|
||
</script></body></html> |