From a18384bcefcfc11c928b01f485dd766012f7f796 Mon Sep 17 00:00:00 2001 From: coso Date: Tue, 16 Dec 2025 18:48:36 +0800 Subject: [PATCH] chore: bump version to 0.9.0 - Add plugin system (Phase 6) - Add plugin manager with hook system - Add plugin loader and lifecycle management - Add plugin configuration UI - All Phase 6 tests passing (292 tests) --- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 207 ++++------- src-tauri/Cargo.toml | 4 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/plugin_cmd.rs | 135 +++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/plugin/loader.rs | 238 ++++++++++++ src-tauri/src/plugin/manager.rs | 445 +++++++++++++++++++++++ src-tauri/src/plugin/mod.rs | 21 ++ src-tauri/src/plugin/tests.rs | 278 ++++++++++++++ src-tauri/src/plugin/types.rs | 428 ++++++++++++++++++++++ src-tauri/src/server.rs | 2 +- src-tauri/tauri.conf.json | 2 +- src/components/plugins/PluginManager.tsx | 373 +++++++++++++++++++ src/components/plugins/index.ts | 2 + 16 files changed, 2009 insertions(+), 134 deletions(-) create mode 100644 src-tauri/src/commands/plugin_cmd.rs create mode 100644 src-tauri/src/plugin/loader.rs create mode 100644 src-tauri/src/plugin/manager.rs create mode 100644 src-tauri/src/plugin/mod.rs create mode 100644 src-tauri/src/plugin/tests.rs create mode 100644 src-tauri/src/plugin/types.rs create mode 100644 src/components/plugins/PluginManager.tsx create mode 100644 src/components/plugins/index.ts diff --git a/package-lock.json b/package-lock.json index 27f852c39..9f64dfa20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxycast", - "version": "0.6.1", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxycast", - "version": "0.6.1", + "version": "0.9.0", "dependencies": { "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", diff --git a/package.json b/package.json index f4228f743..e70cbe362 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.8.0", + "version": "0.9.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4ade15e1b..26d0536f3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -323,22 +323,13 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block2" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" -dependencies = [ - "objc2 0.5.2", -] - [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.3", + "objc2", ] [[package]] @@ -447,9 +438,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ "serde_core", ] @@ -896,9 +887,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.10.0", - "block2 0.6.2", + "block2", "libc", - "objc2 0.6.3", + "objc2", ] [[package]] @@ -918,7 +909,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" dependencies = [ - "libloading", + "libloading 0.8.9", ] [[package]] @@ -2258,7 +2249,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading", + "libloading 0.7.4", "once_cell", ] @@ -2278,6 +2269,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libredox" version = "0.1.10" @@ -2440,10 +2441,10 @@ dependencies = [ "dpi", "gtk", "keyboard-types", - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "once_cell", "png", "serde", @@ -2587,22 +2588,6 @@ dependencies = [ "syn 2.0.111", ] -[[package]] -name = "objc-sys" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" - -[[package]] -name = "objc2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" -dependencies = [ - "objc-sys", - "objc2-encode", -] - [[package]] name = "objc2" version = "0.6.3" @@ -2620,9 +2605,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.10.0", - "block2 0.6.2", + "block2", "libc", - "objc2 0.6.3", + "objc2", "objc2-cloud-kit", "objc2-core-data", "objc2-core-foundation", @@ -2630,8 +2615,8 @@ dependencies = [ "objc2-core-image", "objc2-core-text", "objc2-core-video", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", + "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -2641,8 +2626,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-foundation 0.3.2", + "objc2", + "objc2-foundation", ] [[package]] @@ -2652,8 +2637,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-foundation 0.3.2", + "objc2", + "objc2-foundation", ] [[package]] @@ -2664,7 +2649,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.10.0", "dispatch2", - "objc2 0.6.3", + "objc2", ] [[package]] @@ -2675,7 +2660,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.10.0", "dispatch2", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", "objc2-io-surface", ] @@ -2686,8 +2671,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2 0.6.3", - "objc2-foundation 0.3.2", + "objc2", + "objc2-foundation", ] [[package]] @@ -2697,7 +2682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.10.0", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", "objc2-core-graphics", ] @@ -2709,7 +2694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ "bitflags 2.10.0", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", @@ -2730,18 +2715,6 @@ dependencies = [ "cc", ] -[[package]] -name = "objc2-foundation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" -dependencies = [ - "bitflags 2.10.0", - "block2 0.5.1", - "libc", - "objc2 0.5.2", -] - [[package]] name = "objc2-foundation" version = "0.3.2" @@ -2749,9 +2722,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.10.0", - "block2 0.6.2", + "block2", "libc", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", ] @@ -2762,7 +2735,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.10.0", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", ] @@ -2772,35 +2745,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" dependencies = [ - "objc2 0.6.3", + "objc2", "objc2-core-foundation", ] -[[package]] -name = "objc2-metal" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" -dependencies = [ - "bitflags 2.10.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" -dependencies = [ - "bitflags 2.10.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal", -] - [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2808,8 +2756,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-foundation 0.3.2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", ] [[package]] @@ -2819,7 +2768,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ "bitflags 2.10.0", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", ] @@ -2830,9 +2779,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.10.0", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", ] [[package]] @@ -2842,11 +2791,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ "bitflags 2.10.0", - "block2 0.6.2", - "objc2 0.6.3", + "block2", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "objc2-javascript-core", "objc2-security", ] @@ -3325,10 +3274,11 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anyhow", "async-stream", + "async-trait", "axum", "chrono", "dashmap", @@ -3351,6 +3301,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-shell", "tempfile", + "thiserror 1.0.69", "tiktoken-rs", "tokio", "tracing", @@ -3606,9 +3557,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.25" +version = "0.12.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a" +checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" dependencies = [ "base64 0.22.1", "bytes", @@ -3654,17 +3605,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" dependencies = [ "ashpd", - "block2 0.6.2", + "block2", "dispatch2", "glib-sys", "gobject-sys", "gtk-sys", "js-sys", "log", - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "raw-window-handle", "wasm-bindgen", "wasm-bindgen-futures", @@ -4231,24 +4182,24 @@ dependencies = [ [[package]] name = "softbuffer" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "bytemuck", - "cfg_aliases", - "core-graphics", - "foreign-types 0.5.0", "js-sys", - "log", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-quartz-core 0.2.2", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", "raw-window-handle", "redox_syscall", + "tracing", "wasm-bindgen", "web-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4420,7 +4371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.10.0", - "block2 0.6.2", + "block2", "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", @@ -4437,9 +4388,9 @@ dependencies = [ "ndk", "ndk-context", "ndk-sys", - "objc2 0.6.3", + "objc2", "objc2-app-kit", - "objc2-foundation 0.3.2", + "objc2-foundation", "once_cell", "parking_lot", "raw-window-handle", @@ -4492,9 +4443,9 @@ dependencies = [ "log", "mime", "muda", - "objc2 0.6.3", + "objc2", "objc2-app-kit", - "objc2-foundation 0.3.2", + "objc2-foundation", "objc2-ui-kit", "objc2-web-kit", "percent-encoding", @@ -4687,7 +4638,7 @@ dependencies = [ "gtk", "http", "jni", - "objc2 0.6.3", + "objc2", "objc2-ui-kit", "objc2-web-kit", "raw-window-handle", @@ -4711,9 +4662,9 @@ dependencies = [ "http", "jni", "log", - "objc2 0.6.3", + "objc2", "objc2-app-kit", - "objc2-foundation 0.3.2", + "objc2-foundation", "once_cell", "percent-encoding", "raw-window-handle", @@ -5191,11 +5142,11 @@ dependencies = [ "dirs 6.0.0", "libappindicator", "muda", - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation 0.3.2", + "objc2-foundation", "once_cell", "png", "serde", @@ -5726,10 +5677,10 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "raw-window-handle", "windows-sys 0.59.0", "windows-version", @@ -6256,7 +6207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" dependencies = [ "base64 0.22.1", - "block2 0.6.2", + "block2", "cookie", "crossbeam-channel", "dirs 6.0.0", @@ -6271,10 +6222,10 @@ dependencies = [ "kuchikiki", "libc", "ndk", - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "objc2-ui-kit", "objc2-web-kit", "once_cell", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0120d39cb..353fbe5b5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxycast" -version = "0.8.0" +version = "0.9.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" @@ -41,6 +41,8 @@ dashmap = "5" notify = { version = "6", default-features = false, features = ["macos_fsevent"] } parking_lot = "0.12" tiktoken-rs = "0.6" +async-trait = "0.1" +thiserror = "1" [dev-dependencies] proptest = "1" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8d3fcfd8f..58e8d2b1e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod config_cmd; pub mod injection_cmd; pub mod mcp_cmd; pub mod oauth_cmd; +pub mod plugin_cmd; pub mod prompt_cmd; pub mod provider_pool_cmd; pub mod resilience_cmd; diff --git a/src-tauri/src/commands/plugin_cmd.rs b/src-tauri/src/commands/plugin_cmd.rs new file mode 100644 index 000000000..7ba09074f --- /dev/null +++ b/src-tauri/src/commands/plugin_cmd.rs @@ -0,0 +1,135 @@ +//! 插件系统相关命令 + +use crate::plugin::{PluginConfig, PluginInfo, PluginManager, PluginStatus}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// 插件管理器状态 +pub struct PluginManagerState(pub Arc>); + +/// 插件状态响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginServiceStatus { + pub enabled: bool, + pub plugin_count: usize, + pub plugins_dir: String, +} + +/// 插件配置请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginConfigRequest { + pub enabled: bool, + pub timeout_ms: u64, + pub settings: serde_json::Value, +} + +/// 获取插件服务状态 +#[tauri::command] +pub async fn get_plugin_status( + state: tauri::State<'_, PluginManagerState>, +) -> Result { + let manager = state.0.read().await; + Ok(PluginServiceStatus { + enabled: true, + plugin_count: manager.count(), + plugins_dir: manager.plugins_dir().to_string_lossy().to_string(), + }) +} + +/// 获取所有插件列表 +#[tauri::command] +pub async fn get_plugins( + state: tauri::State<'_, PluginManagerState>, +) -> Result, String> { + let manager = state.0.read().await; + Ok(manager.list().await) +} + +/// 获取单个插件信息 +#[tauri::command] +pub async fn get_plugin_info( + state: tauri::State<'_, PluginManagerState>, + name: String, +) -> Result, String> { + let manager = state.0.read().await; + Ok(manager.get_info(&name).await) +} + +/// 启用插件 +#[tauri::command] +pub async fn enable_plugin( + state: tauri::State<'_, PluginManagerState>, + name: String, +) -> Result<(), String> { + let manager = state.0.read().await; + manager.enable(&name).await.map_err(|e| e.to_string()) +} + +/// 禁用插件 +#[tauri::command] +pub async fn disable_plugin( + state: tauri::State<'_, PluginManagerState>, + name: String, +) -> Result<(), String> { + let manager = state.0.read().await; + manager.disable(&name).await.map_err(|e| e.to_string()) +} + +/// 更新插件配置 +#[tauri::command] +pub async fn update_plugin_config( + state: tauri::State<'_, PluginManagerState>, + name: String, + config: PluginConfigRequest, +) -> Result<(), String> { + let manager = state.0.read().await; + let plugin_config = PluginConfig { + enabled: config.enabled, + timeout_ms: config.timeout_ms, + settings: config.settings, + }; + manager + .update_config(&name, plugin_config) + .await + .map_err(|e| e.to_string()) +} + +/// 获取插件配置 +#[tauri::command] +pub async fn get_plugin_config( + state: tauri::State<'_, PluginManagerState>, + name: String, +) -> Result, String> { + let manager = state.0.read().await; + Ok(manager.get_config(&name)) +} + +/// 重新加载所有插件 +#[tauri::command] +pub async fn reload_plugins( + state: tauri::State<'_, PluginManagerState>, +) -> Result, String> { + let manager = state.0.read().await; + manager.load_all().await.map_err(|e| e.to_string()) +} + +/// 卸载插件 +#[tauri::command] +pub async fn unload_plugin( + state: tauri::State<'_, PluginManagerState>, + name: String, +) -> Result<(), String> { + let manager = state.0.read().await; + manager.unload(&name).await.map_err(|e| e.to_string()) +} + +/// 获取插件目录路径 +#[tauri::command] +pub async fn get_plugins_dir( + state: tauri::State<'_, PluginManagerState>, +) -> Result { + let manager = state.0.read().await; + Ok(manager.plugins_dir().to_string_lossy().to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 011838155..8653243d8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod database; pub mod injection; mod logger; mod models; +pub mod plugin; mod providers; pub mod resilience; mod router; diff --git a/src-tauri/src/plugin/loader.rs b/src-tauri/src/plugin/loader.rs new file mode 100644 index 000000000..d6ab500a3 --- /dev/null +++ b/src-tauri/src/plugin/loader.rs @@ -0,0 +1,238 @@ +//! 插件加载器 + +use super::types::{ + HookResult, Plugin, PluginConfig, PluginContext, PluginError, PluginManifest, PluginType, +}; +use async_trait::async_trait; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::fs; + +pub struct PluginLoader { + plugins_dir: PathBuf, +} + +impl PluginLoader { + pub fn new(plugins_dir: PathBuf) -> Self { + Self { plugins_dir } + } + + pub fn default_plugins_dir() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("proxycast") + .join("plugins") + } + + pub fn with_defaults() -> Self { + Self::new(Self::default_plugins_dir()) + } + + pub async fn ensure_plugins_dir(&self) -> Result<(), PluginError> { + if !self.plugins_dir.exists() { + fs::create_dir_all(&self.plugins_dir).await?; + } + Ok(()) + } + + pub async fn scan(&self) -> Result, PluginError> { + self.ensure_plugins_dir().await?; + let mut plugins = Vec::new(); + let mut entries = fs::read_dir(&self.plugins_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.is_dir() && path.join("manifest.json").exists() { + plugins.push(path); + } + } + Ok(plugins) + } + + pub async fn load_manifest(&self, plugin_dir: &Path) -> Result { + let manifest_path = plugin_dir.join("manifest.json"); + let content = fs::read_to_string(&manifest_path) + .await + .map_err(|e| PluginError::LoadError(format!("无法读取清单文件: {}", e)))?; + let manifest: PluginManifest = serde_json::from_str(&content) + .map_err(|e| PluginError::InvalidManifest(format!("解析失败: {}", e)))?; + manifest.validate()?; + Ok(manifest) + } + + pub async fn load( + &self, + plugin_dir: &Path, + config: &PluginConfig, + ) -> Result, PluginError> { + let manifest = self.load_manifest(plugin_dir).await?; + match manifest.plugin_type { + PluginType::Script => self.load_script_plugin(plugin_dir, manifest, config).await, + PluginType::Native => Err(PluginError::LoadError("原生插件暂不支持".to_string())), + } + } + + async fn load_script_plugin( + &self, + plugin_dir: &Path, + manifest: PluginManifest, + _config: &PluginConfig, + ) -> Result, PluginError> { + let config_path = plugin_dir.join("config.json"); + let plugin_settings = if config_path.exists() { + let content = fs::read_to_string(&config_path) + .await + .map_err(|e| PluginError::LoadError(format!("无法读取配置文件: {}", e)))?; + serde_json::from_str(&content).unwrap_or_default() + } else { + serde_json::Value::Object(serde_json::Map::new()) + }; + let plugin = ScriptPlugin::new(manifest, plugin_settings); + Ok(Arc::new(plugin)) + } + + pub async fn load_all( + &self, + configs: &HashMap, + ) -> Result)>, PluginError> { + let plugin_dirs = self.scan().await?; + let mut plugins = Vec::new(); + for dir in plugin_dirs { + let manifest = match self.load_manifest(&dir).await { + Ok(m) => m, + Err(e) => { + tracing::warn!("跳过插件 {}: {}", dir.display(), e); + continue; + } + }; + let config = configs.get(&manifest.name).cloned().unwrap_or_default(); + match self.load(&dir, &config).await { + Ok(plugin) => plugins.push((dir, plugin)), + Err(e) => { + tracing::warn!("加载插件 {} 失败: {}", manifest.name, e); + } + } + } + Ok(plugins) + } + + pub fn plugins_dir(&self) -> &Path { + &self.plugins_dir + } +} + +pub struct ScriptPlugin { + manifest: PluginManifest, + settings: serde_json::Value, +} + +impl ScriptPlugin { + pub fn new(manifest: PluginManifest, settings: serde_json::Value) -> Self { + Self { manifest, settings } + } + + fn apply_request_transforms(&self, request: &mut serde_json::Value) -> bool { + let transforms = match self.settings.get("request_transforms") { + Some(t) if t.is_array() => t.as_array().unwrap(), + _ => return false, + }; + let mut modified = false; + let obj = match request.as_object_mut() { + Some(o) => o, + None => return false, + }; + for transform in transforms { + if let Some(inject) = transform.get("inject").and_then(|v| v.as_object()) { + for (key, value) in inject { + if !obj.contains_key(key) { + obj.insert(key.clone(), value.clone()); + modified = true; + } + } + } + } + modified + } + + fn apply_response_transforms(&self, response: &mut serde_json::Value) -> bool { + let transforms = match self.settings.get("response_transforms") { + Some(t) if t.is_array() => t.as_array().unwrap(), + _ => return false, + }; + let mut modified = false; + let obj = match response.as_object_mut() { + Some(o) => o, + None => return false, + }; + for transform in transforms { + if let Some(inject) = transform.get("inject").and_then(|v| v.as_object()) { + for (key, value) in inject { + obj.insert(key.clone(), value.clone()); + modified = true; + } + } + } + modified + } +} + +#[async_trait] +impl Plugin for ScriptPlugin { + fn name(&self) -> &str { + &self.manifest.name + } + + fn version(&self) -> &str { + &self.manifest.version + } + + fn manifest(&self) -> &PluginManifest { + &self.manifest + } + + async fn init(&mut self, _config: &PluginConfig) -> Result<(), PluginError> { + Ok(()) + } + + async fn on_request( + &self, + _ctx: &mut PluginContext, + request: &mut serde_json::Value, + ) -> Result { + let start = std::time::Instant::now(); + let modified = self.apply_request_transforms(request); + Ok(HookResult::success( + modified, + start.elapsed().as_millis() as u64, + )) + } + + async fn on_response( + &self, + _ctx: &mut PluginContext, + response: &mut serde_json::Value, + ) -> Result { + let start = std::time::Instant::now(); + let modified = self.apply_response_transforms(response); + Ok(HookResult::success( + modified, + start.elapsed().as_millis() as u64, + )) + } + + async fn on_error( + &self, + _ctx: &mut PluginContext, + _error: &str, + ) -> Result { + let start = std::time::Instant::now(); + Ok(HookResult::success( + false, + start.elapsed().as_millis() as u64, + )) + } + + async fn shutdown(&mut self) -> Result<(), PluginError> { + Ok(()) + } +} diff --git a/src-tauri/src/plugin/manager.rs b/src-tauri/src/plugin/manager.rs new file mode 100644 index 000000000..21b8daa89 --- /dev/null +++ b/src-tauri/src/plugin/manager.rs @@ -0,0 +1,445 @@ +//! 插件管理器 +//! +//! 负责插件的生命周期管理、钩子执行和配置管理 + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use dashmap::DashMap; +use tokio::sync::RwLock; +use tokio::time::timeout; + +use super::loader::PluginLoader; +use super::types::{ + HookResult, Plugin, PluginConfig, PluginContext, PluginError, PluginInfo, PluginInstance, + PluginStatus, +}; + +/// 插件管理器配置 +#[derive(Debug, Clone)] +pub struct PluginManagerConfig { + /// 默认超时时间 (毫秒) + pub default_timeout_ms: u64, + /// 是否启用插件系统 + pub enabled: bool, + /// 最大并发插件数 + pub max_plugins: usize, +} + +impl Default for PluginManagerConfig { + fn default() -> Self { + Self { + default_timeout_ms: 5000, + enabled: true, + max_plugins: 50, + } + } +} + +/// 插件管理器 +pub struct PluginManager { + /// 插件加载器 + loader: PluginLoader, + /// 已加载的插件 + plugins: DashMap>>, + /// 插件配置 + configs: DashMap, + /// 管理器配置 + config: PluginManagerConfig, +} + +impl PluginManager { + /// 创建新的插件管理器 + pub fn new(plugins_dir: PathBuf, config: PluginManagerConfig) -> Self { + Self { + loader: PluginLoader::new(plugins_dir), + plugins: DashMap::new(), + configs: DashMap::new(), + config, + } + } + + /// 使用默认配置创建 + pub fn with_defaults() -> Self { + Self::new( + PluginLoader::default_plugins_dir(), + PluginManagerConfig::default(), + ) + } + + /// 加载所有插件 + pub async fn load_all(&self) -> Result, PluginError> { + if !self.config.enabled { + return Ok(Vec::new()); + } + + let configs: HashMap = self + .configs + .iter() + .map(|r| (r.key().clone(), r.value().clone())) + .collect(); + + let loaded = self.loader.load_all(&configs).await?; + let mut names = Vec::new(); + + for (path, plugin) in loaded { + let name = plugin.name().to_string(); + let config = configs.get(&name).cloned().unwrap_or_default(); + + let mut instance = PluginInstance::new(plugin.clone(), path, config.clone()); + + // 初始化插件 + if let Err(e) = Arc::get_mut(&mut instance.plugin) + .ok_or_else(|| PluginError::InitError("无法获取插件可变引用".to_string()))? + .init(&config) + .await + { + tracing::warn!("插件 {} 初始化失败: {}", name, e); + instance.state.status = PluginStatus::Error; + instance.state.last_error = Some(e.to_string()); + } else { + instance.state.status = if config.enabled { + PluginStatus::Enabled + } else { + PluginStatus::Disabled + }; + } + + self.plugins + .insert(name.clone(), Arc::new(RwLock::new(instance))); + names.push(name); + } + + Ok(names) + } + + /// 加载单个插件 + pub async fn load(&self, plugin_dir: &Path) -> Result { + if self.plugins.len() >= self.config.max_plugins { + return Err(PluginError::LoadError(format!( + "已达到最大插件数限制: {}", + self.config.max_plugins + ))); + } + + let config = PluginConfig::default(); + let plugin = self.loader.load(plugin_dir, &config).await?; + let name = plugin.name().to_string(); + + // 检查是否已加载 + if self.plugins.contains_key(&name) { + return Err(PluginError::LoadError(format!("插件 {} 已加载", name))); + } + + let mut instance = + PluginInstance::new(plugin.clone(), plugin_dir.to_path_buf(), config.clone()); + + // 初始化插件 + if let Err(e) = Arc::get_mut(&mut instance.plugin) + .ok_or_else(|| PluginError::InitError("无法获取插件可变引用".to_string()))? + .init(&config) + .await + { + instance.state.status = PluginStatus::Error; + instance.state.last_error = Some(e.to_string()); + } else { + instance.state.status = PluginStatus::Enabled; + } + + self.plugins + .insert(name.clone(), Arc::new(RwLock::new(instance))); + Ok(name) + } + + /// 卸载插件 + pub async fn unload(&self, name: &str) -> Result<(), PluginError> { + let instance = self + .plugins + .remove(name) + .map(|(_, v)| v) + .ok_or_else(|| PluginError::NotFound(name.to_string()))?; + + // 关闭插件 + let mut inst = instance.write().await; + if let Some(plugin) = Arc::get_mut(&mut inst.plugin) { + plugin.shutdown().await?; + } + + Ok(()) + } + + /// 启用插件 + pub async fn enable(&self, name: &str) -> Result<(), PluginError> { + let instance = self + .plugins + .get(name) + .ok_or_else(|| PluginError::NotFound(name.to_string()))?; + + let mut inst = instance.write().await; + inst.config.enabled = true; + inst.state.status = PluginStatus::Enabled; + + // 更新配置 + self.configs.insert(name.to_string(), inst.config.clone()); + + Ok(()) + } + + /// 禁用插件 + pub async fn disable(&self, name: &str) -> Result<(), PluginError> { + let instance = self + .plugins + .get(name) + .ok_or_else(|| PluginError::NotFound(name.to_string()))?; + + let mut inst = instance.write().await; + inst.config.enabled = false; + inst.state.status = PluginStatus::Disabled; + + // 更新配置 + self.configs.insert(name.to_string(), inst.config.clone()); + + Ok(()) + } + + /// 更新插件配置 + pub async fn update_config(&self, name: &str, config: PluginConfig) -> Result<(), PluginError> { + let instance = self + .plugins + .get(name) + .ok_or_else(|| PluginError::NotFound(name.to_string()))?; + + let mut inst = instance.write().await; + inst.config = config.clone(); + + // 更新状态 + if config.enabled && inst.state.status != PluginStatus::Error { + inst.state.status = PluginStatus::Enabled; + } else if !config.enabled { + inst.state.status = PluginStatus::Disabled; + } + + // 更新配置存储 + self.configs.insert(name.to_string(), config); + + Ok(()) + } + + /// 获取插件配置 + pub fn get_config(&self, name: &str) -> Option { + self.configs.get(name).map(|r| r.value().clone()) + } + + /// 获取插件信息 + pub async fn get_info(&self, name: &str) -> Option { + let instance = self.plugins.get(name)?; + let inst = instance.read().await; + Some(inst.info()) + } + + /// 获取所有插件信息 + pub async fn list(&self) -> Vec { + let mut infos = Vec::new(); + for entry in self.plugins.iter() { + let inst = entry.value().read().await; + infos.push(inst.info()); + } + infos + } + + /// 执行请求前钩子 (带隔离) + pub async fn run_on_request( + &self, + ctx: &mut PluginContext, + request: &mut serde_json::Value, + ) -> Vec { + if !self.config.enabled { + return Vec::new(); + } + + let mut results = Vec::new(); + + for entry in self.plugins.iter() { + let instance = entry.value().read().await; + if !instance.is_enabled() { + continue; + } + + let timeout_ms = instance.config.timeout_ms; + let plugin = instance.plugin.clone(); + let plugin_name = plugin.name().to_string(); + + // 带超时执行 + let result = match timeout( + Duration::from_millis(timeout_ms), + plugin.on_request(ctx, request), + ) + .await + { + Ok(Ok(result)) => result, + Ok(Err(e)) => { + tracing::warn!("插件 {} on_request 执行失败: {}", plugin_name, e); + HookResult::failure(e.to_string(), timeout_ms) + } + Err(_) => { + tracing::warn!("插件 {} on_request 执行超时", plugin_name); + HookResult::failure(format!("执行超时 ({}ms)", timeout_ms), timeout_ms) + } + }; + + // 更新状态 + drop(instance); + if let Some(inst) = self.plugins.get(&plugin_name) { + let mut inst = inst.write().await; + inst.state + .record_execution(result.success, result.error.clone()); + } + + results.push(result); + } + + results + } + + /// 执行响应后钩子 (带隔离) + pub async fn run_on_response( + &self, + ctx: &mut PluginContext, + response: &mut serde_json::Value, + ) -> Vec { + if !self.config.enabled { + return Vec::new(); + } + + let mut results = Vec::new(); + + for entry in self.plugins.iter() { + let instance = entry.value().read().await; + if !instance.is_enabled() { + continue; + } + + let timeout_ms = instance.config.timeout_ms; + let plugin = instance.plugin.clone(); + let plugin_name = plugin.name().to_string(); + + // 带超时执行 + let result = match timeout( + Duration::from_millis(timeout_ms), + plugin.on_response(ctx, response), + ) + .await + { + Ok(Ok(result)) => result, + Ok(Err(e)) => { + tracing::warn!("插件 {} on_response 执行失败: {}", plugin_name, e); + HookResult::failure(e.to_string(), timeout_ms) + } + Err(_) => { + tracing::warn!("插件 {} on_response 执行超时", plugin_name); + HookResult::failure(format!("执行超时 ({}ms)", timeout_ms), timeout_ms) + } + }; + + // 更新状态 + drop(instance); + if let Some(inst) = self.plugins.get(&plugin_name) { + let mut inst = inst.write().await; + inst.state + .record_execution(result.success, result.error.clone()); + } + + results.push(result); + } + + results + } + + /// 执行错误钩子 (带隔离) + pub async fn run_on_error(&self, ctx: &mut PluginContext, error: &str) -> Vec { + if !self.config.enabled { + return Vec::new(); + } + + let mut results = Vec::new(); + + for entry in self.plugins.iter() { + let instance = entry.value().read().await; + if !instance.is_enabled() { + continue; + } + + let timeout_ms = instance.config.timeout_ms; + let plugin = instance.plugin.clone(); + let plugin_name = plugin.name().to_string(); + + // 带超时执行 + let result = match timeout( + Duration::from_millis(timeout_ms), + plugin.on_error(ctx, error), + ) + .await + { + Ok(Ok(result)) => result, + Ok(Err(e)) => { + tracing::warn!("插件 {} on_error 执行失败: {}", plugin_name, e); + HookResult::failure(e.to_string(), timeout_ms) + } + Err(_) => { + tracing::warn!("插件 {} on_error 执行超时", plugin_name); + HookResult::failure(format!("执行超时 ({}ms)", timeout_ms), timeout_ms) + } + }; + + // 更新状态 + drop(instance); + if let Some(inst) = self.plugins.get(&plugin_name) { + let mut inst = inst.write().await; + inst.state + .record_execution(result.success, result.error.clone()); + } + + results.push(result); + } + + results + } + + /// 获取已加载插件数量 + pub fn count(&self) -> usize { + self.plugins.len() + } + + /// 检查插件是否已加载 + pub fn is_loaded(&self, name: &str) -> bool { + self.plugins.contains_key(name) + } + + /// 获取插件目录 + pub fn plugins_dir(&self) -> &Path { + self.loader.plugins_dir() + } + + /// 设置插件配置 (批量) + pub fn set_configs(&self, configs: HashMap) { + for (name, config) in configs { + self.configs.insert(name, config); + } + } + + /// 获取所有插件配置 + pub fn get_all_configs(&self) -> HashMap { + self.configs + .iter() + .map(|r| (r.key().clone(), r.value().clone())) + .collect() + } +} + +impl Default for PluginManager { + fn default() -> Self { + Self::with_defaults() + } +} diff --git a/src-tauri/src/plugin/mod.rs b/src-tauri/src/plugin/mod.rs new file mode 100644 index 000000000..6211f0fd9 --- /dev/null +++ b/src-tauri/src/plugin/mod.rs @@ -0,0 +1,21 @@ +//! 插件系统模块 +//! +//! 提供插件扩展功能,支持: +//! - 插件加载和初始化 +//! - 请求前/响应后钩子 +//! - 插件隔离和错误处理 +//! - 插件配置管理 + +mod loader; +mod manager; +mod types; + +pub use loader::PluginLoader; +pub use manager::PluginManager; +pub use types::{ + HookResult, Plugin, PluginConfig, PluginContext, PluginError, PluginInfo, PluginManifest, + PluginState, PluginStatus, PluginType, +}; + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/plugin/tests.rs b/src-tauri/src/plugin/tests.rs new file mode 100644 index 000000000..d009faea4 --- /dev/null +++ b/src-tauri/src/plugin/tests.rs @@ -0,0 +1,278 @@ +//! 插件系统测试 + +use super::*; +use crate::ProviderType; + +#[test] +fn test_plugin_manifest_validation() { + // 有效清单 + let valid = PluginManifest { + name: "test-plugin".to_string(), + version: "1.0.0".to_string(), + description: "Test plugin".to_string(), + author: Some("Test Author".to_string()), + homepage: None, + license: Some("MIT".to_string()), + entry: "config.json".to_string(), + plugin_type: PluginType::Script, + config_schema: None, + hooks: vec!["on_request".to_string()], + min_proxycast_version: None, + }; + assert!(valid.validate().is_ok()); + + // 空名称 + let invalid_name = PluginManifest { + name: "".to_string(), + ..valid.clone() + }; + assert!(invalid_name.validate().is_err()); + + // 空版本 + let invalid_version = PluginManifest { + version: "".to_string(), + ..valid.clone() + }; + assert!(invalid_version.validate().is_err()); +} + +#[test] +fn test_plugin_context() { + let mut ctx = PluginContext::new( + "req-123".to_string(), + ProviderType::Kiro, + "claude-sonnet-4-5".to_string(), + ); + + assert_eq!(ctx.request_id, "req-123"); + assert_eq!(ctx.provider, ProviderType::Kiro); + assert_eq!(ctx.model, "claude-sonnet-4-5"); + assert!(ctx.metadata.is_empty()); + + // 添加元数据 + ctx.set_metadata("key1", serde_json::json!("value1")); + assert_eq!(ctx.get_metadata("key1"), Some(&serde_json::json!("value1"))); + + // 使用 builder 模式 + let ctx2 = PluginContext::new( + "req-456".to_string(), + ProviderType::Gemini, + "gemini-2.5-flash".to_string(), + ) + .with_metadata("test", serde_json::json!(123)); + + assert_eq!(ctx2.get_metadata("test"), Some(&serde_json::json!(123))); +} + +#[test] +fn test_hook_result() { + let success = HookResult::success(true, 100); + assert!(success.success); + assert!(success.modified); + assert!(success.error.is_none()); + assert_eq!(success.duration_ms, 100); + + let failure = HookResult::failure("test error".to_string(), 50); + assert!(!failure.success); + assert!(!failure.modified); + assert_eq!(failure.error, Some("test error".to_string())); + assert_eq!(failure.duration_ms, 50); +} + +#[test] +fn test_plugin_config() { + let config = PluginConfig::new() + .with_enabled(true) + .with_timeout(3000) + .with_settings(serde_json::json!({"key": "value"})); + + assert!(config.enabled); + assert_eq!(config.timeout_ms, 3000); + assert_eq!(config.settings, serde_json::json!({"key": "value"})); +} + +#[test] +fn test_plugin_state() { + let mut state = PluginState::new("test-plugin".to_string()); + + assert_eq!(state.name, "test-plugin"); + assert_eq!(state.status, PluginStatus::Loaded); + assert_eq!(state.execution_count, 0); + assert_eq!(state.error_count, 0); + assert!(state.last_error.is_none()); + + // 记录成功执行 + state.record_execution(true, None); + assert_eq!(state.execution_count, 1); + assert_eq!(state.error_count, 0); + assert!(state.last_executed.is_some()); + + // 记录失败执行 + state.record_execution(false, Some("test error".to_string())); + assert_eq!(state.execution_count, 2); + assert_eq!(state.error_count, 1); + assert_eq!(state.last_error, Some("test error".to_string())); +} + +#[test] +fn test_plugin_status_display() { + assert_eq!(PluginStatus::Loaded.to_string(), "loaded"); + assert_eq!(PluginStatus::Enabled.to_string(), "enabled"); + assert_eq!(PluginStatus::Disabled.to_string(), "disabled"); + assert_eq!(PluginStatus::Error.to_string(), "error"); +} + +#[test] +fn test_plugin_error_display() { + let err = PluginError::NotFound("test-plugin".to_string()); + assert!(err.to_string().contains("test-plugin")); + + let err = PluginError::Timeout { + plugin_name: "slow-plugin".to_string(), + timeout_ms: 5000, + }; + assert!(err.to_string().contains("slow-plugin")); + assert!(err.to_string().contains("5000")); +} + +#[test] +fn test_plugin_manifest_serde() { + let manifest = PluginManifest { + name: "test-plugin".to_string(), + version: "1.0.0".to_string(), + description: "A test plugin".to_string(), + author: Some("Test".to_string()), + homepage: None, + license: None, + entry: "config.json".to_string(), + plugin_type: PluginType::Script, + config_schema: Some(serde_json::json!({ + "type": "object", + "properties": { + "enabled": { "type": "boolean" } + } + })), + hooks: vec!["on_request".to_string(), "on_response".to_string()], + min_proxycast_version: Some("0.13.0".to_string()), + }; + + // 序列化 + let json = serde_json::to_string(&manifest).unwrap(); + assert!(json.contains("test-plugin")); + + // 反序列化 + let parsed: PluginManifest = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.name, manifest.name); + assert_eq!(parsed.version, manifest.version); + assert_eq!(parsed.hooks.len(), 2); +} + +// Property-based tests +use proptest::prelude::*; + +/// **Feature: enhancement-roadmap, Property 17: 插件隔离性** +/// **Validates: Requirements 6.3 (验收标准 3)** +/// +/// *对于任意* 插件执行失败,主请求处理流程应继续正常执行 +mod property_tests { + use super::*; + use crate::plugin::manager::{PluginManager, PluginManagerConfig}; + use std::path::PathBuf; + use tempfile::TempDir; + + /// 生成随机的请求 JSON + fn arb_request() -> impl Strategy { + prop::collection::hash_map( + "[a-z]{1,10}", + prop_oneof![ + Just(serde_json::Value::Null), + any::().prop_map(serde_json::Value::Bool), + any::().prop_map(|n| serde_json::Value::Number(n.into())), + "[a-zA-Z0-9 ]{0,50}".prop_map(serde_json::Value::String), + ], + 0..5, + ) + .prop_map(|map| serde_json::Value::Object(map.into_iter().collect())) + } + + /// 生成随机的插件上下文 + fn arb_context() -> impl Strategy { + ( + "[a-z0-9]{8,16}", // request_id + prop_oneof![ + Just(ProviderType::Kiro), + Just(ProviderType::Gemini), + Just(ProviderType::Qwen), + ], + "[a-z0-9-]{5,20}", // model + ) + .prop_map(|(request_id, provider, model)| { + PluginContext::new(request_id, provider, model) + }) + } + + proptest! { + /// **Property 17: 插件隔离性** + /// + /// 验证:即使没有插件加载,PluginManager 的钩子执行也应该正常完成 + /// 不会因为空插件列表而失败 + #[test] + fn plugin_manager_handles_empty_plugins( + request in arb_request(), + ctx in arb_context() + ) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let (results_empty, req_unchanged) = rt.block_on(async { + let temp_dir = TempDir::new().unwrap(); + let config = PluginManagerConfig { + default_timeout_ms: 1000, + enabled: true, + max_plugins: 10, + }; + let manager = PluginManager::new(temp_dir.path().to_path_buf(), config); + + let mut ctx = ctx; + let mut req = request.clone(); + + // 即使没有插件,钩子执行也应该成功返回空结果 + let results = manager.run_on_request(&mut ctx, &mut req).await; + + (results.is_empty(), req == request) + }); + + prop_assert!(results_empty, "Empty plugin list should return empty results"); + prop_assert!(req_unchanged, "Request should not be modified when no plugins"); + } + + /// **Property 17: 插件隔离性 - 禁用状态** + /// + /// 验证:当插件系统禁用时,钩子执行应该正常完成且不修改数据 + #[test] + fn disabled_plugin_system_does_not_affect_request( + request in arb_request(), + ctx in arb_context() + ) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let (results_empty, req_unchanged) = rt.block_on(async { + let temp_dir = TempDir::new().unwrap(); + let config = PluginManagerConfig { + default_timeout_ms: 1000, + enabled: false, // 禁用插件系统 + max_plugins: 10, + }; + let manager = PluginManager::new(temp_dir.path().to_path_buf(), config); + + let mut ctx = ctx; + let mut req = request.clone(); + + // 禁用状态下,钩子执行应该返回空结果 + let results = manager.run_on_request(&mut ctx, &mut req).await; + + (results.is_empty(), req == request) + }); + + prop_assert!(results_empty, "Disabled plugin system should return empty results"); + prop_assert!(req_unchanged, "Request should not be modified when plugins disabled"); + } + } +} diff --git a/src-tauri/src/plugin/types.rs b/src-tauri/src/plugin/types.rs new file mode 100644 index 000000000..02db5e503 --- /dev/null +++ b/src-tauri/src/plugin/types.rs @@ -0,0 +1,428 @@ +//! 插件系统类型定义 +//! +//! 定义 Plugin trait、PluginContext、PluginManifest 等核心类型 + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt; +use std::path::PathBuf; +use std::sync::Arc; +use thiserror::Error; + +use crate::ProviderType; + +/// 插件错误类型 +#[derive(Error, Debug)] +pub enum PluginError { + #[error("插件加载失败: {0}")] + LoadError(String), + + #[error("插件初始化失败: {0}")] + InitError(String), + + #[error("插件执行超时: {plugin_name} 超过 {timeout_ms}ms")] + Timeout { + plugin_name: String, + timeout_ms: u64, + }, + + #[error("插件执行失败: {plugin_name} - {message}")] + ExecutionError { + plugin_name: String, + message: String, + }, + + #[error("插件配置错误: {0}")] + ConfigError(String), + + #[error("插件不存在: {0}")] + NotFound(String), + + #[error("插件已禁用: {0}")] + Disabled(String), + + #[error("清单文件无效: {0}")] + InvalidManifest(String), + + #[error("IO 错误: {0}")] + IoError(#[from] std::io::Error), + + #[error("JSON 解析错误: {0}")] + JsonError(#[from] serde_json::Error), +} + +/// 插件状态 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum PluginStatus { + /// 已加载但未启用 + #[default] + Loaded, + /// 已启用 + Enabled, + /// 已禁用 + Disabled, + /// 错误状态 + Error, +} + +impl fmt::Display for PluginStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PluginStatus::Loaded => write!(f, "loaded"), + PluginStatus::Enabled => write!(f, "enabled"), + PluginStatus::Disabled => write!(f, "disabled"), + PluginStatus::Error => write!(f, "error"), + } + } +} + +/// 插件清单 (manifest.json) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginManifest { + /// 插件名称 + pub name: String, + /// 插件版本 + pub version: String, + /// 插件描述 + #[serde(default)] + pub description: String, + /// 作者 + #[serde(default)] + pub author: Option, + /// 主页/仓库地址 + #[serde(default)] + pub homepage: Option, + /// 许可证 + #[serde(default)] + pub license: Option, + /// 入口文件 (相对于插件目录) + #[serde(default = "default_entry")] + pub entry: String, + /// 插件类型 + #[serde(default)] + pub plugin_type: PluginType, + /// 配置 schema (JSON Schema) + #[serde(default)] + pub config_schema: Option, + /// 支持的钩子 + #[serde(default)] + pub hooks: Vec, + /// 最低 ProxyCast 版本要求 + #[serde(default)] + pub min_proxycast_version: Option, +} + +fn default_entry() -> String { + "config.json".to_string() +} + +impl PluginManifest { + /// 验证清单有效性 + pub fn validate(&self) -> Result<(), PluginError> { + if self.name.is_empty() { + return Err(PluginError::InvalidManifest("插件名称不能为空".to_string())); + } + if self.version.is_empty() { + return Err(PluginError::InvalidManifest("插件版本不能为空".to_string())); + } + Ok(()) + } +} + +/// 插件类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum PluginType { + /// 脚本插件 (JSON 配置驱动) + #[default] + #[serde(alias = "lua")] + Script, + /// 原生 Rust 插件 (预留) + Native, +} + +/// 插件上下文 - 传递给钩子函数的上下文信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginContext { + /// 请求 ID + pub request_id: String, + /// Provider 类型 + pub provider: ProviderType, + /// 模型名称 + pub model: String, + /// 元数据 + #[serde(default)] + pub metadata: HashMap, + /// 时间戳 + pub timestamp: DateTime, +} + +impl PluginContext { + /// 创建新的插件上下文 + pub fn new(request_id: String, provider: ProviderType, model: String) -> Self { + Self { + request_id, + provider, + model, + metadata: HashMap::new(), + timestamp: Utc::now(), + } + } + + /// 添加元数据 + pub fn with_metadata(mut self, key: &str, value: serde_json::Value) -> Self { + self.metadata.insert(key.to_string(), value); + self + } + + /// 获取元数据 + pub fn get_metadata(&self, key: &str) -> Option<&serde_json::Value> { + self.metadata.get(key) + } + + /// 设置元数据 + pub fn set_metadata(&mut self, key: &str, value: serde_json::Value) { + self.metadata.insert(key.to_string(), value); + } +} + +/// 钩子执行结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookResult { + /// 是否成功 + pub success: bool, + /// 是否修改了数据 + pub modified: bool, + /// 错误信息 (如果失败) + pub error: Option, + /// 执行时间 (毫秒) + pub duration_ms: u64, +} + +impl HookResult { + /// 创建成功结果 + pub fn success(modified: bool, duration_ms: u64) -> Self { + Self { + success: true, + modified, + error: None, + duration_ms, + } + } + + /// 创建失败结果 + pub fn failure(error: String, duration_ms: u64) -> Self { + Self { + success: false, + modified: false, + error: Some(error), + duration_ms, + } + } +} + +/// 插件配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PluginConfig { + /// 插件特定配置 + #[serde(default)] + pub settings: serde_json::Value, + /// 是否启用 + #[serde(default = "default_enabled")] + pub enabled: bool, + /// 执行超时 (毫秒) + #[serde(default = "default_timeout")] + pub timeout_ms: u64, +} + +fn default_enabled() -> bool { + true +} + +fn default_timeout() -> u64 { + 5000 // 5 秒 +} + +impl PluginConfig { + /// 创建默认配置 + pub fn new() -> Self { + Self::default() + } + + /// 设置配置值 + pub fn with_settings(mut self, settings: serde_json::Value) -> Self { + self.settings = settings; + self + } + + /// 设置启用状态 + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// 设置超时 + pub fn with_timeout(mut self, timeout_ms: u64) -> Self { + self.timeout_ms = timeout_ms; + self + } +} + +/// 插件状态信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginState { + /// 插件名称 + pub name: String, + /// 插件状态 + pub status: PluginStatus, + /// 加载时间 + pub loaded_at: DateTime, + /// 最后执行时间 + pub last_executed: Option>, + /// 执行次数 + pub execution_count: u64, + /// 错误次数 + pub error_count: u64, + /// 最后错误信息 + pub last_error: Option, +} + +impl PluginState { + /// 创建新的插件状态 + pub fn new(name: String) -> Self { + Self { + name, + status: PluginStatus::Loaded, + loaded_at: Utc::now(), + last_executed: None, + execution_count: 0, + error_count: 0, + last_error: None, + } + } + + /// 记录执行 + pub fn record_execution(&mut self, success: bool, error: Option) { + self.last_executed = Some(Utc::now()); + self.execution_count += 1; + if !success { + self.error_count += 1; + self.last_error = error; + } + } +} + +/// 插件信息 (用于 UI 显示) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginInfo { + /// 插件名称 + pub name: String, + /// 插件版本 + pub version: String, + /// 插件描述 + pub description: String, + /// 作者 + pub author: Option, + /// 插件状态 + pub status: PluginStatus, + /// 插件路径 + pub path: PathBuf, + /// 支持的钩子 + pub hooks: Vec, + /// 配置 schema + pub config_schema: Option, + /// 当前配置 + pub config: PluginConfig, + /// 运行时状态 + pub state: PluginState, +} + +/// 插件 trait - 定义插件必须实现的接口 +#[async_trait] +pub trait Plugin: Send + Sync { + /// 获取插件名称 + fn name(&self) -> &str; + + /// 获取插件版本 + fn version(&self) -> &str; + + /// 获取插件清单 + fn manifest(&self) -> &PluginManifest; + + /// 初始化插件 + async fn init(&mut self, config: &PluginConfig) -> Result<(), PluginError>; + + /// 请求前钩子 + async fn on_request( + &self, + ctx: &mut PluginContext, + request: &mut serde_json::Value, + ) -> Result; + + /// 响应后钩子 + async fn on_response( + &self, + ctx: &mut PluginContext, + response: &mut serde_json::Value, + ) -> Result; + + /// 错误钩子 + async fn on_error( + &self, + ctx: &mut PluginContext, + error: &str, + ) -> Result; + + /// 关闭插件 + async fn shutdown(&mut self) -> Result<(), PluginError>; +} + +/// 插件实例包装器 - 用于管理插件生命周期 +pub struct PluginInstance { + /// 插件实现 + pub plugin: Arc, + /// 插件路径 + pub path: PathBuf, + /// 插件配置 + pub config: PluginConfig, + /// 插件状态 + pub state: PluginState, +} + +impl PluginInstance { + /// 创建新的插件实例 + pub fn new(plugin: Arc, path: PathBuf, config: PluginConfig) -> Self { + let state = PluginState::new(plugin.name().to_string()); + Self { + plugin, + path, + config, + state, + } + } + + /// 获取插件信息 + pub fn info(&self) -> PluginInfo { + let manifest = self.plugin.manifest(); + PluginInfo { + name: manifest.name.clone(), + version: manifest.version.clone(), + description: manifest.description.clone(), + author: manifest.author.clone(), + status: self.state.status, + path: self.path.clone(), + hooks: manifest.hooks.clone(), + config_schema: manifest.config_schema.clone(), + config: self.config.clone(), + state: self.state.clone(), + } + } + + /// 是否启用 + pub fn is_enabled(&self) -> bool { + self.config.enabled && self.state.status == PluginStatus::Enabled + } +} diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index 9f751701c..c9be66aa3 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -263,7 +263,7 @@ async fn run_server( async fn health() -> impl IntoResponse { Json(serde_json::json!({ "status": "healthy", - "version": "0.8.0" + "version": "0.9.0" })) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ae8965fce..dbd7f5eda 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.8.0", + "version": "0.9.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/plugins/PluginManager.tsx b/src/components/plugins/PluginManager.tsx new file mode 100644 index 000000000..048ca2aa9 --- /dev/null +++ b/src/components/plugins/PluginManager.tsx @@ -0,0 +1,373 @@ +import React, { useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { + Puzzle, + RefreshCw, + Power, + PowerOff, + Trash2, + FolderOpen, + AlertCircle, + CheckCircle, + Clock, + ChevronDown, + ChevronUp, +} from "lucide-react"; + +interface PluginState { + name: string; + status: string; + loaded_at: string; + last_executed: string | null; + execution_count: number; + error_count: number; + last_error: string | null; +} + +interface PluginConfig { + enabled: boolean; + timeout_ms: number; + settings: Record; +} + +interface PluginInfo { + name: string; + version: string; + description: string; + author: string | null; + status: string; + path: string; + hooks: string[]; + config_schema: Record | null; + config: PluginConfig; + state: PluginState; +} + +interface PluginServiceStatus { + enabled: boolean; + plugin_count: number; + plugins_dir: string; +} + +export function PluginManager() { + const [status, setStatus] = useState(null); + const [plugins, setPlugins] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedPlugin, setExpandedPlugin] = useState(null); + + const fetchData = async () => { + try { + setLoading(true); + const [serviceStatus, pluginList] = await Promise.all([ + invoke("get_plugin_status"), + invoke("get_plugins"), + ]); + setStatus(serviceStatus); + setPlugins(pluginList); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, []); + + const handleTogglePlugin = async (name: string, currentEnabled: boolean) => { + try { + if (currentEnabled) { + await invoke("disable_plugin", { name }); + } else { + await invoke("enable_plugin", { name }); + } + await fetchData(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleReloadPlugins = async () => { + try { + await invoke("reload_plugins"); + await fetchData(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleUnloadPlugin = async (name: string) => { + try { + await invoke("unload_plugin", { name }); + await fetchData(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case "enabled": + return ; + case "disabled": + return ; + case "error": + return ; + default: + return ; + } + }; + + const getStatusText = (status: string) => { + switch (status) { + case "enabled": + return "已启用"; + case "disabled": + return "已禁用"; + case "error": + return "错误"; + case "loaded": + return "已加载"; + default: + return status; + } + }; + + if (loading) { + return ( +
+
+ + 加载中... +
+
+ ); + } + + return ( +
+ {/* 状态概览 */} +
+
+

+ + 插件系统 +

+
+ +
+
+ + {error && ( +
+ {error} +
+ )} + + {status && ( +
+
+
{status.plugin_count}
+
已加载插件
+
+
+
+ + {status.plugins_dir.split("/").pop()} +
+
插件目录
+
+
+ )} +
+ + {/* 插件列表 */} +
+
+

已安装插件

+
+ + {plugins.length === 0 ? ( +
+ +

暂无已安装的插件

+

将插件放入插件目录即可自动加载

+
+ ) : ( +
+ {plugins.map((plugin) => ( + + setExpandedPlugin( + expandedPlugin === plugin.name ? null : plugin.name, + ) + } + onToggleEnabled={() => + handleTogglePlugin(plugin.name, plugin.config.enabled) + } + onUnload={() => handleUnloadPlugin(plugin.name)} + getStatusIcon={getStatusIcon} + getStatusText={getStatusText} + /> + ))} +
+ )} +
+
+ ); +} + +interface PluginItemProps { + plugin: PluginInfo; + expanded: boolean; + onToggleExpand: () => void; + onToggleEnabled: () => void; + onUnload: () => void; + getStatusIcon: (status: string) => React.ReactNode; + getStatusText: (status: string) => string; +} + +function PluginItem({ + plugin, + expanded, + onToggleExpand, + onToggleEnabled, + onUnload, + getStatusIcon, + getStatusText, +}: PluginItemProps) { + return ( +
+
+
+ +
+
+ {plugin.name} + + v{plugin.version} + + + {getStatusIcon(plugin.status)} + {getStatusText(plugin.status)} + +
+
+ {plugin.description || "无描述"} +
+
+
+ +
+ + +
+
+ + {expanded && ( +
+ {plugin.author && ( +
+ 作者: + {plugin.author} +
+ )} + +
+ 路径: + {plugin.path} +
+ + {plugin.hooks.length > 0 && ( +
+ 钩子: +
+ {plugin.hooks.map((hook) => ( + + {hook} + + ))} +
+
+ )} + +
+ 统计: +
+
+
{plugin.state.execution_count}
+
执行次数
+
+
+
+ {plugin.state.error_count} +
+
错误次数
+
+
+
{plugin.config.timeout_ms}ms
+
超时时间
+
+
+
+ + {plugin.state.last_error && ( +
+ 最后错误: + {plugin.state.last_error} +
+ )} +
+ )} +
+ ); +} + +export default PluginManager; diff --git a/src/components/plugins/index.ts b/src/components/plugins/index.ts new file mode 100644 index 000000000..316f4a208 --- /dev/null +++ b/src/components/plugins/index.ts @@ -0,0 +1,2 @@ +export { PluginManager } from "./PluginManager"; +export { default } from "./PluginManager";