This commit is contained in:
fengchuanhn@gmail.com
2026-05-17 22:05:34 +08:00
parent 9dfeaa9e18
commit c70995fbc9
14 changed files with 428 additions and 148 deletions

View File

@@ -4,6 +4,8 @@
# Tauri Rust 请求后端基址(拉取 appConfig、脚本等 # Tauri Rust 请求后端基址(拉取 appConfig、脚本等
# AICLIENT_API_BASE=http://127.0.0.1:8001 # AICLIENT_API_BASE=http://127.0.0.1:8001
# 应用配置在 pythonbackend 的 MySQL 表 desktop_configsname/value # 应用配置在 pythonbackend 的 MySQL 表 desktop_configsname/value
# 登录后自动同步Node 流水线通过环境变量 AICLIENT_CFG_<NAME> 读取。 # 登录后自动同步Node 流水线通过环境变量 AICLIENT_CFG_<NAME> 读取。
# 常用键示例LLM_API_KEY、LLM_API_URL、LLM_MODEL_ID、ASR_MODE、DASHSCOPE_API_KEY、OSS_CONFIGJSON # 常用键示例LLM_API_KEY、LLM_API_URL、LLM_MODEL_ID、ASR_MODE、DASHSCOPE_API_KEY、OSS_CONFIGJSON

104
src-tauri/Cargo.lock generated
View File

