621 lines
30 KiB
HTML
621 lines
30 KiB
HTML
<!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>
|