早上好!以下为昨日摘要:
SOL - $93.16 PUMP - $0.0021 V2EX - $0.0019





| 排名 | 地址 | 持有数量 | 持仓比例 | 排名变化 | 数量变化 |
|---|---|---|---|---|---|
| #2 | Pump.fun AMM (V2EX-WSOL) Pool | 52.39M | 5.24% | - | -0.26M |
| #9 | Meteora (V2EX-WSOL) Market | 5.03M | 0.50% | - | -4.67K |
| #11 | Meteora (V2EX-PUMP) Market | 4.95M | 0.49% | - | +0.897312 |
| #17 | Livid | 2.60M | 0.26% | - | +8.54K |
| #49 | Meteora (V2EX-WET) Market | 0.70M | 0.07% | -5 | -63.37K |
TG 机器人订阅: 点我订阅
原始图表与数据存放于: 点我查看
此报告由 V2EX Info 提供数据, 由 Newsletter Report Bot 自动生成。
此报告仅供参考,不构成任何投资建议。投资有风险,入市需谨慎。
我有没有去锻炼身体?
我有没有去做一些努力减少生活中冒出来的混乱?
理解和实践断舍离。不买无用之物。持续清理不需要的东西。
我有没有和家人在一起的时间?
我有没有花时间去做一个使用起来感到愉悦的软件?
早上好!以下为昨日摘要:
SOL - $91.96 PUMP - $0.0021 V2EX - $0.0019





