feat: Plugin UI 集成到 Extensions 页面

- 在 lib.rs 中注册所有 plugin 命令到 invoke_handler
- 在 ExtensionsPage 添加 Plugins 标签页
- 集成 PluginManager 组件显示插件列表
- 支持启用/禁用/重新加载/卸载插件操作
This commit is contained in:
coso
2025-12-20 14:53:24 +08:00
parent 20a291d7a0
commit 3374339bff
15 changed files with 386 additions and 21 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 653 B

After

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 727 B

After

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 715 B

After

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 715 B

After

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 B

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -736,6 +736,98 @@ pub fn add_claude_key_credential(
)
}
/// 添加 Codex OAuth 凭证(通过文件路径)
#[tauri::command]
pub fn add_codex_oauth_credential(
db: State<'_, DbConnection>,
pool_service: State<'_, ProviderPoolServiceState>,
creds_file_path: String,
name: Option<String>,
) -> Result<ProviderCredential, String> {
// 复制并重命名文件到应用存储目录
let stored_file_path = copy_and_rename_credential_file(&creds_file_path, "codex")?;
pool_service.0.add_credential(
&db,
"codex",
CredentialData::CodexOAuth {
creds_file_path: stored_file_path,
},
name,
Some(true),
None,
)
}
/// 添加 Claude OAuth 凭证(通过文件路径)
#[tauri::command]
pub fn add_claude_oauth_credential(
db: State<'_, DbConnection>,
pool_service: State<'_, ProviderPoolServiceState>,
creds_file_path: String,
name: Option<String>,
) -> Result<ProviderCredential, String> {
// 复制并重命名文件到应用存储目录
let stored_file_path = copy_and_rename_credential_file(&creds_file_path, "claude_oauth")?;
pool_service.0.add_credential(
&db,
"claude_oauth",
CredentialData::ClaudeOAuth {
creds_file_path: stored_file_path,
},
name,
Some(true),
None,
)
}
/// 添加 iFlow OAuth 凭证(通过文件路径)
#[tauri::command]
pub fn add_iflow_oauth_credential(
db: State<'_, DbConnection>,
pool_service: State<'_, ProviderPoolServiceState>,
creds_file_path: String,
name: Option<String>,
) -> Result<ProviderCredential, String> {
// 复制并重命名文件到应用存储目录
let stored_file_path = copy_and_rename_credential_file(&creds_file_path, "iflow")?;
pool_service.0.add_credential(
&db,
"iflow",
CredentialData::IFlowOAuth {
creds_file_path: stored_file_path,
},
name,
Some(true),
None,
)
}
/// 添加 iFlow Cookie 凭证(通过文件路径)
#[tauri::command]
pub fn add_iflow_cookie_credential(
db: State<'_, DbConnection>,
pool_service: State<'_, ProviderPoolServiceState>,
creds_file_path: String,
name: Option<String>,
) -> Result<ProviderCredential, String> {
// 复制并重命名文件到应用存储目录
let stored_file_path = copy_and_rename_credential_file(&creds_file_path, "iflow_cookie")?;
pool_service.0.add_credential(
&db,
"iflow",
CredentialData::IFlowCookie {
creds_file_path: stored_file_path,
},
name,
Some(true),
None,
)
}
/// 刷新凭证的 OAuth Token
#[tauri::command]
pub async fn refresh_pool_credential_token(
+21
View File
@@ -24,6 +24,7 @@ use std::sync::Arc;
use tauri::{Manager, Runtime};
use tokio::sync::RwLock;
use commands::plugin_cmd::PluginManagerState;
use commands::provider_pool_cmd::{CredentialSyncServiceState, ProviderPoolServiceState};
use commands::resilience_cmd::ResilienceConfigState;
use commands::router_cmd::RouterConfigState;
@@ -1373,6 +1374,10 @@ pub fn run() {
// Initialize ResilienceConfigState
let resilience_config_state = ResilienceConfigState::default();
// Initialize PluginManager
let plugin_manager = plugin::PluginManager::with_defaults();
let plugin_manager_state = PluginManagerState(Arc::new(RwLock::new(plugin_manager)));
// Initialize shared telemetry instances for both TelemetryState and RequestProcessor
// This allows the frontend monitoring page to display data recorded by the request processor
let shared_stats = Arc::new(parking_lot::RwLock::new(
@@ -1427,6 +1432,7 @@ pub fn run() {
.manage(router_config_state)
.manage(resilience_config_state)
.manage(telemetry_state)
.manage(plugin_manager_state)
.setup(move |app| {
// 初始化托盘管理器
// Requirements 1.4: 应用启动时显示停止状态图标
@@ -1666,6 +1672,10 @@ pub fn run() {
commands::provider_pool_cmd::add_antigravity_oauth_credential,
commands::provider_pool_cmd::add_openai_key_credential,
commands::provider_pool_cmd::add_claude_key_credential,
commands::provider_pool_cmd::add_codex_oauth_credential,
commands::provider_pool_cmd::add_claude_oauth_credential,
commands::provider_pool_cmd::add_iflow_oauth_credential,
commands::provider_pool_cmd::add_iflow_cookie_credential,
commands::provider_pool_cmd::refresh_pool_credential_token,
commands::provider_pool_cmd::get_pool_credential_oauth_status,
commands::provider_pool_cmd::debug_kiro_credentials,
@@ -1724,6 +1734,17 @@ pub fn run() {
commands::tray_cmd::get_tray_state,
commands::tray_cmd::refresh_tray_menu,
commands::tray_cmd::refresh_tray_with_stats,
// Plugin commands
commands::plugin_cmd::get_plugin_status,
commands::plugin_cmd::get_plugins,
commands::plugin_cmd::get_plugin_info,
commands::plugin_cmd::enable_plugin,
commands::plugin_cmd::disable_plugin,
commands::plugin_cmd::update_plugin_config,
commands::plugin_cmd::get_plugin_config,
commands::plugin_cmd::reload_plugins,
commands::plugin_cmd::unload_plugin,
commands::plugin_cmd::get_plugins_dir,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+183
View File
@@ -104,6 +104,16 @@ impl Default for IFlowCredentials {
}
}
/// iFlow API Key 数据
/// 与 CLIProxyAPI 的 iFlowKeyData 对齐
#[derive(Debug, Clone)]
pub struct IFlowKeyData {
pub api_key: String,
pub expire_time: String,
pub name: String,
pub has_expired: bool,
}
/// Enum representation of iFlow credentials for type-safe handling
#[derive(Debug, Clone)]
pub enum IFlowCredentialsType {
@@ -802,6 +812,179 @@ impl IFlowProvider {
}
}
/// 检查 Cookie API Key 是否需要刷新(距离过期 2 天内)
/// 与 CLIProxyAPI 的 ShouldRefreshAPIKey 对齐
pub fn should_refresh_api_key(&self) -> bool {
if let Some(expire_str) = &self.credentials.expire {
// 尝试解析 "2006-01-02 15:04" 格式(iFlow 返回的格式)
if let Ok(expire) = chrono::NaiveDateTime::parse_from_str(expire_str, "%Y-%m-%d %H:%M")
{
let expire_utc = expire.and_utc();
let now = chrono::Utc::now();
let two_days_from_now = now + chrono::Duration::hours(48);
return expire_utc < two_days_from_now;
}
// 尝试解析 RFC3339 格式
if let Ok(expire) = chrono::DateTime::parse_from_rfc3339(expire_str) {
let now = chrono::Utc::now();
let two_days_from_now = now + chrono::Duration::hours(48);
return expire < two_days_from_now;
}
}
// 如果没有过期时间,默认需要刷新
true
}
/// 通过 Cookie 刷新 API Key
/// 与 CLIProxyAPI 的 RefreshAPIKey 对齐
pub async fn refresh_api_key_with_cookie(
&mut self,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let cookie = self
.credentials
.cookies
.as_ref()
.ok_or_else(|| create_config_error("没有可用的 Cookie"))?
.clone();
let email = self
.credentials
.email
.as_ref()
.ok_or_else(|| create_config_error("没有可用的用户标识(email)"))?
.clone();
tracing::info!("[IFLOW] 正在通过 Cookie 刷新 API Key,用户: {}", email);
// 首先获取当前 API Key 信息(GET 请求)
let key_info = self.fetch_api_key_info(&cookie).await?;
// 然后刷新 API Key(POST 请求)
let refreshed_key = self.refresh_api_key(&cookie, &key_info.name).await?;
// 更新凭证
self.credentials.api_key = Some(refreshed_key.api_key.clone());
self.credentials.expire = Some(refreshed_key.expire_time.clone());
self.credentials.last_refresh = Some(chrono::Utc::now().to_rfc3339());
// 保存凭证
self.save_credentials().await?;
tracing::info!(
"[IFLOW] Cookie API Key 刷新成功,新过期时间: {}",
refreshed_key.expire_time
);
Ok(refreshed_key.api_key)
}
/// 获取 API Key 信息(GET 请求)
async fn fetch_api_key_info(
&self,
cookie: &str,
) -> Result<IFlowKeyData, Box<dyn Error + Send + Sync>> {
let resp = self
.client
.get(IFLOW_API_KEY_URL)
.header("Cookie", cookie)
.header("Accept", "application/json, text/plain, */*")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
.header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
.send()
.await
.map_err(|e| Box::new(ProviderError::from(e)) as Box<dyn Error + Send + Sync>)?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
tracing::error!("[IFLOW] 获取 API Key 信息失败: {} - {}", status, body);
return Err(create_auth_error(&format!(
"获取 API Key 信息失败: {} - {}",
status, body
)));
}
let data: serde_json::Value = resp
.json()
.await
.map_err(|e| Box::new(ProviderError::from(e)) as Box<dyn Error + Send + Sync>)?;
if !data["success"].as_bool().unwrap_or(false) {
let message = data["message"].as_str().unwrap_or("未知错误");
return Err(create_auth_error(&format!(
"获取 API Key 信息失败: {}",
message
)));
}
let key_data = &data["data"];
Ok(IFlowKeyData {
api_key: key_data["apiKey"]
.as_str()
.or_else(|| key_data["apiKeyMask"].as_str())
.unwrap_or("")
.to_string(),
expire_time: key_data["expireTime"].as_str().unwrap_or("").to_string(),
name: key_data["name"].as_str().unwrap_or("").to_string(),
has_expired: key_data["hasExpired"].as_bool().unwrap_or(false),
})
}
/// 刷新 API Key(POST 请求)
async fn refresh_api_key(
&self,
cookie: &str,
name: &str,
) -> Result<IFlowKeyData, Box<dyn Error + Send + Sync>> {
let body = serde_json::json!({ "name": name });
let resp = self
.client
.post(IFLOW_API_KEY_URL)
.header("Cookie", cookie)
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/plain, */*")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
.header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
.header("Origin", "https://platform.iflow.cn")
.header("Referer", "https://platform.iflow.cn/")
.json(&body)
.send()
.await
.map_err(|e| Box::new(ProviderError::from(e)) as Box<dyn Error + Send + Sync>)?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
tracing::error!("[IFLOW] 刷新 API Key 失败: {} - {}", status, body);
return Err(create_auth_error(&format!(
"刷新 API Key 失败: {} - {}",
status, body
)));
}
let data: serde_json::Value = resp
.json()
.await
.map_err(|e| Box::new(ProviderError::from(e)) as Box<dyn Error + Send + Sync>)?;
if !data["success"].as_bool().unwrap_or(false) {
let message = data["message"].as_str().unwrap_or("未知错误");
return Err(create_auth_error(&format!(
"刷新 API Key 失败: {}",
message
)));
}
let key_data = &data["data"];
Ok(IFlowKeyData {
api_key: key_data["apiKey"].as_str().unwrap_or("").to_string(),
expire_time: key_data["expireTime"].as_str().unwrap_or("").to_string(),
name: key_data["name"].as_str().unwrap_or("").to_string(),
has_expired: key_data["hasExpired"].as_bool().unwrap_or(false),
})
}
/// Refresh token with retry mechanism
pub async fn refresh_token_with_retry(
&mut self,
+54 -5
View File
@@ -518,6 +518,7 @@ impl TokenCacheService {
}
/// 刷新 iFlow Cookie Token
/// 与 CLIProxyAPI 的 refreshCookieBased 对齐
async fn refresh_iflow_cookie(&self, creds_path: &str) -> Result<CachedTokenInfo, String> {
use crate::providers::iflow::IFlowProvider;
@@ -527,8 +528,46 @@ impl TokenCacheService {
.await
.map_err(|e| format!("加载 iFlow Cookie 凭证失败: {}", e))?;
// iFlow Cookie 凭证使用 API Key,不需要刷新 OAuth Token
// 直接从凭证中获取 API Key
// 检查是否需要刷新 API Key(距离过期 2 天内)
if provider.should_refresh_api_key() {
tracing::info!("[IFLOW] Cookie API Key 需要刷新");
// 通过 Cookie 刷新 API Key
let api_key = provider
.refresh_api_key_with_cookie()
.await
.map_err(|e| format!("刷新 iFlow Cookie API Key 失败: {}", e))?;
// 解析新的过期时间
let expiry_time = provider
.credentials
.expire
.as_ref()
.and_then(|s| {
// 尝试解析 "2006-01-02 15:04" 格式
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M")
.ok()
.map(|dt| dt.and_utc())
.or_else(|| {
// 尝试解析 RFC3339 格式
chrono::DateTime::parse_from_rfc3339(s)
.ok()
.map(|dt| dt.with_timezone(&Utc))
})
})
.unwrap_or_else(|| Utc::now() + chrono::Duration::days(30));
return Ok(CachedTokenInfo {
access_token: Some(api_key),
refresh_token: None,
expiry_time: Some(expiry_time),
last_refresh: Some(Utc::now()),
refresh_error_count: 0,
last_refresh_error: None,
});
}
// 不需要刷新,直接返回现有的 API Key
let api_key = provider
.credentials
.api_key
@@ -540,9 +579,19 @@ impl TokenCacheService {
.credentials
.expire
.as_ref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|| Utc::now() + chrono::Duration::days(30)); // Cookie 通常有效期较长
.and_then(|s| {
// 尝试解析 "2006-01-02 15:04" 格式
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M")
.ok()
.map(|dt| dt.and_utc())
.or_else(|| {
// 尝试解析 RFC3339 格式
chrono::DateTime::parse_from_rfc3339(s)
.ok()
.map(|dt| dt.with_timezone(&Utc))
})
})
.unwrap_or_else(|| Utc::now() + chrono::Duration::days(30));
Ok(CachedTokenInfo {
access_token: Some(api_key),
+10 -6
View File
@@ -25,6 +25,7 @@ import { useAllOAuthCredentials } from "@/hooks/useOAuthCredentials";
import { StatsOverview } from "./monitoring/StatsOverview";
import { LogViewer } from "./monitoring/LogViewer";
import { TokenStats } from "./monitoring/TokenStats";
import { ProviderIcon } from "@/icons/providers";
import {
getDashboardData,
getRequestLogs,
@@ -376,12 +377,15 @@ function OverviewTab({
key={cred.provider}
className="flex items-center justify-between rounded-lg border bg-background p-3"
>
<div>
<div className="font-medium">
{getProviderName(cred.provider)}
</div>
<div className="text-xs text-muted-foreground">
{cred.has_access_token ? "Token 已加载" : "未配置"}
<div className="flex items-center gap-3">
<ProviderIcon providerType={cred.provider} size={20} />
<div>
<div className="font-medium">
{getProviderName(cred.provider)}
</div>
<div className="text-xs text-muted-foreground">
{cred.has_access_token ? "Token 已加载" : "未配置"}
</div>
</div>
</div>
<div
+5 -2
View File
@@ -1,16 +1,18 @@
import { useState } from "react";
import { Plug, MessageSquare, Boxes } from "lucide-react";
import { Plug, MessageSquare, Boxes, Puzzle } from "lucide-react";
import { cn } from "@/lib/utils";
import { McpPage } from "../mcp/McpPage";
import { PromptsPage } from "../prompts/PromptsPage";
import { SkillsPage } from "../skills/SkillsPage";
import { PluginManager } from "../plugins/PluginManager";
type Tab = "mcp" | "prompts" | "skills";
type Tab = "mcp" | "prompts" | "skills" | "plugins";
const tabs = [
{ id: "mcp" as Tab, label: "MCP", icon: Plug },
{ id: "prompts" as Tab, label: "Prompts", icon: MessageSquare },
{ id: "skills" as Tab, label: "Skills", icon: Boxes },
{ id: "plugins" as Tab, label: "Plugins", icon: Puzzle },
];
export function ExtensionsPage() {
@@ -49,6 +51,7 @@ export function ExtensionsPage() {
{activeTab === "mcp" && <McpPage hideHeader />}
{activeTab === "prompts" && <PromptsPage hideHeader />}
{activeTab === "skills" && <SkillsPage hideHeader />}
{activeTab === "plugins" && <PluginManager />}
</div>
</div>
);
+17 -5
View File
@@ -12,11 +12,22 @@ import { usePrompts } from "@/hooks/usePrompts";
import { cn } from "@/lib/utils";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { HelpTip } from "@/components/HelpTip";
import { ProviderIcon } from "@/icons/providers";
const apps: { id: AppType; label: string; filename: string }[] = [
{ id: "claude", label: "Claude Code", filename: "CLAUDE.md" },
{ id: "codex", label: "Codex", filename: "AGENTS.md" },
{ id: "gemini", label: "Gemini", filename: "GEMINI.md" },
const apps: {
id: AppType;
label: string;
filename: string;
iconType: string;
}[] = [
{
id: "claude",
label: "Claude Code",
filename: "CLAUDE.md",
iconType: "claude",
},
{ id: "codex", label: "Codex", filename: "AGENTS.md", iconType: "openai" },
{ id: "gemini", label: "Gemini", filename: "GEMINI.md", iconType: "gemini" },
];
/** Toggle Switch Component */
@@ -212,12 +223,13 @@ export function PromptsPage({ hideHeader = false }: PromptsPageProps) {
key={app.id}
onClick={() => handleAppChange(app.id)}
className={cn(
"px-4 py-2 rounded-t-lg text-sm font-medium transition-colors",
"flex items-center gap-2 px-4 py-2 rounded-t-lg text-sm font-medium transition-colors",
activeApp === app.id
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-muted-foreground",
)}
>
<ProviderIcon providerType={app.iconType} size={16} />
{app.label}
</button>
))}
@@ -17,7 +17,7 @@ const defaultCredsPath: Record<string, string> = {
antigravity: "~/.antigravity/oauth_creds.json",
codex: "~/.codex/oauth.json",
claude_oauth: "~/.claude/oauth.json",
iflow: "~/.iflow/oauth.json",
iflow: "~/.iflow/oauth_creds.json",
};
export function AddCredentialModal({
@@ -207,7 +207,8 @@ export function AddCredentialModal({
{providerType === "codex" && "默认路径: ~/.codex/oauth.json"}
{providerType === "claude_oauth" &&
"默认路径: ~/.claude/oauth.json"}
{providerType === "iflow" && "默认路径: ~/.iflow/oauth.json"}
{providerType === "iflow" &&
"默认路径: ~/.iflow/oauth_creds.json"}
</p>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@ const providerTypeToIcon: Record<string, string> = {
anthropic: "anthropic",
codex: "openai",
claude_oauth: "claude",
iflow: "openai",
iflow: "alibaba",
amp: "amp",
google: "google",
alibaba: "alibaba",