用 Tauri 写一个 AI Chat:3MB 的桌面应用干翻 Electron 100MB
我之前做了一个网页版 AI Chat(纯浏览器直连 API),遇到两个大问题:API key 暴露在前端、CORS 到处报错。Electron?打包 100MB 起,内存占用动不动 500MB。
上周发现了 Tauri 2.0,用 Rust 做后端、Web 做前端,打包产物 3MB,内存占用不到 50MB。花了一个下午写了个 AI Chat 桌面应用,API key 安全存在 Rust 后端,零 CORS 问题,还支持流式输出。
这篇文章把整个过程拆解给你,从安装到打包发布,每一步都能跟着跑。
本文提纲
- Tauri 是什么、为什么比 Electron 好
- 环境准备与项目初始化
- Rust 后端:API 代理与流式处理
- Svelte 前端:聊天 UI 搭建
- 前后端通信:invoke 与事件系统
- 打包与发布
Tauri 是什么、为什么比 Electron 好
Tauri 的核心思路:用系统自带的 WebView 渲染 UI,用 Rust 处理后端逻辑。
Electron 的做法是把一整个 Chromium 打包进应用,所以体积大、内存高。Tauri 不带浏览器,直接调用操作系统的 WebView(macOS 用 WebKit、Windows 用 WebView2、Linux 用 WebKitGTK),省掉了几十 MB 的运行时。