| 排名 | 地址 | 持有数量 | 持仓比例 | 排名变化 | 数量变化 |
|---|---|---|---|---|---|
| #2 | Pump.fun AMM (V2EX-WSOL) Pool | 52.65M | 5.26% | - | +0.21M |
| #9 | Meteora (V2EX-WSOL) Market | 5.04M | 0.50% | - | +4.70K |
| #30 | BeCool | 1.18M | 0.12% | - | +4.79K |
| #44 | Meteora (V2EX-WET) Market | 0.77M | 0.08% | -4 | -0.10M |
TG 机器人订阅: 点我订阅
原始图表与数据存放于: 点我查看
此报告由 V2EX Info 提供数据, 由 Newsletter Report Bot 自动生成。
此报告仅供参考,不构成任何投资建议。投资有风险,入市需谨慎。
这一周的 brew update,像是给 AI Agent 做了一次集体亮相。
不是某个 IDE 插件、某个 chatbot 包装, 而是连"自我成长"、"使用监控"、"代码评审"都各自有了独立工具。
Agent 不再是某个产品里的一个功能, 它正在变成一个生态。
当一个领域同时出现了"做事的人"、"看着做事的人"和"统计做事人花了多少钱的人",这个领域就不再是趋势,是产业了。
| 名称 | 中文说明 |
|---|---|
| alevin-fry | 单细胞测序数据处理工具,灵活高效 |
| barman | PostgreSQL 备份与恢复管理器 |
| crit | AI Agent 的本地反馈回路,专门用来 review 它写的代码 |
| cutadapt | 移除测序数据中的 adapter 序列 |
| defuddle | 网页正文与元数据提取,Readability 的替代选择 |
| echtvar | 快速的变异注释与过滤工具 |
| freesasa | 溶剂可及表面积(SASA)计算库 |
| gemmi | 大分子晶体学计算库与命令行工具 |
| ginkgo | 高性能数值线性代数库 |
| hermes-agent | 自我学习的 AI Agent,从经验里长出新技能 |
| hyphy | 基于系统发育树的假设检验 |
| iqtree3 | 极大似然法系统发育分析 |
| [email protected] | Kubernetes 1.35 版本的 kubectl |
| lavinmq | 轻量 AMQP/MQTT 消息中间件 |
| libchardet | Mozilla 通用字符集检测器 C/C++ API |
| oarfish | 长读长 RNA-seq 定量工具 |
| opendoor | Web 侦察、目录发现与暴露面评估 CLI |
| paml | 基于极大似然的 DNA/蛋白质系统发育分析 |
| pnpm@10 | 高效的 Node.js 包管理器 v10 |
| pomerium | 身份与上下文感知的访问代理 |
| smlnj | Standard ML 编译器与编程系统 |
| spoa | SIMD 加速的偏序对齐工具 |
| stellar-xdr | Stellar 网络的 XDR 编解码 CLI |
| unordered_dense | 高性能 robin-hood 哈希表/集合 |
| vcflib | 解析与处理 VCF 文件的 C++ 库与工具 |
| zapp | 从终端刷写 ZSA 键盘固件 |
| zfp | 支持高速随机访问的压缩数值数组 |
| 名称 | 中文说明 |
|---|---|
| eez-studio | 仪表自动化与 GUI 开发的可视化工具 |
| factory | Factory.ai 的桌面客户端,把 Droids 装进本地工作流 |
| font-akt | 字体:Akt |
| font-alien-block | 字体:Alien Block |
| font-finlandica-headline | 字体:Finlandica 标题版 |
| font-finlandica-text | 字体:Finlandica 正文版 |
| font-m-plus-u | 字体:M+ U |
| ghostpepper | 语音转文字与会议转写工具 |
| jetbrains-air | JetBrains 出品的 Agent 原生开发环境 |
| manus | 自动化本地电脑工作流的 AI Agent |
| open-webui | Open WebUI 的桌面客户端 |
| openusage | Cursor / Claude Code / Codex / Copilot 的 AI 用量统计工具 |
| screenkite | 屏幕录制与编辑工具 |
| wox | 启动器工具 |
| yakit | 网络安全集成平台 |
这一节不求全, 只挑几个"我看到时停了一下"的东西。
Agent 写代码的速度已经超过了人类阅读代码的速度。这不是夸张。当 Cursor 或者 Claude Code 一次给你 300 行 diff,你下意识做的事是滚动到底,看一眼测试通过没有,然后点 accept。
crit 想做的事很朴素——让 review 这一步重新变成"事",而不是"形式"。它把 agent 生成的 plan 和 code 平铺在一个本地界面里,你可以画出某几行,留一条评论,然后点 finish review,agent 自动按你的评论改。
它没有解决"agent 怎么写得更好", 它解决的是"我怎么不再假装我看过了"。
本地优先、一键分享、支持 190 种语言高亮、Vim 键位——这些都只是配料。真正的判断是:当 agent 越来越快,慢这一步反而成了奢侈品。
Nous Research 出的,定位有点克制——它不是 copilot,不是聊天机器人壳,而是"在你服务器上跑的自治 agent"。
但真正的彩蛋藏在描述里那一句:self-improving。它会从你的项目里持续学习,把解决过的问题留存为"技能",下一次遇到同类问题直接调用。
这个想法不新,新的是它落地的方式:跨 Telegram、Discord、Slack、WhatsApp、邮件、CLI 操作;五种沙箱后端(local / Docker / SSH / Singularity / Modal);并行子 agent 各跑各的对话和终端;自然语言 cron。它在赌的是——agent 真正的护城河不是模型,而是记住自己解决过什么。
它跟 Claude Code 的 skill 机制几乎是同一个方向。只是 hermes 更激进:技能不是人写好的,是它自己长出来的。
Factory.ai 这周有了 Cask。1.5B 估值、150M C 轮、agent 能直接进 VS Code / JetBrains / Vim / 浏览器 / CLI / Slack / Teams 甚至 issue tracker。
它的口号是 "agents that work everywhere you do",听上去像营销,但拆开看其实是个判断:Agent 不应该绑定 IDE,agent 应该绑定工作流。你在哪儿干活,它就在哪儿出现。
这跟同周出现的 jetbrains-air(JetBrains 的"agent 原生开发环境")形成了一种微妙对照——一个想让 agent 跟着人走,另一个想给 agent 造一个自己的家。两条路都还没分出胜负,但桌面级 agent 应用这件事,已经从一两个先锋变成了一片小浪潮。
Obsidian Web Clipper 的副产品,结果反过来比主产品更值得记一笔。
它做的事就一件:从乱七八糟的网页里把正文抠出来,输出干净的 HTML 或 Markdown,顺手把元数据(标题、作者、发布时间、favicon、schema.org)也提取了。
跟 Mozilla Readability 比,它更"宽容"——不确定的元素倾向保留而不是删除,因为很多时候 Readability 删得太狠,把脚注和图注也一起带走了。它还会用移动端样式来识别"装饰性"元素,这个思路挺机灵的。
为什么值得停一下:在"AI 把网页喂给模型"成为日常的今天,预处理这一步的质量直接决定了下游的智商。defuddle 做的是那种默默改善整个生态的事。
把这周的新增放在一起看,AI agent 这个领域出现了一种新的拓扑:
一个领域开始出现"周边工具",意味着它过了草莽期。当有人开始专门做"用量监控"、"代码审计"、"数据清洗"的时候,agent 就不再是某个 IDE 里的一个 panel,它开始有自己的供应链。
也就是说——Agent 终于不是单飞了。
老实说,这周新增的科研工具不少(alevin-fry、cutadapt、hyphy、paml、iqtree3 一整套生物信息学工具链),但我没办法假装它们跟我有关系。这是 Homebrew 有意思的地方——它从来不只是"开发者商店",它还是某些科研社区的发行渠道。
让我留意的是 zapp。ZSA 给自己的键盘做了个 brew install 的刷固件 CLI。这事很小,但很温柔——硬件厂商认真对待终端用户,是这个时代越来越稀缺的事。
至于 AI agent 那一堆,老实说,我未必会都装。openusage 我大概率装,因为我已经被某个月的 token 账单吓过一次;crit 我会观望,看它跟现有 review 习惯能不能对上;hermes-agent 现在还有点重,但方向我赞同——agent 的未来不在模型里,在它的"经验积累"里。
一周之内,Homebrew 同时收下了"做事的 agent"、"审 agent 的工具"、"统计 agent 花销的应用"。这种多层次出现,本身就是一个信号。
工具不再是孤立的功能点,而是开始彼此咬合。当 agent 有了 reviewer、有了用量监控、有了数据清洗器,它就从"实验"变成了"生产"。
一个赛道开始有"配角",才说明它真的成了。
早上好!以下为昨日摘要:
SOL - $88.41 PUMP - $0.0020 V2EX - $0.0018





