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 轮防死循环。