@evomap/evolver-webui 2.0.0-beta.0
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 +2 -0
- package/dist/console.js +66 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/server.d.ts +43 -0
- package/dist/server.js +204 -0
- package/package.json +27 -0
|
@@ -0,0 +1,2 @@
|
|
|
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 & 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>";
|
package/dist/console.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** 3 栏主控台 HTML(自包含, 无构建步; 轮询 /api/* 渲染). 军杰 §9.5: Triggers|Timeline|Inspector. */
|
|
2
|
+
export const CONSOLE_HTML = `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>Evolver 控台</title>
|
|
3
|
+
<style>
|
|
4
|
+
*{box-sizing:border-box}body{margin:0;font:13px/1.5 ui-monospace,Menlo,monospace;background:#0d1117;color:#c9d1d9}
|
|
5
|
+
header{padding:8px 12px;background:#161b22;border-bottom:1px solid #30363d;display:flex;gap:16px;align-items:center}
|
|
6
|
+
header b{color:#58a6ff}.muted{color:#8b949e}
|
|
7
|
+
.cols{display:grid;grid-template-columns:1fr 1fr 1.2fr;height:calc(100vh - 42px)}
|
|
8
|
+
.col{overflow:auto;border-right:1px solid #30363d;padding:8px}
|
|
9
|
+
h2{font-size:12px;text-transform:uppercase;color:#8b949e;margin:4px 0 8px;letter-spacing:.05em}
|
|
10
|
+
.row{padding:6px 8px;border:1px solid #30363d;border-radius:6px;margin-bottom:6px;cursor:pointer}
|
|
11
|
+
.row:hover{border-color:#58a6ff}.tag{display:inline-block;padding:0 6px;border-radius:10px;font-size:11px}
|
|
12
|
+
.ok{background:#1a3d1a;color:#6fdd6f}.fail{background:#3d1a1a;color:#ff7b72}.warn{background:#3d3a1a;color:#e3b341}
|
|
13
|
+
.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}
|
|
14
|
+
button:hover{border-color:#58a6ff}pre{white-space:pre-wrap;word-break:break-all;background:#161b22;padding:8px;border-radius:6px}
|
|
15
|
+
.valuecard{padding:10px 14px;background:#11161d;border-bottom:1px solid #30363d;display:flex;gap:24px;align-items:center;flex-wrap:wrap}
|
|
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
|
+
</style></head><body>
|
|
18
|
+
<header><b>EVOLVER</b> <span id="stat" class="muted">…</span><span id="daily" class="muted"></span></header>
|
|
19
|
+
<div class="valuecard" id="valuecard"><span class="muted">价值 / value …</span></div>
|
|
20
|
+
<div class="valuecard" id="reviewqueue"><span class="muted">review queue …</span></div>
|
|
21
|
+
<div class="cols">
|
|
22
|
+
<div class="col"><h2>Triggers</h2><div id="triggers"></div></div>
|
|
23
|
+
<div class="col"><h2>Now & Timeline</h2><div id="cycles"></div></div>
|
|
24
|
+
<div class="col"><h2>Inspector</h2><div id="inspector" class="muted">选一个 cycle 或事件</div>
|
|
25
|
+
<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>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
<script>
|
|
29
|
+
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()}
|
|
32
|
+
// XSS guards (#200): server data (gene summaries / event titles / payloads) is built from untrusted material —
|
|
33
|
+
// never put it into innerHTML raw. esc() = HTML-text escape (numeric entities) for text positions; q() strips a
|
|
34
|
+
// value to the content-addressed id charset for inline-handler string args so a malicious id cannot break the
|
|
35
|
+
// attribute/JS string. Every dynamic value below goes through one of these before reaching innerHTML.
|
|
36
|
+
const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>'&#'+c.charCodeAt(0)+';');
|
|
37
|
+
const q=s=>String(s==null?'':s).replace(/[^a-zA-Z0-9:_.-]/g,'');
|
|
38
|
+
async function refresh(){
|
|
39
|
+
const s=await j('/api/status');document.getElementById('stat').textContent='events='+s.totalEvents+' cycles='+s.cycles;
|
|
40
|
+
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');
|
|
42
|
+
const top=(v.topGenes||[]).slice(0,3).map(g=>esc(g.assetId)+' ×'+esc(g.reuses)).join(' · ')||'—';
|
|
43
|
+
document.getElementById('valuecard').innerHTML=
|
|
44
|
+
'<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>'
|
|
45
|
+
+'<div><div class="lbl">estimated</div><span class="est">'+nf(v.estimated&&v.estimated.totalTokensSaved)+' tokens</span> <span class="muted">(tracked separately)</span></div>'
|
|
46
|
+
+'<div><div class="lbl">top reused</div><span class="muted">'+top+'</span></div>';
|
|
47
|
+
const rv=await j('/api/review');const stag=s=>s==='quarantined'?'warn':s==='approved'?'ok':s==='rejected'?'fail':'muted';
|
|
48
|
+
const pend=rv.filter(g=>g.state==='quarantined').length;
|
|
49
|
+
const btns=g=>g.state==='quarantined'?' <button onclick="reviewAct(\\''+q(g.assetId)+'\\',\\'approve\\')">Approve</button><button onclick="reviewAct(\\''+q(g.assetId)+'\\',\\'reject\\')">Reject</button>':'';
|
|
50
|
+
document.getElementById('reviewqueue').innerHTML='<div><div class="lbl">review queue</div><span class="big">'+pend+'</span> <span class="muted">待审 pending</span></div>'
|
|
51
|
+
+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('');
|
|
52
|
+
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
|
+
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
|
+
}
|
|
55
|
+
async function showCycle(id){sel=id;const t=await j('/api/cycle?id='+encodeURIComponent(id));
|
|
56
|
+
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
|
+
}
|
|
58
|
+
async function reviewAct(gene,action){const reason=prompt(action+' 理由 reason:');if(reason===null)return;
|
|
59
|
+
try{const r=await j('/api/review',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({gene,action,reason})});
|
|
60
|
+
if(!r||r.ok!==true){alert('review 失败 failed: '+((r&&r.error)||'unknown'));return;}}
|
|
61
|
+
catch(e){alert('review 失败 failed: '+e);return;}
|
|
62
|
+
refresh();}
|
|
63
|
+
async function act(kind){const note=prompt(kind+' 备注:');if(note===null)return;
|
|
64
|
+
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);
|
|
66
|
+
</script></body></html>`;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { events as ev, assetstore, mailbox as mb, ops } from '@evomap/evolver-core';
|
|
2
|
+
export interface WebUIServerDeps {
|
|
3
|
+
eventsPath: string;
|
|
4
|
+
ingestor?: ev.Ingestor;
|
|
5
|
+
store?: assetstore.AssetStoreProvider;
|
|
6
|
+
/** 人审队列用的 review ledger(#117 人审门)。缺省由 LocalJsonlProvider store 的 baseDir 推导。 */
|
|
7
|
+
review?: assetstore.ReviewLedger;
|
|
8
|
+
mailbox?: mb.MailboxStore;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
host?: string;
|
|
11
|
+
/** 人工操作记的 actor id. */
|
|
12
|
+
actorId?: string;
|
|
13
|
+
/** 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
|
+
token?: string;
|
|
16
|
+
/** Value-card data provider (#113). Injected by the composition layer wired to ops.loadValueSummary over the
|
|
17
|
+
* proxy traces + root_events + the adapter's price table — the WebUI stays a THIN shell (it never prices or
|
|
18
|
+
* re-derives savings; M7 克制). Absent → /api/value reports an empty summary instead of failing. */
|
|
19
|
+
valueSummary?: (window: ops.SummaryWindow) => ops.ValueSummary;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* WebUI 控台(M7): 可观测 + 保活. node:http + 自包含 HTML, 仅绑 loopback.
|
|
23
|
+
* 复用 core events/reports 报表 + assetstore + mailbox; 人工操作 Observe/Nudge/Intervene/Teach 走 ingest(actor.kind=human).
|
|
24
|
+
*/
|
|
25
|
+
export declare class WebUIServer {
|
|
26
|
+
private readonly deps;
|
|
27
|
+
private readonly server;
|
|
28
|
+
private readonly ingestor;
|
|
29
|
+
private readonly host;
|
|
30
|
+
private readonly now;
|
|
31
|
+
private readonly actorId;
|
|
32
|
+
/** Review ledger backing the human-review queue. Undefined when no LocalJsonlProvider store is available. */
|
|
33
|
+
private readonly review;
|
|
34
|
+
/** Token guarding /api/*; printed by the launcher, supplied by the browser via ?token= or Bearer. */
|
|
35
|
+
readonly token: string;
|
|
36
|
+
constructor(deps: WebUIServerDeps);
|
|
37
|
+
listen(port?: number): Promise<number>;
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
private handle;
|
|
40
|
+
private readJson;
|
|
41
|
+
private json;
|
|
42
|
+
private send;
|
|
43
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { events as ev, assetstore, mailbox as mb, ops } from '@evomap/evolver-core';
|
|
4
|
+
import { CONSOLE_HTML } from './console.js';
|
|
5
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
6
|
+
/** The empty value summary (zero entries) — the shape /api/value returns when no provider is wired, so the card
|
|
7
|
+
* always gets a valid ValueSummary to render. Derived from core's aggregator to stay shape-identical. */
|
|
8
|
+
const EMPTY_VALUE_SUMMARY = ops.valueSummary([]);
|
|
9
|
+
/** Constant-time token compare (avoids leaking the token via timing). */
|
|
10
|
+
function tokenEq(a, b) {
|
|
11
|
+
const ba = Buffer.from(a), bb = Buffer.from(b);
|
|
12
|
+
return ba.length === bb.length && timingSafeEqual(ba, bb);
|
|
13
|
+
}
|
|
14
|
+
function positiveIntParam(value) {
|
|
15
|
+
if (value === null || value.trim() === '')
|
|
16
|
+
return undefined;
|
|
17
|
+
const n = Number(value);
|
|
18
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
|
|
19
|
+
}
|
|
20
|
+
const HUMAN_ACTIONS = {
|
|
21
|
+
observe: 'actor.human.observe', nudge: 'actor.human.nudge', intervene: 'actor.human.intervene', teach: 'actor.human.teach',
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* WebUI 控台(M7): 可观测 + 保活. node:http + 自包含 HTML, 仅绑 loopback.
|
|
25
|
+
* 复用 core events/reports 报表 + assetstore + mailbox; 人工操作 Observe/Nudge/Intervene/Teach 走 ingest(actor.kind=human).
|
|
26
|
+
*/
|
|
27
|
+
export class WebUIServer {
|
|
28
|
+
deps;
|
|
29
|
+
server;
|
|
30
|
+
ingestor;
|
|
31
|
+
host;
|
|
32
|
+
now;
|
|
33
|
+
actorId;
|
|
34
|
+
/** Review ledger backing the human-review queue. Undefined when no LocalJsonlProvider store is available. */
|
|
35
|
+
review;
|
|
36
|
+
/** Token guarding /api/*; printed by the launcher, supplied by the browser via ?token= or Bearer. */
|
|
37
|
+
token;
|
|
38
|
+
constructor(deps) {
|
|
39
|
+
this.deps = deps;
|
|
40
|
+
this.host = deps.host ?? '127.0.0.1';
|
|
41
|
+
this.now = deps.now ?? (() => Date.now());
|
|
42
|
+
this.actorId = deps.actorId ?? 'console';
|
|
43
|
+
this.token = deps.token ?? randomBytes(16).toString('hex');
|
|
44
|
+
this.ingestor = deps.ingestor ?? new ev.Ingestor({ path: deps.eventsPath });
|
|
45
|
+
// Co-locate the review ledger with the store so the queue reads the same review.jsonl the CLI writes.
|
|
46
|
+
this.review = deps.review ?? (deps.store instanceof assetstore.LocalJsonlProvider ? new assetstore.ReviewLedger(deps.store.baseDir) : undefined);
|
|
47
|
+
this.server = createServer((req, res) => { void this.handle(req, res); });
|
|
48
|
+
}
|
|
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
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
close() { return new Promise((res, rej) => this.server.close((e) => (e ? rej(e) : res()))); }
|
|
56
|
+
async handle(req, res) {
|
|
57
|
+
try {
|
|
58
|
+
if (!LOOPBACK.has(req.socket.remoteAddress ?? ''))
|
|
59
|
+
return this.send(res, 403, 'text/plain', 'non-loopback');
|
|
60
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
61
|
+
const p = url.pathname;
|
|
62
|
+
// Root HTML is a static shell (no data) → served freely; it reads ?token= and authenticates the /api calls.
|
|
63
|
+
if (p === '/' || p === '/index.html')
|
|
64
|
+
return this.send(res, 200, 'text/html; charset=utf-8', CONSOLE_HTML);
|
|
65
|
+
// Everything else (all /api/*) requires the token — accepted via Bearer header or ?token= (browser convenience).
|
|
66
|
+
const auth = req.headers['authorization'] ?? '';
|
|
67
|
+
const supplied = auth.startsWith('Bearer ') ? auth.slice(7) : (url.searchParams.get('token') ?? '');
|
|
68
|
+
if (!tokenEq(supplied, this.token))
|
|
69
|
+
return this.send(res, 401, 'application/json', JSON.stringify({ error: 'unauthorized' }));
|
|
70
|
+
if (p === '/api/status')
|
|
71
|
+
return this.json(res, ev.statusReport(ev.readEvents(this.deps.eventsPath)));
|
|
72
|
+
if (p === '/api/cycles')
|
|
73
|
+
return this.json(res, ev.listCycles(ev.readEvents(this.deps.eventsPath)));
|
|
74
|
+
if (p === '/api/cycle')
|
|
75
|
+
return this.json(res, ev.showCycle(ev.readEvents(this.deps.eventsPath), url.searchParams.get('id') ?? ''));
|
|
76
|
+
if (p === '/api/narrative') {
|
|
77
|
+
return this.json(res, ev.buildNarrativeSnapshot(ev.readEvents(this.deps.eventsPath), { limit: positiveIntParam(url.searchParams.get('limit')) }));
|
|
78
|
+
}
|
|
79
|
+
if (p === '/api/triggers')
|
|
80
|
+
return this.json(res, ev.listTriggers(ev.readEvents(this.deps.eventsPath)));
|
|
81
|
+
if (p === '/api/daily-summary') {
|
|
82
|
+
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));
|
|
84
|
+
}
|
|
85
|
+
if (p === '/api/value') {
|
|
86
|
+
// Thin pass-through: the provider (composition layer) owns prices + traces; the server only scopes the
|
|
87
|
+
// window and serializes. No provider wired → an empty summary so the card renders "no savings yet".
|
|
88
|
+
const window = ops.windowFromSpec(url.searchParams.get('window') ?? undefined, this.now());
|
|
89
|
+
const summary = this.deps.valueSummary ? this.deps.valueSummary(window) : EMPTY_VALUE_SUMMARY;
|
|
90
|
+
return this.json(res, summary);
|
|
91
|
+
}
|
|
92
|
+
if (p === '/api/mailbox') {
|
|
93
|
+
if (!this.deps.mailbox)
|
|
94
|
+
return this.json(res, { pending: 0, dlq: 0, messages: [] });
|
|
95
|
+
return this.json(res, {
|
|
96
|
+
pending: this.deps.mailbox.countByStatus('pending'),
|
|
97
|
+
dlq: this.deps.mailbox.dlq().length,
|
|
98
|
+
messages: this.deps.mailbox.list({ limit: 50 }).map((m) => ({ id: m.id, type: m.type, status: m.status, handler: m.handler })),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (p === '/api/assets') {
|
|
102
|
+
if (!this.deps.store)
|
|
103
|
+
return this.json(res, []);
|
|
104
|
+
const kind = url.searchParams.get('kind');
|
|
105
|
+
return this.json(res, (await this.deps.store.list(kind ?? undefined, 100)).map((a) => ({ asset_id: a.asset_id, type: a.type, summary: a.summary })));
|
|
106
|
+
}
|
|
107
|
+
if (p === '/api/review' && req.method === 'POST') {
|
|
108
|
+
// Approve/reject a quarantined draft from the console: the SAME gate the CLI uses — flip the ReviewLedger
|
|
109
|
+
// state + record an audited actor.human.review.* event. Loopback + bearer already guard the route; actor is
|
|
110
|
+
// the console operator. Resolve the target by asset_id directly (no list, never truncated), or by logical id.
|
|
111
|
+
if (!this.deps.store || !this.review)
|
|
112
|
+
return this.send(res, 400, 'application/json', JSON.stringify({ error: 'no review store wired' }));
|
|
113
|
+
const b = (await this.readJson(req));
|
|
114
|
+
if (b.action !== 'approve' && b.action !== 'reject')
|
|
115
|
+
return this.send(res, 400, 'application/json', JSON.stringify({ error: 'action must be approve or reject' }));
|
|
116
|
+
const target = String(b.gene ?? '');
|
|
117
|
+
const g = (await this.deps.store.get(target)) ?? (await this.deps.store.list('Gene', 1000)).find((x) => String(x['id']) === target) ?? null;
|
|
118
|
+
// Only a Gene is reviewable: store.get resolves ANY asset by id, so guard the kind (a Capsule's asset_id
|
|
119
|
+
// must not be approvable, which would emit a review event with a misleading geneId — matches CLI runReview).
|
|
120
|
+
if (!g || g.type !== 'Gene')
|
|
121
|
+
return this.send(res, 404, 'application/json', JSON.stringify({ error: 'gene not found' }));
|
|
122
|
+
const assetId = String(g.asset_id);
|
|
123
|
+
const geneId = typeof g['id'] === 'string' ? String(g['id']) : assetId;
|
|
124
|
+
const reason = (b.reason ?? '').trim() || `${b.action === 'approve' ? 'approved' : 'rejected'} via console`;
|
|
125
|
+
if (b.action === 'approve')
|
|
126
|
+
this.review.approve(assetId, this.actorId, reason);
|
|
127
|
+
else
|
|
128
|
+
this.review.reject(assetId, this.actorId, reason);
|
|
129
|
+
const root = await this.ingestor.ingest({
|
|
130
|
+
type: b.action === 'approve' ? 'actor.human.review.approve' : 'actor.human.review.reject',
|
|
131
|
+
actor: { kind: 'human', id: this.actorId },
|
|
132
|
+
human: { title: `${b.action} gene ${geneId}`, severity: 'info' },
|
|
133
|
+
payload: { geneId, assetId, reason },
|
|
134
|
+
});
|
|
135
|
+
return this.json(res, { ok: true, geneId, assetId, state: b.action === 'approve' ? 'approved' : 'rejected', seq: root.seq });
|
|
136
|
+
}
|
|
137
|
+
if (p === '/api/review') {
|
|
138
|
+
if (!this.deps.store)
|
|
139
|
+
return this.json(res, []);
|
|
140
|
+
// The human-review queue. The set of REVIEWED (quarantined/approved/rejected) assets comes from the review
|
|
141
|
+
// LEDGER — the authoritative, complete source — so a pending draft is never dropped behind a store.list
|
|
142
|
+
// cutoff (Bugbot). Gene metadata is resolved per asset_id from the store. Default-eligible genes (no ledger
|
|
143
|
+
// record) are appended as context from a bounded list. Pending (quarantined) sorts first. Read-only here.
|
|
144
|
+
// Match the CLI's semantics exactly: only the unattended distill-observer counts as "auto-drafted"
|
|
145
|
+
// (manual `ingest --distill` / skill2gep emit gene.distilled with a different source and are NOT auto).
|
|
146
|
+
const autoDrafted = new Set(this.ingestor.readAll()
|
|
147
|
+
.filter((e) => e.type === 'gene.distilled' && e.payload?.['source'] === 'distill-observer')
|
|
148
|
+
.map((e) => String(e.payload?.['assetId'] ?? ''))
|
|
149
|
+
.filter(Boolean));
|
|
150
|
+
const order = { quarantined: 0, rejected: 1, approved: 2, eligible: 3 };
|
|
151
|
+
const toRow = (assetId, state, g) => ({
|
|
152
|
+
geneId: g && typeof g['id'] === 'string' ? String(g['id']) : assetId,
|
|
153
|
+
assetId, state,
|
|
154
|
+
category: g ? String(g['category'] ?? '') : '',
|
|
155
|
+
summary: g ? String(g.summary ?? '') : '',
|
|
156
|
+
autoDrafted: autoDrafted.has(assetId),
|
|
157
|
+
});
|
|
158
|
+
const records = this.review ? this.review.records() : [];
|
|
159
|
+
const decided = new Set(records.map((r) => r.assetId));
|
|
160
|
+
const reviewed = await Promise.all(records.map(async (r) => toRow(r.assetId, r.state, await this.deps.store.get(r.assetId))));
|
|
161
|
+
const eligible = (await this.deps.store.list('Gene', 1000))
|
|
162
|
+
.filter((g) => !decided.has(String(g.asset_id)))
|
|
163
|
+
.map((g) => toRow(String(g.asset_id), 'eligible', g));
|
|
164
|
+
const rows = [...reviewed, ...eligible].sort((a, b) => (order[a.state] ?? 9) - (order[b.state] ?? 9));
|
|
165
|
+
return this.json(res, rows);
|
|
166
|
+
}
|
|
167
|
+
if (p === '/api/action' && req.method === 'POST') {
|
|
168
|
+
const b = (await this.readJson(req));
|
|
169
|
+
const type = HUMAN_ACTIONS[b.action ?? ''];
|
|
170
|
+
if (!type)
|
|
171
|
+
return this.send(res, 400, 'application/json', JSON.stringify({ error: 'unknown action' }));
|
|
172
|
+
const root = await this.ingestor.ingest({
|
|
173
|
+
type, actor: { kind: 'human', id: this.actorId },
|
|
174
|
+
human: { title: b.title ?? b.action ?? 'human action' },
|
|
175
|
+
payload: { note: b.note ?? '', ...(b.cycleId ? { cycleId: b.cycleId } : {}) },
|
|
176
|
+
});
|
|
177
|
+
return this.json(res, { ok: true, seq: root.seq });
|
|
178
|
+
}
|
|
179
|
+
return this.send(res, 404, 'application/json', JSON.stringify({ error: 'not found' }));
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
return this.send(res, 500, 'application/json', JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
readJson(req) {
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
const chunks = [];
|
|
188
|
+
let size = 0;
|
|
189
|
+
req.on('data', (c) => { size += c.length; if (size > 256 * 1024)
|
|
190
|
+
reject(new Error('body too large'));
|
|
191
|
+
else
|
|
192
|
+
chunks.push(c); });
|
|
193
|
+
req.on('end', () => { try {
|
|
194
|
+
resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {});
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
reject(new Error('invalid json'));
|
|
198
|
+
} });
|
|
199
|
+
req.on('error', reject);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
json(res, body) { this.send(res, 200, 'application/json', JSON.stringify(body)); }
|
|
203
|
+
send(res, code, ct, body) { res.writeHead(code, { 'content-type': ct }); res.end(body); }
|
|
204
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@evomap/evolver-webui",
|
|
3
|
+
"version": "2.0.0-beta.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "保活/可视化 WebUI",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@evomap/evolver-core": "2.0.0-beta.0"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public",
|
|
20
|
+
"tag": "v2-beta"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist/",
|
|
24
|
+
"README.md",
|
|
25
|
+
"package.json"
|
|
26
|
+
]
|
|
27
|
+
}
|