一个抖音遥控器的开发小记

最近折腾了一个很无聊的东西:给抖音做了一个遥控器。

需求也很简单,我想躺在客厅沙发上,用电视刷抖音。

手机当然可以刷,但电视屏幕更大,声音也好很多。电脑也能刷,不过既然已经躺在沙发上了,再抱着一台电脑显然有点奇怪。我家的电视接了一台 Apple TV,书房里还有一台常年开着的 Mac mini,所以我最开始想的只是怎么把这几样东西拼起来。

一开始最直接的办法是 iPhone AirPlay。

我用的是 iPhone 13,抖音直接投到 Apple TV 没什么问题,刷一会儿以后问题就出来了:手机很热,长时间投屏还会卡,而且基本得一直充电。我甚至认真想过给手机弄一个无线充电支架,再配个散热设备,把它固定在那里专门负责播放和投屏。

然后问题从「怎么在电视上刷抖音」变成了「怎么远程控制一台放在那里不能动的 iPhone」。

苹果在 Accessibility 上做的东西比我以前想象得多得多。我先试了 Switch Control,后来又研究 Voice Control。Voice Control 甚至让我看到了一条理论上真能走通的路:让 Mac 播放一句「向上滑」,iPhone 自己听到以后再通过 Voice Control 去操作抖音。

做到这里我觉得事情已经有点离谱了。

我最开始只是想切下一条视频,现在变成一台电脑对着一台手机说话,然后手机听懂以后帮我滑一下屏幕。

中间还想过小爱同学。语音其实很适合这个场景,说一句「下一条」比找遥控器还自然。理论上这条路能走:小爱触发一个公网 Endpoint,再把请求落到 Mac mini。只是为了三个动作再维护 Worker、Token 和鉴权有点过头,所以先放着。以后真懒得拿手机再说。

真正让事情突然简单下来的是,我后来才发现 macOS 居然有官方抖音 App。

于是 iPhone 可以完全退出播放链路:

flowchart LR
    Mac["Mac mini<br/>Douyin.app"]
    ATV["Apple TV"]
    TV["Television"]

    Mac -->|"AirPlay"| ATV
    ATV --> TV

Mac mini 在书房跑抖音,把屏幕镜像到 Apple TV,电视负责最终的画面和声音。

现在剩下的问题只有一个:我躺在沙发上的时候,怎么控制书房里的 Mac mini?

Apple TV 遥控器当然控制不了正在投屏的 Mac。无线鼠标可以,但躺着拿鼠标找光标刷短视频的体验也没多好。其实我真正需要的只有三个动作:

Previous
Play / Pause
Next

如果只看最终需求,这东西大概十分钟就能设计完。

手机浏览器里放三个大按钮,Mac mini 上起一个 HTTP Server,收到请求以后发几个键盘事件,结束。

Phone
  ↓
HTTP / WebSocket
  ↓
Mac mini
  ↓
Keyboard Event
  ↓
Douyin

这大概也是专门做一个抖音遥控器最合理的实现。

但我那段时间刚好一直在折腾 Free4Chat,于是这三个按钮最后走上了一条明显有点过度设计的路。

如果目标只是做这个遥控器,前面的 HTTP Server 才是更合理的工程方案。后面真正想验证的是另一件事:一个本地能力能不能被 Agent 接入,再临时带进 Room,最后让别的 Human 直接使用。

先把 Codex 拉进房间

Free4Chat 最早只是我很多年前做的一个 WebRTC 匿名聊天室。之前写过一篇《一个 WebRTC 聊天室的四次演进》,当时它已经开始加入 Agent,不过和现在又不太一样了。

现在我使用 Free4Chat 的方式,经常不是打开一个 Chat 页面问 Agent 一个问题,而是先开一个 Room,把真正运行在某台机器上的 Agent 接进来。

这次也是一样。

Mac mini 上运行着 free4chat-agent,我把 Codex 作为一个 Agent Participant 接入 Room,然后在房间里给它创建一个 Task,把需求直接丢过去:这台机器已经装好了抖音,我想在手机上有一个遥控器,可以控制上一条、下一条和播放暂停。

