资讯详情

从零实现macOS原生Gemini客户端:流式对话与菜单栏集成

📅 2026/10/9 9:18:10 | 华诺云谱 👁 阅读
从零实现macOS原生Gemini客户端:流式对话与菜单栏集成
我的 Gemini API 额度终于不再“吃灰”了。折腾了一个多月我停用了几个不爱更新的第三方客户端从零写了一个 macOS 原生 Gemini 客户端日常翻译、改文案、写脚本、查资料全放这一个应用里搞定。项目已经开源核心代码都整理在 GitHub 仓库里需要自取顺手帮我点颗星就更好了。这个客户端叫它 Toolbox 也好、Chat 壳也好本质上是把 Gemini 的流式对话能力“焊”进了 macOS 系统层。和网页版最大的区别是我不用再开一个浏览器标签页傻傻等它输出也不用担心切个窗口上下文就找不到了。全局快捷键一按提问框出现在屏幕上答完按 Esc 消失整个过程不打断当前工作流这才是我愿意天天用的形态。如果你也买了 Gemini API 额度但用得稀稀拉拉或者一直想找个趁手的 macOS AI 对话工具却找不到理想的这篇文章值得你花十分钟看完。我会把选型思路、核心代码、踩过的坑一次说清楚基本是按“复盘笔记”的颗粒度来写希望你能直接抄作业。1. 为什么原生客户端是刚需1.1 网页版和第三方客户端的痛点先说网页版。习惯了键盘操作的人应该都有同感打开网页、登录、新建会话、输入问题每一步都在换场景真的非常打断思路。我写代码时会同时开着编辑器、模拟器、终端和文档再把浏览器切到 Gemini 页面光是找回刚才聊到哪一句话就要多花几秒钟。更烦的是网页会话隔离上次问了一半的历史下次再打开可能已经忘记了开头。第三方客户端我也试过不少桌面端和折腾过的各种“壳”都有。问题集中在三个地方要么是包体巨大动辄几百 MB装完感觉不是工具是负担要么是只做了聊天界面菜单栏、快捷键、系统分享这些原生集成完全没有要么干脆闭源不放心把自己的 API Key 和对话记录交给它。我身边不少朋友也因为这个问题宁愿继续用网页版忍一忍就过去了。还有一个很实际的场景上班摸鱼。这里不是鼓励上班开小差而是说当你想在工作中快速查一个技术名词、改一句话术又不想把网页切来切去引起注意时一个悬浮在桌面角落的轻量问答框体验是网页完全给不了的。我把代码写好后公司电脑上也装了一份查命令参数基本三秒就搞定窗口看起来像 IDE 的辅助面板非常低调。1.2 原生客户端的三个不可替代优势第一是系统集成。原生应用可以直接用 macOS 的菜单栏、键盘快捷键、剪贴板、文件访问权限这些都是网页版做不到的。我的客户端支持“选中任意应用里的文字按快捷键直接提问”这个功能在多语言翻译场景下极其好用。PDF 里选中一段英文快捷键呼出问答框它自动带入上下文并默认要求翻译成中文比开浏览器的翻译插件快得多。第二是响应速度和资源占用。原生 App 没有浏览器那一层开销启动它就是一个 StatusBar 进程内存占用控制在几十 MB 级别。我用 Electron 写过类似工具内存轻松上 300 MB这在 MacBook 上是很直观的卡顿来源。SwiftUI 写这种小工具编译完就是几 MB 的二进制运行起来手指根本感受不到负担。第三是数据可控。请求可以直接从你自己的设备发出API Key 存在系统 Keychain 里历史记录存在本地数据库。不会出现“第三方服务器中转你的对话”这种风险。只要代码开源你甚至可以自己审计它到底往外界传了什么这是很多黑盒客户端永远给不了的承诺。1.3 技术选型SwiftUI 而不是 Electron 或 Tauri老实说最开始我考虑的也是跨平台方案毕竟 Python 才是我的主场SwiftUI 写起来需要重新熟悉不少语法。但实际对比之后我发现这套工具的定位决定了它更应该走原生路线。Electron 的优势是 Web 技术栈人才好找、生态丰富、界面好做缺点是包体大、内存高、系统融合感一般。Tauri 相对轻但也依赖 WebView而且在流式输出这种高频 UI 更新场景下WebView 和原生控件之间会有一层桥接损耗做不好容易有输入延迟。对一个只需要常驻菜单栏、弹出一个小窗口的工具来说SwiftUI 的 AppKit 后端已经足够而且天然支持 NSPopover、NSStatusItem 这些 macOS 专属控件。这里要提一句Swift 学习成本并没有想的高。如果你会一点 JavaScriptSwiftUI 声明式语法的理解成本很低核心就是把 state 和 view 绑定数据一变界面自动刷新。真正花时间的是熟悉 Xcode 的操作和打包签名流程这部分我后面单独讲。2. 功能设计与技术底座2.1 菜单栏常驻 弹窗交互macOS 聊天客户端的最优解我观察过自己使用 AI 助手的习惯绝大多数提问都是“短平快”的这个函数签名什么意思、帮我起个变量名、把这段话改成正式语气。真正需要长篇对话的场景其实很少。所以界面的最优形态是菜单栏图标 点击弹出小窗而不是一个正经的窗口应用。用 SwiftUI 实现这个结构非常直接通过 NSStatusBar 创建一个常驻图标点击图标时弹出 NSPopoverpopover 内部嵌一个 SwiftUI view。这种交互的好处是不占 Dock 位置不抢焦点呼出和隐藏都只需要一次点击或一个快捷键天然适配多桌面不会弹错 Space结合全局快捷键连鼠标都不用碰我参考了 macOS 上很多小工具的做法比如一些剪贴板历史工具它们的菜单栏弹窗设计已经很成熟。核心思路就是主界面不要设计成独立窗口所有操作都在 popover 里完成。2.2 核心功能拆解与取舍最终我确定的功能清单如下功能说明优先级多轮对话同一会话内保持上下文支持连续追问高流式输出逐字显示回复不整段等待高多模型切换Gemini 2.5 Flash / 2.5 Pro 等模型秒切高菜单栏弹窗全局呼出不打断当前工作高历史记录本地保存会话启动后自动恢复中选中文字提问选中任意文本快捷键直接带入提问高对话导出导出为 Markdown 文件低语音输入用系统语音识别转文字低中间砍掉的功能也有不少。原本想做 markdown 对话和图片的“双栏预览”开发到一半发现 90% 的使用场景根本不需要反而让弹窗体积变大、输入区变小果断放弃了。经验是这种小工具功能越少越容易坚持用下去每次加功能之前我都会反问一句“没有它我会不会不用这个应用”2.3 本地数据模型与存储对话记录我放在本地 SQLite 里通过 SwiftData 这个 SwiftUI 原生的持久化框架来管。数据模型很简单就两层Session会话和 Message消息。Model final class Session { Attribute(.unique) var id: UUID UUID() var title: String 新对话 var model: String gemini-2.5-flash var createdAt: Date Date() Relationship(deleteRule: .cascade, inverse: \Message.session) var messages: [Message] [] } Model final class Message { var id: UUID UUID() var role: String user // user / model var text: String var createdAt: Date Date() var session: Session? }设计这个模型时我特别注意了一个点不要试图把 gemini 返回的原始 JSON 原样存下来因为这个格式可能会随 API 版本变化。我只需要存 text 字段加上 role恢复会话时重新组装一个 contents 数组传给 API 即可。这样即使将来接口变了历史数据也不会作废。SwiftData 初上手会有一个坑Relationship 的反向引用必须写对否则删除会话时子消息不会被级联删除。我这个项目里显式指定了inverse: \Message.session实测删除会话后消息表会跟着清理干净不会留下孤儿数据。3. Gemini API 接入与流式输出实现3.1 generateContent 与 streamGenerateContent到底该用哪个Gemini API 的能力入口主要分两条路径非流式的 generateContent 和流式的 streamGenerateContent。前者返回一个完整 JSON一般几秒到十几秒后才拿到全部结果后者通过 SSEServer-Sent Events把结果分段推送客户端可以在第一段到达时就渲染文字响应体验好得多。对一个聊天应用来说流式是必须的。原因不只是心理上的“快”也是实际工程需要长文生成如果整段返回网络中断或超时一次就全丢了流式则可以积累已收到的内容中断后也能保留部分输出。我当时的实现直接选:streamGenerateContent再加altsse参数让接口返回标准 SSE 格式。端点长这样模型可换POST https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?altssekeyYOUR_API_KEY请求体是标准的 JSON。多轮对话时把历史消息按 role 组合成 contents 数组即可{ contents: [ { role: user, parts: [{ text: 写一条朋友圈文案主题是周末爬山 }] }, { role: model, parts: [{ text: 给你两版一版轻松一点一版文艺一点…… }] }, { role: user, parts: [{ text: 把文艺那版再压缩到 30 字以内 }] } ] }注意contents 数组里的内容和后端返回的 fullText 有区别实际 UI 显示时可以自己重新拼接不必每次都拿上一次的原始响应。我的做法是本地消息表里把 user 和 model 消息按顺序存好请求时从数据库读取并组装这样和界面显示完全一致不会出现显示版本和发送版本不一致的尴尬。3.2 用 URLSession 做 SSE 流式接收代码拆给你看Swift 里没有现成的 SSE 库——这不是说找不到而是为了一个流式解析引入第三方依赖不值得。SSE 的核心格式比想象的简单服务器不断吐“data: ”开头的一行行文本每个数据块之间用空行分隔最后以“data: [DONE]”表示结束。我直接用 URLSession 的 bytes stream 就能解析。下面这段是我简化后的核心逻辑func streamChat(messages: [ChatMessage]) async throws - AsyncThrowingStreamString, Error { AsyncThrowingStream { continuation in let task Task { var request URLRequest(url: streamURL) request.httpMethod POST request.setValue(application/json, forHTTPHeaderField: Content-Type) request.httpBody try JSONEncoder().encode(buildRequestBody(messages: messages)) let (bytes, response) try await URLSession.shared.bytes(for: request) guard let http response as? HTTPURLResponse, http.statusCode 200 else { continuation.finish(throwing: APIError.badStatus) return } var buffer for try await line in bytes.lines { if line.isEmpty { continue } if line.hasPrefix(data:) { let payload line.dropFirst(5).trimmingCharacters(in: .whitespaces) if payload [DONE] { continuation.finish() return } if let data payload.data(using: .utf8), let json try? JSONSerialization.jsonObject(with: data) as? [String: Any], let candidates json[candidates] as? [[String: Any]] { if let text candidates.first?[content] as? [String: Any], let parts text[content] as? String { continuation.yield(parts) } else { // 兼容部分返回结构 let parts (text[parts] as? [[String: Any]])? .compactMap { $0[text] as? String } .joined() ?? continuation.yield(parts) } } } } continuation.finish() } continuation.onTermination { _ in task.cancel() } } }这里有几个细节你需要特别注意一是 URLSession.shared 默认会对响应做缓冲SSE 流要确保禁用某些缓存策略不然会变成等全部拉完才回调。我在实际开发中遇到过一个现象网络明明很快但文字是一大段一大段地跳出来后来发现是 URLSession 的默认缓存策略导致。二是 bytes.lines 是按行读取的但 Gemini 返回的数据块有可能把一行 JSON 拆成多行所以在解析时我用了一个 buffer 变量做粘包处理。如果只按行直接解析偶尔会出现 JSON 解析失败、某个字直接丢掉的 case。这个坑非常隐蔽日志也看不出明显异常只在输出文本里偶发少字。三是 await URLSession.shared.bytes(for:) 这个方法在 iOS 16/macOS 13 以上才有我的部署目标直接设成了 macOS 13完全够用。如果你的系统版本偏低建议换用旧的 URLSession.dataTask 回调方案原理是一样的。3.3 流式文本的增量渲染别让界面被频繁刷新拖垮拿到流式片段后最直观的做法是每来一段就刷新一次 UI。但文本从几百字到几千字的过程中SwiftUI 的 Text 控件更新频率过高会导致光标闪烁、输入卡顿。我的做法是维护一个字符串缓冲把收到的片段 append 进去然后用一个 30ms 的节流器控制 UI 刷新频率。也就是说代码层面每收到一个 chunk 就更新 buffer但界面最多每 30ms 重新渲染一次。这样既保证了“逐字输出”的观感又不会每来几个字就触发一次全量重绘。渲染 Markdown 我用了 Prince 和 Ink 这类 Swift 下的 Markdown 解析库直接把增量文本转成 AttributedString 显示。这个方案有个好处流式过程中偶尔会收到不完整的 Markdown 语法片段比如代码块只开了三个反引号还没闭合但渲染器会把它们当作普通文本处理不会崩溃。State private var outputText State private var renderTimer: Timer? State private var pendingText private func feedStream(_ chunk: String) { pendingText.append(chunk) if renderTimer nil { renderTimer Timer.scheduledTimer(withTimeInterval: 0.03, repeats: true) { _ in outputText pendingText if pendingText.hasSuffix() { renderTimer?.invalidate() } } } }注意上面只是节流框架没有做真正完整的 Markdown 状态检测生产版本里我换成了基于解析器的状态回调。不过思路是一样的UI 刷新频率需要人为限制不要被流式节奏牵着鼻子走。3.4 API Key 管理这块不容儿戏API Key 直接硬编码进代码或者写进 UserDefaults都会成为安全隐患。我把 Key 放到了系统的 Keychain 里用 KeychainAccess 这个本地库封装了一层读写都走安全存储。用户首次打开应用时输入 Key之后每次请求从 Keychain 读取。Key 不会出现在配置文件或日志里源码仓库里也没有任何真实 Key 的影子。开源项目尤其要注意这一点一旦 Key 被硬编码进仓库哪怕你立刻删掉它也可能被抓取过仓库历史的人拿到。我还加了一个“密钥失效快速反馈”机制请求返回 401 时弹窗内直接提示用户检查 Key而不是把错误 JSON 原样展示出来。这个体验细节帮了大忙因为非技术用户第一次配 Key 时最容易犯的错误就是复制了多余的空格。4. 开发半个月我踩过的那些值得你避开的坑4.1 URLSession 缓冲让流式变成了“假流式”这个坑放在第一个说因为它最隐蔽。第一次跑通端到端时我看到的现象是请求发出后静止两三秒然后整段文字一次刷新出来。从最终结果看似乎没问题但页面底部不会有一个字一个字蹦出来的“生成感”。排查过程花了我一个晚上。最后发现是 URLSession 默认会缓存整个响应体必须显式禁用缓存并设置流式读取模式let config URLSessionConfiguration.default config.requestCachePolicy .reloadIgnoringLocalAndRemoteCacheData config.urlCache nil config.timeoutIntervalForRequest 60改完之后文字立刻按 chunk 速度涌现。这个坑也解释了为什么许多“伪流式”应用存在不管服务端支不支持 SSE只要客户端没处理对用户在界面上看到的效果和一次请求没有任何区别。4.2 长对话上下文膨胀token 超限被强制截断多轮对话做得越顺畅消息列表就越长。某个会话聊了二三十轮之后请求体里的 contents 数组越来越长模型的 context window 有上限继续下去会报 400 token 超限错误。我的解决方案是维护一个“最近 N 轮”的滑动窗口。每次请求前从数据库里读出最近 20 轮消息再统计总字符数超过阈值就把最早的消息折叠为一段摘要用“历史摘要 最近消息”的方式送到模型那里。这个方案比粗暴丢弃前文效果好得多模型还保留对早期话题的大致记忆同时不会撑爆上下文。你也可以用 Gemini API 的服务端上下文缓存功能但我发现对于桌面单用户聊天场景本地滑动窗口已经足够不需要额外开销。4.3 菜单栏弹窗的键盘焦点问题NSPopover 默认不会自动成为 key window输入框弹出后偶尔出现“光标没进去”的问题。你需要手动调一下popover.contentViewController MainViewController() popover.behavior .transient.transient 模式让 popover 在点击外部时自动关闭这对轻量工具是合理交互。但问题是 popover 的文本框有时需要 tap 一下才能获得焦点对于想“一呼出就打字”的用户来说很烦。解决方法是弹出时主动调用popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) DispatchQueue.main.async { self.inputField.becomeFirstResponder() }实测 DispatchQueue 的延迟让 first responder 设置可靠得多。另外别忘了设置输入框的 key view cycle否则 Tab 键可能无法在输入框和按钮之间切换。4.4 快捷键冲突怎么避免和系统抢占全局快捷键我用了系统级事件监听。一开始我设的是 Command Shift Space结果和系统输入法切换快捷键冲突了按下去直接切换输入法弹窗纹丝不动。后来换成 Function 区中的一个功能键情况立刻好了。快捷键方案建议用RegisterEventHotKey的 Carbon API或者封装好的 KeyboardShortcuts 库。后者是目前 macOS 开源项目里体验最成熟的可以自动处理冲突检测。我实际用的是自绘的 Key 记录面板让用户在设置页自己按下一个组合键存到 UserDefaults 里启动时注册全局热键。这种方式比写死快捷键灵活得多适合不同键盘习惯的用户。4.5 打包签名和公证不是“开发完就能分发的”这可能是新手最容易卡住的环节。从 Xcode 里 Run 出来的 app 只能自己电脑运行别人拿到会是“已损坏”或者被 Gatekeeper 拦截。要分发给其他人需要Apple Developer 账号个人账号即可$99/年对 app 进行 Developer ID 签名用 notarytool 做公证notarization把公证通过后的 ticket 拼接进应用完整的命令流程我整理在了项目的 README 里。这里用表格总结一下各环节的作用步骤作用常见问题代码签名保证 app 未被篡改证书类型选错签名后仍提示损坏公证让系统知道 app 已通过安全扫描缺少 entitlements 会被拒拼接 ticket离线环境下也能绕过 Gatekeeper 提示忘了 staple线上用户点右键菜单才运行如果你只是自用跳过签名也可以但我强烈建议按正规流程走一遍体验一下 macOS 的完整性保护机制。分发到 GitHub Release 之后用户下载安装到 Application双击就能运行不会看到任何弹窗警告这比“右键-打开”的临时方案体面得多。5. 开源之后这个项目能带给你什么5.1 开源是所有开发者的“复利投资”这个项目从第一天起就打算开源。动机很直接我的 API 额度自己又用不完不如把客户端代码分享出去让同样有 API Key 的人不用重复造轮子。开源之后收到的 issue 和 PR 反而是最大收获有人提了多窗口支持有人修了中文排版问题还有人贡献了更好的 Markdown 解析库替换方案。“回本”这个标题不是随便起的。我算过一笔账如果订阅一个成熟的聊天客户端的付费版一年也要几百块自己做这个工具的时间成本确实不低但它带来的后续收益不仅体现在省下订阅费还包括对整个 Gemini API 接入流程的熟悉以及对 macOS 打包分发机制的理解。这些经验以后做其他 macOS 工具时都能直接复用相当于一次投入多次产出。当然开源也带来一些责任README 要写清楚使用方法API Key 存储逻辑必须经得起审查隐私政策哪怕不复杂也得交代明白。但这些工作本身也是项目质量的一部分公开代码会倒逼你写出更干净的代码因为你知道有人在看。5.2 开源项目的日常维护成本我原本以为开源是一劳永逸的发布完就躺平了。实际上两周不维护消息提醒能堆到几十条常见的问题翻来覆去就几类问怎么配 Key 的问能不能支持 Windows 的问 Xcode 版本和部署版本不匹配的偶尔还有报告崩溃的解决方案是把 README 写得非常细致加一个有代表性的“如何从源码运行”的文档把环境要求、Xcode 版本、部署目标全部写清楚。一个问题被问了三次就说明本地文档有缺漏优先补文档而不是重复回答。一个比较实用的建议是把 issue 模板建好要求提问者附上系统版本、Xcode 版本、日志片段。这样 80% 的问题能在第一轮就被过滤掉省下来的时间可以投入到真正的功能开发上。5.3 后续计划多模态、语音、以及更顺滑的交互Gemini 本身是多模态模型我现在的客户端还只接了文本对话下一步准备把图片理解也加进来。用户可以拖一张截图进输入框模型直接分析图表或者识别 UI 元素这对产品原型讨论非常有用。语音输入也在计划中用 macOS 自带的 Speech framework 做本地识别避免上传音频带来的隐私负担。快捷键唤醒、说完自动提交、流式文字和语音同屏展示这种交互一旦做顺使用频率会再上一个台阶。我对这个项目的定位是“自己每天都会用的工具”所以后续的每个功能迭代都会从真实需求出发而不是为了炫技。如果你有好的想法也欢迎来 GitHub 提 issue没准就是下一个版本的主角。最后分享一个我个人的经验开发这类小工具别在开始前想太多先搭出一个能跑的壳再在真实使用中不断调整。我第一版只用了一个下午就把菜单栏弹窗和 Gemini 流式对话跑通了真正的打磨花了一个多星期全部是在自己日常使用中发现细节、改细节。好的工具不是设计出来的是用出来的。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