@@ -8,6 +8,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.12" version = "0.8.12"
@@ -547,6 +582,16 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -689,6 +734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core 0.6.4",
"typenum", "typenum",
] ]
@@ -731,6 +777,15 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.23.0" version = "0.23.0"
@@ -1430,6 +1485,16 @@ dependencies = [
"wasip3", "wasip3",
] ]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]] [[package]]
name = "gio" name = "gio"
version = "0.18.4" version = "0.18.4"
@@ -1939,6 +2004,15 @@ dependencies = [
"cfb", "cfb",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@@ -2578,6 +2652,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "open" name = "open"
version = "5.3.5" version = "5.3.5"
@@ -2801,6 +2881,18 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.13.1" version = "1.13.1"
@@ -3984,12 +4076,14 @@ dependencies = [
name = "tauri-app" name = "tauri-app"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aes-gcm",
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",
"env_logger", "env_logger",
"futures-util", "futures-util",
"hex",
"hmac", "hmac",
"log", "log",
"mime_guess", "mime_guess",
@@ -4715,6 +4809,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"

View File

@@ -23,6 +23,10 @@ tokio = { version = "1", features = ["full"] }
futures-util = "0.3" futures-util = "0.3"
async-trait = "0.1" async-trait = "0.1"
# API AES-256-GCM与 pythonbackend 一致)
aes-gcm = "0.10"
hex = "0.4"
# HTTP client # HTTP client
reqwest = { version = "0.12", default-features = false, features = [ reqwest = { version = "0.12", default-features = false, features = [
"json", "json",

View File

@@ -0,0 +1,95 @@
//! 经 Rust 发起的 `/api/v1` HTTP 请求(可选 AES 加解密)。
use std::collections::HashMap;
use std::time::Duration;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde_json::Value;
use crate::api_crypto::{self, ENCRYPTED_HEADER};
use crate::app_config;
pub async fn request_json(
method: &str,
path: &str,
body: Option<&str>,
extra_headers: HashMap<String, String>,
) -> Result<(u16, Value), String> {
let path = if path.starts_with('/') {
path.to_string()
} else {
format!("/{path}")
};
let base = app_config::api_base().trim_end_matches('/').to_string();
let url = format!("{base}/api/v1{path}");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|e| e.to_string())?;
let method = reqwest::Method::from_bytes(method.to_uppercase().as_bytes())
.map_err(|e| format!("无效 HTTP 方法: {e}"))?;
let aes_key = api_crypto::aes_key_from_env();
let mut req = client.request(method, &url);
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("content-type"),
HeaderValue::from_static("application/json"),
);
for (k, v) in extra_headers {
if let (Ok(name), Ok(val)) = (
HeaderName::from_bytes(k.as_bytes()),
HeaderValue::from_str(&v),
) {
headers.insert(name, val);
}
}
if let Some(key) = aes_key {
headers.insert(
HeaderName::from_static("x-aiclient-encrypted"),
HeaderValue::from_static("1"),
);
if let Some(raw) = body.filter(|s| !s.is_empty()) {
let wire = api_crypto::wrap_encrypted(raw.as_bytes(), &key)?;
req = req.body(wire);
}
} else if let Some(raw) = body {
req = req.body(raw.to_string());
}
req = req.headers(headers);
let res = req.send().await.map_err(|e| {
if e.is_connect() {
"无法连接服务器,请确认后端已启动".to_string()
} else {
e.to_string()
}
})?;
let status = res.status().as_u16();
let encrypted = res
.headers()
.get(ENCRYPTED_HEADER)
.and_then(|v| v.to_str().ok())
== Some("1");
let text = res.text().await.map_err(|e| e.to_string())?;
let json_text = if encrypted {
let key = aes_key.ok_or_else(|| "响应已加密但未配置 AICLIENT_API_AES_KEY".to_string())?;
let plain = api_crypto::unwrap_encrypted(&text, &key)?;
String::from_utf8(plain).map_err(|e| format!("响应 UTF-8 无效: {e}"))?
} else {
text
};
let value: Value =
serde_json::from_str(&json_text).unwrap_or(Value::String(json_text.clone()));
Ok((status, value))
}

View File

@@ -0,0 +1,92 @@
//! AES-256-GCM与 pythonbackend `app/core/aes_crypto.py` 一致。
use rand::RngCore;
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
use base64::Engine;
use serde_json::Value;
pub const ENCRYPTED_HEADER: &str = "X-AICLIENT-Encrypted";
const ENVELOPE_VERSION: i64 = 1;
pub fn parse_aes_key(raw: &str) -> Option<[u8; 32]> {
let s = raw.trim();
if s.is_empty() {
return None;
}
let bytes = if s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()) {
hex::decode(s).ok()?
} else {
base64::engine::general_purpose::STANDARD
.decode(s)
.ok()?
};
if bytes.len() != 32 {
return None;
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Some(key)
}
pub fn aes_key_from_env() -> Option<[u8; 32]> {
let s="98cde5e64ee0da6ed0c5842f43a950aa0d8ae5087c43c8d8ee5d10bd8d571bb4";
parse_aes_key(&s)
}
pub fn encrypt_bytes(plaintext: &[u8], key: &[u8; 32]) -> Result<String, String> {
let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?;
let mut nonce_bytes = [0u8; 12];
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|e| format!("加密失败: {e}"))?;
let mut combined = nonce_bytes.to_vec();
combined.extend(ciphertext);
Ok(base64::engine::general_purpose::STANDARD.encode(&combined))
}
pub fn decrypt_payload(b64_payload: &str, key: &[u8; 32]) -> Result<Vec<u8>, String> {
let raw = base64::engine::general_purpose::STANDARD
.decode(b64_payload.trim())
.map_err(|e| format!("Base64 解码失败: {e}"))?;
if raw.len() < 13 {
return Err("密文过短".into());
}
let (nonce_bytes, ciphertext) = raw.split_at(12);
let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?;
let nonce = Nonce::from_slice(nonce_bytes);
cipher
.decrypt(nonce, ciphertext)
.map_err(|e| format!("解密失败: {e}"))
}
pub fn wrap_encrypted(plaintext: &[u8], key: &[u8; 32]) -> Result<String, String> {
let payload = encrypt_bytes(plaintext, key)?;
let envelope = serde_json::json!({
"v": ENVELOPE_VERSION,
"payload": payload,
});
serde_json::to_string(&envelope).map_err(|e| e.to_string())
}
pub fn unwrap_encrypted(body: &str, key: &[u8; 32]) -> Result<Vec<u8>, String> {
let v: Value = serde_json::from_str(body).map_err(|e| format!("信封 JSON 无效: {e}"))?;
let version = v.get("v").and_then(|x| x.as_i64());
let payload = v
.get("payload")
.and_then(|x| x.as_str())
.ok_or_else(|| "缺少 payload".to_string())?;
if version != Some(ENVELOPE_VERSION) {
return Err("不支持的加密版本".into());
}
decrypt_payload(payload, key)
}
pub fn crypto_enabled() -> bool {
aes_key_from_env().is_some()
}

View File

@@ -237,34 +237,20 @@ impl AppConfig {
} }
async fn fetch_from_server(token: &str) -> Result<HashMap<String, String>, AppConfigError> { async fn fetch_from_server(token: &str) -> Result<HashMap<String, String>, AppConfigError> {
let base = api_base().trim_end_matches('/').to_string(); let mut headers = HashMap::new();
let url = format!("{base}/api/v1/appConfig"); headers.insert(
"Authorization".to_string(),
format!("Bearer {}", token.trim()),
);
let client = reqwest::Client::builder() let (status, v) = crate::api_client::request_json("GET", "/appConfig", None, headers)
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| AppConfigError::Fetch(e.to_string()))?;
let res = client
.get(&url)
.header("Authorization", format!("Bearer {}", token.trim()))
.send()
.await .await
.map_err(|e| AppConfigError::Fetch(e.to_string()))?; .map_err(|e| AppConfigError::Fetch(e))?;
let status = res.status(); if status == 401 {
let body = res
.text()
.await
.map_err(|e| AppConfigError::Fetch(e.to_string()))?;
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(AppConfigError::Fetch("未认证或令牌已失效".into())); return Err(AppConfigError::Fetch("未认证或令牌已失效".into()));
} }
let v: Value =
serde_json::from_str(&body).map_err(|e| AppConfigError::Fetch(format!("响应非 JSON: {e}")))?;
let ok = v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false); let ok = v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false);
if !ok { if !ok {
let msg = v let msg = v

View File

@@ -0,0 +1,63 @@
//! 前端 API 代理:由 Rust 发起 HTTP并与 Python 端 AES 加解密对齐。
use std::collections::HashMap;
use serde_json::Value;
use crate::api_client;
fn default_method() -> String {
"GET".into()
}
/// 将 HTTP 结果整理为前端 `apiRequest` 约定结构。
fn normalize_response(status: u16, value: Value) -> Value {
if let Some(obj) = value.as_object() {
if obj.contains_key("ok") {
return value;
}
}
if status == 401 {
return serde_json::json!({ "ok": false, "message": "未登录或登录已过期" });
}
if status == 403 {
let msg = value
.get("detail")
.and_then(|d| d.as_str())
.unwrap_or("无权限执行此操作");
return serde_json::json!({ "ok": false, "message": msg });
}
if status == 422 {
let msg = value
.get("detail")
.and_then(|d| d.as_array())
.and_then(|a| a.first())
.and_then(|e| e.get("msg"))
.and_then(|m| m.as_str())
.unwrap_or("请求参数无效");
return serde_json::json!({ "ok": false, "message": msg });
}
let fallback = format!("请求失败({status}");
let message = value
.get("message")
.and_then(|m| m.as_str())
.unwrap_or(fallback.as_str());
serde_json::json!({ "ok": false, "message": message })
}
#[tauri::command]
pub async fn api_request(
path: String,
method: Option<String>,
body: Option<String>,
headers: Option<HashMap<String, String>>,
) -> Result<Value, String> {
let method = method.unwrap_or_else(|| default_method());
let headers = headers.unwrap_or_default();
let (status, value) =
api_client::request_json(&method, &path, body.as_deref(), headers).await?;
Ok(normalize_response(status, value))
}

View File

@@ -1,3 +1,4 @@
pub mod api;
pub mod app_config; pub mod app_config;
pub mod auth; pub mod auth;
pub mod nodejs; pub mod nodejs;

View File

@@ -7,6 +7,7 @@
pub mod native; pub mod native;
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use rquickjs::{async_with, promise::Promise, AsyncContext, AsyncRuntime, CatchResultExt}; use rquickjs::{async_with, promise::Promise, AsyncContext, AsyncRuntime, CatchResultExt};
@@ -47,27 +48,11 @@ fn api_base() -> String {
} }
async fn fetch_script_source(script_name: &str) -> Result<String, JsError> { async fn fetch_script_source(script_name: &str) -> Result<String, JsError> {
let base = api_base().trim_end_matches('/').to_string();
let q = urlencoding::encode(script_name); let q = urlencoding::encode(script_name);
let url = format!("{base}/api/v1/quickjs-scripts?name={q}"); let path = format!("/quickjs-scripts?name={q}");
let client = reqwest::Client::builder() let (_status, v) = crate::api_client::request_json("GET", &path, None, HashMap::new())
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| JsError::FetchScript(e.to_string()))?;
let res = client
.get(&url)
.send()
.await .await
.map_err(|e| JsError::FetchScript(e.to_string()))?; .map_err(|e| JsError::FetchScript(e))?;
let body = res
.text()
.await
.map_err(|e| JsError::FetchScript(e.to_string()))?;
let v: Value =
serde_json::from_str(&body).map_err(|e| JsError::FetchScript(format!("响应非 JSON: {e}")))?;
let ok = v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false); let ok = v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false);
if !ok { if !ok {
@@ -242,26 +227,10 @@ pub async fn run_script_source(
/// 当前服务端 `scripts/quickjs` 目录下的 `.js` 文件名列表。 /// 当前服务端 `scripts/quickjs` 目录下的 `.js` 文件名列表。
pub async fn list_scripts() -> Result<Vec<String>, JsError> { pub async fn list_scripts() -> Result<Vec<String>, JsError> {
let base = api_base().trim_end_matches('/').to_string(); let (_status, v) =
let url = format!("{base}/api/v1/quickjs-scripts/list"); crate::api_client::request_json("GET", "/quickjs-scripts/list", None, HashMap::new())
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| JsError::FetchScript(e.to_string()))?;
let res = client
.get(&url)
.send()
.await .await
.map_err(|e| JsError::FetchScript(e.to_string()))?; .map_err(|e| JsError::FetchScript(e))?;
let body = res
.text()
.await
.map_err(|e| JsError::FetchScript(e.to_string()))?;
let v: Value =
serde_json::from_str(&body).map_err(|e| JsError::FetchScript(format!("响应非 JSON: {e}")))?;
if !v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false) { if !v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false) {
let msg = v let msg = v

View File

@@ -1,3 +1,5 @@
pub mod api_client;
pub mod api_crypto;
pub mod app_config; pub mod app_config;
pub mod local_config; pub mod local_config;
pub mod asr; pub mod asr;
@@ -43,6 +45,7 @@ pub fn run() {
})) }))
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::api::api_request,
commands::auth::sync_auth_session, commands::auth::sync_auth_session,
commands::app_config::get_app_config, commands::app_config::get_app_config,
commands::app_config::get_app_config_last_message, commands::app_config::get_app_config_last_message,