后面基本就让它自己干了。

这里的 Task 和普通的一轮 Prompt 不太一样。它更像一次有生命周期的 Agent 工作。我可以选择 Agent 使用的模型和执行方式,可以离开 Task 去做别的事情,也可以过一会儿回来继续看。Codex 需要本机权限的时候,会把 Permission Request 交回来,我可以 Approve 或 Reject;方向不对可以继续 Steer,真跑偏了可以 Interrupt。过程中它可以持续输出执行状态,最后交付的也不一定只是几行文字,可以是 Artifact、Live View,也可以是一个真正能操作的 Task App。

从 Human 的角度看,大概是这样:

sequenceDiagram
    participant H as Human
    participant R as Free4Chat Room / Task
    participant A as Codex
    participant RT as Agent Runtime
    participant OS as macOS

    H->>R: Create Room
    H->>R: Attach Agent
    R->>A: Start / Resume Agent Session

    H->>R: Create Task
    R->>A: Deliver Task Context

    A->>RT: Inspect local environment
    RT->>OS: Read / operate local system
    OS-->>RT: Result
    RT-->>A: Result

    A->>R: Permission Request
    R-->>H: Approve / Reject
    H->>R: Approve
    R->>A: Continue

    A->>RT: Build and register Adapter
    A->>R: Publish Artifact / Live View / Task App
    R-->>H: Interactive result ready

如果把「Agent」只理解成一次 LLM 调用,这套东西看起来会显得很重。但当 Agent 真的开始独立工作几十分钟、几个小时,甚至 Human 中途离开以后,问题就完全不一样了。

谁创建这个 Session?怎么恢复?Agent 当前是在 Thinking、Running 还是 Waiting for Approval?权限请求属于哪一次执行?Human 怎么取消?怎么继续发新的指令而不是重新开一个会话?Agent 完成以后交出来的文件、预览和 UI 又属于哪个 Task?

这些东西和模型聪不聪明没太大关系,都是控制问题。

所以我一直对 ACP(Agent Client Protocol)这一类协议很感兴趣。它关注的不是 Agent 怎么调用一个工具,而是 Client / Host 怎么控制一个 Agent:Session 创建和恢复、Streaming Update、Permission、Cancellation 等。

MCP 则在另外一侧。它解决的是 Agent 怎么发现和使用外部工具。

粗略画一下,它们关心的边界其实完全不同:

flowchart LR
    Human["Human / Host"]
    Agent["Agent<br/>Codex / Claude"]
    Tools["Tools / MCP Servers"]
    World["External Systems"]

    Human -->|"Session / Streaming / Permission / Cancel<br/>ACP concerns"| Agent
    Agent -->|"Tool discovery / invocation<br/>MCP concerns"| Tools
    Tools --> World

Free4Chat 不需要把自己包装成这些协议的另一种实现,但当 Agent 真正进入一个多人实时 Room 以后,这些协议正在解决的问题会自然叠到一起。

因为现在不只是 Human 在控制 Agent。

Room 里可以同时有多个 Human、多个 Agent、多个 Task,还有每个 Agent 自己带进来的工具和本地能力。Agent 之间会出现寻址、协作和任务交接的问题,这又和 A2A(Agent-to-Agent)关心的问题重合。Agent 最后怎么把结果变成 Human 能直接操作的界面,又会碰到 A2UI 这一类 Agent-to-UI 的问题。

抖音遥控器只是一个很小的入口,但它刚好把这些东西都串起来了。

Agent 先给自己做了一个本地能力

Codex 接到 Task 以后,先研究怎么控制 macOS 上的抖音。

最后其实没有什么黑科技,也没有逆向抖音协议。macOS 版抖音本身就支持键盘操作,所以底层最后还是 Accessibility 和 Keyboard Event。

但我不希望 Runtime 看到的是:

pressArrowDown()
pressSpace()
pressArrowUp()

更不希望 Free4Chat Core 里出现一个 DouyinIntegration。

最后 Agent 做的是一个很薄的本地 Adapter,对外只暴露几个语义动作:

douyin_remote
├── previous
├── play_pause
└── next