关键数据对比:
| 指标 | Tauri 2.0 | Electron |
|---|---|---|
| 打包体积 | 3-10 MB | 100-200 MB |
| 空载内存 | ~30-50 MB | ~200-500 MB |
| 启动速度 | < 1s | 2-5s |
| 语言 | Rust(内存安全) | Node.js |
| 移动端 | iOS + Android | 不支持 |
| 安全模型 | 最小权限(白名单) | 全权限 |
还有一个容易被忽略的好处:Rust 的性能让 API 代理几乎零开销。同样的流式转发,Node.js 需要处理 backpressure 和内存管理,Rust 天然高效。
但也要诚实说 Tauri 的缺点:Rust 学习曲线陡,生态不如 Node.js 丰富,遇到 WebView 兼容性问题时排查更麻烦。如果你团队没有 Rust 经验,Electron 的开发效率可能更高。
环境准备与项目初始化
第 1 步:安装依赖
macOS:
bash
xcode-select --install
rustup-init
Windows:
bash
# 安装 Visual Studio C++ Build Tools
# 安装 WebView2(Windows 11 自带,Windows 10 需手动装)
# 安装 Rust: https://rustup.rs
Linux (Ubuntu):
bash
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
第 2 步:创建项目
bash
npm create tauri-app@latest ai-chat -- --template svelte-ts
cd ai-chat
npm install
项目结构长这样:
bash
ai-chat/
├── src/ # Svelte 前端
│ ├── App.svelte
│ ├── main.ts
│ └── app.css
├── src-tauri/ # Rust 后端
│ ├── src/
│ │ └── main.rs # Rust 入口
│ ├── Cargo.toml # Rust 依赖
│ └── tauri.conf.json # Tauri 配置
├── package.json
└── vite.config.ts
第 3 步:验证能跑
bash
npm run tauri dev
第一次运行会编译 Rust 依赖,比较慢(2-5 分钟)。之后热更新很快,前端秒级刷新。
看到窗口弹出来就说明环境 OK。
Rust 后端:API 代理与流式处理
这是整个应用最关键的部分。Rust 后端负责:调用 AI API、处理流式响应、通过事件推送给前端。
安装 Rust 依赖
编辑 src-tauri/Cargo.toml:
bash
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json", "stream"] }
tokio = { version = "1", features = ["full"] }
futures = "0.3"
定义数据结构
在 src-tauri/src/main.rs 顶部:
bash
use serde::{Deserialize, Serialize};
use tauri::Emitter;
#[derive(Debug, Serialize, Deserialize, Clone)]
struct Message {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct ChatRequest {
messages: Vec<Message>,
model: String,
api_key: String,
api_url: String,
}
实现 API 调用命令
bash
#[tauri::command]
async fn send_chat(
app: tauri::AppHandle,
request: ChatRequest,
) -> Result<String, String> {
let client = reqwest::Client::new();
let body = serde_json::json!({
"model": request.model,
"messages": request.messages,
"stream": true,
"max_tokens": 4096,
});
let response = client
.post(&request.api_url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", request.api_key))
.json(&body)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
let mut stream = response.bytes_stream();
use futures::StreamExt;
let mut full_content = String::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Stream error: {}", e))?;
let text = String::from_utf8_lossy(&chunk);
for line in text.lines() {
if line.starts_with("data: ") {
let data = &line[6..];
if data == "[DONE]" {
continue;
}
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
if let Some(content) = parsed["choices"][0]["delta"]["content"].as_str() {
full_content.push_str(content);
let _ = app.emit("chat-chunk", content);
}
}
}
}
}
Ok(full_content)
}
这段代码做了三件事:
- 用
reqwest发送 POST 请求到 AI API(支持 OpenAI 兼容的任何 API) - 解析 SSE(Server-Sent Events)流式响应
- 每收到一个 chunk 就通过
app.emit("chat-chunk", content)推送给前端
注册命令
bash
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![send_chat])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Svelte 前端:聊天 UI 搭建
前端就是普通的 Svelte 应用,唯一的区别是调 API 用 Tauri 的 invoke 而不是 fetch。
安装 Tauri API 包
bash
npm install @tauri-apps/api
聊天主界面
替换 src/App.svelte:
bash
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
interface Msg {
role: string;
content: string;
}
let messages: Msg[] = [];
let input = "";
let streaming = false;
// config
let config = {
apiUrl: "https://api.openai.com/v1/chat/completions",
apiKey: "",
model: "gpt-4o-mini",
};
// listen streaming chunks
let unlisten: (() => void) | null = null;
async function startListening() {
unlisten = await listen<string>("chat-chunk", (event) => {
const last = messages[messages.length - 1];
if (last && last.role === "assistant") {
last.content += event.payload;
messages = [...messages];
}
});
}
async function send() {
if (!input.trim() || streaming) return;
const userMsg: Msg = { role: "user", content: input.trim() };
messages = [...messages, userMsg];
input = "";
streaming = true;
// add empty assistant message for streaming
const assistantMsg: Msg = { role: "assistant", content: "" };
messages = [...messages, assistantMsg];
await startListening();
try {
await invoke("send_chat", {
request: {
messages: messages.filter((m) => m.content),
model: config.model,
apiKey: config.apiKey,
apiUrl: config.apiUrl,
},
});
} catch (e) {
const last = messages[messages.length - 1];
if (last) last.content = `Error: ${e}`;
messages = [...messages];
} finally {
streaming = false;
if (unlisten) unlisten();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}
</script>
<main>
<div>
<h3>Settings</h3>
<label>API URL</label>
<input type="text" bind:value={config.apiUrl} />
<label>API Key</label>
<input type="password" bind:value={config.apiKey} />
<label>Model</label>
<input type="text" bind:value={config.model} />
</div>
<div>
<div>
{#each messages as msg}
<div>
<span>{msg.role}</span>
<pre>{msg.content}</pre>
</div>
{/each}
</div>
<div>
<textarea
bind:value={input}
on:keydown={handleKeydown}
placeholder="Type a message..."
rows="3"
disabled={streaming}
/>
<button on:click={send} disabled={streaming || !input.trim()}>
{streaming ? "..." : "Send"}
</button>
</div>
</div>
</main>
<style>
:global(body) { margin: 0; font-family: system-ui; background: #1a1a2e; color: #eee; }
.container { display: flex; height: 100vh; }
.sidebar { width: 260px; padding: 16px; background: #16213e; border-right: 1px solid #333; }
.sidebar label { display: block; margin-top: 12px; font-size: 12px; color: #aaa; }
.sidebar input { width: 100%; padding: 6px 8px; margin-top: 4px; background: #0f3460; border: 1px solid #444; border-radius: 4px; color: #eee; }
.chat { flex: 1; display: flex; flex-direction: column; }
.messages { flex: 1; overflow-y: auto; padding: 16px; }
.msg { margin-bottom: 16px; padding: 12px; border-radius: 8px; }
.msg.user { background: #0f3460; }
.msg.assistant { background: #222; }
.role { font-size: 11px; color: #888; text-transform: uppercase; }
.content { white-space: pre-wrap; margin: 4px 0 0; font-size: 14px; font-family: inherit; }
.input-area { display: flex; padding: 12px; gap: 8px; border-top: 1px solid #333; }
textarea { flex: 1; padding: 8px; background: #16213e; border: 1px solid #444; border-radius: 6px; color: #eee; resize: none; font-size: 14px; }
button { padding: 8px 20px; background: #764ba2; color: white; border: none; border-radius: 6px; cursor: pointer; }
button:disabled { opacity: 0.5; cursor: default; }
</style>
前后端通信:invoke 与事件系统
上面的代码展示了 Tauri 前后端通信的两种方式,值得单独说一下:
方式一:invoke(请求-响应)
前端调用 Rust 命令,等返回结果:
bash
// 前端
const result = await invoke("send_chat", { request: { ... } });
// 对应 Rust
#[tauri::command]
async fn send_chat(request: ChatRequest) -> Result<String, String> {
// ...
}
参数名必须一致:前端传 request,Rust 函数参数也叫 request。
方式二:事件系统(实时推送)
Rust 端发射事件,前端监听:
bash
// Rust 端发射
app.emit("chat-chunk", content)?;
bash
// 前端监听
const unlisten = await listen<string>("chat-chunk", (event) => {
console.log(event.payload); // 收到的 chunk
});
// 用完记得清理
unlisten();
这种模式特别适合流式输出:Rust 从 API 收到一个 chunk 就推一个,前端实时追加显示。
权限配置
Tauri 2.0 的安全模型默认禁止一切。需要在 src-tauri/capabilities/default.json 中声明权限:
bash
{
"identifier": "default",
"description": "Default permissions",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:event:allow-listen",
"core:event:allow-emit"
]
}
打包与发布
开发模式
bash
npm run tauri dev
构建正式包
bash
npm run tauri build
构建产物在 src-tauri/target/release/bundle/:
| 平台 | 格式 | 大小 |
|---|---|---|
| macOS | .dmg / .app |
~8 MB |
| Windows | .msi / .exe |
~5 MB |
| Linux | .deb / .AppImage |
~4 MB |
对比 Electron 打包动辄 100MB+,Tauri 这个体积让人心情很好。
图标和元信息
在 src-tauri/tauri.conf.json 里配置:
bash
{
"productName": "AI Chat",
"version": "0.1.0",
"identifier": "com.example.ai-chat",
"app": {
"windows": [
{
"title": "AI Chat",
"width": 900,
"height": 700,
"resizable": true
}
]
}
}
如果想自定义图标(应用图标、安装器图标),准备一张 1024x1024 的 PNG,然后:
bash
npm run tauri icon path/to/icon.png
自动生成各平台各尺寸的图标。
跑完 npm run tauri dev 就能看到一个完整的 AI 聊天桌面应用。改 API URL 可以切到 DeepSeek、Claude、本地 Ollama,任何 OpenAI 兼容的 API 都行。
完整的源码思路都在上面了,可以直接复制粘贴跑起来。如果要加功能------对话历史持久化、Markdown 渲染、多 Agent 切换------都是普通的前端活,跟 Tauri 没关系了。
想动手试试的话,从 npm create tauri-app@latest 开始,半小时内就能看到第一个版本。
作者 : itech001
来源 : 公众号:AI人工智能时代
主页 : www.theaiera.cn(每日分享最前沿的AI新闻和技术)
本文首发于 AI人工智能时代,转载请注明出处。