1. 从 SQLite 到模型调用:工作进度管理桌面应用的真实痛点
用 Tauri 2.5.1 加 Leptos 0.7.8 写一个自用的工作进度管理桌面应用,前端用 Rust 编译成 WASM,后端用 sqlx 操作 SQLite,部门、人员、工作类型、工作主表、进度记录五张表通过外键串起来,这套结构跑起来之后,数据读写其实已经稳了。真正让人头疼的是另一件事:当你想在这个应用里加一点“智能”能力,比如让模型帮你把一段口语化的进度描述整理成规范记录,或者根据工作内容自动推荐工作类型,模型调用的配置就开始到处散落。
我试过最原始的做法,把 API Key 直接写在前端某个const里,编译进 WASM。结果就是每次换 Key 都要重新trunk build,而且 Key 明文躺在打包产物里,自己看着都不舒服。后来改成后端 Rust 侧读取环境变量,但 Tauri 打包成桌面应用之后,环境变量在不同机器上又不一样,用户装完还得手动配。再后来想接多个模型,DeepSeek 一个 Key、另一个模型又一个 Key,配置文件越写越乱,lib.rs里塞了一堆和数据库无关的 HTTP 逻辑。
这个场景的核心矛盾是:桌面应用的数据层已经用 SQLite 本地化了,但模型调用层还停留在“每个项目各自为政”的状态。TaoToken 在这里扮演的角色,是把模型调用的 Base URL、Key、Model ID 收敛成一套统一配置,让 Tauri 后端只认一个入口,前端 Leptos 只管发请求,不用关心背后是哪个模型厂商。下面我会把数据库结构、Tauri 命令、Leptos 请求封装、以及接入 TaoToken 统一 Key 的完整配置一步步写出来,最后给出启动应用后验证进度数据读写和模型调用是否连通的检查步骤。
适合谁看:已经在用 Tauri 2.x 做桌面应用、前端选了 Leptos、并且希望把模型调用配置集中管理的开发者。如果你还没搭好 SQLite 那套表结构,建议先把部门、人员、工作、进度记录这几张表跑通,再来看模型接入部分,否则排查问题时变量太多。
2. TaoToken 统一 Key 的前置准备与配置思路
在动手改代码之前,先把 TaoToken 这边的准备工作做完。你需要一个可用的 API Key,以及确认要调用的模型 ID。TaoToken 的 API 入口是https://taotoken.net/api,这个地址在 Tauri 后端作为统一的 Base URL 使用,前端 Leptos 不直接接触它,所有模型请求都通过 Tauri 命令转发。
为什么不让 Leptos 前端直接发 HTTP 请求?两个原因。第一,WASM 里发请求会暴露 Key,哪怕你做了混淆,打包产物里依然能翻出来。第二,Tauri 的 Rust 侧有reqwest这样的成熟 HTTP 客户端,超时、重试、错误处理都比在 WASM 里写fetch舒服。所以架构上我选择:Leptos 通过invoke调用 Tauri 命令,Tauri 命令内部用reqwest请求 TaoToken 的 API,Key 存在 Rust 侧,前端完全无感。
配置的存放位置,我建议放在 Tauri 的应用数据目录下,和db.sqlite同级。这样打包分发之后,每个用户有自己的配置文件,不会互相干扰。具体路径可以通过app.path().app_data_dir()拿到,和之前setup_db里创建数据库的路径逻辑一致。配置文件用 JSON 格式,字段包括base_url、api_key、model_id三项,后续如果要加超时时间或者代理设置,也在这个文件里扩展。
这里有一个容易踩的坑:Tauri 2.x 的app_data_dir在不同操作系统下路径不同,Windows 是C:\Users\<user>\AppData\Roaming\<bundle_id>,macOS 是~/Library/Application Support/<bundle_id>,Linux 是~/.config/<bundle_id>。你在写配置文件读写逻辑时,不要硬编码路径,统一用app.path().app_data_dir()解析。另外,首次启动时配置文件不存在,需要给一个默认模板,引导用户填入自己的 Key,而不是直接 panic。
关于 Key 的安全,TaoToken 的 Key 本身是调用凭证,放在本地配置文件里,权限设置为仅当前用户可读即可。不要把它提交到 Git 仓库,.gitignore里加上配置文件名。如果你要做多用户或者团队共享,那是另一个话题,本文聚焦单机自用场景。
模型 ID 的选择上,TaoToken 支持多种模型,你在配置里填哪个,后端请求时就传哪个。建议先用一个你熟悉的模型跑通链路,比如做文本整理类的任务,选一个响应稳定的模型即可。不要一上来就配一堆模型做路由,先把单模型链路验证通过,再考虑扩展。
3. 可复制的 Tauri 配置片段与 Leptos 请求封装
这一节给出可以直接粘贴的代码。先看 Tauri 侧的配置结构。在src-tauri目录下新建一个config.rs,或者直接写在lib.rs里,我倾向于单独一个模块,保持lib.rs聚焦数据库命令。
// src-tauri/src/llm_config.rs use serde::{Deserialize, Serialize}; use std::path::PathBuf; use tauri::Manager; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct LlmConfig { pub base_url: String, pub api_key: String, pub model_id: String, } impl Default for LlmConfig { fn default() -> Self { Self { base_url: "https://taotoken.net/api".to_string(), api_key: String::new(), model_id: "deepseek-chat".to_string(), } } } pub fn config_path(app: &tauri::AppHandle) -> PathBuf { let mut path = app.path().app_data_dir().expect("获取应用数据目录失败"); path.push("llm_config.json"); path } pub fn load_config(app: &tauri::AppHandle) -> LlmConfig { let path = config_path(app); if !path.exists() { let default = LlmConfig::default(); save_config(app, &default); return default; } let content = std::fs::read_to_string(&path).unwrap_or_default(); serde_json::from_str(&content).unwrap_or_default() } pub fn save_config(app: &tauri::AppHandle, config: &LlmConfig) { let path = config_path(app); let content = serde_json::to_string_pretty(config).unwrap(); std::fs::write(path, content).expect("写入配置文件失败"); }这段代码的关键点是base_url默认值指向https://taotoken.net/api,model_id给一个占位值,api_key留空等用户填。load_config在文件不存在时会自动生成默认配置,避免首次启动报错。
接下来是 Tauri 命令,负责接收前端传来的 prompt,调用 TaoToken 的 API,返回模型输出。这里用reqwest,需要在Cargo.toml里加上依赖:
[dependencies] reqwest = { version = "0.12", features = ["json"] } tokio = { version = "1", features = ["full"] }命令实现:
// src-tauri/src/lib.rs 中新增 use reqwest::Client; use serde_json::json; #[derive(Serialize, Deserialize)] struct ChatRequest { prompt: String, } #[derive(Serialize, Deserialize)] struct ChatResponse { content: String, } #[tauri::command] async fn call_llm( app: tauri::AppHandle, request: ChatRequest, ) -> Result<ChatResponse, String> { let config = crate::llm_config::load_config(&app); if config.api_key.is_empty() { return Err("ERROR! 请先在配置文件中填入 TaoToken API Key".to_string()); } let client = Client::new(); let url = format!("{}/v1/chat/completions", config.base_url.trim_end_matches('/')); let body = json!({ "model": config.model_id, "messages": [ {"role": "user", "content": request.prompt} ], "stream": false }); let resp = client .post(&url) .header("Authorization", format!("Bearer {}", config.api_key)) .header("Content-Type", "application/json") .json(&body) .send() .await .map_err(|e| format!("请求失败: {}", e))?; let status = resp.status(); let text = resp.text().await.map_err(|e| format!("读取响应失败: {}", e))?; if !status.is_success() { return Err(format!("API 返回错误 {}: {}", status, text)); } let parsed: serde_json::Value = serde_json::from_str(&text) .map_err(|e| format!("解析响应失败: {},原始内容: {}", e, text))?; let content = parsed["choices"][0]["message"]["content"] .as_str() .unwrap_or("") .to_string(); Ok(ChatResponse { content }) }注意url的拼接方式,base_url末尾可能带斜杠,用trim_end_matches('/')处理掉,再拼/v1/chat/completions。这是 OpenAI 兼容格式的路径,TaoToken 的 API 遵循这个规范。请求头里Authorization用Bearer加 Key,这是标准做法。
然后在invoke_handler里注册这个命令:
.invoke_handler(tauri::generate_handler![ // ... 原有的数据库命令 call_llm, ])Leptos 侧的封装,在schedule.rs或者新建一个llm.rs模块里,定义请求参数结构体和调用函数:
// src/app/llm.rs use leptos::task::spawn_local; use leptos::*; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; use serde_wasm_bindgen; #[wasm_bindgen] extern "C" { #[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"], catch)] async fn invoke(cmd: &str, args: JsValue) -> Result<JsValue, JsValue>; } #[derive(Serialize, Deserialize)] struct ChatRequest { prompt: String, } #[derive(Serialize, Deserialize)] struct ChatResponse { content: String, } pub fn request_llm( prompt: String, set_result: WriteSignal<String>, set_error: WriteSignal<String>, ) { spawn_local(async move { let args = ChatRequest { prompt }; let args_js = match serde_wasm_bindgen::to_value(&args) { Ok(v) => v, Err(e) => { set_error.set(format!("参数序列化失败: {}", e)); return; } }; match invoke("call_llm", args_js).await { Ok(result) => { match serde_wasm_bindgen::from_value::<ChatResponse>(result) { Ok(resp) => { set_result.set(resp.content); set_error.set(String::new()); } Err(e) => { set_error.set(format!("响应反序列化失败: {}", e)); } } } Err(e) => { let err_str = e.as_string().unwrap_or_else(|| format!("{:?}", e)); set_error.set(err_str); } } }); }这里invoke的签名和之前数据库命令一致,参数结构体的键prompt必须和 Tauri 命令里ChatRequest的字段名一致,否则反序列化会失败。这是 Tauri 命令参数传递的硬性要求,键名不能带下划线,也不能用驼峰,保持和 Rust 结构体字段名完全一致。
在schedule.rs里使用这个封装,比如在进度记录表单旁边加一个“智能整理”按钮:
let (llm_result, set_llm_result) = signal(String::new()); let (llm_error, set_llm_error) = signal(String::new()); // 在 view! 中 <button on:click=move |_| { let raw = progress_content.get_untracked(); if raw.is_empty() { set_llm_error.set("进度内容为空,无法整理".to_string()); return; } let prompt = format!("请将以下工作进度描述整理成规范的中文记录,保留关键信息,不要添加额外内容:\n{}", raw); request_llm(prompt, set_llm_result, set_llm_error); }>"智能整理"</button>这样,前端只负责收集输入和展示结果,Key 和 Base URL 完全在 Rust 侧,Leptos 编译出的 WASM 里没有任何敏感信息。
4. 启动应用后验证进度数据读写与模型调用连通
代码写完之后,验证分两步走:先确认数据库读写正常,再确认模型调用链路通。
数据库部分,启动trunk serve --open和cargo tauri dev,应用窗口打开后,按顺序操作:新建一个部门,比如“研发部”;在部门人员管理里给这个部门添加一个员工;新建一个工作类型,比如“功能开发”;然后新建一个工作,标题填“测试工作流”,内容填一段超过 10 个字符的描述,选择工作类型和部门,勾选员工并指定负责人,提交。提交成功后,点击“读取工作列表”,应该能看到刚创建的工作,包含责任部门和参与人员信息。再选中这个工作,在进度记录区域添加一条进度,记录人选择刚才的员工,提交后进度记录历史里应该出现这条记录,按时间倒序排列。这一套走完,说明 SQLite 的增删改查和外键关联都正常。
模型调用部分,先确认配置文件已经生成。在应用数据目录下找到llm_config.json,填入你的 TaoToken API Key,base_url保持https://taotoken.net/api,model_id填你要用的模型。保存后重启应用,让配置生效。然后在进度记录表单里输入一段口语化的描述,比如“今天把登录模块的接口调通了,明天开始写单元测试”,点击“智能整理”按钮。如果配置正确,几秒后结果区域会显示整理后的文本;如果出错,错误区域会显示具体原因。
验证模型调用是否真正走通,可以看 Tauri 的终端输出。reqwest请求失败时,错误信息会通过Err返回给前端,同时你也可以在call_llm里加一行println!打印请求 URL 和状态码,方便排查。如果返回 401,说明 Key 不对或者没填;如果返回 404,说明base_url拼接有问题,检查是不是多拼或少拼了/v1;如果返回 200 但choices为空,说明model_id可能不对,换一个确认可用的模型 ID 再试。
一个完整的成功链路是这样的:Leptos 按钮点击 →invoke("call_llm", args)→ Tauri 命令读取llm_config.json→reqwestPOST 到https://taotoken.net/api/v1/chat/completions→ 返回 JSON → 提取choices[0].message.content→ 通过ChatResponse返回前端 →set_llm_result更新界面。每一步都有明确的输入输出,哪一步断了都能定位。
如果你在验证时发现数据库操作正常但模型调用一直超时,先检查网络是否能访问taotoken.net,再检查Cargo.toml里reqwest的 features 是否包含了json,以及tokio的 features 是否完整。Tauri 2.x 默认使用 tokio 运行时,reqwest的异步请求需要 tokio 支持,features 不全时会在编译期或运行期报错。
5. 本篇常见错误排查:401、local proxy failed、reading choices
这一节把接入过程中最容易撞上的几个报错拆开讲,每个都给出定位方法和修复动作。
401 Unauthorized。这个最直接,Key 不对或者没带上。检查llm_config.json里的api_key字段是否为空,是否有多余空格。TaoToken 的 Key 通常是一串字符,复制时不要带换行。另外确认请求头里Authorization的格式是Bearer <key>,中间一个空格,不要写成Bearer:<key>或者漏掉Bearer。如果你在配置文件里填了 Key 但依然 401,可以在call_llm里打印一下实际发送的 header,确认没有被其他逻辑覆盖。
local proxy failed。这个报错通常出现在reqwest无法建立连接时,原因可能是base_url写错,比如写成了https://taotoken.net但漏了/api,或者写成了http://而不是https://。另一个可能是本机网络环境对taotoken.net的访问受限,但这种情况在正常网络下不应该出现。先确认base_url是https://taotoken.net/api,再确认url拼接后是https://taotoken.net/api/v1/chat/completions。如果拼接逻辑里trim_end_matches('/')把/api后面的斜杠也去掉了,那是对的,因为/api本身不带尾斜杠。
reading 'choices'。这个报错说明响应 JSON 里没有choices字段,或者choices是空数组。常见原因有三个:一是model_id填错了,API 返回了错误信息而不是正常的 completion 结构;二是请求体格式不对,比如messages数组为空,或者model字段缺失;三是 API 返回了非 200 状态但你没有先检查status,直接去解析choices。修复方法是在解析choices之前先判断status.is_success(),不成功时把原始text返回,这样能看到 API 的具体错误信息。另外,parsed["choices"][0]["message"]["content"]这种索引方式在choices为空时会返回Null,用as_str().unwrap_or("")兜底,避免 panic。
OAuth 相关报错。如果你在配置里误填了 OAuth 类型的凭证,或者base_url指向了需要 OAuth 流程的端点,会看到类似invalid_grant或unauthorized_client的错误。TaoToken 的 API 使用 Bearer Key 认证,不需要 OAuth 流程,所以确认base_url是https://taotoken.net/api,认证方式用Authorization: Bearer。如果你之前配过其他平台的 OAuth 配置,检查是否残留了client_id、client_secret之类的字段,这些在 TaoToken 的配置里不需要。
Tauri 命令参数反序列化失败。这个报错不会直接显示为 401 或 404,而是前端收到参数序列化失败或响应反序列化失败。原因是 Leptos 侧结构体字段名和 Tauri 命令侧结构体字段名不一致。比如前端ChatRequest { prompt: String },后端也必须是ChatRequest { prompt: String },字段名、大小写、下划线都要一致。Tauri 用 serde 做序列化,字段名就是 JSON 的键,任何不一致都会导致反序列化失败。检查时把两边的结构体定义并排看,确认字段名完全一致。
数据库外键约束报错。虽然不属于模型调用,但在验证进度数据读写时容易遇到。比如删除部门时,如果该部门下还有人员,或者该部门关联了工作,外键约束会阻止删除。SQLite 的外键约束需要在每次连接时执行PRAGMA foreign_keys = ON;,这个在迁移文件里已经写了,但如果你手动用其他工具打开数据库,可能没有启用。应用内的删除操作走的是 Tauri 命令,命令里用的是同一个连接池,外键约束是生效的。遇到删除失败时,先检查是否有级联删除的配置,work_departments和work_personnel表对works表有ON DELETE CASCADE,但personnel对departments没有级联,所以删除部门前要先清空该部门的人员。
6. 把统一 Key 用在长期编码与 Agent 场景
走到这里,你的 Tauri 加 Leptos 桌面应用已经能通过 TaoToken 统一 Key 调用模型了。配置集中在llm_config.json,前端 Leptos 通过invoke调用 Tauri 命令,后端 Rust 用reqwest发请求,Key 不出现在 WASM 产物里。数据库那套部门、人员、工作、进度记录的表结构继续正常工作,模型调用只是多了一个命令,不影响原有的 SQLite 读写。
如果你后续想把这个应用扩展成更自动化的工具,比如让模型根据进度记录自动生成周报,或者根据工作内容自动推荐工作类型,只需要在call_llm的基础上加新的 Tauri 命令,复用同一份配置。前端 Leptos 侧也只需要加对应的invoke封装,不需要重复处理 Key 和 Base URL。
对于长期编码和 Agent 场景,TaoToken 的 Coding Plan 提供了更适合持续调用的方案,你可以在控制台里管理 Key 和用量。如果你需要查看模型列表或者调试对话,模型对话页面可以直接测试。接入文档里有完整的 API 说明,API Keys 页面可以创建和管理你的凭证。把这些入口收藏好,后续换模型或者加新功能时,改配置就行,不用动代码结构。
最后留一个实用技巧:在call_llm里加一个简单的重试逻辑,当reqwest返回超时或者 5xx 时,自动重试一次。桌面应用的网络环境不一定稳定,一次重试能省掉很多手动点击。重试时注意不要重复发送已经成功的请求,用一个简单的计数器控制次数即可。这个逻辑不复杂,但能明显提升使用体验。