| 排名 | 地址 | 持有数量 | 持仓比例 | 排名变化 | 数量变化 |
|---|---|---|---|---|---|
| #2 | Pump.fun AMM (V2EX-WSOL) Pool | 52.44M | 5.24% | - | +0.10M |
| #5 | DdGV...aRkf | 7.02M | 0.70% | - | +2.30K |
| #9 | Meteora (V2EX-WSOL) Market | 5.03M | 0.50% | - | +19.8341 |
| #11 | Meteora (V2EX-PUMP) Market | 4.95M | 0.49% | - | +6.4322 |
| #17 | Livid | 2.59M | 0.26% | - | +7.16K |
| #40 | Meteora (V2EX-WET) Market | 0.87M | 0.09% | - | +3.56K |
TG 机器人订阅: 点我订阅
原始图表与数据存放于: 点我查看
此报告由 V2EX Info 提供数据, 由 Newsletter Report Bot 自动生成。
此报告仅供参考,不构成任何投资建议。投资有风险,入市需谨慎。
因为AI给大部分人造成的效率幻觉, 让人们觉得自己是超人, 通过AI快速出来了一个demo, 就迫不及待的来给大家推广安利
以期能够撞到蓝海或者风口, 实现一波暴富, 没错, 说的是我🤡
好的产品往往需要细细的打磨, 对每一个环节的设计和实现, 都应该精益求精甚至苛刻.
每一步的交互, 每一个逻辑的实现, 都应该为提升用户体验来服务, 掌握每一行代码往往是做到这一点的前提.
但是在AI辅助开发的时代, 掌握每一行代码真的是太困难了, 需要太大的毅力和精力来处理AI的代码, 这是很多人不想做的一件事情, 当然也包括我
让AI来审核AI看似是一条解决之路, 把自己定位成管理者, 管理各种AI员工来工作, 自己只需要验证结果, 甚至结果也让AI来验证, 貌似没有什么问题, 但是这样会让事情逐渐的走向失控.
我对项目的了解越来越少, 甚至我都不知道一个功能需求是通过什么方式来实现的, 虽然这个功能运行良好.
随着迭代次数的增加, 这样的功能需求越来越多, 直到有一天, 我打开了项目, 想要排查一个线上的问题.
看着熟悉又陌生的代码, 我发现我已经无法掌控这个庞然大物, 所以排查线上的问题就直接扔给了AI员工来处理, 在AI员工给我汇报原因和解决方案的时候, 我甚至都不能确定这是不是问题的根源, 但是只能签字部署, 观察结果, 像极了我年轻时候的领导, 哈哈哈, 终归是活成了我最讨厌的模样.
以上, 是闲暇时的碎嘴, 但是在我和AI互帮互助的过程中, 确实产生了一些类似上述的问题, 也拍脑袋的做了很多的小玩具.
昨晚上睡前冥想的时候, 猛然警觉, 我现在使用AI做的这些玩具Demo, 除了消耗一些Token, 浪费大家一些流量带宽, 还有什么其他的意义吗? 答案是大部分都没啥意义, 纯粹是做出来玩一下, 博个眼球, 要些关注, 甚至有些玩具生成完连代码都没有看一眼.
今天上班骑行的路上, 我也在思考AI到底能不能做出优秀的作品来, 到现在大概得出了一个不成熟的结论:
决定作品是否优秀的从来不是框架、编程语言或者大模型, 而是做这个作品的人.
用AI也好, 用古法也罢, 只有静下心来, 专心打磨, 才有可能产出优秀的作品.
与诸君共勉!
早上好!以下为昨日摘要:
SOL - $89.15 PUMP - $0.0019 V2EX - $0.0019





