This commit is contained in:
fengchuanhn@gmail.com
2026-05-17 21:13:45 +08:00
parent c18176ba60
commit 9dfeaa9e18
10 changed files with 695 additions and 17 deletions

74
src-tauri/Cargo.lock generated
View File

@@ -8,6 +8,18 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -1078,6 +1090,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -1560,6 +1584,15 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1575,6 +1608,15 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -2134,6 +2176,17 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -3238,6 +3291,20 @@ dependencies = [
"cc",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags 2.11.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -3931,6 +3998,7 @@ dependencies = [
"regex",
"reqwest 0.12.28",
"rquickjs",
"rusqlite",
"serde",
"serde_json",
"sha1",
@@ -4714,6 +4782,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"

View File

@@ -63,6 +63,9 @@ log = "0.4"
env_logger = "0.11"
thiserror = "2"
# Local SQLite config
rusqlite = { version = "0.32", features = ["bundled"] }
# Misc
mime_guess = "2"
dirs = "5"

View File

@@ -1,13 +1,16 @@
//! 全局应用配置:登录后从 `GET /api/v1/appConfig` 拉取 `desktop_configs` 键值表,
//! 原样缓存;启动 Node 时写入环境变量 `AICLIENT_CFG_<规范化键名>`。
//! 应用配置:服务端 `desktop_configs` + SQLite 本地配置,同名键**本地优先**。
//! 合并结果供 `get_app_config` 与 Node 环境变量 `AICLIENT_CFG_*`。
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use serde_json::Value;
use thiserror::Error;
use tokio::sync::RwLock;
use crate::local_config::LocalConfigDb;
/// Node 子进程环境变量前缀,与 `scripts/nodejs/desktop_config.js` 一致。
pub const NODE_ENV_PREFIX: &str = "AICLIENT_CFG_";
@@ -19,8 +22,10 @@ pub enum AppConfigError {
#[derive(Debug, Default)]
struct Inner {
entries: HashMap<String, String>,
server_entries: HashMap<String, String>,
local_entries: HashMap<String, String>,
last_message: String,
local_db: Option<Arc<LocalConfigDb>>,
}
#[derive(Debug, Default)]
@@ -59,6 +64,14 @@ pub fn node_env_var_name(db_name: &str) -> String {
format!("{}{}", NODE_ENV_PREFIX, normalize_config_key(db_name))
}
fn merged_entries(server: &HashMap<String, String>, local: &HashMap<String, String>) -> HashMap<String, String> {
let mut map = server.clone();
for (k, v) in local {
map.insert(k.clone(), v.clone());
}
map
}
impl AppConfig {
pub fn new() -> Self {
Self {
@@ -66,28 +79,128 @@ impl AppConfig {
}
}
/// 应用启动时初始化 SQLite 并加载本地配置。
pub async fn init_local_store(&self, db_path: PathBuf) -> Result<(), String> {
let entries = tokio::task::spawn_blocking(move || -> Result<(Arc<LocalConfigDb>, HashMap<String, String>), String> {
let db = LocalConfigDb::open(db_path).map_err(|e| e.to_string())?;
let map = db.list_all_map().map_err(|e| e.to_string())?;
Ok((Arc::new(db), map))
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e)?;
let (db, map) = entries;
let mut g = self.inner.write().await;
g.local_db = Some(db);
g.local_entries = map;
log::info!(
target: "app_config",
"本地配置已加载,共 {} 项",
g.local_entries.len()
);
Ok(())
}
async fn with_db<F, T>(&self, f: F) -> Result<T, String>
where
F: FnOnce(Arc<LocalConfigDb>) -> Result<T, String> + Send + 'static,
T: Send + 'static,
{
let db = self
.inner
.read()
.await
.local_db
.clone()
.ok_or_else(|| "本地配置数据库未初始化".to_string())?;
tokio::task::spawn_blocking(move || f(db))
.await
.map_err(|e| e.to_string())?
}
/// 合并后的有效配置(本地覆盖服务端同名键)。
pub async fn entries(&self) -> HashMap<String, String> {
self.inner.read().await.entries.clone()
let g = self.inner.read().await;
merged_entries(&g.server_entries, &g.local_entries)
}
pub async fn server_entries(&self) -> HashMap<String, String> {
self.inner.read().await.server_entries.clone()
}
pub async fn local_entries(&self) -> HashMap<String, String> {
self.inner.read().await.local_entries.clone()
}
pub async fn get(&self, name: &str) -> Option<String> {
self.inner.read().await.entries.get(name).cloned()
let g = self.inner.read().await;
if let Some(v) = g.local_entries.get(name) {
return Some(v.clone());
}
g.server_entries.get(name).cloned()
}
pub async fn last_message(&self) -> String {
self.inner.read().await.last_message.clone()
}
pub async fn clear(&self) {
pub async fn clear_server(&self) {
let mut g = self.inner.write().await;
g.entries.clear();
g.server_entries.clear();
g.last_message.clear();
}
/// 供 Node 子进程 `Command::env` 使用。
pub async fn reload_local_from_db(&self) -> Result<(), String> {
let map = self
.with_db(|db| db.list_all_map().map_err(|e| e.to_string()))
.await?;
self.inner.write().await.local_entries = map;
Ok(())
}
pub async fn set_local(&self, name: String, value: String, mark: Option<String>) -> Result<(), String> {
let name_trim = name.trim().to_string();
if name_trim.is_empty() {
return Err("配置键名不能为空".into());
}
let value_owned = value;
let mark_owned = mark.clone();
self.with_db({
let name = name_trim.clone();
let value = value_owned.clone();
move |db| db.upsert(&name, &value, mark_owned.as_deref()).map_err(|e| e.to_string())
})
.await?;
let mut g = self.inner.write().await;
g.local_entries.insert(name_trim, value_owned);
Ok(())
}
pub async fn delete_local(&self, name: &str) -> Result<(), String> {
let key = name.trim().to_string();
if key.is_empty() {
return Err("配置键名不能为空".into());
}
self.with_db({
let key = key.clone();
move |db| db.delete(&key).map_err(|e| e.to_string())
})
.await?;
self.inner.write().await.local_entries.remove(name.trim());
Ok(())
}
pub async fn list_local_items(&self) -> Result<Vec<crate::local_config::LocalConfigItem>, String> {
self.with_db(|db| db.list_items().map_err(|e| e.to_string()))
.await
}
/// 供 Node 子进程 `Command::env` 使用(已合并,本地优先)。
pub async fn env_for_node(&self) -> HashMap<String, String> {
let g = self.inner.read().await;
g.entries
let merged = merged_entries(&g.server_entries, &g.local_entries);
merged
.iter()
.map(|(name, value)| (node_env_var_name(name), value.clone()))
.collect()
@@ -95,18 +208,21 @@ impl AppConfig {
pub async fn refresh_after_auth(&self, token: &str) -> Result<(), AppConfigError> {
if token.trim().is_empty() {
self.clear().await;
self.clear_server().await;
return Ok(());
}
match fetch_from_server(token).await {
Ok(map) => {
let mut g = self.inner.write().await;
g.entries = map;
g.server_entries = map;
g.last_message.clear();
let merged_len =
merged_entries(&g.server_entries, &g.local_entries).len();
log::info!(
target: "app_config",
"应用配置已更新共 {} 项",
g.entries.len()
"服务端配置已更新;合并后共 {} 项(本地 {} 项可覆盖同名键)",
merged_len,
g.local_entries.len()
);
Ok(())
}

View File

@@ -1,9 +1,18 @@
use std::collections::HashMap;
use serde::Serialize;
use tauri::State;
use crate::app_config::AppConfig;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigEntryDetail {
pub name: String,
pub value: String,
pub source: String,
}
#[tauri::command]
pub async fn get_app_config(
app_config: State<'_, AppConfig>,
@@ -17,3 +26,69 @@ pub async fn get_app_config_last_message(
) -> Result<String, String> {
Ok(app_config.last_message().await)
}
/// 仅服务端下发的配置(不含本地覆盖)。
#[tauri::command]
pub async fn get_server_app_config(
app_config: State<'_, AppConfig>,
) -> Result<HashMap<String, String>, String> {
Ok(app_config.server_entries().await)
}
/// 合并配置明细:标明每项来自 local 或 server本地同名已覆盖的不重复列出 server 项)。
#[tauri::command]
pub async fn get_app_config_detail(
app_config: State<'_, AppConfig>,
) -> Result<Vec<ConfigEntryDetail>, String> {
let server = app_config.server_entries().await;
let local = app_config.local_entries().await;
let mut items = Vec::new();
let mut names: Vec<String> = server
.keys()
.chain(local.keys())
.cloned()
.collect();
names.sort();
names.dedup();
for name in names {
if local.contains_key(&name) {
items.push(ConfigEntryDetail {
name: name.clone(),
value: local.get(&name).cloned().unwrap_or_default(),
source: "local".into(),
});
} else if let Some(v) = server.get(&name) {
items.push(ConfigEntryDetail {
name,
value: v.clone(),
source: "server".into(),
});
}
}
Ok(items)
}
#[tauri::command]
pub async fn list_local_app_config(
app_config: State<'_, AppConfig>,
) -> Result<Vec<crate::local_config::LocalConfigItem>, String> {
app_config.list_local_items().await
}
#[tauri::command]
pub async fn set_local_app_config(
app_config: State<'_, AppConfig>,
name: String,
value: String,
mark: Option<String>,
) -> Result<(), String> {
app_config.set_local(name, value, mark).await
}
#[tauri::command]
pub async fn delete_local_app_config(
app_config: State<'_, AppConfig>,
name: String,
) -> Result<(), String> {
app_config.delete_local(&name).await
}

View File

@@ -1,4 +1,5 @@
pub mod app_config;
pub mod local_config;
pub mod asr;
pub mod auth_session;
pub mod chat;
@@ -18,6 +19,21 @@ pub fn run() {
tauri::Builder::default()
.manage(auth_session::AuthSession::default())
.manage(app_config::AppConfig::new())
.setup(|app| {
let handle = app.handle().clone();
tauri::async_runtime::block_on(async move {
let app_config = handle.state::<app_config::AppConfig>();
let dir = handle
.path()
.app_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let db_path = dir.join("local_config.db");
app_config.init_local_store(db_path).await?;
Ok::<(), String>(())
})
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
Ok(())
})
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
@@ -30,6 +46,11 @@ pub fn run() {
commands::auth::sync_auth_session,
commands::app_config::get_app_config,
commands::app_config::get_app_config_last_message,
commands::app_config::get_server_app_config,
commands::app_config::get_app_config_detail,
commands::app_config::list_local_app_config,
commands::app_config::set_local_app_config,
commands::app_config::delete_local_app_config,
commands::quickjs::run_quickjs_script,
commands::quickjs::run_quickjs_script_source,
commands::quickjs::list_quickjs_scripts,

View File

@@ -0,0 +1,130 @@
//! SQLite 本地键值配置(`app_data_dir/local_config.db`)。
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use rusqlite::{params, Connection};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum LocalConfigError {
#[error("{0}")]
Db(#[from] rusqlite::Error),
#[error("{0}")]
Message(String),
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalConfigItem {
pub name: String,
pub value: String,
pub mark: Option<String>,
pub updated_at: i64,
}
#[derive(Debug)]
pub struct LocalConfigDb {
conn: Mutex<Connection>,
}
impl LocalConfigDb {
pub fn open(path: PathBuf) -> Result<Self, LocalConfigError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| LocalConfigError::Message(format!("创建配置目录失败: {e}")))?;
}
let conn = Connection::open(path)?;
conn.execute_batch(
r#"
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS local_configs (
name TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL,
mark TEXT,
updated_at INTEGER NOT NULL
);
"#,
)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
pub fn list_all_map(&self) -> Result<HashMap<String, String>, LocalConfigError> {
let conn = self.conn.lock().map_err(|_| {
LocalConfigError::Message("本地数据库锁异常".into())
})?;
let mut stmt = conn.prepare(
"SELECT name, value FROM local_configs ORDER BY name ASC",
)?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut map = HashMap::new();
for row in rows {
let (k, v) = row?;
map.insert(k, v);
}
Ok(map)
}
pub fn list_items(&self) -> Result<Vec<LocalConfigItem>, LocalConfigError> {
let conn = self.conn.lock().map_err(|_| {
LocalConfigError::Message("本地数据库锁异常".into())
})?;
let mut stmt = conn.prepare(
"SELECT name, value, mark, updated_at FROM local_configs ORDER BY name ASC",
)?;
let rows = stmt.query_map([], |row| {
Ok(LocalConfigItem {
name: row.get(0)?,
value: row.get(1)?,
mark: row.get(2)?,
updated_at: row.get(3)?,
})
})?;
let mut items = Vec::new();
for row in rows {
items.push(row?);
}
Ok(items)
}
pub fn upsert(
&self,
name: &str,
value: &str,
mark: Option<&str>,
) -> Result<(), LocalConfigError> {
let name = name.trim();
if name.is_empty() {
return Err(LocalConfigError::Message("配置键名不能为空".into()));
}
let now = chrono::Utc::now().timestamp();
let conn = self.conn.lock().map_err(|_| {
LocalConfigError::Message("本地数据库锁异常".into())
})?;
conn.execute(
r#"
INSERT INTO local_configs (name, value, mark, updated_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(name) DO UPDATE SET
value = excluded.value,
mark = excluded.mark,
updated_at = excluded.updated_at
"#,
params![name, value, mark, now],
)?;
Ok(())
}
pub fn delete(&self, name: &str) -> Result<(), LocalConfigError> {
let conn = self.conn.lock().map_err(|_| {
LocalConfigError::Message("本地数据库锁异常".into())
})?;
conn.execute("DELETE FROM local_configs WHERE name = ?1", params![name])?;
Ok(())
}
}