楼层: 首页/ 软件技术/ Rust 项目实战/ 项目二:跨端 AI 应用系统(Tauri 2.0)
03

项目二:跨端 AI 应用系统(Tauri 2.0)

Desktop AI Assistant · Tauri 2 + RAG + Agent

这一次换个口味:做一个跑在你电脑桌面上的 AI 助手。左边聊天下拉框,右边知识库面板,提问时 AI 先去你的文档里检索,再组织回答——数据不出本机。技术栈是 Tauri 2.0:前端用 React 写界面,后端用 Rust 干活,打包出来是个几 MB 的安装包(Electron 动不动几百 MB)。

论Tauri 和 Electron 到底差在哪

Electron(VS Code、旧版钉钉用的)= 每个应用塞一个完整的 Chrome 浏览器进去,十个应用占十个浏览器内存,安装包几百 MB。

Tauri 2.0 = 不塞浏览器,直接用操作系统自带的 WebView(Windows 用 WebView2,macOS 用 WKWebView),后端是 Rust 编译的原生二进制。同样一个应用,Tauri 版体积是 Electron 的十分之一,内存占用低一大截。而且 Tauri 2.0 一套代码还能同时出 Windows / macOS / Linux / 安卓 / iOS。

架构设计:前端界面 + Rust 大脑