| 排名 | 地址 | 持有数量 | 持仓比例 | 排名变化 | 数量变化 |
|---|---|---|---|---|---|
| #2 | Pump.fun AMM (V2EX-WSOL) Pool | 52.34M | 5.23% | - | -0.19M |
| #9 | Meteora (V2EX-WSOL) Market | 5.03M | 0.50% | - | +19.8054 |
| #11 | Meteora (V2EX-PUMP) Market | 4.95M | 0.49% | - | +2.92K |
| #17 | Livid | 2.58M | 0.26% | - | +7.14K |
| #30 | BeCool | 1.18M | 0.12% | - | +4.72K |
| #40 | Meteora (V2EX-WET) Market | 0.87M | 0.09% | - | +565.05 |
TG 机器人订阅: 点我订阅
原始图表与数据存放于: 点我查看
此报告由 V2EX Info 提供数据, 由 Newsletter Report Bot 自动生成。
此报告仅供参考,不构成任何投资建议。投资有风险,入市需谨慎。
关于 SideStore & LiveContainer,网上有太多使用安装教程,但信息比较零碎和缺乏必要说明。
大部分情况下,SideStore & LiveContainer 官方文档和Issues足矣解决99.99%的问题。
老鸟可以略过,新人可以继续阅读。
下面我简单说一下,我对这个项目的理解,以及如何正确使用。
SideStores 是什么?
“SideStore is a fork of AltStore that doesn't require an AltServer.”
“SideStore is an untethered, community driven alternative app store for non-jailbroken iOS devices”
上面两段话意思,SideStore 是一个替代应用商店的应用,不需要用户越狱即可安装应用。
“🍎越狱(iOS Jailbreaking)是指通过技术手段,利用iOS系统的漏洞,获取iPhone、iPad等苹果设备操作系统的最高权限(Root权限),从而打破苹果公司所设定的封闭式生态环境限制。”
越狱手机后的手机,可以安装任何应用,没有任何限制🚫。但是,并不是所有人都希望越狱,但又想安装其他非🍎官方上架的应用,怎么办?
SideStore 就可以做到,完美解决非越狱用户即可安装应用的需求。
在 SideStore 出现之前,AltStore 一直是这项需求这个领域的首选方案。但是 AltStore 有一个非常麻烦的问题:每隔7天需要刷新一次,否则通过 AltStore 安装的应用就会不可使用。
SideStore 并没有解决这个问题:需要每隔7天刷新一次。
但是,SideStore 可以较方便的在Wi-Fi环境下刷新应用,而 AltStore 只能在电脑上刷新。
“这里强调一点,AltStore 只能在电脑上刷新,是因为依赖 AltServer,只要设备和电脑在同一网络,也可以通过Wi-Fi自动刷新。”
在使用 SideStore 之前,必须了解官方提供了:
具体如何安装,本文不赘述,建议查看官方文档。
安装 SideStore.ipa,还可以通过以下几种方式:
当然,仍然希望大家遵循 SideStore 官方文档。
“为什么要说这件事情?因为 SideStore 迭代很快,之前确实推荐使用 AltStore / Sideloadly 安装 SideStore,后面有了 iloader,才开始成为主要安装方式。”
也许若干时间,还有其他变化。😊
说完 SideStore,现在聊聊 LiveContainer。
SideStore 最多只能装3个App,而 LiveContainer,能突破这个限制,变成近乎无限。
这就是 LiveContainer 最大的魅力,其他玩法(多个 LiveContainer),可以查看官方文档或社区。
最后,分享一些 SideStore & LiveContainer 的使用心得:
如果不小心掉签了,别急,可以在电脑上通过iloader恢复。
早上好!以下为昨日摘要:
SOL - $86.22 PUMP - $0.0019 V2EX - $0.0018





