Files
agent-loop/explainers/cloud-agent-loop-film.html
2026-11-12 15:29:12 +08:00

621 lines
30 KiB
HTML
Raw Permalink 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">
<meta name="description" content="用一段可暂停的动画,看云端 Agent Loop 如何通过本机 daemon 读仓库,以及 Mac 离线后两种架构的区别。">
<title>云端 Agent Loop 怎么读本机仓库?</title>
<style>
:root {
color-scheme: light;
--paper: #f3f5f1;
--ink: #172b36;
--muted: #445a62;
--line: #cbd7d4;
--screen: #0c222c;
--cloud: #a5d2ff;
--local: #a2e6be;
--paused: #ffd29a;
--light: #f4f9fc;
--subtle: #c0d0d6;
--mono: "IBM Plex Mono", ui-monospace, Menlo, monospace;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font: 16px/1.62 "Inter", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
h1, h2, h3, p, pre, dl, dd { margin: 0; }
h1, h2, h3 { line-height: 1.22; }
h1 { font-size: clamp(2rem, 5.6vw, 4.05rem); letter-spacing: -.048em; text-wrap: balance; }
h1 span { white-space: nowrap; }
h2 { font-size: clamp(1.45rem, 3.2vw, 2.05rem); letter-spacing: -.025em; }
h3 { font-size: 1.05rem; }
a { color: #165984; text-underline-offset: .2em; }
button, input { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: default; opacity: .45; }
:focus-visible { outline: 3px solid #3787bc; outline-offset: 3px; }
.wrap { width: min(100% - 2rem, 1100px); margin-inline: auto; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.heading { padding-block: clamp(1.7rem, 4.4vw, 3.35rem) 1.1rem; }
.heading p { margin-top: .6rem; color: var(--muted); }
.player { border-radius: 14px; overflow: clip; background: var(--screen); color: var(--light); }
.controls {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: .65rem .9rem;
padding: .7rem clamp(.7rem, 2.3vw, 1.2rem);
border-bottom: 1px solid #35505b;
}
.play {
min-width: 6rem;
min-height: 44px;
padding: .45rem .65rem;
border: 0;
border-radius: 7px;
background: var(--cloud);
color: var(--screen);
font-weight: 750;
}
.seek {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: .55rem;
}
.seek input { width: 100%; min-width: 0; accent-color: var(--cloud); cursor: pointer; }
.seek output { font: .75rem var(--mono); white-space: nowrap; color: var(--subtle); }
.chapters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: .45rem;
padding: .65rem clamp(.7rem, 2.3vw, 1.2rem);
}
.chapters button {
min-height: 44px;
padding: .35rem .4rem;
border: 1px solid #35505b;
border-radius: 7px;
background: #122e3a;
color: var(--subtle);
font-size: .86rem;
font-weight: 650;
}
.chapters button[aria-pressed="true"] { border-color: var(--cloud); color: var(--cloud); }
.chapters button:hover { background: #1d3a48; }
.frame { padding: 0 clamp(.7rem, 2.3vw, 1.2rem) .7rem; }
.diagram { position: relative; display: grid; gap: .1rem; }
.wire { position: absolute; z-index: 1; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.zone { padding: .7rem clamp(.45rem, 2.1vw, 1.15rem) .75rem; border: 1px solid #2e4b58; border-radius: 9px; background: #112d3a; }
.zone.mac { background: #112f2c; border-color: #30544b; }
.zone-label { margin-bottom: .48rem; color: var(--cloud); font-size: .79rem; font-weight: 700; }
.mac .zone-label { color: var(--local); }
.actors { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(.65rem, 5vw, 4.65rem); }
.actor {
z-index: 2;
min-width: 0;
min-height: 76px;
display: flex;
flex-direction: column;
justify-content: center;
gap: .17rem;
padding: .4rem clamp(.3rem, 1.5vw, .8rem);
border: 1px solid #466372;
border-radius: 7px;
background: #173544;
overflow-wrap: anywhere;
}
.mac .actor { background: #193a35; border-color: #41665a; }
.actor .name { font-size: clamp(.78rem, 2.15vw, 1.08rem); font-weight: 750; line-height: 1.28; }
.actor .state { color: var(--subtle); font-size: clamp(.69rem, 1.5vw, .87rem); line-height: 1.35; }
.actor[data-active] { border-color: var(--cloud); box-shadow: inset 0 0 0 1px var(--cloud); }
.mac .actor[data-active] { border-color: var(--local); box-shadow: inset 0 0 0 1px var(--local); }
.actor[data-active="receiver"] { background: #234b5e; }
.mac .actor[data-active="receiver"] { background: #245446; }
.channel {
position: relative;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: center;
min-height: 48px;
color: var(--subtle);
font-size: .75rem;
}
.channel::before { content: ""; position: absolute; inset-inline: 0; top: 50%; border-top: 1px dashed #57707b; }
.channel span { position: relative; z-index: 2; grid-column: 3; justify-self: end; padding-inline: .4rem; background: var(--screen); text-align: right; }
.caption {
display: grid;
gap: .55rem;
align-items: start;
padding: .9rem clamp(.7rem, 2.3vw, 1.2rem) 1.05rem;
border-top: 1px solid #35505b;
background: #112c39;
}
.caption h2 { font-size: clamp(1.18rem, 2.5vw, 1.65rem); }
.caption p { margin-top: .3rem; color: var(--subtle); font-size: .94rem; }
.payload {
padding: .55rem .65rem;
border: 1px solid #436471;
border-radius: 7px;
color: var(--cloud);
background: var(--screen);
font: .82rem/1.55 var(--mono);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.payload[data-tone="local"] { color: var(--local); }
.payload[data-tone="wait"] { color: var(--paused); }
.comparison {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: clamp(.45rem, 2.3vw, 1rem);
}
.side { display: grid; align-content: start; gap: .55rem; min-width: 0; padding: .65rem; border: 1px solid #3f5864; border-radius: 9px; background: #112d3a; }
.side h3 { min-height: 2.55em; font-size: clamp(.9rem, 2.4vw, 1.1rem); }
.compare-state { padding: .5rem; min-height: 3rem; border-radius: 6px; background: #193848; color: var(--light); font-size: clamp(.77rem, 1.65vw, .93rem); line-height: 1.4; }
.compare-state[data-state="off"] { color: var(--paused); background: #473a2c; }
.compare-state[data-state="online"] { color: var(--local); background: #23443b; }
.compare-state[data-state="waiting"] { color: var(--paused); border: 1px dashed #997653; background: #302f2b; }
.compare-state strong { display: block; }
.compare-state span { display: block; margin-top: .12rem; }
.compare-state.reveal { animation: arrive .35s ease-out both; }
@keyframes arrive { from { opacity: .5; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.answers { display: grid; gap: 1.25rem; padding-block: 1.6rem; }
.answers p { margin-top: .5rem; color: var(--muted); }
.answers strong { color: var(--ink); }
.sources { padding-block: .8rem 2rem; border-top: 1px solid var(--line); color: var(--muted); font-size: .84rem; }
.sources a { display: inline-block; margin: .3rem .75rem 0 0; }
@media (min-width: 720px) {
.wrap { width: min(100% - 3.5rem, 1100px); }
.caption { grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: 1.2rem; }
.comparison { gap: 1rem; }
.side { padding: 1rem; min-height: 16.7rem; }
.side h3 { min-height: 0; }
.answers { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 395px) {
.wrap { width: calc(100% - 1rem); }
.chapters button { font-size: .75rem; }
.actor { min-height: 70px; }
.controls { gap: .5rem; }
.seek { grid-template-columns: minmax(0, 1fr); gap: 0; }
.seek output { text-align: end; }
}
@media (prefers-reduced-motion: reduce) {
.compare-state.reveal { animation: none; }
}
</style>
</head>
<body>
<main class="wrap">
<header class="heading">
<h1>云端 Agent Loop <span>怎么读本机仓库?</span></h1>
<p>跟着一次读文件请求走。最后让 Mac 合盖,看两种架构分别停在哪里。</p>
</header>
<section class="player" aria-label="Agent Loop 动画">
<div class="controls" id="controls" hidden>
<button class="play" id="play" type="button">▶ 播放</button>
<div class="seek">
<label class="sr-only" for="seek">播放进度</label>
<input id="seek" type="range" min="0" value="0" step="0.1">
<output for="seek" id="clock"></output>
</div>
</div>
<nav class="chapters" id="chapters" aria-label="动画章节" hidden>
<button type="button" data-chapter="0" aria-pressed="true">云端怎么跑</button>
<button type="button" data-chapter="1" aria-pressed="false">怎么读仓库</button>
<button type="button" data-chapter="2" aria-pressed="false">Mac 合盖以后</button>
</nav>
<div class="frame">
<div class="diagram" id="diagram" aria-label="云端与开发机的部署位置">
<div class="zone">
<p class="zone-label">云端</p>
<div class="actors">
<div class="actor" id="model"><span class="name">模型 API</span><span class="state">V4 Flash 推理</span></div>
<div class="actor" id="loop"><span class="name">Agent Loop</span><span class="state">组请求、派工具</span></div>
<div class="actor" id="session"><span class="name">Session</span><span class="state">持久化记录</span></div>
</div>
</div>
<div class="channel"><span id="channel">云端 / 本机边界</span></div>
<div class="zone mac">
<p class="zone-label">你的 Mac</p>
<div class="actors">
<div aria-hidden="true"></div>
<div class="actor" id="daemon"><span class="name">执行器 daemon</span><span class="state">接请求、跑工具</span></div>
<div class="actor" id="repo"><span class="name">Git 工作区</span><span class="state">源码和未提交改动</span></div>
</div>
</div>
<canvas class="wire" id="wire" aria-hidden="true"></canvas>
</div>
<div class="comparison" id="comparison" aria-label="Mac 离线后的并排比较" hidden>
<section class="side" aria-labelledby="local-title">
<h3 id="local-title">本地 Loop</h3>
<div class="compare-state" data-state="off" id="local-loop"></div>
<div class="compare-state" data-state="off" id="local-files"></div>
<div class="compare-state" id="local-outcome"></div>
</section>
<section class="side" aria-labelledby="cloud-title">
<h3 id="cloud-title">云端 Loop + 本机执行</h3>
<div class="compare-state" data-state="online" id="cloud-loop"></div>
<div class="compare-state" data-state="off" id="cloud-files"></div>
<div class="compare-state" id="cloud-outcome"></div>
</section>
</div>
</div>
<div class="caption">
<div><h2 id="shot-title">云端 Loop 接到开发需求</h2><p id="shot-text">你要给订单列表加状态筛选。云端进程读回 Session,准备下一次模型请求。</p></div>
<pre class="payload" id="payload">Session → Agent Loop
任务需求 + 已有会话记录</pre>
</div>
<p class="sr-only" id="notice" aria-live="polite"></p>
</section>
<section class="answers" aria-label="两个问题的答案">
<article>
<h2>工具和信道各管什么?</h2>
<p><strong>工具定义要做什么,信道负责把请求送过去。</strong>模型给出 <code>read_file</code> 和路径。云端 Loop 把它封成工具请求,发到这台 Mac 的 daemon。daemon 读本地磁盘,再沿连接把文件内容和调用 ID 回给 Loop。原仓库留在本机,返回的文件内容可以进入云端 Session 和下一次模型请求。</p>
</article>
<article>
<h2>只有这台 Mac,值得搬吗?</h2>
<p><strong>如果所有工作都依赖这台 Mac,而且你通常在开着电脑时才用 Agent,本地 Loop 更直接。</strong>云端能在你关终端或换设备后接收消息、回答已保存的结果、等待 Mac 重连。要在 Mac 离线时继续跑代码,得有另一台在线的执行机,且它得有任务所需的代码版本和环境。</p>
</article>
</section>
<footer class="sources">
<p>动画按 DSH §5 的部署设想演绎。WebSocket 是信道举例。此前实测使用真实 V4 Flash 网关,Loop 和 daemon 则在同一台 Mac 上以独立进程通过 HTTP 通信。动画时间为讲解节奏。</p>
<a href="agent-loop-and-execution.html">查看真实请求、进程号和 Git diff</a>
<a href="https://telegra.ph/Super-agent-%E8%AE%BE%E8%AE%A1%E6%8E%A2%E7%B4%A2%E8%BF%88%E5%90%91%E6%9B%B4%E8%87%AA%E4%B8%BB%E7%9A%84-Agent-%E7%B3%BB%E7%BB%9F-10-31-2">DSH 设计文章 §5(中文镜像)</a>
</footer>
</main>
<script>
(() => {
const shots = [
{
duration: 4.6, chapter: 0, route: ["session", "loop"],
title: "云端 Loop 接到开发需求", arrival: "装载会话",
text: "你要给订单列表加状态筛选。云端调度启动一个 Loop 进程,它读回 Session,准备下一次模型请求。",
payload: "Session → Agent Loop\n任务需求 + 已有会话记录"
},
{
duration: 4.5, chapter: 0, route: ["loop", "model"],
title: "Loop 组请求,模型做推理", arrival: "收到消息和工具定义",
text: "云端 Loop 把需求、会话消息和可用工具的定义发给 V4 Flash。Loop 和模型 API 各自运行。",
payload: "Agent Loop → 模型 API\nmessages + tools"
},
{
duration: 4.6, chapter: 0, route: ["model", "loop"],
title: "模型决定先读一个文件", arrival: "收到 read_file",
text: "V4 Flash 返回结构化的工具调用。此时只有函数名和参数,文件还没被读取。",
payload: "模型 API → Agent Loop\nread_file({ path: \"orders.mjs\" })"
},
{
duration: 5.2, chapter: 1, route: ["daemon", "loop"], link: true, tone: "local",
title: "Mac 先连上设备信道", arrival: "设备已登记",
text: "工具下发前,Mac 上的 daemon 已主动连到云端,并登记设备身份。这里用一条双向 WebSocket 举例。",
payload: "Mac daemon → 云端设备入口\n建立已鉴权的双向连接"
},
{
duration: 5, chapter: 1, route: ["loop", "daemon"], link: true,
title: "Loop 把工具请求发给这台 Mac", arrival: "收到路径和调用 ID",
text: "Loop 根据任务绑定的设备和工作区,经云端设备入口找到连接,把工具名、路径和调用 ID 发给 daemon。",
payload: "Agent Loop → 本机 daemon\n{ id: \"call-1\", tool: \"read_file\", path: \"orders.mjs\" }"
},
{
duration: 4.4, chapter: 1, route: ["daemon", "repo"], link: true, tone: "local",
title: "daemon 在本地磁盘上执行读取", arrival: "读取 orders.mjs",
text: "daemon 校验工作区范围,在 Mac 上打开 orders.mjs。它读的是当前工作区,包括尚未提交的内容。",
payload: "本机 daemon → Git 工作区\nfs.readFile(工作区内的 orders.mjs)"
},
{
duration: 4.8, chapter: 1, route: ["daemon", "loop"], link: true, tone: "local",
title: "文件内容沿同一条信道回云端", arrival: "收到源码",
text: "daemon 带着同一个调用 ID 返回源码。Git 仓库仍在 Mac,读出的文件内容现在也到了云端。",
payload: "本机 daemon → Agent Loop\ncall-1 · export function filterOrders(orders) { … }"
},
{
duration: 4.6, chapter: 1, route: ["loop", "model"], link: true,
title: "Loop 把工具结果放进下一次请求", arrival: "收到 tool_result",
text: "模型看见本机读出的源码,才能决定怎么改。这就完成了一轮「请求模型 → 执行工具 → 把结果给模型」。",
payload: "Agent Loop → 模型 API\nmessages + tool_result(源码)"
},
{
duration: 4.7, chapter: 1, route: ["loop", "daemon"], link: true, repo: "写入后有未提交改动",
title: "写代码和跑测试也走这个信道", arrival: "写入后启动测试",
text: "Loop 下发补丁和测试命令,daemon 在原工作区写入并启动测试。代码改动和工具进程都在 Mac。",
payload: "Agent Loop → 本机 daemon\nwrite_file / start_tests"
},
{
duration: 4.8, chapter: 1, route: ["loop", "session"], link: true, repo: "改动仍未提交",
title: "测试结果回传后,Session 记下结果", arrival: "保存退出码和摘要",
text: "daemon 把测试输出回传给 Loop。云端保存退出码和摘要,以后可以从记录里查到这次测试。",
payload: "Agent Loop → Session\n退出码 0 · 3 个测试通过"
},
{
duration: 5.6, chapter: 2, compare: "closed", tone: "wait",
title: "现在把 Mac 合盖",
text: "两种架构都碰不到这台 Mac 的工作区。本地 Loop 随电脑暂停,云端 Loop 还可以收到新消息。",
payload: "本地工作区离线\n两边都读不到当前文件"
},
{
duration: 6, chapter: 2, compare: "question", tone: "wait",
title: "从手机问:测试过了吗?再看一眼 diff",
text: "云端可以从 Session 回答「3 个已通过」。当前 diff 只能等 Mac 重连。本地 Loop 则要等 Mac 恢复才能处理。",
payload: "查以前的结果 · 云端可以\n读现在的文件 · 等 Mac 重连"
},
{
duration: 5.1, chapter: 2, compare: "resume", tone: "local",
title: "Mac 重连,云端把待办接着做",
text: "工作区恢复可达。云端收到设备上线事件,派发等着的 git diff,把结果回到手机。本地会话也可以恢复工作。",
payload: "设备重连 → 下发待办 → 读取本机 diff"
},
{
duration: 6.7, chapter: 2, compare: "verdict", tone: "local",
title: "只有这台 Mac,收益主要在任务管理",
text: "日常都是开着 Mac 改这一个仓库,本地 Loop 更直接。云端的价值是保持任务入口在线,或把可迁移的工作交给另一台有代码和环境的执行机。",
payload: "单机交互开发 · 本地更简单\n跨设备接续和调度 · 云端有用"
}
];
const byId = id => document.getElementById(id);
const diagram = byId("diagram");
const comparison = byId("comparison");
const canvas = byId("wire");
const ctx = canvas.getContext("2d");
const slider = byId("seek");
const chapters = [...document.querySelectorAll("[data-chapter]")];
const actors = [...document.querySelectorAll(".actor")];
const defaults = Object.fromEntries(actors.map(actor => [actor.id, actor.querySelector(".state").textContent]));
const quietMotion = matchMedia("(prefers-reduced-motion: reduce)");
let duration = 0;
const starts = shots.map(shot => {
const start = duration;
duration = Math.round((duration + shot.duration) * 10) / 10;
return start;
});
const chapterStarts = chapters.map((_, chapter) => starts[shots.findIndex(shot => shot.chapter === chapter)]);
let time = 0;
let shotIndex = -1;
let playing = false;
let frameId = 0;
let lastFrame = 0;
let geometry = null;
let arrived = false;
function compareState(id, title, description, status) {
const element = byId(id);
const label = document.createElement("strong");
const detail = document.createElement("span");
label.textContent = title;
detail.textContent = description;
element.dataset.state = status;
element.replaceChildren(label, detail);
if (!quietMotion.matches && playing) {
element.classList.remove("reveal");
requestAnimationFrame(() => element.classList.add("reveal"));
}
}
function renderComparison(scene) {
const result = scene === "question";
if (scene === "verdict") {
compareState("local-loop", "单机交互开发", "你开着 Mac,Agent 跟着你改代码。", "online");
compareState("local-files", "原工作区", "直接使用本机源码、改动和工具链。", "online");
compareState("local-outcome", "本地 Loop 更直接", "会话和工具都跟着开发机。", "online");
compareState("cloud-loop", "任务入口持续在线", "换到手机,也能接收任务、查询已有结果。", "online");
compareState("cloud-files", "依赖 Mac 的任务", "Mac 离线时等待重连。", "waiting");
compareState("cloud-outcome", "有其他在线执行机", "它有匹配的代码和环境时,可以接手对应任务。", "online");
return;
}
if (scene === "resume") {
compareState("local-loop", "Mac 上的 Loop", "电脑唤醒,可以恢复本地会话", "online");
compareState("local-files", "Mac 工作区", "在线,可以读当前文件", "online");
compareState("local-outcome", "本地操作", "在本地会话发起读取。", "online");
compareState("cloud-loop", "云端 Loop + Session", "收到设备重新上线事件", "online");
compareState("cloud-files", "Mac 工作区", "daemon 执行挂起的 git diff", "online");
compareState("cloud-outcome", "待办已完成", "当前 diff 回到手机。", "online");
return;
}
compareState("local-loop", "Mac 上的 Loop", "电脑休眠,Loop 暂停", "off");
compareState("local-files", "Mac 工作区", "离线,当前文件不可访问", "off");
compareState("local-outcome", "新消息",
result ? "等 Mac 恢复,才能处理测试和 diff 的追问。" : "等 Mac 恢复后处理。", "waiting");
compareState("cloud-loop", "云端 Loop + Session", "在线,可以接消息、查历史", "online");
compareState("cloud-files", "Mac 工作区", "离线,当前文件同样不可访问", "off");
compareState("cloud-outcome", result ? "3 个测试已通过" : "新消息",
result ? "当前 diff:等待 Mac 重连。" : "先接收消息,依赖文件的动作排队。", "waiting");
}
function updateGeometry() {
if (diagram.hidden || !ctx) return;
const area = diagram.getBoundingClientRect();
const scale = window.devicePixelRatio || 1;
canvas.width = Math.round(area.width * scale);
canvas.height = Math.round(area.height * scale);
ctx.setTransform(scale, 0, 0, scale, 0, 0);
const nodes = Object.fromEntries(actors.map(actor => {
const bounds = actor.getBoundingClientRect();
return [actor.id, {
left: bounds.left - area.left, right: bounds.right - area.left,
top: bounds.top - area.top, bottom: bounds.bottom - area.top,
x: (bounds.left + bounds.right) / 2 - area.left,
y: (bounds.top + bounds.bottom) / 2 - area.top
}];
}));
geometry = { width: area.width, height: area.height, nodes };
}
function ports(from, to) {
const a = geometry.nodes[from];
const b = geometry.nodes[to];
if (Math.abs(a.y - b.y) > 30) {
return a.y < b.y ? [[a.x, a.bottom], [b.x, b.top]] : [[a.x, a.top], [b.x, b.bottom]];
}
return a.x < b.x ? [[a.right, a.y], [b.left, b.y]] : [[a.left, a.y], [b.right, b.y]];
}
function line(start, end, color, width, dashed = false) {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.setLineDash(dashed ? [4, 4] : []);
ctx.beginPath();
ctx.moveTo(...start);
ctx.lineTo(...end);
ctx.stroke();
ctx.setLineDash([]);
}
function arrow(tip, angle, color, size) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(...tip);
ctx.lineTo(tip[0] - size * Math.cos(angle - .45), tip[1] - size * Math.sin(angle - .45));
ctx.lineTo(tip[0] - size * Math.cos(angle + .45), tip[1] - size * Math.sin(angle + .45));
ctx.closePath();
ctx.fill();
}
function draw(shot, progress) {
if (!geometry || !shot.route || !ctx) return;
ctx.clearRect(0, 0, geometry.width, geometry.height);
if (shot.link) {
const [top, bottom] = ports("loop", "daemon");
line(top, bottom, "#7494a1", 2, true);
}
const [start, end] = ports(...shot.route);
const color = shot.tone === "local" ? "#a2e6be" : "#a5d2ff";
line(start, end, "#587888", 2);
const travel = quietMotion.matches ? 1 : Math.max(.08, Math.min(1, progress / .72));
const x = end[0] - start[0];
const y = end[1] - start[1];
const length = Math.hypot(x, y);
const angle = Math.atan2(y, x);
const tip = [start[0] + x * travel, start[1] + y * travel];
line(start, tip, color, 3);
arrow(tip, angle, color, 8);
if (travel < 1) {
const size = Math.min(20, length * .45);
const offset = Math.max(size / 2, Math.min(length - size / 2, travel * length));
const center = [start[0] + offset * Math.cos(angle), start[1] + offset * Math.sin(angle)];
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(center[0] - size / 2, center[1] - size / 2, size, size, 4);
ctx.fill();
if (size >= 14) {
ctx.fillStyle = "#0c222c";
ctx.font = "700 11px monospace";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("··", center[0], center[1]);
}
}
const complete = travel >= 1;
if (complete !== arrived) {
arrived = complete;
const receiver = byId(shot.route[1]);
if (complete) receiver.dataset.active = "receiver";
else delete receiver.dataset.active;
const idle = receiver.id === "repo" && shot.repo ? shot.repo : defaults[receiver.id];
receiver.querySelector(".state").textContent = complete ? shot.arrival : idle;
}
}
function activate(index, announce) {
shotIndex = index;
const shot = shots[index];
byId("shot-title").textContent = shot.title;
byId("shot-text").textContent = shot.text;
const payload = byId("payload");
payload.textContent = shot.payload;
payload.dataset.tone = shot.tone || "";
chapters.forEach((button, position) => button.setAttribute("aria-pressed", String(position === shot.chapter)));
diagram.hidden = Boolean(shot.compare);
comparison.hidden = !shot.compare;
if (shot.compare) {
renderComparison(shot.compare);
} else {
byId("channel").textContent = shot.link ? "双向设备信道" : "云端 / 本机边界";
for (const actor of actors) {
delete actor.dataset.active;
actor.querySelector(".state").textContent = actor.id === "repo" && shot.repo ? shot.repo : defaults[actor.id];
}
byId(shot.route[0]).dataset.active = "sender";
arrived = false;
updateGeometry();
}
if (announce) byId("notice").textContent = shot.title;
}
function timestamp(seconds) {
const whole = Math.floor(seconds + .001);
return Math.floor(whole / 60) + ":" + String(whole % 60).padStart(2, "0");
}
function render(position, announce = false) {
time = Math.max(0, Math.min(duration, position));
const index = starts.findLastIndex(start => start <= time);
if (index !== shotIndex) activate(index, announce);
slider.value = time;
byId("clock").textContent = timestamp(time) + " / " + timestamp(duration);
const progress = (time - starts[index]) / shots[index].duration;
draw(shots[index], progress);
}
function pause() {
playing = false;
cancelAnimationFrame(frameId);
byId("play").textContent = time >= duration ? "↻ 重播" : "▶ 播放";
}
function tick(now) {
if (!playing) return;
render(time + (now - lastFrame) / 1000);
lastFrame = now;
if (time >= duration) pause();
else frameId = requestAnimationFrame(tick);
}
byId("play").addEventListener("click", () => {
if (playing) {
pause();
return;
}
if (time >= duration) render(0, true);
playing = true;
byId("play").textContent = "Ⅱ 暂停";
lastFrame = performance.now();
frameId = requestAnimationFrame(tick);
});
slider.addEventListener("input", () => {
render(Number(slider.value), true);
if (playing) lastFrame = performance.now();
else pause();
});
chapters.forEach((button, index) => button.addEventListener("click", () => {
render(chapterStarts[index], true);
if (playing) lastFrame = performance.now();
else pause();
}));
new ResizeObserver(() => {
updateGeometry();
render(time);
}).observe(diagram);
slider.max = duration;
byId("controls").hidden = false;
byId("chapters").hidden = false;
render(0);
})();
</script>
</body>
</html>