View File

@@ -262,24 +262,11 @@ fn api_base() -> String {
} }
async fn fetch_node_script_source(script_name: &str) -> Result<String, NodeError> { async fn fetch_node_script_source(script_name: &str) -> Result<String, NodeError> {
let base = api_base().trim_end_matches('/').to_string();
let q = urlencoding::encode(script_name); let q = urlencoding::encode(script_name);
let url = format!("{base}/api/v1/nodejs-scripts?name={q}"); let path = format!("/nodejs-scripts?name={q}");
let client = reqwest::Client::builder() let (_status, v) = crate::api_client::request_json("GET", &path, None, HashMap::new())
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
let res = client
.get(&url)
.send()
.await .await
.map_err(|e| NodeError::FetchScript(e.to_string()))?; .map_err(|e| NodeError::FetchScript(e))?;
let body = res
.text()
.await
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
let v: Value = serde_json::from_str(&body)
.map_err(|e| NodeError::FetchScript(format!("响应非 JSON: {e}")))?;
if !v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false) { if !v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false) {
let msg = v let msg = v
.get("message") .get("message")
@@ -297,23 +284,10 @@ async fn fetch_node_script_source(script_name: &str) -> Result<String, NodeError
/// 后端 `scripts/nodejs` 目录下的 `.js` 文件名列表。 /// 后端 `scripts/nodejs` 目录下的 `.js` 文件名列表。
pub async fn list_scripts() -> Result<Vec<String>, NodeError> { pub async fn list_scripts() -> Result<Vec<String>, NodeError> {
let base = api_base().trim_end_matches('/').to_string(); let (_status, v) =
let url = format!("{base}/api/v1/nodejs-scripts/list"); crate::api_client::request_json("GET", "/nodejs-scripts/list", None, HashMap::new())
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
let res = client
.get(&url)
.send()
.await .await
.map_err(|e| NodeError::FetchScript(e.to_string()))?; .map_err(|e| NodeError::FetchScript(e))?;
let body = res
.text()
.await
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
let v: Value = serde_json::from_str(&body)
.map_err(|e| NodeError::FetchScript(format!("响应非 JSON: {e}")))?;
if !v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false) { if !v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false) {
let msg = v let msg = v
.get("message") .get("message")

View File

@@ -1,69 +1,59 @@
/** API 基址:开发走 Vite 代理 `/api`;生产可设 VITE_API_BASE_URL */ import { invoke } from "@tauri-apps/api/core";
/** API 路径前缀(实际 HTTP 由 Rust `api_request` 发出) */
const API_BASE = import.meta.env.VITE_API_BASE_URL || "/api/v1"; const API_BASE = import.meta.env.VITE_API_BASE_URL || "/api/v1";
function formatValidationDetail(detail) { function isTauri() {
if (!Array.isArray(detail)) { return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
return "请求参数无效";
}
const first = detail[0];
if (first?.msg) {
return String(first.msg);
}
return "请求参数无效";
} }
/** /**
* @param {string} path - 相对路径,如 `/auth/register` * @param {string} path - 相对 `/api/v1` 的路径,如 `/auth/login`
* @param {RequestInit} options * @param {RequestInit} options
* @returns {Promise<{ ok: boolean, message: string, data?: unknown }>} * @returns {Promise<{ ok: boolean, message: string, data?: unknown }>}
*/ */
export async function apiRequest(path, options = {}) { export async function apiRequest(path, options = {}) {
const url = `${API_BASE}${path}`; if (!isTauri()) {
const headers = { return {
"Content-Type": "application/json", ok: false,
...options.headers, message: "API 请求需在 Tauri 桌面端运行(由 Rust 代理并加解密)",
}; };
}
const method = (options.method || "GET").toUpperCase();
let body;
if (options.body != null && options.body !== "") {
body = typeof options.body === "string" ? options.body : JSON.stringify(options.body);
}
const headers = {};
if (options.headers) {
const h = options.headers;
if (h instanceof Headers) {
h.forEach((value, key) => {
headers[key] = value;
});
} else {
Object.assign(headers, h);
}
}
const relPath = path.startsWith("/") ? path : `/${path}`;
let res;
try { try {
res = await fetch(url, { ...options, headers }); const result = await invoke("api_request", {
} catch { path: relPath,
return { ok: false, message: "无法连接服务器,请确认后端已启动" }; method,
body: body ?? null,
headers: Object.keys(headers).length ? headers : null,
});
if (result && typeof result === "object" && "ok" in result) {
return result;
} }
return { ok: false, message: "服务器返回格式异常" };
let body = null; } catch (err) {
try { return { ok: false, message: String(err) };
body = await res.json();
} catch {
body = null;
} }
if (res.status === 422) {
return {
ok: false,
message: formatValidationDetail(body?.detail),
};
}
if (body && typeof body.ok === "boolean") {
return body;
}
if (res.status === 401) {
return { ok: false, message: "未登录或登录已过期" };
}
if (res.status === 403) {
return {
ok: false,
message: body?.detail || "无权限执行此操作",
};
}
return {
ok: false,
message: body?.message || `请求失败(${res.status}`,
};
} }
export { API_BASE }; export { API_BASE };

View File

@@ -10,15 +10,12 @@ export async function ensureAppConfigReady() {
try { try {
const map = await invoke("get_app_config"); const map = await invoke("get_app_config");
if (map && typeof map === "object" && Object.keys(map).length > 0) { if (map && typeof map === "object" && Object.keys(map).length > 0) {
const apiKey = const apiKey =map.BAILIAN_API_KEY ;
map.LLM_API_KEY ||
map.llm_api_key ||
map["llm.api_key"];
if (!apiKey) { if (!apiKey) {
return { return {
ok: false, ok: false,
message: message:
"缺少 LLM_API_KEY,请在管理后台 desktop_configs 或侧栏「本地配置」中设置", "缺少 BAILIAN_API_KEY 配置管理 或侧栏「本地配置」中设置",
}; };
} }
return { ok: true }; return { ok: true };

View File

@@ -119,10 +119,10 @@ onMounted(() => {
<div class="local-config-page custom-scrollbar p-6"> <div class="local-config-page custom-scrollbar p-6">
<div class="mb-4"> <div class="mb-4">
<h1 class="gradient-text text-xl font-semibold">本地配置</h1> <h1 class="gradient-text text-xl font-semibold">本地配置</h1>
<p class="mt-1 text-sm text-text-muted"> <!-- <p class="mt-1 text-sm text-text-muted">
保存在本机 SQLite与登录后服务端下发的配置合并<strong class="text-text-secondary">同名键本地优先</strong>并注入 Node 环境变量 保存在本机 SQLite与登录后服务端下发的配置合并<strong class="text-text-secondary">同名键本地优先</strong>并注入 Node 环境变量
<code class="rounded bg-white/10 px-1 font-mono text-xs">AICLIENT_CFG_*</code> <code class="rounded bg-white/10 px-1 font-mono text-xs">AICLIENT_CFG_*</code>
</p> </p> -->
</div> </div>
<Message <Message