用 Tauri 写一个 AI Chat:3MB 的桌面应用干翻 Electron 100MB

用 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 问题,还支持流式输出。

这篇文章把整个过程拆解给你,从安装到打包发布,每一步都能跟着跑。

本文提纲

  1. Tauri 是什么、为什么比 Electron 好
  2. 环境准备与项目初始化
  3. Rust 后端:API 代理与流式处理
  4. Svelte 前端:聊天 UI 搭建
  5. 前后端通信:invoke 与事件系统
  6. 打包与发布

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)
}

这段代码做了三件事:

  1. reqwest 发送 POST 请求到 AI API(支持 OpenAI 兼容的任何 API)
  2. 解析 SSE(Server-Sent Events)流式响应
  3. 每收到一个 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人工智能时代,转载请注明出处。

相关推荐
scglwsj1 小时前
Spec:让 AI 在实现前真正理解问题
人工智能
狐狐生风1 小时前
LangGraph 核心概念全解笔记
人工智能·python·langchain·prompt·langgraph
EAIReport1 小时前
深度拆解WorkBuddy技术实现:腾讯云全场景AI智能体的架构设计与核心逻辑
人工智能·云计算·腾讯云
美狐美颜SDK开放平台1 小时前
什么是美颜SDK?高并发场景下的企业级美颜SDK如何开发?
android·人工智能·ios·美颜sdk·第三方美颜sdk·视频美颜sdk
Westward-sun.1 小时前
Claude Code 接入 DeepSeek V4 Pro:从 npm 安装到 CC Switch 配置完整记录
网络·人工智能
项目題供诗1 小时前
STM32-对射式红外传感器计次&旋转编码器计次(九)
人工智能·stm32·嵌入式硬件
灵机一物1 小时前
灵机一物AI原生电商小程序、PC端(已上线)-黄仁勋 CNBC 对话全文解析:AI 算力、芯片出口、安全开源与产业生态核心观点
人工智能
叶子Talk1 小时前
AI终端国标发布:你的手机/眼镜是L几?
人工智能·ai·智能手机·国家标准·智能终端·工信部
Apifox.1 小时前
Apifox 近期更新|AI Agent Debugger、A2A Debugger、Postman API 导入、Ask AI 侧边栏对话
前端·人工智能·后端·测试工具·测试用例·postman