Skip to content

Tauri 应用接入

本页是一份完整的接入示例:从加依赖到前端拿到结构化错误。 示例基于 Tauri 2.x + React,但除了 Command 那一层,其余对任何 Rust 应用都适用。

1. 加依赖

toml
# src-tauri/Cargo.toml
[dependencies]
ai-profile = { git = "https://github.com/bkywksj/ai-profile", features = ["chat", "client"] }

如果你的应用还要生图 / 语音,在 features 里一并开启 —— 见 feature 矩阵

2. 把 Verifier 存进 AppState

Verifier 内部持有 reqwest::Client 的连接池。它是需要长期持有的资源,不是每次调用现建的临时对象

rust
// src-tauri/src/state.rs
use ai_profile::client::Verifier;

pub struct AppState {
    pub db: Database,
    /// 模型服务验证器 —— 持有连接池,全应用共用一个。
    ///
    /// 🔴 不要在 Command 里现建:reqwest::Client 的连接池是它的全部价值,
    /// 每次新建等于每次都从 TCP + TLS 握手重来,跨境端点尤其明显。
    pub verifier: Verifier,
}

带上应用自己的代理设置

多数桌面应用有自己的代理配置。用 from_builder 把它注入进来:

rust
// src-tauri/src/lib.rs —— setup 阶段
use ai_profile::client::Verifier;

// 用本 crate 重导出的 reqwest 建 builder,版本必然匹配
let builder = crate::proxy::apply_proxy(ai_profile::reqwest::Client::builder());
let verifier = Verifier::from_builder(builder)
    .map_err(|e| AppError::Custom(format!("模型服务验证器初始化失败: {e}")))?;

app.manage(AppState { db, verifier });

ai_profile::reqwest,不要用自己依赖树里的 reqwest

两边版本一旦不同,报错会是 expected ClientBuilder, found ClientBuilder 这种看不懂的形式。 本 crate 重导出 reqwest 就是为了消掉这个坑。

超时与禁重定向你覆盖不掉

from_builder 会在你的配置之后再施加超时和禁重定向。

禁重定向不是可选项:跨 host 跳转时 reqwest 只剥 Authorization 等标准头、 不剥自定义头 —— Anthropic 的 x-api-key 会被原样发往跳转目标。

3. Service 层

按三层架构,Command 只做 IPC 包装,逻辑放 Service:

rust
// src-tauri/src/services/model_service.rs
use ai_profile::client::{ServiceConfig, Verifier, check_required_fields};
use ai_profile::{Kind, ProviderPreset, VerifyError, Vendor, vendors, preset_by_key};

/// 本应用声明支持的能力 —— 只做对话。
const SUPPORTED: &[Kind] = &[Kind::Chat];

pub struct ModelService;

impl ModelService {
    /// 服务商目录,给设置页的卡片列表用
    pub fn vendor_catalog() -> Vec<Vendor> {
        vendors(SUPPORTED)
    }

    /// 某一条预置的完整信息,给表单预填用
    pub fn preset(key: &str) -> Option<&'static ProviderPreset> {
        preset_by_key(key)
    }

    /// 验证一条配置
    pub async fn verify(
        verifier: &Verifier,
        preset_key: Option<&str>,
        base_url: &str,
        api_key: &str,
        model: &str,
        extra: &[(&str, &str)],
    ) -> Result<ai_profile::client::VerifyOk, VerifyError> {
        // 缺必填专有字段的话不必发请求 —— 但前端本就该把按钮置灰,
        // 这里是第二道闸门(前端可能被绕过,也可能是别的调用方)
        check_required_fields(preset_key, extra)?;

        let protocol = preset_key
            .and_then(preset_by_key)
            .map(|p| p.protocol)
            // 没有预置就按 base_url 反推
            .unwrap_or_else(|| {
                let key = ai_profile::preset::infer_preset_key(
                    ai_profile::Protocol::OpenAiCompatible,
                    Some(base_url),
                );
                preset_by_key(key).map(|p| p.protocol)
                    .unwrap_or(ai_profile::Protocol::OpenAiCompatible)
            });

        let mut cfg = ServiceConfig::new(protocol, base_url)
            .with_api_key(api_key)
            .with_model(model)
            .with_extra(extra);
        if let Some(k) = preset_key {
            cfg = cfg.with_preset(k);
        }
        verifier.verify(cfg).await
    }
}

