用 Tauri 2.x + React 19 打造微信风格的 AI 桌面客户端
一个 533 个 Rust crate、77 个前端测试、完整 SQLite 持久化的桌面应用是如何诞生的?
前言

周一早上,老板路过小明工位,看到一个绿色的聊天界面。
"上班时间聊微信?!"老板的脸瞬间黑了。
小明淡定地转过屏幕:"老板您看,这是 TalkCozy,我在让 AI 帮我重构代码呢。"
老板凑近一看:嚯,这对话框里全是代码,还有语法高亮、Diff 对比、Mermaid 流程图...
"这玩意儿...能帮我写周报吗?"
"能。"
"能帮我Review代码吗?"
"能。"
"能帮我把这个月加班费算一下吗?"
"老板,这个 AI 不能替代财务,但它能帮你生成一个加班费计算器。"
老板若有所思地点点头,转身就走。走到门口又回头问了一句:"这个软件叫什么来着?"
"TalkCozy,用 Tauri 开发的,React 19 + Rust。"
"好,记住了。那个...能给我也装一个吗?"
作为一个 macOS 用户,我一直想要一个原生体验的 AI 助手客户端。Web 版的 ChatGPT 和 Claude 虽然功能强大,但总觉得少了点什么------可能是 Command+Q 就能退出的爽快感,可能是 Dock 栏那个熟悉的小图标,也可能是系统原生通知带来的归属感。
更重要的是,我每天都在用 Claude Code CLI 进行开发工作。它很强大,但终端界面毕竟有局限性:会话管理靠文件系统,历史记录靠滚动翻页,多项目切换更是噩梦。于是我萌生了一个想法:能不能给 Claude Code 包一层微信风格的 GUI?
经过几个月的开发,TalkCozy 诞生了。本文将分享这个项目的技术架构和开发过程中的经验。
项目概览
TalkCozy 是一个 macOS 原生 AI 助手客户端,核心特性包括:
- 会话管理:多项目会话,支持置顶、归档、工作流状态标记(todo/in_progress/done)
- 实时流式对话:Shiki 语法高亮、Mermaid 图表渲染、Unified Diff 展示
- 多 Provider 支持:Anthropic / OpenAI / Google AI,API Key 安全存储于 macOS Keychain
- 文件树浏览:实时变更检测,文件内容预览
- Agent 任务面板:工具调用追踪,消息关联历史浏览
- Slash 命令 :
/clear、/model、/help等
技术栈一览
| 层 | 技术 | 版本 |
|---|---|---|
| 桌面框架 | Tauri (Rust) | 2.x |
| 前端 | React + TypeScript | 19.2 + 5.8 |
| 构建 | Vite | 7.x |
| 样式 | Tailwind CSS | 4.x |
| 状态管理 | Zustand | 5.x |
| 数据库 | SQLite (rusqlite) | - |
| 测试 | Vitest + Playwright | - |
架构设计
整体架构
scss
┌─────────────────────────────────────────────────────────────┐
│ Frontend (React 19) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Chat │ │ File │ │ Agent │ │ Settings │ │
│ │ View │ │ Tree │ │ Panel │ │ Panel │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ └────────────┴────────────┴────────────┘ │
│ │ │
│ Zustand Stores │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ session │ │ chat │ │ agent │ │ ui │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────────┬──────────────────────────────────┘
│ Tauri IPC
┌──────────────────────────┴──────────────────────────────────┐
│ Backend (Rust) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Sidecar │ │ DB │ │ FS │ │Security │ │
│ │ Manager │ │ (SQLite) │ │ Watcher │ │Keychain │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
│
Claude CLI
前后端通信
Tauri 采用 IPC(进程间通信)机制连接前端和后端:
- Commands (前端 → Rust):类型安全的 invoke 调用,定义在
src/lib/ipc.ts - Events (Rust → 前端):实时流式事件,如
cli:stream、cli:status、cli:error
以发送消息为例:
bash
用户发送消息 → sendChat IPC → Rust send_chat command
↓
Rust 写入 sidecar stdin → Sidecar 处理 → 输出到 stdout
↓
handler.rs 解析 → OutputBuffer 批处理(16ms)
↓
cli:stream 事件 → 前端 useStreamChat hook
↓
StreamingBubble 渲染 + 打字机效果
↓
done 事件 → 保存到 SQLite → 关联 Agent 任务
核心功能实现
1. Sidecar 进程管理
这是整个项目最复杂的部分。我们需要管理 Claude CLI 子进程的生命周期:
rust
// src-tauri/src/sidecar/manager.rs
pub struct SidecarManager {
process: Option<Child>,
stdin: Option<ChildStdin>,
output_buffer: OutputBufferManager,
dispatch_queue: DispatchQueue,
// ...
}
impl SidecarManager {
pub fn spawn(&mut self, session_id: &str) -> Result<()> {
// 使用 PTY 包装解决缓冲问题
let mut cmd = Command::new("script");
cmd.args(["-q", "/dev/null"])
.arg(&self.sidecar_path)
.arg("--resume")
.arg("--permission-mode")
.arg("bypassPermissions")
.envs(self.env_vars());
let child = cmd.spawn()?;
// ...
}
}
关键技术点:
- PTY 包装 :使用
script -q /dev/null创建伪终端,解决 CLI 输出缓冲问题 - 输出批处理:16ms 间隔(约 60fps)批量发送事件,避免前端渲染抖动
- 命令队列:防止命令交错,每个 session 独立队列
- 看门狗:120s 无响应检测,自动发送 synthetic done 事件
2. 流式对话渲染
前端使用自定义 hook 处理流式数据:
typescript
// src/hooks/useStreamChat.ts
export function useStreamChat(sessionId: string) {
const addMessage = useChatStore((s) => s.addMessage);
const setStreamingContent = useChatStore((s) => s.setStreamingContent);
useEffect(() => {
const unlisten = listen<StreamEvent>('cli:stream', (event) => {
const { type, content, seq } = event.payload;
if (type === 'token') {
// 使用 requestAnimationFrame 累积 tokens
rafAccumulator.add(content, seq);
} else if (type === 'done') {
// 保存消息到数据库
addMessage(sessionId, assistantMessage);
setStreamingContent(null);
}
});
return () => { unlisten.then(f => f()); };
}, [sessionId]);
}
打字机效果 :由于 CLI -p 模式并非真正的流式输出(而是一次性返回完整内容),我们实现了前端的打字机效果:
typescript
// src/hooks/useTypewriter.ts
export function useTypewriter(content: string | null) {
const [displayed, setDisplayed] = useState('');
const rafRef = useRef<number>();
useEffect(() => {
if (!content) return;
const animate = () => {
setDisplayed(prev => {
if (prev.length >= content.length) return prev;
// 自适应速度:根据剩余内容量调整
const speed = calculateSpeed(content.length - prev.length);
return content.slice(0, prev.length + speed);
});
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafRef.current!);
}, [content]);
return displayed;
}
3. 状态管理(React 19 兼容)
React 19 的 useSyncExternalStore 对状态管理提出了严格要求。Zustand selector 必须返回稳定引用:
typescript
// ❌ 错误:每次渲染创建新对象 → 无限循环
const { setNodes, setRootPath } = useFileTreeStore();
// ✅ 正确:逐个订阅
const setNodes = useFileTreeStore((s) => s.setNodes);
const setRootPath = useFileTreeStore((s) => s.setRootPath);
// ❌ 错误:selector 内创建新数组
const ports = useStore((s) => s.data?.ports) ?? [];
// ✅ 正确:模块级常量兜底
const EMPTY: number[] = [];
const ports = useStore((s) => s.data?.ports ?? EMPTY);
4. 数据库设计
使用 SQLite 存储会话和消息,支持全文搜索:
sql
-- 会话表
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
project_path TEXT,
workflow_status TEXT DEFAULT 'active',
is_archived INTEGER DEFAULT 0,
pinned_at INTEGER,
created_at INTEGER,
updated_at INTEGER
);
-- 消息表 + FTS5
CREATE TABLE messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER,
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
CREATE VIRTUAL TABLE messages_fts USING fts5(
content,
content='messages',
content_rowid='rowid'
);
5. 安全的 API Key 存储
使用 macOS Keychain 存储敏感信息:
rust
// src-tauri/src/security/keychain.rs
use keyring::Entry;
pub fn store_api_key(provider: &str, key: &str) -> Result<()> {
let entry = Entry::new("talkcozy", provider)?;
entry.set_password(key)?;
Ok(())
}
pub fn get_api_key(provider: &str) -> Result<Option<String>> {
let entry = Entry::new("talkcozy", provider)?;
match entry.get_password() {
Ok(key) => Ok(Some(key)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.into()),
}
}
支持多 Provider:anthropic、openai、google。
设计理念
微信风格界面
参考微信的设计语言:
- 配色:以绿色(#07C160)为主色调,搭配浅灰背景
- 气泡:用户消息为微信绿,AI 消息为白色
- 无硬边框:使用阴影而非边框营造层次感
- 方形头像:4px 圆角,简洁大方
Apple 设计系统
项目还参考了 Apple 的设计原则:
- 字体:SF Pro Display(大标题)+ SF Pro Text(正文)
- 留白:大量留白,让内容呼吸
- 单一强调色:蓝色仅用于交互元素
- 无阴影卡片:通过背景色对比而非阴影区分层级
css
/* 深色模式 */
:root {
--background: #000000;
--foreground: #ffffff;
--primary: #07C160; /* 微信绿 */
--muted: #272729;
--border: #3a3a3c;
}
/* 浅色模式 */
.light {
--background: #f5f5f7;
--foreground: #1d1d1f;
--primary: #07C160;
--muted: #f0f0f0;
--border: #d9d9d9;
}
测试策略
三层测试体系
bash
# 第一层:编译 + lint(秒级)
pnpm check # tsc + eslint + cargo check
# 第二层:单元测试
pnpm test # Rust test + Vitest
# 第三层:E2E 测试
pnpm test:e2e # Playwright
pnpm test:tauri-e2e # WebdriverIO(真实 Tauri)
测试覆盖
- Rust 测试:协议解析、端口检测、DB CRUD/FTS、文件树扫描、Sidecar 管理
- 前端单元测试:所有 Zustand stores、组件、工具函数
- E2E 测试:应用启动、侧边栏交互、键盘快捷键、设置面板
开发中的坑
1. Cargo 缓存损坏
如果 ~/.cargo/registry 是符号链接指向 RAMDisk,可能会遇到目录创建失败的问题。解决方案:
bash
# 确保 RAMDisk 已挂载并创建必要目录
mkdir -p /Volumes/RAMCache/cargo-registry/{cache,index}
2. PTY 控制字符
Sidecar 通过 PTY 运行时会混入控制字符,需要在解析 JSON 前过滤:
rust
// src-tauri/src/sidecar/protocol.rs
pub fn parse_line(line: &str) -> Result<ProtocolMessage> {
// 跳过 PTY 控制字符,找到第一个 JSON 对象
let start = line.find('{').ok_or_else(|| anyhow!("No JSON found"))?;
let json = &line[start..];
let msg: ProtocolMessage = serde_json::from_str(json)?;
Ok(msg)
}
3. React 19 严格模式
React 19 对 dangerouslySetInnerHTML 和 children 共存会直接崩溃:
tsx
// ❌ 崩溃!
<div dangerouslySetInnerHTML={{ __html: html }}>
{!html && <span>fallback</span>}
</div>
// ✅ 正确:条件渲染
{html ? (
<div dangerouslySetInnerHTML={{ __html: html }} />
) : (
<div><span>fallback</span></div>
)}
总结
TalkCozy 是一个典型的"小而美"项目:
- 代码量:前端约 15000 行,Rust 约 8000 行
- 依赖:前端 49 个依赖,Rust 533 个 crate
- 测试:77 个前端测试 + 50 个 Rust 测试
- 构建产物:约 15MB(未压缩)
如果你也在开发类似的桌面应用,希望这篇文章能给你一些启发。项目已开源,欢迎 Star 和 PR! github.com/talkcozy/ta...
参考资源: