找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5358

积分

0

好友

707

主题
发表于 4 小时前 | 查看: 2| 回复: 0

从 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。

daerwen CLI 命令界面

daerwen 桌面GUI主界面

这篇聊的是:怎么做的、为什么这么做、中间踩的几个坑、以及一个我越做越确信的观察——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 桌面开发感兴趣,欢迎在 云栈社区 交流探讨。




上一篇:AMD Fleet方案解析:多芯粒GPU如何通过Chiplet-task将LLM推理L2命中率提升至54%
下一篇:Gin 1.12参数绑定新特性解析:如何用parser标签简化自定义类型处理
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-23 08:39 , Processed in 0.711393 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表