first commit

This commit is contained in:
魏天炜
2026-11-12 15:29:12 +08:00
commit 5dabea54ab
93 changed files with 12170 additions and 0 deletions

View File

@@ -0,0 +1,497 @@
<!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>