从 CLI 到 MCP server 再到 GUI —— 同一个灵魂,三种皮囊。
这一次,Rust 写的达尔文 Agent,它终于有脸了
之前聊过 daerwen,一个用 Rust 写的自我进化 AI Agent,以及它如何完成内在的进化闭环,如何对接外部生态。但一直到上周,它都只有两张“脸”:
- CLI 脸:我敲
daerwen chat,它在终端里流式吐字。
- MCP 脸:Claude Code 通过
daerwen serve 把它当作“记忆+技能”后端来消费。
这两张脸其实都不是给人“看”的,但面向的人不一样——CLI 给会 Terminal 的我,MCP 给另一个 agent。中间留了个缺口:那个虽然关心 agent 技术但不想每天敲 shell 的我(或朋友、或任何非开发者)没法直接看见 daerwen 在做什么。
这次把第三张脸补上了:一个真正的桌面 GUI。Tauri + React + Tailwind,所有 UI 原语自写。单二进制 ~20MB,跑起来像原生 macOS app。


这篇聊的是:怎么做的、为什么这么做、中间踩的几个坑、以及一个我越做越确信的观察——agent 有了三张脸之后,变化的不是交互方式,是它在我生活中的位置。
为什么我觉得仅仅是 CLI,它还不够
一句话:CLI 是优化时间的,GUI 是优化空间的。
终端很适合“输入命令→看输出→下一条命令”这种线性节奏。但 agent 交互不是线性的——它需要:
- 并列展示:当前对话 + 历史会话 + 活跃目标 + inbox 建议 + 工具调用状态 + token 计数...
- 同时存在的上下文:我想一边和 agent 聊天,一边瞄一眼它最近的反思结果,一边看它在跑什么工具
- 流式的视觉信号:token 实时打字、工具调用气泡、进度条
在终端里这些都能实现,但要么靠 tmux 分屏(笨),要么靠 Rich / Textual(重)。把一张 1920×1080 的桌面压缩成一个 80×24 的 tty,本质是丢信息。
而且——说实话——我希望我的朋友(非开发者)也能用 daerwen。“先装 Rust、再 cargo build --release、然后 chmod +x...” 这话我没法对一个设计师说。
所以 GUI 不是 CLI 的替代,它是 daerwen 的另一个受众。
为什么是 Tauri,不是 Electron
选桌面框架是个老话题,但对 daerwen 结论很干脆:
|
Tauri 2 |
Electron |
| 二进制体积 |
~20MB |
200MB+ |
| 内存占用 |
~100MB |
500MB+ |
| 后端语言 |
Rust(原生 daerwen 核心) |
Node(要桥 Rust) |
| 窗口引擎 |
系统 WebView(WebKit / WebView2) |
捆绑 Chromium |
| IPC 成本 |
Rust function 直调 + 零拷贝(大部分) |
JSON over IPC |
对 daerwen 这种“Rust 为心脏”的项目,Tauri 近乎天作之合。我可以直接把 daerwen-agent / daerwen-memory / daerwen-skills 这些现有 crate path 依赖进 Tauri 后端:
# ui/src-tauri/Cargo.toml
[dependencies]
tauri = { version = "2" }
daerwen-agent = { path = "../../crates/daerwen-agent" }
daerwen-memory = { path = "../../crates/daerwen-memory" }
daerwen-skills = { path = "../../crates/daerwen-skills" }
# ...
前端 invoke('chat_send', ...) → 后端 Tauri 命令 → 直接调 Agent::run_with_history(...)。没有 HTTP server、没有 IPC 桥、没有序列化开销(除了 Tauri 命令边界的 JSON)。这是 Electron 架构做不到的——Electron 要跑 Rust 得靠 N-API 或 WASM 或 sidecar,每一种都是复杂度税。
最漂亮的一段:流式的零 IPC 桥
桌面 app 对话类 UI 的灵魂是流式 token。从 OpenAI/Anthropic SSE 拿到 token 后,要原样传给前端,逐字渲染。
传统架构要两段:SSE → 后端 → IPC → 前端。每一段都有转换成本。Tauri 2 给了个优雅的解——Channel<T>:
前端:
import { Channel } from "@tauri-apps/api/core";
async chatSend(message: string, sessionId: string | null, onEvent: (ev: ChatEvent) => void) {
const channel = new Channel<ChatEvent>();
channel.onmessage = onEvent;
await invoke<void>("chat_send", { message, sessionId, channel });
}
后端:
#[derive(Serialize)]
#[serde(tag = "type")]
enum ChatEvent {
TextDelta { text: String },
ToolStart { name: String, input: Value },
ToolEnd { name: String, ok: bool, preview: String },
Done { message: String, total_tokens: u64, session_id: String, messages: Vec<Message> },
Error { message: String },
}
struct ChannelCallbacks {
tx: Channel<ChatEvent>,
}
#[async_trait]
impl AgentCallbacks for ChannelCallbacks {
async fn on_text_delta(&self, text: &str) {
let _ = self.tx.send(ChatEvent::TextDelta { text: text.to_string() });
}
async fn on_tool_start(&self, name: &str, input: &Value) {
let _ = self.tx.send(ChatEvent::ToolStart { name: name.to_string(), input: input.clone() });
}
// ...
}
#[tauri::command]
async fn chat_send(
message: String,
session_id: Option<String>,
channel: Channel<ChatEvent>,
state: State<'_ , AppState>,
) -> Result<(), String> {
let agent = /* 构造 Agent */;
let cb = ChannelCallbacks { tx: channel };
agent.run_with_history(history, &cb).await?;
Ok(())
}
看到这里对 daerwen 熟悉的人会会心一笑——这就是原本 CLI 里用的 AgentCallbacks trait。同一个 trait,CLI 里实现成“打印到终端”,GUI 里实现成“发送到 Channel”。Rust trait 抽象再次证明它的价值——上层 Agent::run_with_history 完全不感知下游接收者是谁。
前端接收:
await api.chatSend(text, currentSessionId, (ev: ChatEvent) => {
switch (ev.type) {
case "TextDelta":
setStreamedText((t) => t + ev.text); // 增量渲染
break;
case "ToolStart":
setLiveTools((tools) => [...tools, { name: ev.name, input: ev.input }]);
break;
case "ToolEnd":
setLiveTools((tools) => updateLast(tools, ev.name, ev.ok, ev.preview));
break;
case "Done":
setMessages(ev.messages.filter((m) => m.role !== "system"));
setTokensUsed(Number(ev.total_tokens));
break;
}
});
从 DeepSeek API 流出的 token,到前端 <span> 里显示的字符,中间经过:
DeepSeek SSE
↓ (eventsource-stream)
openai_compat::stream()
↓ (StreamDelta::TextChunk via mpsc)
Agent::stream_once
↓ (AgentCallbacks::on_text_delta)
ChannelCallbacks::on_text_delta
↓ (channel.send(ChatEvent::TextDelta))
Tauri IPC
↓ (channel.onmessage)
React setState
↓
<div>
看起来很多层,但每层都是约 20 行 Rust / TS。抽象分层的代价是代码行数,收益是每层可以独立替换——明天把 DeepSeek 换成 Claude,只改 openai_compat.rs / anthropic.rs;后天把前端换成 Solid.js,只改 React 层;AgentCallbacks trait 是稳定的中轴。
CLI / MCP / GUI 三形态的共生
这是 daerwen 现在的全貌:
┌─────────────────┐
│ ~/.daerwen/ │
│ │
│ soul.md │
│ aaak.md │
│ memory/ │
│ skills/ │
│ goals/ │
│ inbox/ │
│ logs/ │
└────────┬────────┘
│
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
┌───────┐ ┌────────┐ ┌───────┐
│ CLI │ │ MCP │ │ GUI │
│ │ │ server │ │ │
│ daer- │ │ │ │ Tauri │
│ wen │ │ Claude │ │ + │
│ chat │ │ Code │ │ React │
└───────┘ └────────┘ └───────┘
同一份磁盘状态,三种进入方式。我可以:
- 用 CLI 写脚本:
daerwen reflect --since 7 放 cron
- 用 MCP:在 Claude Code 里让它调
daerwen_memory_search 找我半年前的决定
- 用 GUI:日常聊天、看 inbox 建议、查 AAAK 状态
三种形态共享同一份 ~/.daerwen/ 数据。我上午在 CLI 里 daerwen goal add "Ship daerwen v0.2",下午打开 GUI,任务列表里就有它;晚上 Claude Code 帮我写 PR 时通过 MCP 读到这个 goal。
这是 daerwen 的存储架构决定的——文件系统即真相,进程只是视角。CLI 是一个视角,MCP 是另一个,GUI 又是一个。agent 的灵魂在磁盘上,脸只是皮囊。
这也解释了为什么最初选择“所有演化都写回文件”这条设计原则这么重要。如果 daerwen 把状态存在进程内存 / Redis / Postgres,三张脸就会打架——得有一个服务协调。而把磁盘当真相源,三个 binary 互不感知,却共享同一个 agent。
像 Unix 哲学里的管道:文件是通用界面。
写在最后
前篇结尾我用了两个比喻:
- 第一篇:“LLM 是 CPU / context 是 RAM / 文件是磁盘”
- 第二篇:加一笔,“MCP 是以太网”,GUI 是显示器。
CPU(LLM)算东西,RAM(context)暂存,磁盘(~/.daerwen/)存状态,以太网(MCP)连其他主机——但是一台没有显示器的计算机对人类是陌生的。人类要看见才觉得它存在。
daerwen 现在是一台完整的计算机:有 CPU,有 RAM,有磁盘,有网卡,有显示器。它缺的下一个,可能是我的耳朵和嘴——语音输入、语音输出。
但那是下一篇的事了。今晚先让它静静地在屏幕里跑着,我在这里敲字,它在右主区流式回复,左栏的 session 列表慢慢变长,中栏的 inbox 偶尔跳出新的建议。
这才是我想要的 AI Agent。不是一个被我调用的 API,是一个和我一起工作的东西。
它有三张脸。我认得每一张。
这个从零开始的 开源实战 项目地址是:https://github.com/coder-brzhang/rust-daerwen-agent 。如果你对用 Rust 构建 AI Agent 或 Tauri 桌面开发感兴趣,欢迎在 云栈社区 交流探讨。