Files
agent-loop/demo/tools/viewer-template.html
2026-11-12 15:29:12 +08:00

431 lines
34 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>真实回放:云端 Agent Loop 改一次 bug</title>
<style>
:root{--bg:#f5f6f8;--card:#fff;--ink:#1f2328;--mut:#656d76;--line:#d8dee4;
--user:#b7791f;--gw:#c2410c;--worker:#2563eb;--sess:#7c3aed;--lap:#15803d;--job:#0f766e;--bad:#dc2626}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif}
main{max-width:1320px;margin:0 auto;padding:22px 22px 60px}
h1{font-size:24px;margin:0}
h2{font-size:18px;margin:30px 0 10px}
.mut{color:var(--mut)}
code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px}
code{background:#eef1f4;padding:1px 4px;border-radius:4px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
.top{display:flex;justify-content:space-between;align-items:flex-end;gap:16px;flex-wrap:wrap}
select,button{font:inherit;font-size:14px;padding:5px 12px;border-radius:7px;border:1px solid var(--line);background:#fff;cursor:pointer}
button.on{background:var(--ink);color:#fff;border-color:var(--ink)}
.setup{margin-top:10px;font-size:14px}
.setup b{font-weight:600}
/* 角色 */
.roles{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
.role{border-top:4px solid}
.role h3{margin:0;font-size:15px}
.role .where{font-size:12.5px;color:var(--mut);margin-bottom:6px}
.role ul{margin:0;padding-left:17px;font-size:13.5px}
.role .no{color:var(--mut)}
.role .pid{font-family:ui-monospace,monospace;font-size:12px;color:var(--mut)}
/* 时间线 */
.scenes{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}
.scenes button{font-size:13px;padding:4px 10px}
.tl{position:relative;user-select:none}
.lane{display:grid;grid-template-columns:190px 1fr;border-bottom:1px dashed #e5e8eb;min-height:38px}
.lane .lbl{font-size:13px;padding:8px 8px 0 0;font-weight:600}
.lane .lbl small{display:block;font-weight:400;color:var(--mut);font-size:11.5px;line-height:1.3}
.track{position:relative;height:38px;cursor:crosshair}
.axis .track{height:22px}
.tick{position:absolute;top:2px;font-size:11px;color:var(--mut);transform:translateX(-50%)}
.bar{position:absolute;top:9px;height:20px;border-radius:5px;font-size:11.5px;color:#fff;padding:1px 6px;white-space:nowrap;overflow:hidden;cursor:pointer;min-width:3px}
.bar.aborted{background:repeating-linear-gradient(45deg,var(--bad),var(--bad) 5px,#f87171 5px,#f87171 10px)!important}
.pt{position:absolute;top:12px;width:12px;height:12px;border-radius:50%;transform:translateX(-50%);cursor:pointer;border:2px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.25)}
.pt.big{width:16px;height:16px;top:10px}
.pt.x{border-radius:2px;transform:translateX(-50%) rotate(45deg)}
.sel{outline:3px solid #facc15;outline-offset:1px;z-index:5}
.future{opacity:.22}
.playhead{position:absolute;top:0;bottom:0;width:2px;background:#111;pointer-events:none;z-index:6}
.scenemark{position:absolute;top:0;bottom:0;border-left:1px dotted #9aa4ae;pointer-events:none}
.scenemark span{position:absolute;top:-1px;left:3px;font-size:11px;color:#57606a;background:var(--card);padding:0 3px;white-space:nowrap}
.ctrl{display:flex;gap:8px;align-items:center;margin-top:10px;flex-wrap:wrap}
.ctrl input[type=range]{flex:1;min-width:240px}
.clock{font-family:ui-monospace,monospace;min-width:70px}
.narr{margin-top:12px;background:#fffdf3;border:1px solid #f0e2a8}
.narr h3{margin:0 0 4px;font-size:16px}
.narr p{margin:4px 0}
/* 下半区 */
.grid2{display:grid;grid-template-columns:1.05fr 1fr;gap:12px;margin-top:12px}
.snap{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
.snap .col{border-radius:8px;padding:10px;font-size:13px;min-height:200px}
.snap h4{margin:0 0 6px;font-size:13.5px}
.c-w{background:#eaf1fe}.c-s{background:#f3edfd}.c-l{background:#e7f5ec}
.chip{display:inline-block;background:#fff;border:1px solid rgba(0,0,0,.08);border-radius:6px;padding:2px 7px;margin:0 4px 4px 0;font-size:12.5px}
.empty{color:var(--mut);font-style:italic}
pre.box{background:#0d1117;color:#e6edf3;border-radius:8px;padding:10px;overflow:auto;max-height:420px;white-space:pre-wrap;word-break:break-all;margin:6px 0}
pre.mini{max-height:160px}
.diff .a{color:#7ee787}.diff .d{color:#ff7b72}.diff .h{color:#79c0ff}
.insp h3{margin:0;font-size:16px}
.insp .who{font-size:12.5px;color:var(--mut);margin:2px 0 8px}
.insp .why{background:#f6f8fa;border-radius:6px;padding:8px 10px;font-size:14px;margin-bottom:8px}
.msg{border-left:3px solid #d0d7de;padding:2px 8px;margin:4px 0;font-size:13px}
.msg .r{font-weight:600;font-size:12px;color:var(--mut)}
.blk{background:#f6f8fa;border-radius:4px;padding:3px 6px;margin:3px 0;white-space:pre-wrap;word-break:break-all;font-family:ui-monospace,monospace;font-size:12px;max-height:150px;overflow:auto}
.blk.tu{background:#fff4e5}.blk.tr{background:#e7f5ec}
details summary{cursor:pointer;color:var(--mut);font-size:13px}
/* 证据 */
.ev{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}
.ev .card h3{margin:0 0 6px;font-size:16px}
.ev .num{font-size:22px;font-weight:700}
.ev .card p{margin:5px 0;font-size:14px}
.ev .cmp{font-size:13px;color:var(--mut);border-top:1px dashed var(--line);padding-top:6px;margin-top:8px}
.ok{color:var(--lap);font-weight:600}.bad{color:var(--bad);font-weight:600}
.caveat{font-size:13.5px}
.caveat li{margin:3px 0}
@media(max-width:1000px){.roles,.grid2,.ev,.snap{grid-template-columns:1fr}}
</style>
</head>
<body>
<main>
<div class="top">
<div>
<h1>真实回放:一次改 bug,计算 / 状态 / 执行环境各在哪儿、各做了什么</h1>
<div class="mut">下面每一个点都是一条真实记录:真实的进程、真实的私有网关请求、真实的 git 仓库和测试。</div>
</div>
<div>运行:<select id="runSel"></select></div>
</div>
<div class="card setup" id="setup"></div>
<h2>四个角色:谁在哪儿、手里有什么、没有什么</h2>
<div class="roles" id="roles"></div>
<h2>时间线(点任意色块看原始数据;拖动或播放看每一刻的状态)</h2>
<div class="card">
<div class="scenes" id="scenes"></div>
<div class="tl" id="tl"></div>
<div class="ctrl">
<button id="play">▶ 播放</button>
<select id="speed"><option value="2">2×</option><option value="5" selected>5×</option><option value="12">12×</option></select>
<input type="range" id="scrub" min="0" max="100" step="0.01" value="0">
<span class="clock" id="clock">0.00 s</span>
</div>
<div class="card narr" id="narr"></div>
</div>
<div class="grid2">
<div class="card">
<h3 style="margin:0 0 8px;font-size:16px">此刻快照 <span class="mut" id="snapT" style="font-weight:400"></span></h3>
<div class="snap">
<div class="col c-w"><h4>☁️ 云端 · 活着的 Worker 进程</h4><div id="snapW"></div></div>
<div class="col c-s"><h4>☁️ 云端 · Session(已持久化)</h4><div id="snapS"></div></div>
<div class="col c-l"><h4>💻 笔记本 · 仓库与进程</h4><div id="snapL"></div></div>
</div>
</div>
<div class="card insp" id="insp"><span class="mut">点时间线上的任意色块,这里显示它的原始记录和解释。</span></div>
</div>
<h2>这次运行证明了什么(数字全部来自上面的 trace)</h2>
<div class="ev" id="ev"></div>
<h2>最终留在笔记本上的改动(未提交)</h2>
<div class="card"><pre class="box diff" id="diff"></pre></div>
<h2>需要说清楚的边界</h2>
<div class="card caveat">
<ul>
<li>“云端”在这里是本机上的独立进程和独立目录(<code>cloud-data/</code>),用来演示进程与数据的边界;笔记本仓库在 <code>laptop-home/</code>,云端进程的工作目录是空的 <code>worker-scratch/</code>。没有真的部署到云服务器。</li>
<li>模型调用是真的:走 <code>~/.pi/agent/models.json</code> 里的私有网关(Anthropic Messages 协议)。当前令牌只放行 DeepSeek-V4-Flash,其它模型返回 403。Authorization 头没有写进 trace。</li>
<li>“环境不上云”不等于“代码不出本机”:模型读过的文件、命令输出都会作为工具结果进入 Session 和网关请求(见上面“上云了什么”)。没被工具读到的东西(例如 .env 里的密码)才不会出去。</li>
<li>Worker 被杀时正在等的那次模型回应丢了,新 Worker 会重发同一请求,所以模型调用是“至少一次”。工具调用这边,守护进程按 call_id 去重;但如果守护进程本身在执行中途重启,非幂等操作(如 git push、发 PR)仍需要额外的幂等设计——这正是组会可以讨论的点。</li>
<li>这是对文章 §5.1/§5.2 架构思路的最小复现,不代表 DeepSeek Harness 或其公开原型的实现。</li>
</ul>
</div>
</main>
<script>
const RUNS = /*__DATA__*/null;
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const fmt = (s) => s.toFixed(2) + ' s';
const short = (s, n = 160) => { s = String(s ?? ''); return s.length > n ? s.slice(0, n) + '…' : s; };
const COLORS = { user: 'var(--user)', gw: 'var(--gw)', worker: 'var(--worker)', session: 'var(--sess)', laptop: 'var(--lap)', jobs: 'var(--job)' };
const TYPE_COLOR = { user_message: '#b7791f', model_response: '#c2410c', tool_result: '#15803d', job_finished: '#0f766e', reply_to_user: '#b7791f', waiting_device: '#dc2626' };
const TYPE_ZH = { user_message: '用户消息', model_response: '模型回应', tool_result: '工具结果', job_finished: '后台任务完成', reply_to_user: '回复用户', waiting_device: '等待设备' };
let R, items = [], view = [0, 1], T = 0, selected = null, playing = null;
function diffHTML(d) {
if (!d) return '<span class="empty">(工作区干净)</span>';
return d.split('\n').map((l) => {
const c = l.startsWith('+') && !l.startsWith('+++') ? 'a' : l.startsWith('-') && !l.startsWith('---') ? 'd' : l.startsWith('@@') ? 'h' : '';
return `<span class="${c}">${esc(l)}</span>`;
}).join('\n');
}
// ---------- 从 trace 构建时间线元素 ----------
function buildItems() {
const recs = R.recs, ev = R.evidence;
const out = [];
const sessAt = (seq) => R.session.find((e) => e.seq === seq);
for (const r of recs) {
if (r.kind === 'note' && r.text.startsWith('用户:')) out.push({ lane: 'user', s: r.s, kind: 'pt', big: true, color: 'var(--user)', title: '你发消息', rec: r });
if (r.kind === 'session_append') {
const e = sessAt(r.seq);
out.push({ lane: 'session', s: r.s, kind: 'pt', color: TYPE_COLOR[r.type] || '#7c3aed', x: r.type === 'waiting_device', title: `#${r.seq} ${TYPE_ZH[r.type] || r.type}`, rec: r, sess: e });
if (r.type === 'reply_to_user') out.push({ lane: 'user', s: r.s, kind: 'pt', color: '#fff', ring: true, title: 'Agent 回复你', rec: r, sess: e });
}
if (r.kind === 'model_request') {
const resp = recs.find((x) => x.kind === 'model_response' && x.pid === r.pid && x.s >= r.s);
const killed = !resp && recs.find((x) => x.kind === 'inject_kill' && x.worker_pid === r.pid);
out.push({ lane: 'gw', s: r.s, e: resp ? resp.s : killed ? killed.s : r.s + 0.3, kind: 'bar', color: 'var(--gw)', aborted: !resp, label: resp ? `${resp.ms}ms` : '结果丢失', title: resp ? `模型调用(Worker ${r.pid})` : `模型调用被中断(Worker ${r.pid} 被杀)`, rec: r, resp });
}
if (r.kind === 'tool_exec') {
out.push({ lane: 'laptop', s: r.s, kind: 'pt', big: r.tool === 'edit_file' || r.tool === 'write_file', color: r.tool.includes('edit') || r.tool.includes('write') ? '#b91c1c' : 'var(--lap)', title: `执行 ${r.tool}`, rec: r });
if (r.tool === 'run_command') out.push({ lane: 'jobs', s: r.s - r.ms / 1000, e: r.s, kind: 'bar', color: '#64748b', label: short(r.input.command, 24), title: `同步命令:${r.input.command}`, rec: r });
}
if (r.kind === 'inject_kill') out.push({ lane: 'worker', s: r.s, kind: 'pt', big: true, x: true, color: 'var(--bad)', title: `kill -9 Worker ${r.worker_pid}`, rec: r });
if (r.kind === 'dispatch_offline') out.push({ lane: 'laptop', s: r.s, kind: 'pt', x: true, big: true, color: 'var(--bad)', title: '派发失败:设备离线', rec: r });
}
for (const w of ev.workers) out.push({ lane: 'worker', s: w.start, e: w.end, kind: 'bar', color: w.signal ? 'var(--bad)' : 'var(--worker)', label: `pid ${w.pid}`, title: `Worker 进程 pid ${w.pid}${w.signal ? '(被 ' + w.signal + ')' : ''}`, worker: w });
const starts = recs.filter((r) => r.kind === 'daemon_start');
for (const d of starts) {
const stop = recs.find((r) => r.kind === 'daemon_stop' && r.pid === d.pid);
out.push({ lane: 'laptop', s: d.s, e: stop ? stop.s : R.end, kind: 'bar', color: 'rgba(21,128,61,.18)', fg: '#14532d', label: `守护进程 pid ${d.pid}`, title: `守护进程 pid ${d.pid}(在线)`, rec: d, under: true });
}
for (const j of ev.jobs) out.push({ lane: 'jobs', s: j.start, e: j.end, kind: 'bar', color: 'var(--job)', label: `${j.id} npm test · pid ${j.pid}`, title: `后台任务 ${j.id}:npm test`, job: j, rec: recs.find((r) => r.kind === 'job_start' && r.job_id === j.id) });
out.forEach((it, i) => (it.id = i));
return out;
}
const LANES = [
['user', '你', 'IM / 网页'],
['gw', '私有网关', '模型推理(dd-ai-api)'],
['worker', '云端 · Loop Worker', '无状态进程,用完即退'],
['session', '云端 · Session', '只追加的事件日志'],
['laptop', '笔记本 · 守护进程', '在仓库里执行工具'],
['jobs', '笔记本 · 子进程', '测试 / 命令'],
];
const pos = (s) => ((s - view[0]) / (view[1] - view[0])) * 100;
function renderTL() {
const tl = $('tl');
const span = view[1] - view[0];
const step = span > 40 ? 5 : span > 15 ? 2 : span > 6 ? 1 : 0.5;
let html = '<div class="lane axis"><div class="lbl"></div><div class="track">';
for (let t = Math.ceil(view[0] / step) * step; t <= view[1]; t += step) html += `<span class="tick" style="left:${pos(t)}%">${+t.toFixed(1)}s</span>`;
html += '</div></div>';
for (const [id, name, sub] of LANES) {
html += `<div class="lane"><div class="lbl" style="color:${COLORS[id]}">${name}<small>${sub}</small></div><div class="track" data-lane="${id}">`;
for (const it of items.filter((x) => x.lane === id).sort((a, b) => (b.under ? 1 : 0) - (a.under ? 1 : 0))) {
const end = it.e ?? it.s;
if (end < view[0] || it.s > view[1]) continue;
const fut = it.s > T ? ' future' : '';
const sel = selected === it.id ? ' sel' : '';
if (it.kind === 'bar') {
const l = Math.max(0, pos(it.s)), r = Math.min(100, pos(end));
html += `<div class="bar${it.aborted ? ' aborted' : ''}${fut}${sel}" data-id="${it.id}" title="${esc(it.title)}" style="left:${l}%;width:${Math.max(r - l, 0.25)}%;background:${it.color};color:${it.fg || '#fff'};${it.under ? 'z-index:0;' : 'z-index:2;'}">${esc(it.label || '')}</div>`;
} else {
html += `<div class="pt${it.big ? ' big' : ''}${it.x ? ' x' : ''}${fut}${sel}" data-id="${it.id}" title="${esc(it.title)} @ ${fmt(it.s)}" style="left:${pos(it.s)}%;background:${it.color};${it.ring ? 'box-shadow:0 0 0 2px var(--user);' : ''}z-index:3"></div>`;
}
}
html += '</div></div>';
}
html += `<div class="playhead" style="left:calc(190px + (100% - 190px) * ${Math.min(Math.max(pos(T), 0), 100) / 100})"></div>`;
for (const sc of R.recs.filter((r) => r.kind === 'scene' && r.id !== 'END')) {
if (sc.s < view[0] || sc.s > view[1]) continue;
html += `<div class="scenemark" style="left:calc(190px + (100% - 190px) * ${pos(sc.s) / 100})"><span>${sc.id}</span></div>`;
}
tl.innerHTML = html;
}
$('tl').addEventListener('click', (e) => {
const el = e.target.closest('[data-id]');
if (el) { select(+el.dataset.id); return; }
const tr = e.target.closest('.track');
if (tr) { const b = tr.getBoundingClientRect(); setT(view[0] + ((e.clientX - b.left) / b.width) * (view[1] - view[0])); }
});
// ---------- 场景 ----------
function scenes() {
const sc = R.recs.filter((r) => r.kind === 'scene');
return sc.filter((s) => s.id !== 'END' && s.id !== 'D2').map((s, i, arr) => {
const next = sc.find((x) => x.s > s.s && x.id !== 'D2');
return { ...s, start: s.s, stop: next ? next.s : R.end };
});
}
function narrative(id) {
const ev = R.evidence, w = ev.workers, k = ev.kill, o = ev.offline, j = ev.jobs;
const first = w[0];
const N = {
ALL: ['全程', `一共 ${fmt(ev.wall)}。云端先后起了 <b>${ev.workerCount} 个</b> Worker 进程,加起来只活了 <b>${fmt(ev.workerAlive)}</b>;${ev.gateway.count} 次网关调用;${ev.toolExecs} 次工具执行全部发生在笔记本上。点上面的场景按钮放大看每一段。`],
A: ['A. 提需求:云端起 Worker,工具穿到笔记本执行', `你的消息先写进 Session(紫色点)。调度器发现“有新输入、没有 Worker 在跑”,起了 <b>Worker pid ${first?.pid}</b>,它的工作目录是空的 <code>worker-scratch/</code>——<b>云端手上没有代码</b>。Worker 把日志翻译成 messages 发给私有网关(橙色条),模型回 <code>tool_use: read_file</code>;这个调用被派给你的笔记本,<b>守护进程在仓库里读文件</b>(绿点),内容回到云端写进 Session,再进入下一次模型请求。红色大点是 <code>edit_file</code>:改动落在笔记本的仓库里。`],
B: ['B. kill -9 云端 Worker:进程丢了,什么都没丢', k ? `${fmt(k.at)} 时脚本对 <b>Worker pid ${k.pid}</b> 发 SIGKILL。它当时${k.lostRequest ? '<b>正在等网关回应,这次请求的结果丢了</b>(橙色斜纹)' : '没有在途请求'}。${fmt(k.nextAt - k.at)} 后调度器从日志判断“还有没处理的工具结果”,起了 <b>Worker pid ${k.nextPid}</b>。新 Worker 重建出的请求体与被杀的那个${k.identical ? '<b>逐字节相同</b>' : '不同'}(${k.bytes} 字节)——因为它知道的一切都来自 Session。笔记本上的改动、守护进程都不受影响。` : '本次运行没有触发崩溃注入。'],
C: ['C. 回归在笔记本后台跑,云端不陪着等', j[0] ? `<code>npm test</code> 在笔记本上作为子进程 pid ${j[0].pid} 跑了 ${fmt(j[0].end - j[0].start)}。启动后 Worker 立刻结束本轮并<b>退出</b>——测试期间云端没有为它驻留进程。你追问 diff,调度器起了新 Worker,<code>git diff</code> 在笔记本上执行,${ev.answeredDuringJob ? '<b>回答时测试还没跑完</b>' : '回答在测试结束之后'}。测试结束,守护进程把 <code>job_finished</code> 写回 Session,又唤醒一个 Worker 汇报结果。` : ''],
D: ['D. 合上笔记本:任务挂在日志里,云端零进程', o ? `守护进程退出(模拟合盖)。新需求到达,Worker 调模型拿到 <code>${o.parkedTool}</code>,派发时发现设备离线:写下 <code>waiting_device</code>,<b>Worker 直接退出,不空等</b>。离线 ${fmt(o.to - o.from)} 期间,Worker 进程数 = <b>${o.workersDuring}</b>,模型调用 = 0。没执行的工具调用就是日志里一个“还没有结果的 tool_use”。` : ''],
E: ['E. 打开笔记本:自动续上', o ? `守护进程重新连上(新 pid)。调度器读日志发现那个悬着的 <code>${o.parkedTool}</code>,起 Worker 后<b>不重新问模型</b>,直接派发给笔记本执行,从上线到工具执行用了 <b>${o.resumeMs} ms</b>。之后补测试、后台回归、汇报。` : ''],
};
return N[id] || N.ALL;
}
function renderScenes() {
const sc = scenes();
$('scenes').innerHTML = `<button data-sc="ALL" class="on">全程</button>` + sc.map((s) => `<button data-sc="${s.id}">${esc(s.id)} · ${esc(s.title)}</button>`).join('');
$('scenes').onclick = (e) => {
const b = e.target.closest('button'); if (!b) return;
[...$('scenes').children].forEach((x) => x.classList.toggle('on', x === b));
const id = b.dataset.sc;
if (id === 'ALL') { view = [0, R.end + 0.5]; setNarr('ALL'); setT(T); return; }
const s = sc.find((x) => x.id === id);
const pad = (s.stop - s.start) * 0.04 + 0.2;
view = [Math.max(0, s.start - pad), s.stop + pad];
setNarr(id); setT(s.start + 0.01);
};
}
function setNarr(id) { const [h, p] = narrative(id); $('narr').innerHTML = `<h3>${h}</h3><p>${p}</p>`; }
// ---------- 快照 ----------
function renderSnap() {
$('snapT').textContent = `t = ${fmt(T)}`;
const ws = R.evidence.workers.filter((w) => w.start <= T && w.end > T);
$('snapW').innerHTML = ws.length
? ws.map((w) => `<div class="chip">Worker pid ${w.pid}</div>`).join('') + `<p class="mut" style="font-size:12px">内存里只有从日志重建的 messages。工作目录:空。持有:网关密钥。</p>`
: `<div class="empty">0 个进程。没有任何东西在等。</div>`;
const evs = R.session.filter((e) => e.s <= T);
$('snapS').innerHTML = `<div><b>${evs.length}</b> 条事件</div>` + (evs.slice(-6).reverse().map((e) => `<div class="chip" style="display:block">#${e.seq} ${TYPE_ZH[e.type] || e.type}<br><span class="mut">${esc(short(e.type === 'model_response' ? e.content.map((b) => b.type === 'tool_use' ? '→ ' + b.name : b.type === 'text' ? b.text : '').filter(Boolean).join(' ') : e.text || e.output || e.tail || e.tool || '', 70))}</span></div>`).join('') || '<div class="empty">空</div>');
const daemons = items.filter((i) => i.lane === 'laptop' && i.under);
const on = daemons.find((d) => d.s <= T && d.e > T);
const snap = [...R.recs].reverse().find((r) => r.s <= T && (r.kind === 'tool_exec' || r.kind === 'daemon_start'));
const repo = snap ? (snap.repo_after || snap.repo_state) : null;
const jobs = R.evidence.jobs.filter((j) => j.start <= T && j.end > T);
$('snapL').innerHTML = `<div>${on ? `<span class="ok">● 在线</span> 守护进程 pid ${on.rec.pid}` : '<span class="bad">● 离线(合盖)</span>'}</div>` +
`<div style="margin-top:6px"><b>git status</b></div><pre class="box mini">${esc(repo?.status || '(干净)')}</pre>` +
(jobs.length ? jobs.map((j) => `<div class="chip">运行中:${j.id} npm test(pid ${j.pid})</div>`).join('') : '<div class="mut" style="font-size:12px">没有子进程在跑</div>') +
`<div class="mut" style="font-size:12px;margin-top:4px">一直在这里:仓库、.env(含内网密码)、node 工具链</div>`;
}
// ---------- 详情 ----------
function msgsHTML(messages) {
return messages.map((m) => `<div class="msg"><div class="r">${m.role}</div>${m.content.map((b) => {
if (b.type === 'text') return `<div class="blk">${esc(short(b.text, 400))}</div>`;
if (b.type === 'thinking') return `<div class="blk" style="opacity:.7">💭 ${esc(short(b.thinking, 200))}</div>`;
if (b.type === 'tool_use') return `<div class="blk tu">→ ${esc(b.name)} ${esc(short(JSON.stringify(b.input), 300))}</div>`;
if (b.type === 'tool_result') return `<div class="blk tr">← 工具结果(${b.tool_use_id.slice(-6)})\n${esc(short(b.content, 500))}</div>`;
return `<div class="blk">${esc(b.type)}</div>`;
}).join('')}</div>`).join('');
}
function select(id) {
selected = id;
const it = items[id];
const r = it.rec || {};
let who = '', why = '', body = '';
if (it.lane === 'gw') {
who = `发起方:云端 Worker pid ${r.pid} → ${esc(r.url)}`;
why = it.resp ? `Worker 从 Session 的 ${R.session.filter((e) => e.s <= r.s).length} 条事件重建出 ${r.message_count} 条 messages(${r.request_bytes} 字节)发给私有网关,${it.resp.ms} ms 后拿到回应:<b>${esc(it.resp.stop_reason)}</b>。注意请求里包含之前工具读到的代码——这就是“上云的部分”。` : `这次请求发出后,Worker 被 kill -9,回应没人接收。下一个 Worker 会从日志重建并重发同一请求。`;
body = `<b>请求 messages</b>${msgsHTML(r.request.messages)}` + (it.resp ? `<b>模型回应</b>${msgsHTML([{ role: 'assistant', content: it.resp.content }])}<div class="mut" style="font-size:12px">usage: ${esc(JSON.stringify(it.resp.usage))}</div>` : '') +
`<details><summary>原始请求体 JSON(不含 Authorization 头)</summary><pre class="box">${esc(JSON.stringify(r.request, null, 1))}</pre></details>`;
} else if (it.lane === 'laptop' && r.kind === 'tool_exec') {
who = `执行方:笔记本守护进程 pid ${r.pid},目录 <code>${esc(r.cwd)}</code>`;
why = `云端只发来了 <code>${esc(r.tool)}</code> 和参数,真正读写文件、跑命令的是你电脑上的这个进程,耗时 ${r.ms} ms。${r.replay ? '(重复派发,直接返回缓存结果,没有重复执行)' : ''}执行后仓库状态如下。`;
body = `<b>参数</b><pre class="box mini">${esc(JSON.stringify(r.input, null, 1))}</pre><b>返回给云端的结果</b><pre class="box mini">${esc(r.output)}</pre><b>执行后 git diff</b><pre class="box diff">${diffHTML(r.repo_after?.diff)}</pre>`;
} else if (it.worker) {
const w = it.worker;
who = `云端 Worker pid ${w.pid},${fmt(w.start)} → ${fmt(w.end)},活了 ${fmt(w.end - w.start)}`;
why = `拉起原因:<code>${w.reason}</code>(${w.reason === 'model' ? '日志里有新输入,该调模型' : '日志里有没结果的工具调用,该派发'})。期间调了 ${w.models} 次模型、派发了 ${w.tools} 次工具。${w.signal ? '<b>被 ' + w.signal + ' 杀掉。</b>' : '干完就自己退出了。'}`;
const recs = R.recs.filter((x) => x.pid === w.pid && x.role === 'cloud-worker').map((x) => `${fmt(x.s)} ${x.kind} ${x.decision || x.tool || x.stop_reason || x.reason || ''}`);
body = `<b>这个进程的全部动作</b><pre class="box">${esc(recs.join('\n'))}</pre>`;
} else if (it.lane === 'session' || it.sess) {
const e = it.sess;
who = `写入方:${esc(r.by || '')} · 存储:cloud-data/sessions/*.jsonl`;
why = `Session 是唯一需要持久保存的东西。Worker 每做一步都先写到这里;任何新 Worker 都从这里读出完整历史。`;
body = `<pre class="box">${esc(JSON.stringify(e, null, 1))}</pre>`;
} else if (it.job) {
who = `笔记本子进程 pid ${it.job.pid},${fmt(it.job.start)} → ${fmt(it.job.end)}`;
why = `后台跑的完整回归(含读本地 .env 的集成测试)。启动时守护进程立刻返回 job_id,Worker 不用等;结束时守护进程把退出码和输出尾部写进 Session,成为新的输入事件。退出码:${it.job.code}。`;
const fin = R.session.find((e) => e.type === 'job_finished' && e.job_id === it.job.id);
body = fin ? `<pre class="box">${esc(fin.tail)}</pre>` : '';
} else if (it.under) {
who = `笔记本守护进程 pid ${r.pid}`;
why = `只负责领取工具调用、在仓库里执行、回传结果。不存会话、没有模型密钥。它主动长轮询连接云端(笔记本在 NAT 后面,云端连不进来)。`;
body = `<b>启动时仓库状态</b><pre class="box mini">${esc(r.repo_state?.status || '(干净)')} HEAD ${esc(r.repo_state?.head)}</pre>`;
} else {
who = esc(r.role || '');
why = r.kind === 'inject_kill' ? '演示脚本模拟云端机器故障 / 发版 / 缩容:直接 SIGKILL 正在运行的 Worker。' : r.kind === 'dispatch_offline' ? '云端想把工具调用发给笔记本,但设备不在线。Worker 记下 waiting_device 后退出。' : r.kind === 'note' ? esc(r.text) : '';
body = `<pre class="box">${esc(JSON.stringify(r, null, 1))}</pre>`;
}
$('insp').innerHTML = `<h3>${esc(it.title)} <span class="mut" style="font-weight:400;font-size:13px">@ ${fmt(it.s)}</span></h3><div class="who">${who}</div><div class="why">${why}</div>${body}`;
renderTL();
}
// ---------- 证据 ----------
function renderEvidence() {
const ev = R.evidence, k = ev.kill, o = ev.offline;
const card = (h, num, p, cmp) => `<div class="card"><h3>${h}</h3><div class="num">${num}</div><p>${p}</p><div class="cmp">本地 CLI 对照:${cmp}</div></div>`;
$('ev').innerHTML = [
card('① 计算可以随时丢', k ? (k.identical ? '<span class="ok">请求逐字节一致</span>' : '<span class="bad">请求不一致</span>') : '—',
k ? `Worker pid ${k.pid} 被 kill -9,${fmt(k.nextAt - k.at)} 后 pid ${k.nextPid} 接手,重建的模型请求与被杀前发出的完全相同(${k.bytes} 字节)。任务照常完成。` : '',
'Loop 进程就是会话本身。进程被杀,内存里的上下文跟着没了,要靠手动 resume 从本地会话文件恢复,后台测试通常也被一起清理。'),
card('② 云端不为“等待”驻留', `${fmt(ev.workerAlive)} / ${fmt(ev.wall)}`,
`整个任务 ${fmt(ev.wall)},云端先后 ${ev.workerCount} 个 Worker,累计只活了 ${fmt(ev.workerAlive)}(${Math.round((ev.workerAlive / ev.wall) * 100)}%)。等测试、等你、等笔记本开机时都是 0 个进程。`,
'进程从开始到结束一直开着(这里就是 ' + fmt(ev.wall) + '),终端关了就停。'),
card('③ 设备离线不丢任务', o ? `离线 ${fmt(o.to - o.from)},上线后 ${o.resumeMs} ms 续上` : '—',
o ? `合盖期间 ${o.parkedTool} 挂在日志里,Worker 数 = ${o.workersDuring}。守护进程一上线,调度器直接派发这个悬着的调用,没有重新问模型。` : '',
'合盖等于 Agent 暂停;在途的网络请求可能超时报错,需要你回来重新驱动。'),
card('④ 环境不搬家(但看过的会上云)', ev.gateway.secretInGateway || ev.secretInSession ? '<span class="bad">.env 密码出现在云端</span>' : '<span class="ok">.env 密码 0 次出现在云端</span>',
`${ev.gateway.count} 次网关请求共 ${(ev.gateway.bytes / 1024).toFixed(1)} KB。上云的只有工具结果:${ev.uploaded.map(esc).join('、')}。仓库、.env、node_modules、git 历史都没离开 <code>laptop-home/</code>;集成测试读 .env 是在笔记本上发生的。`,
'这一点和本地 CLI 一样:工具也在本地跑,读到的内容同样会发给模型 API。差别在于 Loop 和会话也在本地。'),
].join('') + (ev.answeredDuringJob ? `<div class="card" style="grid-column:1/-1"><h3>附带:异步工具让追问不用排队</h3><p>完整回归在笔记本上跑的时候,你追问的 diff 已经被回答(见场景 C)。这正好把上一轮讨论的“异步 Agent Loop”和“计算与状态分离”连起来:工具结果本来就是写进日志的事件,所以谁来处理、什么时候处理都可以解耦。</p></div>` : '');
}
// ---------- 播放 ----------
function setT(t) {
T = Math.max(0, Math.min(R.end, t));
$('scrub').value = T; $('clock').textContent = fmt(T);
renderTL(); renderSnap();
}
$('scrub').oninput = (e) => { setT(+e.target.value); };
$('play').onclick = () => {
if (playing) { clearInterval(playing); playing = null; $('play').textContent = '▶ 播放'; return; }
if (T >= R.end - 0.05) setT(view[0]);
$('play').textContent = '⏸ 暂停';
let last = performance.now();
playing = setInterval(() => {
const now = performance.now(); const dt = (now - last) / 1000 * +$('speed').value; last = now;
setT(T + dt);
if (T >= Math.min(R.end, view[1])) { clearInterval(playing); playing = null; $('play').textContent = '▶ 播放'; }
}, 50);
};
function loadRun(i) {
R = RUNS[i];
items = buildItems();
view = [0, R.end + 0.5]; selected = null;
$('scrub').max = R.end;
const m = R.meta, ev = R.evidence;
$('setup').innerHTML = `<b>这次运行:</b>${new Date(m.finished).toLocaleString('zh-CN')} · 模型 <code>${esc(m.model)}</code> 经私有网关 <code>${esc(m.gateway)}</code>(Anthropic Messages 协议,配置来自 <code>~/.pi/agent/models.json</code>) · 会话 <code>${esc(m.session)}</code> · 设备 <code>${esc(m.device)}</code><br>
<b>剧本:</b>A 提需求 → B 改完文件后 kill -9 云端 Worker → C 回归在后台跑时追问 → D 合上笔记本再提新需求 → E 打开笔记本。剧本只控制“什么时候发消息、什么时候杀进程/断设备”,模型说什么、调什么工具都是真实返回的。`;
const pids = (role) => [...new Set(R.recs.filter((r) => r.role === role).map((r) => r.pid))];
const role = (color, h, where, has, not, pidline) => `<div class="card role" style="border-top-color:${color}"><h3>${h}</h3><div class="where">${where}</div><ul>${has.map((x) => `<li>${x}</li>`).join('')}${not.map((x) => `<li class="no">✗ ${x}</li>`).join('')}</ul><div class="pid">${pidline}</div></div>`;
$('roles').innerHTML = [
role('var(--sess)', '☁️ 云端控制面 + Session', 'cloud/server.js · 常驻', ['把事件追加进 Session(唯一持久的东西)', '调度:有活且没人在干 → 起 Worker', '转发工具调用给设备'], ['不调模型', '不碰代码'], `pid ${pids('cloud-server').join(', ')}`),
role('var(--worker)', '☁️ 云端 Loop Worker', 'cloud/loop-worker.js · 按需拉起', ['读日志 → 重建 messages → 调网关', '把 tool_use 派给设备,结果写回日志', '持有网关密钥'], ['没有仓库(cwd 是空目录)', '不保存任何状态,随时可杀'], `本次共 ${ev.workerCount} 个进程:pid ${ev.workers.map((w) => w.pid).join(', ')}`),
role('var(--gw)', '🌐 私有网关 · 模型', esc(m.gateway), ['只做推理:收 messages,回 text / tool_use'], ['不知道会话存在哪', '不执行任何工具'], `${ev.gateway.count} 次请求`),
role('var(--lap)', '💻 笔记本守护进程', 'laptop/daemon.js · 在你电脑上', ['在仓库里读写文件、跑命令、跑测试', '主动连云端领任务(NAT 友好)', '仓库 / .env / 工具链都在这'], ['不存会话', '没有模型密钥'], `pid ${pids('laptop-daemon').join(', ')}(合盖前后各一个)`),
].join('');
renderScenes(); setNarr('ALL'); renderEvidence();
$('diff').innerHTML = diffHTML(R.finalDiff);
$('insp').innerHTML = '<span class="mut">点时间线上的任意色块,这里显示它的原始记录和解释。</span>';
setT(R.end);
}
$('runSel').innerHTML = RUNS.map((r, i) => `<option value="${i}">${r.name}(${r.meta.ok ? '成功' : '失败'},${r.end.toFixed(0)} s)</option>`).join('');
$('runSel').onchange = (e) => loadRun(+e.target.value);
loadRun(0);
</script>
</body>
</html>