@evomap/evolver-webui 2.0.0-beta.1 → 2.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/console.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** 3 栏主控台 HTML(自包含, 无构建步; 轮询 /api/* 渲染). 军杰 §9.5: Triggers|Timeline|Inspector. */
2
- export declare const CONSOLE_HTML = "<!doctype html><html lang=\"zh\"><head><meta charset=\"utf-8\"><title>Evolver \u63A7\u53F0</title>\n<style>\n*{box-sizing:border-box}body{margin:0;font:13px/1.5 ui-monospace,Menlo,monospace;background:#0d1117;color:#c9d1d9}\nheader{padding:8px 12px;background:#161b22;border-bottom:1px solid #30363d;display:flex;gap:16px;align-items:center}\nheader b{color:#58a6ff}.muted{color:#8b949e}\n.cols{display:grid;grid-template-columns:1fr 1fr 1.2fr;height:calc(100vh - 42px)}\n.col{overflow:auto;border-right:1px solid #30363d;padding:8px}\nh2{font-size:12px;text-transform:uppercase;color:#8b949e;margin:4px 0 8px;letter-spacing:.05em}\n.row{padding:6px 8px;border:1px solid #30363d;border-radius:6px;margin-bottom:6px;cursor:pointer}\n.row:hover{border-color:#58a6ff}.tag{display:inline-block;padding:0 6px;border-radius:10px;font-size:11px}\n.ok{background:#1a3d1a;color:#6fdd6f}.fail{background:#3d1a1a;color:#ff7b72}.warn{background:#3d3a1a;color:#e3b341}\n.act{display:flex;gap:6px;margin-top:8px}button{background:#21262d;color:#c9d1d9;border:1px solid #30363d;border-radius:6px;padding:4px 8px;cursor:pointer}\nbutton:hover{border-color:#58a6ff}pre{white-space:pre-wrap;word-break:break-all;background:#161b22;padding:8px;border-radius:6px}\n.valuecard{padding:10px 14px;background:#11161d;border-bottom:1px solid #30363d;display:flex;gap:24px;align-items:center;flex-wrap:wrap}\n.valuecard .big{font-size:18px;color:#6fdd6f;font-weight:600}.valuecard .est{color:#e3b341}.valuecard .lbl{color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:.05em}\n</style></head><body>\n<header><b>EVOLVER</b> <span id=\"stat\" class=\"muted\">\u2026</span><span id=\"daily\" class=\"muted\"></span></header>\n<div class=\"valuecard\" id=\"valuecard\"><span class=\"muted\">\u4EF7\u503C / value \u2026</span></div>\n<div class=\"valuecard\" id=\"reviewqueue\"><span class=\"muted\">review queue \u2026</span></div>\n<div class=\"cols\">\n <div class=\"col\"><h2>Triggers</h2><div id=\"triggers\"></div></div>\n <div class=\"col\"><h2>Now &amp; Timeline</h2><div id=\"cycles\"></div></div>\n <div class=\"col\"><h2>Inspector</h2><div id=\"inspector\" class=\"muted\">\u9009\u4E00\u4E2A cycle \u6216\u4E8B\u4EF6</div>\n <div class=\"act\"><button onclick=\"act('observe')\">Observe</button><button onclick=\"act('nudge')\">Nudge</button><button onclick=\"act('intervene')\">Intervene</button><button onclick=\"act('teach')\">Teach</button></div>\n </div>\n</div>\n<script>\nlet sel=null;\nconst TOK=new URLSearchParams(location.search).get('token')||'';\nasync function j(u,o){o=o||{};o.headers=Object.assign({},o.headers||{},{authorization:'Bearer '+TOK});const r=await fetch(u,o);return r.json()}\n// XSS guards (#200): server data (gene summaries / event titles / payloads) is built from untrusted material \u2014\n// never put it into innerHTML raw. esc() = HTML-text escape (numeric entities) for text positions; q() strips a\n// value to the content-addressed id charset for inline-handler string args so a malicious id cannot break the\n// attribute/JS string. Every dynamic value below goes through one of these before reaching innerHTML.\nconst esc=s=>String(s==null?'':s).replace(/[&<>\"']/g,c=>'&#'+c.charCodeAt(0)+';');\nconst q=s=>String(s==null?'':s).replace(/[^a-zA-Z0-9:_.-]/g,'');\nasync function refresh(){\n const s=await j('/api/status');document.getElementById('stat').textContent='events='+s.totalEvents+' cycles='+s.cycles;\n const d=await j('/api/daily-summary');document.getElementById('daily').textContent='\u4ECA\u65E5 '+d.solidified+'\u2713/'+d.failed+'\u2717 \u89E6\u53D1'+d.triggered;\n const v=await j('/api/value?window=7d');const nf=n=>Math.round(n||0).toLocaleString('en-US');\n const top=(v.topGenes||[]).slice(0,3).map(g=>esc(g.assetId)+' \u00D7'+esc(g.reuses)).join(' \u00B7 ')||'\u2014';\n document.getElementById('valuecard').innerHTML=\n '<div><div class=\"lbl\">measured saved (7d)</div><span class=\"big\">'+nf(v.totalTokensSaved)+' tokens</span> <span class=\"muted\">$'+(v.totalCostUsd||0).toFixed(4)+'</span></div>'\n +'<div><div class=\"lbl\">estimated</div><span class=\"est\">'+nf(v.estimated&&v.estimated.totalTokensSaved)+' tokens</span> <span class=\"muted\">(tracked separately)</span></div>'\n +'<div><div class=\"lbl\">top reused</div><span class=\"muted\">'+top+'</span></div>';\n const rv=await j('/api/review');const stag=s=>s==='quarantined'?'warn':s==='approved'?'ok':s==='rejected'?'fail':'muted';\n const pend=rv.filter(g=>g.state==='quarantined').length;\n const btns=g=>g.state==='quarantined'?' <button onclick=\"reviewAct(\\''+q(g.assetId)+'\\',\\'approve\\')\">Approve</button><button onclick=\"reviewAct(\\''+q(g.assetId)+'\\',\\'reject\\')\">Reject</button>':'';\n document.getElementById('reviewqueue').innerHTML='<div><div class=\"lbl\">review queue</div><span class=\"big\">'+pend+'</span> <span class=\"muted\">\u5F85\u5BA1 pending</span></div>'\n +rv.slice(0,8).map(g=>'<div><span class=\"tag '+stag(g.state)+'\">'+esc(g.state)+'</span> '+esc(g.geneId)+(g.autoDrafted?' <span class=\"muted\">auto</span>':'')+btns(g)+'</div>').join('');\n const tr=await j('/api/triggers');document.getElementById('triggers').innerHTML=tr.map(t=>'<div class=\"row\"><span class=\"tag '+(t.triggered?'ok':'warn')+'\">'+(t.triggered?'\u89E6\u53D1':'\u6291\u5236')+'</span> '+esc(t.patternId)+' <span class=\"muted\">v='+((t.value||0).toFixed?t.value.toFixed(2):esc(t.value))+'</span></div>').join('')||'<div class=\"muted\">\u65E0</div>';\n const cs=await j('/api/cycles');document.getElementById('cycles').innerHTML=cs.slice(-30).reverse().map(c=>'<div class=\"row\" onclick=\"showCycle(\\''+q(c.cycleId)+'\\')\"><span class=\"tag '+(c.finalStage==='solidified'?'ok':c.finalStage==='failed'?'fail':'warn')+'\">'+esc(c.finalStage)+'</span> '+esc(c.cycleId)+' <span class=\"muted\">('+esc(c.events)+')</span></div>').join('')||'<div class=\"muted\">\u65E0 cycle</div>';\n}\nasync function showCycle(id){sel=id;const t=await j('/api/cycle?id='+encodeURIComponent(id));\n document.getElementById('inspector').innerHTML='<b>'+esc(id)+'</b>'+t.timeline.map(e=>'<div class=\"row\">#'+esc(e.seq)+' <b>'+esc(e.type)+'</b><br>'+esc(e.title)+(e.why?'<br><span class=\"muted\">why: '+esc(e.why)+'</span>':'')+(e.payload?'<pre>'+esc(JSON.stringify(e.payload,null,1))+'</pre>':'')+'</div>').join('');\n}\nasync function reviewAct(gene,action){const reason=prompt(action+' \u7406\u7531 reason:');if(reason===null)return;\n try{const r=await j('/api/review',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({gene,action,reason})});\n if(!r||r.ok!==true){alert('review \u5931\u8D25 failed: '+((r&&r.error)||'unknown'));return;}}\n catch(e){alert('review \u5931\u8D25 failed: '+e);return;}\n refresh();}\nasync function act(kind){const note=prompt(kind+' \u5907\u6CE8:');if(note===null)return;\n await j('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:kind,title:kind+(sel?(' @'+sel):''),note,cycleId:sel})});refresh();}\nrefresh();setInterval(refresh,4000);\n</script></body></html>";
2
+ export declare const CONSOLE_HTML = "<!doctype html><html lang=\"zh\"><head><meta charset=\"utf-8\"><title>Evolver \u63A7\u53F0</title>\n<style>\n*{box-sizing:border-box}html{height:100%}body{min-height:100%;margin:0;font:13px/1.5 ui-monospace,Menlo,monospace;background:#0d1117;color:#c9d1d9;display:grid;grid-template-rows:auto auto auto auto minmax(320px,1fr);overflow:auto}\nheader{padding:8px 12px;background:#161b22;border-bottom:1px solid #30363d;display:flex;gap:16px;align-items:center}\nheader b{color:#58a6ff}.muted{color:#8b949e}\n.cols{display:grid;grid-template-columns:1fr 1fr 1.2fr;min-height:320px}\n.col{overflow:auto;border-right:1px solid #30363d;padding:8px}\nh2{font-size:12px;text-transform:uppercase;color:#8b949e;margin:4px 0 8px;letter-spacing:.05em}\n.row{padding:6px 8px;border:1px solid #30363d;border-radius:6px;margin-bottom:6px;cursor:pointer}\n.row:hover{border-color:#58a6ff}.tag{display:inline-block;padding:0 6px;border-radius:10px;font-size:11px}\n.ok{background:#1a3d1a;color:#6fdd6f}.fail{background:#3d1a1a;color:#ff7b72}.warn{background:#3d3a1a;color:#e3b341}\n.act{display:flex;gap:6px;margin-top:8px}button{background:#21262d;color:#c9d1d9;border:1px solid #30363d;border-radius:6px;padding:4px 8px;cursor:pointer}\nbutton:hover{border-color:#58a6ff}pre{white-space:pre-wrap;word-break:break-all;background:#161b22;padding:8px;border-radius:6px}\n.valuecard{padding:10px 14px;background:#11161d;border-bottom:1px solid #30363d;display:flex;gap:24px;align-items:center;flex-wrap:wrap}\n.valuecard .big{font-size:18px;color:#6fdd6f;font-weight:600}.valuecard .est{color:#e3b341}.valuecard .lbl{color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:.05em}\n#reviewqueue{max-height:156px;overflow:auto;align-items:flex-start}\n@media(max-width:760px){body{display:block;overflow:auto}.cols{display:block}.col{border-right:0;border-bottom:1px solid #30363d;min-height:240px}#reviewqueue{max-height:none}}\n</style></head><body>\n<header><b>EVOLVER</b> <span id=\"stat\" class=\"muted\">\u2026</span><span id=\"daily\" class=\"muted\"></span></header>\n<div class=\"valuecard\" id=\"valuecard\"><span class=\"muted\">\u4EF7\u503C / value \u2026</span></div>\n<div class=\"valuecard\" id=\"reviewqueue\"><span class=\"muted\">review queue \u2026</span></div>\n<div class=\"valuecard\" id=\"retentionbar\"><span class=\"muted\">retention health \u2026</span></div>\n<div class=\"cols\">\n <div class=\"col\"><h2>Triggers</h2><div id=\"triggers\"></div></div>\n <div class=\"col\"><h2>Now &amp; Timeline</h2><div id=\"cycles\"></div></div>\n <div class=\"col\"><h2>Inspector</h2><div id=\"inspector\" class=\"muted\">\u9009\u4E00\u4E2A cycle \u6216\u4E8B\u4EF6</div>\n <div class=\"act\"><button onclick=\"act('observe')\">Observe</button><button onclick=\"act('nudge')\">Nudge</button><button onclick=\"act('intervene')\">Intervene</button><button onclick=\"act('teach')\">Teach</button></div>\n </div>\n</div>\n<script>\nlet sel=null;\nconst FRAG=new URLSearchParams(location.hash.slice(1));\nconst QUERY=new URLSearchParams(location.search);\nlet STORED='';try{STORED=sessionStorage.getItem('evolver.dashboard.token')||''}catch{}\nconst TOK=FRAG.get('token')||QUERY.get('token')||STORED;\nif(TOK)try{sessionStorage.setItem('evolver.dashboard.token',TOK)}catch{}\nif(FRAG.has('token')||QUERY.has('token')){QUERY.delete('token');const q=QUERY.toString();history.replaceState(null,'',location.pathname+(q?'?'+q:''))}\nasync function j(u,o){o=o||{};o.headers=Object.assign({},o.headers||{});if(TOK)o.headers.authorization='Bearer '+TOK;o.credentials='same-origin';const r=await fetch(u,o);return r.json()}\n// XSS guards (#200): server data (gene summaries / event titles / payloads) is built from untrusted material \u2014\n// never put it into innerHTML raw. esc() = HTML-text escape (numeric entities) for text positions; q() strips a\n// value to the content-addressed id charset for inline-handler string args so a malicious id cannot break the\n// attribute/JS string. Every dynamic value below goes through one of these before reaching innerHTML.\nconst esc=s=>String(s==null?'':s).replace(/[&<>\"']/g,c=>'&#'+c.charCodeAt(0)+';');\nconst q=s=>String(s==null?'':s).replace(/[^a-zA-Z0-9:_.-]/g,'');\nconst nf=n=>Math.round(Number(n)||0).toLocaleString('en-US');\nasync function refresh(){\n const s=await j('/api/status');document.getElementById('stat').textContent='events='+s.totalEvents+' cycles='+s.cycles;\n const d=await j('/api/daily-summary');document.getElementById('daily').textContent='\u4ECA\u65E5 '+d.solidified+'\u2713/'+d.failed+'\u2717 \u89E6\u53D1'+d.triggered;\n const v=await j('/api/value?window=7d');\n const top=(v.topGenes||[]).slice(0,3).map(g=>esc(g.assetId)+' \u00D7'+esc(g.reuses)).join(' \u00B7 ')||'\u2014';\n document.getElementById('valuecard').innerHTML=\n '<div><div class=\"lbl\">measured saved (7d)</div><span class=\"big\">'+nf(v.totalTokensSaved)+' tokens</span> <span class=\"muted\">$'+(v.totalCostUsd||0).toFixed(4)+'</span></div>'\n +'<div><div class=\"lbl\">estimated</div><span class=\"est\">'+nf(v.estimated&&v.estimated.totalTokensSaved)+' tokens</span> <span class=\"muted\">(tracked separately)</span></div>'\n +'<div><div class=\"lbl\">top reused</div><span class=\"muted\">'+top+'</span></div>';\n const rv=await j('/api/review');const stag=s=>s==='quarantined'?'warn':s==='approved'?'ok':s==='rejected'?'fail':'muted';\n const pend=rv.filter(g=>g.state==='quarantined').length;\n const btns=g=>g.state==='quarantined'?' <button onclick=\"reviewAct(\\''+q(g.assetId)+'\\',\\'approve\\')\">Approve</button><button onclick=\"reviewAct(\\''+q(g.assetId)+'\\',\\'reject\\')\">Reject</button>':'';\n document.getElementById('reviewqueue').innerHTML='<div><div class=\"lbl\">review queue</div><span class=\"big\">'+pend+'</span> <span class=\"muted\">\u5F85\u5BA1 pending</span></div>'\n +rv.slice(0,8).map(g=>'<div><span class=\"tag '+stag(g.state)+'\">'+esc(g.state)+'</span> '+esc(g.geneId)+(g.autoDrafted?' <span class=\"muted\">auto</span>':'')+btns(g)+'</div>').join('');\n const tr=await j('/api/triggers');document.getElementById('triggers').innerHTML=tr.map(t=>'<div class=\"row\"><span class=\"tag '+(t.triggered?'ok':'warn')+'\">'+(t.triggered?'\u89E6\u53D1':'\u6291\u5236')+'</span> '+esc(t.patternId)+' <span class=\"muted\">v='+((t.value||0).toFixed?t.value.toFixed(2):esc(t.value))+'</span></div>').join('')||'<div class=\"muted\">\u65E0</div>';\n const cs=await j('/api/cycles');document.getElementById('cycles').innerHTML=cs.slice(-30).reverse().map(c=>'<div class=\"row\" onclick=\"showCycle(\\''+q(c.cycleId)+'\\')\"><span class=\"tag '+(c.finalStage==='solidified'?'ok':c.finalStage==='failed'?'fail':'warn')+'\">'+esc(c.finalStage)+'</span> '+esc(c.cycleId)+' <span class=\"muted\">('+esc(c.events)+')</span></div>').join('')||'<div class=\"muted\">\u65E0 cycle</div>';\n}\nasync function refreshRetention(){const el=document.getElementById('retentionbar');\n try{const r=await j('/api/retention');if(!r||r.available!==true){el.innerHTML='<span class=\"muted\">retention unavailable</span>';return;}\n const safe=!!(r.rootEvents&&r.rootEvents.archiveRotationSafe&&r.material&&r.material.archiveRotationSafe);\n const gate=safe?'ok':'warn';const warnings=Array.isArray(r.warnings)?r.warnings.length:0;\n el.innerHTML='<div><div class=\"lbl\">retention health</div><span class=\"tag '+gate+'\">'+(safe?'safe':'attention')+'</span> <span class=\"muted\">warnings '+esc(warnings)+'</span></div>'\n +'<div><div class=\"lbl\">root events \u00B7 '+esc(r.rootEvents.state)+'</div><span class=\"big\">'+esc(nf(r.rootEvents.records))+'</span> <span class=\"muted\">active \u00B7 '+esc(nf(r.rootEvents.archiveRecords))+' archive \u00B7 '+esc(nf(r.rootEvents.historyRecords))+' history</span></div>'\n +'<div><div class=\"lbl\">material \u00B7 '+esc(r.material.state)+'</div><span class=\"big\">'+esc(nf(r.material.records))+'</span> <span class=\"muted\">active \u00B7 '+esc(nf(r.material.archiveRecords))+' archive \u00B7 '+esc(nf(r.material.historyRecords))+' history \u00B7 '+esc(nf(r.material.pending))+' pending \u00B7 cursor '+esc(r.material.cursor)+'</span></div>';\n }catch{el.innerHTML='<span class=\"tag warn\">retention unavailable</span>'}}\nasync function showCycle(id){sel=id;const t=await j('/api/cycle?id='+encodeURIComponent(id));\n document.getElementById('inspector').innerHTML='<b>'+esc(id)+'</b>'+t.timeline.map(e=>'<div class=\"row\">#'+esc(e.seq)+' <b>'+esc(e.type)+'</b><br>'+esc(e.title)+(e.why?'<br><span class=\"muted\">why: '+esc(e.why)+'</span>':'')+(e.payload?'<pre>'+esc(JSON.stringify(e.payload,null,1))+'</pre>':'')+'</div>').join('');\n}\nasync function reviewAct(gene,action){const reason=prompt(action+' \u7406\u7531 reason:');if(reason===null)return;\n try{const r=await j('/api/review',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({gene,action,reason})});\n if(!r||r.ok!==true){alert('review \u5931\u8D25 failed: '+((r&&r.error)||'unknown'));return;}}\n catch(e){alert('review \u5931\u8D25 failed: '+e);return;}\n refresh();}\nasync function act(kind){const note=prompt(kind+' \u5907\u6CE8:');if(note===null)return;\n await j('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:kind,title:kind+(sel?(' @'+sel):''),note,cycleId:sel})});refresh();}\nrefresh();refreshRetention();setInterval(refresh,4000);setInterval(refreshRetention,60000);\n</script></body></html>";
package/dist/console.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /** 3 栏主控台 HTML(自包含, 无构建步; 轮询 /api/* 渲染). 军杰 §9.5: Triggers|Timeline|Inspector. */
2
2
  export const CONSOLE_HTML = `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>Evolver 控台</title>
3
3
  <style>
4
- *{box-sizing:border-box}body{margin:0;font:13px/1.5 ui-monospace,Menlo,monospace;background:#0d1117;color:#c9d1d9}
4
+ *{box-sizing:border-box}html{height:100%}body{min-height:100%;margin:0;font:13px/1.5 ui-monospace,Menlo,monospace;background:#0d1117;color:#c9d1d9;display:grid;grid-template-rows:auto auto auto auto minmax(320px,1fr);overflow:auto}
5
5
  header{padding:8px 12px;background:#161b22;border-bottom:1px solid #30363d;display:flex;gap:16px;align-items:center}
6
6
  header b{color:#58a6ff}.muted{color:#8b949e}
7
- .cols{display:grid;grid-template-columns:1fr 1fr 1.2fr;height:calc(100vh - 42px)}
7
+ .cols{display:grid;grid-template-columns:1fr 1fr 1.2fr;min-height:320px}
8
8
  .col{overflow:auto;border-right:1px solid #30363d;padding:8px}
9
9
  h2{font-size:12px;text-transform:uppercase;color:#8b949e;margin:4px 0 8px;letter-spacing:.05em}
10
10
  .row{padding:6px 8px;border:1px solid #30363d;border-radius:6px;margin-bottom:6px;cursor:pointer}
@@ -14,10 +14,13 @@ h2{font-size:12px;text-transform:uppercase;color:#8b949e;margin:4px 0 8px;letter
14
14
  button:hover{border-color:#58a6ff}pre{white-space:pre-wrap;word-break:break-all;background:#161b22;padding:8px;border-radius:6px}
15
15
  .valuecard{padding:10px 14px;background:#11161d;border-bottom:1px solid #30363d;display:flex;gap:24px;align-items:center;flex-wrap:wrap}
16
16
  .valuecard .big{font-size:18px;color:#6fdd6f;font-weight:600}.valuecard .est{color:#e3b341}.valuecard .lbl{color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:.05em}
17
+ #reviewqueue{max-height:156px;overflow:auto;align-items:flex-start}
18
+ @media(max-width:760px){body{display:block;overflow:auto}.cols{display:block}.col{border-right:0;border-bottom:1px solid #30363d;min-height:240px}#reviewqueue{max-height:none}}
17
19
  </style></head><body>
18
20
  <header><b>EVOLVER</b> <span id="stat" class="muted">…</span><span id="daily" class="muted"></span></header>
19
21
  <div class="valuecard" id="valuecard"><span class="muted">价值 / value …</span></div>
20
22
  <div class="valuecard" id="reviewqueue"><span class="muted">review queue …</span></div>
23
+ <div class="valuecard" id="retentionbar"><span class="muted">retention health …</span></div>
21
24
  <div class="cols">
22
25
  <div class="col"><h2>Triggers</h2><div id="triggers"></div></div>
23
26
  <div class="col"><h2>Now &amp; Timeline</h2><div id="cycles"></div></div>
@@ -27,18 +30,24 @@ button:hover{border-color:#58a6ff}pre{white-space:pre-wrap;word-break:break-all;
27
30
  </div>
28
31
  <script>
29
32
  let sel=null;
30
- const TOK=new URLSearchParams(location.search).get('token')||'';
31
- async function j(u,o){o=o||{};o.headers=Object.assign({},o.headers||{},{authorization:'Bearer '+TOK});const r=await fetch(u,o);return r.json()}
33
+ const FRAG=new URLSearchParams(location.hash.slice(1));
34
+ const QUERY=new URLSearchParams(location.search);
35
+ let STORED='';try{STORED=sessionStorage.getItem('evolver.dashboard.token')||''}catch{}
36
+ const TOK=FRAG.get('token')||QUERY.get('token')||STORED;
37
+ if(TOK)try{sessionStorage.setItem('evolver.dashboard.token',TOK)}catch{}
38
+ if(FRAG.has('token')||QUERY.has('token')){QUERY.delete('token');const q=QUERY.toString();history.replaceState(null,'',location.pathname+(q?'?'+q:''))}
39
+ async function j(u,o){o=o||{};o.headers=Object.assign({},o.headers||{});if(TOK)o.headers.authorization='Bearer '+TOK;o.credentials='same-origin';const r=await fetch(u,o);return r.json()}
32
40
  // XSS guards (#200): server data (gene summaries / event titles / payloads) is built from untrusted material —
33
41
  // never put it into innerHTML raw. esc() = HTML-text escape (numeric entities) for text positions; q() strips a
34
42
  // value to the content-addressed id charset for inline-handler string args so a malicious id cannot break the
35
43
  // attribute/JS string. Every dynamic value below goes through one of these before reaching innerHTML.
36
44
  const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>'&#'+c.charCodeAt(0)+';');
37
45
  const q=s=>String(s==null?'':s).replace(/[^a-zA-Z0-9:_.-]/g,'');
46
+ const nf=n=>Math.round(Number(n)||0).toLocaleString('en-US');
38
47
  async function refresh(){
39
48
  const s=await j('/api/status');document.getElementById('stat').textContent='events='+s.totalEvents+' cycles='+s.cycles;
40
49
  const d=await j('/api/daily-summary');document.getElementById('daily').textContent='今日 '+d.solidified+'✓/'+d.failed+'✗ 触发'+d.triggered;
41
- const v=await j('/api/value?window=7d');const nf=n=>Math.round(n||0).toLocaleString('en-US');
50
+ const v=await j('/api/value?window=7d');
42
51
  const top=(v.topGenes||[]).slice(0,3).map(g=>esc(g.assetId)+' ×'+esc(g.reuses)).join(' · ')||'—';
43
52
  document.getElementById('valuecard').innerHTML=
44
53
  '<div><div class="lbl">measured saved (7d)</div><span class="big">'+nf(v.totalTokensSaved)+' tokens</span> <span class="muted">$'+(v.totalCostUsd||0).toFixed(4)+'</span></div>'
@@ -52,6 +61,14 @@ async function refresh(){
52
61
  const tr=await j('/api/triggers');document.getElementById('triggers').innerHTML=tr.map(t=>'<div class="row"><span class="tag '+(t.triggered?'ok':'warn')+'">'+(t.triggered?'触发':'抑制')+'</span> '+esc(t.patternId)+' <span class="muted">v='+((t.value||0).toFixed?t.value.toFixed(2):esc(t.value))+'</span></div>').join('')||'<div class="muted">无</div>';
53
62
  const cs=await j('/api/cycles');document.getElementById('cycles').innerHTML=cs.slice(-30).reverse().map(c=>'<div class="row" onclick="showCycle(\\''+q(c.cycleId)+'\\')"><span class="tag '+(c.finalStage==='solidified'?'ok':c.finalStage==='failed'?'fail':'warn')+'">'+esc(c.finalStage)+'</span> '+esc(c.cycleId)+' <span class="muted">('+esc(c.events)+')</span></div>').join('')||'<div class="muted">无 cycle</div>';
54
63
  }
64
+ async function refreshRetention(){const el=document.getElementById('retentionbar');
65
+ try{const r=await j('/api/retention');if(!r||r.available!==true){el.innerHTML='<span class="muted">retention unavailable</span>';return;}
66
+ const safe=!!(r.rootEvents&&r.rootEvents.archiveRotationSafe&&r.material&&r.material.archiveRotationSafe);
67
+ const gate=safe?'ok':'warn';const warnings=Array.isArray(r.warnings)?r.warnings.length:0;
68
+ el.innerHTML='<div><div class="lbl">retention health</div><span class="tag '+gate+'">'+(safe?'safe':'attention')+'</span> <span class="muted">warnings '+esc(warnings)+'</span></div>'
69
+ +'<div><div class="lbl">root events · '+esc(r.rootEvents.state)+'</div><span class="big">'+esc(nf(r.rootEvents.records))+'</span> <span class="muted">active · '+esc(nf(r.rootEvents.archiveRecords))+' archive · '+esc(nf(r.rootEvents.historyRecords))+' history</span></div>'
70
+ +'<div><div class="lbl">material · '+esc(r.material.state)+'</div><span class="big">'+esc(nf(r.material.records))+'</span> <span class="muted">active · '+esc(nf(r.material.archiveRecords))+' archive · '+esc(nf(r.material.historyRecords))+' history · '+esc(nf(r.material.pending))+' pending · cursor '+esc(r.material.cursor)+'</span></div>';
71
+ }catch{el.innerHTML='<span class="tag warn">retention unavailable</span>'}}
55
72
  async function showCycle(id){sel=id;const t=await j('/api/cycle?id='+encodeURIComponent(id));
56
73
  document.getElementById('inspector').innerHTML='<b>'+esc(id)+'</b>'+t.timeline.map(e=>'<div class="row">#'+esc(e.seq)+' <b>'+esc(e.type)+'</b><br>'+esc(e.title)+(e.why?'<br><span class="muted">why: '+esc(e.why)+'</span>':'')+(e.payload?'<pre>'+esc(JSON.stringify(e.payload,null,1))+'</pre>':'')+'</div>').join('');
57
74
  }
@@ -62,5 +79,5 @@ async function reviewAct(gene,action){const reason=prompt(action+' 理由 reason
62
79
  refresh();}
63
80
  async function act(kind){const note=prompt(kind+' 备注:');if(note===null)return;
64
81
  await j('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:kind,title:kind+(sel?(' @'+sel):''),note,cycleId:sel})});refresh();}
65
- refresh();setInterval(refresh,4000);
82
+ refresh();refreshRetention();setInterval(refresh,4000);setInterval(refreshRetention,60000);
66
83
  </script></body></html>`;
@@ -0,0 +1,13 @@
1
+ import { events as ev } from '@evomap/evolver-core';
2
+ export interface EventSnapshotSource {
3
+ version(): string;
4
+ read(): readonly ev.ReportEvent[];
5
+ }
6
+ export declare function fileEventSnapshotSource(eventsPath: string): EventSnapshotSource;
7
+ /** Reuses a parsed append-only event snapshot only when the file identity is stable across the read. */
8
+ export declare class EventSnapshotCache {
9
+ private readonly source;
10
+ private cached;
11
+ constructor(source: EventSnapshotSource);
12
+ read(): readonly ev.ReportEvent[];
13
+ }
@@ -0,0 +1,51 @@
1
+ import { statSync } from 'node:fs';
2
+ import { events as ev } from '@evomap/evolver-core';
3
+ function isMissing(error) {
4
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
5
+ }
6
+ export function fileEventSnapshotSource(eventsPath) {
7
+ return {
8
+ version: () => {
9
+ try {
10
+ const stat = statSync(eventsPath, { bigint: true });
11
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
12
+ }
13
+ catch (error) {
14
+ if (isMissing(error))
15
+ return 'missing';
16
+ throw error;
17
+ }
18
+ },
19
+ read: () => ev.readEvents(eventsPath),
20
+ };
21
+ }
22
+ /** Reuses a parsed append-only event snapshot only when the file identity is stable across the read. */
23
+ export class EventSnapshotCache {
24
+ source;
25
+ cached;
26
+ constructor(source) {
27
+ this.source = source;
28
+ }
29
+ read() {
30
+ let before;
31
+ try {
32
+ before = this.source.version();
33
+ }
34
+ catch {
35
+ return this.source.read();
36
+ }
37
+ if (this.cached?.version === before)
38
+ return this.cached.events;
39
+ const events = this.source.read();
40
+ let after;
41
+ try {
42
+ after = this.source.version();
43
+ }
44
+ catch {
45
+ return events;
46
+ }
47
+ if (before === after)
48
+ this.cached = { version: after, events };
49
+ return events;
50
+ }
51
+ }
package/dist/server.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { events as ev, assetstore, mailbox as mb, ops } from '@evomap/evolver-core';
2
+ import { type EventSnapshotSource } from './eventSnapshot.js';
2
3
  export interface WebUIServerDeps {
3
4
  eventsPath: string;
4
5
  ingestor?: ev.Ingestor;
@@ -11,12 +12,18 @@ export interface WebUIServerDeps {
11
12
  /** 人工操作记的 actor id. */
12
13
  actorId?: string;
13
14
  /** Bearer token required for all /api/* routes; auto-generated if absent. Loopback alone is not an
14
- * auth boundary on shared hosts. The launcher prints it; the console gets it via the ?token= URL param. */
15
+ * auth boundary on shared hosts. The launcher passes it in a URL fragment; ?token= remains compatible. */
15
16
  token?: string;
17
+ /** One-time launcher ticket exchanged for an HttpOnly session cookie. */
18
+ launchTicket?: string;
16
19
  /** Value-card data provider (#113). Injected by the composition layer wired to ops.loadValueSummary over the
17
20
  * proxy traces + root_events + the adapter's price table — the WebUI stays a THIN shell (it never prices or
18
21
  * re-derives savings; M7 克制). Absent → /api/value reports an empty summary instead of failing. */
19
- valueSummary?: (window: ops.SummaryWindow) => ops.ValueSummary;
22
+ valueSummary?: (window: ops.SummaryWindow, events: readonly ev.ReportEvent[]) => ops.ValueSummary;
23
+ /** Shared core retention report provider. Kept injectable so WebUI never owns filesystem policy or paths. */
24
+ retentionReport?: () => ev.RetentionReport;
25
+ /** Injectable file/source seam for versioned root-event snapshots. */
26
+ eventSource?: EventSnapshotSource;
20
27
  }
21
28
  /**
22
29
  * WebUI 控台(M7): 可观测 + 保活. node:http + 自包含 HTML, 仅绑 loopback.
@@ -31,10 +38,14 @@ export declare class WebUIServer {
31
38
  private readonly actorId;
32
39
  /** Review ledger backing the human-review queue. Undefined when no LocalJsonlProvider store is available. */
33
40
  private readonly review;
34
- /** Token guarding /api/*; printed by the launcher, supplied by the browser via ?token= or Bearer. */
41
+ /** Token guarding /api/*; supplied by the browser via Bearer, with ?token= retained for compatibility. */
35
42
  readonly token: string;
43
+ readonly launchTicket: string;
44
+ private launchTicketAvailable;
45
+ private readonly eventSnapshots;
36
46
  constructor(deps: WebUIServerDeps);
37
47
  listen(port?: number): Promise<number>;
48
+ private listenOnce;
38
49
  close(): Promise<void>;
39
50
  private handle;
40
51
  private readJson;
package/dist/server.js CHANGED
@@ -2,7 +2,16 @@ import { createServer } from 'node:http';
2
2
  import { randomBytes, timingSafeEqual } from 'node:crypto';
3
3
  import { events as ev, assetstore, mailbox as mb, ops } from '@evomap/evolver-core';
4
4
  import { CONSOLE_HTML } from './console.js';
5
+ import { EventSnapshotCache, fileEventSnapshotSource } from './eventSnapshot.js';
5
6
  const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
7
+ const DASHBOARD_COOKIE = 'evolver_dashboard';
8
+ const BROWSER_BLOCKED_PORTS = new Set([
9
+ 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95,
10
+ 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179,
11
+ 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601,
12
+ 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 5060, 5061, 6000, 6566,
13
+ 6665, 6666, 6667, 6668, 6669, 6697, 10080,
14
+ ]);
6
15
  /** The empty value summary (zero entries) — the shape /api/value returns when no provider is wired, so the card
7
16
  * always gets a valid ValueSummary to render. Derived from core's aggregator to stay shape-identical. */
8
17
  const EMPTY_VALUE_SUMMARY = ops.valueSummary([]);
@@ -11,6 +20,15 @@ function tokenEq(a, b) {
11
20
  const ba = Buffer.from(a), bb = Buffer.from(b);
12
21
  return ba.length === bb.length && timingSafeEqual(ba, bb);
13
22
  }
23
+ function cookieValue(header, name) {
24
+ for (const part of (header ?? '').split(';')) {
25
+ const at = part.indexOf('=');
26
+ if (at < 0 || part.slice(0, at).trim() !== name)
27
+ continue;
28
+ return part.slice(at + 1).trim();
29
+ }
30
+ return '';
31
+ }
14
32
  function positiveIntParam(value) {
15
33
  if (value === null || value.trim() === '')
16
34
  return undefined;
@@ -33,62 +51,137 @@ export class WebUIServer {
33
51
  actorId;
34
52
  /** Review ledger backing the human-review queue. Undefined when no LocalJsonlProvider store is available. */
35
53
  review;
36
- /** Token guarding /api/*; printed by the launcher, supplied by the browser via ?token= or Bearer. */
54
+ /** Token guarding /api/*; supplied by the browser via Bearer, with ?token= retained for compatibility. */
37
55
  token;
56
+ launchTicket;
57
+ launchTicketAvailable = true;
58
+ eventSnapshots;
38
59
  constructor(deps) {
39
60
  this.deps = deps;
40
61
  this.host = deps.host ?? '127.0.0.1';
41
62
  this.now = deps.now ?? (() => Date.now());
42
63
  this.actorId = deps.actorId ?? 'console';
43
64
  this.token = deps.token ?? randomBytes(16).toString('hex');
65
+ this.launchTicket = deps.launchTicket ?? randomBytes(16).toString('hex');
66
+ this.eventSnapshots = new EventSnapshotCache(deps.eventSource ?? fileEventSnapshotSource(deps.eventsPath));
44
67
  this.ingestor = deps.ingestor ?? new ev.Ingestor({ path: deps.eventsPath });
45
68
  // Co-locate the review ledger with the store so the queue reads the same review.jsonl the CLI writes.
46
69
  this.review = deps.review ?? (deps.store instanceof assetstore.LocalJsonlProvider ? new assetstore.ReviewLedger(deps.store.baseDir) : undefined);
47
70
  this.server = createServer((req, res) => { void this.handle(req, res); });
48
71
  }
49
- listen(port = 0) {
50
- return new Promise((resolve) => this.server.listen(port, this.host, () => {
51
- const a = this.server.address();
52
- resolve(a && typeof a === 'object' ? a.port : port);
53
- }));
72
+ async listen(port = 0) {
73
+ if (port !== 0 && BROWSER_BLOCKED_PORTS.has(port))
74
+ throw new Error('webui_port_blocked');
75
+ for (let attempt = 0; attempt < 5; attempt += 1) {
76
+ const assigned = await this.listenOnce(port);
77
+ if (port !== 0 || !BROWSER_BLOCKED_PORTS.has(assigned))
78
+ return assigned;
79
+ await this.close();
80
+ }
81
+ throw new Error('webui_safe_port_unavailable');
82
+ }
83
+ listenOnce(port) {
84
+ return new Promise((resolve, reject) => {
85
+ const onError = (error) => reject(error);
86
+ this.server.once('error', onError);
87
+ this.server.listen(port, this.host, () => {
88
+ this.server.removeListener('error', onError);
89
+ const a = this.server.address();
90
+ resolve(a && typeof a === 'object' ? a.port : port);
91
+ });
92
+ });
93
+ }
94
+ close() {
95
+ return new Promise((resolve, reject) => {
96
+ this.server.close((error) => (error ? reject(error) : resolve()));
97
+ this.server.closeAllConnections();
98
+ });
54
99
  }
55
- close() { return new Promise((res, rej) => this.server.close((e) => (e ? rej(e) : res()))); }
56
100
  async handle(req, res) {
57
101
  try {
58
102
  if (!LOOPBACK.has(req.socket.remoteAddress ?? ''))
59
103
  return this.send(res, 403, 'text/plain', 'non-loopback');
60
104
  const url = new URL(req.url ?? '/', 'http://localhost');
61
105
  const p = url.pathname;
106
+ if (p === '/launch') {
107
+ if (req.method !== 'GET') {
108
+ res.writeHead(405, { allow: 'GET', 'content-type': 'text/plain' });
109
+ res.end('method not allowed');
110
+ return;
111
+ }
112
+ const ticket = url.searchParams.get('ticket') ?? '';
113
+ if (!this.launchTicketAvailable || !tokenEq(ticket, this.launchTicket)) {
114
+ return this.send(res, 401, 'text/plain', 'unauthorized');
115
+ }
116
+ this.launchTicketAvailable = false;
117
+ res.writeHead(302, {
118
+ location: '/',
119
+ 'set-cookie': `${DASHBOARD_COOKIE}=${this.token}; HttpOnly; SameSite=Strict; Path=/`,
120
+ 'cache-control': 'no-store',
121
+ 'referrer-policy': 'no-referrer',
122
+ });
123
+ res.end();
124
+ return;
125
+ }
62
126
  // Root HTML is a static shell (no data) → served freely; it reads ?token= and authenticates the /api calls.
63
127
  if (p === '/' || p === '/index.html')
64
128
  return this.send(res, 200, 'text/html; charset=utf-8', CONSOLE_HTML);
65
129
  // Everything else (all /api/*) requires the token — accepted via Bearer header or ?token= (browser convenience).
66
130
  const auth = req.headers['authorization'] ?? '';
67
- const supplied = auth.startsWith('Bearer ') ? auth.slice(7) : (url.searchParams.get('token') ?? '');
68
- if (!tokenEq(supplied, this.token))
131
+ const bearer = auth.startsWith('Bearer ') ? auth.slice(7) : '';
132
+ const queryToken = url.searchParams.get('token') ?? '';
133
+ const cookieToken = cookieValue(req.headers.cookie, DASHBOARD_COOKIE);
134
+ const bearerValid = tokenEq(bearer, this.token);
135
+ const queryValid = tokenEq(queryToken, this.token);
136
+ const cookieValid = tokenEq(cookieToken, this.token);
137
+ if (!bearerValid && !queryValid && !cookieValid) {
69
138
  return this.send(res, 401, 'application/json', JSON.stringify({ error: 'unauthorized' }));
139
+ }
140
+ const stateChanging = req.method !== 'GET' && req.method !== 'HEAD';
141
+ if (stateChanging && cookieValid && !bearerValid && !queryValid) {
142
+ const expectedOrigin = req.headers.host ? `http://${req.headers.host}` : '';
143
+ if (!expectedOrigin || req.headers.origin !== expectedOrigin) {
144
+ return this.send(res, 403, 'application/json', JSON.stringify({ error: 'same_origin_required' }));
145
+ }
146
+ if (!(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) {
147
+ return this.send(res, 415, 'application/json', JSON.stringify({ error: 'json_required' }));
148
+ }
149
+ }
70
150
  if (p === '/api/status')
71
- return this.json(res, ev.statusReport(ev.readEvents(this.deps.eventsPath)));
151
+ return this.json(res, ev.statusReport(this.eventSnapshots.read()));
72
152
  if (p === '/api/cycles')
73
- return this.json(res, ev.listCycles(ev.readEvents(this.deps.eventsPath)));
153
+ return this.json(res, ev.listCycles(this.eventSnapshots.read()));
74
154
  if (p === '/api/cycle')
75
- return this.json(res, ev.showCycle(ev.readEvents(this.deps.eventsPath), url.searchParams.get('id') ?? ''));
155
+ return this.json(res, ev.showCycle(this.eventSnapshots.read(), url.searchParams.get('id') ?? ''));
76
156
  if (p === '/api/narrative') {
77
- return this.json(res, ev.buildNarrativeSnapshot(ev.readEvents(this.deps.eventsPath), { limit: positiveIntParam(url.searchParams.get('limit')) }));
157
+ return this.json(res, ev.buildNarrativeSnapshot(this.eventSnapshots.read(), { limit: positiveIntParam(url.searchParams.get('limit')) }));
78
158
  }
79
159
  if (p === '/api/triggers')
80
- return this.json(res, ev.listTriggers(ev.readEvents(this.deps.eventsPath)));
160
+ return this.json(res, ev.listTriggers(this.eventSnapshots.read()));
81
161
  if (p === '/api/daily-summary') {
82
162
  const day = url.searchParams.get('day') ?? new Date(this.now()).toISOString().slice(0, 10);
83
- return this.json(res, ev.dailySummary(ev.readEvents(this.deps.eventsPath), day));
163
+ return this.json(res, ev.dailySummary(this.eventSnapshots.read(), day));
84
164
  }
85
165
  if (p === '/api/value') {
86
166
  // Thin pass-through: the provider (composition layer) owns prices + traces; the server only scopes the
87
167
  // window and serializes. No provider wired → an empty summary so the card renders "no savings yet".
88
168
  const window = ops.windowFromSpec(url.searchParams.get('window') ?? undefined, this.now());
89
- const summary = this.deps.valueSummary ? this.deps.valueSummary(window) : EMPTY_VALUE_SUMMARY;
169
+ const summary = this.deps.valueSummary ? this.deps.valueSummary(window, this.eventSnapshots.read()) : EMPTY_VALUE_SUMMARY;
90
170
  return this.json(res, summary);
91
171
  }
172
+ if (p === '/api/retention') {
173
+ if (!this.deps.retentionReport)
174
+ return this.json(res, { available: false });
175
+ try {
176
+ return this.json(res, { ...this.deps.retentionReport(), available: true });
177
+ }
178
+ catch {
179
+ return this.send(res, 503, 'application/json', JSON.stringify({
180
+ available: false,
181
+ error: 'retention_unavailable',
182
+ }));
183
+ }
184
+ }
92
185
  if (p === '/api/mailbox') {
93
186
  if (!this.deps.mailbox)
94
187
  return this.json(res, { pending: 0, dlq: 0, messages: [] });
@@ -143,7 +236,7 @@ export class WebUIServer {
143
236
  // record) are appended as context from a bounded list. Pending (quarantined) sorts first. Read-only here.
144
237
  // Match the CLI's semantics exactly: only the unattended distill-observer counts as "auto-drafted"
145
238
  // (manual `ingest --distill` / skill2gep emit gene.distilled with a different source and are NOT auto).
146
- const autoDrafted = new Set(this.ingestor.readAll()
239
+ const autoDrafted = new Set(this.eventSnapshots.read()
147
240
  .filter((e) => e.type === 'gene.distilled' && e.payload?.['source'] === 'distill-observer')
148
241
  .map((e) => String(e.payload?.['assetId'] ?? ''))
149
242
  .filter(Boolean));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evomap/evolver-webui",
3
- "version": "2.0.0-beta.1",
3
+ "version": "2.0.0-beta.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "保活/可视化 WebUI",
@@ -13,7 +13,7 @@
13
13
  }
14
14
  },
15
15
  "dependencies": {
16
- "@evomap/evolver-core": "2.0.0-beta.1"
16
+ "@evomap/evolver-core": "2.0.0-beta.2"
17
17
  },
18
18
  "publishConfig": {
19
19
  "access": "public",
@@ -21,6 +21,7 @@
21
21
  },
22
22
  "files": [
23
23
  "dist/",
24
+ "assets/",
24
25
  "README.md",
25
26
  "package.json"
26
27
  ]