4. 错误映射:别把结构拍扁

这是最容易做错的一步。不要这样写:

rust
// ❌ 结构没了,前端只能显示一行红字
.map_err(|e| CommandError { code: "VERIFY_FAILED".into(), message: e.to_string() })

这样做等于把本库最有价值的部分丢掉了:suggested_url 没了,「一键改用」按钮就做不出来。

有两种做法能保住结构,选哪个取决于你的应用有没有统一的 CommandError 约定。

做法 A:让验证结果成为返回值(推荐)

「端点连不上」是验证的结果,不是 Command 执行失败 —— Command 成功地完成了 验证工作。真正的 Err 留给读库失败、解密失败这类调用方无能为力的情况。

这样做还有个实际好处:应用里那套 Result<T, CommandError> 的约定一条不破。

rust
/// 成功与失败都是正常返回值
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifyOutcome {
    pub ok: bool,
    pub result: Option<ai_profile::client::VerifyOk>,
    /// 带 `code` 判别字段,前端据此给动作
    pub error: Option<ai_profile::VerifyError>,
    /// 失败是否可能通过「改配置」解决;false = 只能重试
    pub actionable: bool,
}

#[tauri::command]
pub async fn verify_model_service(
    state: tauri::State<'_, AppState>,
    preset_key: Option<String>,
    base_url: String,
    api_key: String,
    model: String,
) -> Result<VerifyOutcome, CommandError> {
    let cfg = /* … 见上一节 … */;
    Ok(match state.verifier.verify(cfg).await {
        Ok(ok) => VerifyOutcome { ok: true, result: Some(ok), error: None, actionable: true },
        Err(e) => VerifyOutcome {
            ok: false,
            result: None,
            actionable: e.is_actionable(),
            error: Some(e),
        },
    })
}

sigil 就是这么接的。

做法 B:直接返回 VerifyError

应用没有统一错误类型时更简单 —— VerifyError 自身就是结构化的:

rust
#[tauri::command]
pub async fn verify_model_service(/* … */) -> Result<ai_profile::client::VerifyOk, VerifyError> {
    state.verifier.verify(cfg).await
}

代价是这个 Command 的错误形状与应用里其它 Command 不一致 —— 前端那套 getErrorMessage / getErrorCode 辅助函数对它不适用。

rust
#[tauri::command]
pub fn list_model_vendors() -> Vec<ai_profile::Vendor> {
    ModelService::vendor_catalog()
}

别忘了注册:

rust
// src-tauri/src/lib.rs
.invoke_handler(tauri::generate_handler![
    // …既有的
    commands::model_service::verify_model_service,
    commands::model_service::list_model_vendors,
])

密钥不要写进日志

VerifyError 的所有 detail 字段只放端点返回的文本摘要(截断 300 字符), 密钥绝不会进错误信息 —— 所以你可以放心 log::warn!("{e}")

但别自己把 api_key 拼进日志行。

5. 批量验证

设置页常有「全部测试」按钮。verify 只借 &self,直接并发即可:

rust
use futures_util::future::join_all;

let results = join_all(configs.iter().map(|c| {
    let v = &state.verifier;
    async move { (c.id, ModelService::verify(v, /* … */).await) }
}))
.await;

共用一个 Verifier 意味着共用连接池 —— 同一家的多条配置只握手一次。

6. 迁移存量配置

用户升级前已经存了一堆配置。infer_preset_key 能从 base_url 反推出对应的预置模板, 让老配置回到「认识的服务商」而不是掉进「自定义端点」:

rust
use ai_profile::{preset::infer_preset_key, Protocol};

let key = infer_preset_key(Protocol::OpenAiCompatible, Some(&row.base_url));
// "deepseek" —— 即使用户存的是不带版本段的 https://api.deepseek.com

它按 host 而非完整字符串匹配,所以带不带 /v1、带不带结尾斜杠都认得回来。

preset.key 是存量配置的锚

改预置的 key 会让所有老配置掉进「自定义端点」—— 用户看到的是 "我配好的服务商突然不认识了"。这在本库里被定为 major 变更, 你的应用侧同理:别把 key 当可以随手改的展示文本。

下一步

MIT 协议开源 · 文档同样欢迎 PR