┌───────────────────────────────────────────────┐ │ 前端(React + TypeScript + shadcn/ui) │ │ ChatWindow 聊天 Sidebar 会话列表 │ │ KnowledgeBase 知识库 AgentPanel 工具面板 │ └──────────────┬────────────────────────────────┘ │ invoke() 调命令 / emit 推事件 ┌──────────────▼────────────────────────────────┐ │ Rust 后端(#[tauri::command]) │ │ llm.rs 调 OpenAI 兼容 API(reqwest + SSE) │ │ rag.rs 文档切分→向量化→qdrant 检索 │ │ agent.rs ReAct 循环 + 工具调用 │ │ store.rs sqlite 存聊天记录/设置 │ └──────────────┬────────────────────────────────┘ │ qdrant(向量) + sqlite(本地) + LLM API

Cargo.toml(Tauri 部分关键依赖)

[dependencies] tauri = { version = "2", features = ["tray-icon"] } serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", features = ["json", "stream"] } // 调 LLM API tokio = { version = "1", features = ["full"] } sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] } qdrant-client = "1" // 向量数据库客户端 tracing = "0.1" anyhow = "1"

核心一:Tauri 命令(前端怎么调 Rust)

Tauri 的通信模型:Rust 端写个函数加 #[tauri::command],前端用 invoke 调用。就像调一个普通的 JS 函数,只不过它实际跑在 Rust 里。

src-tauri/src/lib.rs(Rust 命令定义)

use tauri::State; /// 应用状态:全局持有 LLM 客户端和数据库连接 pub struct AppState { pub llm: LlmClient, pub db: sqlx::SqlitePool, } /// 命令:前端 invoke("chat", { message }) 就调到这里 #[tauri::command] async fn chat( message: String, window: tauri::Window, state: State<'_, AppState>, ) -> Result<String, String> { // 流式:每生成一个字,就 emit 事件推给前端 let stream = state.llm.chat_stream(&message).await .map_err(|e| e.to_string())?; 逐字读取... while let Some(chunk) = stream.next().await { window.emit("chat-token", &chunk).map_err(|e| e.to_string())?; } Ok("done".into()) } /// 把命令注册进应用 #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .manage(AppState { /* ... */ }) .invoke_handler(tauri::generate_handler![chat, add_doc, ask_agent]) .run(tauri::generate_context!()) .expect("启动失败"); }

前端 React 怎么调(TS)

// 调命令:一发出去就等着 import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; async function send(text: string) { // 监听 Rust 推过来的逐字 token const unlisten = await listen<string>("chat-token", (e) => { appendToken(e.payload); // 往消息框里追加这一个字 }); await invoke("chat", { message: text }); unlisten(); }
前端效果: 你好 → 你好,我是 AI 助手…(逐字蹦出来,跟 ChatGPT 一模一样)

核心二:LLM 调用层(reqwest + SSE 流式)

Rust 后端用 reqwest 调 OpenAI 兼容的 API(DeepSeek、通义、本地 Ollama 都行)。关键是流式读取——响应是 SSE 流,一个 chunk 一个 chunk 地吐,我们边收边往前端推。

src-tauri/src/ai/llm.rs

use futures::StreamExt; pub struct LlmClient { http: reqwest::Client, api_key: String, base_url: String, // 如 https://api.deepseek.com/v1 } impl LlmClient { /// 流式对话:返回一个 token 流 pub async fn chat_stream(&self, prompt: &str) -> anyhow::Result<impl Stream<Item = anyhow::Result<String>>> { let resp = self.http .post(format!("{}/chat/completions", self.base_url)) .bearer_auth(&self.api_key) .json(&serde_json::json!({ "model": "deepseek-chat", "messages": [{"role": "user", "content": prompt}], "stream": true // 关键:开流式 })) .send().await?; // 把响应体拆成流,逐行解析 SSE let stream = resp.bytes_stream().map(|bytes| { let bytes = bytes?; let text = String::from_utf8_lossy(&bytes); // SSE 格式:data: {...}\n\n,取出 delta.content let chunk = parse_sse_delta(&text); Ok(chunk) }); Ok(stream) } }

核心三:RAG 知识库(检索增强生成)

直接问 AI"我们公司年假几天",它根本不知道。RAG 的思路是:先把你的文档切碎、存进向量库;提问时先检索出最相关的几段,塞进 prompt 里,让 AI 看着资料回答。

src-tauri/src/ai/rag.rs(检索流程)

/// 1. 文档入库:读文件 → 切块 → 向量化 → 存 qdrant pub async fn add_document(&self, path: &str) -> anyhow::Result<()> { let text = tokio::fs::read_to_string(path).await?; // 递归字符切分:按句号/换行切,每块约 500 字,有重叠 let chunks = chunk_text(&text, 500, 50); for c in chunks { let vec = self.embed(&c).await?; // 调 embedding API self.qdrant.upsert_point(&c, vec).await?; // 存向量库 } Ok(()) } /// 2. 提问:先检索,再把资料拼进 prompt pub async fn answer_with_rag(&self, question: &str) -> anyhow::Result<String> { // 问题向量化,去 qdrant 找最相似的 5 段(余弦相似度) let qvec = self.embed(question).await?; let hits = self.qdrant.search(&qvec, 5).await?; // 拼成"照着资料回答"的 prompt let context: String = hits.iter() .map(|h| format!("[来源] {}\n", h.text)).collect(); let prompt = format!( "严格根据以下资料回答,资料里没有就说不知道。\n\n资料:\n{context}\n\n问题:{question}", ); self.llm.chat(&prompt).await }

解RAG 为什么能"不胡说"

普通 LLM 是"凭训练记忆瞎编"。RAG 是把证据摆在它眼前:"只能用这几段话回答"。于是它回答"年假 10 天"时,你能让它附上[来源:员工手册第3章]——这就是企业愿意用的关键:可溯源、不编造。检索质量取决于切块大小和 embedding 模型,块太大引入噪音,太小丢失上下文,500 字左右是经验值。

核心四:Agent 系统(ReAct 推理循环)

普通聊天只会"说",Agent 会"做"——查文件、算数、搜网页。核心是 ReAct 循环:思考 → 决定用哪个工具 → 执行 → 看结果 → 再思考,直到能给出答案。

src-tauri/src/ai/agent.rs

/// 工具注册表:名字 + 描述 + 执行函数 struct Tool { name: String, desc: String, exec: fn(&str) -> String } async fn react_loop(llm: &LlmClient, question: &str) -> anyhow::Result<String> { let tools = [ Tool { name: "calculator", desc: "数学计算", exec: |e| eval(e) }, Tool { name: "read_file", desc: "读本地文件", exec: |p| read(p) }, ]; let mut history = String::new(); for _ in 0..5 { // 最多思考 5 轮,防死循环 // 1. 让模型决定下一步 let thought = llm.chat(&build_prompt(question, &tools, &history)).await?; // 2. 如果模型给出最终答案就结束;否则它会要求调工具 if let Some(tool_call) = parse_tool_call(&thought) { let result = (tool_call.fn_)(&tool_call.args); // 执行工具 history.push_str(&format!("\n工具结果: {result}")); } else { return Ok(thought); // 模型想清楚了,输出答案 } } anyhow::bail!("超过最大轮次"); }
过程演示: 用户: 帮我算一下 2345 * 6789 模型: 需要调用 calculator(2345*6789) 工具: 15920205 模型: 答案是 15920205

核心五:本地存储(SQLite)与前端结构

聊天记录、会话、设置都存在本机 SQLite,用户换电脑前导出一下就行。建表很简单:

migrations/0001.sqlite.sql —— 本地数据库表

-- 会话表 CREATE TABLE IF NOT EXISTS conversations ( id TEXT PRIMARY KEY, -- UUID title TEXT NOT NULL DEFAULT '新会话', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- 消息表(外键级联删除,删会话连消息一起删) CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, role TEXT NOT NULL, -- user / assistant content TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- 设置表(KV 存 API Key、模型选择、主题) CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL );

前端 React 组件树(src/)

src/ ├── App.tsx // 根组件 ├── components/ │ ├── Sidebar.tsx // 左侧会话列表、新建会话按钮 │ ├── ChatWindow.tsx // 中间聊天区 │ ├── MessageList.tsx // 消息列表(Markdown 渲染 + 代码高亮) │ ├── MessageInput.tsx // 底部输入框 │ ├── KnowledgeBase.tsx // 右侧知识库:上传文档、查看向量 │ └── Settings.tsx // 设置:API Key、模型、主题 ├── store/ │ └── useChat.ts // Zustand 状态管理:当前会话、消息列表 └── lib/tauri.ts // 封装 invoke,TS 类型安全

系统托盘(关闭窗口时缩到托盘,不退出)

use tauri::{menu::{Menu, MenuItem}, tray::{TrayIcon, TrayIconBuilder}, Manager}; fn build_tray(app: &tauri::App) -> tauri::Result<()> { let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; let menu = Menu::with_items(app, &[&show, &quit])?; TrayIconBuilder::new() .icon(app.default_window_icon().unwrap().clone()) .menu(&menu) .on_menu_event(|app, event| match event.id().as_ref() { "show" => app.get_window("main").unwrap().show().unwrap(), "quit" => app.exit(0), _ => {} }) .build(app)?; Ok(()) }

打包发布与安全

事项怎么做
打包命令tauri build,自动出 Windows NSIS 安装包、macOS .dmg、Linux .AppImage。
自动更新Tauri updater 插件,每次启动检查版本,签名验证后静默下载安装。
系统托盘TrayIcon + 菜单,点关闭时最小化到托盘,不退出。
API Key 安全存系统钥匙串(keyring crate),不写进前端 JS bundles,杜绝泄露。
权限管控Tauri capabilities 配置精确授权:只允许访问指定目录、指定域名,前端不能为所欲为。
别把 API Key 写进前端

Tauri 的前端虽然跑在你本机,但前端代码是能被打开看的。把 OpenAI 的 API Key 直接写进 JS,等于把家门钥匙贴在门上——别人拿到你的安装包就能扒出来刷你的额度。正确做法:Key 只存在 Rust 后端,用系统钥匙串加密,前端永远拿不到,调用时也是 Rust 后端去发请求。这是 Tauri 安全模型的核心。

记
本章小结

① Tauri 2.0 = 系统 WebView + Rust 后端,安装包几 MB,一套代码出五端。

② 前后端通信:Rust 写 #[tauri::command],前端 invoke 调、listen 收流式事件。

③ RAG = 切块→向量化→qdrant 检索→拼进 prompt,让 AI 照着资料答、可溯源。

④ Agent = ReAct 循环:思考→调工具→看结果→再想,最多 5 轮防死循环。