下面这个 JSON 只是为了表达大概的意思,不是当前协议的完整 Schema:

{
  "id": "douyin_remote",
  "actions": [
    { "name": "previous" },
    { "name": "play_pause" },
    { "name": "next" }
  ]
}

Runtime 只需要知道这里有一个 Capability,可以列出来、描述、调用,必要时观察状态。

至于这个 Capability 底下到底是 Accessibility、AppleScript、CUPS、串口、局域网 HTTP、厂商 SDK,还是以后某个 ESP32,Runtime 不需要理解。

我后来把这条边界概括成一句很简单的话:

Runtime knows capabilities, not integrations.

真正和厂商、设备、操作系统协议打交道的是 Adapter。

flowchart TB
    Runtime["Agent Runtime<br/>Generic Capability RPC"]
    Adapter["External Adapter<br/>douyin_remote"]
    OS["macOS<br/>Accessibility / Keyboard"]
    App["Douyin.app"]

    Runtime -->|"describe / invoke / observe"| Adapter
    Adapter --> OS
    OS --> App

这个区别看起来很小,但它决定了 Free4Chat 会不会慢慢变成另一个 Home Assistant,或者另一个 Zapier。

如果每接一个东西都往 Core 里写 Integration,打印机一套、抖音一套、Bilibili 一套、智能灯再一套,最后 Runtime 必须知道所有厂商协议,Core 还得帮大家保存各种 Credential。

我不太想走这条路。

我的想法更像是:能力属于 Participant,自定义 Integration 也属于 Participant。Free4Chat 只需要知道当前 Room 里某个 Participant 愿意临时暴露哪些能力。

之前我拿一台 Epson 打印机做过类似实验。底层 Adapter 走的是本机 CUPS,但对 Runtime 来说它只是一项类似 printer_status 的语义能力。

打印机和抖音看起来完全没有关系,到了 Runtime 这一层其实是一样的:

printer_status
douyin_remote

一个读打印机状态,一个控制本地 App。

至于后面是硬件还是软件,对 Room 不重要。

然后 Codex 给这个能力做了一个 App

到这里 Agent 自己已经能控制抖音了。

我可以在 Room 里告诉它「下一条」,它调用 douyin_remote.next,书房里的抖音就真的会切换。

但这显然不是一个遥控器。

如果每看完一条视频都要给 Codex 发一句 next,还不如继续研究小爱同学。

所以我继续让它在同一个 Task 里做手机界面。

Free4Chat 现在允许 Agent 给 Task 生成一个临时 App。Codex 根据刚刚注册好的 douyin_remote,最后生成的东西非常简单。

全屏以后大概就是:

┌────────────────────┐
│                    │
│      Previous      │
│                    │
│    Play / Pause    │
│                    │
│        Next        │
│                    │
└────────────────────┘

这就是整个项目最终留在手机屏幕上的东西。

三个按钮。

我在 iPhone 上进入同一个 Room,打开这个 Task App,按下 Next,电视上的视频切到了下一条。实际用起来就是普通遥控器的感觉,没有「等 Codex 想一下」这一步。后来我又让第二个 Human 设备加入同一个 Room,同一个 Task App 也可以控制 Mac mini,App 的共享状态会在两个端之间同步。

到这里,这个需求其实已经完成了。手机上还是三个按钮,我点一下,电视上的抖音就动一下。

如果文章写到这里就结束,这大概只是一个「我让 AI 帮我写了一个网页」的故事。但如果顺着这个 Next 按钮继续往下看,事情就没这么简单了。

按下 Next 以后到底发生了什么

先看完整链路。

flowchart TB
    subgraph Phone["iPhone / Browser"]
        App["Generated Task App<br/>Previous / Play / Next"]
        Host["Room App Host"]
        App --> Host
    end

    subgraph Room["Free4Chat Room"]
        Presence["Presence / Identity"]
        Task["Task State"]
        Permission["Authorization"]
        Projection["Capability Projection"]
        AppState["App Metadata / Shared State"]
    end

    subgraph Mac["Mac mini / Agent Participant"]
        Runtime["Agent Runtime"]
        Router["Capability Router"]
        Adapter["douyin_remote Adapter"]
        Accessibility["macOS Accessibility / Keyboard"]
        Douyin["Douyin.app"]

        Runtime --> Router
        Router --> Adapter
        Adapter --> Accessibility
        Accessibility --> Douyin
    end

    Host -. "Room state / authorization" .-> Room
    Host == "Reliable WebRTC DataChannel" ==> Runtime

    Douyin -->|"AirPlay"| ATV["Apple TV"]
    ATV --> TV["Television"]

