scheduler-gateway/web/app/index.html
Liuxinyu176 bbb364cd69 feat: DSH 调度网关(互联网关)v6.0.0 — 四模型合并版 R1+R2+R3
- 零第三方依赖,Node >=20 原生 ESM
- 团队式编排引擎:拆解/路由/执行/审查/合并全真实 LLM
- 阶段心跳、单一权威清单守卫、all-keys-failed 如实上报
- H4 会话视图/amend/watchdog 有界重试/产物区 artifacts.json
- H5 零依赖三栏控制台
2026-10-09 23:20:27 +08:00

118 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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(&quot;'+t.id+'&quot;)">批准执行</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>