From 9dfeaa9e1842c2ebaad91b5971d11a50ca946d83 Mon Sep 17 00:00:00 2001 From: "fengchuanhn@gmail.com" Date: Sun, 17 May 2026 21:13:45 +0800 Subject: [PATCH] 11 --- src-tauri/Cargo.lock | 74 +++++++++ src-tauri/Cargo.toml | 3 + src-tauri/src/app_config.rs | 142 ++++++++++++++-- src-tauri/src/commands/app_config.rs | 75 +++++++++ src-tauri/src/lib.rs | 21 +++ src-tauri/src/local_config.rs | 130 +++++++++++++++ src/components/AppSidebar.vue | 8 + src/config/videoPipeline.js | 9 +- src/router/index.js | 12 ++ src/views/LocalConfigView.vue | 238 +++++++++++++++++++++++++++ 10 files changed, 695 insertions(+), 17 deletions(-) create mode 100644 src-tauri/src/local_config.rs create mode 100644 src/views/LocalConfigView.vue diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b62bbf3..6b5d431 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bb6d198..8d833c0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 206cbd2..ff21ce1 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -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, + server_entries: HashMap, + local_entries: HashMap, last_message: String, + local_db: Option>, } #[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, local: &HashMap) -> HashMap { + 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, HashMap), 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(&self, f: F) -> Result + where + F: FnOnce(Arc) -> Result + 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 { - 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 { + self.inner.read().await.server_entries.clone() + } + + pub async fn local_entries(&self) -> HashMap { + self.inner.read().await.local_entries.clone() } pub async fn get(&self, name: &str) -> Option { - 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) -> 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, 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 { 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(()) } diff --git a/src-tauri/src/commands/app_config.rs b/src-tauri/src/commands/app_config.rs index 8e27fa6..ca31800 100644 --- a/src-tauri/src/commands/app_config.rs +++ b/src-tauri/src/commands/app_config.rs @@ -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 { Ok(app_config.last_message().await) } + +/// 仅服务端下发的配置(不含本地覆盖)。 +#[tauri::command] +pub async fn get_server_app_config( + app_config: State<'_, AppConfig>, +) -> Result, String> { + Ok(app_config.server_entries().await) +} + +/// 合并配置明细:标明每项来自 local 或 server(本地同名已覆盖的不重复列出 server 项)。 +#[tauri::command] +pub async fn get_app_config_detail( + app_config: State<'_, AppConfig>, +) -> Result, String> { + let server = app_config.server_entries().await; + let local = app_config.local_entries().await; + let mut items = Vec::new(); + let mut names: Vec = 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, 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, +) -> 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 +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8328ca3..7201912 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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::(); + 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 { 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, diff --git a/src-tauri/src/local_config.rs b/src-tauri/src/local_config.rs new file mode 100644 index 0000000..fbfd9e9 --- /dev/null +++ b/src-tauri/src/local_config.rs @@ -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, + pub updated_at: i64, +} + +#[derive(Debug)] +pub struct LocalConfigDb { + conn: Mutex, +} + +impl LocalConfigDb { + pub fn open(path: PathBuf) -> Result { + 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, 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, 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(()) + } +} diff --git a/src/components/AppSidebar.vue b/src/components/AppSidebar.vue index 8864dee..47136ee 100644 --- a/src/components/AppSidebar.vue +++ b/src/components/AppSidebar.vue @@ -15,6 +15,7 @@ const baseMenuItems = [ { name: "extract", label: "提取", to: "/extract", icon: "extract" }, { name: "design", label: "设计", to: "/design", icon: "design" }, { name: "model", label: "模型", to: "/model", icon: "model" }, + { name: "local-config", label: "本地配置", to: "/local-config", icon: "settings" }, { name: "help", label: "帮助", to: "/help", icon: "help" }, ]; @@ -81,6 +82,13 @@ const activeName = computed(() => { + + + + diff --git a/src/config/videoPipeline.js b/src/config/videoPipeline.js index 715b787..65ef4fe 100644 --- a/src/config/videoPipeline.js +++ b/src/config/videoPipeline.js @@ -1,5 +1,5 @@ /** - * 流水线配置校验:优先使用 Rust 缓存的 desktop_configs(get_app_config)。 + * 流水线配置校验:Rust 合并服务端 desktop_configs + 本地 SQLite(同名本地优先)。 * 实际密钥由 Node 子进程通过环境变量 AICLIENT_CFG_* 读取,无需在前端 params 传递。 */ @@ -17,7 +17,8 @@ export async function ensureAppConfigReady() { if (!apiKey) { return { ok: false, - message: "服务端配置缺少 LLM_API_KEY,请在 desktop_configs 表中维护", + message: + "缺少 LLM_API_KEY,请在管理后台 desktop_configs 或侧栏「本地配置」中设置", }; } return { ok: true }; @@ -28,12 +29,12 @@ export async function ensureAppConfigReady() { } return { ok: false, - message: "未获取到应用配置,请先登录并确保 desktop_configs 表有数据", + message: "未获取到应用配置,请登录服务端或在「本地配置」中添加 LLM_API_KEY", }; } catch { return { ok: false, - message: "请在 Tauri 桌面端登录后使用(配置由服务端下发)", + message: "请在 Tauri 桌面端使用,并登录或在「本地配置」中设置密钥", }; } } diff --git a/src/router/index.js b/src/router/index.js index b230f11..ca9bbb7 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -116,6 +116,18 @@ const mainChildren = [ }, + { + + path: "local-config", + + name: "local-config", + + component: () => import("../views/LocalConfigView.vue"), + + meta: { title: "本地配置" }, + + }, + { path: "admin", diff --git a/src/views/LocalConfigView.vue b/src/views/LocalConfigView.vue new file mode 100644 index 0000000..513e812 --- /dev/null +++ b/src/views/LocalConfigView.vue @@ -0,0 +1,238 @@ + + +
+
+

本地配置

+

+ 保存在本机 SQLite,与登录后服务端下发的配置合并;同名键本地优先,并注入 Node 环境变量 + AICLIENT_CFG_*。 +

+
+ + + {{ feedback.message }} + + +
+
+ +

本地配置项

+ + + + + + + + + + + + + + + + +

当前生效(合并后)

+ + + + + + + + + + + + + + +
+
+ + +
+
+ +