这个图里最容易让人误解的一点,是中间那个 Room。

看到一个云端 Room,直觉上很容易认为按钮调用是这样走的:

iPhone
  ↓
Cloudflare Worker
  ↓
Durable Object
  ↓
Mac mini

其实现在不是。

Room 的确是这个协作空间的状态中心,但真正的 Participant-local Capability 调用不需要让 Room Durable Object 充当 RPC Proxy。

这里实际上有两条不同的路径。

一个 Room 里有两条路

Free4Chat 本来就是一个实时协作系统,所以一直有一条控制面的连接。

Human 加入或离开,Room 里有哪些 Participant,Task 当前是什么状态,Agent 是否还在工作,Generated App 是什么,Capability 从哪个 Participant 投影出来,这些都是 Room 级别的状态。

浏览器和 Room 之间会通过 WebSocket 维护这类状态,Durable Object 负责这个房间的协调。

但按下 Next 是另外一回事。

它最终要触碰的是 Mac mini 上的本地能力。如果每一个本地调用都:

Browser
→ Worker
→ Durable Object
→ Agent
→ Runtime
→ Adapter

那 Room 很快就会变成一个万能远程调用代理。

这会带来几个我不太喜欢的结果:所有 Participant-local 数据都要绕云端;实时调用的延迟和 Worker 成本都会增加;Core 开始承担本来属于 Participant 的 Integration 责任;更麻烦的是,本地 Credential 和隐私边界也容易慢慢向中心移动。

所以现在 Free4Chat 把这两件事拆开了。

flowchart TB
    subgraph CP["Control Plane"]
        WS["Room WebSocket"]
        DO["Room Durable Object"]
        Presence["Presence / Identity"]
        Tasks["Task Lifecycle"]
        Perm["Permissions"]
        Apps["App Metadata / Shared State"]
        Cap["Capability Projection"]

        WS --> DO
        DO --> Presence
        DO --> Tasks
        DO --> Perm
        DO --> Apps
        DO --> Cap
    end

    subgraph DP["Participant Data Plane"]
        Browser["Human Browser"]
        DC["Reliable WebRTC DataChannel"]
        Agent["Agent Participant"]
        Runtime["Agent Runtime"]
        Adapter["Local Adapter"]

        Browser --> DC
        DC --> Agent
        Agent --> Runtime
        Runtime --> Adapter
    end

    CP -. "identity / addressing / authorization" .-> DP

Control Plane 管的是「谁和谁现在可以协作」。

Data Plane 才负责真正的数据。

这里的 participant-direct 不是说浏览器和 Mac mini 一定做纯 P2P 直连,它仍然建立在 Free4Chat 当前的 WebRTC / SFU participant transport 上。所谓 direct,更多是说这个调用属于 Human Participant 和目标 Agent Participant 之间的可靠通道,不需要再绕 Room DO 做应用层中转。

对于一个 next 来说,这个区别当然感觉不到。

但当以后本地能力变成实时控制、连续状态同步、硬件传感器,或者 Room 里同时出现很多 Participant 时,这条边界会越来越重要。

Free4Chat 最早做 WebRTC 时,我考虑的是音频媒体怎么在 Participant 之间流动。现在 Agent 加进来以后,DataChannel 开始承担另一类东西:可靠的机器数据和本地能力调用。

聊天室和 Agent Runtime 就这样慢慢连到了一起。

Agent 不在遥控器的运行时里

还有一个我比较在意的设计是:点击 Next 以后,不应该再调用一次 Codex。

如果路径是:

Human Click
   ↓
LLM
   ↓
理解“Next”
   ↓
选择 Tool
   ↓
Capability Invoke

那做遥控器就非常奇怪。

模型推理有延迟,有成本,还不是完全确定性的。一个三个按钮的遥控器没有理由每按一次都重新让模型思考。

所以这次其实有两个完全不同的阶段。

flowchart TB
    subgraph Build["Build Time - Agent involved"]
        Req["Human Requirement"]
        Agent["Codex"]
        Inspect["Inspect Environment"]
        BuildAdapter["Build Adapter"]
        Register["Register Capability"]
        Generate["Generate Task App"]

        Req --> Agent
        Agent --> Inspect
        Agent --> BuildAdapter
        BuildAdapter --> Register
        Agent --> Generate
    end

    subgraph Run["Run Time - deterministic hot path"]
        Human["Human Click"]
        App["Task App"]
        Host["Room App Host"]
        DC["Reliable DataChannel"]
        Runtime["Agent Runtime"]
        Adapter["douyin_remote"]
        Action["Accessibility / Keyboard"]

        Human --> App
        App --> Host
        Host --> DC
        DC --> Runtime
        Runtime --> Adapter
        Adapter --> Action
    end

    Register --> Runtime
    Generate --> App

Codex 负责把 Integration 和 UI 做出来。

东西做完以后,Agent 应该退出 Runtime Hot Path。

Human 每一次点击都只是普通的确定性 RPC。

这个设计和我最近在其他 Agent 项目里越来越常用的一种思路其实很像:让模型负责模糊的、高层的、一次性的生成和决策,让传统程序负责高频、确定、可验证的执行。

只不过这次这个分界非常直观。

模型最后留下来的就是三个按钮。

一个 Agent 生成的 App 为什么可以运行

Generated Task App 还有另外一个问题。

让 Agent 生成 HTML 并不困难。现在随便一个 Coding Agent 都可以十几秒写一个遥控器界面。

麻烦的是,我为什么敢运行它?

如果一个 Agent 生成的 JavaScript 可以直接拿到 Room 的内部对象、Agent Credential、本机 Runtime,甚至任意调用系统能力,那 Generated App 越强,安全问题就越大。

所以 Task App 不是「Agent 生成一段 JS,然后浏览器随便执行」。

中间还有一个 App Host。

flowchart TB
    Generated["Agent Generated App"]
    Sandbox["Sandboxed App Surface"]
    Host["Room App Host"]
    State["Bounded Shared State API"]
    Cap["Bounded Capability API"]
    Room["Room Context"]

    Generated --> Sandbox
    Sandbox --> Host
    Host --> State
    Host --> Cap
    Host --> Room

App 运行在一个受限的 Host Contract 里面。

它不应该知道 Mac mini 上的 Adapter 怎么启动,也不应该直接拥有本地系统权限。它看到的是 Host 暴露出来的有限能力,例如共享状态,或者对已经授权 Capability 的调用。

像这次 douyin_remote.next 这种会在真实机器上产生 Side Effect 的操作,调用还需要遵循 Human Interaction 的安全边界。Agent 可以生成那个按钮,但不等于它生成的 App 就可以在后台悄悄无限触发本地动作。

这个区别在一个抖音遥控器里有点小题大做,但如果把 next 换成:

open_door
start_printer
move_robot
delete_file

事情一下就不一样了。

所以对 Generated UI 来说,<button> 本身没什么难度,难的是怎么把它放进一个可控的执行边界里。

Text、Artifact、Live View 和 Task App

做这个遥控器的时候,我对 Agent 最后到底应该「交付什么」也有了更直观的感觉。

Chat 产品很容易默认 Agent 的最终结果就是一条 Message。

但真正做 Task 时,Message 只是最简单的一种输出。

如果 Codex 做的是一个设计文档,Artifact 更自然;如果它正在跑一个需要 Human 观察的程序,Live View 更适合;如果最后的结果需要 Human 继续操作,那就需要一个 App。

flowchart LR
    Agent["Agent Task"]
    Text["Text"]
    Artifact["Artifact"]
    Live["Live View"]
    App["Generated Task App"]
    Human["Human"]

    Agent --> Text --> Human
    Agent --> Artifact --> Human
    Agent --> Live --> Human
    Agent --> App --> Human

这几个东西看起来只是 UI 形态不同,实际上代表 Human 和 Agent 之间不同的协作关系。

Text 是「告诉我结果」。

Artifact 是「把结果交给我」。

Live View 是「让我看到你正在做什么」。

Task App 则是「把一个可以继续操作的东西交给我」。

抖音遥控器显然属于最后一种。

我对 A2UI(Agent-to-UI)感兴趣的也不是「LLM 生成一个 React 页面」,Coding Agent 本来就很擅长写前端。真正有区别的是,这个 UI 不再是一个孤立页面,而是和当前 Task、权限、实时状态、本地 Capability 连在一起,成了 Agent 系统里 Human 真正要操作的那一面。

这次三个按钮背后控制的不是 Mock API,而是一台真的放在书房里的 Mac mini。

ACP、MCP、A2A、A2UI 最后碰到了一起

前面已经提过 ACP 和 MCP。把视角再拉开一点,一个 Room 里同时有 Human 和多个 Agent 以后,A2A、A2UI 关心的问题也会自然出现。

这里我更关心的是四条边界:Host 怎么控制 Agent,Agent 怎么使用 Tool,Agent 之间怎么协作,以及 Agent 最后怎么把结果交给 Human 操作。到了一个真实的多人实时 Room 里,它们很难永远待在互不相干的框里。

flowchart TB
    Room["Free4Chat Room<br/>temporary collaboration boundary"]

    HumanA["Human A"]
    HumanB["Human B"]
    AgentA["Codex"]
    AgentB["Another Agent"]

    MCP["MCP / Tools"]
    Runtime["Participant Runtime"]
    Adapter["Local Adapter"]
    UI["Live View / Generated App"]

    HumanA --> Room
    HumanB --> Room
    Room -->|"Agent session / permission / interrupt<br/>ACP problem space"| AgentA
    Room -->|"Agent lifecycle"| AgentB

    AgentA <-->|"Agent collaboration / handoff<br/>A2A problem space"| AgentB

    AgentA -->|"Tool access"| MCP
    AgentA --> Runtime
    Runtime --> Adapter

    AgentA -->|"Interactive result<br/>A2UI problem space"| UI
    UI --> HumanA
    UI --> HumanB

这里我故意写 problem space,因为 Free4Chat 没必要宣称自己就是这些标准协议的完整实现。

我更关心的是这些协议背后的系统问题。

当一个 Agent 不再只是嵌在网页里的 Chat Bot,而是一个有 Session、有生命周期、能自己执行任务的 Participant,Host 就必须控制它,这会碰到 ACP 的问题。

当 Agent 需要使用外部软件和数据,就会碰到 MCP 的问题。

当一个 Room 里出现两个 Agent,它们开始共享 Task、交换上下文或者接手工作,就会碰到 A2A 的问题。

当 Agent 最后不只是说「做完了」,而是交出一个 Human 可以直接操作的界面,就会碰到 A2UI。

Free4Chat 做的事情更像在这些边界外面再套一个实时协作空间。

Human、Agent、Task、App 和 Capability 都有自己的生命周期,但最后要在同一个 Room 里工作。

到了这个阶段,再叫它一个「AI 聊天室」已经不太准确了。

Room 是一个临时的信任边界

遥控器做完后还有个顺手得到的效果:它并不属于我的 iPhone。

我用自己的手机进 Room 可以控制。

换另外一台设备也可以。

同一个 Room 里的另一个 Human,如果有权限,也可以打开同一个 Task App 去控制 Mac mini。

但这不代表 douyin_remote 被复制到了他们的手机,更不代表我把 Mac mini 变成了一个永久公开的 HTTP API。

几个东西其实属于不同的 Owner:

Capability    → Mac mini Participant
Adapter       → Mac mini / Agent
Task App      → Task
Authorization → Room
Human         → 临时使用 Capability

能力一直留在 Mac mini。

Adapter 也只在 Mac mini 本地运行。

