Files
agent-loop/explainers/compute-state-separation.html
2026-11-12 15:29:12 +08:00

498 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>计算与状态分离:一次改 bug 的全过程</title>
<style>
:root {
--bg: #f6f7f9;
--card: #ffffff;
--ink: #1f2328;
--muted: #656d76;
--line: #d8dee4;
--compute: #2f6fde;
--compute-bg: #eaf1fd;
--state: #8a4dd6;
--state-bg: #f3ecfc;
--local: #1a7f4b;
--local-bg: #e6f4ec;
--warn: #b35900;
--warn-bg: #fff4e5;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 16px/1.65 -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
main { max-width: 1180px; margin: 0 auto; padding: 28px 24px 60px; }
h1 { font-size: 26px; margin: 0 0 6px; }
h2 { font-size: 19px; margin: 36px 0 12px; }
.lead { color: var(--muted); margin: 0 0 4px; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; background: #eef1f4; padding: 1px 5px; border-radius: 4px; }
.note { background: var(--warn-bg); border-left: 4px solid var(--warn); padding: 10px 14px; border-radius: 6px; margin: 14px 0; font-size: 15px; }
/* 三种布局对比 */
.layouts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.layout { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px; }
.layout h3 { font-size: 16px; margin: 0 0 2px; }
.layout .sub { color: var(--muted); font-size: 13px; margin-bottom: 10px; }
.tier { border-radius: 8px; padding: 8px 10px; margin-top: 8px; font-size: 14px; }
.tier b { display: block; font-size: 12px; letter-spacing: .04em; color: var(--muted); font-weight: 600; }
.tier.cloud { background: #eef2f7; }
.tier.mine { background: var(--local-bg); }
.chip { display: inline-block; padding: 1px 8px; margin: 3px 4px 0 0; border-radius: 999px; font-size: 13px; background: #fff; border: 1px solid var(--line); }
.chip.c { border-color: var(--compute); color: var(--compute); }
.chip.s { border-color: var(--state); color: var(--state); }
.chip.l { border-color: var(--local); color: var(--local); }
.chip.x { border-style: dashed; color: var(--muted); }
.layout .verdict { font-size: 14px; margin-top: 10px; color: var(--muted); }
.layout.pick { border: 2px solid var(--ink); }
/* 舞台 */
.stage { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 18px; }
.you {
display: flex; align-items: center; gap: 10px;
border: 1px dashed var(--line); border-radius: 8px; padding: 8px 12px; margin-bottom: 12px; font-size: 15px;
transition: background .25s, border-color .25s;
}
.you.on { background: #fff8d6; border-color: #d4a72c; }
.you .who { font-weight: 600; }
.zones { display: grid; grid-template-columns: 1fr 1.15fr 1.15fr; gap: 12px; }
.zone { border-radius: 10px; padding: 12px 14px; min-height: 300px; border: 2px solid transparent; transition: border-color .25s, box-shadow .25s; }
.zone h3 { margin: 0; font-size: 16px; }
.zone .where { font-size: 12px; color: var(--muted); margin-bottom: 10px; }
.zone.compute { background: var(--compute-bg); }
.zone.state { background: var(--state-bg); }
.zone.local { background: var(--local-bg); }
.zone.on.compute { border-color: var(--compute); box-shadow: 0 0 0 4px rgba(47,111,222,.12); }
.zone.on.state { border-color: var(--state); box-shadow: 0 0 0 4px rgba(138,77,214,.12); }
.zone.on.local { border-color: var(--local); box-shadow: 0 0 0 4px rgba(26,127,75,.12); }
.zone.off { filter: grayscale(1); opacity: .55; }
.item { background: #fff; border-radius: 6px; padding: 6px 9px; margin-bottom: 6px; font-size: 14px; border: 1px solid rgba(0,0,0,.06); }
.item.new { animation: pop .6s ease; outline: 2px solid #d4a72c; }
.item.gone { text-decoration: line-through; color: var(--muted); background: transparent; border-style: dashed; }
.item small { display: block; color: var(--muted); font-size: 12px; }
.empty { color: var(--muted); font-size: 14px; font-style: italic; padding: 6px 0; }
@keyframes pop { from { transform: scale(.96); } to { transform: scale(1); } }
.log { list-style: none; padding: 0; margin: 0; max-height: 640px; overflow-y: auto; }
.log li { display: flex; gap: 8px; font-size: 13.5px; background: #fff; border-radius: 6px; padding: 5px 8px; margin-bottom: 5px; border: 1px solid rgba(0,0,0,.06); }
.log li .n { color: var(--state); font-weight: 600; min-width: 26px; }
.log li .t { color: var(--muted); min-width: 64px; }
.log li.new { outline: 2px solid #d4a72c; }
.flow { margin: 14px 0 0; font-size: 14px; color: var(--muted); }
.flow span { color: var(--ink); font-weight: 600; }
/* 解说区 */
.explain { display: grid; grid-template-columns: 1.6fr 1fr; gap: 14px; margin-top: 14px; }
.box { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
.box h4 { margin: 0 0 6px; font-size: 17px; }
.box p { margin: 6px 0; }
.box.cli { background: #fafbfc; }
.box.cli h4 { font-size: 14px; color: var(--muted); font-weight: 600; }
.box.cli p { font-size: 14.5px; }
.cloudline { margin-top: 8px; font-size: 14px; background: #eef2f7; border-radius: 6px; padding: 6px 10px; }
.controls { display: flex; align-items: center; gap: 10px; margin-top: 16px; flex-wrap: wrap; }
button {
font: inherit; font-size: 15px; padding: 7px 16px; border-radius: 8px; border: 1px solid var(--line);
background: #fff; cursor: pointer;
}
button.primary { background: var(--ink); color: #fff; border-color: var(--ink); }
button:disabled { opacity: .4; cursor: default; }
.dots { display: flex; gap: 6px; flex: 1; justify-content: center; }
.dot { width: 26px; height: 26px; border-radius: 50%; border: 1px solid var(--line); background: #fff; font-size: 12px; padding: 0; }
.dot.on { background: var(--ink); color: #fff; border-color: var(--ink); }
.dot.done { background: #d0d7de; }
.hint { font-size: 13px; color: var(--muted); }
.summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.summary .box h4 { font-size: 15px; }
.summary ul { margin: 6px 0 0; padding-left: 18px; font-size: 14.5px; }
.takeaway { margin-top: 16px; font-size: 17px; background: var(--card); border: 2px solid var(--ink); border-radius: 10px; padding: 14px 18px; }
.src { font-size: 13px; color: var(--muted); margin-top: 26px; }
.src a { color: var(--muted); }
@media (max-width: 900px) {
.layouts, .zones, .explain, .summary { grid-template-columns: 1fr; }
.zone { min-height: 0; }
}
</style>
</head>
<body>
<main>
<h1>计算与状态分离:跟着一次改 bug 走一遍</h1>
<p class="lead">需求:<b>“登录令牌在过期的那一秒还能用,修一下,并跑回归测试。”</b></p>
<p class="lead">下面一步步看:哪些东西在云端、云端在做什么、你的电脑上留着什么。</p>
<div class="note">
先澄清一个容易混的点:<b>模型推理本来就在云端</b>(你今天用 Claude Code、Codex 也是调云端模型 API)。这里说的“计算”指的是 <b>Agent Loop 本身</b>:拼上下文、调模型、分发工具调用、记录结果。它今天跑在你的电脑上,文章要把它搬走。
</div>
<h2>先看全景:三种摆法</h2>
<div class="layouts">
<div class="layout">
<h3>① 今天的本地 CLI</h3>
<div class="sub">Claude Code / Codex CLI / OpenCode 的常见用法</div>
<div class="tier cloud"><b>云端</b><span class="chip x">只有模型 API</span></div>
<div class="tier mine"><b>你的电脑</b>
<span class="chip c">Agent Loop 进程</span>
<span class="chip s">会话记录文件</span>
<span class="chip l">仓库 / 凭据 / 工具链</span>
</div>
<div class="verdict">三样东西都在一台机器上。合盖、断网、关终端,Agent 就停了。</div>
</div>
<div class="layout">
<h3>② 全搬上云(云端沙箱)</h3>
<div class="sub">文章 §5.2 提到的第一种常见做法</div>
<div class="tier cloud"><b>云端</b>
<span class="chip c">Agent Loop</span>
<span class="chip s">会话数据库</span>
<span class="chip l">沙箱:拉代码、重建环境</span>
</div>
<div class="tier mine"><b>你的电脑</b><span class="chip x">基本不参与</span></div>
<div class="verdict">一直在线,但你没提交的改动、本地凭据、内网服务,沙箱都拿不到,或者同步成本很高。</div>
</div>
<div class="layout pick">
<h3>③ 文章方案:§5.1 + §5.2</h3>
<div class="sub">计算、状态、执行环境三者分开</div>
<div class="tier cloud"><b>云端</b>
<span class="chip c">Agent Loop(无状态,可随时换)</span>
<span class="chip s">会话数据库</span>
</div>
<div class="tier mine"><b>你的电脑</b>
<span class="chip l">仓库 / 凭据 / 工具链</span>
<span class="chip l">轻量守护进程</span>
</div>
<div class="verdict">Loop 在云上一直在线,环境留在原地,工具调用穿过守护进程远程执行。<b>下面演示的就是这一种。</b></div>
</div>
</div>
<h2>逐步演示(方案 ③)</h2>
<div class="stage">
<div class="you" id="you"><span class="who">你</span><span id="youText">网页 / IM / CLI,都只是“发消息、看回复”的窗口</span></div>
<div class="zones">
<div class="zone compute" id="zCompute">
<h3>云端 · 计算</h3>
<div class="where">跑 Agent Loop 的容器,按需借用,用完即还</div>
<div id="computeBody"></div>
</div>
<div class="zone state" id="zState">
<h3>云端 · 状态(Session)</h3>
<div class="where">数据库里的只追加事件日志,唯一需要长期保存的东西</div>
<ul class="log" id="logBody"></ul>
</div>
<div class="zone local" id="zLocal">
<h3>你的电脑 · 执行环境</h3>
<div class="where">代码、改动、凭据、依赖都留在这里</div>
<div id="localBody"></div>
</div>
</div>
<div class="flow" id="flow"></div>
<div class="explain">
<div class="box">
<h4 id="title"></h4>
<p id="desc"></p>
<div class="cloudline" id="cloudline"></div>
</div>
<div class="box cli">
<h4>对照:如果是今天的本地 CLI</h4>
<p id="cli"></p>
</div>
</div>
<div class="controls">
<button id="prev">← 上一步</button>
<div class="dots" id="dots"></div>
<button id="next" class="primary">下一步 →</button>
</div>
<div class="hint" style="text-align:center;margin-top:6px">也可以用键盘 ← → 切换</div>
</div>
<h2>走完一遍,东西都在哪</h2>
<div class="summary">
<div class="box">
<h4 style="color:var(--compute)">云端 · 计算:什么都没留</h4>
<ul>
<li>前后用过 A、B、C 三个实例,都已经回收</li>
<li>它们内存里的东西都能从日志重建,丢了也没关系</li>
</ul>
</div>
<div class="box">
<h4 style="color:var(--state)">云端 · 状态:完整的事件日志</h4>
<ul>
<li>你的需求、模型每一步的决定</li>
<li>工具结果:读到的代码片段、diff 摘要、测试输出摘要</li>
<li>任务状态:等待设备、已完成</li>
</ul>
</div>
<div class="box">
<h4 style="color:var(--local)">你的电脑:真正干活的地方</h4>
<ul>
<li>两个文件的改动,<b>没提交</b>,等你 review</li>
<li>凭据、内网依赖、工具链,从来没离开过</li>
<li>测试是在这里真实跑的</li>
</ul>
</div>
</div>
<div class="takeaway">
一句话:<b>计算可以随时丢,因为状态存在别处;环境不用搬,因为工具调用可以远程执行。</b><br>
<span style="font-size:15px;color:var(--muted)">§5.1 让 Agent 不再依附某个进程,§5.2 让它不再依附某台机器。</span>
</div>
<p class="src">
依据:DeepSeek Harness《Super-agent 设计探索》第 5 节(<a href="https://xitter.cf/DeepSeekHarness/article/2116857257245987236">英文版镜像</a>),以及 Anthropic《<a href="https://www.anthropic.com/engineering/managed-agents">Scaling Managed Agents: Decoupling the brain from the hands</a>》中的 <code>wake(sessionId)</code>、<code>getSession(id)</code>、<code>emitEvent(id, event)</code> 接口。这是文章的架构设想,不代表 DeepSeek Harness 或公开原型已经实现;步骤中的事件、文件名和测试数量都是示意。
</p>
</main>
<script>
const STEPS = [
{
title: "1. 你提出需求",
desc: "你在网页或 IM 里发出需求。这条消息<b>先写进 Session</b>,而不是直接交给某个正在运行的进程。此时云端没有任何 Loop 在跑。",
cloud: "上云的:你的这条消息。",
cli: "消息直接进入你终端里那个进程的内存,同时追加到本机的会话记录文件里。进程和记录都在你的电脑上。",
on: ["you", "state"],
you: "发出:“登录令牌在过期那一秒还能用,修一下,并跑回归测试。”",
flow: "你 → <span>云端状态</span>",
compute: [],
computeEmpty: "空闲:没有实例在跑。计算是按需借用的。",
events: [["用户", "修令牌过期边界的 bug,并跑回归"]],
local: [
{ t: "仓库 auth-service", s: "main 分支,工作区干净" },
{ t: "本地凭据和内网 npm 源", s: ".env、~/.npmrc" },
{ t: "守护进程:在线", s: "只负责执行命令、返回结果,不保存会话" }
]
},
{
title: "2. 拉起一个 Loop 实例",
desc: "云端发现 Session 里有还没处理的事件,就分配一个<b>无状态容器</b>来跑 Agent Loop,记为实例 A。A 读取事件日志,拼出模型请求。模型的第一个决定是先读一下令牌校验的代码。",
cloud: "实例 A 的内存里只有从日志重建出来的消息列表,随时可以丢掉。",
cli: "本地 CLI 的 Loop 就是你启动的那个进程,一直开着,不需要“拉起”。代价是关掉终端它就没了。",
on: ["state", "compute"],
flow: "<span>云端状态</span> → <span>云端计算(实例 A)</span> → 模型 API",
compute: [
{ t: "实例 A · 运行中", s: "从日志重建上下文 → 调模型", isNew: true }
],
events: [["模型", "先读 src/auth/token.ts"]],
local: "same"
},
{
title: "3. 读代码:工具调用穿到你的电脑上",
desc: "代码在你的电脑上,<b>云端没有仓库副本</b>。实例 A 把 <code>read_file</code> 发给本地守护进程,守护进程读出文件返回,结果写进 Session。读到第 42 行:<code>if (now &gt; exp) reject()</code>。",
cloud: "上云的:<b>读到的那段代码</b>会作为工具结果进入 Session。“环境留在本地”不等于“代码一行都不上云”,模型需要看到的片段还是会上去。",
cli: "效果一样:文件也是在本地读的,读到的内容也会发给云端模型 API。区别只在于 Loop 和会话记录也在本地。",
on: ["compute", "local", "state"],
flow: "<span>云端计算</span> → <span>本地守护进程</span> → <span>云端计算</span> → <span>云端状态</span>",
compute: [{ t: "实例 A · 运行中", s: "等待本地返回工具结果" }],
events: [["工具调用", "read_file src/auth/token.ts"], ["工具结果", "第 42 行:if (now > exp) reject()"]],
local: [
{ t: "仓库 auth-service", s: "main 分支,工作区干净" },
{ t: "本地凭据和内网 npm 源", s: ".env、~/.npmrc" },
{ t: "守护进程:执行 read_file", s: "执行完即忘,不记会话", isNew: true }
]
},
{
title: "4. 改代码:改动只落在本地",
desc: "模型判断边界写错了:<code>now &gt; exp</code> 应该是 <code>now &gt;= exp</code>。<code>edit</code> 在你的电脑上执行,文件被改了,但<b>没有提交,也没有推送</b>。",
cloud: "上云的:这次修改的 diff 摘要(一行变更)。完整的工作区仍然只在本地。",
cli: "一样:改动也只在本地。",
on: ["compute", "local", "state"],
flow: "<span>云端计算</span> → <span>本地:修改文件</span> → <span>云端状态</span>",
compute: [{ t: "实例 A · 运行中", s: "" }],
events: [["工具调用", "edit token.ts:> 改成 >="], ["工具结果", "1 行变更"]],
local: [
{ t: "仓库 auth-service", s: "src/auth/token.ts 已修改(未提交)", isNew: true },
{ t: "本地凭据和内网 npm 源", s: ".env、~/.npmrc" },
{ t: "守护进程:在线", s: "" }
]
},
{
title: "5. 启动回归测试(异步)",
desc: "<code>pnpm test auth</code> 要跑一分钟,还要连内网服务,只能在你的电脑上跑。守护进程在本地启动测试,<b>立刻返回 job-17</b>,Loop 的这一步就结束了,不用一直等着。",
cloud: "上云的:“job-17 已启动”这条记录。测试进程本身在本地。",
cli: "同步执行的话,这一分钟里 Loop 一直卡在等结果。用后台任务(例如 Claude Code 的 run_in_background)才能先返回。",
on: ["compute", "local", "state"],
flow: "<span>云端计算</span> → <span>本地:启动测试</span> → <span>云端状态</span>",
compute: [{ t: "实例 A · 空闲", s: "这一步做完了,没有别的事" }],
events: [["工具调用", "后台运行 pnpm test auth"], ["工具结果", "已启动 job-17"]],
local: [
{ t: "仓库 auth-service", s: "src/auth/token.ts 已修改(未提交)" },
{ t: "本地凭据和内网 npm 源", s: "测试正在用它们" },
{ t: "进程:pnpm test auth", s: "job-17 · 运行中", isNew: true }
]
},
{
title: "6. 云端实例被回收(或崩溃)",
desc: "实例 A 闲着,被回收了。换成机器故障、版本发布、扩缩容,结果也一样。<b>A 内存里的东西全丢了,但没关系</b>:该记的都已经在 Session 里。你电脑上的测试照常在跑。",
cloud: "这一步就是“计算与状态分离”的核心:丢掉计算,什么都不会丢。",
cli: "本地 CLI 这时如果进程退出,它启动的后台测试一般也会被清理(Claude Code 文档写明退出时会自动清理后台任务),测试结果没有地方可以回。",
on: ["compute"],
flow: "<span>云端计算</span>:实例 A 消失。Session 和本地都不受影响",
compute: [{ t: "实例 A", s: "已回收", gone: true }],
events: [],
local: [
{ t: "仓库 auth-service", s: "src/auth/token.ts 已修改(未提交)" },
{ t: "本地凭据和内网 npm 源", s: "" },
{ t: "进程:pnpm test auth", s: "job-17 · 仍在运行" }
]
},
{
title: "7. 测试跑完,新实例接上",
desc: "测试结束,守护进程上报“job-17 退出码 0,24 个通过”,这条结果写进 Session,成为一个新事件。云端发现新事件,拉起<b>实例 B</b>:<code>wake(sessionId)</code> → 读出完整日志 → 从最后一个事件接着推理。模型决定再补一个“恰好过期”的测试用例。",
cloud: "实例 B 和 A 不是同一个进程,但 Session 是同一个。B 知道的一切都来自日志。",
cli: "本地 CLI 要“接着跑”,只能是同一个进程还活着;进程挂了就得手动 resume,而且后台测试的结果多半已经丢了。",
on: ["local", "state", "compute"],
flow: "<span>本地:测试结束</span> → <span>云端状态</span> → <span>云端计算(实例 B)</span> → 模型 API",
compute: [
{ t: "实例 A", s: "已回收", gone: true },
{ t: "实例 B · 运行中", s: "wake(sessionId) → 读日志 → 调模型", isNew: true }
],
events: [["后台完成", "job-17 退出码 0,24 个通过"], ["模型", "补一个“恰好过期”的测试用例"]],
local: [
{ t: "仓库 auth-service", s: "src/auth/token.ts 已修改(未提交)" },
{ t: "本地凭据和内网 npm 源", s: "" },
{ t: "进程:pnpm test auth", s: "job-17 · 已结束,24 个通过", isNew: true }
]
},
{
title: "8. 你合上了笔记本",
desc: "实例 B 要在本地新增测试文件,但你的电脑离线了,工具调用发不出去。任务被标记为“<b>等待设备上线</b>”,实例 B 也被回收。没有东西丢失,也没有东西在空转。",
cloud: "上云的:“等待设备”这个任务状态。计算区又空了。",
cli: "本地 CLI 合盖等于整个 Agent 暂停。进程被挂起,打开后正在进行的网络请求可能已经超时报错。",
on: ["local", "state"],
offLocal: true,
flow: "<span>本地:离线</span> → <span>云端状态</span>:记录“等待设备”",
compute: [],
computeEmpty: "空闲:实例 B 已回收,没有实例在等。",
events: [["工具调用", "edit token.spec.ts → 等待设备上线"]],
local: [
{ t: "仓库 auth-service", s: "src/auth/token.ts 已修改(未提交)" },
{ t: "本地凭据和内网 npm 源", s: "" },
{ t: "守护进程:离线", s: "电脑已合盖", isNew: true }
]
},
{
title: "9. 第二天打开电脑:自动续上",
desc: "守护进程重新连上,云端发现有任务在等这台设备,就拉起<b>实例 C</b>,执行挂起的那次工具调用:新增测试,再跑一遍回归。",
cloud: "文章原话的意思:个人设备重连后,云端 Agent 会自动开始并完成需要在这台设备上跑的任务。",
cli: "本地 CLI 没有“等设备”这个概念,因为 Loop 和设备是同一个东西:设备在,Loop 才在。",
on: ["local", "state", "compute"],
flow: "<span>本地:重新上线</span> → <span>云端状态</span> → <span>云端计算(实例 C)</span> → <span>本地:改文件、跑测试</span>",
compute: [{ t: "实例 C · 运行中", s: "读日志,执行挂起的工具调用", isNew: true }],
events: [["工具结果", "新增 1 个测试用例"], ["后台完成", "pnpm test auth:25 个通过"]],
local: [
{ t: "仓库 auth-service", s: "token.ts、token.spec.ts 已修改(未提交)", isNew: true },
{ t: "本地凭据和内网 npm 源", s: "" },
{ t: "守护进程:在线", s: "" }
]
},
{
title: "10. 交付:汇报给你,改动留在本地",
desc: "模型总结修了什么、怎么验证的,回复给你。代码改动还在你的电脑上,等你 review、提交。实例 C 回收,这次任务结束。",
cloud: "最后留在云端的,只有这份事件日志。",
cli: "结果差不多,但前提是这期间你的电脑一直开着、终端一直没关。",
on: ["state", "you", "compute"],
you: "收到:“已修复 token.ts 第 42 行的边界判断,新增 1 个用例,回归 25 个全部通过。改动未提交,请 review。”",
flow: "<span>云端计算</span> → <span>云端状态</span> → 你",
compute: [{ t: "实例 C", s: "已回收", gone: true }],
events: [["回复你", "已修复,新增 1 个用例,25 个通过,改动未提交"]],
local: [
{ t: "仓库 auth-service", s: "token.ts、token.spec.ts 已修改(未提交)" },
{ t: "本地凭据和内网 npm 源", s: "从未离开过这台电脑" },
{ t: "守护进程:在线", s: "空闲" }
]
}
];
// 本地状态 "same" 表示沿用上一步
for (let i = 0; i < STEPS.length; i++) {
if (STEPS[i].local === "same") {
STEPS[i].local = STEPS[i - 1].local.map(x => ({ t: x.t, s: x.s }));
}
}
const $ = id => document.getElementById(id);
let cur = 0;
function itemHTML(x) {
const cls = ["item"];
if (x.isNew) cls.push("new");
if (x.gone) cls.push("gone");
return `<div class="${cls.join(" ")}">${x.t}${x.s ? `<small>${x.s}</small>` : ""}</div>`;
}
function esc(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function render() {
const st = STEPS[cur];
$("title").innerHTML = st.title;
$("desc").innerHTML = st.desc;
$("cloudline").innerHTML = st.cloud;
$("cli").innerHTML = st.cli;
$("flow").innerHTML = "这一步的数据流:" + st.flow;
$("you").classList.toggle("on", st.on.includes("you"));
$("youText").textContent = st.you || "网页 / IM / CLI,都只是“发消息、看回复”的窗口";
$("zCompute").classList.toggle("on", st.on.includes("compute"));
$("zState").classList.toggle("on", st.on.includes("state"));
$("zLocal").classList.toggle("on", st.on.includes("local"));
$("zLocal").classList.toggle("off", !!st.offLocal);
$("computeBody").innerHTML = st.compute.length
? st.compute.map(itemHTML).join("")
: `<div class="empty">${st.computeEmpty || "空闲"}</div>`;
$("localBody").innerHTML = st.local.map(itemHTML).join("");
// 事件日志:累积
let n = 0, html = "";
for (let i = 0; i <= cur; i++) {
for (const [type, text] of STEPS[i].events) {
n++;
html += `<li class="${i === cur ? "new" : ""}"><span class="n">#${n}</span><span class="t">${type}</span><span>${esc(text)}</span></li>`;
}
}
$("logBody").innerHTML = html || `<li><span class="t">(空)</span></li>`;
const log = $("logBody");
log.scrollTop = log.scrollHeight;
$("prev").disabled = cur === 0;
$("next").disabled = cur === STEPS.length - 1;
[...$("dots").children].forEach((d, i) => {
d.className = "dot" + (i === cur ? " on" : i < cur ? " done" : "");
});
}
STEPS.forEach((_, i) => {
const b = document.createElement("button");
b.className = "dot";
b.textContent = i + 1;
b.title = STEPS[i].title;
b.onclick = () => { cur = i; render(); };
$("dots").appendChild(b);
});
$("prev").onclick = () => { if (cur > 0) { cur--; render(); } };
$("next").onclick = () => { if (cur < STEPS.length - 1) { cur++; render(); } };
document.addEventListener("keydown", e => {
if (e.key === "ArrowRight") $("next").click();
if (e.key === "ArrowLeft") $("prev").click();
});
render();
</script>
</body>
</html>