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)
This commit is contained in:
coso
2025-12-16 18:48:36 +08:00
parent 51b1761e89
commit a18384bcef
16 changed files with 2009 additions and 134 deletions
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.8.0",
"version": "0.9.0",
"type": "module",
"scripts": {
"dev": "vite",
+79 -128
View File
@@ -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",
+3 -1
View File
@@ -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"
+1
View File
@@ -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;
+135
View File
@@ -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<RwLock<PluginManager>>);
/// 插件状态响应
#[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<PluginServiceStatus, String> {
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<Vec<PluginInfo>, 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<Option<PluginInfo>, 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<Option<PluginConfig>, String> {
let manager = state.0.read().await;
Ok(manager.get_config(&name))
}
/// 重新加载所有插件
#[tauri::command]
pub async fn reload_plugins(
state: tauri::State<'_, PluginManagerState>,
) -> Result<Vec<String>, 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<String, String> {
let manager = state.0.read().await;
Ok(manager.plugins_dir().to_string_lossy().to_string())
}
+1
View File
@@ -6,6 +6,7 @@ mod database;
pub mod injection;
mod logger;
mod models;
pub mod plugin;
mod providers;
pub mod resilience;
mod router;
+238
View File
@@ -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<Vec<PathBuf>, 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<PluginManifest, PluginError> {
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<Arc<dyn Plugin>, 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<Arc<dyn Plugin>, 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<String, PluginConfig>,
) -> Result<Vec<(PathBuf, Arc<dyn Plugin>)>, 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<HookResult, PluginError> {
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<HookResult, PluginError> {
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<HookResult, PluginError> {
let start = std::time::Instant::now();
Ok(HookResult::success(
false,
start.elapsed().as_millis() as u64,
))
}
async fn shutdown(&mut self) -> Result<(), PluginError> {
Ok(())
}
}
+445
View File
@@ -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<String, Arc<RwLock<PluginInstance>>>,
/// 插件配置
configs: DashMap<String, PluginConfig>,
/// 管理器配置
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<Vec<String>, PluginError> {
if !self.config.enabled {
return Ok(Vec::new());
}
let configs: HashMap<String, PluginConfig> = 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<String, PluginError> {
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<PluginConfig> {
self.configs.get(name).map(|r| r.value().clone())
}
/// 获取插件信息
pub async fn get_info(&self, name: &str) -> Option<PluginInfo> {
let instance = self.plugins.get(name)?;
let inst = instance.read().await;
Some(inst.info())
}
/// 获取所有插件信息
pub async fn list(&self) -> Vec<PluginInfo> {
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<HookResult> {
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<HookResult> {
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<HookResult> {
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<String, PluginConfig>) {
for (name, config) in configs {
self.configs.insert(name, config);
}
}
/// 获取所有插件配置
pub fn get_all_configs(&self) -> HashMap<String, PluginConfig> {
self.configs
.iter()
.map(|r| (r.key().clone(), r.value().clone()))
.collect()
}
}
impl Default for PluginManager {
fn default() -> Self {
Self::with_defaults()
}
}
+21
View File
@@ -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;
+278
View File
@@ -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<Value = serde_json::Value> {
prop::collection::hash_map(
"[a-z]{1,10}",
prop_oneof![
Just(serde_json::Value::Null),
any::<bool>().prop_map(serde_json::Value::Bool),
any::<i64>().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<Value = PluginContext> {
(
"[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");
}
}
}
+428
View File
@@ -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<String>,
/// 主页/仓库地址
#[serde(default)]
pub homepage: Option<String>,
/// 许可证
#[serde(default)]
pub license: Option<String>,
/// 入口文件 (相对于插件目录)
#[serde(default = "default_entry")]
pub entry: String,
/// 插件类型
#[serde(default)]
pub plugin_type: PluginType,
/// 配置 schema (JSON Schema)
#[serde(default)]
pub config_schema: Option<serde_json::Value>,
/// 支持的钩子
#[serde(default)]
pub hooks: Vec<String>,
/// 最低 ProxyCast 版本要求
#[serde(default)]
pub min_proxycast_version: Option<String>,
}
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<String, serde_json::Value>,
/// 时间戳
pub timestamp: DateTime<Utc>,
}
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<String>,
/// 执行时间 (毫秒)
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<Utc>,
/// 最后执行时间
pub last_executed: Option<DateTime<Utc>>,
/// 执行次数
pub execution_count: u64,
/// 错误次数
pub error_count: u64,
/// 最后错误信息
pub last_error: Option<String>,
}
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<String>) {
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<String>,
/// 插件状态
pub status: PluginStatus,
/// 插件路径
pub path: PathBuf,
/// 支持的钩子
pub hooks: Vec<String>,
/// 配置 schema
pub config_schema: Option<serde_json::Value>,
/// 当前配置
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<HookResult, PluginError>;
/// 响应后钩子
async fn on_response(
&self,
ctx: &mut PluginContext,
response: &mut serde_json::Value,
) -> Result<HookResult, PluginError>;
/// 错误钩子
async fn on_error(
&self,
ctx: &mut PluginContext,
error: &str,
) -> Result<HookResult, PluginError>;
/// 关闭插件
async fn shutdown(&mut self) -> Result<(), PluginError>;
}
/// 插件实例包装器 - 用于管理插件生命周期
pub struct PluginInstance {
/// 插件实现
pub plugin: Arc<dyn Plugin>,
/// 插件路径
pub path: PathBuf,
/// 插件配置
pub config: PluginConfig,
/// 插件状态
pub state: PluginState,
}
impl PluginInstance {
/// 创建新的插件实例
pub fn new(plugin: Arc<dyn Plugin>, 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
}
}
+1 -1
View File
@@ -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"
}))
}
+1 -1
View File
@@ -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",
+373
View File
@@ -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<string, unknown>;
}
interface PluginInfo {
name: string;
version: string;
description: string;
author: string | null;
status: string;
path: string;
hooks: string[];
config_schema: Record<string, unknown> | null;
config: PluginConfig;
state: PluginState;
}
interface PluginServiceStatus {
enabled: boolean;
plugin_count: number;
plugins_dir: string;
}
export function PluginManager() {
const [status, setStatus] = useState<PluginServiceStatus | null>(null);
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
const fetchData = async () => {
try {
setLoading(true);
const [serviceStatus, pluginList] = await Promise.all([
invoke<PluginServiceStatus>("get_plugin_status"),
invoke<PluginInfo[]>("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 <CheckCircle className="h-4 w-4 text-green-500" />;
case "disabled":
return <PowerOff className="h-4 w-4 text-gray-400" />;
case "error":
return <AlertCircle className="h-4 w-4 text-red-500" />;
default:
return <Clock className="h-4 w-4 text-yellow-500" />;
}
};
const getStatusText = (status: string) => {
switch (status) {
case "enabled":
return "已启用";
case "disabled":
return "已禁用";
case "error":
return "错误";
case "loaded":
return "已加载";
default:
return status;
}
};
if (loading) {
return (
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" />
<span>加载中...</span>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* 状态概览 */}
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold flex items-center gap-2">
<Puzzle className="h-4 w-4" />
插件系统
</h3>
<div className="flex items-center gap-2">
<button
onClick={handleReloadPlugins}
className="p-1 hover:bg-muted rounded"
title="重新加载插件"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
</div>
{error && (
<div className="mb-4 p-2 bg-red-50 text-red-600 rounded text-sm">
{error}
</div>
)}
{status && (
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="text-center">
<div className="text-2xl font-bold">{status.plugin_count}</div>
<div className="text-xs text-muted-foreground">已加载插件</div>
</div>
<div className="text-center">
<div
className="text-sm font-mono truncate"
title={status.plugins_dir}
>
<FolderOpen className="h-4 w-4 inline mr-1" />
{status.plugins_dir.split("/").pop()}
</div>
<div className="text-xs text-muted-foreground">插件目录</div>
</div>
</div>
)}
</div>
{/* 插件列表 */}
<div className="rounded-lg border bg-card">
<div className="p-4 border-b">
<h4 className="font-semibold">已安装插件</h4>
</div>
{plugins.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Puzzle className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>暂无已安装的插件</p>
<p className="text-sm mt-1">将插件放入插件目录即可自动加载</p>
</div>
) : (
<div className="divide-y">
{plugins.map((plugin) => (
<PluginItem
key={plugin.name}
plugin={plugin}
expanded={expandedPlugin === plugin.name}
onToggleExpand={() =>
setExpandedPlugin(
expandedPlugin === plugin.name ? null : plugin.name,
)
}
onToggleEnabled={() =>
handleTogglePlugin(plugin.name, plugin.config.enabled)
}
onUnload={() => handleUnloadPlugin(plugin.name)}
getStatusIcon={getStatusIcon}
getStatusText={getStatusText}
/>
))}
</div>
)}
</div>
</div>
);
}
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 (
<div className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={onToggleExpand}
className="p-1 hover:bg-muted rounded"
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{plugin.name}</span>
<span className="text-xs text-muted-foreground">
v{plugin.version}
</span>
<span className="flex items-center gap-1 text-xs">
{getStatusIcon(plugin.status)}
{getStatusText(plugin.status)}
</span>
</div>
<div className="text-sm text-muted-foreground">
{plugin.description || "无描述"}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={onToggleEnabled}
className={`p-2 rounded ${
plugin.config.enabled
? "bg-green-100 text-green-600 hover:bg-green-200"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
title={plugin.config.enabled ? "禁用插件" : "启用插件"}
>
{plugin.config.enabled ? (
<Power className="h-4 w-4" />
) : (
<PowerOff className="h-4 w-4" />
)}
</button>
<button
onClick={onUnload}
className="p-2 rounded bg-red-100 text-red-600 hover:bg-red-200"
title="卸载插件"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{expanded && (
<div className="mt-4 pl-8 space-y-3">
{plugin.author && (
<div className="text-sm">
<span className="text-muted-foreground">作者:</span>
{plugin.author}
</div>
)}
<div className="text-sm">
<span className="text-muted-foreground">路径:</span>
<span className="font-mono text-xs">{plugin.path}</span>
</div>
{plugin.hooks.length > 0 && (
<div className="text-sm">
<span className="text-muted-foreground">钩子:</span>
<div className="flex gap-1 mt-1">
{plugin.hooks.map((hook) => (
<span
key={hook}
className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs"
>
{hook}
</span>
))}
</div>
</div>
)}
<div className="text-sm">
<span className="text-muted-foreground">统计:</span>
<div className="grid grid-cols-3 gap-2 mt-1">
<div className="text-center p-2 bg-muted rounded">
<div className="font-bold">{plugin.state.execution_count}</div>
<div className="text-xs text-muted-foreground">执行次数</div>
</div>
<div className="text-center p-2 bg-muted rounded">
<div className="font-bold text-red-500">
{plugin.state.error_count}
</div>
<div className="text-xs text-muted-foreground">错误次数</div>
</div>
<div className="text-center p-2 bg-muted rounded">
<div className="font-bold">{plugin.config.timeout_ms}ms</div>
<div className="text-xs text-muted-foreground">超时时间</div>
</div>
</div>
</div>
{plugin.state.last_error && (
<div className="text-sm p-2 bg-red-50 text-red-600 rounded">
<span className="font-medium">最后错误:</span>
{plugin.state.last_error}
</div>
)}
</div>
)}
</div>
);
}
export default PluginManager;
+2
View File
@@ -0,0 +1,2 @@
export { PluginManager } from "./PluginManager";
export { default } from "./PluginManager";