关于 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 自动生成。
此报告仅供参考,不构成任何投资建议。投资有风险,入市需谨慎。
Planet 里如果添加 iCloud 同步,那么最大的 blocker 是 IPNS 目前(似乎)无法稳定地从多台机器上发布最后的 CID 到同一个 IPNS key。但是如果是发布到 Cloudflare Pages,那么就完全不是问题。
如果:
这件事情大概不会发生在现实世界。但不妨碍我实践这个假想实验。
本文是「深入 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 协议和可扩展的工具渲染器相关链接:
早上好!以下为昨日摘要:
SOL - $83.74 PUMP - $0.0018 V2EX - $0.0018





| 排名 | 地址 | 持有数量 | 持仓比例 | 排名变化 | 数量变化 |
|---|---|---|---|---|---|
| #2 | Pump.fun AMM (V2EX-WSOL) Pool | 52.19M | 5.22% | - | -0.13M |
| #11 | Meteora (V2EX-PUMP) Market | 4.95M | 0.49% | - | +3.57K |
| #28 | Meteora (V2EX-WET) Market | 1.35M | 0.13% | - | +356.86 |
| #31 | BeCool | 1.16M | 0.12% | +2 | +9.38K |
TG 机器人订阅: 点我订阅
原始图表与数据存放于: 点我查看
此报告由 V2EX Info 提供数据, 由 Newsletter Report Bot 自动生成。
此报告仅供参考,不构成任何投资建议。投资有风险,入市需谨慎。
Clumsy Redraw runs the viral prompt — "redraw the attached image in the most clumsy, scribbly, and utterly pathetic way possible" — on any photo you upload. The result: MS Paint with a mouse on a white background, vaguely similar but also not really, with that low-quality pixel-by-pixel feel that really emphasizes how ridiculously bad it is.
Codex 的新功能 /goal 实在是让我兴奋到睡不着。太多可能性了。
实际尝试之后——现阶段还是无法实现丢一个复杂任务给它,然后睡醒之后看结果。中途还是需要太多输入。
早上好!以下为昨日摘要:
SOL - $83.01 PUMP - $0.0018 V2EX - $0.0017





| 排名 | 地址 | 持有数量 | 持仓比例 | 排名变化 | 数量变化 |
|---|---|---|---|---|---|
| #2 | Pump.fun AMM (V2EX-WSOL) Pool | 52.32M | 5.23% | - | -82.37K |
| #11 | Meteora (V2EX-PUMP) Market | 4.94M | 0.49% | - | -305.36 |
| #28 | Meteora (V2EX-WET) Market | 1.35M | 0.13% | - | +37.34K |
TG 机器人订阅: 点我订阅
原始图表与数据存放于: 点我查看
此报告由 V2EX Info 提供数据, 由 Newsletter Report Bot 自动生成。
此报告仅供参考,不构成任何投资建议。投资有风险,入市需谨慎。
本文是「深入 Open Agent SDK (Swift)」系列番外篇。系列目录见这里。
前七篇文章从各个子系统分析了 Open Agent SDK 的设计。但 SDK 写得好不好,最终得放到真实项目里验证。这篇文章记录我把 SDK 集成到一个开源 macOS 原生 Agent 应用——Motive——的完整过程:从理解原有架构到实现替换,以及一路上踩过的坑。
Motive 是一个 macOS 原生的 AI Agent 桌面应用,用 SwiftUI 写的。它的核心交互是:用户输入 prompt → Agent 在后台跑 Agent Loop(调工具、读文件、执行命令)→ 流式输出结果到 UI。
在集成 SDK 之前,Motive 的 Agent 后端长这样:
Motive App (SwiftUI)
└── OpenCodeBridge (actor)
├── OpenCodeServer — 启动外部 opencode 二进制进程 (opencode serve)
├── SSEClient — 通过 Server-Sent Events 接收流式事件
└── OpenCodeAPIClient — 通过 REST API 发送 prompt、回复权限请求
每次用户发 prompt,Motive 要:
opencode serve 进程(如果没在跑的话)POST /sessions 创建会话POST /sessions/{id}/prompt 发送 prompt这套架构能用,但有几个问题:
opencode CLI,Motive 还要处理二进制签名、路径查找SDK 的出现正好给了另一种可能——把 Agent Loop 直接跑在应用进程内。
我想做的替换:不启动外部进程,不经过 HTTP,直接在 Motive 进程内用 SDK 的 Agent.stream() 跑 Agent Loop。
目标架构:
Motive App (SwiftUI)
└── BackendBridge (enum wrapper)
├── .opencode → OpenCodeBridge (原有架构,保留)
└── .sdk → SDKBridge (新增,用 OpenAgentSDK)
└── Agent.stream() → 直接在进程内跑 Agent Loop
保留原有的 OpenCodeBridge 作为备选,让用户可以在设置中切换后端类型。这是一个务实的决定——万一 SDK 后端有问题,用户还能切回去。
原有的 OpenCodeBridge 是一个 actor,Motive 的 AppState 直接跟它交互。现在要加一个平行的 SDKBridge,需要一个分派层。
我用了一个 enum 而不是 protocol:
enum BackendBridge {
case opencode(OpenCodeBridge)
case sdk(SDKBridge)
func submitIntent(text: String, cwd: String, ...) async { ... }
func interrupt() async { ... }
func stop() async { ... }
// ...
}
为什么不用 protocol?因为 OpenCodeBridge 和 SDKBridge 的能力不完全一样。OpenCodeBridge 有权限请求(permission)、问题回复(question)等 SDK 后端不需要的概念。用 enum 可以在共享接口上做统一分派,同时保留各自特有的方法:
// OpenCode-only 方法,SDK 后端直接 no-op
func replyToQuestion(requestID: String, answers: [[String]], ...) async {
guard case .opencode(let bridge) = self else { return }
await bridge.replyToQuestion(requestID: requestID, answers: answers, ...)
}
对于 AppState 来说,大部分代码不需要改——它调 bridge.submitIntent(),至于底层是 HTTP 还是 SDK,它不关心。
SDKBridge 是整个替换的核心。它是一个 actor,负责:
Configuration(API key、model、MCP servers 等)createAgent() 创建 AgentAgent.stream() 获取流式响应SDKMessage 映射成 Motive 已有的 OpenCodeEventactor SDKBridge {
struct Configuration: Sendable {
let apiKey: String
let model: String
let provider: String // "anthropic", "openai", etc.
let baseURL: String?
let debugMode: Bool
let projectDirectory: String
let mcpEntries: [String: MCPEntry]?
let env: [String: String]?
let skillDirectories: [String]?
}
struct MCPEntry: Sendable {
let command: String
let args: [String]?
let env: [String: String]?
}
}
MCPEntry 是中间类型——Motive 的配置系统有自己的 MCP 描述格式,在传入 SDK 之前转成 McpServerConfig.stdio。
private func createAgent(from config: Configuration, sessionId: String? = nil) -> Agent {
let provider: LLMProvider = Self.anthropicProviders.contains(config.provider) ? .anthropic : .openai
let mcpServers = config.mcpEntries?.mapValues { entry in
McpServerConfig.stdio(McpStdioConfig(
command: entry.command,
args: entry.args,
env: entry.env
))
}
// 始终包含 core + specialist 工具,确保基本能力
let coreTools = getAllBaseTools(tier: .core) + getAllBaseTools(tier: .specialist)
return OpenAgentSDK.createAgent(options: AgentOptions(
apiKey: config.apiKey,
model: config.model,
baseURL: config.baseURL,
provider: provider,
permissionMode: .bypassPermissions,
cwd: config.projectDirectory,
tools: coreTools,
mcpServers: mcpServers,
sessionStore: sessionStore,
sessionId: sessionId,
skillDirectories: config.skillDirectories,
logLevel: config.debugMode ? .debug : .none,
env: config.env
))
}
注意几个细节:
"anthropic"、"openai"),SDK 用 LLMProvider 枚举,这里做了转换这是最核心的方法。用户每次发 prompt 都走这里:
func submitIntent(
text: String,
cwd: String,
agent: String? = nil,
forceNewSession: Bool = false,
correlationId: String? = nil
) async {
guard let config = configuration else {
eventContinuation.yield(OpenCodeEvent(kind: .error, rawJson: "", text: "SDK bridge not configured"))
return
}
let sessionId = forceNewSession ? UUID().uuidString : (currentSessionId ?? UUID().uuidString)
currentSessionId = sessionId
// 创建 Agent
let sdkAgent = createAgent(from: config, sessionId: sessionId)
self.agent = sdkAgent
// 取消之前的流
streamTask?.cancel()
// 在后台 Task 中消费 stream
streamTask = _Task { [weak self] in
guard let self else { return }
for await message in sdkAgent.stream(text) {
guard !_Task.isCancelled else { return }
await self.handleSDKMessage(message, sessionId: sessionId)
}
}
}
用 Swift 的 Task 包裹 stream() 的 for await 循环,这样用户中断时可以 cancel 掉这个 Task。注意 _Task 是 _Concurrency.Task 的别名——因为 OpenAgentSDK 里也有个 Task 类型,直接用 Task 会冲突。
Motive 的 UI 已经有一套基于 OpenCodeEvent 的事件处理系统。与其重写 UI 层,不如在 bridge 层做映射:
private func handleSDKMessage(_ message: SDKMessage, sessionId: String) {
switch message {
case .partialMessage(let data):
eventContinuation.yield(OpenCodeEvent(kind: .assistant, rawJson: "", text: data.text))
case .toolUse(let data):
eventContinuation.yield(OpenCodeEvent(kind: .tool, rawJson: "", text: data.input,
toolName: data.toolName, toolCallId: data.toolUseId))
case .toolResult(let data):
let output = data.isError ? "Error: \(data.content)" : data.content
eventContinuation.yield(OpenCodeEvent(kind: .tool, rawJson: "", text: "",
toolName: "Result", toolOutput: output, toolCallId: data.toolUseId))
case .result(let data):
// 映射 usage
// 映射 finish / error
...
default:
break
}
}
eventContinuation 是一个 AsyncStream<OpenCodeEvent>.Continuation,在初始化时传入。AppState 在 MainActor 上消费这个流,驱动 UI 更新。这个设计让 SDKBridge 和 OpenCodeBridge 共用同一套 UI 处理逻辑——AppState 不知道也不关心事件来自哪个后端。
这不是一次顺利的替换。以下是我遇到的真实问题。
这是最头疼的问题。macOS 的 GUI 应用不继承用户的 shell 环境。SDK 的 MCPStdioTransport 用 Process 启动 MCP 子进程时,PATH 里没有 nvm、homebrew 等路径——MCP 服务器找不到 node、python。
解决方案:在 buildSDKMcpServers() 里手动构建扩展 PATH:
let extendedPath = configManager.buildExtendedPath(base: ProcessInfo.processInfo.environment["PATH"])
for entry in mcpEntries {
var mergedEnv = spec.environment
// ...
mergedEnv["PATH"] = extendedPath // 注入扩展 PATH
}
这样 MCP 子进程能找到正确的 node/python 可执行文件。OpenCode 后端没这个问题,因为 opencode CLI 是从终端启动的,自带完整 shell 环境。
SDK 的 assembleFullToolPool() 在没有 MCP 服务器时走了一条短路径——只返回 baseTools(用户自定义工具),不包含内置的 Core 和 Specialist 工具。这意味着如果不配 MCP,Agent 连 Read、Write、Bash 都没有。
修复:在 createAgent() 里始终传入 core + specialist 工具:
let coreTools = getAllBaseTools(tier: .core) + getAllBaseTools(tier: .specialist)
return OpenAgentSDK.createAgent(options: AgentOptions(
// ...
tools: coreTools, // 始终包含
// ...
))
AppState.start() 里异步配置 bridge,但用户可能在配置完成之前就发了 prompt。这导致 "SDK bridge not configured" 错误。
修复:在每次 submitIntent 和 resumeSession 之前都调用 configureBridge(),确保配置是最新的:
func submitIntent(...) async {
await configureBridge() // 先确保配置完成
// 然后检查配置是否成功
guard configuration != nil else { ... }
// ...
}
OpenAgentSDK 的类型命名跟 Swift 标准库有冲突——SDK 里有个 Task 类型(用于任务追踪),跟 Swift 并发的 Task 撞了。直接写 Task { } 编译器会找错类型。
用 typealias 解决:
private typealias _Task = _Concurrency.Task
然后所有地方用 _Task { } 代替 Task { }。
不是所有 LLM 提供商都需要 API key。本地运行的 Ollama、LM Studio 就不需要。但 SDK 默认要求 API key 不为空。
修复:在配置时检查 provider 是否允许空 API key:
if apiKey.isEmpty, !configManager.provider.allowsOptionalAPIKey {
lastErrorMessage = "API key required for SDK backend. Check Settings."
return
}
SDK 本身也支持空 API key——传入空字符串就行,它会跳过认证 header。
为了让 SDK 后端能连接外部 MCP 工具,我在 Advanced Settings 里加了一个 MCP 服务器配置界面。用户可以添加自定义的 MCP stdio 服务器(配置命令、参数、环境变量),保存到 UserDefaults,然后在创建 Agent 时注入。
struct CustomMcpServerConfig: Codable, Identifiable {
let id: UUID
var name: String
var command: String
var args: [String]
var env: [String: String]
var enabled: Bool
}
这些自定义服务器在 buildSDKMcpServers() 里跟 Skill 系统注册的 MCP 服务器合并,一起传给 SDK。
替换前后的关键差异:
| 方面 | OpenCode 后端 | SDK 后端 |
|---|---|---|
| Agent 运行位置 | 外部 opencode 进程 |
应用进程内 |
| 通信方式 | REST API + SSE | 直接函数调用 |
| 启动延迟 | 进程冷启动 ~2-5s | 毫秒级 |
| 额外依赖 | 需要安装 opencode CLI | SPM 依赖,无需额外安装 |
| 调试 | 跨进程,需要看外部日志 | 进程内,Xcode 断点直接打 |
| 事件映射 | SSE JSON → OpenCodeEvent | SDKMessage → OpenCodeEvent |
| MCP 服务器 | opencode 内部管理 | 应用层配置,通过 SDK 传入 |
替换后代码量对比:
SDKBridge.swift:361 行(新增)BackendBridge.swift:134 行(新增)AppState+Bridge.swift:+123/-16 行(修改)AdvancedSettingsView.swift:+309/-44 行(MCP UI)总共净增约 600 行,换来的是去掉了对外部二进制的依赖。
这次集成验证了 SDK 在以下方面的工程表现:
能用的部分:
Agent.stream() 的 AsyncStream<SDKMessage> 接口简洁,可以直接用在 SwiftUI 的响应式流程里SessionStore 的会话持久化开箱即用,不需要自己管理 JSON 文件permissionMode: .bypassPermissions 适合桌面应用的自动执行场景需要注意的部分:
Task 命名冲突需要手动解决assembleFullToolPool() 在无 MCP 时的短路径行为需要了解清楚整体评价: SDK 的 API 设计对 GUI 应用集成是友好的。核心的 createAgent + stream 两个调用就替代了原来启动外部进程 + HTTP 服务 + SSE 客户端 + REST API 客户端四个组件。对于一个 361 行的 actor 来说,这个替换比是合理的。
完整代码在 terryso/motive,已经合并了 SDK 后端,可以直接 clone 下来跑。
系列文章:
相关链接:
