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,620 @@
<!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>