在实际项目里,API 账单和 token 消耗通常是月底才知道,但真正需要优化成本时,已经晚了。一个常驻在 Mac 菜单栏上的扩展,让 LLM usage 数据随时可见,是很多开发者的刚需。所谓 panel / pill / nub,分别对应展开后的详情面板、菜单栏里的胶囊指示器、以及可以拖到屏幕边缘的小圆点。这篇文章从零实现一个 macOS 菜单栏扩展:用 SwiftUI 的 MenuBarExtra 搭建入口,用一个可替换的 UsageProvider 拉取用量数据,通过定时刷新把总 token、花费和剩余额度展示在菜单栏胶囊里。
读完这篇文章,你会知道 LLM usage 数据模型怎么设计,Provider 怎么抽象,SwiftUI 菜单栏怎么接,常见坑怎么排查,以及如何用 NSStatusItem 或 NSPanel 升级成更原生的形态。
1. 先梳理需求:LLM Usage 和 Mac 上的三种呈现形态
1.1 用量数据到底指哪些字段
LLM usage 不是单纯一个 token 数字。不同供应商返回的字段差别很大,但在做展示端时,可以统一成几个核心概念:
| 概念 | 英文常见字段 | 说明 |
|---|---|---|
| 输入 token 数 | prompt_tokens / input_tokens | 请求里发送给模型的文本 token 数 |
| 输出 token 数 | completion_tokens / output_tokens | 模型生成的文本 token 数 |
| 总 token 数 | total_tokens | 输入和输出的总和 |
| 费用 | cost / amount | 按 token 量和模型单价计算出的金额 |
| 币种 | currency | USD、CNY 等 |
| 剩余额度 | remaining_credit / balance | 账户剩余可用余额 |
| 统计周期 | period_start / period_end | 当前账单周期或查询时间范围 |
| 按模型拆分 | models / model_usage | 每个模型各自的请求数、token 数和费用 |
| 请求次数 | requests / n_requests | 一段时间内调用次数,做限额观察时需要 |
如果只是显示一个“总 token”,对日常开发不够。最实用的菜单栏形态是:胶囊显示费用或总 token,点击展开后,面板里展示完整的模型拆解和周期信息。
1.2 panel / pill / nub 的差异和适用场景
这三种叫法来自不同 UI 形态:
- Panel:展开后的详情面板,通常是一个 popover 或 NSPanel。适合展示表格、统计图、模型列表。
- Pill:菜单栏里的胶囊状小标签,紧凑显示一个核心数字,比如
$12.34。 - Nub:悬浮在屏幕边缘的小圆点或小把手,可以拖动,适合做常驻监控浮窗。
这篇文章先实现 pill + panel 的组合:菜单栏显示胶囊,点击后弹出详情面板。nub 作为进阶方向,会在后面用 NSPanel 的思路说明。
1.3 技术选型:MenuBarExtra、NSStatusItem、NSPanel
macOS 上实现菜单栏扩展,主要有三条路:
| 方案 | 最低系统 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| SwiftUI MenuBarExtra | macOS 13 | 声明式、写法简单、自带 popover | 自定义菜单栏 View 的能力受限 | 快速实现菜单栏入口和详情面板 |
| AppKit NSStatusItem | macOS 10.0 | 完全控制按钮、图标、事件 | 代码多,需要处理生命周期 | 需要自定义胶囊背景、点击手势的菜单栏工具 |
| NSPanel 悬浮窗口 | macOS 10.0 | 可以实现屏幕任意位置悬浮 | 要处理拖拽、层级、焦点 | nub 形态的常驻监控浮窗 |
对一个不需要复杂交互的用量展示工具,先用 MenuBarExtra 足够。
2. 环境准备与工程骨架
2.1 环境要求
| 项目 | 要求 |
|---|---|
| macOS | 13.0 或更高版本 |
| Xcode | 14.3 或更高版本 |
| Swift | 5.8 或更高 |
| 外部依赖 | 无 |
| 网络 | 可以访问你的 LLM Usage 查询接口 |
示例代码全部使用系统自带框架,不需要引入 CocoaPods 或 Swift Package。
2.2 创建 macOS App 项目
在 Xcode 中按以下步骤操作:
- 选择 File > New > Project。
- 选择 macOS > App。
- Interface 选 SwiftUI。
- Lifecycle 选 SwiftUI App。
- Language 选 Swift。
- 取消勾选 Use Core Data、Include Tests。
- Deployment Target 设置为 macOS 13.0 或更高。
创建完成后,项目里默认会有一个ContentView.swift。这个项目只需要菜单栏,不需要主窗口,所以后面可以把默认窗口删掉。
2.3 App Sandbox 和网络权限
macOS App 默认开启 App Sandbox。如果使用 URLSession 发送网络请求,必须开启 outgoing network 权限。
打开 target 的 Signing & Capabilities,点击 App Sandbox,确保 Network 下面的 Outgoing Connections 已勾选。也可以直接修改 entitlement 文件:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>com.apple.security.app-sandbox</key> <true/> <key>com.apple.security.network.client</key> <true/> </dict> </plist>如果漏掉com.apple.security.network.client,运行时会看到类似The network connection was lost或请求立即失败的错误。这个权限是网络请求最常见的坑。
2.4 工程文件与职责
代码按职责拆成几个文件,不要全部塞进 App 入口:
| 文件 | 职责 |
|---|---|
| LLMUsageApp.swift | App 入口,创建菜单栏入口和 Provider |
| UsageModels.swift | 用量数据模型 |
| UsageProvider.swift | 数据源协议、HTTP 实现、Mock 实现 |
| UsageStore.swift | 状态管理、自动刷新、错误状态 |
| UsagePanelView.swift | 菜单栏胶囊和详情面板 UI |
3. 定义用量模型:先让数据层稳定
3.1 领域模型
在写网络请求之前,先定义 UI 真正需要的领域模型。这样 Provider 返回什么、界面展示什么,都会被统一约束,不会因为上游字段变化导致 UI 到处改。
import Foundation struct LLMUsage: Codable, Equatable { var totalTokens: Int var inputTokens: Int var outputTokens: Int var cost: Double var currency: String var remainingCredit: Double? var periodStart: String var periodEnd: String var models: [String: ModelUsage] } struct ModelUsage: Codable, Equatable { var requests: Int var tokens: Int var cost: Double }这里把periodStart和periodEnd直接定义为字符串,而不是Date。原因很简单:不同供应商返回的日期格式差异很大,iOS 17 和 macOS 14 之后虽然有ISO8601FormatStyle,但为了兼容较多接口,先保留字符串,展示时再格式化最稳妥。
3.2 上游返回结构示例
下面是一个兼容多数“网关聚合统计接口”的 JSON 示例。字段名按 snake_case 给出,因为很多供应商实际接口也使用 snake_case:
{ "data": { "total_tokens": 1234567, "input_tokens": 800000, "output_tokens": 434567, "cost": 12.34, "currency": "USD", "remaining_credit": 87.66, "period_start": "2026-05-01T00:00:00Z", "period_end": "2026-05-31T23:59:59Z", "models": [ { "name": "gpt-4o-mini", "requests": 1200, "tokens": 900000, "cost": 5.20 }, { "name": "gpt-4o", "requests": 300, "tokens": 334567, "cost": 7.14 } ] } }这里的数据是示例,不是某个供应商的真实接口。实际项目中,以你使用的服务商文档为准。
3.3 用 Decodable 适配上游字段
为兼容 snake_case,可以定义RemoteUsageResponse,并通过CodingKeys做字段映射。
struct RemoteUsageResponse: Decodable { let data: RemoteUsageData } struct RemoteUsageData: Decodable { let totalTokens: Int let inputTokens: Int let outputTokens: Int let cost: Double let currency: String let remainingCredit: Double? let periodStart: String let periodEnd: String let models: [RemoteModelUsage]? enum CodingKeys: String, CodingKey { case totalTokens = "total_tokens" case inputTokens = "input_tokens" case outputTokens = "output_tokens" case cost, currency case remainingCredit = "remaining_credit" case periodStart = "period_start" case periodEnd = "period_end" case models } } struct RemoteModelUsage: Decodable { let name: String let requests: Int let tokens: Int let cost: Double }然后写一个toDomain()方法,把远程结构转换成 UI 使用的LLMUsage:
extension RemoteUsageResponse { func toDomain() -> LLMUsage { var modelMap: [String: ModelUsage] = [:] for item in data.models ?? [] { modelMap[item.name] = ModelUsage( requests: item.requests, tokens: item.tokens, cost: item.cost ) } return LLMUsage