| 排名 | 地址 | 持有数量 | 持仓比例 | 排名变化 | 数量变化 |
|---|---|---|---|---|---|
| #2 | Pump.fun AMM (V2EX-WSOL) Pool | 52.53M | 5.25% | - | +0.40M |
| #9 | Meteora (V2EX-WSOL) Market | 5.03M | 0.50% | - | +53.0446 |
| #11 | Meteora (V2EX-PUMP) Market | 4.95M | 0.49% | - | -37.0122 |
| #40 | Meteora (V2EX-WET) Market | 0.87M | 0.09% | -12 | -0.44M |
TG 机器人订阅: 点我订阅
原始图表与数据存放于: 点我查看
此报告由 V2EX Info 提供数据, 由 Newsletter Report Bot 自动生成。
此报告仅供参考,不构成任何投资建议。投资有风险,入市需谨慎。
早上好!以下为昨日摘要:
SOL - $84.08 PUMP - $0.0018 V2EX - $0.0018





| 排名 | 地址 | 持有数量 | 持仓比例 | 排名变化 | 数量变化 |
|---|---|---|---|---|---|
| #2 | Pump.fun AMM (V2EX-WSOL) Pool | 52.13M | 5.21% | - | -0.20M |
| #9 | Meteora (V2EX-WSOL) Market | 5.03M | 0.50% | - | +9.7453 |
| #11 | Meteora (V2EX-PUMP) Market | 4.95M | 0.49% | - | -30.1715 |
| #17 | Livid | 2.58M | 0.26% | - | +4.72K |
| #28 | Meteora (V2EX-WET) Market | 1.31M | 0.13% | - | -38.58K |
| #31 | BeCool | 1.17M | 0.12% | - | +4.66K |
TG 机器人订阅: 点我订阅
原始图表与数据存放于: 点我查看
此报告由 V2EX Info 提供数据, 由 Newsletter Report Bot 自动生成。
此报告仅供参考,不构成任何投资建议。投资有风险,入市需谨慎。
早上好!以下为昨日摘要:
SOL - $83.90 PUMP - $0.0018 V2EX - $0.0018





| 排名 | 地址 | 持有数量 | 持仓比例 | 排名变化 | 数量变化 |
|---|---|---|---|---|---|
| #2 | Pump.fun AMM (V2EX-WSOL) Pool | 52.33M | 5.23% | - | +0.22M |
| #9 | Meteora (V2EX-WSOL) Market | 5.03M | 0.50% | - | +2.09K |
| #11 | Meteora (V2EX-PUMP) Market | 4.95M | 0.49% | - | +1.09K |
| #28 | Meteora (V2EX-WET) Market | 1.35M | 0.13% | - | +37.50K |
| #31 | BeCool | 1.17M | 0.12% | - | +9.31K |
TG 机器人订阅: 点我订阅
原始图表与数据存放于: 点我查看
此报告由 V2EX Info 提供数据, 由 Newsletter Report Bot 自动生成。
此报告仅供参考,不构成任何投资建议。投资有风险,入市需谨慎。
本文是「深入 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 协议和可扩展的工具渲染器相关链接:
