原文:When AI builds itself — Anthropic Institute 作者:Marina Favaro, Jack Clark | 发布于 2026 年 6 月
Anthropic 用自己内部的硬数据证明了:AI 正在加速 AI 的开发,而且加速度本身也在加快。从外部基准测试到内部工程效率,所有曲线都在上扬。递归自我改进——AI 完全自主地设计和开发自己的继任者——还没有实现,但可能来得比大多数机构准备好的时间更早。
Anthropic 把这个过程分成了五个阶段:
| 阶段 | 时间 | 人类在做什么 | AI 在做什么 |
|---|---|---|---|
| 人工驱动 | 2021–2023 | 写代码、写文档 | 不存在 |
| 聊天助手 | 2023–2025 | 主导一切工作 | 生成代码片段,人类复制粘贴 |
| 编程 Agent | 2025–2026 | 审查和引导 | 独立写文件、编辑代码 |
| 自主 Agent | 今天 | 设定目标 | 自己跑代码,给其他 Agent 派活 |
| 闭环 | 20XX? | 监督与验证 | 自己训练和构建模型 |
这个阶段的划分不是理论推演,而是 Anthropic 内部真实发生的事情。注意最后那个 **20XX?**——连 Anthropic 自己都不确定时间点,但方向是明确的。
如果你只看公开数据,趋势同样惊人。
AI 能完成的任务时长每 4 个月翻一倍(之前是每 7 个月)。这是什么概念?
几个重要基准测试的状态:
公开基准测试能告诉你模型有多强,但看不到 AI 对 AI 开发本身的加速效应。Anthropic 这次公开了内部数据,这是这篇文章最有价值的部分。
截至 2026 年 5 月,Anthropic 合并到代码库的代码中超过 80% 由 Claude 编写。Claude Code 在 2025 年 2 月发布之前,这个数字只有低个位数。

这张图有两个拐点:
2026 年 Q2,典型工程师每天合并的代码量是 2024 年的 8 倍。注意,代码行数是不完美的度量——它度量的是数量而非质量。但方向是明确的。
一个更直观的数字:2026 年 3 月,130 名 Anthropic 研究人员的调查显示,中位数受访者估计使用 Mythos Preview 后产出约为不使用 AI 时的 4 倍。
代码质量有两个维度:能用 和 可维护。
在"能用"这个维度上,证据已经非常清楚。Anthropic 员工纠正、重定向或接管 Claude 的频率持续下降——包括最复杂、最开放的任务。

在开放性任务上,Claude 的成功率在 2026 年 5 月达到 **76%**,六个月内提升了 50 个百分点。
一个具体的例子:一次常规升级导致数万个训练任务崩溃。工程师把现场信息丢给 Claude,Claude 在大约两小时内隔离了一个冷门的调试 flag,可靠地复现了问题并确认了修复。这通常是两到三天的工作量。
在"可维护"这个维度上,差距在快速收窄。Anthropic 内部普遍认为:Claude 写的代码在 2025 年底还不如人类,目前已经基本持平,预计年内将超过人类水平。
一个有趣的发现:Anthropic 用 Claude 自动审查代码变更,回溯分析发现,如果一直用 Claude 审查,它能在大约三分之一的 bug 进入生产环境之前就发现它们。而写出那些代码的工程师,是世界上构建这类系统最顶尖的一批人。
Anthropic 每次发模型都跑一个固定测试:给 Claude 一段训练小型 AI 模型的代码,让它尽可能加速同时保持正确性。
在明确目标下的实验执行这个环节,Claude 在不到一年内从"超有用"变成了"超人类"。
但实验执行和实验设计是两回事。Anthropic 做了一个实验来衡量这个差距:
他们找了 129 个真实的研究会话,这些会话都有一个共同特点——研究员在某个时刻走了一个弯路。他们把这个弯路之前的内容截断,问各个 Claude 模型"你下一步会怎么做",然后用一个能看到完整会话结果的 Claude 来判断:AI 和人类谁的选择更好?