Room 只是知道:当前这个 Agent Participant 投影出了什么 Capability,当前这些 Human 在这个临时协作空间里是否可以使用它。

Task 结束、Participant 离开或者 Room 消失以后,这个临时关系也就结束了。

所以我现在越来越倾向把 Room 理解成一个 Trust Boundary。

它不是 Integration 的 Owner,也不应该成为本地 Credential 的仓库。

flowchart LR
    Mac["Mac mini Participant<br/>owns capability"]
    Agent["Agent<br/>projects capability"]
    Room["Room<br/>scopes trust"]
    App["Task App<br/>provides UI"]
    Human["Human<br/>temporarily uses"]

    Mac --> Agent
    Agent --> Room
    Room --> App
    App --> Human

这对抖音当然没那么重要。

但如果以后接的是公司内网、家里的智能设备、打印机、本地文件或者其他不能直接暴露到公网的东西,这个模型会比「给每个东西做一个云端 Integration」舒服很多。

三个按钮下面到底压了多少状态

前面的图已经很多了,但还有一个地方特别容易被忽略。

从用户角度,这个遥控器的状态非常少。

最多就是:

Ready
Unavailable

然而要让 Next 真的工作,背后至少同时存在很多不同生命周期的状态:

Room membership
Human session
Agent participant session
Agent harness session
Task lifecycle
Task execution state
Permission state
Generated App lifecycle
Room App Host state
Shared App state
Room WebSocket
WebRTC PeerConnection
Publisher / Subscriber session
Reliable DataChannel
Capability projection
Capability authorization
Runtime transport
Adapter process
macOS Accessibility permission
Douyin application state
AirPlay session

这些状态还不属于同一个进程。

浏览器有浏览器的状态,Room Durable Object 有 Room 状态,SFU 有 Participant transport,Agent Runtime 有自己的 Session 和 Capability,Adapter 是本地进程,操作系统还有自己的 Accessibility 权限。

所以一个按钮真正依赖的更像是:

flowchart TB
    Join["Human joined Room"]
    Task["Task / App available"]
    Agent["Agent participant ready"]
    Projection["Capability projected"]
    Host["App Host mounted"]
    RTC["WebRTC participant transport ready"]
    DC["Reliable DataChannel ready"]
    Auth["Capability authorized"]
    Runtime["Runtime route ready"]
    Adapter["Adapter reachable"]
    OS["OS permission valid"]
    Action["Local action"]
    TV["TV picture changes"]

    Join --> Task
    Task --> Agent
    Agent --> Projection
    Projection --> Host
    Host --> RTC
    RTC --> DC
    DC --> Auth
    Auth --> Runtime
    Runtime --> Adapter
    Adapter --> OS
    OS --> Action
    Action --> TV

任何一个条件不成立,Human 最后看到的可能都只是一个按钮不可用。

麻烦也就在这里。

状态不是越多越高级,恰恰相反,能删当然应该删。但很多边界是客观存在的:Human 和 Agent 是不同 Participant,浏览器和 Runtime 是不同进程,Room 和本地机器不在一个网络,Task 和 Agent Session 也不是同一个生命周期。

把文件拆得再漂亮,也不会让这些状态消失。

最后要搞清楚的还是每个状态属于谁,谁负责推进下一次转换,失败以后谁负责恢复。

Free4Chat 最初做 WebRTC 时已经有一堆这样的状态:ICE、PeerConnection、Publisher、Subscriber、房间信令。

Agent 进来以后,又叠上 Task、Permission、Harness Session、Capability 和 Generated App。

所以现在看上去只是一个 Room,实际上越来越像一个小型的分布式实时协作系统。

这部分才是那三个按钮下面最麻烦的东西。

Accessibility 又绕了回来

做到最后再回头看,最开始折腾 Switch Control 和 Voice Control 倒也不算完全跑偏。

苹果几十年来投入很多资源做 Accessibility,原本的目标当然是让那些不能正常使用鼠标、键盘、触摸屏的人也能操作软件。

结果 Agent 出现以后,这些能力突然多了一类完全没有预料到的使用者。

Agent 也没有手。

对于一个没有 API 的桌面软件,它同样需要知道当前界面上有什么、什么东西可以操作、怎么触发一个动作、操作以后发生了什么。

Browser 有 DOM。

Terminal 有 Shell。

成熟的 SaaS 有 API 和 MCP。

但大量 Desktop App 什么都没有。

这时候 Accessibility Tree、Keyboard Event、UI Automation 就成了机器理解 GUI 的一个现成入口。

这也解释了为什么现在各种 Computer Use Agent 可以去操作 Finder、浏览器、IDE,甚至很多老软件。

这些接口最早不是为 AI 设计的,但它们解决的问题恰好有一部分重合:传统的 GUI 输入方式不是唯一的输入方式。

这次抖音遥控器最后根本没用到多复杂的 Computer Vision,几个键盘事件就够了。

但它从 Switch Control 开始,最后又绕回 Accessibility,我觉得还挺有意思。

从抖音到 B 站,再到打印机

抖音跑通以后,我第一反应是 B 站其实也可以这么搞。

电视端的 Bilibili 和正常的手机、电脑体验并不完全一样,我自己更习惯桌面端。既然 Mac mini 已经可以负责播放和 AirPlay,那手机仍然可以只做一个遥控器。

不同的只是 Capability:

douyin_remote
├── previous
├── play_pause
└── next

bilibili_remote
├── previous
├── play_pause
├── next
├── fullscreen
└── favorite

再往前那个打印机实验:

printer_status
├── state
└── accepting_jobs

这几个东西放在一起以后,抖音本身反而没那么重要了。

flowchart TB
    Runtime["Participant Runtime<br/>Capability Protocol"]

    Douyin["douyin_remote<br/>macOS App"]
    Bilibili["bilibili_remote<br/>macOS App / Web"]
    Printer["printer_status<br/>CUPS"]
    Device["future device<br/>ESP32 / Home Automation"]

    Runtime --> Douyin
    Runtime --> Bilibili
    Runtime --> Printer
    Runtime --> Device

复用的其实不是「怎么按抖音的下一条」,而是这一整条路径:

Local Integration
      ↓
Semantic Capability
      ↓
Participant Runtime
      ↓
Temporary Room
      ↓
Agent-generated Human Interface

所以抖音本身没那么重要。Free4Chat 这里更想做的,也不是把所有东西先变成一个中央平台支持的 Integration。

Agent 可以就在 Capability 旁边。

打印机在本地,Agent 在本地。

抖音在 Mac 上,Agent 也在 Mac 上。

以后一个 ESP32 只在局域网里能访问,也可以由旁边的 Participant 负责接入。

Room 做的是在需要协作的时候,把这些 Human、Agent 和 Capability 临时接起来。

再回到那个 Next

如果把前面所有东西压回去,最终用户看到的还是这个:

┌────────────────────┐
│                    │
│      Previous      │
│                    │
│    Play / Pause    │
│                    │
│        Next        │
│                    │
└────────────────────┘

我按下 Next。

Browser 里的 Generated Task App 通过受限的 Host API 发起 Capability Invoke;Room 已经建立好 Human、Task 和 Agent 之间的授权关系;Browser 通过可靠的 WebRTC DataChannel 把请求发给 Mac mini 上对应的 Agent Participant;Runtime 根据 Capability Projection 找到 douyin_remote;Adapter 把语义动作转换成 macOS 本地输入;抖音切到下一条;Mac mini 继续通过 AirPlay 把结果送到 Apple TV。

电视画面变了。

这件事从手机上看,大概不到一秒,也不需要再经过一次 LLM。

如果只是为了实现这三个按钮,我肯定不会专门设计这么一套东西。一个 HTTP Server 明显简单得多。

但 Free4Chat 本来就在做 Human、Agent、Task、实时通信和本地 Capability,所以这个看起来有点傻的需求刚好把整条链路从头到尾跑了一遍。

也让我第一次比较直观地看到,以前分开做的那些东西——WebRTC、Task、Permission、Live View、Generated App、Runtime、Adapter——最后是怎么连起来的。

现在我躺在沙发上刷抖音的时候当然不会想这些。

手机上还是只有三个按钮。