构建AI编程助手的桌面客户端
原文:https://dev.to/timexingxin/building-a-desktop-client-for-an-ai-coding-agent-147n
从包装grok-build中学到的经验——架构、陷阱,以及为什么我们选择Tauri而非Electron。
TL;DR
grok-build是xAI开源的Rust编码代理。它以TUI形式发布。我们为其编写了一个原生桌面客户端——Tauri 2(约8 MB二进制文件)、React前端、Rust运行时,将CLI作为子进程启动并通过ACP/JSON-RPC 2.0与之通信。本文深入剖析了架构:各部件如何组合、哪些部分出乎意料,以及下次我们会有哪些不同设计。
完整源代码见github.com/timexingxin/grok-gui。基于MIT许可证。README中有演示GIF。
问题
grok-build在代码工作方面确实出色——在我的工作流程中与Claude Code相当。但它以Rust TUI形式发布。经过六个月在终端和浏览器标签页之间来回切换后,我想要一个真正的桌面体验,同时保留CLI的优点。
天真的方案都有问题:
- 将其包装为webview中的tmux会话:无济于事——你仍然在阅读回滚内容。
- 使用社区构建的web包装器:它们都直接包装OpenAI Chat Completions API。它们不与实际代理运行时通信,因此会错过工具调用、计划更新、权限请求以及使编码代理反应灵敏的流式事件层。
- 从头编写桌面GUI:意味着重新实现代理循环、模型集成、工具调用。需要六个月的工作,而且最终的客户端总会落后于上游。
正确答案摆在我面前:grok-build已经有一个通过stdio的JSON-RPC 2.0接口,称为Agent Client Protocol(ACP)。这就是我应该成为其客户端的协议。我的工作只是编写客户端。
什么是ACP?
ACP是一个JSON-RPC 2.0协议,编码代理CLI通过其stdin/stdout暴露该协议。代理发出通知(文本增量、工具调用、计划更新、权限请求、会话生命周期);客户端发送请求(用户提示、权限响应、模型切换、会话加载)。
如果你的代理支持ACP,你就可以编写客户端而无需重新实现代理循环。只需连接到stdio,解析JSON帧,然后渲染。
┌──────────────────────────────────────────┐
│ Desktop Shell (Tauri 2, ~8 MB) │ React + Vite + Tailwind
└──────────────┬───────────────────────────┘
│ Tauri commands + events (typed)
▼
┌──────────────────────────────────────────┐
│ Rust bridge (apps/desktop/src-tauri/src/grok_runtime.rs)
│ - spawns `grok agent stdio` as a child │
│ - JSON-RPC 2.0 over its stdin/stdout │
│ - LANG/LC_ALL forwarded for locale │
│ - manages a pool of live runtimes │
└──────────────┬───────────────────────────┘
│ subprocess stdin/stdout
▼
┌──────────────────────────────────────────┐
│ Grok Build runtime (xai-org/grok-build) │ upstream, Apache-2.0
│ Agent loop, tools, context, MCP, skills │
└──────────────────────────────────────────┘
Rust桥是有趣的部分。它管理生命周期:生成子进程,执行初始化握手(代理告知其版本和所支持功能),执行session/new或session/load,将每个事件流式传输到前端。前端只需监听类型化事件并进行渲染。
为什么选择Tauri,而非Electron
我考虑了两种方案。决策矩阵:
| 特性 | Tauri 2 | Electron |
|------|---------|----------|
| 二进制大小 | ~8 MB | ~150 MB |
| 内存(空闲) | ~80 MB | ~300 MB |
| Webview | 操作系统原生 WebKit / WebView2 | 捆绑 Chromium |
| 原生感觉 | 更接近(真正的操作系统部件) | Web应用 |
| 后端语言 | Rust | Node.js |
| IPC开销 | 函数调用(类型化结构体) | 通过IPC的JSON |
Tauri胜出有三个原因:
- 后端本来就要用Rust。grok-build是Rust,ACP客户端必须用Rust来正确生成子进程并与之通信,而且Rust对于长时间运行的进程池模式很合适。Electron意味着同一个项目中要用Rust + Node.js。
- 8 MB vs 150 MB对分发很重要。编码工具的安装程序不应该比Electron运行时本身在磁盘上还大。在系统其他部分都是小巧原生二进制的MacBook上,150 MB的Electron应用感觉很臃肿。
- 操作系统原生的webview感觉正确。macOS上的WebKit渲染效果与Safari相同,这意味着React应用看起来像Mac应用,而不是Windows 95的网页。`title-bar-style="hiddenInset"`和`trafficLightPosition`设置让我们获得了原生窗口控件,同时没有可见的标题栏。
折衷方案:Tauri在Windows上需要MSVC构建工具。但windows-latest GitHub Actions运行器已预装了这些工具,所以CI成本相同。
多会话并行
对我来说,杀手级功能是能同时进行多个对话。如果我在等待另一个任务时让Grok重构一个文件,我不希望在切换时丢失第一个回合的流式输出。
幼稚的实现:在应用启动时生成N个CLI进程。对于只有一个会话的用户来说太浪费了。
我构建的是:一个活动运行时的池,空闲时按LRU驱逐。
// 伪代码
struct RuntimePool {
runtimes: HashMap<SessionId, GrokRuntime>,
capacity: usize,
}
fn acquire(pool: &mut RuntimePool, session_id: SessionId) -> GrokRuntime {
if let Some(rt) = pool.runtimes.remove(&session_id) {
// LRU命中:重用现有进程。无需生成开销。
return rt;
}
if pool.runtimes.len() >= pool.capacity {
// 驱逐最近最少使用的空闲运行时。
let (victim_id, victim_rt) = pool.runtimes.pop_lru();
victim_rt.shutdown();
}
// 生成新的CLI子进程。
GrokRuntime::spawn(...)
}
当用户开始新会话时,池会查找该会话ID的现有活动运行时。如果找到,则重用——无需生成开销,无需重新握手初始化。如果池已满,它将关闭最近最少使用的空闲运行时(优雅终止,等待最多5秒让代理稳定下来)。否则,它将生成新的CLI。
LRU驱逐至关重要,因为`grok agent stdio`可能持有数百MB的上下文内存。没有驱逐,打开几个会话就会耗尽所有可用RAM。有了驱逐,空闲会话被清理,用户的工作集保持有界。
“后台回合继续流式输出”的行为——我最自豪的一点——从这个设计中自然而然地实现了。用户当前未查看的会话在池中仍有活动的GrokRuntime,这意味着子进程仍在运行,因此代理仍在流式输出事件。当用户切换回来时,他们看到的是最新状态,而不是需要追赶的空白面板。
多提供商抽象
grok-build默认使用xAI。我们不想成为单一供应商的客户端——那是一条战略死路。
协议使这变得容易。代理的初始化握手返回其`availableModels`列表,`session/set_model`请求可在它们之间切换。CLI处理提供商特定的管道(API密钥、请求格式、流式格式);我们只需告诉它使用哪个模型。
前端侧:一个`useActiveModel()` Zustand选择器从握手的`availableModels`中读取。切换模型只需一次IPC调用。
// store层
const resp = await tauri.invoke("start_session", {
workspacePath: workspace,
provider: get().activeModel?.providerId ?? "xai",
model: get().activeModel?.id ?? "grok-4.5",
// ...
locale: language ?? get().settings.language,
});
// rust桥——模型原样传递给CLI
let mut cmd = Command::new(&grok_bin);
cmd.env("XAI_API_KEY", api_key);
// ...
cmd.args(["agent", "stdio"]);
cmd.args(["--model", model]); // 或者在初始化后通过`session/set_model`
结果:同一个UI可用于xAI、OpenAI、Anthropic、Google等。
DeepSeek、OpenRouter、Ollama 以及任何兼容 OpenAI 的端点。只要代理运行时支持某个模型,选择器就会显示它。
LANG/LC_ALL 漏洞(以及它为什么比你想象的更重要)
我犯过的最尴尬的 bug 是这样的:UI 语言选择器存储了用户的选择(“English” / “简体中文”),但我从未将其传递到生成的 grok 代理 stdio 进程。该进程继承了系统区域设置——我 Mac 上是 zh_CN——因此无论 UI 显示什么,它都会用中文回复。
修复方法仅需一行代码,但背后的原则更为重要:
if let Some(lang_value) = locale_env_value(options.locale.as_deref()) {
cmd.env("LANG", &lang_value);
cmd.env("LC_ALL", &lang_value);
}
LANG 是事实上的标准区域设置环境变量。LC_ALL 是覆盖变量(某些 CLI 会先检查 LC_ALL 再检查 LANG)。同时设置两者是双保险。映射函数显式处理了两个选择器值:
fn locale_env_value(locale: Option<&str>) -> Option<String> {
let tag = locale?;
let posix = match tag {
"en-US" | "en" => "en_US.UTF-8".to_string(),
"zh-CN" | "zh" => "zh_CN.UTF-8".to_string(),
other if other.contains('-') => other.replacen('-', "_", 1),
other => other.to_string(),
};
Some(format!("{}.UTF-8", posix))
}
通用的 '-' -> '_' 回退机制意味着未来添加 fr-FR 选择器时无需修改代码即可正常工作。
原则:如果你生成的子进程有任何面向用户的输出涉及语言,那么系统区域设置是不够的。用户的 UI 选择才是真正受其控制的因素。务必显式传递它。
权限模式:Ask / Plan / Build
grok-build 有三种沙盒模式——只读、只读+无 Shell、完全访问。我将它们映射为 UI 中 Codex 风格的 Ask / Plan / Build 选择器:
选择器 沙盒 Shell 文件写入 网络
Ask 严格 ❌ ❌ ✅
Plan 只读 ❌ ❌ ✅
Build 关闭 ✅ ✅ ✅
不明显的点:当用户切换模式时,代理必须重启。沙盒是在进程创建时设置的(通过 argv 标志 --sandbox),因此运行中的运行时无法降级其权限。
// 在 UI 中切换模式时:
fn switch_mode(session_id: SessionId, new_mode: String) {
let old_rt = pool.runtimes.remove(&session_id);
old_rt.shutdown(); // 优雅终止
let new_rt = GrokRuntime::spawn(/* ...使用 new_mode... */);
pool.insert(session_id, new_rt);
}
这在实践中有点烦人——切换时会有 2-3 秒的停顿——但另一种选择是让 UI 虚报安全状态。我们选择了诚实的版本。
签名与分发:文档中没有的部分
在你真正发布之前没人会告诉你的事情:
- macOS 临时签名并不是“真正的”签名。使用 `--` 签名的 .app 包在 Gatekeeper 看来是未签名的——首次安装会触发“未识别开发者”提示。解决方法是右键单击→打开,或者使用我们的 First-Run-Open-Me.command 脚本清除 com.apple.quarantine 扩展属性。要摆脱这种情况,你需要真正的 Apple Developer ID(每年 99 美元)并通过 xcrun notarytool 进行公证构建。我们两者都没有。
- Electron 占位签名存在问题。默认情况下,electron-builder 生成的 Windows .exe 的 Authenticode 签名以一种特定的方式损坏:链接器将二进制文件签名为 Identifier=Electron,然后 electron-builder 将可执行文件重命名为你的产品名称,而 CodeDirectory 显示“无密封资源”,但实际捆绑包中包含资源。Windows 代码签名工具会拒绝此文件。macOS Sequoia 的 Gatekeeper 也会以“已损坏,无法打开”为由拒绝它。
修复方法:在 electron-builder 完成后,使用 post-build 钩子执行 codesign --force --deep --sign - 命令,用临时身份重新签名 .app 包。我们通过 electron-builder.yml 的 afterSign 钩子接入,该钩子调用 scripts/afterSign.js(并添加 process.platform 检查以跳过非 darwin 平台)。
Windows CI 上的代码签名是一个 5 分钟的任务。Tauri Windows 使用 WiX 3.14 的 light.exe,该程序在 github-actions windows-latest 上会崩溃,报错“failed to run ...\WixTools314\light.exe”(已知的 .NET Framework 依赖问题)。我们通过向 tauri build 传递 --bundles nsis 参数来绕过此问题,这会跳过 MSI 目标,只构建 NSIS 安装程序(它使用 makensis,随 Tauri CLI 一起提供,没有依赖地狱)。
我们会以不同的方式构建什么
- 将代理的计划/权限请求流式传输到单独的通知界面。目前它们以内联工具调用卡片的形式显示在对话中。它们应该成为推送通知,以便用户可以在查看其他标签页时对其执行操作。
- 缓存 grok 代理 stdio 的初始化。每次会话生成都会重新进行 JSON-RPC 初始化握手,耗时约 300 毫秒。通过足够的工作区状态指纹识别,我们可以跳过“这是我刚刚交谈过的同一工作区”情况下的握手。
- 将多运行时池开放给其他后端。目前该池硬编码为 grok 代理 stdio。其他 ACP 服务器(OpenCode、Claude Code、Continue)可以共享相同的池基础设施。
- 让 AGENTS.md 可感知。工作区级别的规则会覆盖 UI 语言选择器(这是正确的——它们更具体)。但用户需要自己发现这一点。当存在 AGENTS.md 时,在聊天标题中添加一个小徽章会有所帮助。
试一试
brew install --cask timexingxin/grok-gui/grok-gui-lite
或者从发布页面获取 macOS DMG / Windows .exe。
源代码在 github.com/timexingxin/grok-gui。
采用 MIT 许可证。包装的 grok-build 运行时来自 xai-org/grok-build,采用 Apache-2.0 许可证。
如果你为 AI 代理编写桌面客户端并遇到上述任何模式,我很乐意交流经验——请在 GitHub 上开 issue,或通过 Twitter @timexingxin 联系我。