结果:
注意,这组数据本身就偏向 AI——因为他们刻意挑选了人类判断有改进空间的时刻。但作为一个衡量 AI 研究判断力随时间提升的指标,方向是清晰的。
这就是 AI 今天和"能自主设计自己继任者"之间的差距:方向设定——选择什么问题值得研究、什么结果值得信任、什么时候该放弃一条路。
Anthropic 提出了三种可能的未来:
指数曲线可能实际上是 S 曲线,我们可能正在接近拐点。"研究品味"可能是一种无法通过扩大训练来获得的能力。或者瓶颈可能在供应链——芯片产能、电网扩张、互联带宽。
即使模型能力冻结在今天的水平,变革仍然巨大。Project Glasswing 项目中,Mythos Preview 在最初几周就发现了全球最重要系统中超过一万个高危软件漏洞。一个 100 人的公司将能完成过去 1000 人的工作。
Anthropic 认为这个场景可能性最低——因为他们观察到每一个可衡量的能力指标都在同一条上升曲线上,还没有看到曲线变平的迹象。
AI 开发被大幅自动化,但人类继续设定研究方向。100 人的公司能做 10,000 甚至 100,000 人组织的工作。
但这里有 Amdahl 定律的影子:加速一部分流程只会把瓶颈推到其他地方。Anthropic 已经遇到了这个问题——随着代码量暴增,人类的代码审查成了新的瓶颈。同样,新想法、新工具、新模拟的爆炸式增长远远超出了他们能追求的范围。
识别和修复瓶颈的能力,可能成为任何组织最重要的能力。
AI 开始设计和精炼自身。进步的速度完全由算力可用性决定。人类角色大幅缩减,主要转向监督、验证和确认一个不断扩展的"虚拟实验室"。
这个场景最不确定的部分是对齐问题:
文章最后提出了一个明确的政策立场:
如果有可能有效地减缓这项技术的发展,给我们更多时间来处理其巨大影响,我们认为这可能是好事。但如果减速只是让最不谨慎的参与者在技术上赶上来,可能会让每个人都更不安全。
Anthropic 明确表示:如果其他前沿开发者也能以可验证的方式减速或暂停,他们愿意这样做。
但实现可信的暂停极其困难:
Anthropic 承诺在未来几个月组织政策制定者、研究人员、公民社会和其他 AI 公司的对话,推动这些问题——特别是围绕完全递归自我改进和如何创建更好的协调选项。
几点个人观察:
1. "8 倍代码量"是一个被低估的数字。 因为这不只是"写了更多代码"——它改变了工程师的角色定义。工程师从"写代码的人"变成了"审查和引导 AI 的人"。当审查速度跟不上生成速度时(Amdahl 定律),整个流程会再次重组。
2. 研究判断力的进步是最值得关注的指标。 代码编写和实验执行已经接近或超过人类水平,但"决定研究什么"这个最后的人类堡垒正在缩小——从 51% 到 64% 的胜率提升只用了五个月。如果这个趋势持续,"研究品味"可能也只是另一种 AI 能力——AI 会失败一段时间,然后突然变好。
3. 三种场景的分布比结论更重要。 Anthropic 明确说他们认为场景一最不可能。但他们没有押注场景二还是场景三——这本身就是一种信号。如果他们确信递归自我改进不会发生,他们会说"我们距离场景三还很远"。他们没有这么说。
4. 暂停的悖论。 Anthropic 愿意暂停的前提是"其他人也暂停"。但在一个没有全球协调机制的世界里,这几乎等同于"我们不暂停"。这不是批评——这是一个真实的囚徒困境。文章在这一点上非常诚实。
5. 最被低估的风险:不是 AI 变得强大,而是人类的协作基础设施被侵蚀。 文章引用了一位 Anthropic 员工的话让我印象深刻:
工作和生活曾经运行在人与人之间的小恩小惠的礼物经济上。"你能帮我跑一下这个脚本吗?"……每一个请求都创造了一点人情债、一点相互认知。Claude 更快,不产生人情债,但每一个这样的请求都是一次人类协作机会的丧失。
当 AI 让每个请求都能被即时满足时,人与人之间的协作纽带也在被悄无声息地削弱。这不是技术问题,而是社会结构问题。
| 指标 | 数值 |
|---|---|
| Claude 编写的代码占比 | > 80%(2026 年 5 月) |
| 工程师代码产出提升 | 8x(对比 2024 年) |
| 研究员自评产出提升 | ~4x(使用 Mythos Preview) |
| 开放性任务成功率 | 76%(2026 年 5 月,六个月提升 50 个百分点) |
| 实验优化加速 | 从 3x(Opus 4)到 52x(Mythos Preview) |
| 研究判断力超越人类 | 64% 的时刻模型建议优于人类(Mythos Preview) |
| 任务时长翻倍周期 | ~4 个月(从 ~7 个月加速) |
| Claude 一次性修复量 | 800+ 修复将某类 API 错误降低 1000 倍 |
本文基于 Anthropic Institute 2026 年 6 月发布的 When AI builds itself 撰写,包含个人解读和分析。
很多人做「终端风」,就是在白色博客上换成深色背景加个等宽字体,完了。这个博客不是这样做的。
打开 blog.suchuanyi.dev,你看到的不是一个换了皮的 WordPress。你会看到一个在浏览器里运行的终端 IDE。每一个 UI 元素都有对应的终端隐喻,不是装饰,是交互逻辑本身。
导航栏模仿的是 tmux 的 pane 标题行。左边是站点名 terry.so 前面带一个绿色圆点 ●,然后是当前路径:
● terry.so ~/posts/open-source-terminal-blog main*
~/ 后面跟着你当前所在的路径段,最后一截高亮显示——就像你在 tmux 里看到的 pane 标题一样。末尾的 main* 表示当前分支有未提交的改动(当然是假的,但感觉对了)。
右边是状态信息:GitHub Fork 链接、⌘K 命令面板入口,还有一个绿色脉冲圆点配 CONNECTED 字样——你的终端连上了远程服务器那种感觉。
整个导航栏是 sticky 的,磨砂玻璃效果(backdrop-blur),往下滚也不会消失。
页面最底部固定了一行状态栏,完全模仿 Vim 的底部 mode 行:
[NORMAL] index.md g home t tags a about ⌘K palette UTF-8 14:32
NORMAL 模式标签(绿色高亮),像 Vim 的 -- INSERT --index.md 或 posts/open-source-terminal-blog.md这不是静态装饰。时间每秒刷新,文件名跟随路由切换,NORMAL 标签一直告诉你「你不在输入模式」。
这是我最喜欢的部分。整个站点的导航可以用 Vim 键位操作:
| 按键 | 动作 |
|---|---|
g |
回首页(连续按两次 gg 跳到第一页) |
t |
标签页 |
a |
关于页 |
⌘K |
命令面板 |
h / ← / [ |
上一页 |
l / → / ] |
下一页 |
G(大写) |
跳到最后一页 |
/ |
聚焦搜索框(Vim 搜索的肌肉记忆) |
ESC |
关闭命令面板 |
在首页翻页的时候,h 和 l 的体验和 Vim 里左右移动光标一模一样。gg 跳回第一页,G 跳到最后一页——完全复刻 Vim 的行首行尾。
搜索框按 / 聚焦,这是 Vim 里搜索的键位。搜索结果出来之后可以 ESC 关掉。整套键盘流可以完全不用鼠标浏览整个博客。
⌘K 打开命令面板。外观是一个 $ 开头的终端输入框,底下列出可用命令:
$ type a command...
:home
:tags
:about
输入几个字母自动过滤,Enter 执行第一个匹配项,ESC 关闭。和 VS Code 的命令面板一样好用,但长得像你的 shell。
ls 你的文章列表首页不是传统博客那种大图卡片布局。它更像是在终端里 ls -la 你的文章目录:
$ ls -la ~/articles | sed -n '1,10p'
上面这行是真的渲染在页面上的,作为 banner 的一部分。每个文章条目是一个网格行:
01 文章标题 UPDATED: 2026-05-30
文章描述文字... SIZE: 12KB
#tag1 #tag2 #tag3 READ: 8MIN
左边是序号(两位数,零填充),中间是标题 + 描述 + 标签,右边是文件元信息——就像 ls -la 的输出列。标签用 # 前缀,加了细边框,像终端里的 badge。
右上角显示当前页码和总数:PAGE 01/04 · TOTAL 37,用大写字母和零填充——信息密度拉满,但不会觉得乱。
打开一篇文章,正文上方不是传统的「作者 + 日期」元信息块。你看到的是一段被渲染的 YAML frontmatter:
---
title: "文章标题"
date: 2026-06-07
category:[开源, 博客]
tags: [开源, TanStack Start, pgvector]
status: published
---
绿色分隔线、等宽字体、键值对网格布局——就像你在终端里 cat 一个 Markdown 文件,frontmatter 原样输出。status: published 用绿色高亮,暗示这篇文章已经 merge 了。
这个设计不是偶然的。写博客的人天天和 frontmatter 打交道,把它直接展示出来,读者一眼就知道「这是一篇 Markdown 文件」,而不是一个 WordPress 页面。
grep首页的 AI 搜索框不是一个普通的输入框。它长这样:
$ grep -r 问点啥...例如 swift agent 集成 /
左边是绿色的 $ 提示符,紧跟着 grep -r,然后才是输入区域。右边有个 kbd 标签提示按 / 可以聚焦——还是 Vim 的搜索键。
搜索中的状态是 embedding query...,搜索结果标题行显示匹配数:// 3 matches,每条结果前面有相似度百分比。搜索失败的时候是 err: ...。
整个搜索体验就像你在终端里跑了一个命令,然后看着输出一行一行出来。
终端风的灵魂不只是等宽字体,还有配色。
整个博客的颜色系统用 oklch 色彩空间定义,只有六个 token:
| Token | 值 | 用途 |
|---|---|---|
| background | oklch(0.16 0.01 260) |
深蓝黑底 |
| foreground | oklch(0.96 0.005 260) |
接近白色的前景文字 |
| surface | oklch(0.21 0.012 260) |
卡片/面板背景 |
| border | oklch(0.30 0.012 260) |
微妙的分隔线 |
| muted | oklch(0.62 0.01 260) |
次要信息 |
| accent | oklch(0.78 0.18 145) |
终端绿——所有可交互元素的颜色 |
accent 是那个标志性的终端绿,用在链接、提示符、YAML 分隔线、闪烁光标、状态指示灯、快捷键高亮……所有需要「跳出来」的地方。统一、克制、不花哨。
组件层不写任何裸色值。所有颜色都走这六个 token。想换一套配色?改 styles.css 里六行代码,全站跟着变。
选中文字的高亮也是绿色的——::selection 用了 color-mix 把 accent 和透明度混合,选中效果像终端里高亮了一行输出。
页面标题末尾有一个闪烁的下划线 _,用 CSS step-end 动画实现,一秒闪烁一次——和终端里光标的节拍一模一样。
这个光标不是装饰。它在告诉读者「这个页面是活的,你可以输入」。首页标题「Agent 内核深潜」后面跟着 cursor-blink,搜索框打开的时候也是这种节奏。整个站点的交互节奏是统一的。
cat: post not found文章找不到的时候,你看到的不是一个大大的 404 插画。你看到的是:
$ cat: post not found
cd ~/
cd ~/ 是一个可点击的链接,带你回首页。就像你在终端里 cat 了一个不存在的文件,然后 cd 回到 home 目录。
正文用 sans-serif 字体(Inter),行高 1.75,但标题全部回到等宽字体。这是刻意的设计——结构信息用 mono,阅读内容用 sans。代码块背景比页面底色更深一层(oklch(0.13)),有细边框和圆角,代码高亮用 github-dark 主题。
引用块的左边是绿色竖线,背景有 6% 的绿色透明叠加。分隔线是虚线(dashed),不是实线——像终端里的注释行。
列表的 marker(disc / decimal)全部用 accent 绿色。链接有下划线但透明度 40%,hover 的时候变成实色——微妙但有反馈。
表格强制等宽字体,字号缩小到 0.875rem,表头有 surface 背景。整个表格看起来像终端里的 ps 或 top 输出。
说了这么多终端风,AI 功能其实是锦上添花。但既然做了,也挺好用:
grep 框输入自然语言,按语义返回结果三个功能全部通过 Lovable AI Gateway 调用,项目里没有任何 API Key。同步管线用内容哈希做增量闸门,没变过的文章不重算、不花钱。一行脚本触发:./scripts/sync-posts.sh prod。
不想用 AI?VITE_ENABLE_AI=false 一行关掉,退化成纯静态博客。
| 层 | 选型 |
|---|---|
| 框架 | TanStack Start(React 19、SSR、文件路由) |
| 构建 | Vite 7 |
| 样式 | Tailwind v4 + shadcn/ui,oklch 色彩 token |
| 后端 | Supabase(Postgres + pgvector + RLS) |
| AI | Lovable AI Gateway(Gemini embedding + Flash 摘要) |
| 部署 | Cloudflare Workers |
| 内容 | Markdown,gray-matter 解析 |
首屏 < 100KB,SSR 输出,每个路由都有 canonical / OG / JSON-LD。
如果你想基于这个博客做自己的:
content/posts/ → 放你自己的 Markdown(frontmatter:title / date / description / tags)__root.tsx(站点信息)、about.tsx(自我介绍)、SiteShell.tsx(站名和导航)、styles.css(配色 token)scripts/sync-posts.sh 换成你的域名./scripts/sync-posts.sh prod终端风的 UI 和 Vim 键位不需要任何后端依赖。即使你完全不用 AI 功能,这套终端交互体验也是开箱即用的。
这个博客最大的亮点不是 AI,不是 RAG,不是增量同步。是你打开它的那一刻,感觉像在终端里读文章。顶部路径栏、底部模式行、Vim 键位、grep 搜索框、YAML frontmatter 渲染、闪烁光标——整套 UI 都在说同一件事:这里属于程序员。
AI 是工具,终端是审美,开源是态度。
仓库在这里:**github.com/terryso/hack-buffer**
有问题开 Issue,或者直接在博客上按 / 搜——毕竟它自己就能搜。
/bmad-spec 提炼意图合约, /bmad-ux 拆分视觉与行为脊柱, /bmad-investigate 用工程化取证方式解决复杂问题。
完整更新日志: https://www.bmadcode.com/bmad-update-may-2026-web-bundles-prd-brief-platforms/
想深入了解, 可以阅读下面关于Hermes自进化的系列文章: 从 Memory、Skill 到 Background Review,完整拆解 Hermes 的自进化架构。
https://blog.suchuanyi.dev/posts/hermes-self-evolution-1-overview
如果你已经习惯通过 BAMD 写代码,接下来真正耗时间的,往往不是“写”,而是“协调”。
一个 Epic 里有 5 个、10 个、20 个 Story。每个 Story 都要经历创建规格、开发实现、自动化测试、代码审查、回顾总结。真正让人疲惫的,不是某一步本身,而是你要不断盯着流程、切换会话、处理失败、决定下一步。
Story Automator 想解决的,就是这层“人肉编排”。
昨晚我实际跑了一遍 /bmad-story-automator 的完整流程。下面就是这次使用过程的记录,以及一些当时截下来的图。
先用一句话概括 Story Automator:
你告诉它要处理哪些 Story、用什么执行策略,它就自动完成「创建规格 → 开发实现 → 测试自动化 → 代码审查 → 回顾」这条流水线,只在真的需要人类决策时才打断你。
这和普通“单命令跑一个 Skill”不一样。它更像一个构建周期编排器:
初始化
→ 读取 Epic / Sprint 状态
→ 选择 Story 范围
→ 评估复杂度
→ 选择 Agent 策略
→ 执行 create / dev / automate / review / retro
→ 在失败或冲突时升级给人类
从设计上说,它是在自动化“协调工作”,而不是直接替代某一个具体开发步骤。
如果你想体验 Story Automator,需要先把 BMAD 升到 6.6。安装时记得把 BMad Automator (Experimental) 这个模块勾上。
不然装完 BMAD,后面是用不起来的。
第一次执行 /bmad-story-automator 时,它不会急着开跑,而是先做初始化检查。
从下面这张图可以看到,它先加载配置,然后尝试读取当前编排状态;如果发现状态目录还不存在,这是正常的首次运行场景。接着它会自动安装 Stop Hook 到 .claude/settings.json 中。
真正进入编排前,Story Automator 会先读取 Epic 和 sprint 状态,然后让你决定处理范围。
下面这张图展示了一个很典型的场景:Epic 5、6、7 中一部分 Story 已完成,一部分仍然待办。工具会把这些状态直接展示出来,然后询问你要处理哪些 Story。
这是我觉得 Story Automator 最有意思的部分之一。
它不是把所有 Story 一股脑丢给同一个 Agent,而是先生成一个Story 复杂度矩阵。截图里可以看到:
更关键的是,它不只给分,还给出原因:
这意味着复杂度评估不是黑盒。你看到的不只是“结论”,而是“为什么它觉得这个 Story 更难”。这会直接影响后续的 Agent 选择策略。
换句话说,Story Automator 在做的事其实是:
先把 Story 变成“可调度对象”,再决定谁来执行。
接下来它会问你有没有自定义指令。
比如:
这个设计我很喜欢,因为它在“全自动”和“可控”之间找到了一个不错的平衡:
none也就是说,它把“人类经验”当成一种可选输入,而不是每次都强迫你从头配置一大堆参数。
再往下,就是执行策略层面的配置。截图中可以看到两个核心问题:
automate 步骤(测试自动化)
默认值是:
对大多数真实项目来说,测试自动化是交付闭环里最不该轻易跳过的一环;而并行度默认设为 1,也避免了多个会话同时改动同一代码库时互相干扰。也就是说:
默认先追求“可控完成”,而不是“并行冲刺到极限”。
有了复杂度矩阵之后,Story Automator 就能推荐 Agent 配置。
推荐配置如下:
从这个配置可以看出,它已经不是“调用一个模型”的层面了,而是在做模型编排。
而且它还提供了策略选项:
不同团队可以这样用:
而从后面的配置摘要截图也能看出来,这次实际演示最后保存成了 all-claude。这恰好说明:推荐是推荐,不是强制。 你既可以让系统按复杂度智能分配,也可以为了稳定性或一致性,手动统一到同一类 Agent。
这一步让整个系统更像一个“调度器”,而不是一个简单的命令包装器。
当你确认后,Story Automator 会把这次运行配置保存下来。截图里展示的是一个名为 all-claude 的配置摘要:
摘要里写了几件事:
这类“摘要页”看起来很普通,但它是编排器可恢复、可审计、可复盘的基础。
因为自动化一旦跨越多个 Story、多次会话、多个阶段,就一定会面对这些问题:
有了显式保存的配置,后续无论是恢复执行还是事后分析,都不会变成猜谜游戏。
昨晚睡觉前,我直接把这 5 个 Story 交给 Story Automator 去跑。早上起来看结果,它总共跑了 5 个半小时。
坦白说,速度是比我自己手工盯着跑要慢的。按我平时的节奏,这 5 个 Story 如果自己来,估计 3 个小时内能收完。至于为什么会慢这么多, 具体原因还不太清楚, 还没有仔细的去分析它的实现原理,不过目前还只是试验版,能跑通比较重要。
目前比较适合:睡觉前梭一把。
另外一个我觉得做得不错的点,是每个 Epic 跑完之后,它会顺手做一次复盘,把有用的信息补到 project-context.md 里。
所以我现在对它的看法很简单:
白天手工跑,目前还是自己手工跑会更快。
但睡前把一批 Story 交给它过夜跑,这个场景它真的挺合适。
如果你正在使用 BMAD 来开发项目,你一定要试一下 Story Automator,它可能是你将重复协调时间从小时级降到分钟级的工具。
本文是「深入 SwiftWork」系列第 4 篇(完结篇)。系列目录见这里。
前三篇讲了事件怎么从 SDK 流到 UI、时间线怎么渲染、工具卡片怎么可视化。这篇收尾,看 SwiftWork 的基础设施——数据怎么存、状态怎么恢复、Markdown 怎么渲染、代码怎么高亮、API Key 怎么管。
这些组件各自独立,但都是"让应用可用"的必要部分。
SwiftWork 用 SwiftData 做持久化,注册了四个模型:
// SwiftWorkApp.swift
.modelContainer(for: [
Session.self,
Event.self,
AppConfiguration.self,
PermissionRule.self
])
@Model
final class Session {
@Attribute(.unique) var id: UUID
var title: String
var createdAt: Date
var updatedAt: Date
var workspacePath: String?
@Relationship(deleteRule: .cascade, inverse: \Event.session)
var events: [Event]
}
@Relationship(deleteRule: .cascade) 意味着删除 Session 时自动删除它下面所有 Event。workspacePath 是可选的——用户可以给每个会话指定不同的工作目录。
@Model
final class Event {
@Attribute(.unique) var id: UUID
var sessionID: UUID
var eventType: String
var rawData: Data // JSON 序列化的 AgentEvent
var timestamp: Date
var order: Int
var session: Session?
}
第 1 篇讲过这个设计——rawData 是整个 AgentEvent 序列化后的 JSON blob。不拆成独立字段的原因是 metadata 的结构因事件类型而异,拆字段会导致大量空列和 Schema 频繁变更。
@Model
final class AppConfiguration {
@Attribute(.unique) var id: UUID
var key: String
var value: Data
var updatedAt: Date
}
通用的 key-value 存储。用 SwiftData 实现而不是 UserDefaults,因为 SwiftData 支持 async 访问、数据迁移和 iCloud 同步(将来可能用到)。存的值包括:
hasCompletedOnboarding — 是否完成首次引导selectedModel — 用户选择的模型lastActiveSessionID — 上次活跃的会话 IDwindowFrame — 窗口位置和大小inspectorVisible — Inspector 面板是否可见AppStateManager 负责在 App 重启后恢复用户的工作状态——上次打开的会话、窗口位置、Inspector 面板的开关。
@MainActor
@Observable
final class AppStateManager {
var lastActiveSessionID: UUID?
var windowFrame: NSRect?
var isInspectorVisible: Bool = false
func loadAppState() {
lastActiveSessionID = loadUUID(key: "lastActiveSessionID")
windowFrame = loadNSRect(key: "windowFrame")
isInspectorVisible = loadBool(key: "inspectorVisible")
}
func saveLastActiveSessionID(_ id: UUID?) { ... }
func saveWindowFrame(_ frame: NSRect) { ... }
func saveInspectorVisibility(_ visible: Bool) { ... }
}
底层用 AppConfiguration 的 key-value 存取:
private func saveString(_ string: String, forKey key: String) {
let descriptor = FetchDescriptor<AppConfiguration>(
predicate: #Predicate { $0.key == key }
)
if let existing = try? modelContext.fetch(descriptor).first {
existing.value = Data(string.utf8)
} else {
let config = AppConfiguration(key: key, value: Data(string.utf8))
modelContext.insert(config)
}
try? modelContext.save()
}
upsert 逻辑——先查有没有,有就更新,没有就插入。loadNSRect 把字符串转回 NSRect(用 NSRectFromString),loadBool 比较字符串 "true"。
状态保存不是在 App 退出时一次性完成的,而是在各个触发点分散保存:
| 状态 | 保存时机 |
|---|---|
lastActiveSessionID |
用户切换会话时(SessionViewModel.selectSession) |
windowFrame |
窗口移动/缩放时(500ms 节流)+ App 退出时 |
inspectorVisible |
Inspector 面板切换时 |
窗口位置的保存做了节流——didMoveNotification 和 didResizeNotification 触发频率很高,每次都写 SwiftData 不值得。用一个 500ms 的 Task.sleep 做防抖,只有最后一次移动/缩放才会真正保存:
// ContentView.swift
let saveWindowFrameThrottled: (Notification) -> Void = { _ in
saveTask?.cancel()
saveTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
if let window = mainWindow {
appStateManager.saveWindowFrame(window.frame)
}
}
}
App 启动时,ContentView.task 触发恢复:
.task {
settingsViewModel.configure(modelContext: modelContext)
hasCompletedOnboarding = settingsViewModel.isAPIKeyConfigured
&& !settingsViewModel.isFirstLaunch
if hasCompletedOnboarding == true {
configureAndRestoreState()
}
}
configureAndRestoreState 按顺序恢复:
AppStateManager,加载保存的状态SessionViewModel,获取会话列表lastActiveSessionID 选中对应会话isInspectorVisible窗口位置的恢复有一个时序问题——WindowAccessor 的回调是异步的,window 引用可能在 task 之后才到达。所以 onChange(of: mainWindow) 里也做了恢复:
.onChange(of: mainWindow) { _, newWindow in
if let newWindow {
restoreWindowFrame(in: newWindow)
}
}
Agent 的回复是 Markdown 格式的——标题、列表、代码块、粗体、链接。SwiftWork 用 Apple 的 swift-markdown 库解析 Markdown,然后用 Visitor 模式遍历 AST,生成 SwiftUI 视图。
macOS 上的 Markdown 渲染组件不多。AttributedString(markdown:) 只支持基础格式(粗体、链接),不支持代码块、表格、引用块。WebView 方案(用 Markdown.js 渲染到 HTML)引入了 WebKit 的依赖和内存开销。手写 Visitor 可以精确控制每个元素的渲染方式,而且不引入额外依赖。
private struct MarkdownToViewsVisitor: @preconcurrency MarkupVisitor {
private(set) var views: [AnyView] = []
mutating func visitHeading(_ heading: Heading) -> Result { ... }
mutating func visitParagraph(_ paragraph: Paragraph) -> Result { ... }
mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> Result { ... }
mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> Result { ... }
mutating func visitOrderedList(_ orderedList: OrderedList) -> Result { ... }
mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> Result { ... }
mutating func visitTable(_ table: Table) -> Result { ... }
mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) -> Result { ... }
}
每个 visit 方法处理一种 Markdown 节点,把生成的视图追加到 views 数组。最终 MarkdownRenderer.render() 返回这个数组,MarkdownContentView 用 ForEach 渲染。
段落、列表项里的内联格式(粗体、斜体、行内代码、链接)通过 collectAttributedString 处理。它递归遍历子节点,构建 AttributedString:
private mutating func collectAttributedString(from markup: any Markup) -> AttributedString {
var result = AttributedString()
for child in markup.children {
if let strong = child as? Strong {
var s = collectAttributedString(from: strong)
s.font = .body.bold()
result.append(s)
} else if let emphasis = child as? Emphasis {
var e = collectAttributedString(from: emphasis)
e.font = .body.italic()
result.append(e)
} else if let inlineCode = child as? InlineCode {
var codeAttr = AttributedString(inlineCode.code)
codeAttr.backgroundColor = Color.primary.opacity(0.06)
codeAttr.font = .system(.body, design: .monospaced)
result.append(codeAttr)
} else if let link = child as? MarkdownLink {
var linkAttr = AttributedString(collectInlineText(from: link))
linkAttr.foregroundColor = Color.accentColor
linkAttr.underlineStyle = .single
linkAttr.link = URL(string: link.destination)
result.append(linkAttr)
}
// ... SoftBreak, LineBreak, Strikethrough
}
return result
}
AttributedString 是 SwiftUI 原生支持的富文本类型。把它传给 SwiftUI.Text(attributed),SwiftUI 会按设定的 font、color、backgroundColor 渲染。行内代码得到灰色背景的等宽字体,链接得到蓝色下划线。
swift-markdown 和 SwiftUI 有类型名冲突——两者都有 Text、Link 等类型。解决方案是用 typealias:
private typealias MarkdownText = Markdown.Text
private typealias MarkdownLink = Markdown.Link
在 visitor 内部用 MarkdownText 和 MarkdownLink 引用 swift-markdown 的类型,SwiftUI.Text 引用 SwiftUI 的类型。
代码块的高亮用 John Sundell 的 Splash 库。目前只支持 Swift 语法高亮,其他语言 fallback 到等宽纯文本:
enum CodeHighlighter {
static func highlight(code: String, language: String?) -> AnyView {
let trimmedLanguage = language?.lowercased()
if trimmedLanguage == "swift" {
return highlightedSwiftView(code: code)
} else {
return plainCodeView(code: code)
}
}
private static func highlightedSwiftView(code: String) -> AnyView {
let theme = Theme.sundellsColors(withFont: Splash.Font(size: 13))
let format = AttributedStringOutputFormat(theme: theme)
let highlighter = SyntaxHighlighter(format: format)
let attributed = try? AttributedString(highlighter.highlight(code), including: \.appKit)
return AnyView(Text(attributed ?? AttributedString(code)))
}
}
Splash 的管线:源码字符串 → SyntaxHighlighter → AttributedStringOutputFormat → NSAttributedString → AttributedString → SwiftUI.Text。
为什么只支持 Swift?因为 Splash 只支持 Swift。如果要支持 Python/JavaScript/Bash,需要换一个多语言的高亮库(比如 Highlight.js 的 Swift wrapper),或者用 Tree-sitter。目前 Swift 代码块的高亮频率最高(SwiftWork 本身是 Swift 项目),先支持 Swift 够用。
API Key 不能明文存在 SwiftData 或 UserDefaults 里。SwiftWork 用 macOS Keychain 存储:
struct KeychainManager: KeychainManaging, Sendable {
func save(key: String, data: Data) throws {
let query = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key
]
let status = SecItemAdd(query.merging([kSecValueData: data]), nil)
if status == errSecDuplicateItem {
SecItemUpdate(query, [kSecValueData: data])
}
}
func load(key: String) throws -> Data? {
let query = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query, &result)
if status == errSecItemNotFound { return nil }
return result as? Data
}
}
KeychainManaging 协议抽象了底层实现,方便测试时 mock。协议扩展提供了 saveAPIKey/getAPIKey/deleteAPIKey 的便捷方法。
Keychain 存储有两个好处:数据加密(系统级别的),以及不受 App Sandbox 的文件访问限制。
新建的会话标题是"新会话"。Agent 第一次执行完成后,TitleGenerator 用 LLM 根据对话内容生成一个简短的标题:
enum TitleGenerator {
static func generate(events: [AgentEvent], apiKey: String, ...) async -> String? {
guard !apiKey.isEmpty else { return nil }
let messages = events
.filter { $0.type == .userMessage || $0.type == .assistant }
.suffix(10) // 只取最近 10 条
.map { ["role": ..., "content": String($0.content.prefix(500))] }
let body = [
"model": model,
"max_tokens": 50,
"system": "根据以下对话内容,生成一个简短的标题(最多20个字符)。只输出标题。",
"messages": messages
]
// 调 LLM API,返回标题文本
}
}
触发时机在 WorkspaceView.setupTitleGeneration 里——通过 AgentBridge.onResult 回调,在 Agent 执行完成且会话标题还是"新会话"时触发:
agentBridge.onResult = { [weak session] _ in
guard let session, session.title == "新会话" else { return }
if let title = await TitleGenerator.generate(events: events, ...) {
sessionViewModel.updateSessionTitle(session, title: title)
}
}
这是一个轻量的 LLM 调用——只有 50 token 的输出限制,system prompt 很短,取最近的 10 条消息、每条截断到 500 字符。实测延迟在 1-2 秒,不影响用户体验。
SwiftWork 的数据层和服务组件各司其职:
| 组件 | 职责 |
|---|---|
| SwiftData | Session/Event/AppConfiguration 持久化 |
| AppStateManager | 应用状态恢复(会话、窗口、面板) |
| EventStore | 事件持久化协议,SwiftData 实现 |
| MarkdownRenderer | swift-markdown AST → SwiftUI 视图 |
| CodeHighlighter | Splash 语法高亮(Swift) |
| KeychainManager | API Key 安全存储 |
| TitleGenerator | LLM 自动生成会话标题 |
它们是前几篇讲的核心管线(AgentBridge → EventMapper → TimelineView)之外的"支撑层"。没有它们应用也能跑,但用户体验会差很多——没有持久化意味着每次重启都从零开始,没有 Markdown 渲染意味着 Agent 的回复是一堆原始文本,没有 Keychain 管理意味着 API Key 明文存储。
系列文章:
相关链接:
本文是「深入 SwiftWork」系列第 3 篇。系列目录见这里。
前两篇讲了事件怎么从 SDK 流到 UI。这篇聚焦其中一类事件——工具调用的可视化。
Agent 调工具是 Agent 应用里最频繁的操作。一次典型任务可能调用二三十次工具——读文件、写文件、执行命令、搜索代码。如果每次工具调用都显示成一样的灰色方块,用户很难快速区分"Bash 在跑什么命令"、"Edit 在改哪个文件"。
SwiftWork 的解决方案是一套可扩展的工具渲染系统:每种工具注册一个渲染器,ToolCardView 根据工具名称查找对应的渲染器来显示。新增工具类型时,只需要写一个实现 ToolRenderable 协议的 struct,注册到 ToolRendererRegistry,不用改 TimelineView 的任何代码。
最简单的做法是给所有工具调用用同一个视图——显示工具名称、输入参数、输出结果。第 2 篇里的 ToolCallView 就是这个角色:
struct ToolCallView: View {
let event: AgentEvent
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Image(systemName: "wrench.and.screwdriver")
Text(event.content) // 工具名称
}
Text(input) // 原始 JSON
}
}
}
这个视图对所有工具一视同仁——同样的扳手图标,同样的 JSON 输出。它作为 fallback 够用,但有几个问题:
git status),不是 {"command": "git status"}src/main.swift),不是完整的 JSON每个工具都有不同的"最有用的信息"。Tool Card 系统就是让每个工具自己决定怎么展示。
协议定义了工具渲染器的契约:
protocol ToolRenderable: Sendable {
/// 此渲染器处理的工具名称(与 SDK ToolUseData.toolName 匹配)
static var toolName: String { get }
/// 工具类型主题色(左边条、图标着色)
static var accentColor: Color { get }
/// 工具类型 SF Symbol 图标名
static var icon: String { get }
/// 根据工具内容生成 SwiftUI 视图
@ViewBuilder @MainActor
func body(content: ToolContent) -> any View
/// 生成摘要标题(折叠状态显示)
func summaryTitle(content: ToolContent) -> String
/// 生成副标题(如文件路径、命令摘要)
func subtitle(content: ToolContent) -> String?
}
协议扩展提供了默认值:
extension ToolRenderable {
static var accentColor: Color { .gray }
static var icon: String { "wrench.and.screwdriver" }
func summaryTitle(content: ToolContent) -> String {
content.toolName
}
func subtitle(content: ToolContent) -> String? {
nil
}
}
六个成员,三个有默认值。实现者只需要提供 toolName(静态路由键)和 body(渲染内容)。summaryTitle 和 subtitle 可以覆盖来提供更有意义的摘要,accentColor 和 icon 可以覆盖来做视觉区分。
注册表是一个 [String: ToolRenderable] 字典,用 toolName 做键:
@MainActor
@Observable
final class ToolRendererRegistry {
private var renderers: [String: any ToolRenderable] = [:]
init() {
register(BashToolRenderer())
register(FileEditToolRenderer())
register(SearchToolRenderer())
register(ReadToolRenderer())
register(WriteToolRenderer())
}
func register(_ renderer: any ToolRenderable) {
renderers[type(of: renderer).toolName] = renderer
}
func renderer(for toolName: String) -> (any ToolRenderable)? {
renderers[toolName]
}
}
init 时预注册 5 个内置渲染器。查找是 O(1) 的字典访问。@Observable 标记让 SwiftUI 在注册新渲染器时自动刷新——虽然目前的用法里渲染器在 init 时就注册完了,动态注册是留给插件系统准备的。
struct BashToolRenderer: ToolRenderable {
static let toolName = "Bash"
static let accentColor: Color = .green
static let icon: String = "terminal"
func summaryTitle(content: ToolContent) -> String {
// 从 input JSON 提取 command 字段
// {"command": "git status"} → "git status"
guard let json = parseInput(content),
let command = json["command"] as? String
else { return content.toolName }
return command
}
}
绿色主题 + 终端图标。summaryTitle 从 input JSON 提取 command 字段——折叠状态下用户直接看到正在跑什么命令。
struct ReadToolRenderer: ToolRenderable {
static let toolName = "Read"
static let accentColor: Color = .blue
static let icon: String = "doc.text"
func summaryTitle(content: ToolContent) -> String {
// {"file_path": "src/main.swift"} → "src/main.swift"
guard let json = parseInput(content),
let filePath = json["file_path"] as? String
else { return content.toolName }
return filePath
}
}
蓝色主题 + 文档图标。summaryTitle 提取文件路径。
struct WriteToolRenderer: ToolRenderable {
static let toolName = "Write"
static let accentColor: Color = .orange
static let icon: String = "pencil.and.outline"
func summaryTitle(content: ToolContent) -> String {
// 提取 file_path
}
func subtitle(content: ToolContent) -> String? {
// 提取 content 字段,截取前 80 字符
// {"content": "import Foundation\n..."} → "import Foundation..."
guard let json = parseInput(content),
let contentStr = json["content"] as? String, !contentStr.isEmpty
else { return nil }
return "\(contentStr.prefix(80))..."
}
}
橙色主题 + 铅笔图标。比 Read 多一个 subtitle——显示写入内容的前 80 个字符。因为写入的内容通常很长,subtitle 给用户一个快速预览。
struct FileEditToolRenderer: ToolRenderable {
static let toolName = "Edit"
static let accentColor: Color = .orange
static let icon: String = "pencil.line"
func summaryTitle(content: ToolContent) -> String {
// 提取 file_path
}
func subtitle(content: ToolContent) -> String? {
// 提取 old_string,截取前 50 字符
// {"old_string": "func hello() {"} → "Editing: func hello() {"
guard let json = parseInput(content),
let oldString = json["old_string"] as? String, !oldString.isEmpty
else { return nil }
return "Editing: \(oldString.prefix(50))"
}
}
橙色主题 + 编辑图标。subtitle 显示被替换的旧文本片段——让用户知道 Edit 在改哪一行。
struct SearchToolRenderer: ToolRenderable {
static let toolName = "Grep"
static let accentColor: Color = .purple
static let icon: String = "text.magnifyingglass"
func summaryTitle(content: ToolContent) -> String {
// 提取 pattern
}
func subtitle(content: ToolContent) -> String? {
// 提取 path
}
}
紫色主题 + 放大镜图标。summaryTitle 显示搜索 pattern,subtitle 显示搜索路径。
| 工具 | 颜色 | 图标 | summaryTitle | subtitle |
|---|---|---|---|---|
| Bash | 绿色 | terminal | 命令 | - |
| Read | 蓝色 | doc.text | 文件路径 | - |
| Write | 橙色 | pencil.and.outline | 文件路径 | 内容前 80 字符 |
| Edit | 橙色 | pencil.line | 文件路径 | 被替换文本前 50 字符 |
| Grep | 紫色 | text.magnifyingglass | 搜索 pattern | 搜索路径 |
五种工具在折叠状态下就能一眼区分:颜色不同、图标不同、摘要文本不同。
ToolCardView 是工具卡片的容器。它不做具体的渲染,而是委托给注册表里查到的渲染器:
struct ToolCardView: View {
let content: ToolContent
let registry: ToolRendererRegistry
let isSelected: Bool
let onSelect: () -> Void
@State private var isExpanded = false
var body: some View {
HStack(spacing: 0) {
// 左边条(3px,渲染器的主题色)
RoundedRectangle(cornerRadius: 2)
.fill(toolAccentColor)
.frame(width: 3)
VStack(alignment: .leading, spacing: 0) {
titleRow // 始终可见
.onTapGesture {
onSelect()
withAnimation { isExpanded.toggle() }
}
if isExpanded {
expandedContent // 展开后可见
}
}
}
}
}
卡片分两层:titleRow(始终可见)和 expandedContent(点击展开)。
private var titleRow: some View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: toolIcon) // 渲染器的图标
.foregroundStyle(toolIconColor)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(resolvedSummaryTitle) // 渲染器的 summaryTitle
.fontWeight(.medium)
Spacer()
if content.status == .running {
ProgressView().controlSize(.mini) // 运行中转圈
}
Text(statusLabel) // pending / running / completed / failed
.font(.system(size: 9))
.background(statusColor.opacity(0.15))
}
Text(content.toolName) // 工具名称(小字)
if let subtitle = resolvedSubtitle { // 渲染器的 subtitle
Text(subtitle)
}
}
}
}
标题行从渲染器获取图标、颜色、摘要标题和副标题。状态标签(pending/running/completed/failed)由 ToolContent.status 决定,不在渲染器的控制范围内——它是通用的执行状态,跟工具类型无关。
private var expandedContent: some View {
VStack(alignment: .leading, spacing: 8) {
Divider()
// 工具特定的 body(从渲染器获取)
if let renderer = registry.renderer(for: content.toolName) {
AnyView(renderer.body(content: content))
} else {
genericToolBody // fallback
}
// 通用 INPUT 区域
if !content.input.isEmpty {
HStack {
Text("INPUT")
Spacer()
CopyButton(text: content.input)
}
Text(content.input)
.font(.system(.caption, design: .monospaced))
}
// 通用 OUTPUT 区域
if let output = content.output, !output.isEmpty {
ToolResultContentView(output: output, isError: content.isError)
}
}
}
展开内容分三块:
body**:工具特定的自定义内容。目前的 5 个内置渲染器都在 body 里显示了一个带图标的摘要块——和 titleRow 里的信息类似但更详细。将来可以为复杂工具(比如显示代码 diff 预览)提供更丰富的 body。ToolResultContentView,下一节讲。genericToolBody 是没有注册渲染器时的 fallback——只显示工具名和原始输入。
ToolResultContentView 有一个智能功能:自动检测输出内容是不是 diff 格式,如果是就用颜色标注。
private var isDiffContent: Bool {
let lines = output.components(separatedBy: "\n")
let diffLines = lines.filter { $0.hasPrefix("+") || $0.hasPrefix("-") || $0.hasPrefix("@@") }
return diffLines.count >= 2
}
检测逻辑:如果输出里至少有两行以 +、-、@@ 开头,就认为是 diff 内容。简单但够用——SDK 的 Edit 工具输出 diff 格式的结果。
Diff 渲染给每行加背景色:
private func diffLineView(_ line: String) -> some View {
Text(line)
.font(.system(.caption, design: .monospaced))
.padding(.horizontal, 4)
.background(diffLineBackground(line))
}
private func diffLineBackground(_ line: String) -> Color {
if line.hasPrefix("+") { return .green.opacity(0.15) } // 新增行
if line.hasPrefix("-") { return .red.opacity(0.15) } // 删除行
if line.hasPrefix("@@") { return .blue.opacity(0.1) } // 位置标记
return .clear
}
非 diff 内容按普通文本渲染,有截断逻辑——超过 5 行或 200 字符时折叠,带展开按钮。
假设 SDK 新增了一个 WebFetch 工具,你想在 SwiftWork 里给它一个专属的卡片样式。只需要两个步骤:
第一步:写渲染器
struct WebFetchToolRenderer: ToolRenderable {
static let toolName = "WebFetch"
static let accentColor: Color = .cyan
static let icon: String = "globe"
@MainActor
func body(content: ToolContent) -> any View {
// 自定义视图...
}
func summaryTitle(content: ToolContent) -> String {
// 从 input 提取 URL
guard let json = parseInput(content),
let url = json["url"] as? String
else { return content.toolName }
return url
}
}
第二步:注册
// ToolRendererRegistry.init()
register(WebFetchToolRenderer())
不需要改 TimelineView、ToolCardView 或任何其他文件。ToolCardView 在渲染时通过 registry.renderer(for:) 查找渲染器,查到了就用,查不到就用 fallback。
Tool Card 系统的设计思路是协议 + 注册表:
| 组件 | 职责 |
|---|---|
ToolRenderable |
定义渲染契约——工具名、颜色、图标、摘要、自定义视图 |
ToolRendererRegistry |
字典查找,toolName → ToolRenderable |
ToolCardView |
容器视图,委托给渲染器,处理通用逻辑(展开/折叠、状态标签、INPUT/OUTPUT 区域) |
ToolResultContentView |
输出渲染,自动 diff 检测 |
这个模式的好处是开放扩展、关闭修改。TimelineView 的分派逻辑(第 2 篇的 toolCardView(for:))不需要知道有多少种工具——它只查注册表。新增工具类型时,改动的范围限定在渲染器文件和注册表的 init 方法。
下一篇是最后一篇,看数据层——SwiftData 的会话/事件持久化、App 状态恢复、Markdown 渲染和代码高亮。
系列文章:
相关链接:
本文是「深入 SwiftWork」系列第 2 篇。系列目录见这里。
第 1 篇讲了 AgentBridge 怎么把 SDK 的 AsyncStream<SDKMessage> 变成 [AgentEvent]。这篇看 [AgentEvent] 变成什么——TimelineView 怎么渲染 18 种事件、怎么处理滚屏行为、怎么在事件量很大时保持流畅。
TimelineView 是工作区的主体,占满了侧边栏和输入框之间的所有空间。它的视图层级很浅:
TimelineView
├── ScrollView
│ ├── topPlaceholder (虚拟化占位)
│ ├── LazyVStack
│ │ └── ForEach(virtualizedEvents) → eventView(for:)
│ ├── bottomPlaceholder (虚拟化占位)
│ ├── StreamingTextView (流式文本)
│ └── bottom-anchor (滚动锚点)
└── returnToBottomButton (回到底部)
没有事件时显示空状态:"发送消息开始与 Agent 对话"。有事件时进入 ScrollViewReader + LazyVStack 的结构。
eventView(for:) 是事件分派的核心。18 种 AgentEventType 映射到 8 种视图:
@ViewBuilder
private func eventView(for event: AgentEvent) -> some View {
switch event.type {
case .userMessage: UserMessageView(event: event)
case .partialMessage: EmptyView()
case .assistant: AssistantMessageView(event: event)
case .toolUse: toolCardView(for: event)
case .toolResult,
.toolProgress: pairedToolEventView(for: event)
case .result: ResultView(event: event)
case .system: systemOrThinking(event: event)
case .hookStarted, .hookProgress, .hookResponse,
.taskStarted, .taskProgress, .authStatus,
.filesPersisted, .localCommandOutput,
.promptSuggestion, .toolUseSummary:
SystemEventView(event: event)
case .unknown: UnknownEventView(event: event)
}
}
几个值得说的分派逻辑:
partialMessage 渲染为 EmptyView。 流式文本不走 ForEach(events),而是在 LazyVStack 下方用单独的 StreamingTextView 渲染。原因在第 1 篇讲过——partialMessage 只累积在 streamingText 里,不进 events 数组。这样避免了 ForEach 频繁插入/删除带来的闪烁和性能开销。
toolUse 走 toolCardView,toolResult/toolProgress 走 pairedToolEventView。 如果 toolContentMap 里有对应的条目(说明已经收到了配对的 toolUse),toolUse 渲染为 ToolCardView,配对的 toolResult/toolProgress 渲染为 EmptyView——因为它们的内容已经合并在卡片里了。如果 toolContentMap 里没有(比如历史事件加载不完整),就 fallback 到简单的 ToolCallView/ToolResultView。
system 类型需要区分"思考中"和普通系统事件。 systemOrThinking 方法检查 metadata 里的 subtype:
private func systemOrThinking(event: AgentEvent) -> some View {
let subtype = event.metadata["subtype"] as? String ?? ""
let isLastEvent = agentBridge.events.last?.id == event.id
if (subtype == "init" || subtype == "status") && isLastEvent {
ThinkingView() // 旋转齿轮 + "思考中..."
} else if subtype == "init" || subtype == "status" {
ThinkingView(isActive: false) // 对勾 + "Agent 已响应"
} else if let isError = event.metadata["isError"] as? Bool, isError {
SystemEventView(event: event, isError: true) // 红色错误条
} else {
SystemEventView(event: event) // 普通系统消息
}
}
只有最后一条 init/status 事件才显示旋转动画。历史事件显示静态的"Agent 已响应"。这避免了所有历史思考状态都在转圈的问题。
struct UserMessageView: View {
let event: AgentEvent
var body: some View {
HStack {
Spacer()
Text(event.content)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.blue.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
用户消息右对齐,蓝色半透明背景,圆角矩形。跟 ChatGPT 的消息布局一致。
struct AssistantMessageView: View {
let event: AgentEvent
var body: some View {
HStack(alignment: .top, spacing: 0) {
RoundedRectangle(cornerRadius: 1)
.fill(Color.secondary.opacity(0.3))
.frame(width: 2)
.padding(.trailing, 8)
MarkdownContentView(markdown: event.content)
Spacer()
}
}
}
左边一条灰色竖线做视觉分隔,内容用 MarkdownContentView 渲染。这个组件处理 Markdown 解析、代码高亮和长文本折叠,第 4 篇会详细讲。
struct ThinkingView: View {
var isActive: Bool = true
@State private var isAnimating = false
var body: some View {
HStack(spacing: 8) {
if isActive {
Image(systemName: "gearshape")
.rotationEffect(.degrees(isAnimating ? 360 : 0))
.animation(.linear(duration: 1).repeatForever(autoreverses: false),
value: isAnimating)
Text("思考中...")
} else {
Image(systemName: "checkmark.circle")
Text("Agent 已响应")
}
Spacer()
}
.onAppear { if isActive { isAnimating = true } }
}
}
isActive 控制两种状态:旋转齿轮表示正在思考,绿色对勾表示思考完成。onAppear 触发动画,视图滚出屏幕再滚回来时不会重新触发。
struct ResultView: View {
let event: AgentEvent
// 从 metadata 提取 durationMs、totalCostUsd、numTurns
var body: some View {
HStack(spacing: 4) {
Image(systemName: statusIcon) // checkmark.circle / pause.circle / xmark.circle
.foregroundStyle(statusColor)
Text(subtype) // success / cancelled / error
}
// 下方显示:耗时 | 轮数 | 费用
HStack(spacing: 12) {
Label("\(duration)ms", systemImage: "clock")
Label("\(turns) 轮", systemImage: "arrow.triangle.2.circlepath")
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
}
}
}
Result 事件显示执行结果的概要统计——耗时多少毫秒、经过多少轮对话、花费多少美元。错误时红底高亮。
struct SystemEventView: View {
let event: AgentEvent
let isError: Bool
var body: some View {
HStack(spacing: 4) {
if isError {
RoundedRectangle(cornerRadius: 1).fill(Color.red).frame(width: 3)
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red)
} else {
Image(systemName: "info.circle").foregroundStyle(.secondary)
}
Text(event.content)
}
.background(isError ? Color.red.opacity(0.08) : Color.clear)
}
}
普通系统消息一行灰色文字 + info 图标。错误消息加红色左边条 + 红色背景 + 警告图标。
Agent 在执行时会持续产出事件。用户通常想看到最新的事件(自动滚到底部),但有时候想往上翻看历史。这两个需求是冲突的。
SwiftWork 用 ScrollModeManager 管理两种模式的切换:
enum ScrollMode {
case followLatest // 自动跟随最新事件
case manualBrowse // 用户手动浏览历史
}
@MainActor
@Observable
final class ScrollModeManager {
var scrollMode: ScrollMode = .followLatest
var showReturnToBottomButton: Bool {
scrollMode == .manualBrowse
}
private let nearBottomThreshold: CGFloat = 96
private let scrollUpThreshold: CGFloat = 16
private var cumulativeUpwardDelta: CGFloat = 0
}
自动跟随的条件: 当用户距底部不超过 96pt 时,自动切回 followLatest。每次新事件到来,TimelineView 自动滚到底部。
切到手动浏览的条件: 用户向上滚动超过 16pt 时,切到 manualBrowse。此时新事件不再触发自动滚动,右下角显示"回到底部"按钮。
// TimelineView.swift
.onChange(of: agentBridge.events.count) { _, newCount in
updateVisibleRangeForCount(newCount)
if scrollModeManager.scrollMode == .followLatest {
scrollToLast(proxy: proxy)
}
}
.onChange(of: agentBridge.streamingText) { _, _ in
if scrollModeManager.scrollMode == .followLatest {
scrollToLast(proxy: proxy)
}
}
两个 onChange 监听事件数量变化和流式文本变化。只有在 followLatest 模式下才自动滚动。
回到底部按钮: 点击后切回 followLatest,更新 visibleRange 到最新 50 条事件,动画滚到底部:
Button {
scrollModeManager.returnToBottom()
let total = agentBridge.events.count
let lower = max(0, total - 50)
visibleRange = lower..<total
withAnimation {
proxy.scrollTo("bottom-anchor", anchor: .bottom)
}
}
当事件数量超过几百条时,全部渲染会导致 LazyVStack 创建大量视图,滚动掉帧。SwiftWork 用 visibleRange + renderBuffer 做虚拟化——只渲染可见区域附近的 ±20 条事件。
@MainActor
final class TimelineVirtualizationManager {
let renderBuffer = 20
func eventsToRender(visibleRange: Range<Int>, allEvents: [AgentEvent]) -> [AgentEvent] {
guard !allEvents.isEmpty else { return [] }
let lower = max(0, visibleRange.lowerBound - renderBuffer)
let upper = min(allEvents.count, visibleRange.upperBound + renderBuffer)
guard lower < upper else { return [] }
return Array(allEvents[lower..<upper])
}
}
传入 ForEach 的不是 agentBridge.events,而是 virtualizedEvents——经过虚拟化裁剪后的子集:
private var virtualizedEvents: [AgentEvent] {
let allEvents = agentBridge.events
if allEvents.isEmpty { return [] }
if visibleRange.isEmpty {
let upper = allEvents.count
let lower = max(0, upper - 50)
return virtualizationManager.eventsToRender(visibleRange: lower..<upper, allEvents: allEvents)
}
return virtualizationManager.eventsToRender(visibleRange: visibleRange, allEvents: allEvents)
}
被裁掉的区域用占位符撑高度,保持滚动条的位置准确:
private var topPlaceholder: some View {
let upper = max(0, visibleRange.lowerBound - virtualizationManager.renderBuffer)
return Group {
if upper > 0 && !visibleRange.isEmpty {
Spacer().frame(height: CGFloat(upper) * estimatedRowHeight)
}
}
}
estimatedRowHeight 取 80pt——一个经验值,大部分事件视图的高度在这个范围附近。不需要精确,只需要让滚动条的大致位置正确。
visibleRange 在几个关键时刻更新:
.task(id: agentBridge.events.first?.id)):设为最后 50 条事件.onChange(of: events.count)):如果在 followLatest 模式,滑动窗口保持最新 50 条目前没有实现滚动过程中的 visibleRange 动态更新——用户向上滚动浏览大量历史事件时,visibleRange 不会跟着滚动位置变化。这是一个已知的限制,将来可以通过 onAppear/onDisappear 回调或 ScrollView 的 offset 监听来实现。
首次加载事件列表时,SwiftUI 的 ScrollView 默认从顶部开始渲染。如果会话有几百条事件,用户会先看到顶部的事件,然后闪一下跳到底部。这个闪烁在每次切换会话时都会出现。
SwiftWork 的解决方案:延迟 150ms 后再滚动到底部,等 LazyVStack 完成首屏渲染:
.task(id: agentBridge.events.first?.id) {
hasCompletedInitialScroll = false
guard !agentBridge.events.isEmpty else { return }
scrollModeManager.scrollMode = .followLatest
visibleRange = 0..<0
try? await Task.sleep(for: .milliseconds(150))
guard !Task.isCancelled else { return }
let total = agentBridge.events.count
let lower = max(0, total - 50)
visibleRange = lower..<total
withAnimation {
proxy.scrollTo("bottom-anchor", anchor: .bottom)
}
hasCompletedInitialScroll = true
}
hasCompletedInitialScroll 标记位控制后续的滚动模式切换——在初始滚动完成之前,onChange(of: scrollPositionId) 不会触发模式切换,避免干扰。
TimelineView 的设计可以概括为三个子系统:
| 子系统 | 解决的问题 | 实现 |
|---|---|---|
| 事件分派 | 18 种类型到 8 种视图 | eventView(for:) + ViewBuilder |
| 滚屏控制 | 自动跟随 vs 手动浏览 | ScrollModeManager + scrollPosition |
| 虚拟化 | 大量事件时的渲染性能 | visibleRange + renderBuffer + 占位符 |
事件分派是纯粹的视图逻辑——根据 event.type 选择对应的视图组件。滚屏控制和虚拟化是 TimelineView 独有的性能问题,跟 SDK 集成层无关。
下一篇看 Tool Card 系统——ToolRenderable 协议怎么让每种工具有自己的渲染器,以及 ToolRendererRegistry 怎么做到不改动时间线代码就能新增工具类型。
系列文章:
相关链接:
本文是「深入 SwiftWork」系列第 1 篇。系列目录见这里。
第 0 篇画了全景图——AsyncStream<SDKMessage> → AgentBridge → EventMapper → SwiftUI。这篇拆开中间两层:AgentBridge 和 EventMapper,看它们怎么把 SDK 的消息流变成 SwiftUI 可以直接消费的事件列表。
先说结论:AgentBridge 是整个应用里最复杂的单个文件。它同时做了五件事——消费 Stream、映射事件、配对工具内容、持久化数据、管理内存。每一件都不难,但五件叠在一起要处理不少状态。这篇文章逐个讲清楚。
回顾一下 SDK 提供的核心接口(第 1 篇讲过的):
// SDK 的 Agent.stream() 返回 AsyncStream<SDKMessage>
let agent = createAgent(options: ...)
for await message in agent.stream("hello") {
switch message {
case .assistant(let data): ...
case .toolUse(let data): ...
case .toolResult(let data): ...
// 18 种类型
}
}
SDK 给你一个 AsyncStream<SDKMessage>——一个异步事件流。SwiftUI 需要一个 [AgentEvent]——一个可以在主线程渲染的数组。AgentBridge 就是这两者之间的桥。
它的核心状态只有几个:
@MainActor
@Observable
final class AgentBridge {
var events: [AgentEvent] = [] // SwiftUI 消费的事件数组
var isRunning = false // Agent 是否在执行
var streamingText: String = "" // 流式文本的累积缓冲区
var toolContentMap: [String: ToolContent] = [:] // 工具内容配对
var errorMessage: String? // 错误信息
@ObservationIgnored private var agent: Agent?
@ObservationIgnored private var currentTask: Task<Void, Never>?
// ...
}
@MainActor 保证所有状态都在主线程访问。@Observable 让 SwiftUI 自动追踪变化。@ObservationIgnored 标记的 agent 和 currentTask 不需要触发 UI 更新——它们是实现细节,不是 UI 状态。
用户在输入框打字,按回车。InputBarView 调用 agentBridge.sendMessage(text)。接下来发生的事情:
func sendMessage(_ text: String) {
guard let agent, !text.isEmpty else { return }
if isRunning { cancelExecution() } // 如果正在跑,先停掉
// 1. 用户消息立即追加到事件列表
let userEvent = AgentEvent(type: .userMessage, content: text, timestamp: .now)
appendAndPersist(userEvent)
errorMessage = nil
isRunning = true
// 2. 递增 generation 计数器(用于检测过期的 cancel)
activeTaskGeneration &+= 1
let myGeneration = activeTaskGeneration
// 3. 在后台 Task 中消费 stream
currentTask = Task { [weak self] in
guard let self else { return }
var receivedResult = false
let stream = agent.stream(text)
for await message in stream {
guard !Task.isCancelled else { break }
if case .userMessage = message { continue }
let event = EventMapper.map(message)
// 流式文本走单独的缓冲区,不进 events 数组
if event.type == .partialMessage {
self.streamingText += event.content
continue
}
if event.type == .assistant {
self.streamingText = ""
}
if event.type == .result {
receivedResult = true
self.onResult?(event.content)
}
self.appendAndPersist(event)
}
// 流结束但没收到 result → 异常终止
if !Task.isCancelled && !receivedResult {
self.appendAndPersist(AgentEvent(
type: .system,
content: "Agent 流异常结束,未收到完整响应。",
metadata: ["isError": true],
timestamp: .now
))
}
self.finalizeToolContentMap()
if self.activeTaskGeneration == myGeneration {
self.currentTask = nil
}
self.isRunning = false
}
}
几个值得注意的设计决策:
用户消息不等 Stream。 用户消息直接追加到 events,不等 SDK 的 AsyncStream 返回 .userMessage。这样 UI 可以立即显示用户输入,不用等网络往返。Stream 里收到的 .userMessage 被 continue 跳过。
流式文本有单独的缓冲区。 partialMessage 不进 events 数组,而是累积到 streamingText。当收到完整的 .assistant 事件时,清空 streamingText。这样 SwiftUI 的 TimelineView 可以用一个单独的 StreamingTextView 渲染正在输入的文本,而 ForEach(events) 不需要频繁插入再删除。
Generation 计数器防止 cancel 竞态。 activeTaskGeneration 是一个递增的计数器。每次 sendMessage 都递增它,记录自己的 generation。Stream 结束后检查 if self.activeTaskGeneration == myGeneration,只有当前 generation 匹配时才清空 currentTask。这防止了用户快速连续发消息时的 cancel 竞态——前一个 Stream 的 cancel 回调不会把新一个 Task 的引用清掉。
EventMapper 做的事情很纯粹:SDKMessage → AgentEvent。没有副作用,没有状态。
struct EventMapper {
static func map(_ message: SDKMessage) -> AgentEvent {
switch message {
case .partialMessage(let data):
return AgentEvent(type: .partialMessage, content: data.text, timestamp: .now)
case .assistant(let data):
return AgentEvent(type: .assistant, content: data.text,
metadata: ["model": data.model, "stopReason": data.stopReason],
timestamp: .now)
case .toolUse(let data):
return AgentEvent(type: .toolUse, content: data.toolName,
metadata: ["toolName": data.toolName, "toolUseId": data.toolUseId,
"input": data.input],
timestamp: .now)
case .toolResult(let data):
return AgentEvent(type: .toolResult, content: data.content,
metadata: ["toolUseId": data.toolUseId, "isError": data.isError],
timestamp: .now)
case .toolProgress(let data):
return AgentEvent(type: .toolProgress, content: data.toolName,
metadata: ["toolUseId": data.toolUseId, "toolName": data.toolName,
"elapsedTimeSeconds": data.elapsedTimeSeconds ?? 0],
timestamp: .now)
case .result(let data):
return AgentEvent(type: .result, content: data.text,
metadata: ["subtype": data.subtype.rawValue, "numTurns": data.numTurns,
"durationMs": data.durationMs, "totalCostUsd": data.totalCostUsd],
timestamp: .now)
case .system(let data):
return AgentEvent(type: .system, content: data.message,
metadata: ["subtype": data.subtype.rawValue], timestamp: .now)
// hook、task、auth 等消息全部映射为 system 类型
case .hookStarted, .hookProgress, .hookResponse,
.taskStarted, .taskProgress,
.authStatus, .filesPersisted,
.localCommandOutput, .promptSuggestion, .toolUseSummary:
return AgentEvent(type: .system, content: extractContent(from: message),
metadata: extractMetadata(from: message), timestamp: .now)
case .userMessage(let data):
return AgentEvent(type: .userMessage, content: data.message, timestamp: .now)
}
}
}
映射策略:
assistant、toolUse、toolResult、toolProgress、result、userMessage 各自对应一个 AgentEventTypehookStarted/hookProgress/hookResponse、taskStarted/taskProgress、authStatus、filesPersisted 等 10 种 SDK 消息全部映射成 .system 类型,通过 metadata 区分具体子类型metadata 字典里,UI 视图按 key 取用为什么要用 metadata: [String: any Sendable] 而不是给每种事件类型定义单独的 struct?因为 metadata 是一个灵活的字典——新增事件类型时只需要在 EventMapper 里加一个 case,不需要定义新的模型类型。代价是类型安全性降低,取值时需要 as? 转换。对于 UI 层来说,这个取舍是合理的——事件数据只在渲染时读取,不需要编译期类型检查。
SDK 的工具调用经历三个阶段:toolUse(开始)→ toolProgress(进度更新)→ toolResult(完成)。它们是三个独立的 SDKMessage,但 UI 需要展示为一个完整的工具卡片——包含工具名称、输入参数、执行进度、输出结果。
这就是 toolContentMap 的用途。它用 toolUseId 做键,把三个阶段的事件合并成一个 ToolContent:
// AgentBridge+ToolContentMap.swift
func processToolContentMap(for event: AgentEvent) {
switch event.type {
case .toolUse:
let content = ToolContent.fromToolUseEvent(event)
toolContentMap[content.toolUseId] = content
case .toolProgress:
let toolUseId = event.metadata["toolUseId"] as? String ?? ""
if let existing = toolContentMap[toolUseId] {
toolContentMap[toolUseId] = existing.applyingProgress(event)
}
case .toolResult:
let resultContent = ToolContent.fromToolResultEvent(event)
let toolUseId = resultContent.toolUseId
if let existing = toolContentMap[toolUseId] {
toolContentMap[toolUseId] = ToolContent(
toolName: existing.toolName,
toolUseId: existing.toolUseId,
input: existing.input,
output: resultContent.output,
isError: resultContent.isError,
status: resultContent.status,
elapsedTimeSeconds: existing.elapsedTimeSeconds
)
}
default:
break
}
}
配对过程:
toolUse → 创建 ToolContent,状态 .pendingtoolProgress → 更新已有条目,状态改为 .running,记录耗时toolResult → 合并输出和错误状态,状态改为 .completed 或 .failedToolContent 是一个 struct,每次更新都创建新副本。AgentBridge 的 toolContentMap 是 @Observable 追踪的属性,所以每次赋值都会触发 SwiftUI 更新。这意味着工具卡片可以实时显示进度变化。
还有一个 finalizeToolContentMap 方法——在 Stream 结束时调用,把所有还在 .pending 或 .running 状态的工具标记为 .completed。防止 Stream 异常终止时,UI 上永远停着一个转圈的进度条。
每条事件都经过 appendAndPersist,同时更新内存数组和数据库:
private func appendAndPersist(_ event: AgentEvent) {
events.append(event)
processToolContentMap(for: event)
guard event.type != .partialMessage,
let eventStore, let currentSession else { return }
totalPersistedEvents += 1
try eventStore.persist(event, session: currentSession, order: eventOrder)
eventOrder += 1
trimOldEvents()
}
持久化通过 EventStoring 协议抽象:
@MainActor
protocol EventStoring {
func persist(_ event: AgentEvent, session: Session, order: Int) throws
func fetchEvents(for sessionID: UUID) throws -> [AgentEvent]
func fetchEvents(for sessionID: UUID, offset: Int, limit: Int) throws -> [AgentEvent]
func totalEventCount(for sessionID: UUID) throws -> Int
}
目前只有一个实现 SwiftDataEventStore,用 SwiftData 的 ModelContext 做存储。序列化是手写的 JSON——EventSerializer 把 AgentEvent 转成 [String: Any] 的字典再压成 Data:
// SwiftData 的 Event 模型
@Model
final class Event {
@Attribute(.unique) var id: UUID
var sessionID: UUID
var eventType: String
var rawData: Data // JSON 序列化的 AgentEvent
var timestamp: Date
var order: Int
var session: Session?
}
为什么把 metadata 塞进 rawData 而不是拆成独立的 SwiftData 字段?因为 metadata 的内容因事件类型而异——toolUse 有 toolName/toolUseId/input,result 有 numTurns/durationMs/totalCostUsd。拆成独立字段会导致大量空列,而且每次新增事件类型都要改 Schema。用一个 JSON blob 存储,读取时再反序列化,更灵活。
持久化的写入时机是每条事件一次。对于 Agent 的一次典型执行(可能产生 50-100 条事件),这意味着 50-100 次 SwiftData 写入。实测没有性能问题——SwiftData 在内存中缓存,批量刷盘。如果将来事件量更大,可以改成批量写入。
Agent 的一次复杂执行可能产生上千条事件。全部留在内存里不现实。AgentBridge 用了两层策略:
private let maxInMemory = 500
func trimOldEvents() {
guard events.count > maxInMemory else { return }
let removeCount = events.count - maxInMemory
let removed = Array(events.prefix(removeCount))
events.removeFirst(removeCount)
trimmedEventCount += removeCount
for event in removed {
if event.type == .toolUse {
let toolUseId = event.metadata["toolUseId"] as? String ?? ""
toolContentMap.removeValue(forKey: toolUseId)
}
}
}
内存数组最多保留 500 条事件。超出部分从头部删除,同时清理 toolContentMap 里对应的条目。trimmedEventCount 记录已经删除了多少条,用于分页查询时的偏移计算。
切换会话时,loadEvents 按总量决定加载策略:
func loadEvents(for session: Session) {
clearEvents()
currentSession = session
guard let eventStore else { return }
let total = try eventStore.totalEventCount(for: session.id)
totalPersistedEvents = total
if total > 1000 {
// 大会话:只加载第一页
let firstPage = try eventStore.fetchEvents(for: session.id, offset: 0, limit: 50)
events = firstPage
eventOrder = total
} else {
// 小会话:全部加载
let persisted = try eventStore.fetchEvents(for: session.id)
events = persisted
eventOrder = persisted.count
}
rebuildToolContentMap()
}
用户向上滚动时,loadMoreEvents 按页追加:
func loadMoreEvents() {
guard let eventStore, let currentSession else { return }
let offset = trimmedEventCount + events.count
guard offset < totalPersistedEvents else { return }
let remaining = totalPersistedEvents - offset
let limit = min(pageSize, remaining)
let nextPage = try eventStore.fetchEvents(for: currentSession.id, offset: offset, limit: limit)
events.append(contentsOf: nextPage)
rebuildToolContentMap()
}
hasMoreEvents 是一个计算属性,SwiftUI 可以用它显示"加载更多"按钮:
var hasMoreEvents: Bool {
totalPersistedEvents > trimmedEventCount + events.count
}
SDK 的 permissionMode: .default 会在工具执行前询问用户是否允许。AgentBridge 通过 setCanUseTool 回调接入这个机制:
private func setupPermissionCallback() {
agent?.setCanUseTool { [weak self] tool, input, _ in
guard let self else { return .allow() }
return await self.handlePermission(tool: tool, input: input)
}
}
PermissionHandler 先检查已有的权限规则(用户之前选过"始终允许"的工具)。如果规则匹配,直接放行。如果没有匹配的规则,弹出一个原生的 SwiftUI sheet 让用户审批:
var pendingPermissionRequest: PendingPermissionRequest?
PendingPermissionRequest 内部用一个 CheckedContinuation 挂起异步执行,等用户点击"允许一次"/"始终允许"/"拒绝"后恢复:
private func presentPermissionDialog(...) async -> CanUseToolResult {
let request = PendingPermissionRequest(...)
self.pendingPermissionRequest = request
let dialogResult = await request.waitForResult() // 挂起,等 UI 操作
self.pendingPermissionRequest = nil
switch dialogResult {
case .allowOnce: // 本次允许
case .alwaysAllow: // 写入持久规则
case .deny: // 拒绝
}
}
这个设计把 SDK 的同步权限检查(canUseTool 回调)和 SwiftUI 的异步 UI 交互(用户点击按钮)桥接在一起,靠 Swift 的 async/await + CheckedContinuation 实现。
AgentBridge 的配置入口是 configure:
func configure(apiKey: String, baseURL: String?, model: String, workspacePath: String?) {
let options = AgentOptions(
apiKey: apiKey,
model: model,
baseURL: baseURL,
maxTurns: 10,
permissionMode: .default,
cwd: workspacePath,
tools: getAllBaseTools(tier: .core)
)
self.agent = createAgent(options: options)
setupPermissionCallback()
}
每次用户切换会话,WorkspaceView 会重新调用 configure(因为不同会话可能有不同的 workspace path):
// WorkspaceView.swift
.onChange(of: session.id) { _, _ in
agentBridge.clearEvents()
configureAgent() // 重新创建 Agent
loadPersistedEvents() // 加载该会话的历史事件
setupTitleGeneration() // 设置自动标题
}
clearEvents 做完整的重置——清空事件数组、取消正在执行的 Task、重置分页状态:
func clearEvents() {
events = []
streamingText = ""
errorMessage = nil
isRunning = false
toolContentMap = [:]
currentTask?.cancel()
currentTask = nil
eventOrder = 0
totalPersistedEvents = 0
trimmedEventCount = 0
}
AgentBridge 承担了五个职责:
| 职责 | 实现方式 |
|---|---|
| 消费 Stream | Task 里 for await 循环,cancel 时 Task.cancel() |
| 映射事件 | EventMapper.map() 纯函数 |
| 配对工具内容 | toolContentMap: [String: ToolContent] |
| 持久化 | EventStoring 协议 + SwiftData 实现 |
| 内存管理 | 500 条滑动窗口 + 按需分页加载 |
整条管线在 @MainActor 上运行,SwiftUI 通过 @Observable 自动响应变化。视图层不需要知道 Stream 的存在,不需要知道 SDK 的类型,只需要处理 AgentEvent 和 ToolContent。
下一篇看事件时间线——TimelineView 怎么渲染 18 种事件、怎么做虚拟化、怎么处理流式文本和滚动行为。
系列文章:
相关链接:
本文是「深入 SwiftWork」系列第 0 篇。系列目录见这里。
前面七篇文章加上番外篇,我们把 Open Agent SDK 的内部机制翻了个底朝天——Agent Loop、工具系统、MCP 集成、多 Agent 协作、会话持久化、多 LLM 支持。番外篇还把 SDK 塞进了一个 macOS 原生应用 Motive 里跑了跑。
但 Motive 只是一个替换后端的实验。真正的问题是:拿到 SDK 之后,怎么从零开始构建一个完整的 Agent 应用? SDK 给了你 Agent 的"大脑"——但用户看到的不是 Agent Loop,而是一个界面。Agent 在调工具、读文件、执行命令的时候,用户需要知道它在干什么、进展如何、结果是什么。
这就是 SwiftWork 要解决的问题——一个 macOS 原生的 Agent 可视化工作台。
SwiftWork 是一个 macOS 原生的 AI Agent 桌面应用。它做的事情用一句话概括:让用户看到 Agent 正在做什么。
具体来说:
这不是一个终端里的 CLI 工具,也不是一个网页应用。它是用 SwiftUI 写的原生 macOS 应用,用 @Observable 做状态管理,用 SwiftData 做数据持久化,用 Apple 原生的渲染管线做 Markdown 和代码高亮。
做 SwiftWork 有两个动机。
第一,SDK 需要一个"展示应用"。 SDK 的 31 个示例项目覆盖了各种用法——流式输出、自定义工具、MCP 集成——但都是命令行工具。SDK 的能力需要一个 GUI 来完整展示,尤其是工具调用的可视化、事件流的实时渲染这些 CLI 做不好的事情。
第二,Agent 应用的可视化是一个被低估的问题。 当前的 Agent 应用(包括 Claude Code 自己)在终端里跑,用户看到的是滚动的文字流。但 Agent 在执行一个复杂任务时,可能调用十几次工具、读写多个文件、执行多条命令。终端里的线性输出很难让用户理解全局进展。SwiftWork 试图用事件时间线和工具卡片来改善这个问题。
SwiftWork 采用事件驱动架构。整条数据流是一条单向管线:
SDK Agent Loop
│
│ AsyncStream<SDKMessage>
▼
AgentBridge (@Observable)
│
│ EventMapper.map() → AgentEvent
▼
AgentBridge.events: [AgentEvent]
│
│ SwiftUI 自动响应 @Observable 变化
▼
TimelineView → 各 EventView
四个角色:
| 组件 | 职责 |
|---|---|
| Agent Loop | SDK 提供,跑 Agent 的推理循环,产出 SDKMessage 流 |
| AgentBridge | 消费 AsyncStream<SDKMessage>,映射成 AgentEvent,管理生命周期 |
| EventMapper | 纯函数,SDKMessage → AgentEvent 的类型映射 |
| TimelineView | SwiftUI 视图,消费 AgentEvent 数组,渲染时间线 |
核心设计决策:视图不直接接触 SDK 类型。 AgentEvent 是 SwiftWork 自己定义的 UI 模型,跟 SDK 的 SDKMessage 完全解耦。视图只认 AgentEvent,不知道也不关心事件来自哪个 SDK 版本。
用户发一条消息,整条管线的运转过程:
// AgentBridge.swift
func sendMessage(_ text: String) {
// 用户消息直接追加到事件列表
let userEvent = AgentEvent(type: .userMessage, content: text, timestamp: .now)
appendAndPersist(userEvent)
isRunning = true
// 在后台 Task 中消费 stream
currentTask = Task { [weak self] in
let stream = agent.stream(text)
for await message in stream {
let event = EventMapper.map(message)
self.appendAndPersist(event)
}
self.isRunning = false
}
}
用户消息先追加到事件列表(即时显示),然后启动一个 Task 来消费 SDK 的 AsyncStream。
EventMapper 是一个纯函数,把 SDK 的 18 种 SDKMessage 映射成 SwiftWork 的 AgentEventType:
// EventMapper.swift
static func map(_ message: SDKMessage) -> AgentEvent {
switch message {
case .assistant(let data):
return AgentEvent(type: .assistant, content: data.text,
metadata: ["model": data.model, "stopReason": data.stopReason], timestamp: .now)
case .toolUse(let data):
return AgentEvent(type: .toolUse, content: data.toolName,
metadata: ["toolName": data.toolName, "toolUseId": data.toolUseId, "input": data.input],
timestamp: .now)
case .toolResult(let data):
return AgentEvent(type: .toolResult, content: data.content,
metadata: ["toolUseId": data.toolUseId, "isError": data.isError], timestamp: .now)
// ... 18 种消息类型
}
}
为什么要有这一层映射?因为 SDK 的类型是给 Agent 运行时用的,它包含很多 UI 不需要的细节。AgentEvent 只保留 UI 渲染需要的字段:类型、内容、元数据、时间戳。视图不需要知道 SDKMessage 的枚举定义,只需要处理 AgentEventType。
每条事件都经过 appendAndPersist,同时更新内存数组和 SwiftData 数据库:
private func appendAndPersist(_ event: AgentEvent) {
events.append(event)
processToolContentMap(for: event)
guard event.type != .partialMessage,
let eventStore, let currentSession else { return }
try eventStore.persist(event, session: currentSession, order: eventOrder)
eventOrder += 1
trimOldEvents()
}
注意 partialMessage 不持久化——它是流式文本的中间片段,累积完成后会生成一条完整的 .assistant 事件。
AgentBridge 标记了 @Observable。当 events 数组变化时,TimelineView 自动重新渲染:
// TimelineView.swift
ForEach(virtualizedEvents) { event in
eventView(for: event)
}
eventView 根据 event.type 分派到不同的视图组件——UserMessageView、AssistantMessageView、ToolCardView、SystemEventView 等。
SwiftWork/
├── App/
│ ├── SwiftWorkApp.swift # @main 入口,注册 SwiftData 模型
│ └── ContentView.swift # NavigationSplitView 根视图
├── Models/
│ ├── UI/ # UI 模型层
│ │ ├── AgentEvent.swift # 事件模型(SwiftUI 渲染用)
│ │ ├── AgentEventType.swift # 18 种事件类型枚举
│ │ ├── ToolContent.swift # 工具内容(配对 toolUse + toolResult)
│ │ ├── PermissionDecision.swift # 权限决策
│ │ └── AppError.swift # 错误模型
│ └── SwiftData/ # 持久化模型层
│ ├── Session.swift # 会话
│ ├── Event.swift # 持久化事件
│ ├── AppConfiguration.swift # 应用配置
│ └── PermissionRule.swift # 权限规则
├── ViewModels/
│ ├── SessionViewModel.swift # 会话 CRUD
│ └── SettingsViewModel.swift # 设置管理
├── Views/
│ ├── Sidebar/ # 会话列表
│ ├── Workspace/
│ │ ├── Timeline/
│ │ │ ├── TimelineView.swift # 时间线主视图 + 虚拟化
│ │ │ ├── EventViews/ # 各事件类型视图 + ToolCardView
│ │ │ │ ├── ToolRenderers/ # 5 个内置工具渲染器
│ │ │ │ ├── StreamingTextView.swift
│ │ │ │ ├── MarkdownContentView.swift
│ │ │ │ └── ...
│ │ │ └── Inspector/ # 事件详情面板
│ │ └── InputBar/ # 消息输入框
│ ├── Settings/ # 设置界面
│ ├── Onboarding/ # 首次启动引导
│ └── Permission/ # 权限审批弹窗
├── SDKIntegration/
│ ├── AgentBridge.swift # SDK ↔ ViewModel 桥接
│ ├── AgentBridge+ToolContentMap.swift # 工具内容配对逻辑
│ ├── EventMapper.swift # SDKMessage → AgentEvent
│ ├── ToolRenderable.swift # 工具渲染协议
│ └── ToolRendererRegistry.swift # 工具渲染注册表
├── Services/
│ ├── CodeHighlighter.swift # Splash 代码高亮
│ ├── MarkdownRenderer.swift # swift-markdown 渲染
│ ├── KeychainManager.swift # API Key 安全存储
│ ├── EventStore.swift # 事件持久化接口
│ ├── AppStateManager.swift # 应用状态保存/恢复
│ └── TitleGenerator.swift # 自动生成会话标题
└── Utils/
└── Extensions/ # 颜色、日期格式化等
结构上的核心分层:
AgentEvent)是给 SwiftUI 渲染用的,SwiftData 模型(Event)是给持久化用的。两者之间有转换逻辑。OpenAgentSDK。| 组件 | 选择 | 原因 |
|---|---|---|
| 语言 | Swift 6.1 严格并发 | Agent SDK 要求,Sendable 保证线程安全 |
| UI | SwiftUI + @Observable |
macOS 14+ 支持,跟 Swift 并发配合好 |
| 持久化 | SwiftData | 跟 SwiftUI 深度集成,比 Core Data 简洁 |
| Markdown | swift-markdown (Apple) | 原生 Apple 库,CommonMark 兼容 |
| 代码高亮 | Splash (John Sundell) | 轻量、支持 Swift/Python/JS/Bash |
| 自动更新 | Sparkle 2.x | macOS 应用更新的标准方案 |
| Agent SDK | Open Agent SDK | 自己写的 SDK,当然用自己的 |
这篇文章给了一个全景图。接下来的文章会逐层拆开,看每个子系统怎么实现:
AsyncStream<SDKMessage>、映射事件、管理生命周期ToolRenderable 协议和可扩展的工具渲染器相关链接: