feat: v0.5.0 - 整合模型和日志页面到 API Server

将"模型"和"日志"页面迁移到 API Server 页面中作为标签页,使功能更加集中和关联。

主要变更:
- 创建 ModelsTab.tsx 和 LogsTab.tsx 组件
- 在 API Server 页面添加"可用模型"和"日志"两个新标签页
- 从导航栏移除独立的"模型"和"日志"入口
- 删除原始的 Models.tsx 和 Logs.tsx 文件
- 更新版本号到 0.5.0

优化:
- 导航栏更简洁,减少了两个独立入口
- API Server 页面成为管理代理服务的中心
- 所有功能逻辑保持不变,只是组织结构更合理

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
coso
2025-12-14 18:27:58 +08:00
co-authored by Claude Sonnet 4.5
parent cf0bf919c2
commit 42d140dfd5
70 changed files with 9606 additions and 925 deletions
+4 -1
View File
@@ -8,7 +8,10 @@
"Bash(cargo build:*)",
"Bash(npm run check:*)",
"Bash(npm run:*)",
"Bash(tree:*)"
"Bash(tree:*)",
"Bash(find:*)",
"Bash(cargo check:*)",
"Bash(rm:*)"
],
"deny": [],
"ask": []
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.4.3",
"version": "0.5.0",
"type": "module",
"scripts": {
"dev": "vite",
+57 -2
View File
@@ -121,6 +121,17 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-launch"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
dependencies = [
"dirs 4.0.0",
"thiserror 1.0.69",
"winreg 0.10.1",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -638,6 +649,15 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys 0.3.7",
]
[[package]]
name = "dirs"
version = "5.0.1"
@@ -656,6 +676,17 @@ dependencies = [
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users 0.4.6",
"winapi",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
@@ -777,7 +808,7 @@ dependencies = [
"rustc_version",
"toml 0.9.8",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@@ -2842,7 +2873,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.4.3"
version = "0.5.0"
dependencies = [
"async-stream",
"axum",
@@ -2857,6 +2888,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-autostart",
"tauri-plugin-shell",
"tokio",
"tracing",
@@ -3974,6 +4006,20 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-autostart"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70"
dependencies = [
"auto-launch",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.3.3"
@@ -5398,6 +5444,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.55.0"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "proxycast"
version = "0.4.3"
version = "0.5.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
@@ -15,6 +15,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-autostart = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
File diff suppressed because one or more lines are too long
+42
View File
@@ -386,6 +386,48 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`",
"type": "string",
"const": "autostart:default",
"markdownDescription": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`"
},
{
"description": "Enables the disable command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-disable",
"markdownDescription": "Enables the disable command without any pre-configured scope."
},
{
"description": "Enables the enable command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-enable",
"markdownDescription": "Enables the enable command without any pre-configured scope."
},
{
"description": "Enables the is_enabled command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-is-enabled",
"markdownDescription": "Enables the is_enabled command without any pre-configured scope."
},
{
"description": "Denies the disable command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-disable",
"markdownDescription": "Denies the disable command without any pre-configured scope."
},
{
"description": "Denies the enable command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-enable",
"markdownDescription": "Denies the enable command without any pre-configured scope."
},
{
"description": "Denies the is_enabled command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-is-enabled",
"markdownDescription": "Denies the is_enabled command without any pre-configured scope."
},
{
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string",
+42
View File
@@ -386,6 +386,48 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`",
"type": "string",
"const": "autostart:default",
"markdownDescription": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`"
},
{
"description": "Enables the disable command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-disable",
"markdownDescription": "Enables the disable command without any pre-configured scope."
},
{
"description": "Enables the enable command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-enable",
"markdownDescription": "Enables the enable command without any pre-configured scope."
},
{
"description": "Enables the is_enabled command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-is-enabled",
"markdownDescription": "Enables the is_enabled command without any pre-configured scope."
},
{
"description": "Denies the disable command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-disable",
"markdownDescription": "Denies the disable command without any pre-configured scope."
},
{
"description": "Denies the enable command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-enable",
"markdownDescription": "Denies the enable command without any pre-configured scope."
},
{
"description": "Denies the is_enabled command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-is-enabled",
"markdownDescription": "Denies the is_enabled command without any pre-configured scope."
},
{
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string",
+196
View File
@@ -0,0 +1,196 @@
use crate::models::AppType;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::AppHandle;
use tauri_plugin_autostart::ManagerExt;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigStatus {
pub exists: bool,
pub path: String,
pub has_env: bool,
}
/// Get the config directory path for an app type
fn get_config_dir(app_type: &AppType) -> Option<PathBuf> {
let home = dirs::home_dir()?;
match app_type {
AppType::Claude => Some(home.join(".claude")),
AppType::Codex => Some(home.join(".codex")),
AppType::Gemini => Some(home.join(".gemini")),
AppType::ProxyCast => dirs::config_dir().map(|d| d.join("proxycast")),
}
}
#[tauri::command]
pub fn get_config_status(app_type: String) -> Result<ConfigStatus, String> {
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
let config_dir = get_config_dir(&app).ok_or("Cannot determine config directory")?;
let main_config = match app {
AppType::Claude => config_dir.join("settings.json"),
AppType::Codex => config_dir.join("auth.json"),
AppType::Gemini => config_dir.join(".env"),
AppType::ProxyCast => config_dir.join("config.json"),
};
let has_env = match app {
AppType::Claude => {
config_dir.join("settings.json").exists()
&& std::fs::read_to_string(config_dir.join("settings.json"))
.map(|s| s.contains("env"))
.unwrap_or(false)
}
AppType::Codex => config_dir.join("auth.json").exists(),
AppType::Gemini => config_dir.join(".env").exists(),
AppType::ProxyCast => config_dir.join("config.json").exists(),
};
Ok(ConfigStatus {
exists: main_config.exists(),
path: config_dir.to_string_lossy().to_string(),
has_env,
})
}
#[tauri::command]
pub fn get_config_dir_path(app_type: String) -> Result<String, String> {
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
let config_dir = get_config_dir(&app).ok_or("Cannot determine config directory")?;
Ok(config_dir.to_string_lossy().to_string())
}
#[tauri::command]
pub async fn open_config_folder(handle: AppHandle, app_type: String) -> Result<bool, String> {
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
let config_dir = get_config_dir(&app).ok_or("Cannot determine config directory")?;
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(&config_dir)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.arg(&config_dir)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(&config_dir)
.spawn()
.map_err(|e| e.to_string())?;
}
Ok(true)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolVersion {
pub name: String,
pub version: Option<String>,
pub installed: bool,
}
#[tauri::command]
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
let mut versions = Vec::new();
// Check Claude Code version
let claude_version = std::process::Command::new("claude")
.arg("--version")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout).ok()
} else {
None
}
})
.map(|s| s.trim().to_string());
versions.push(ToolVersion {
name: "Claude Code".to_string(),
version: claude_version.clone(),
installed: claude_version.is_some(),
});
// Check Codex version
let codex_version = std::process::Command::new("codex")
.arg("--version")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout).ok()
} else {
None
}
})
.map(|s| s.trim().to_string());
versions.push(ToolVersion {
name: "Codex".to_string(),
version: codex_version.clone(),
installed: codex_version.is_some(),
});
// Check Gemini CLI version
let gemini_version = std::process::Command::new("gemini")
.arg("--version")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout).ok()
} else {
None
}
})
.map(|s| s.trim().to_string());
versions.push(ToolVersion {
name: "Gemini CLI".to_string(),
version: gemini_version.clone(),
installed: gemini_version.is_some(),
});
Ok(versions)
}
#[tauri::command]
pub async fn get_auto_launch_status(app: AppHandle) -> Result<bool, String> {
let autostart_manager = app.autolaunch();
autostart_manager
.is_enabled()
.map_err(|e| format!("Failed to get autostart status: {}", e))
}
#[tauri::command]
pub async fn set_auto_launch(app: AppHandle, enabled: bool) -> Result<bool, String> {
let autostart_manager = app.autolaunch();
if enabled {
autostart_manager
.enable()
.map_err(|e| format!("Failed to enable autostart: {}", e))?;
} else {
autostart_manager
.disable()
.map_err(|e| format!("Failed to disable autostart: {}", e))?;
}
Ok(enabled)
}
+44
View File
@@ -0,0 +1,44 @@
use crate::database::DbConnection;
use crate::models::McpServer;
use crate::services::mcp_service::McpService;
use tauri::State;
#[tauri::command]
pub fn get_mcp_servers(db: State<'_, DbConnection>) -> Result<Vec<McpServer>, String> {
McpService::get_all(&db)
}
#[tauri::command]
pub fn add_mcp_server(db: State<'_, DbConnection>, server: McpServer) -> Result<(), String> {
McpService::add(&db, server)
}
#[tauri::command]
pub fn update_mcp_server(db: State<'_, DbConnection>, server: McpServer) -> Result<(), String> {
McpService::update(&db, server)
}
#[tauri::command]
pub fn delete_mcp_server(db: State<'_, DbConnection>, id: String) -> Result<(), String> {
McpService::delete(&db, &id)
}
#[tauri::command]
pub fn toggle_mcp_server(
db: State<'_, DbConnection>,
id: String,
app_type: String,
enabled: bool,
) -> Result<(), String> {
McpService::toggle_enabled(&db, &id, &app_type, enabled)
}
#[tauri::command]
pub fn import_mcp_from_app(db: State<'_, DbConnection>, app_type: String) -> Result<usize, String> {
McpService::import_from_app(&db, &app_type)
}
#[tauri::command]
pub fn sync_all_mcp_to_live(db: State<'_, DbConnection>) -> Result<(), String> {
McpService::sync_all_to_live(&db)
}
+5
View File
@@ -0,0 +1,5 @@
pub mod config_cmd;
pub mod mcp_cmd;
pub mod oauth_cmd;
pub mod prompt_cmd;
pub mod switch_cmd;
+488
View File
@@ -0,0 +1,488 @@
//! Unified OAuth Commands for Kiro/Gemini/Qwen Providers
//!
//! This module consolidates the OAuth credential management commands
//! for all three OAuth providers into a single set of parameterized commands.
use crate::providers;
use crate::AppState;
use crate::LogState;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::State;
/// Supported OAuth provider types
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum OAuthProvider {
Kiro,
Gemini,
Qwen,
}
impl OAuthProvider {
pub fn from_str(s: &str) -> Result<Self, String> {
match s.to_lowercase().as_str() {
"kiro" => Ok(OAuthProvider::Kiro),
"gemini" => Ok(OAuthProvider::Gemini),
"qwen" => Ok(OAuthProvider::Qwen),
_ => Err(format!("Unknown provider: {}", s)),
}
}
pub fn display_name(&self) -> &'static str {
match self {
OAuthProvider::Kiro => "Kiro",
OAuthProvider::Gemini => "Gemini",
OAuthProvider::Qwen => "Qwen",
}
}
}
/// Unified credential status for all OAuth providers
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthCredentialStatus {
pub provider: String,
pub loaded: bool,
pub has_access_token: bool,
pub has_refresh_token: bool,
pub is_valid: bool,
pub expiry_info: Option<String>,
pub creds_path: String,
/// Provider-specific additional info
pub extra: serde_json::Value,
}
/// Environment variable representation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvVariable {
pub key: String,
pub value: String,
pub masked: String,
}
/// Result of credential file change check
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckResult {
pub changed: bool,
pub new_hash: String,
pub reloaded: bool,
}
fn mask_token(token: &str) -> String {
let chars: Vec<char> = token.chars().collect();
if chars.len() <= 12 {
"****".to_string()
} else {
let prefix: String = chars[..6].iter().collect();
let suffix: String = chars[chars.len() - 4..].iter().collect();
format!("{prefix}****{suffix}")
}
}
fn get_creds_path(provider: &OAuthProvider) -> PathBuf {
match provider {
OAuthProvider::Kiro => providers::kiro::KiroProvider::default_creds_path(),
OAuthProvider::Gemini => providers::gemini::GeminiProvider::default_creds_path(),
OAuthProvider::Qwen => providers::qwen::QwenProvider::default_creds_path(),
}
}
/// Get OAuth credentials status for a provider
#[tauri::command]
pub async fn get_oauth_credentials(
state: State<'_, AppState>,
provider: String,
) -> Result<OAuthCredentialStatus, String> {
let provider_type = OAuthProvider::from_str(&provider)?;
let s = state.read().await;
let path = get_creds_path(&provider_type);
match provider_type {
OAuthProvider::Kiro => {
let creds = &s.kiro_provider.credentials;
Ok(OAuthCredentialStatus {
provider: provider.clone(),
loaded: creds.access_token.is_some() || creds.refresh_token.is_some(),
has_access_token: creds.access_token.is_some(),
has_refresh_token: creds.refresh_token.is_some(),
is_valid: creds.access_token.is_some() && !s.kiro_provider.is_token_expiring_soon(),
expiry_info: creds.expires_at.clone(),
creds_path: path.to_string_lossy().to_string(),
extra: serde_json::json!({
"region": creds.region,
"auth_method": creds.auth_method,
}),
})
}
OAuthProvider::Gemini => {
let creds = &s.gemini_provider.credentials;
Ok(OAuthCredentialStatus {
provider: provider.clone(),
loaded: creds.access_token.is_some() || creds.refresh_token.is_some(),
has_access_token: creds.access_token.is_some(),
has_refresh_token: creds.refresh_token.is_some(),
is_valid: s.gemini_provider.is_token_valid(),
expiry_info: creds.expiry_date.map(|d| d.to_string()),
creds_path: path.to_string_lossy().to_string(),
extra: serde_json::json!({}),
})
}
OAuthProvider::Qwen => {
let creds = &s.qwen_provider.credentials;
Ok(OAuthCredentialStatus {
provider: provider.clone(),
loaded: creds.access_token.is_some() || creds.refresh_token.is_some(),
has_access_token: creds.access_token.is_some(),
has_refresh_token: creds.refresh_token.is_some(),
is_valid: s.qwen_provider.is_token_valid(),
expiry_info: creds.expiry_date.map(|d| d.to_string()),
creds_path: path.to_string_lossy().to_string(),
extra: serde_json::json!({
"resource_url": creds.resource_url,
}),
})
}
}
}
/// Reload OAuth credentials from file
#[tauri::command]
pub async fn reload_oauth_credentials(
state: State<'_, AppState>,
logs: State<'_, LogState>,
provider: String,
) -> Result<String, String> {
let provider_type = OAuthProvider::from_str(&provider)?;
let display_name = provider_type.display_name();
logs.write()
.await
.add("info", &format!("[{display_name}] 正在加载凭证..."));
let mut s = state.write().await;
let result = match provider_type {
OAuthProvider::Kiro => s.kiro_provider.load_credentials().await,
OAuthProvider::Gemini => s.gemini_provider.load_credentials().await,
OAuthProvider::Qwen => s.qwen_provider.load_credentials().await,
};
match result {
Ok(_) => {
logs.write()
.await
.add("info", &format!("[{display_name}] 凭证加载成功"));
Ok(format!("{display_name} credentials reloaded"))
}
Err(e) => {
logs.write()
.await
.add("error", &format!("[{display_name}] 凭证加载失败: {e}"));
Err(e.to_string())
}
}
}
/// Refresh OAuth token for a provider
#[tauri::command]
pub async fn refresh_oauth_token(
state: State<'_, AppState>,
logs: State<'_, LogState>,
provider: String,
) -> Result<String, String> {
let provider_type = OAuthProvider::from_str(&provider)?;
let display_name = provider_type.display_name();
logs.write()
.await
.add("info", &format!("[{display_name}] 正在刷新 Token..."));
let mut s = state.write().await;
let result = match provider_type {
OAuthProvider::Kiro => s.kiro_provider.refresh_token().await,
OAuthProvider::Gemini => s.gemini_provider.refresh_token().await,
OAuthProvider::Qwen => s.qwen_provider.refresh_token().await,
};
match result {
Ok(token) => {
logs.write()
.await
.add("info", &format!("[{display_name}] Token 刷新成功"));
Ok(token)
}
Err(e) => {
logs.write()
.await
.add("error", &format!("[{display_name}] Token 刷新失败: {e}"));
Err(e.to_string())
}
}
}
/// Get environment variables for a provider
#[tauri::command]
pub async fn get_oauth_env_variables(
state: State<'_, AppState>,
provider: String,
) -> Result<Vec<EnvVariable>, String> {
let provider_type = OAuthProvider::from_str(&provider)?;
let s = state.read().await;
let mut vars = Vec::new();
match provider_type {
OAuthProvider::Kiro => {
let creds = &s.kiro_provider.credentials;
if let Some(token) = &creds.access_token {
vars.push(EnvVariable {
key: "KIRO_ACCESS_TOKEN".to_string(),
value: token.clone(),
masked: mask_token(token),
});
}
if let Some(token) = &creds.refresh_token {
vars.push(EnvVariable {
key: "KIRO_REFRESH_TOKEN".to_string(),
value: token.clone(),
masked: mask_token(token),
});
}
if let Some(id) = &creds.client_id {
vars.push(EnvVariable {
key: "KIRO_CLIENT_ID".to_string(),
value: id.clone(),
masked: mask_token(id),
});
}
if let Some(secret) = &creds.client_secret {
vars.push(EnvVariable {
key: "KIRO_CLIENT_SECRET".to_string(),
value: secret.clone(),
masked: mask_token(secret),
});
}
if let Some(arn) = &creds.profile_arn {
vars.push(EnvVariable {
key: "KIRO_PROFILE_ARN".to_string(),
value: arn.clone(),
masked: arn.clone(),
});
}
if let Some(region) = &creds.region {
vars.push(EnvVariable {
key: "KIRO_REGION".to_string(),
value: region.clone(),
masked: region.clone(),
});
}
if let Some(method) = &creds.auth_method {
vars.push(EnvVariable {
key: "KIRO_AUTH_METHOD".to_string(),
value: method.clone(),
masked: method.clone(),
});
}
}
OAuthProvider::Gemini => {
let creds = &s.gemini_provider.credentials;
if let Some(token) = &creds.access_token {
vars.push(EnvVariable {
key: "GEMINI_ACCESS_TOKEN".to_string(),
value: token.clone(),
masked: mask_token(token),
});
}
if let Some(token) = &creds.refresh_token {
vars.push(EnvVariable {
key: "GEMINI_REFRESH_TOKEN".to_string(),
value: token.clone(),
masked: mask_token(token),
});
}
if let Some(expiry) = creds.expiry_date {
let expiry_str = expiry.to_string();
vars.push(EnvVariable {
key: "GEMINI_EXPIRY_DATE".to_string(),
value: expiry_str.clone(),
masked: expiry_str,
});
}
}
OAuthProvider::Qwen => {
let creds = &s.qwen_provider.credentials;
if let Some(token) = &creds.access_token {
vars.push(EnvVariable {
key: "QWEN_ACCESS_TOKEN".to_string(),
value: token.clone(),
masked: mask_token(token),
});
}
if let Some(token) = &creds.refresh_token {
vars.push(EnvVariable {
key: "QWEN_REFRESH_TOKEN".to_string(),
value: token.clone(),
masked: mask_token(token),
});
}
if let Some(url) = &creds.resource_url {
vars.push(EnvVariable {
key: "QWEN_RESOURCE_URL".to_string(),
value: url.clone(),
masked: url.clone(),
});
}
if let Some(expiry) = creds.expiry_date {
let expiry_str = expiry.to_string();
vars.push(EnvVariable {
key: "QWEN_EXPIRY_DATE".to_string(),
value: expiry_str.clone(),
masked: expiry_str,
});
}
}
}
Ok(vars)
}
/// Get token file hash for a provider
#[tauri::command]
pub async fn get_oauth_token_file_hash(provider: String) -> Result<String, String> {
let provider_type = OAuthProvider::from_str(&provider)?;
let path = get_creds_path(&provider_type);
if !tokio::fs::try_exists(&path).await.unwrap_or(false) {
return Ok("".to_string());
}
let content = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let hash = format!("{:x}", md5::compute(&content));
Ok(hash)
}
/// Check credential file changes and auto-reload
#[tauri::command]
pub async fn check_and_reload_oauth_credentials(
state: State<'_, AppState>,
logs: State<'_, LogState>,
provider: String,
last_hash: String,
) -> Result<CheckResult, String> {
let provider_type = OAuthProvider::from_str(&provider)?;
let display_name = provider_type.display_name();
let path = get_creds_path(&provider_type);
if !tokio::fs::try_exists(&path).await.unwrap_or(false) {
return Ok(CheckResult {
changed: false,
new_hash: "".to_string(),
reloaded: false,
});
}
let content = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let new_hash = format!("{:x}", md5::compute(&content));
if !last_hash.is_empty() && new_hash != last_hash {
logs.write().await.add(
"info",
&format!("[{display_name}][自动检测] 凭证文件已变化,正在重新加载..."),
);
let mut s = state.write().await;
let result = match provider_type {
OAuthProvider::Kiro => s.kiro_provider.load_credentials().await,
OAuthProvider::Gemini => s.gemini_provider.load_credentials().await,
OAuthProvider::Qwen => s.qwen_provider.load_credentials().await,
};
match result {
Ok(_) => {
logs.write()
.await
.add("info", &format!("[{display_name}][自动检测] 凭证重新加载成功"));
Ok(CheckResult {
changed: true,
new_hash,
reloaded: true,
})
}
Err(e) => {
logs.write().await.add(
"error",
&format!("[{display_name}][自动检测] 凭证重新加载失败: {e}"),
);
Ok(CheckResult {
changed: true,
new_hash,
reloaded: false,
})
}
}
} else {
Ok(CheckResult {
changed: false,
new_hash,
reloaded: false,
})
}
}
/// Get all OAuth providers status at once
#[tauri::command]
pub async fn get_all_oauth_credentials(
state: State<'_, AppState>,
) -> Result<Vec<OAuthCredentialStatus>, String> {
let s = state.read().await;
let mut results = Vec::new();
// Kiro
let kiro_creds = &s.kiro_provider.credentials;
let kiro_path = providers::kiro::KiroProvider::default_creds_path();
results.push(OAuthCredentialStatus {
provider: "kiro".to_string(),
loaded: kiro_creds.access_token.is_some() || kiro_creds.refresh_token.is_some(),
has_access_token: kiro_creds.access_token.is_some(),
has_refresh_token: kiro_creds.refresh_token.is_some(),
is_valid: kiro_creds.access_token.is_some() && !s.kiro_provider.is_token_expiring_soon(),
expiry_info: kiro_creds.expires_at.clone(),
creds_path: kiro_path.to_string_lossy().to_string(),
extra: serde_json::json!({
"region": kiro_creds.region,
"auth_method": kiro_creds.auth_method,
}),
});
// Gemini
let gemini_creds = &s.gemini_provider.credentials;
let gemini_path = providers::gemini::GeminiProvider::default_creds_path();
results.push(OAuthCredentialStatus {
provider: "gemini".to_string(),
loaded: gemini_creds.access_token.is_some() || gemini_creds.refresh_token.is_some(),
has_access_token: gemini_creds.access_token.is_some(),
has_refresh_token: gemini_creds.refresh_token.is_some(),
is_valid: s.gemini_provider.is_token_valid(),
expiry_info: gemini_creds.expiry_date.map(|d| d.to_string()),
creds_path: gemini_path.to_string_lossy().to_string(),
extra: serde_json::json!({}),
});
// Qwen
let qwen_creds = &s.qwen_provider.credentials;
let qwen_path = providers::qwen::QwenProvider::default_creds_path();
results.push(OAuthCredentialStatus {
provider: "qwen".to_string(),
loaded: qwen_creds.access_token.is_some() || qwen_creds.refresh_token.is_some(),
has_access_token: qwen_creds.access_token.is_some(),
has_refresh_token: qwen_creds.refresh_token.is_some(),
is_valid: s.qwen_provider.is_token_valid(),
expiry_info: qwen_creds.expiry_date.map(|d| d.to_string()),
creds_path: qwen_path.to_string_lossy().to_string(),
extra: serde_json::json!({
"resource_url": qwen_creds.resource_url,
}),
});
Ok(results)
}
+92
View File
@@ -0,0 +1,92 @@
use crate::database::DbConnection;
use crate::models::Prompt;
use crate::services::prompt_service::PromptService;
use std::collections::HashMap;
use tauri::State;
/// Get all prompts for an app type (as HashMap for frontend)
#[tauri::command]
pub fn get_prompts(
db: State<'_, DbConnection>,
app: String,
) -> Result<HashMap<String, Prompt>, String> {
PromptService::get_all_map(&db, &app)
}
/// Upsert a prompt (insert or update)
#[tauri::command]
pub fn upsert_prompt(
db: State<'_, DbConnection>,
app: String,
id: String,
prompt: Prompt,
) -> Result<(), String> {
// Ensure the prompt has the correct app_type and id
let mut prompt = prompt;
prompt.app_type = app.clone();
prompt.id = id;
PromptService::upsert(&db, &app, prompt)
}
/// Add a new prompt
#[tauri::command]
pub fn add_prompt(db: State<'_, DbConnection>, prompt: Prompt) -> Result<(), String> {
PromptService::add(&db, prompt)
}
/// Update an existing prompt
#[tauri::command]
pub fn update_prompt(db: State<'_, DbConnection>, prompt: Prompt) -> Result<(), String> {
PromptService::update(&db, prompt)
}
/// Delete a prompt
#[tauri::command]
pub fn delete_prompt(
db: State<'_, DbConnection>,
app: String,
id: String,
) -> Result<(), String> {
PromptService::delete(&db, &app, &id)
}
/// Enable a prompt and sync to live file
#[tauri::command]
pub fn enable_prompt(
db: State<'_, DbConnection>,
app: String,
id: String,
) -> Result<(), String> {
PromptService::enable(&db, &app, &id)
}
/// Import prompt from live file
#[tauri::command]
pub fn import_prompt_from_file(
db: State<'_, DbConnection>,
app: String,
) -> Result<String, String> {
PromptService::import_from_file(&db, &app)
}
/// Get current live prompt file content
#[tauri::command]
pub fn get_current_prompt_file_content(app: String) -> Result<Option<String>, String> {
PromptService::get_live_content(&app)
}
/// Auto-import prompt from live file on first launch (if no prompts exist)
#[tauri::command]
pub fn auto_import_prompt(db: State<'_, DbConnection>, app: String) -> Result<usize, String> {
PromptService::import_on_first_launch(&db, &app)
}
// Legacy command for compatibility
#[tauri::command]
pub fn switch_prompt(
db: State<'_, DbConnection>,
app_type: String,
id: String,
) -> Result<(), String> {
PromptService::enable(&db, &app_type, &id)
}
+65
View File
@@ -0,0 +1,65 @@
use crate::database::DbConnection;
use crate::models::Provider;
use crate::services::switch::SwitchService;
use serde_json::Value;
use tauri::State;
#[tauri::command]
pub fn get_switch_providers(
db: State<'_, DbConnection>,
app_type: String,
) -> Result<Vec<Provider>, String> {
SwitchService::get_providers(&db, &app_type)
}
#[tauri::command]
pub fn get_current_switch_provider(
db: State<'_, DbConnection>,
app_type: String,
) -> Result<Option<Provider>, String> {
SwitchService::get_current_provider(&db, &app_type)
}
#[tauri::command]
pub fn add_switch_provider(db: State<'_, DbConnection>, provider: Provider) -> Result<(), String> {
SwitchService::add_provider(&db, provider)
}
#[tauri::command]
pub fn update_switch_provider(
db: State<'_, DbConnection>,
provider: Provider,
) -> Result<(), String> {
SwitchService::update_provider(&db, provider)
}
#[tauri::command]
pub fn delete_switch_provider(
db: State<'_, DbConnection>,
app_type: String,
id: String,
) -> Result<(), String> {
SwitchService::delete_provider(&db, &app_type, &id)
}
#[tauri::command]
pub fn switch_provider(
db: State<'_, DbConnection>,
app_type: String,
id: String,
) -> Result<(), String> {
SwitchService::switch_provider(&db, &app_type, &id)
}
#[tauri::command]
pub fn import_default_config(
db: State<'_, DbConnection>,
app_type: String,
) -> Result<bool, String> {
SwitchService::import_default_config(&db, &app_type)
}
#[tauri::command]
pub fn read_live_provider_settings(app_type: String) -> Result<Value, String> {
SwitchService::read_live_settings(&app_type)
}
+103 -2
View File
@@ -1,2 +1,103 @@
// MCP Servers DAO - 数据访问对象
// TODO: 实现 mcp_servers 表的 CRUD 操作
use crate::models::McpServer;
use rusqlite::{params, Connection};
use serde_json::Value;
pub struct McpDao;
impl McpDao {
pub fn get_all(conn: &Connection) -> Result<Vec<McpServer>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT id, name, server_config, description, enabled_proxycast,
enabled_claude, enabled_codex, enabled_gemini, created_at
FROM mcp_servers ORDER BY created_at",
)?;
let servers = stmt.query_map([], |row| {
let config_str: String = row.get(2)?;
let server_config: Value = serde_json::from_str(&config_str).unwrap_or(Value::Null);
Ok(McpServer {
id: row.get(0)?,
name: row.get(1)?,
server_config,
description: row.get(3)?,
enabled_proxycast: row.get::<_, i32>(4)? == 1,
enabled_claude: row.get::<_, i32>(5)? == 1,
enabled_codex: row.get::<_, i32>(6)? == 1,
enabled_gemini: row.get::<_, i32>(7)? == 1,
created_at: row.get(8)?,
})
})?;
servers.collect()
}
pub fn insert(conn: &Connection, server: &McpServer) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO mcp_servers (id, name, server_config, description,
enabled_proxycast, enabled_claude, enabled_codex,
enabled_gemini, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
server.id,
server.name,
serde_json::to_string(&server.server_config).unwrap_or_default(),
server.description,
if server.enabled_proxycast { 1 } else { 0 },
if server.enabled_claude { 1 } else { 0 },
if server.enabled_codex { 1 } else { 0 },
if server.enabled_gemini { 1 } else { 0 },
server.created_at,
],
)?;
Ok(())
}
pub fn update(conn: &Connection, server: &McpServer) -> Result<(), rusqlite::Error> {
conn.execute(
"UPDATE mcp_servers SET name = ?1, server_config = ?2, description = ?3,
enabled_proxycast = ?4, enabled_claude = ?5, enabled_codex = ?6, enabled_gemini = ?7
WHERE id = ?8",
params![
server.name,
serde_json::to_string(&server.server_config).unwrap_or_default(),
server.description,
if server.enabled_proxycast { 1 } else { 0 },
if server.enabled_claude { 1 } else { 0 },
if server.enabled_codex { 1 } else { 0 },
if server.enabled_gemini { 1 } else { 0 },
server.id,
],
)?;
Ok(())
}
pub fn delete(conn: &Connection, id: &str) -> Result<(), rusqlite::Error> {
conn.execute("DELETE FROM mcp_servers WHERE id = ?", [id])?;
Ok(())
}
pub fn toggle_enabled(
conn: &Connection,
id: &str,
app_type: &str,
enabled: bool,
) -> Result<(), rusqlite::Error> {
let column = match app_type {
"proxycast" => "enabled_proxycast",
"claude" => "enabled_claude",
"codex" => "enabled_codex",
"gemini" => "enabled_gemini",
_ => {
return Err(rusqlite::Error::InvalidParameterName(format!(
"Invalid app_type: {}",
app_type
)))
}
};
let sql = format!("UPDATE mcp_servers SET {} = ? WHERE id = ?", column);
conn.execute(&sql, params![if enabled { 1 } else { 0 }, id])?;
Ok(())
}
}
+4
View File
@@ -1,3 +1,7 @@
pub mod mcp;
pub mod prompts;
pub mod providers;
pub use mcp::McpDao;
pub use prompts::PromptDao;
pub use providers::ProviderDao;
+190 -2
View File
@@ -1,2 +1,190 @@
// Prompts DAO - 数据访问对象
// TODO: 实现 prompts 表的 CRUD 操作
use crate::models::Prompt;
use rusqlite::{params, Connection};
use std::collections::HashMap;
pub struct PromptDao;
impl PromptDao {
/// Get all prompts for an app type
pub fn get_all(conn: &Connection, app_type: &str) -> Result<Vec<Prompt>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT id, app_type, name, content, description, enabled, created_at, updated_at
FROM prompts WHERE app_type = ? ORDER BY created_at",
)?;
let prompts = stmt.query_map([app_type], |row| {
Ok(Prompt {
id: row.get(0)?,
app_type: row.get(1)?,
name: row.get(2)?,
content: row.get(3)?,
description: row.get(4)?,
enabled: row.get::<_, i32>(5)? == 1,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
})?;
prompts.collect()
}
/// Get all prompts as a HashMap (id -> Prompt)
pub fn get_all_map(
conn: &Connection,
app_type: &str,
) -> Result<HashMap<String, Prompt>, rusqlite::Error> {
let prompts = Self::get_all(conn, app_type)?;
Ok(prompts.into_iter().map(|p| (p.id.clone(), p)).collect())
}
/// Get a single prompt by id
pub fn get_by_id(
conn: &Connection,
app_type: &str,
id: &str,
) -> Result<Option<Prompt>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT id, app_type, name, content, description, enabled, created_at, updated_at
FROM prompts WHERE app_type = ? AND id = ?",
)?;
let mut rows = stmt.query([app_type, id])?;
if let Some(row) = rows.next()? {
Ok(Some(Prompt {
id: row.get(0)?,
app_type: row.get(1)?,
name: row.get(2)?,
content: row.get(3)?,
description: row.get(4)?,
enabled: row.get::<_, i32>(5)? == 1,
created_at: row.get(6)?,
updated_at: row.get(7)?,
}))
} else {
Ok(None)
}
}
/// Get the currently enabled prompt
pub fn get_enabled(
conn: &Connection,
app_type: &str,
) -> Result<Option<Prompt>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT id, app_type, name, content, description, enabled, created_at, updated_at
FROM prompts WHERE app_type = ? AND enabled = 1",
)?;
let mut rows = stmt.query([app_type])?;
if let Some(row) = rows.next()? {
Ok(Some(Prompt {
id: row.get(0)?,
app_type: row.get(1)?,
name: row.get(2)?,
content: row.get(3)?,
description: row.get(4)?,
enabled: row.get::<_, i32>(5)? == 1,
created_at: row.get(6)?,
updated_at: row.get(7)?,
}))
} else {
Ok(None)
}
}
/// Insert or update a prompt (upsert)
pub fn upsert(conn: &Connection, prompt: &Prompt) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO prompts (id, app_type, name, content, description, enabled, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(id, app_type) DO UPDATE SET
name = excluded.name,
content = excluded.content,
description = excluded.description,
enabled = excluded.enabled,
updated_at = excluded.updated_at",
params![
prompt.id,
prompt.app_type,
prompt.name,
prompt.content,
prompt.description,
if prompt.enabled { 1 } else { 0 },
prompt.created_at,
prompt.updated_at,
],
)?;
Ok(())
}
/// Insert a new prompt
pub fn insert(conn: &Connection, prompt: &Prompt) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO prompts (id, app_type, name, content, description, enabled, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
prompt.id,
prompt.app_type,
prompt.name,
prompt.content,
prompt.description,
if prompt.enabled { 1 } else { 0 },
prompt.created_at,
prompt.updated_at,
],
)?;
Ok(())
}
/// Update an existing prompt
pub fn update(conn: &Connection, prompt: &Prompt) -> Result<(), rusqlite::Error> {
conn.execute(
"UPDATE prompts SET name = ?1, content = ?2, description = ?3, enabled = ?4, updated_at = ?5
WHERE id = ?6 AND app_type = ?7",
params![
prompt.name,
prompt.content,
prompt.description,
if prompt.enabled { 1 } else { 0 },
prompt.updated_at,
prompt.id,
prompt.app_type,
],
)?;
Ok(())
}
/// Delete a prompt
pub fn delete(conn: &Connection, app_type: &str, id: &str) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM prompts WHERE app_type = ? AND id = ?",
[app_type, id],
)?;
Ok(())
}
/// Disable all prompts for an app type
pub fn disable_all(conn: &Connection, app_type: &str) -> Result<(), rusqlite::Error> {
conn.execute("UPDATE prompts SET enabled = 0 WHERE app_type = ?", [app_type])?;
Ok(())
}
/// Enable a specific prompt (and disable all others)
pub fn enable(conn: &Connection, app_type: &str, id: &str) -> Result<(), rusqlite::Error> {
// First disable all
Self::disable_all(conn, app_type)?;
// Then enable the specific one
conn.execute(
"UPDATE prompts SET enabled = 1 WHERE app_type = ? AND id = ?",
[app_type, id],
)?;
Ok(())
}
// Legacy method for compatibility
pub fn set_current(conn: &Connection, app_type: &str, id: &str) -> Result<(), rusqlite::Error> {
Self::enable(conn, app_type, id)
}
}
+173 -2
View File
@@ -1,2 +1,173 @@
// Providers DAO - 数据访问对象
// TODO: 实现 providers 表的 CRUD 操作
use crate::models::Provider;
use rusqlite::{params, Connection};
use serde_json::Value;
pub struct ProviderDao;
impl ProviderDao {
pub fn get_all(conn: &Connection, app_type: &str) -> Result<Vec<Provider>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT id, app_type, name, settings_config, category, icon, icon_color,
notes, created_at, sort_index, is_current
FROM providers WHERE app_type = ? ORDER BY sort_index, created_at",
)?;
let providers = stmt.query_map([app_type], |row| {
let settings_str: String = row.get(3)?;
let settings_config: Value = serde_json::from_str(&settings_str).unwrap_or(Value::Null);
Ok(Provider {
id: row.get(0)?,
app_type: row.get(1)?,
name: row.get(2)?,
settings_config,
category: row.get(4)?,
icon: row.get(5)?,
icon_color: row.get(6)?,
notes: row.get(7)?,
created_at: row.get(8)?,
sort_index: row.get(9)?,
is_current: row.get::<_, i32>(10)? == 1,
})
})?;
providers.collect()
}
pub fn get_by_id(
conn: &Connection,
app_type: &str,
id: &str,
) -> Result<Option<Provider>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT id, app_type, name, settings_config, category, icon, icon_color,
notes, created_at, sort_index, is_current
FROM providers WHERE app_type = ? AND id = ?",
)?;
let result = stmt.query_row([app_type, id], |row| {
let settings_str: String = row.get(3)?;
let settings_config: Value = serde_json::from_str(&settings_str).unwrap_or(Value::Null);
Ok(Provider {
id: row.get(0)?,
app_type: row.get(1)?,
name: row.get(2)?,
settings_config,
category: row.get(4)?,
icon: row.get(5)?,
icon_color: row.get(6)?,
notes: row.get(7)?,
created_at: row.get(8)?,
sort_index: row.get(9)?,
is_current: row.get::<_, i32>(10)? == 1,
})
});
match result {
Ok(provider) => Ok(Some(provider)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
pub fn insert(conn: &Connection, provider: &Provider) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO providers (id, app_type, name, settings_config, category, icon,
icon_color, notes, created_at, sort_index, is_current)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
provider.id,
provider.app_type,
provider.name,
serde_json::to_string(&provider.settings_config).unwrap_or_default(),
provider.category,
provider.icon,
provider.icon_color,
provider.notes,
provider.created_at,
provider.sort_index,
if provider.is_current { 1 } else { 0 },
],
)?;
Ok(())
}
pub fn update(conn: &Connection, provider: &Provider) -> Result<(), rusqlite::Error> {
conn.execute(
"UPDATE providers SET name = ?1, settings_config = ?2, category = ?3,
icon = ?4, icon_color = ?5, notes = ?6, sort_index = ?7
WHERE id = ?8 AND app_type = ?9",
params![
provider.name,
serde_json::to_string(&provider.settings_config).unwrap_or_default(),
provider.category,
provider.icon,
provider.icon_color,
provider.notes,
provider.sort_index,
provider.id,
provider.app_type,
],
)?;
Ok(())
}
pub fn delete(conn: &Connection, app_type: &str, id: &str) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM providers WHERE app_type = ? AND id = ?",
[app_type, id],
)?;
Ok(())
}
pub fn set_current(conn: &Connection, app_type: &str, id: &str) -> Result<(), rusqlite::Error> {
// 先清除所有 is_current
conn.execute(
"UPDATE providers SET is_current = 0 WHERE app_type = ?",
[app_type],
)?;
// 设置新的 current
conn.execute(
"UPDATE providers SET is_current = 1 WHERE app_type = ? AND id = ?",
[app_type, id],
)?;
Ok(())
}
pub fn get_current(
conn: &Connection,
app_type: &str,
) -> Result<Option<Provider>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT id, app_type, name, settings_config, category, icon, icon_color,
notes, created_at, sort_index, is_current
FROM providers WHERE app_type = ? AND is_current = 1",
)?;
let result = stmt.query_row([app_type], |row| {
let settings_str: String = row.get(3)?;
let settings_config: Value = serde_json::from_str(&settings_str).unwrap_or(Value::Null);
Ok(Provider {
id: row.get(0)?,
app_type: row.get(1)?,
name: row.get(2)?,
settings_config,
category: row.get(4)?,
icon: row.get(5)?,
icon_color: row.get(6)?,
notes: row.get(7)?,
created_at: row.get(8)?,
sort_index: row.get(9)?,
is_current: true,
})
});
match result {
Ok(provider) => Ok(Some(provider)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
}
+8 -1
View File
@@ -44,13 +44,20 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
name TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT,
is_current INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 0,
created_at INTEGER,
updated_at INTEGER,
PRIMARY KEY (id, app_type)
)",
[],
)?;
// Migration: rename is_current to enabled if old column exists
let _ = conn.execute("ALTER TABLE prompts RENAME COLUMN is_current TO enabled", []);
// Migration: add updated_at column if it doesn't exist
let _ = conn.execute("ALTER TABLE prompts ADD COLUMN updated_at INTEGER", []);
// 设置表
conn.execute(
"CREATE TABLE IF NOT EXISTS settings (
+56 -3
View File
@@ -1,3 +1,4 @@
mod commands;
mod config;
mod converter;
mod database;
@@ -5,6 +6,7 @@ mod logger;
mod models;
mod providers;
mod server;
mod services;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
@@ -1257,14 +1259,22 @@ pub fn run() {
let state: AppState = Arc::new(RwLock::new(server::ServerState::new(config)));
let logs: LogState = Arc::new(RwLock::new(logger::LogStore::new()));
// Initialize database for Switch functionality
let db = database::init_database().expect("Failed to initialize database");
// Clone for setup hook
let state_clone = state.clone();
let logs_clone = logs.clone();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec!["--minimized"]),
))
.manage(state)
.manage(logs)
.manage(db)
.setup(move |_app| {
// 自动启动服务器
let state = state_clone.clone();
@@ -1313,21 +1323,29 @@ pub fn run() {
save_config,
get_default_provider,
set_default_provider,
// Kiro commands
// Unified OAuth commands (new)
commands::oauth_cmd::get_oauth_credentials,
commands::oauth_cmd::reload_oauth_credentials,
commands::oauth_cmd::refresh_oauth_token,
commands::oauth_cmd::get_oauth_env_variables,
commands::oauth_cmd::get_oauth_token_file_hash,
commands::oauth_cmd::check_and_reload_oauth_credentials,
commands::oauth_cmd::get_all_oauth_credentials,
// Legacy Kiro commands (deprecated, kept for compatibility)
refresh_kiro_token,
reload_credentials,
get_kiro_credentials,
get_env_variables,
get_token_file_hash,
check_and_reload_credentials,
// Gemini commands
// Legacy Gemini commands (deprecated, kept for compatibility)
get_gemini_credentials,
reload_gemini_credentials,
refresh_gemini_token,
get_gemini_env_variables,
get_gemini_token_file_hash,
check_and_reload_gemini_credentials,
// Qwen commands
// Legacy Qwen commands (deprecated, kept for compatibility)
get_qwen_credentials,
reload_qwen_credentials,
refresh_qwen_token,
@@ -1347,6 +1365,41 @@ pub fn run() {
get_available_models,
// API Compatibility
check_api_compatibility,
// Switch commands
commands::switch_cmd::get_switch_providers,
commands::switch_cmd::get_current_switch_provider,
commands::switch_cmd::add_switch_provider,
commands::switch_cmd::update_switch_provider,
commands::switch_cmd::delete_switch_provider,
commands::switch_cmd::switch_provider,
commands::switch_cmd::import_default_config,
commands::switch_cmd::read_live_provider_settings,
// Config commands
commands::config_cmd::get_config_status,
commands::config_cmd::get_config_dir_path,
commands::config_cmd::open_config_folder,
commands::config_cmd::get_tool_versions,
commands::config_cmd::get_auto_launch_status,
commands::config_cmd::set_auto_launch,
// MCP commands
commands::mcp_cmd::get_mcp_servers,
commands::mcp_cmd::add_mcp_server,
commands::mcp_cmd::update_mcp_server,
commands::mcp_cmd::delete_mcp_server,
commands::mcp_cmd::toggle_mcp_server,
commands::mcp_cmd::import_mcp_from_app,
commands::mcp_cmd::sync_all_mcp_to_live,
// Prompt commands
commands::prompt_cmd::get_prompts,
commands::prompt_cmd::upsert_prompt,
commands::prompt_cmd::add_prompt,
commands::prompt_cmd::update_prompt,
commands::prompt_cmd::delete_prompt,
commands::prompt_cmd::enable_prompt,
commands::prompt_cmd::import_prompt_from_file,
commands::prompt_cmd::get_current_prompt_file_content,
commands::prompt_cmd::auto_import_prompt,
commands::prompt_cmd::switch_prompt,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+9 -4
View File
@@ -8,22 +8,27 @@ pub struct Prompt {
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Whether this prompt is currently enabled (synced to live file)
#[serde(default)]
pub is_current: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: bool,
#[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")]
pub created_at: Option<i64>,
#[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
pub updated_at: Option<i64>,
}
impl Prompt {
pub fn new(id: String, app_type: String, name: String, content: String) -> Self {
let now = chrono::Utc::now().timestamp();
Self {
id,
app_type,
name,
content,
description: None,
is_current: false,
created_at: Some(chrono::Utc::now().timestamp()),
enabled: false,
created_at: Some(now),
updated_at: Some(now),
}
}
}
+54 -20
View File
@@ -356,19 +356,34 @@ async fn chat_completions(
)
.into_response(),
}
} else if status.as_u16() == 403 {
// Token 过期,尝试刷新
} else if status.as_u16() == 403 || status.as_u16() == 402 {
// Token 过期或账户问题,尝试重新加载凭证并刷新
drop(kiro);
let _guard = state.kiro_refresh_lock.lock().await;
let mut kiro = state.kiro.write().await;
state
.logs
.write()
.await
.add("warn", "Got 403, attempting token refresh");
state.logs.write().await.add(
"warn",
&format!(
"[AUTH] Got {}, reloading credentials and attempting token refresh...",
status.as_u16()
),
);
// 先重新加载凭证文件(可能用户换了账户)
if let Err(e) = kiro.load_credentials().await {
state.logs.write().await.add(
"error",
&format!("[AUTH] Failed to reload credentials: {e}"),
);
}
match kiro.refresh_token().await {
Ok(_) => {
state
.logs
.write()
.await
.add("info", "[AUTH] Token refreshed successfully after reload");
// 重试请求
drop(kiro);
let kiro = state.kiro.read().await;
@@ -438,13 +453,22 @@ async fn chat_completions(
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": {"message": e.to_string()}})),
).into_response(),
)
.into_response(),
}
}
Err(e) => (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": {"message": format!("Token refresh failed: {e}")}})),
).into_response(),
Err(e) => {
state
.logs
.write()
.await
.add("error", &format!("[AUTH] Token refresh failed: {e}"));
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": {"message": format!("Token refresh failed: {e}")}})),
)
.into_response()
}
}
} else {
let body = resp.text().await.unwrap_or_default();
@@ -655,23 +679,33 @@ async fn anthropic_messages(
.into_response()
}
}
} else if status.as_u16() == 403 {
// Token 过期,尝试刷新
} else if status.as_u16() == 403 || status.as_u16() == 402 {
// Token 过期或账户问题,尝试重新加载凭证并刷新
drop(kiro);
let _guard = state.kiro_refresh_lock.lock().await;
let mut kiro = state.kiro.write().await;
state.logs.write().await.add(
"warn",
"[AUTH] Got 403 Forbidden, attempting token refresh...",
&format!(
"[AUTH] Got {}, reloading credentials and attempting token refresh...",
status.as_u16()
),
);
// 先重新加载凭证文件(可能用户换了账户)
if let Err(e) = kiro.load_credentials().await {
state.logs.write().await.add(
"error",
&format!("[AUTH] Failed to reload credentials: {e}"),
);
}
match kiro.refresh_token().await {
Ok(_) => {
state
.logs
.write()
.await
.add("info", "[AUTH] Token refreshed, retrying request...");
state.logs.write().await.add(
"info",
"[AUTH] Token refreshed successfully, retrying request...",
);
drop(kiro);
let kiro = state.kiro.read().await;
match kiro.call_api(&openai_request).await {
+230
View File
@@ -0,0 +1,230 @@
use crate::models::{AppType, Provider};
use serde_json::{json, Value};
use std::path::PathBuf;
/// Get the configuration file path for an app type
pub fn get_app_config_path(app_type: &AppType) -> Option<PathBuf> {
let home = dirs::home_dir()?;
match app_type {
AppType::Claude => Some(home.join(".claude").join("settings.json")),
AppType::Codex => Some(home.join(".codex")),
AppType::Gemini => Some(home.join(".gemini")),
AppType::ProxyCast => None,
}
}
/// Sync provider configuration to live config files
pub fn sync_to_live(
app_type: &AppType,
provider: &Provider,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match app_type {
AppType::Claude => sync_claude_settings(provider),
AppType::Codex => sync_codex_config(provider),
AppType::Gemini => sync_gemini_config(provider),
AppType::ProxyCast => Ok(()),
}
}
/// Sync Claude settings to ~/.claude/settings.json
fn sync_claude_settings(
provider: &Provider,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
let claude_dir = home.join(".claude");
let config_path = claude_dir.join("settings.json");
// Create directory if not exists
std::fs::create_dir_all(&claude_dir)?;
// Read existing settings to preserve other fields
let mut settings: Value = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
// Merge env variables into settings
if let Some(env_obj) = provider.settings_config.get("env").and_then(|v| v.as_object()) {
let settings_obj = settings.as_object_mut().ok_or("Invalid settings format")?;
// Ensure env object exists
if !settings_obj.contains_key("env") {
settings_obj.insert("env".to_string(), json!({}));
}
if let Some(target_env) = settings_obj.get_mut("env").and_then(|v| v.as_object_mut()) {
for (key, value) in env_obj {
target_env.insert(key.clone(), value.clone());
}
}
} else {
// If settings_config is the full settings object, use it directly
settings = provider.settings_config.clone();
}
// Write settings
let content = serde_json::to_string_pretty(&settings)?;
std::fs::write(&config_path, content)?;
Ok(())
}
/// Sync Codex config to ~/.codex/auth.json and ~/.codex/config.toml
fn sync_codex_config(
provider: &Provider,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
let codex_dir = home.join(".codex");
// Create directory if not exists
std::fs::create_dir_all(&codex_dir)?;
if let Some(obj) = provider.settings_config.as_object() {
// Write auth.json
if let Some(auth) = obj.get("auth") {
let auth_path = codex_dir.join("auth.json");
let content = serde_json::to_string_pretty(auth)?;
std::fs::write(&auth_path, content)?;
}
// Write config.toml
if let Some(config) = obj.get("config").and_then(|v| v.as_str()) {
let config_path = codex_dir.join("config.toml");
std::fs::write(&config_path, config)?;
}
}
Ok(())
}
/// Sync Gemini config to ~/.gemini/.env and ~/.gemini/settings.json
fn sync_gemini_config(
provider: &Provider,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
let gemini_dir = home.join(".gemini");
// Create directory if not exists
std::fs::create_dir_all(&gemini_dir)?;
// Write .env file
if let Some(env_obj) = provider.settings_config.get("env").and_then(|v| v.as_object()) {
let env_path = gemini_dir.join(".env");
let mut content = String::new();
for (key, value) in env_obj {
if let Some(val) = value.as_str() {
// Only write non-empty values
if !val.is_empty() {
content.push_str(&format!("{}={}\n", key, val));
}
}
}
std::fs::write(&env_path, content)?;
}
// Write settings.json (for MCP servers and other config)
if let Some(config) = provider.settings_config.get("config") {
if config.is_object() {
let settings_path = gemini_dir.join("settings.json");
// Read existing settings to preserve mcpServers
let mut settings: Value = if settings_path.exists() {
let content = std::fs::read_to_string(&settings_path)?;
serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
// Merge config into settings
if let (Some(settings_obj), Some(config_obj)) =
(settings.as_object_mut(), config.as_object())
{
for (key, value) in config_obj {
settings_obj.insert(key.clone(), value.clone());
}
}
let content = serde_json::to_string_pretty(&settings)?;
std::fs::write(&settings_path, content)?;
}
}
Ok(())
}
/// Read current live settings for an app type
pub fn read_live_settings(app_type: &AppType) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
match app_type {
AppType::Claude => {
let path = home.join(".claude").join("settings.json");
if !path.exists() {
return Err("Claude settings file not found".into());
}
let content = std::fs::read_to_string(&path)?;
Ok(serde_json::from_str(&content)?)
}
AppType::Codex => {
let codex_dir = home.join(".codex");
let auth_path = codex_dir.join("auth.json");
let config_path = codex_dir.join("config.toml");
let auth: Value = if auth_path.exists() {
let content = std::fs::read_to_string(&auth_path)?;
serde_json::from_str(&content)?
} else {
json!({})
};
let config = if config_path.exists() {
std::fs::read_to_string(&config_path)?
} else {
String::new()
};
Ok(json!({
"auth": auth,
"config": config
}))
}
AppType::Gemini => {
let gemini_dir = home.join(".gemini");
let env_path = gemini_dir.join(".env");
let settings_path = gemini_dir.join("settings.json");
// Read .env file
let mut env_map: serde_json::Map<String, Value> = serde_json::Map::new();
if env_path.exists() {
let content = std::fs::read_to_string(&env_path)?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
env_map.insert(key.trim().to_string(), json!(value.trim()));
}
}
}
// Read settings.json
let config: Value = if settings_path.exists() {
let content = std::fs::read_to_string(&settings_path)?;
serde_json::from_str(&content)?
} else {
json!({})
};
Ok(json!({
"env": env_map,
"config": config
}))
}
AppType::ProxyCast => Ok(json!({})),
}
}
+110
View File
@@ -0,0 +1,110 @@
use crate::database::dao::mcp::McpDao;
use crate::database::DbConnection;
use crate::models::{AppType, McpServer};
use crate::services::mcp_sync;
pub struct McpService;
impl McpService {
pub fn get_all(db: &DbConnection) -> Result<Vec<McpServer>, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
McpDao::get_all(&conn).map_err(|e| e.to_string())
}
pub fn add(db: &DbConnection, server: McpServer) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
McpDao::insert(&conn, &server).map_err(|e| e.to_string())?;
// Sync to enabled apps
let servers = McpDao::get_all(&conn).map_err(|e| e.to_string())?;
mcp_sync::sync_all_mcp_to_live(&servers).map_err(|e| e.to_string())?;
Ok(())
}
pub fn update(db: &DbConnection, server: McpServer) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
McpDao::update(&conn, &server).map_err(|e| e.to_string())?;
// Sync to enabled apps
let servers = McpDao::get_all(&conn).map_err(|e| e.to_string())?;
mcp_sync::sync_all_mcp_to_live(&servers).map_err(|e| e.to_string())?;
Ok(())
}
pub fn delete(db: &DbConnection, id: &str) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
McpDao::delete(&conn, id).map_err(|e| e.to_string())?;
// Remove from all apps
mcp_sync::remove_mcp_from_all_apps(id).map_err(|e| e.to_string())?;
Ok(())
}
pub fn toggle_enabled(
db: &DbConnection,
id: &str,
app_type: &str,
enabled: bool,
) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
McpDao::toggle_enabled(&conn, id, app_type, enabled).map_err(|e| e.to_string())?;
// Get the server and sync
let servers = McpDao::get_all(&conn).map_err(|e| e.to_string())?;
let server = servers.iter().find(|s| s.id == id);
if let Some(server) = server {
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
if enabled {
// Sync server to the app
mcp_sync::sync_mcp_to_app(&app, &servers).map_err(|e| e.to_string())?;
} else {
// Remove server from the app
mcp_sync::remove_mcp_from_app(&app, id).map_err(|e| e.to_string())?;
}
}
Ok(())
}
/// Sync all enabled MCP servers to all apps
pub fn sync_all_to_live(db: &DbConnection) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
let servers = McpDao::get_all(&conn).map_err(|e| e.to_string())?;
mcp_sync::sync_all_mcp_to_live(&servers).map_err(|e| e.to_string())
}
/// Import MCP servers from an app
pub fn import_from_app(db: &DbConnection, app_type: &str) -> Result<usize, String> {
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
let conn = db.lock().map_err(|e| e.to_string())?;
// Get existing servers
let existing = McpDao::get_all(&conn).map_err(|e| e.to_string())?;
let existing_ids: std::collections::HashSet<String> =
existing.iter().map(|s| s.id.clone()).collect();
// Import from app
let imported = mcp_sync::import_mcp_from_app(&app).map_err(|e| e.to_string())?;
let mut count = 0;
for server in imported {
if existing_ids.contains(&server.id) {
// Update existing server's enabled status for this app
if McpDao::toggle_enabled(&conn, &server.id, app_type, true).is_ok() {
count += 1;
}
} else {
// Insert new server
if McpDao::insert(&conn, &server).is_ok() {
count += 1;
}
}
}
Ok(count)
}
}
+536
View File
@@ -0,0 +1,536 @@
use crate::models::{AppType, McpServer};
use serde_json::{json, Map, Value};
use std::path::PathBuf;
/// Get the MCP config file path for an app type
pub fn get_mcp_config_path(app_type: &AppType) -> Option<PathBuf> {
let home = dirs::home_dir()?;
match app_type {
AppType::Claude => Some(home.join(".claude").join("settings.json")),
AppType::Codex => Some(home.join(".codex").join("config.toml")),
AppType::Gemini => Some(home.join(".gemini").join("settings.json")),
AppType::ProxyCast => None,
}
}
/// Sync all enabled MCP servers to their respective app configurations
pub fn sync_all_mcp_to_live(
servers: &[McpServer],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Sync to Claude
let claude_servers: Vec<&McpServer> = servers.iter().filter(|s| s.enabled_claude).collect();
sync_mcp_to_claude(&claude_servers)?;
// Sync to Codex
let codex_servers: Vec<&McpServer> = servers.iter().filter(|s| s.enabled_codex).collect();
sync_mcp_to_codex(&codex_servers)?;
// Sync to Gemini
let gemini_servers: Vec<&McpServer> = servers.iter().filter(|s| s.enabled_gemini).collect();
sync_mcp_to_gemini(&gemini_servers)?;
Ok(())
}
/// Sync MCP servers to a specific app
pub fn sync_mcp_to_app(
app_type: &AppType,
servers: &[McpServer],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let enabled_servers: Vec<&McpServer> = servers
.iter()
.filter(|s| match app_type {
AppType::Claude => s.enabled_claude,
AppType::Codex => s.enabled_codex,
AppType::Gemini => s.enabled_gemini,
AppType::ProxyCast => s.enabled_proxycast,
})
.collect();
match app_type {
AppType::Claude => sync_mcp_to_claude(&enabled_servers),
AppType::Codex => sync_mcp_to_codex(&enabled_servers),
AppType::Gemini => sync_mcp_to_gemini(&enabled_servers),
AppType::ProxyCast => Ok(()),
}
}
/// Sync MCP servers to Claude's settings.json
/// Claude uses the mcpServers field in ~/.claude/settings.json
fn sync_mcp_to_claude(
servers: &[&McpServer],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
let claude_dir = home.join(".claude");
let config_path = claude_dir.join("settings.json");
// Create directory if not exists
std::fs::create_dir_all(&claude_dir)?;
// Read existing settings
let mut settings: Value = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
// Build mcpServers object - use name as key (not id which may be UUID)
let mut mcp_servers = Map::new();
for server in servers {
if let Some(config) = server.server_config.as_object() {
mcp_servers.insert(server.name.clone(), Value::Object(config.clone()));
}
}
// Update settings with mcpServers
if let Some(obj) = settings.as_object_mut() {
obj.insert("mcpServers".to_string(), Value::Object(mcp_servers));
}
// Write settings
let content = serde_json::to_string_pretty(&settings)?;
std::fs::write(&config_path, content)?;
Ok(())
}
/// Sync MCP servers to Codex's config.toml
/// Codex uses [mcp_servers.*] sections in ~/.codex/config.toml
fn sync_mcp_to_codex(
servers: &[&McpServer],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
let codex_dir = home.join(".codex");
let config_path = codex_dir.join("config.toml");
// Create directory if not exists
std::fs::create_dir_all(&codex_dir)?;
// Read existing config
let mut existing_content = if config_path.exists() {
std::fs::read_to_string(&config_path)?
} else {
String::new()
};
// Remove existing [mcp_servers.*] sections
let mut lines: Vec<&str> = existing_content.lines().collect();
let mut new_lines: Vec<String> = Vec::new();
let mut in_mcp_section = false;
for line in &lines {
let trimmed = line.trim();
if trimmed.starts_with("[mcp_servers.") {
in_mcp_section = true;
continue;
}
if in_mcp_section && trimmed.starts_with('[') {
in_mcp_section = false;
}
if !in_mcp_section {
new_lines.push(line.to_string());
}
}
// Add new MCP server sections - use name as key
for server in servers {
new_lines.push(String::new());
new_lines.push(format!("[mcp_servers.{}]", server.name));
if let Some(config) = server.server_config.as_object() {
// Convert JSON config to TOML format
if let Some(command) = config.get("command").and_then(|v| v.as_str()) {
new_lines.push(format!("command = \"{}\"", command));
}
if let Some(args) = config.get("args").and_then(|v| v.as_array()) {
let args_str: Vec<String> = args
.iter()
.filter_map(|a| a.as_str())
.map(|s| format!("\"{}\"", s))
.collect();
new_lines.push(format!("args = [{}]", args_str.join(", ")));
}
if let Some(env) = config.get("env").and_then(|v| v.as_object()) {
new_lines.push("[mcp_servers.".to_string() + &server.name + ".env]");
for (key, value) in env {
if let Some(val) = value.as_str() {
new_lines.push(format!("{} = \"{}\"", key, val));
}
}
}
}
}
// Write config
let content = new_lines.join("\n");
std::fs::write(&config_path, content)?;
Ok(())
}
/// Sync MCP servers to Gemini's settings.json
/// Gemini uses the mcpServers field in ~/.gemini/settings.json
fn sync_mcp_to_gemini(
servers: &[&McpServer],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
let gemini_dir = home.join(".gemini");
let settings_path = gemini_dir.join("settings.json");
// Create directory if not exists
std::fs::create_dir_all(&gemini_dir)?;
// Read existing settings
let mut settings: Value = if settings_path.exists() {
let content = std::fs::read_to_string(&settings_path)?;
serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
// Build mcpServers object - use name as key
let mut mcp_servers = Map::new();
for server in servers {
if let Some(config) = server.server_config.as_object() {
mcp_servers.insert(server.name.clone(), Value::Object(config.clone()));
}
}
// Update settings with mcpServers
if let Some(obj) = settings.as_object_mut() {
obj.insert("mcpServers".to_string(), Value::Object(mcp_servers));
}
// Write settings
let content = serde_json::to_string_pretty(&settings)?;
std::fs::write(&settings_path, content)?;
Ok(())
}
/// Remove a specific MCP server from an app's config
pub fn remove_mcp_from_app(
app_type: &AppType,
server_id: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match app_type {
AppType::Claude => remove_mcp_from_claude(server_id),
AppType::Codex => remove_mcp_from_codex(server_id),
AppType::Gemini => remove_mcp_from_gemini(server_id),
AppType::ProxyCast => Ok(()),
}
}
fn remove_mcp_from_claude(
server_id: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
let config_path = home.join(".claude").join("settings.json");
if !config_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&config_path)?;
let mut settings: Value = serde_json::from_str(&content)?;
if let Some(mcp_servers) = settings
.as_object_mut()
.and_then(|o| o.get_mut("mcpServers"))
.and_then(|v| v.as_object_mut())
{
mcp_servers.remove(server_id);
}
let content = serde_json::to_string_pretty(&settings)?;
std::fs::write(&config_path, content)?;
Ok(())
}
fn remove_mcp_from_codex(
server_id: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
let config_path = home.join(".codex").join("config.toml");
if !config_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&config_path)?;
let mut lines: Vec<&str> = content.lines().collect();
let mut new_lines: Vec<String> = Vec::new();
let section_header = format!("[mcp_servers.{}]", server_id);
let env_header = format!("[mcp_servers.{}.env]", server_id);
let mut skip_section = false;
for line in &lines {
let trimmed = line.trim();
// Check if this is the section we want to remove
if trimmed == section_header || trimmed == env_header {
skip_section = true;
continue;
}
// Check if we've reached a new section
if skip_section && trimmed.starts_with('[') {
skip_section = false;
}
if !skip_section {
new_lines.push(line.to_string());
}
}
let content = new_lines.join("\n");
std::fs::write(&config_path, content)?;
Ok(())
}
fn remove_mcp_from_gemini(
server_id: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
let settings_path = home.join(".gemini").join("settings.json");
if !settings_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&settings_path)?;
let mut settings: Value = serde_json::from_str(&content)?;
if let Some(mcp_servers) = settings
.as_object_mut()
.and_then(|o| o.get_mut("mcpServers"))
.and_then(|v| v.as_object_mut())
{
mcp_servers.remove(server_id);
}
let content = serde_json::to_string_pretty(&settings)?;
std::fs::write(&settings_path, content)?;
Ok(())
}
/// Remove a specific MCP server from all apps
pub fn remove_mcp_from_all_apps(
server_id: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
remove_mcp_from_claude(server_id)?;
remove_mcp_from_codex(server_id)?;
remove_mcp_from_gemini(server_id)?;
Ok(())
}
/// Import MCP servers from Claude's settings.json
pub fn import_mcp_from_claude() -> Result<Vec<crate::models::McpServer>, Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
let config_path = home.join(".claude").join("settings.json");
if !config_path.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(&config_path)?;
let settings: Value = serde_json::from_str(&content)?;
let mut servers = Vec::new();
if let Some(mcp_servers) = settings.get("mcpServers").and_then(|v| v.as_object()) {
for (id, config) in mcp_servers {
let server = crate::models::McpServer {
id: id.clone(),
name: id.clone(),
server_config: config.clone(),
description: None,
enabled_proxycast: false,
enabled_claude: true,
enabled_codex: false,
enabled_gemini: false,
created_at: Some(chrono::Utc::now().timestamp()),
};
servers.push(server);
}
}
Ok(servers)
}
/// Import MCP servers from Codex's config.toml
pub fn import_mcp_from_codex() -> Result<Vec<crate::models::McpServer>, Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
let config_path = home.join(".codex").join("config.toml");
if !config_path.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(&config_path)?;
let mut servers = Vec::new();
let mut current_server_id: Option<String> = None;
let mut current_config: Map<String, Value> = Map::new();
let mut current_env: Map<String, Value> = Map::new();
let mut in_env_section = false;
for line in content.lines() {
let trimmed = line.trim();
// Check for [mcp_servers.name] section
if trimmed.starts_with("[mcp_servers.") && trimmed.ends_with(']') {
// Save previous server if any
if let Some(ref id) = current_server_id {
if !current_env.is_empty() {
current_config.insert("env".to_string(), Value::Object(current_env.clone()));
}
let server = crate::models::McpServer {
id: id.clone(),
name: id.clone(),
server_config: Value::Object(current_config.clone()),
description: None,
enabled_proxycast: false,
enabled_claude: false,
enabled_codex: true,
enabled_gemini: false,
created_at: Some(chrono::Utc::now().timestamp()),
};
servers.push(server);
}
// Parse new server ID
let section = &trimmed[13..trimmed.len() - 1]; // Remove "[mcp_servers." and "]"
if section.ends_with(".env") {
in_env_section = true;
} else {
current_server_id = Some(section.to_string());
current_config = Map::new();
current_env = Map::new();
in_env_section = false;
}
continue;
}
// Check for other sections
if trimmed.starts_with('[') {
// Save previous server if any
if let Some(ref id) = current_server_id {
if !current_env.is_empty() {
current_config.insert("env".to_string(), Value::Object(current_env.clone()));
}
let server = crate::models::McpServer {
id: id.clone(),
name: id.clone(),
server_config: Value::Object(current_config.clone()),
description: None,
enabled_proxycast: false,
enabled_claude: false,
enabled_codex: true,
enabled_gemini: false,
created_at: Some(chrono::Utc::now().timestamp()),
};
servers.push(server);
current_server_id = None;
}
in_env_section = false;
continue;
}
// Parse key = value
if current_server_id.is_some() && trimmed.contains('=') {
if let Some((key, value)) = trimmed.split_once('=') {
let key = key.trim();
let value = value.trim().trim_matches('"');
if in_env_section {
current_env.insert(key.to_string(), Value::String(value.to_string()));
} else if key == "command" {
current_config.insert(key.to_string(), Value::String(value.to_string()));
} else if key == "args" {
// Parse array: ["arg1", "arg2"]
if value.starts_with('[') && value.ends_with(']') {
let args_str = &value[1..value.len() - 1];
let args: Vec<Value> = args_str
.split(',')
.map(|s| Value::String(s.trim().trim_matches('"').to_string()))
.collect();
current_config.insert(key.to_string(), Value::Array(args));
}
}
}
}
}
// Save last server if any
if let Some(ref id) = current_server_id {
if !current_env.is_empty() {
current_config.insert("env".to_string(), Value::Object(current_env));
}
let server = crate::models::McpServer {
id: id.clone(),
name: id.clone(),
server_config: Value::Object(current_config),
description: None,
enabled_proxycast: false,
enabled_claude: false,
enabled_codex: true,
enabled_gemini: false,
created_at: Some(chrono::Utc::now().timestamp()),
};
servers.push(server);
}
Ok(servers)
}
/// Import MCP servers from Gemini's settings.json
pub fn import_mcp_from_gemini() -> Result<Vec<crate::models::McpServer>, Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
let settings_path = home.join(".gemini").join("settings.json");
if !settings_path.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(&settings_path)?;
let settings: Value = serde_json::from_str(&content)?;
let mut servers = Vec::new();
if let Some(mcp_servers) = settings.get("mcpServers").and_then(|v| v.as_object()) {
for (id, config) in mcp_servers {
let server = crate::models::McpServer {
id: id.clone(),
name: id.clone(),
server_config: config.clone(),
description: None,
enabled_proxycast: false,
enabled_claude: false,
enabled_codex: false,
enabled_gemini: true,
created_at: Some(chrono::Utc::now().timestamp()),
};
servers.push(server);
}
}
Ok(servers)
}
/// Import MCP servers from a specific app
pub fn import_mcp_from_app(
app_type: &AppType,
) -> Result<Vec<crate::models::McpServer>, Box<dyn std::error::Error + Send + Sync>> {
match app_type {
AppType::Claude => import_mcp_from_claude(),
AppType::Codex => import_mcp_from_codex(),
AppType::Gemini => import_mcp_from_gemini(),
AppType::ProxyCast => Ok(Vec::new()),
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod switch;
pub mod live_sync;
pub mod mcp_service;
pub mod mcp_sync;
pub mod prompt_service;
pub mod prompt_sync;
+232
View File
@@ -0,0 +1,232 @@
use crate::database::dao::prompts::PromptDao;
use crate::database::DbConnection;
use crate::models::{AppType, Prompt};
use crate::services::prompt_sync;
use std::collections::HashMap;
pub struct PromptService;
impl PromptService {
/// Get all prompts for an app type
pub fn get_all(db: &DbConnection, app_type: &str) -> Result<Vec<Prompt>, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
PromptDao::get_all(&conn, app_type).map_err(|e| e.to_string())
}
/// Get all prompts as a HashMap (for frontend)
pub fn get_all_map(db: &DbConnection, app_type: &str) -> Result<HashMap<String, Prompt>, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
PromptDao::get_all_map(&conn, app_type).map_err(|e| e.to_string())
}
/// Upsert a prompt (insert or update)
/// If the prompt is enabled, sync to live file
pub fn upsert(db: &DbConnection, app_type: &str, prompt: Prompt) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
PromptDao::upsert(&conn, &prompt).map_err(|e| e.to_string())?;
// If this prompt is enabled, sync to live file
if prompt.enabled {
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
prompt_sync::write_live_prompt(&app, &prompt.content)?;
}
Ok(())
}
/// Add a new prompt
pub fn add(db: &DbConnection, prompt: Prompt) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
PromptDao::insert(&conn, &prompt).map_err(|e| e.to_string())
}
/// Update an existing prompt
/// If the prompt is enabled, sync to live file
pub fn update(db: &DbConnection, prompt: Prompt) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
PromptDao::update(&conn, &prompt).map_err(|e| e.to_string())?;
// If this prompt is enabled, sync to live file
if prompt.enabled {
let app = prompt.app_type.parse::<AppType>().map_err(|e| e.to_string())?;
prompt_sync::write_live_prompt(&app, &prompt.content)?;
}
Ok(())
}
/// Delete a prompt
/// Cannot delete an enabled prompt
pub fn delete(db: &DbConnection, app_type: &str, id: &str) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
// Check if prompt is enabled
if let Ok(Some(prompt)) = PromptDao::get_by_id(&conn, app_type, id) {
if prompt.enabled {
return Err("Cannot delete an enabled prompt. Disable it first.".to_string());
}
}
PromptDao::delete(&conn, app_type, id).map_err(|e| e.to_string())
}
/// Enable a prompt and sync to live file
/// This will:
/// 1. Backfill the current live file content to the currently enabled prompt (if any)
/// 2. Disable all other prompts
/// 3. Enable the specified prompt
/// 4. Write the prompt content to the live file
pub fn enable(db: &DbConnection, app_type: &str, id: &str) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
// Step 1: Backfill current live content to the currently enabled prompt
if let Ok(Some(live_content)) = prompt_sync::read_live_prompt(&app) {
if !live_content.trim().is_empty() {
if let Ok(Some(mut current_enabled)) = PromptDao::get_enabled(&conn, app_type) {
// Update the current enabled prompt with live content
current_enabled.content = live_content.clone();
current_enabled.updated_at = Some(chrono::Utc::now().timestamp());
let _ = PromptDao::update(&conn, &current_enabled);
tracing::info!(
"Backfilled live content to enabled prompt: {}",
current_enabled.id
);
} else {
// No enabled prompt, check if we should create a backup
let prompts = PromptDao::get_all(&conn, app_type).map_err(|e| e.to_string())?;
let content_exists = prompts.iter().any(|p| p.content.trim() == live_content.trim());
if !content_exists {
// Create a backup prompt
let timestamp = chrono::Utc::now().timestamp();
let backup = Prompt {
id: format!("backup-{}", timestamp),
app_type: app_type.to_string(),
name: format!(
"Original Prompt {}",
chrono::Local::now().format("%Y-%m-%d %H:%M")
),
content: live_content,
description: Some("Auto-backup of original prompt".to_string()),
enabled: false,
created_at: Some(timestamp),
updated_at: Some(timestamp),
};
let _ = PromptDao::insert(&conn, &backup);
tracing::info!("Created backup prompt: {}", backup.id);
}
}
}
}
// Step 2 & 3: Enable the specified prompt (this also disables others)
PromptDao::enable(&conn, app_type, id).map_err(|e| e.to_string())?;
// Step 4: Write to live file
if let Ok(Some(prompt)) = PromptDao::get_by_id(&conn, app_type, id) {
prompt_sync::write_live_prompt(&app, &prompt.content)?;
tracing::info!("Synced prompt {} to live file", id);
}
Ok(())
}
/// Disable a prompt
pub fn disable(db: &DbConnection, app_type: &str, id: &str) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
// Get the prompt and set enabled to false
if let Ok(Some(mut prompt)) = PromptDao::get_by_id(&conn, app_type, id) {
prompt.enabled = false;
prompt.updated_at = Some(chrono::Utc::now().timestamp());
PromptDao::update(&conn, &prompt).map_err(|e| e.to_string())?;
}
Ok(())
}
/// Import prompt from live file
pub fn import_from_file(db: &DbConnection, app_type: &str) -> Result<String, String> {
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
let content = prompt_sync::read_live_prompt(&app)?
.ok_or_else(|| "Prompt file does not exist or is empty".to_string())?;
if content.trim().is_empty() {
return Err("Prompt file is empty".to_string());
}
let timestamp = chrono::Utc::now().timestamp();
let id = format!("imported-{}", timestamp);
let prompt = Prompt {
id: id.clone(),
app_type: app_type.to_string(),
name: format!(
"Imported Prompt {}",
chrono::Local::now().format("%Y-%m-%d %H:%M")
),
content,
description: Some("Imported from existing config file".to_string()),
enabled: false,
created_at: Some(timestamp),
updated_at: Some(timestamp),
};
let conn = db.lock().map_err(|e| e.to_string())?;
PromptDao::insert(&conn, &prompt).map_err(|e| e.to_string())?;
Ok(id)
}
/// Get current live file content
pub fn get_live_content(app_type: &str) -> Result<Option<String>, String> {
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
prompt_sync::read_live_prompt(&app)
}
/// Import from file on first launch (if no prompts exist)
pub fn import_on_first_launch(db: &DbConnection, app_type: &str) -> Result<usize, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
// Check if prompts already exist
let existing = PromptDao::get_all(&conn, app_type).map_err(|e| e.to_string())?;
if !existing.is_empty() {
return Ok(0);
}
// Read from live file
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
let content = match prompt_sync::read_live_prompt(&app) {
Ok(Some(c)) if !c.trim().is_empty() => c,
_ => return Ok(0),
};
tracing::info!("Found prompt file, auto-importing for {}", app_type);
let timestamp = chrono::Utc::now().timestamp();
let prompt = Prompt {
id: format!("auto-imported-{}", timestamp),
app_type: app_type.to_string(),
name: format!(
"Auto-imported {}",
chrono::Local::now().format("%Y-%m-%d %H:%M")
),
content,
description: Some("Automatically imported on first launch".to_string()),
enabled: true, // Enable on first import
created_at: Some(timestamp),
updated_at: Some(timestamp),
};
PromptDao::insert(&conn, &prompt).map_err(|e| e.to_string())?;
tracing::info!("Auto-imported prompt for {}", app_type);
Ok(1)
}
// Legacy method for compatibility
pub fn set_current(db: &DbConnection, app_type: &str, id: &str) -> Result<(), String> {
Self::enable(db, app_type, id)
}
}
+78
View File
@@ -0,0 +1,78 @@
use crate::models::AppType;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
/// Get the prompt file path for an app
/// Claude: ~/CLAUDE.md (user's home directory)
/// Codex: ~/AGENTS.md
/// Gemini: ~/GEMINI.md
pub fn get_prompt_file_path(app: &AppType) -> Option<PathBuf> {
let home = dirs::home_dir()?;
match app {
AppType::Claude => Some(home.join("CLAUDE.md")),
AppType::Codex => Some(home.join("AGENTS.md")),
AppType::Gemini => Some(home.join("GEMINI.md")),
AppType::ProxyCast => None, // ProxyCast doesn't have a prompt file
}
}
/// Get the prompt file name for an app
pub fn get_prompt_filename(app: &AppType) -> &'static str {
match app {
AppType::Claude => "CLAUDE.md",
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
AppType::ProxyCast => "",
}
}
/// Read current content from the live prompt file
pub fn read_live_prompt(app: &AppType) -> Result<Option<String>, String> {
let path = get_prompt_file_path(app).ok_or("Cannot determine prompt file path")?;
if !path.exists() {
return Ok(None);
}
fs::read_to_string(&path)
.map(|c| Some(c))
.map_err(|e| format!("Failed to read prompt file: {}", e))
}
/// Write content to the live prompt file (atomic write)
pub fn write_live_prompt(app: &AppType, content: &str) -> Result<(), String> {
let path = get_prompt_file_path(app).ok_or("Cannot determine prompt file path")?;
// Ensure parent directory exists
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
}
// Atomic write: write to temp file first, then rename
let temp_path = path.with_extension("md.tmp");
let mut file =
fs::File::create(&temp_path).map_err(|e| format!("Failed to create temp file: {}", e))?;
file.write_all(content.as_bytes())
.map_err(|e| format!("Failed to write content: {}", e))?;
file.sync_all()
.map_err(|e| format!("Failed to sync file: {}", e))?;
fs::rename(&temp_path, &path).map_err(|e| format!("Failed to rename file: {}", e))?;
Ok(())
}
/// Delete the live prompt file
pub fn delete_live_prompt(app: &AppType) -> Result<(), String> {
let path = get_prompt_file_path(app).ok_or("Cannot determine prompt file path")?;
if path.exists() {
fs::remove_file(&path).map_err(|e| format!("Failed to delete prompt file: {}", e))?;
}
Ok(())
}
+169
View File
@@ -0,0 +1,169 @@
use crate::database::dao::providers::ProviderDao;
use crate::database::DbConnection;
use crate::models::{AppType, Provider};
use crate::services::live_sync;
pub struct SwitchService;
impl SwitchService {
pub fn get_providers(db: &DbConnection, app_type: &str) -> Result<Vec<Provider>, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
ProviderDao::get_all(&conn, app_type).map_err(|e| e.to_string())
}
pub fn get_current_provider(
db: &DbConnection,
app_type: &str,
) -> Result<Option<Provider>, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
ProviderDao::get_current(&conn, app_type).map_err(|e| e.to_string())
}
pub fn add_provider(db: &DbConnection, provider: Provider) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
// Check if this is the first provider for this app type
let existing = ProviderDao::get_all(&conn, &provider.app_type).map_err(|e| e.to_string())?;
let is_first = existing.is_empty();
ProviderDao::insert(&conn, &provider).map_err(|e| e.to_string())?;
// If this is the first provider, automatically set it as current and sync
if is_first {
ProviderDao::set_current(&conn, &provider.app_type, &provider.id)
.map_err(|e| e.to_string())?;
if let Ok(app_type_enum) = provider.app_type.parse::<AppType>() {
if app_type_enum != AppType::ProxyCast {
live_sync::sync_to_live(&app_type_enum, &provider)
.map_err(|e| format!("Failed to sync: {}", e))?;
}
}
}
Ok(())
}
pub fn update_provider(db: &DbConnection, provider: Provider) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
// Check if this is the current provider
let current = ProviderDao::get_current(&conn, &provider.app_type).map_err(|e| e.to_string())?;
let is_current = current.as_ref().map(|p| p.id == provider.id).unwrap_or(false);
ProviderDao::update(&conn, &provider).map_err(|e| e.to_string())?;
// If this is the current provider, sync to live
if is_current {
if let Ok(app_type_enum) = provider.app_type.parse::<AppType>() {
if app_type_enum != AppType::ProxyCast {
live_sync::sync_to_live(&app_type_enum, &provider)
.map_err(|e| format!("Failed to sync: {}", e))?;
}
}
}
Ok(())
}
pub fn delete_provider(db: &DbConnection, app_type: &str, id: &str) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
// Check if trying to delete the current provider
let current = ProviderDao::get_current(&conn, app_type).map_err(|e| e.to_string())?;
if let Some(ref current_provider) = current {
if current_provider.id == id {
return Err("Cannot delete the currently active provider".to_string());
}
}
ProviderDao::delete(&conn, app_type, id).map_err(|e| e.to_string())
}
pub fn switch_provider(db: &DbConnection, app_type: &str, id: &str) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
// Get target provider
let target_provider = ProviderDao::get_by_id(&conn, app_type, id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Provider not found: {}", id))?;
let app_type_enum = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
// Skip backfill and sync for ProxyCast
if app_type_enum != AppType::ProxyCast {
// Backfill: Read current live config and save to current provider
if let Some(current_provider) = ProviderDao::get_current(&conn, app_type)
.map_err(|e| e.to_string())?
{
// Only backfill if switching to a different provider
if current_provider.id != id {
if let Ok(live_settings) = live_sync::read_live_settings(&app_type_enum) {
// Update current provider with live settings
let mut updated_provider = current_provider.clone();
updated_provider.settings_config = live_settings;
let _ = ProviderDao::update(&conn, &updated_provider);
}
}
}
}
// Set new current provider
ProviderDao::set_current(&conn, app_type, id).map_err(|e| e.to_string())?;
// Sync target provider to live config
if app_type_enum != AppType::ProxyCast {
live_sync::sync_to_live(&app_type_enum, &target_provider)
.map_err(|e| format!("Failed to sync: {}", e))?;
}
Ok(())
}
/// Import current live config as a default provider
pub fn import_default_config(db: &DbConnection, app_type: &str) -> Result<bool, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
// Check if providers already exist
let existing = ProviderDao::get_all(&conn, app_type).map_err(|e| e.to_string())?;
if !existing.is_empty() {
return Ok(false); // Already has providers, skip import
}
let app_type_enum = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
// Skip for ProxyCast
if app_type_enum == AppType::ProxyCast {
return Ok(false);
}
// Read live settings
let live_settings = live_sync::read_live_settings(&app_type_enum)
.map_err(|e| format!("Failed to read live settings: {}", e))?;
// Create default provider
let provider = Provider {
id: "default".to_string(),
app_type: app_type.to_string(),
name: "Default (Imported)".to_string(),
settings_config: live_settings,
category: Some("custom".to_string()),
icon: None,
icon_color: Some("#6366f1".to_string()),
notes: Some("Imported from existing configuration".to_string()),
is_current: true,
sort_index: Some(0),
created_at: Some(chrono::Utc::now().timestamp()),
};
ProviderDao::insert(&conn, &provider).map_err(|e| e.to_string())?;
Ok(true)
}
/// Read current live settings for an app type
pub fn read_live_settings(app_type: &str) -> Result<serde_json::Value, String> {
let app_type_enum = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
live_sync::read_live_settings(&app_type_enum).map_err(|e| e.to_string())
}
}
+30 -9
View File
@@ -2,11 +2,24 @@ import { useState } from "react";
import { Sidebar } from "./components/Sidebar";
import { Dashboard } from "./components/Dashboard";
import { Providers } from "./components/Providers";
import { Models } from "./components/Models";
import { Settings } from "./components/Settings";
import { Logs } from "./components/Logs";
import { SettingsPage } from "./components/settings";
import { SwitchPage } from "./components/switch";
import { ClientsPage } from "./components/clients";
import { McpPage } from "./components/mcp";
import { PromptsPage } from "./components/prompts";
import { CredentialsPage } from "./components/credentials/CredentialsPage";
import { ApiServerPage } from "./components/api-server/ApiServerPage";
type Page = "dashboard" | "providers" | "models" | "settings" | "logs";
type Page =
| "dashboard"
| "credentials"
| "clients"
| "api-server"
| "providers"
| "settings"
| "switch"
| "mcp"
| "prompts";
function App() {
const [currentPage, setCurrentPage] = useState<Page>("dashboard");
@@ -15,14 +28,22 @@ function App() {
switch (currentPage) {
case "dashboard":
return <Dashboard />;
case "credentials":
return <CredentialsPage />;
case "clients":
return <ClientsPage />;
case "api-server":
return <ApiServerPage />;
case "providers":
return <Providers />;
case "models":
return <Models />;
case "settings":
return <Settings />;
case "logs":
return <Logs />;
return <SettingsPage />;
case "switch":
return <SwitchPage />;
case "mcp":
return <McpPage />;
case "prompts":
return <PromptsPage />;
default:
return <Dashboard />;
}
+159 -452
View File
@@ -1,145 +1,53 @@
import { useState, useEffect, useRef } from "react";
import React, { useState, useEffect } from "react";
import {
Activity,
Server,
Zap,
Clock,
Play,
Copy,
Check,
ChevronDown,
ChevronUp,
RefreshCw,
Key,
Monitor,
Globe,
CheckCircle2,
AlertCircle,
} from "lucide-react";
import {
startServer,
stopServer,
getServerStatus,
getConfig,
reloadCredentials,
testApi,
ServerStatus,
Config,
TestResult,
getKiroCredentials,
checkAndReloadCredentials,
getTokenFileHash,
KiroCredentialStatus,
getDefaultProvider,
} from "@/hooks/useTauri";
interface TestState {
endpoint: string;
status: "idle" | "loading" | "success" | "error";
response?: string;
time?: number;
httpStatus?: number;
}
import { useAllOAuthCredentials } from "@/hooks/useOAuthCredentials";
export function Dashboard() {
const [status, setStatus] = useState<ServerStatus | null>(null);
const [config, setConfig] = useState<Config | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [testResults, setTestResults] = useState<Record<string, TestState>>({});
const [copiedCmd, setCopiedCmd] = useState<string | null>(null);
const [expandedTest, setExpandedTest] = useState<string | null>(null);
// Token sync state
const [kiroStatus, setKiroStatus] = useState<KiroCredentialStatus | null>(
null,
);
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
const [lastCheckTime, setLastCheckTime] = useState<Date | null>(null);
const kiroHashRef = useRef<string>("");
const fetchStatus = async () => {
try {
const s = await getServerStatus();
setStatus(s);
} catch (e) {
console.error(e);
}
};
const fetchConfig = async () => {
try {
const c = await getConfig();
setConfig(c);
} catch (e) {
console.error(e);
}
};
const [defaultProvider, setDefaultProvider] = useState<string>("kiro");
const { credentials: oauthCredentials, reload: reloadCredentials } =
useAllOAuthCredentials();
useEffect(() => {
fetchStatus();
fetchConfig();
loadKiroStatus();
initTokenHash();
const statusInterval = setInterval(fetchStatus, 3000);
const tokenInterval = setInterval(checkTokenFileChanges, 10000);
return () => {
clearInterval(statusInterval);
clearInterval(tokenInterval);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const initTokenHash = async () => {
try {
kiroHashRef.current = await getTokenFileHash();
} catch (e) {
console.error("Failed to get initial hash:", e);
}
};
const loadKiroStatus = async () => {
try {
const status = await getKiroCredentials();
setKiroStatus(status);
} catch (e) {
console.error("Failed to load Kiro status:", e);
}
};
const checkTokenFileChanges = async () => {
setLastCheckTime(new Date());
try {
const result = await checkAndReloadCredentials(kiroHashRef.current);
kiroHashRef.current = result.new_hash;
if (result.changed && result.reloaded) {
await loadKiroStatus();
setLastSyncTime(new Date());
const fetchData = async () => {
try {
const [s, c, dp] = await Promise.all([
getServerStatus(),
getConfig(),
getDefaultProvider(),
]);
setStatus(s);
setConfig(c);
setDefaultProvider(dp);
} catch (e) {
console.error("Failed to fetch data:", e);
}
} catch (e) {
console.error("Token check error:", e);
}
};
};
const handleStart = async () => {
setLoading(true);
setError(null);
try {
await reloadCredentials();
await startServer();
await fetchStatus();
} catch (e: any) {
setError(e.toString());
}
setLoading(false);
};
fetchData();
reloadCredentials();
const handleStop = async () => {
setLoading(true);
try {
await stopServer();
await fetchStatus();
} catch (e: any) {
setError(e.toString());
}
setLoading(false);
};
const interval = setInterval(fetchData, 5000);
return () => clearInterval(interval);
}, [reloadCredentials]);
const formatUptime = (secs: number) => {
const h = Math.floor(secs / 3600);
@@ -150,152 +58,37 @@ export function Dashboard() {
const serverUrl = status
? `http://${status.host}:${status.port}`
: "http://localhost:3001";
const apiKey = config?.server.api_key || "proxycast-key";
const maskedKey =
apiKey.length > 8 ? apiKey.slice(0, 4) + "****" + apiKey.slice(-4) : "****";
// 测试端点配置
const testEndpoints = [
{
id: "health",
name: "健康检查",
method: "GET",
path: "/health",
needsAuth: false,
body: null,
},
{
id: "models",
name: "模型列表",
method: "GET",
path: "/v1/models",
needsAuth: true,
body: null,
},
{
id: "chat",
name: "OpenAI 聊天",
method: "POST",
path: "/v1/chat/completions",
needsAuth: true,
body: JSON.stringify({
model: "claude-sonnet-4-5",
messages: [{ role: "user", content: "Say hi in one word" }],
}),
},
{
id: "anthropic",
name: "Anthropic 消息",
method: "POST",
path: "/v1/messages",
needsAuth: true,
body: JSON.stringify({
model: "claude-sonnet-4-5",
max_tokens: 100,
messages: [
{
role: "user",
content: "What is 1+1? Answer with just the number.",
},
],
}),
},
];
const runTest = async (endpoint: (typeof testEndpoints)[0]) => {
setTestResults((prev) => ({
...prev,
[endpoint.id]: { endpoint: endpoint.path, status: "loading" },
}));
try {
const result: TestResult = await testApi(
endpoint.method,
endpoint.path,
endpoint.body,
endpoint.needsAuth, // maps to 'auth' parameter
);
// 添加调试日志
console.log(`Test ${endpoint.id}:`, result);
setTestResults((prev) => ({
...prev,
[endpoint.id]: {
endpoint: endpoint.path,
status: result.success ? "success" : "error",
response: result.body || `HTTP ${result.status}: 无响应内容`,
time: result.time_ms,
httpStatus: result.status,
},
}));
} catch (e: any) {
console.error(`Test ${endpoint.id} error:`, e);
setTestResults((prev) => ({
...prev,
[endpoint.id]: {
endpoint: endpoint.path,
status: "error",
response: `请求失败: ${e.toString()}`,
},
}));
const getProviderName = (id: string) => {
switch (id) {
case "kiro":
return "Kiro Claude";
case "gemini":
return "Gemini CLI";
case "qwen":
return "通义千问";
case "openai":
return "OpenAI 自定义";
case "claude":
return "Claude 自定义";
default:
return id;
}
};
const runAllTests = async () => {
for (const endpoint of testEndpoints) {
await runTest(endpoint);
}
};
const getCurlCommand = (endpoint: (typeof testEndpoints)[0]) => {
let cmd = `curl -s ${serverUrl}${endpoint.path}`;
if (endpoint.needsAuth) {
cmd += ` \\\n -H "Authorization: Bearer ${apiKey}"`;
}
if (endpoint.body) {
cmd += ` \\\n -H "Content-Type: application/json"`;
cmd += ` \\\n -d '${endpoint.body}'`;
}
return cmd;
};
const copyCommand = (id: string, cmd: string) => {
navigator.clipboard.writeText(cmd);
setCopiedCmd(id);
setTimeout(() => setCopiedCmd(null), 2000);
};
const getStatusBadge = (result?: TestState) => {
if (!result || result.status === "idle") {
return <span className="text-xs text-gray-400">未测试</span>;
}
if (result.status === "loading") {
return <span className="text-xs text-blue-500">测试中...</span>;
}
if (result.status === "success") {
return <span className="text-xs text-green-600">✓ {result.time}ms</span>;
}
return (
<span className="text-xs text-red-500">
✗ 失败 {result.httpStatus ? `(${result.httpStatus})` : ""}
</span>
);
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">仪表盘</h2>
<p className="text-muted-foreground">服务状态概览</p>
<p className="text-muted-foreground">系统状态概览</p>
</div>
{/* Status Cards */}
{/* Server Status Cards */}
<div className="grid grid-cols-4 gap-4">
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">状态</span>
<span className="text-sm text-muted-foreground">服务状态</span>
</div>
<div className="mt-2 flex items-center gap-2">
<div
@@ -328,219 +121,133 @@ export function Dashboard() {
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">当前 Provider</span>
<span className="text-sm text-muted-foreground">默认 Provider</span>
</div>
<div className="mt-2 font-medium capitalize">Kiro</div>
<div className="mt-2 font-medium">{getProviderName(defaultProvider)}</div>
</div>
</div>
{/* Token 同步状态 */}
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold flex items-center gap-2">
<RefreshCw className="h-4 w-4" />
Token 同步状态
</h3>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
自动监测中
</div>
</div>
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Token 状态:</span>
<span
className={`ml-2 ${kiroStatus?.has_access_token ? "text-green-600" : "text-red-500"}`}
{/* Quick Links */}
<div className="grid grid-cols-3 gap-4">
<QuickLinkCard
icon={Key}
title="凭证管理"
description="管理 OAuth 凭证"
status={
oauthCredentials.filter((c) => c.is_valid).length > 0
? "success"
: "warning"
}
statusText={`${oauthCredentials.filter((c) => c.is_valid).length}/${oauthCredentials.length} 有效`}
/>
<QuickLinkCard
icon={Monitor}
title="AI Clients"
description="配置 Claude Code/Codex/Gemini CLI"
status="info"
statusText="管理 Provider 配置"
/>
<QuickLinkCard
icon={Globe}
title="API Server"
description={`${serverUrl}`}
status={status?.running ? "success" : "warning"}
statusText={status?.running ? "运行中" : "已停止"}
/>
</div>
{/* OAuth Credentials Overview */}
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<Key className="h-4 w-4" />
OAuth 凭证状态
</h3>
<div className="grid grid-cols-3 gap-4">
{oauthCredentials.map((cred) => (
<div
key={cred.provider}
className="flex items-center justify-between rounded-lg border bg-background p-3"
>
{kiroStatus?.has_access_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
<div>
<span className="text-muted-foreground">Refresh Token:</span>
<span
className={`ml-2 ${kiroStatus?.has_refresh_token ? "text-green-600" : "text-red-500"}`}
>
{kiroStatus?.has_refresh_token ? "✓ 可用" : "✗ 不可用"}
</span>
</div>
<div>
<span className="text-muted-foreground">最后同步:</span>
<span className="ml-2">
{lastSyncTime ? lastSyncTime.toLocaleTimeString() : "从未同步"}
</span>
</div>
<div>
<span className="text-muted-foreground">最后检测:</span>
<span className="ml-2">
{lastCheckTime ? lastCheckTime.toLocaleTimeString() : "-"}
</span>
</div>
<div>
<div className="font-medium">{getProviderName(cred.provider)}</div>
<div className="text-xs text-muted-foreground">
{cred.has_access_token ? "Token 已加载" : "未配置"}
</div>
</div>
<div
className={`h-3 w-3 rounded-full ${
cred.is_valid
? "bg-green-500"
: cred.loaded
? "bg-yellow-500"
: "bg-gray-400"
}`}
/>
</div>
))}
</div>
</div>
{error && (
<div className="rounded-lg border border-red-500 bg-red-50 p-4 text-red-700">
{error}
{/* Server Info */}
{config && (
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-semibold">服务器信息</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">API 地址:</span>
<code className="ml-2 rounded bg-muted px-2 py-1">{serverUrl}</code>
</div>
<div>
<span className="text-muted-foreground">API Key:</span>
<code className="ml-2 rounded bg-muted px-2 py-1">
{config.server.api_key.length > 8
? `${config.server.api_key.slice(0, 4)}****${config.server.api_key.slice(-4)}`
: "****"}
</code>
</div>
</div>
</div>
)}
</div>
);
}
{/* Server Control */}
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-semibold">服务控制</h3>
<div className="flex items-center gap-4">
<button
className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
onClick={handleStart}
disabled={loading || status?.running}
>
{loading ? "处理中..." : "启动服务"}
</button>
<button
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
onClick={handleStop}
disabled={loading || !status?.running}
>
停止服务
</button>
function QuickLinkCard({
icon: Icon,
title,
description,
status,
statusText,
}: {
icon: React.ElementType;
title: string;
description: string;
status: "success" | "warning" | "error" | "info";
statusText: string;
}) {
const statusColors = {
success: "text-green-600",
warning: "text-yellow-600",
error: "text-red-600",
info: "text-blue-600",
};
const StatusIcon = status === "success" ? CheckCircle2 : AlertCircle;
return (
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-3 mb-2">
<div className="rounded-lg bg-primary/10 p-2">
<Icon className="h-5 w-5 text-primary" />
</div>
<div className="mt-4 flex items-center gap-4 text-sm text-muted-foreground">
<span>
API 地址:{" "}
<code className="rounded bg-muted px-2 py-1">{serverUrl}</code>
</span>
<span>
API Key:{" "}
<code className="rounded bg-muted px-2 py-1">{maskedKey}</code>
</span>
<div>
<h4 className="font-medium">{title}</h4>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
{/* API 测试 */}
<div className="rounded-lg border bg-card p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="font-semibold">API 测试</h3>
<p className="text-xs text-muted-foreground">
如遇问题,请打开浏览器开发者工具查看控制台日志
</p>
</div>
<button
onClick={runAllTests}
disabled={!status?.running}
className="flex items-center gap-2 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Play className="h-4 w-4" />
测试全部
</button>
</div>
<div className="space-y-3">
{testEndpoints.map((endpoint) => {
const result = testResults[endpoint.id];
const isExpanded = expandedTest === endpoint.id;
const curlCmd = getCurlCommand(endpoint);
return (
<div
key={endpoint.id}
className="rounded-lg border bg-background"
>
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-3">
<span
className={`rounded px-2 py-0.5 text-xs font-medium ${
endpoint.method === "GET"
? "bg-green-100 text-green-700"
: "bg-blue-100 text-blue-700"
}`}
>
{endpoint.method}
</span>
<span className="font-medium">{endpoint.name}</span>
<code className="text-xs text-muted-foreground">
{endpoint.path}
</code>
{getStatusBadge(result)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => copyCommand(endpoint.id, curlCmd)}
className="rounded p-1.5 hover:bg-muted"
title="复制 curl 命令"
>
{copiedCmd === endpoint.id ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
<button
onClick={() => runTest(endpoint)}
disabled={
!status?.running || result?.status === "loading"
}
className="rounded bg-primary/10 px-2 py-1 text-xs font-medium text-primary hover:bg-primary/20 disabled:opacity-50"
>
测试
</button>
<button
onClick={() =>
setExpandedTest(isExpanded ? null : endpoint.id)
}
className="rounded p-1.5 hover:bg-muted"
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</div>
</div>
{isExpanded && (
<div className="border-t p-3 space-y-3">
<div>
<p className="mb-1 text-xs font-medium text-muted-foreground">
curl 命令
</p>
<pre className="rounded bg-muted p-2 text-xs overflow-x-auto">
{curlCmd}
</pre>
</div>
{result?.response && (
<div>
<p className="mb-1 text-xs font-medium text-muted-foreground">
响应{" "}
{result.httpStatus && `(HTTP ${result.httpStatus})`}
</p>
<pre
className={`rounded p-2 text-xs overflow-x-auto max-h-40 ${
result.status === "success"
? "bg-green-50"
: "bg-red-50"
}`}
>
{(() => {
try {
return JSON.stringify(
JSON.parse(result.response),
null,
2,
);
} catch {
return result.response || "(空响应)";
}
})()}
</pre>
</div>
)}
</div>
)}
</div>
);
})}
</div>
<div className={`flex items-center gap-1 text-xs ${statusColors[status]}`}>
<StatusIcon className="h-3 w-3" />
{statusText}
</div>
</div>
);
-380
View File
@@ -1,380 +0,0 @@
import { useState, useEffect } from "react";
import {
Eye,
EyeOff,
Copy,
Check,
Shield,
AlertTriangle,
CheckCircle2,
XCircle,
Loader2,
} from "lucide-react";
import {
getConfig,
saveConfig,
Config,
checkApiCompatibility,
ApiCompatibilityResult,
} from "@/hooks/useTauri";
export function Settings() {
const [config, setConfig] = useState<Config | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
const [copied, setCopied] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
// API Compatibility Check
const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState<ApiCompatibilityResult | null>(
null,
);
const [lastCheckTime, setLastCheckTime] = useState<Date | null>(null);
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
const c = await getConfig();
setConfig(c);
} catch (e) {
console.error(e);
}
};
const handleSave = async () => {
if (!config) return;
setSaving(true);
setMessage(null);
try {
await saveConfig(config);
setMessage("设置已保存");
setTimeout(() => setMessage(null), 2000);
} catch (e: any) {
setMessage(`保存失败: ${e.toString()}`);
}
setSaving(false);
};
const copyApiKey = () => {
if (config) {
navigator.clipboard.writeText(config.server.api_key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleCheckApiCompatibility = async (provider: string) => {
setChecking(true);
setCheckResult(null);
try {
const result = await checkApiCompatibility(provider);
setCheckResult(result);
setLastCheckTime(new Date());
} catch (e) {
setMessage(`API 检测失败: ${e}`);
setTimeout(() => setMessage(null), 5000);
}
setChecking(false);
};
const getStatusIcon = (status: string) => {
switch (status) {
case "healthy":
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
case "partial":
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
case "error":
return <XCircle className="h-5 w-5 text-red-500" />;
default:
return null;
}
};
const getStatusText = (status: string) => {
switch (status) {
case "healthy":
return "所有模型可用";
case "partial":
return "部分模型可用";
case "error":
return "API 不可用";
default:
return "未知";
}
};
if (!config) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">
<svg
className="animate-spin h-8 w-8 mx-auto mb-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>加载配置中...</span>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">设置</h2>
<p className="text-muted-foreground">配置服务参数</p>
</div>
{message && (
<div
className={`rounded-lg border p-3 text-sm ${message.includes("失败") ? "border-red-500 bg-red-50 text-red-700" : "border-green-500 bg-green-50 text-green-700"}`}
>
{message}
</div>
)}
<div className="max-w-md space-y-4 rounded-lg border bg-card p-6">
<div>
<label className="mb-1 block text-sm font-medium">监听地址</label>
<input
type="text"
value={config.server.host}
onChange={(e) =>
setConfig({
...config,
server: { ...config.server, host: e.target.value },
})
}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium">端口</label>
<input
type="number"
value={config.server.port}
onChange={(e) =>
setConfig({
...config,
server: {
...config.server,
port: parseInt(e.target.value) || 3001,
},
})
}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium">API Key</label>
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showApiKey ? "text" : "password"}
value={config.server.api_key}
onChange={(e) =>
setConfig({
...config,
server: { ...config.server, api_key: e.target.value },
})
}
className="w-full rounded-lg border bg-background px-3 py-2 pr-20 text-sm"
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 gap-1">
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="rounded p-1 hover:bg-muted"
title={showApiKey ? "隐藏" : "显示"}
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
<button
type="button"
onClick={copyApiKey}
className="rounded p-1 hover:bg-muted"
title="复制"
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "保存中..." : "保存设置"}
</button>
</div>
{/* Claude Code 兼容性检测 */}
<div className="max-w-2xl space-y-4 rounded-lg border bg-card p-6">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-purple-500" />
<h3 className="font-semibold">Claude Code 兼容性检测</h3>
</div>
<p className="text-sm text-muted-foreground">
检测 API 是否支持 Claude Code 所需的功能:基础对话、Tool Calls 等
</p>
<div className="rounded-lg bg-purple-50 p-3 text-sm">
<p className="font-medium text-purple-700">检测项目:</p>
<ul className="mt-1 list-inside list-disc text-purple-600">
<li>基础对话能力 (basic)</li>
<li>Tool Calls 支持 (tool_call) - Claude Code 核心功能</li>
</ul>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleCheckApiCompatibility("kiro")}
disabled={checking}
className="flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700 disabled:opacity-50"
>
{checking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Shield className="h-4 w-4" />
)}
检测 Kiro
</button>
<button
onClick={() => handleCheckApiCompatibility("gemini")}
disabled={checking}
className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{checking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Shield className="h-4 w-4" />
)}
检测 Gemini
</button>
<button
onClick={() => handleCheckApiCompatibility("qwen")}
disabled={checking}
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
>
{checking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Shield className="h-4 w-4" />
)}
检测 Qwen
</button>
</div>
{lastCheckTime && (
<p className="text-xs text-muted-foreground">
最后检测时间: {lastCheckTime.toLocaleString()}
</p>
)}
{checkResult && (
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getStatusIcon(checkResult.overall_status)}
<span className="font-medium">
{checkResult.provider.toUpperCase()} -{" "}
{getStatusText(checkResult.overall_status)}
</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(checkResult.checked_at).toLocaleString()}
</span>
</div>
{/* 检测结果 */}
<div className="space-y-2">
<p className="text-sm font-medium">检测结果:</p>
{checkResult.results.map((r) => (
<div
key={r.model}
className={`flex items-center justify-between rounded p-2 text-sm ${
r.available ? "bg-green-50" : "bg-red-50"
}`}
>
<div className="flex items-center gap-2">
{r.available ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-red-500" />
)}
<span>
{r.model.includes("tool_call") ? (
<span className="font-medium text-purple-600">
{r.model}
</span>
) : (
r.model
)}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{r.status > 0 && <span>HTTP {r.status}</span>}
<span>{r.time_ms}ms</span>
{r.error_type && (
<span className="rounded bg-red-100 px-1 text-red-600">
{r.error_type}
</span>
)}
</div>
</div>
))}
</div>
{/* 警告信息 */}
{checkResult.warnings.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium text-yellow-600">警告:</p>
{checkResult.warnings.map((w, i) => (
<div
key={i}
className="flex items-start gap-2 rounded bg-yellow-50 p-2 text-sm text-yellow-700"
>
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>{w}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
+23 -7
View File
@@ -1,13 +1,24 @@
import {
LayoutDashboard,
Server,
Settings,
ScrollText,
Cpu,
Plug,
MessageSquare,
Key,
Monitor,
Globe,
} from "lucide-react";
import { cn } from "@/lib/utils";
type Page = "dashboard" | "providers" | "models" | "settings" | "logs";
type Page =
| "dashboard"
| "credentials"
| "clients"
| "api-server"
| "providers"
| "settings"
| "switch"
| "mcp"
| "prompts";
interface SidebarProps {
currentPage: Page;
@@ -16,10 +27,15 @@ interface SidebarProps {
const navItems = [
{ id: "dashboard" as Page, label: "仪表盘", icon: LayoutDashboard },
{ id: "providers" as Page, label: "Provider", icon: Server },
{ id: "models" as Page, label: "模型", icon: Cpu },
{ id: "logs" as Page, label: "日志", icon: ScrollText },
{ id: "credentials" as Page, label: "凭证管理", icon: Key },
{ id: "clients" as Page, label: "AI Clients", icon: Monitor },
{ id: "api-server" as Page, label: "API Server", icon: Globe },
{ id: "mcp" as Page, label: "MCP", icon: Plug },
{ id: "prompts" as Page, label: "Prompts", icon: MessageSquare },
{ id: "settings" as Page, label: "设置", icon: Settings },
// Legacy pages (hidden but accessible)
// { id: "providers" as Page, label: "Provider (旧)", icon: Server },
// { id: "switch" as Page, label: "Switch (旧)", icon: ArrowLeftRight },
];
export function Sidebar({ currentPage, onNavigate }: SidebarProps) {
+796
View File
@@ -0,0 +1,796 @@
import { useState, useEffect } from "react";
import {
Activity,
Server,
Zap,
Clock,
Play,
Copy,
Check,
ChevronDown,
ChevronUp,
Settings,
RefreshCw,
} from "lucide-react";
import { ModelsTab } from "./ModelsTab";
import { LogsTab } from "./LogsTab";
import {
startServer,
stopServer,
getServerStatus,
getConfig,
saveConfig,
reloadCredentials,
testApi,
ServerStatus,
Config,
TestResult,
getDefaultProvider,
setDefaultProvider,
// OpenAI/Claude Custom
getOpenAICustomStatus,
setOpenAICustomConfig,
getClaudeCustomStatus,
setClaudeCustomConfig,
OpenAICustomStatus,
ClaudeCustomStatus,
} from "@/hooks/useTauri";
interface TestState {
endpoint: string;
status: "idle" | "loading" | "success" | "error";
response?: string;
time?: number;
httpStatus?: number;
}
type TabId = "server" | "openai" | "claude" | "models" | "logs";
export function ApiServerPage() {
const [status, setStatus] = useState<ServerStatus | null>(null);
const [config, setConfig] = useState<Config | null>(null);
const [loading, setLoading] = useState(false);
const [_error, setError] = useState<string | null>(null);
const [testResults, setTestResults] = useState<Record<string, TestState>>({});
const [copiedCmd, setCopiedCmd] = useState<string | null>(null);
const [expandedTest, setExpandedTest] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<TabId>("server");
// Config editing
const [editPort, setEditPort] = useState<string>("");
const [editApiKey, setEditApiKey] = useState<string>("");
const [defaultProvider, setDefaultProviderState] = useState<string>("kiro");
// OpenAI Custom state
const [openaiStatus, setOpenaiStatus] = useState<OpenAICustomStatus | null>(
null,
);
const [openaiApiKey, setOpenaiApiKey] = useState("");
const [openaiBaseUrl, setOpenaiBaseUrl] = useState("");
// Claude Custom state
const [claudeStatus, setClaudeStatus] = useState<ClaudeCustomStatus | null>(
null,
);
const [claudeApiKey, setClaudeApiKey] = useState("");
const [claudeBaseUrl, setClaudeBaseUrl] = useState("");
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const fetchStatus = async () => {
try {
const s = await getServerStatus();
setStatus(s);
} catch (e) {
console.error(e);
}
};
const fetchConfig = async () => {
try {
const c = await getConfig();
setConfig(c);
setEditPort(c.server.port.toString());
setEditApiKey(c.server.api_key);
} catch (e) {
console.error(e);
}
};
const loadOpenAICustomStatus = async () => {
try {
const status = await getOpenAICustomStatus();
setOpenaiStatus(status);
setOpenaiBaseUrl(status.base_url);
} catch (e) {
console.error("Failed to load OpenAI Custom status:", e);
}
};
const loadClaudeCustomStatus = async () => {
try {
const status = await getClaudeCustomStatus();
setClaudeStatus(status);
setClaudeBaseUrl(status.base_url);
} catch (e) {
console.error("Failed to load Claude Custom status:", e);
}
};
useEffect(() => {
fetchStatus();
fetchConfig();
loadDefaultProvider();
loadOpenAICustomStatus();
loadClaudeCustomStatus();
const statusInterval = setInterval(fetchStatus, 3000);
return () => clearInterval(statusInterval);
}, []);
const loadDefaultProvider = async () => {
try {
const dp = await getDefaultProvider();
setDefaultProviderState(dp);
} catch (e) {
console.error("Failed to get default provider:", e);
}
};
const handleStart = async () => {
setLoading(true);
setError(null);
try {
await reloadCredentials();
await startServer();
await fetchStatus();
setMessage({ type: "success", text: "服务已启动" });
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
setError(errMsg);
setMessage({ type: "error", text: `启动失败: ${errMsg}` });
}
setLoading(false);
};
const handleStop = async () => {
setLoading(true);
try {
await stopServer();
await fetchStatus();
setMessage({ type: "success", text: "服务已停止" });
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
setError(errMsg);
setMessage({ type: "error", text: `停止失败: ${errMsg}` });
}
setLoading(false);
};
const handleSaveServerConfig = async () => {
if (!config) return;
setLoading(true);
try {
const newConfig = {
...config,
server: {
...config.server,
port: parseInt(editPort) || 3001,
api_key: editApiKey,
},
};
await saveConfig(newConfig);
await fetchConfig();
setMessage({ type: "success", text: "服务器配置已保存" });
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
setMessage({ type: "error", text: `保存失败: ${errMsg}` });
}
setLoading(false);
};
const handleSetDefaultProvider = async (providerId: string) => {
setLoading(true);
try {
await setDefaultProvider(providerId);
setDefaultProviderState(providerId);
setMessage({ type: "success", text: `默认 Provider 已切换为: ${providerId}` });
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
setMessage({ type: "error", text: `切换失败: ${errMsg}` });
}
setLoading(false);
};
const handleSaveOpenAIConfig = async () => {
setLoading(true);
try {
await setOpenAICustomConfig(
openaiApiKey || null,
openaiBaseUrl || null,
true,
);
await loadOpenAICustomStatus();
setMessage({ type: "success", text: "OpenAI 配置已保存" });
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
setMessage({ type: "error", text: `保存失败: ${errMsg}` });
}
setLoading(false);
};
const handleSaveClaudeConfig = async () => {
setLoading(true);
try {
await setClaudeCustomConfig(
claudeApiKey || null,
claudeBaseUrl || null,
true,
);
await loadClaudeCustomStatus();
setMessage({ type: "success", text: "Claude 配置已保存" });
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
setMessage({ type: "error", text: `保存失败: ${errMsg}` });
}
setLoading(false);
};
const formatUptime = (secs: number) => {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
return `${h}h ${m}m`;
};
const serverUrl = status
? `http://${status.host}:${status.port}`
: "http://localhost:3001";
const apiKey = config?.server.api_key || "proxycast-key";
// Test endpoints
const testEndpoints = [
{
id: "health",
name: "健康检查",
method: "GET",
path: "/health",
needsAuth: false,
body: null,
},
{
id: "models",
name: "模型列表",
method: "GET",
path: "/v1/models",
needsAuth: true,
body: null,
},
{
id: "chat",
name: "OpenAI Chat",
method: "POST",
path: "/v1/chat/completions",
needsAuth: true,
body: JSON.stringify({
model: "claude-sonnet-4-5",
messages: [{ role: "user", content: "Say hi in one word" }],
}),
},
{
id: "anthropic",
name: "Anthropic Messages",
method: "POST",
path: "/v1/messages",
needsAuth: true,
body: JSON.stringify({
model: "claude-sonnet-4-5",
max_tokens: 100,
messages: [{ role: "user", content: "What is 1+1? Answer with just the number." }],
}),
},
];
const runTest = async (endpoint: (typeof testEndpoints)[0]) => {
setTestResults((prev) => ({
...prev,
[endpoint.id]: { endpoint: endpoint.path, status: "loading" },
}));
try {
const result: TestResult = await testApi(
endpoint.method,
endpoint.path,
endpoint.body,
endpoint.needsAuth,
);
setTestResults((prev) => ({
...prev,
[endpoint.id]: {
endpoint: endpoint.path,
status: result.success ? "success" : "error",
response: result.body || `HTTP ${result.status}: 无响应内容`,
time: result.time_ms,
httpStatus: result.status,
},
}));
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
setTestResults((prev) => ({
...prev,
[endpoint.id]: {
endpoint: endpoint.path,
status: "error",
response: `请求失败: ${errMsg}`,
},
}));
}
};
const runAllTests = async () => {
for (const endpoint of testEndpoints) {
await runTest(endpoint);
}
};
const getCurlCommand = (endpoint: (typeof testEndpoints)[0]) => {
let cmd = `curl -s ${serverUrl}${endpoint.path}`;
if (endpoint.needsAuth) {
cmd += ` \\\n -H "Authorization: Bearer ${apiKey}"`;
}
if (endpoint.body) {
cmd += ` \\\n -H "Content-Type: application/json"`;
cmd += ` \\\n -d '${endpoint.body}'`;
}
return cmd;
};
const copyCommand = (id: string, cmd: string) => {
navigator.clipboard.writeText(cmd);
setCopiedCmd(id);
setTimeout(() => setCopiedCmd(null), 2000);
};
const getStatusBadge = (result?: TestState) => {
if (!result || result.status === "idle") {
return <span className="text-xs text-gray-400">未测试</span>;
}
if (result.status === "loading") {
return <span className="text-xs text-blue-500">测试中...</span>;
}
if (result.status === "success") {
return <span className="text-xs text-green-600">{result.time}ms</span>;
}
return (
<span className="text-xs text-red-500">
失败 {result.httpStatus ? `(${result.httpStatus})` : ""}
</span>
);
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">API Server</h2>
<p className="text-muted-foreground">
管理代理 API 服务器和自定义 API 配置
</p>
</div>
{message && (
<div
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
message.type === "success"
? "border-green-500 bg-green-50 text-green-700 dark:bg-green-950/30"
: "border-red-500 bg-red-50 text-red-700 dark:bg-red-950/30"
}`}
>
{message.type === "success" ? (
<Check className="h-4 w-4" />
) : (
<RefreshCw className="h-4 w-4" />
)}
{message.text}
</div>
)}
{/* Status Cards */}
<div className="grid grid-cols-4 gap-4">
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">状态</span>
</div>
<div className="mt-2 flex items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${status?.running ? "bg-green-500" : "bg-red-500"}`}
/>
<span className="font-medium">
{status?.running ? "运行中" : "已停止"}
</span>
</div>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">请求数</span>
</div>
<div className="mt-2 text-2xl font-bold">{status?.requests || 0}</div>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">运行时间</span>
</div>
<div className="mt-2 font-medium">
{formatUptime(status?.uptime_secs || 0)}
</div>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">默认 Provider</span>
</div>
<div className="mt-2 font-medium capitalize">{defaultProvider}</div>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b overflow-x-auto">
{[
{ id: "server" as TabId, name: "服务器控制" },
{ id: "openai" as TabId, name: "OpenAI 自定义" },
{ id: "claude" as TabId, name: "Claude 自定义" },
{ id: "models" as TabId, name: "可用模型" },
{ id: "logs" as TabId, name: "日志" },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px whitespace-nowrap ${
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{tab.name}
</button>
))}
</div>
{/* Server Control Tab */}
{activeTab === "server" && (
<div className="space-y-6">
{/* Server Control */}
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<Settings className="h-4 w-4" />
服务控制
</h3>
<div className="flex items-center gap-4 mb-4">
<button
className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
onClick={handleStart}
disabled={loading || status?.running}
>
{loading ? "处理中..." : "启动服务"}
</button>
<button
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
onClick={handleStop}
disabled={loading || !status?.running}
>
停止服务
</button>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<label className="block text-muted-foreground mb-1">端口</label>
<input
type="number"
value={editPort}
onChange={(e) => setEditPort(e.target.value)}
className="w-full rounded-lg border bg-background px-3 py-2"
/>
</div>
<div>
<label className="block text-muted-foreground mb-1">API Key</label>
<input
type="text"
value={editApiKey}
onChange={(e) => setEditApiKey(e.target.value)}
className="w-full rounded-lg border bg-background px-3 py-2"
/>
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-muted-foreground">
API 地址:{" "}
<code className="rounded bg-muted px-2 py-1">{serverUrl}</code>
</div>
<button
onClick={handleSaveServerConfig}
disabled={loading}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
>
保存配置
</button>
</div>
</div>
{/* Default Provider */}
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-semibold">默认 Provider</h3>
<div className="flex flex-wrap gap-2">
{["kiro", "gemini", "qwen", "openai", "claude"].map((p) => (
<button
key={p}
onClick={() => handleSetDefaultProvider(p)}
disabled={loading}
className={`rounded-lg px-4 py-2 text-sm font-medium ${
defaultProvider === p
? "bg-primary text-primary-foreground"
: "border hover:bg-muted"
} disabled:opacity-50`}
>
{p === "kiro"
? "Kiro Claude"
: p === "gemini"
? "Gemini CLI"
: p === "qwen"
? "通义千问"
: p === "openai"
? "OpenAI 自定义"
: "Claude 自定义"}
</button>
))}
</div>
</div>
{/* API Testing */}
<div className="rounded-lg border bg-card p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-semibold">API 测试</h3>
<button
onClick={runAllTests}
disabled={!status?.running}
className="flex items-center gap-2 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Play className="h-4 w-4" />
测试全部
</button>
</div>
<div className="space-y-3">
{testEndpoints.map((endpoint) => {
const result = testResults[endpoint.id];
const isExpanded = expandedTest === endpoint.id;
const curlCmd = getCurlCommand(endpoint);
return (
<div
key={endpoint.id}
className="rounded-lg border bg-background"
>
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-3">
<span
className={`rounded px-2 py-0.5 text-xs font-medium ${
endpoint.method === "GET"
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
}`}
>
{endpoint.method}
</span>
<span className="font-medium">{endpoint.name}</span>
<code className="text-xs text-muted-foreground">
{endpoint.path}
</code>
{getStatusBadge(result)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => copyCommand(endpoint.id, curlCmd)}
className="rounded p-1.5 hover:bg-muted"
title="复制 curl 命令"
>
{copiedCmd === endpoint.id ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
<button
onClick={() => runTest(endpoint)}
disabled={
!status?.running || result?.status === "loading"
}
className="rounded bg-primary/10 px-2 py-1 text-xs font-medium text-primary hover:bg-primary/20 disabled:opacity-50"
>
测试
</button>
<button
onClick={() =>
setExpandedTest(isExpanded ? null : endpoint.id)
}
className="rounded p-1.5 hover:bg-muted"
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</div>
</div>
{isExpanded && (
<div className="border-t p-3 space-y-3">
<div>
<p className="mb-1 text-xs font-medium text-muted-foreground">
curl 命令
</p>
<pre className="rounded bg-muted p-2 text-xs overflow-x-auto">
{curlCmd}
</pre>
</div>
{result?.response && (
<div>
<p className="mb-1 text-xs font-medium text-muted-foreground">
响应{" "}
{result.httpStatus && `(HTTP ${result.httpStatus})`}
</p>
<pre
className={`rounded p-2 text-xs overflow-x-auto max-h-40 ${
result.status === "success"
? "bg-green-50 dark:bg-green-950/30"
: "bg-red-50 dark:bg-red-950/30"
}`}
>
{(() => {
try {
return JSON.stringify(
JSON.parse(result.response),
null,
2,
);
} catch {
return result.response || "(空响应)";
}
})()}
</pre>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
)}
{/* OpenAI Custom Tab */}
{activeTab === "openai" && (
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-semibold">OpenAI 自定义 API 配置</h3>
<p className="text-sm text-muted-foreground mb-4">
配置自定义 OpenAI 兼容 API 端点,用于转发请求到其他 OpenAI 兼容服务
</p>
<div className="space-y-4">
<div>
<label className="block text-sm text-muted-foreground mb-1">
API Key
</label>
<input
type="password"
value={openaiApiKey}
onChange={(e) => setOpenaiApiKey(e.target.value)}
placeholder="sk-..."
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">
Base URL
</label>
<input
type="text"
value={openaiBaseUrl}
onChange={(e) => setOpenaiBaseUrl(e.target.value)}
placeholder="https://api.openai.com/v1"
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">状态:</span>
<span
className={
openaiStatus?.has_api_key ? "text-green-600" : "text-red-500"
}
>
{openaiStatus?.has_api_key ? "已配置" : "未配置"}
</span>
</div>
<button
onClick={handleSaveOpenAIConfig}
disabled={loading}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "保存中..." : "保存配置"}
</button>
</div>
</div>
)}
{/* Claude Custom Tab */}
{activeTab === "claude" && (
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-semibold">Claude 自定义 API 配置</h3>
<p className="text-sm text-muted-foreground mb-4">
配置自定义 Claude API 端点,用于直接调用 Anthropic API
</p>
<div className="space-y-4">
<div>
<label className="block text-sm text-muted-foreground mb-1">
API Key
</label>
<input
type="password"
value={claudeApiKey}
onChange={(e) => setClaudeApiKey(e.target.value)}
placeholder="sk-ant-..."
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">
Base URL
</label>
<input
type="text"
value={claudeBaseUrl}
onChange={(e) => setClaudeBaseUrl(e.target.value)}
placeholder="https://api.anthropic.com"
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">状态:</span>
<span
className={
claudeStatus?.has_api_key ? "text-green-600" : "text-red-500"
}
>
{claudeStatus?.has_api_key ? "已配置" : "未配置"}
</span>
</div>
<button
onClick={handleSaveClaudeConfig}
disabled={loading}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "保存中..." : "保存配置"}
</button>
</div>
</div>
)}
{/* Models Tab */}
{activeTab === "models" && <ModelsTab />}
{/* Logs Tab */}
{activeTab === "logs" && <LogsTab />}
</div>
);
}
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react";
import { Trash2, Download } from "lucide-react";
import { getLogs, clearLogs, LogEntry } from "@/hooks/useTauri";
export function Logs() {
export function LogsTab() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const logsEndRef = useRef<HTMLDivElement>(null);
@@ -81,36 +81,30 @@ export function Logs() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">日志</h2>
<p className="text-muted-foreground">查看请求和系统日志</p>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
className="rounded"
/>
自动滚动
</label>
<button
onClick={handleExport}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
>
<Download className="h-4 w-4" />
导出
</button>
<button
onClick={handleClear}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
>
<Trash2 className="h-4 w-4" />
清空
</button>
</div>
<div className="flex items-center justify-end gap-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
className="rounded"
/>
自动滚动
</label>
<button
onClick={handleExport}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
>
<Download className="h-4 w-4" />
导出
</button>
<button
onClick={handleClear}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
>
<Trash2 className="h-4 w-4" />
清空
</button>
</div>
<div className="rounded-lg border bg-card">
@@ -58,7 +58,7 @@ const MODEL_GROUPS: Record<
},
};
export function Models() {
export function ModelsTab() {
const [models, setModels] = useState<ModelInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -144,11 +144,6 @@ export function Models() {
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">可用模型</h2>
<p className="text-muted-foreground">查看当前可用的 AI 模型列表</p>
</div>
{error && (
<div className="rounded-lg border border-red-500 bg-red-50 p-4 text-red-700">
{error}
+35
View File
@@ -0,0 +1,35 @@
import { cn } from "@/lib/utils";
import { AppType } from "@/lib/api/switch";
interface AppTabsProps {
activeApp: AppType;
onAppChange: (app: AppType) => void;
}
const apps: { id: AppType; label: string; description: string }[] = [
{ id: "claude", label: "Claude Code", description: "Claude CLI 配置" },
{ id: "codex", label: "Codex", description: "OpenAI Codex CLI" },
{ id: "gemini", label: "Gemini", description: "Google Gemini CLI" },
];
export function AppTabs({ activeApp, onAppChange }: AppTabsProps) {
return (
<div className="flex gap-2 border-b pb-2">
{apps.map((app) => (
<button
key={app.id}
onClick={() => onAppChange(app.id)}
className={cn(
"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"
)}
title={app.description}
>
{app.label}
</button>
))}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useState } from "react";
import { AppType } from "@/lib/api/switch";
import { AppTabs } from "./AppTabs";
import { ProviderList } from "./ProviderList";
export function ClientsPage() {
const [activeApp, setActiveApp] = useState<AppType>("claude");
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">AI Clients</h2>
<p className="text-muted-foreground">
管理 Claude Code / Codex / Gemini CLI 的 Provider 配置
</p>
</div>
<AppTabs activeApp={activeApp} onAppChange={setActiveApp} />
<ProviderList appType={activeApp} />
</div>
);
}
@@ -0,0 +1,90 @@
import { useState, useEffect } from "react";
import { X, RefreshCw, FileText } from "lucide-react";
import { switchApi, AppType } from "@/lib/api/switch";
interface LiveConfigModalProps {
appType: AppType;
onClose: () => void;
}
const configPaths: Record<AppType, string> = {
claude: "~/.claude/settings.json",
codex: "~/.codex/auth.json & config.toml",
gemini: "~/.gemini/.env & settings.json",
proxycast: "",
};
export function LiveConfigModal({ appType, onClose }: LiveConfigModalProps) {
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadConfig = async () => {
setLoading(true);
setError(null);
try {
const data = await switchApi.readLiveSettings(appType);
setConfig(data);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
loadConfig();
}, [appType]);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-background rounded-xl shadow-lg w-full max-w-2xl max-h-[80vh] overflow-hidden border border-border">
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">当前生效的配置</h3>
</div>
<div className="flex items-center gap-2">
<button
onClick={loadConfig}
disabled={loading}
className="p-1.5 rounded hover:bg-muted"
title="刷新"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</button>
<button onClick={onClose} className="p-1.5 rounded hover:bg-muted">
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="p-4 overflow-auto max-h-[60vh]">
{loading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
<p className="text-destructive">{error}</p>
</div>
) : config ? (
<pre className="p-4 rounded-lg bg-muted/50 font-mono text-sm overflow-auto whitespace-pre-wrap">
{JSON.stringify(config, null, 2)}
</pre>
) : (
<p className="text-muted-foreground text-center py-8">
无配置数据
</p>
)}
</div>
<div className="p-4 border-t bg-muted/30">
<p className="text-xs text-muted-foreground">
配置文件路径: <code className="px-1 py-0.5 rounded bg-muted">{configPaths[appType]}</code>
</p>
</div>
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { Check, Edit2, Trash2, Zap } from "lucide-react";
import { Provider } from "@/lib/api/switch";
import { cn } from "@/lib/utils";
interface ProviderCardProps {
provider: Provider;
isCurrent: boolean;
onSwitch: () => void;
onEdit: () => void;
onDelete: () => void;
}
export function ProviderCard({
provider,
isCurrent,
onSwitch,
onEdit,
onDelete,
}: ProviderCardProps) {
return (
<div
className={cn(
"relative rounded-lg border p-4 transition-all",
isCurrent
? "border-primary bg-primary/5 ring-1 ring-primary"
: "hover:border-muted-foreground/50"
)}
>
{isCurrent && (
<div className="absolute -top-2 -right-2 rounded-full bg-primary p-1">
<Check className="h-3 w-3 text-primary-foreground" />
</div>
)}
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div
className="h-8 w-8 rounded-lg flex items-center justify-center text-lg"
style={{ backgroundColor: provider.icon_color || "#6366f1" }}
>
{provider.icon || provider.name.charAt(0).toUpperCase()}
</div>
<div>
<h3 className="font-medium">{provider.name}</h3>
{provider.category && (
<span className="text-xs text-muted-foreground">
{provider.category}
</span>
)}
</div>
</div>
<div className="flex gap-1">
<button
onClick={onEdit}
className="p-1.5 rounded hover:bg-muted"
title="编辑"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (!isCurrent) {
onDelete();
}
}}
disabled={isCurrent}
className={cn(
"p-1.5 rounded text-destructive",
isCurrent
? "opacity-30 cursor-not-allowed"
: "hover:bg-destructive/10"
)}
title={isCurrent ? "无法删除当前使用中的配置" : "删除"}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
{provider.notes && (
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">
{provider.notes}
</p>
)}
<div className="mt-4">
{isCurrent ? (
<span className="text-sm text-primary font-medium">当前使用中</span>
) : (
<button
onClick={onSwitch}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<Zap className="h-3.5 w-3.5" />
切换到此配置
</button>
)}
</div>
</div>
);
}
+883
View File
@@ -0,0 +1,883 @@
import React, { useState, useMemo, useCallback } from "react";
import { X, ExternalLink, Wand2, Eye, EyeOff } from "lucide-react";
import { Provider, AppType } from "@/lib/api/switch";
import { getConfig } from "@/hooks/useTauri";
import { cn } from "@/lib/utils";
interface ProviderFormProps {
appType: AppType;
provider: Provider | null;
onSave: (data: Omit<Provider, "id" | "is_current" | "created_at">) => Promise<void>;
onCancel: () => void;
}
// 供应商分类
type ProviderCategory = "official" | "cn_official" | "aggregator" | "third_party" | "proxy" | "custom";
// 预设供应商接口
interface ProviderPreset {
id: string;
name: string;
category: ProviderCategory;
iconColor?: string;
websiteUrl?: string;
apiKeyUrl?: string;
// Claude 专属默认配置
defaultBaseUrl?: string;
defaultModel?: string;
// Codex 专属默认配置
defaultCodexAuth?: Record<string, unknown>;
defaultCodexConfig?: string;
// Gemini 专属默认配置
defaultGeminiEnv?: Record<string, string>;
}
// 预设供应商配置
const presets: Record<AppType, ProviderPreset[]> = {
claude: [
// 官方
{
id: "anthropic",
name: "Anthropic 官方",
category: "official",
iconColor: "#D97757",
websiteUrl: "https://www.anthropic.com/claude-code",
apiKeyUrl: "https://console.anthropic.com/settings/keys",
},
// 国内官方
{
id: "deepseek",
name: "DeepSeek",
category: "cn_official",
iconColor: "#0066FF",
websiteUrl: "https://platform.deepseek.com",
apiKeyUrl: "https://platform.deepseek.com/api_keys",
defaultBaseUrl: "https://api.deepseek.com/anthropic",
},
{
id: "zhipu",
name: "智谱 GLM",
category: "cn_official",
iconColor: "#5B6BE8",
websiteUrl: "https://open.bigmodel.cn",
apiKeyUrl: "https://open.bigmodel.cn/usercenter/apikeys",
defaultBaseUrl: "https://open.bigmodel.cn/api/anthropic",
},
{
id: "qwen",
name: "通义千问 Coder",
category: "cn_official",
iconColor: "#6B4FBB",
websiteUrl: "https://bailian.console.aliyun.com",
apiKeyUrl: "https://bailian.console.aliyun.com/?apiKey=1#/api-key",
defaultBaseUrl: "https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy",
},
{
id: "kimi",
name: "Kimi k2",
category: "cn_official",
iconColor: "#000000",
websiteUrl: "https://platform.moonshot.cn/console",
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
defaultBaseUrl: "https://api.moonshot.cn/anthropic",
},
{
id: "kimi-coding",
name: "Kimi For Coding",
category: "cn_official",
iconColor: "#000000",
websiteUrl: "https://www.kimi.com/coding/docs/",
apiKeyUrl: "https://www.kimi.com/coding/profile",
defaultBaseUrl: "https://api.kimi.com/coding/",
},
{
id: "minimax",
name: "MiniMax",
category: "cn_official",
iconColor: "#F97316",
websiteUrl: "https://platform.minimaxi.com",
apiKeyUrl: "https://platform.minimaxi.com/user-center/basic-information/interface-key",
defaultBaseUrl: "https://api.minimaxi.com/anthropic",
},
{
id: "doubao",
name: "豆包 Seed",
category: "cn_official",
iconColor: "#5DADEC",
websiteUrl: "https://www.volcengine.com/product/doubao",
apiKeyUrl: "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey",
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/coding",
},
// 聚合服务
{
id: "openrouter",
name: "OpenRouter",
category: "aggregator",
iconColor: "#6366f1",
websiteUrl: "https://openrouter.ai",
apiKeyUrl: "https://openrouter.ai/keys",
defaultBaseUrl: "https://openrouter.ai/api",
},
// 本地代理
{
id: "proxycast",
name: "ProxyCast",
category: "proxy",
iconColor: "#3b82f6",
defaultBaseUrl: "http://127.0.0.1:3001",
},
// 自定义
{
id: "custom",
name: "自定义",
category: "custom",
iconColor: "#8b5cf6",
},
],
codex: [
// 官方
{
id: "openai",
name: "OpenAI 官方",
category: "official",
iconColor: "#10a37f",
websiteUrl: "https://chatgpt.com/codex",
apiKeyUrl: "https://platform.openai.com/api-keys",
},
// 第三方
{
id: "azure",
name: "Azure OpenAI",
category: "third_party",
iconColor: "#0078D4",
websiteUrl: "https://learn.microsoft.com/azure/ai-services/openai",
defaultCodexAuth: {
api_key: "",
api_base_url: "https://YOUR_RESOURCE_NAME.openai.azure.com/openai",
},
defaultCodexConfig: `# Azure OpenAI 配置
model = "gpt-4o"
`,
},
// 本地代理
{
id: "proxycast",
name: "ProxyCast",
category: "proxy",
iconColor: "#3b82f6",
defaultCodexAuth: {
api_key: "proxycast",
api_base_url: "http://127.0.0.1:3001/v1",
},
},
// 自定义
{
id: "custom",
name: "自定义",
category: "custom",
iconColor: "#8b5cf6",
},
],
gemini: [
// 官方
{
id: "google",
name: "Google 官方",
category: "official",
iconColor: "#4285f4",
websiteUrl: "https://ai.google.dev/",
apiKeyUrl: "https://aistudio.google.com/app/apikey",
},
// 本地代理
{
id: "proxycast",
name: "ProxyCast",
category: "proxy",
iconColor: "#3b82f6",
defaultGeminiEnv: {
GEMINI_API_KEY: "proxycast",
GOOGLE_GEMINI_BASE_URL: "http://127.0.0.1:3001",
GEMINI_MODEL: "gemini-2.0-flash",
},
},
// 自定义
{
id: "custom",
name: "自定义",
category: "custom",
iconColor: "#8b5cf6",
},
],
proxycast: [],
};
const categoryLabels: Record<ProviderCategory, string> = {
official: "官方",
cn_official: "国内官方",
aggregator: "聚合服务",
third_party: "第三方",
proxy: "本地代理",
custom: "自定义",
};
// 默认 Codex auth.json 模板
const defaultCodexAuth = JSON.stringify({
api_key: "",
api_base_url: ""
}, null, 2);
// 默认 Codex config.toml 模板
const defaultCodexConfig = `# Codex 配置文件
model = "gpt-4"
`;
// 默认 Gemini .env 模板
const defaultGeminiEnv = `GEMINI_API_KEY=
GOOGLE_GEMINI_BASE_URL=
GEMINI_MODEL=gemini-2.0-flash`;
// 默认 Gemini settings.json 模板
const defaultGeminiSettings = JSON.stringify({
mcpServers: {}
}, null, 2);
export function ProviderForm({ appType, provider, onSave, onCancel }: ProviderFormProps) {
const isEditMode = Boolean(provider);
const appPresets = useMemo(() => presets[appType] || [], [appType]);
// 基础字段
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
isEditMode ? null : "custom"
);
const [name, setName] = useState(provider?.name || "");
const [notes, setNotes] = useState(provider?.notes || "");
const [iconColor, setIconColor] = useState(provider?.icon_color || "#6366f1");
// 从 provider.settings_config 中提取 Claude 配置
const extractClaudeConfig = () => {
if (!provider?.settings_config || typeof provider.settings_config !== 'object') {
return { apiKey: "", baseUrl: "", primaryModel: "claude-sonnet-4-20250514", haikuModel: "", sonnetModel: "", opusModel: "" };
}
const env = (provider.settings_config as Record<string, unknown>).env as Record<string, string> | undefined;
if (!env) {
return { apiKey: "", baseUrl: "", primaryModel: "claude-sonnet-4-20250514", haikuModel: "", sonnetModel: "", opusModel: "" };
}
return {
apiKey: env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || "",
baseUrl: env.ANTHROPIC_BASE_URL || "",
primaryModel: env.ANTHROPIC_MODEL || "claude-sonnet-4-20250514",
haikuModel: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || "",
sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL || "",
opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL || "",
};
};
const claudeConfig = extractClaudeConfig();
// Claude 专属字段 - 编辑模式从现有配置加载
const [apiKey, setApiKey] = useState(claudeConfig.apiKey);
const [baseUrl, setBaseUrl] = useState(claudeConfig.baseUrl);
const [primaryModel, setPrimaryModel] = useState(claudeConfig.primaryModel);
const [haikuModel, setHaikuModel] = useState(claudeConfig.haikuModel);
const [sonnetModel, setSonnetModel] = useState(claudeConfig.sonnetModel);
const [opusModel, setOpusModel] = useState(claudeConfig.opusModel);
// Claude 配置 JSON(可编辑)
const [claudeConfigJson, setClaudeConfigJson] = useState(() => {
if (provider?.settings_config && typeof provider.settings_config === 'object') {
return JSON.stringify(provider.settings_config, null, 2);
}
return JSON.stringify({ env: {} }, null, 2);
});
const [jsonError, setJsonError] = useState<string | null>(null);
// 当表单字段变化时,同步更新 JSON(仅当 JSON 未被手动修改时)
const [jsonManuallyEdited, setJsonManuallyEdited] = useState(false);
// 从表单字段生成 JSON
const generateJsonFromFields = useCallback(() => {
const env: Record<string, string> = {};
if (apiKey) {
env.ANTHROPIC_AUTH_TOKEN = apiKey;
env.ANTHROPIC_API_KEY = apiKey;
}
if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl;
if (primaryModel) env.ANTHROPIC_MODEL = primaryModel;
if (haikuModel) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = haikuModel;
if (sonnetModel) env.ANTHROPIC_DEFAULT_SONNET_MODEL = sonnetModel;
if (opusModel) env.ANTHROPIC_DEFAULT_OPUS_MODEL = opusModel;
return JSON.stringify({ env }, null, 2);
}, [apiKey, baseUrl, primaryModel, haikuModel, sonnetModel, opusModel]);
// 同步表单到 JSON(当表单字段变化且 JSON 未被手动编辑时)
React.useEffect(() => {
if (!jsonManuallyEdited && appType === "claude") {
setClaudeConfigJson(generateJsonFromFields());
}
}, [generateJsonFromFields, jsonManuallyEdited, appType]);
// 处理 JSON 编辑
const handleJsonChange = (value: string) => {
setClaudeConfigJson(value);
setJsonManuallyEdited(true);
setJsonError(null);
// 尝试解析并同步回表单字段
try {
const parsed = JSON.parse(value);
const env = parsed.env || {};
setApiKey(env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || "");
setBaseUrl(env.ANTHROPIC_BASE_URL || "");
setPrimaryModel(env.ANTHROPIC_MODEL || "");
setHaikuModel(env.ANTHROPIC_DEFAULT_HAIKU_MODEL || "");
setSonnetModel(env.ANTHROPIC_DEFAULT_SONNET_MODEL || "");
setOpusModel(env.ANTHROPIC_DEFAULT_OPUS_MODEL || "");
} catch {
setJsonError("JSON 格式错误");
}
};
// 格式化 JSON
const handleFormatJson = () => {
try {
const parsed = JSON.parse(claudeConfigJson);
setClaudeConfigJson(JSON.stringify(parsed, null, 2));
setJsonError(null);
} catch {
setJsonError("JSON 格式错误,无法格式化");
}
};
// Codex 专属字段 - 使用代码编辑器
const [codexAuth, setCodexAuth] = useState(() => {
if (provider?.settings_config && typeof provider.settings_config === 'object') {
const auth = (provider.settings_config as Record<string, unknown>).auth;
if (auth) return JSON.stringify(auth, null, 2);
}
return defaultCodexAuth;
});
const [codexConfig, setCodexConfig] = useState(() => {
if (provider?.settings_config && typeof provider.settings_config === 'object') {
const config = (provider.settings_config as Record<string, unknown>).config;
if (typeof config === 'string') return config;
}
return defaultCodexConfig;
});
// Gemini 专属字段 - 使用代码编辑器
const [geminiEnv, setGeminiEnv] = useState(() => {
if (provider?.settings_config && typeof provider.settings_config === 'object') {
const env = (provider.settings_config as Record<string, unknown>).env;
if (env && typeof env === 'object') {
return Object.entries(env as Record<string, string>)
.map(([k, v]) => `${k}=${v}`)
.join('\n');
}
}
return defaultGeminiEnv;
});
const [geminiSettings, setGeminiSettings] = useState(() => {
if (provider?.settings_config && typeof provider.settings_config === 'object') {
const config = (provider.settings_config as Record<string, unknown>).config;
if (config && typeof config === 'object') {
return JSON.stringify(config, null, 2);
}
}
return defaultGeminiSettings;
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
// 当前选中的预设
const selectedPreset = useMemo(() => {
return appPresets.find((p) => p.id === selectedPresetId);
}, [appPresets, selectedPresetId]);
// 是否显示 API Key 和端点字段(编辑模式始终显示,新增模式非官方时显示)
const showApiFields = isEditMode || selectedPreset?.category !== "official";
// 按分类分组预设
const groupedPresets = useMemo(() => {
const groups: Partial<Record<ProviderCategory, typeof appPresets>> = {};
for (const preset of appPresets) {
const cat = preset.category;
if (!groups[cat]) groups[cat] = [];
groups[cat]!.push(preset);
}
return groups;
}, [appPresets]);
const handlePresetChange = async (presetId: string) => {
setSelectedPresetId(presetId);
const preset = appPresets.find((p) => p.id === presetId);
if (preset) {
setName(preset.name);
setIconColor(preset.iconColor || "#6366f1");
// ProxyCast 预设:自动从设置中读取 API Key 和端口
if (preset.id === "proxycast") {
try {
const config = await getConfig();
const proxyApiKey = config.server.api_key || "";
const proxyHost = config.server.host || "127.0.0.1";
const proxyPort = config.server.port || 3001;
const proxyBaseUrl = `http://${proxyHost}:${proxyPort}`;
if (appType === "claude") {
setApiKey(proxyApiKey);
setBaseUrl(proxyBaseUrl);
// 重置 JSON 手动编辑标记,让表单同步到 JSON
setJsonManuallyEdited(false);
} else if (appType === "codex") {
setCodexAuth(JSON.stringify({
api_key: proxyApiKey,
api_base_url: `${proxyBaseUrl}/v1`,
}, null, 2));
} else if (appType === "gemini") {
setGeminiEnv(`GEMINI_API_KEY=${proxyApiKey}\nGOOGLE_GEMINI_BASE_URL=${proxyBaseUrl}\nGEMINI_MODEL=gemini-2.0-flash`);
}
return;
} catch (e) {
console.error("Failed to load ProxyCast config:", e);
}
}
// 应用预设的默认配置
if (appType === "claude") {
// Claude: 设置默认 base URL
if (preset.defaultBaseUrl) {
setBaseUrl(preset.defaultBaseUrl);
} else {
setBaseUrl("");
}
if (preset.defaultModel) {
setPrimaryModel(preset.defaultModel);
}
// 重置 JSON 手动编辑标记
setJsonManuallyEdited(false);
} else if (appType === "codex") {
// Codex: 设置默认 auth.json
if (preset.defaultCodexAuth) {
setCodexAuth(JSON.stringify(preset.defaultCodexAuth, null, 2));
} else {
setCodexAuth(defaultCodexAuth);
}
if (preset.defaultCodexConfig) {
setCodexConfig(preset.defaultCodexConfig);
} else {
setCodexConfig(defaultCodexConfig);
}
} else if (appType === "gemini") {
// Gemini: 设置默认 env
if (preset.defaultGeminiEnv) {
const envLines = Object.entries(preset.defaultGeminiEnv)
.map(([k, v]) => `${k}=${v}`)
.join('\n');
setGeminiEnv(envLines);
} else {
setGeminiEnv(defaultGeminiEnv);
}
}
}
};
const buildSettingsConfig = (): Record<string, unknown> => {
if (appType === "claude") {
// 直接使用 JSON 编辑器的值
try {
return JSON.parse(claudeConfigJson);
} catch {
throw new Error("配置 JSON 格式错误");
}
}
if (appType === "codex") {
try {
const auth = JSON.parse(codexAuth);
return { auth, config: codexConfig };
} catch {
throw new Error("auth.json 格式错误");
}
}
if (appType === "gemini") {
const env: Record<string, string> = {};
for (const line of geminiEnv.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf('=');
if (idx > 0) {
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
env[key] = value;
}
}
try {
const config = JSON.parse(geminiSettings);
return { env, config };
} catch {
throw new Error("settings.json 格式错误");
}
}
return {};
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("请填写供应商名称");
return;
}
// 非官方供应商必填校验(新增模式必填,编辑模式可留空保持原值)
if (!isEditMode && showApiFields && appType === "claude" && !apiKey.trim()) {
setError("请填写 API Key");
return;
}
try {
setSaving(true);
const settings_config = buildSettingsConfig();
await onSave({
app_type: appType,
name: name.trim(),
settings_config,
category: selectedPreset?.category || "custom",
icon_color: iconColor,
notes: notes.trim() || undefined,
});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-background rounded-xl shadow-lg w-full max-w-3xl max-h-[90vh] overflow-auto border border-border">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-semibold">
{isEditMode ? "编辑供应商" : "添加供应商"}
</h3>
<button onClick={onCancel} className="p-1 hover:bg-muted rounded">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* 预设选择器(仅新增模式) */}
{!isEditMode && (
<div className="space-y-3">
<label className="block text-sm font-medium">选择预设</label>
<div className="space-y-4">
{Object.entries(groupedPresets).map(([category, items]) => (
<div key={category}>
<p className="text-xs text-muted-foreground mb-2">
{categoryLabels[category as ProviderCategory] || category}
</p>
<div className="flex flex-wrap gap-2">
{items.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => handlePresetChange(preset.id)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-all",
selectedPresetId === preset.id
? "border-primary bg-primary/10 text-primary"
: "border-border hover:border-muted-foreground/50"
)}
>
<span
className="w-4 h-4 rounded"
style={{ backgroundColor: preset.iconColor }}
/>
{preset.name}
</button>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* 基础字段 */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1.5">
供应商名称 <span className="text-destructive">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
placeholder="输入供应商名称"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">备注</label>
<input
type="text"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
placeholder="可选备注信息"
/>
</div>
</div>
{/* Claude 表单字段 */}
{appType === "claude" && showApiFields && (
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-medium">
API Key <span className="text-destructive">*</span>
</label>
{(selectedPreset?.apiKeyUrl || selectedPreset?.websiteUrl) && (
<a
href={selectedPreset?.apiKeyUrl || selectedPreset?.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-1"
>
获取 API Key
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="w-full px-3 py-2 pr-10 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-mono"
placeholder="sk-..."
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-muted"
title={showApiKey ? "隐藏" : "显示"}
>
{showApiKey ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">API 端点</label>
<input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-mono"
placeholder="https://api.example.com/v1"
/>
<p className="text-xs text-muted-foreground mt-1">留空使用默认端点</p>
</div>
<div className="space-y-4">
<label className="block text-sm font-medium">模型配置</label>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-muted-foreground mb-1">主模型</label>
<input
type="text"
value={primaryModel}
onChange={(e) => setPrimaryModel(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none text-sm"
placeholder="claude-sonnet-4-20250514"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">Haiku 默认模型</label>
<input
type="text"
value={haikuModel}
onChange={(e) => setHaikuModel(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none text-sm"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">Sonnet 默认模型</label>
<input
type="text"
value={sonnetModel}
onChange={(e) => setSonnetModel(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none text-sm"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">Opus 默认模型</label>
<input
type="text"
value={opusModel}
onChange={(e) => setOpusModel(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none text-sm"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
可选:指定默认使用的 Claude 模型,留空则使用系统默认。
</p>
</div>
{/* 配置 JSON 编辑器 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-medium">配置 JSON</label>
<button
type="button"
onClick={handleFormatJson}
className="flex items-center gap-1 text-xs text-primary hover:underline"
>
<Wand2 className="h-3 w-3" />
格式化
</button>
</div>
<textarea
value={claudeConfigJson}
onChange={(e) => handleJsonChange(e.target.value)}
className={cn(
"w-full px-3 py-2 rounded-lg border bg-muted/50 font-mono text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none resize-none",
jsonError && "border-destructive focus:ring-destructive/20 focus:border-destructive"
)}
rows={10}
placeholder='{"env": {}}'
/>
{jsonError ? (
<p className="text-xs text-destructive mt-1">{jsonError}</p>
) : (
<p className="text-xs text-muted-foreground mt-1">
可手动编辑 JSON,修改会同步到上方表单
</p>
)}
</div>
</div>
)}
{/* Codex 配置编辑器 */}
{appType === "codex" && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* auth.json 编辑器 */}
<div>
<label className="block text-sm font-medium mb-1.5">
auth.json
</label>
<textarea
value={codexAuth}
onChange={(e) => setCodexAuth(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-muted/50 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-mono text-sm"
rows={8}
placeholder='{"api_key": "", "api_base_url": ""}'
/>
<p className="text-xs text-muted-foreground mt-1">
JSON 格式的认证配置
</p>
</div>
{/* config.toml 编辑器 */}
<div>
<label className="block text-sm font-medium mb-1.5">
config.toml
</label>
<textarea
value={codexConfig}
onChange={(e) => setCodexConfig(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-muted/50 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-mono text-sm"
rows={8}
placeholder='model = "gpt-4"'
/>
<p className="text-xs text-muted-foreground mt-1">
TOML 格式的配置文件
</p>
</div>
</div>
</div>
)}
{/* Gemini 配置编辑器 */}
{appType === "gemini" && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* .env 编辑器 */}
<div>
<label className="block text-sm font-medium mb-1.5">
env 环境变量
</label>
<textarea
value={geminiEnv}
onChange={(e) => setGeminiEnv(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-muted/50 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-mono text-sm"
rows={8}
placeholder="GEMINI_API_KEY=&#10;GOOGLE_GEMINI_BASE_URL=&#10;GEMINI_MODEL=gemini-2.0-flash"
/>
<p className="text-xs text-muted-foreground mt-1">
每行一个环境变量,格式:KEY=VALUE
</p>
</div>
{/* settings.json 编辑器 */}
<div>
<label className="block text-sm font-medium mb-1.5">
settings.json
</label>
<textarea
value={geminiSettings}
onChange={(e) => setGeminiSettings(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-muted/50 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-mono text-sm"
rows={8}
placeholder='{"mcpServers": {}}'
/>
<p className="text-xs text-muted-foreground mt-1">
JSON 格式的配置文件(MCP 服务器等)
</p>
</div>
</div>
</div>
)}
{/* 错误提示 */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* 按钮 */}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg border hover:bg-muted transition-colors"
>
取消
</button>
<button
type="submit"
disabled={saving || !name.trim()}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saving ? "保存中..." : isEditMode ? "保存" : "添加"}
</button>
</div>
</form>
</div>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { useState } from "react";
import { Plus, RefreshCw, Eye } from "lucide-react";
import { AppType } from "@/lib/api/switch";
import { useSwitch } from "@/hooks/useSwitch";
import { ProviderCard } from "./ProviderCard";
import { ProviderForm } from "./ProviderForm";
import { LiveConfigModal } from "./LiveConfigModal";
interface ProviderListProps {
appType: AppType;
}
export function ProviderList({ appType }: ProviderListProps) {
const {
providers,
currentProvider,
loading,
error,
addProvider,
updateProvider,
deleteProvider,
switchToProvider,
refresh,
} = useSwitch(appType);
const [showForm, setShowForm] = useState(false);
const [editingProvider, setEditingProvider] = useState<typeof providers[0] | null>(null);
const [showLiveConfig, setShowLiveConfig] = useState(false);
const handleAdd = () => {
setEditingProvider(null);
setShowForm(true);
};
const handleEdit = (provider: typeof providers[0]) => {
setEditingProvider(provider);
setShowForm(true);
};
const handleSave = async (data: Parameters<typeof addProvider>[0]) => {
if (editingProvider) {
await updateProvider({ ...editingProvider, ...data });
} else {
await addProvider(data);
}
setShowForm(false);
setEditingProvider(null);
};
const handleDelete = async (id: string) => {
// 不能删除当前使用中的 provider
if (currentProvider?.id === id) {
alert("无法删除当前使用中的配置");
return;
}
if (confirm("确定要删除这个 Provider 吗?")) {
try {
await deleteProvider(id);
} catch (e) {
alert("删除失败: " + (e instanceof Error ? e.message : String(e)));
}
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
<p className="text-destructive">{error}</p>
<button
onClick={refresh}
className="mt-2 text-sm text-muted-foreground hover:underline"
>
重试
</button>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">
当前: {currentProvider?.name || "未设置"}
</p>
<button
onClick={() => setShowLiveConfig(true)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title="查看当前生效的配置"
>
<Eye className="h-4 w-4" />
</button>
</div>
<div className="flex gap-2">
<button
onClick={refresh}
className="p-2 rounded-lg hover:bg-muted"
title="刷新"
>
<RefreshCw className="h-4 w-4" />
</button>
<button
onClick={handleAdd}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm"
>
<Plus className="h-4 w-4" />
添加 Provider
</button>
</div>
</div>
{providers.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>暂无 Provider 配置</p>
<p className="text-sm mt-1">点击上方按钮添加第一个配置</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{providers.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isCurrent={provider.id === currentProvider?.id}
onSwitch={() => switchToProvider(provider.id)}
onEdit={() => handleEdit(provider)}
onDelete={() => handleDelete(provider.id)}
/>
))}
</div>
)}
{showForm && (
<ProviderForm
appType={appType}
provider={editingProvider}
onSave={handleSave}
onCancel={() => {
setShowForm(false);
setEditingProvider(null);
}}
/>
)}
{showLiveConfig && (
<LiveConfigModal
appType={appType}
onClose={() => setShowLiveConfig(false)}
/>
)}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
export { ClientsPage } from "./ClientsPage";
export { AppTabs } from "./AppTabs";
export { ProviderList } from "./ProviderList";
export { ProviderCard } from "./ProviderCard";
export { ProviderForm } from "./ProviderForm";
@@ -0,0 +1,401 @@
import { useState, useEffect } from "react";
import {
RefreshCw,
FolderOpen,
AlertCircle,
CheckCircle2,
Eye,
EyeOff,
Copy,
FileText,
Key,
} from "lucide-react";
import {
useOAuthCredentials,
useAllOAuthCredentials,
} from "@/hooks/useOAuthCredentials";
import { OAuthProvider, EnvVariable } from "@/lib/api/credentials";
type TabId = "kiro" | "gemini" | "qwen";
const tabs: { id: TabId; name: string; description: string }[] = [
{
id: "kiro",
name: "Kiro Claude",
description: "通过 Kiro OAuth 访问 Claude Sonnet 4.5",
},
{
id: "gemini",
name: "Gemini CLI",
description: "通过 Gemini CLI OAuth 访问 Gemini 模型",
},
{
id: "qwen",
name: "通义千问",
description: "通过 Qwen OAuth 访问通义千问",
},
];
function CredentialPanel({ provider }: { provider: OAuthProvider }) {
const {
credentials,
envVariables,
loading,
refreshing,
error,
reloadFromFile,
refreshToken,
} = useOAuthCredentials(provider);
const [showEnv, setShowEnv] = useState(false);
const [showValues, setShowValues] = useState(false);
const [copied, setCopied] = useState<string | null>(null);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const handleReload = async () => {
setMessage(null);
try {
await reloadFromFile();
setMessage({ type: "success", text: "凭证加载成功!" });
} catch (e) {
setMessage({
type: "error",
text: `加载失败: ${e instanceof Error ? e.message : String(e)}`,
});
}
};
const handleRefresh = async () => {
setMessage(null);
try {
await refreshToken();
setMessage({ type: "success", text: "Token 刷新成功!" });
} catch (e) {
setMessage({
type: "error",
text: `刷新失败: ${e instanceof Error ? e.message : String(e)}`,
});
}
};
const copyValue = (key: string, value: string) => {
navigator.clipboard.writeText(value);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
};
const copyAllEnv = (vars: EnvVariable[]) => {
navigator.clipboard.writeText(
vars.map((v) => `${v.key}=${v.value}`).join("\n"),
);
setCopied("all");
setTimeout(() => setCopied(null), 2000);
};
const isLoading = loading || refreshing;
// Provider-specific extra info
const renderExtraInfo = () => {
if (!credentials?.extra) return null;
const extra = credentials.extra as Record<string, unknown>;
if (provider === "kiro") {
return (
<>
<div>
<span className="text-muted-foreground">区域:</span>
<span className="ml-2">{(extra.region as string) || "未设置"}</span>
</div>
<div>
<span className="text-muted-foreground">认证方式:</span>
<span className="ml-2">
{(extra.auth_method as string) || "social"}
</span>
</div>
</>
);
}
if (provider === "qwen" && extra.resource_url) {
return (
<div className="col-span-2">
<span className="text-muted-foreground">Resource URL:</span>
<code className="ml-2 rounded bg-muted px-2 py-0.5 text-xs break-all">
{extra.resource_url as string}
</code>
</div>
);
}
return null;
};
return (
<div className="space-y-4">
{message && (
<div
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
message.type === "success"
? "border-green-500 bg-green-50 text-green-700 dark:bg-green-950/30"
: "border-red-500 bg-red-50 text-red-700 dark:bg-red-950/30"
}`}
>
{message.type === "success" ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
{message.text}
</div>
)}
{error && (
<div className="flex items-center gap-2 rounded-lg border border-red-500 bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30">
<AlertCircle className="h-4 w-4" />
{error}
</div>
)}
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="font-semibold flex items-center gap-2">
<Key className="h-4 w-4" />
凭证状态
</h3>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<span
className={`h-2 w-2 rounded-full ${
credentials?.is_valid
? "bg-green-500"
: credentials?.loaded
? "bg-yellow-500"
: "bg-gray-400"
} animate-pulse`}
/>
{credentials?.is_valid
? "有效"
: credentials?.loaded
? "已加载"
: "未加载"}
</span>
</div>
</div>
<div className="mb-4 grid grid-cols-2 gap-4 text-sm">
<div className="col-span-2">
<span className="text-muted-foreground">凭证路径:</span>
<code className="ml-2 rounded bg-muted px-2 py-0.5 text-xs break-all">
{credentials?.creds_path || "未知"}
</code>
</div>
<div>
<span className="text-muted-foreground">Access Token:</span>
<span
className={`ml-2 ${credentials?.has_access_token ? "text-green-600" : "text-red-500"}`}
>
{credentials?.has_access_token ? "已加载" : "未加载"}
</span>
</div>
<div>
<span className="text-muted-foreground">Refresh Token:</span>
<span
className={`ml-2 ${credentials?.has_refresh_token ? "text-green-600" : "text-red-500"}`}
>
{credentials?.has_refresh_token ? "已加载" : "未加载"}
</span>
</div>
{renderExtraInfo()}
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={handleReload}
disabled={isLoading}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<FolderOpen className="h-4 w-4" />
{loading ? "加载中..." : "读取凭证"}
</button>
<button
onClick={handleRefresh}
disabled={isLoading || !credentials?.has_refresh_token}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
刷新 Token
</button>
<button
onClick={() => setShowEnv(!showEnv)}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted"
>
<FileText className="h-4 w-4" />
{showEnv ? "隐藏" : "查看"} .env 变量
</button>
</div>
</div>
{/* Environment Variables */}
{showEnv && (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="font-semibold">.env 环境变量</h3>
<div className="flex items-center gap-2">
<button
onClick={() => setShowValues(!showValues)}
className="flex items-center gap-1 rounded px-2 py-1 text-xs hover:bg-muted"
>
{showValues ? (
<EyeOff className="h-3 w-3" />
) : (
<Eye className="h-3 w-3" />
)}
{showValues ? "隐藏值" : "显示值"}
</button>
<button
onClick={() => copyAllEnv(envVariables)}
className="flex items-center gap-1 rounded px-2 py-1 text-xs hover:bg-muted"
>
{copied === "all" ? (
<CheckCircle2 className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
复制全部
</button>
</div>
</div>
{envVariables.length === 0 ? (
<p className="text-sm text-muted-foreground">
暂无环境变量,请先加载凭证
</p>
) : (
<div className="space-y-2 font-mono text-sm">
{envVariables.map((v) => (
<div
key={v.key}
className="flex items-center gap-2 rounded bg-muted p-2"
>
<span className="text-blue-600 dark:text-blue-400 shrink-0">
{v.key}
</span>
<span>=</span>
<span className="flex-1 truncate text-muted-foreground">
{showValues ? v.value : v.masked}
</span>
<button
onClick={() => copyValue(v.key, v.value)}
className="rounded p-1 hover:bg-background shrink-0"
>
{copied === v.key ? (
<CheckCircle2 className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
function CredentialOverview() {
const { credentials, reload } = useAllOAuthCredentials();
useEffect(() => {
reload();
}, [reload]);
return (
<div className="grid grid-cols-3 gap-4">
{credentials.map((cred) => {
const tab = tabs.find((t) => t.id === cred.provider);
return (
<div
key={cred.provider}
className="rounded-lg border bg-card p-4 space-y-2"
>
<div className="flex items-center justify-between">
<h4 className="font-medium">{tab?.name || cred.provider}</h4>
<span
className={`h-2 w-2 rounded-full ${
cred.is_valid
? "bg-green-500"
: cred.loaded
? "bg-yellow-500"
: "bg-gray-400"
}`}
/>
</div>
<p className="text-xs text-muted-foreground">
{tab?.description || ""}
</p>
<div className="text-xs space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Access Token</span>
<span className={cred.has_access_token ? "text-green-600" : "text-red-500"}>
{cred.has_access_token ? "已加载" : "未加载"}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Refresh Token</span>
<span className={cred.has_refresh_token ? "text-green-600" : "text-red-500"}>
{cred.has_refresh_token ? "已加载" : "未加载"}
</span>
</div>
</div>
</div>
);
})}
</div>
);
}
export function CredentialsPage() {
const [activeTab, setActiveTab] = useState<TabId>("kiro");
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">OAuth 凭证管理</h2>
<p className="text-muted-foreground">
管理 Kiro/Gemini/Qwen 的 OAuth 凭证
</p>
</div>
{/* Overview Cards */}
<CredentialOverview />
{/* Tabs */}
<div className="flex gap-2 border-b overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px whitespace-nowrap ${
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{tab.name}
</button>
))}
</div>
{/* Tab Content */}
<CredentialPanel provider={activeTab} />
<p className="text-xs text-muted-foreground">
系统每 5 秒自动检查凭证文件变化,如有更新会自动重新加载并记录日志
</p>
</div>
);
}
+525
View File
@@ -0,0 +1,525 @@
import { useState } from "react";
import { Plus, RefreshCw, Trash2, Download, Upload } from "lucide-react";
import { useMcpServers } from "@/hooks/useMcpServers";
import { McpServer } from "@/lib/api/mcp";
import { cn } from "@/lib/utils";
// 预设 MCP 服务器配置
const mcpPresets = [
{
id: "filesystem",
name: "Filesystem",
description: "文件系统访问",
server_config: {
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
},
},
{
id: "github",
name: "GitHub",
description: "GitHub API",
server_config: {
command: "npx",
args: ["-y", "@modelcontextprotocol/server-github"],
env: { GITHUB_TOKEN: "" },
},
},
{
id: "postgres",
name: "PostgreSQL",
description: "数据库访问",
server_config: {
command: "npx",
args: ["-y", "@modelcontextprotocol/server-postgres"],
env: { DATABASE_URL: "" },
},
},
{
id: "custom",
name: "自定义",
description: "自定义配置",
server_config: {
command: "",
args: [],
},
},
];
// 默认配置模板
const defaultServerConfig = JSON.stringify(
{
command: "npx",
args: ["-y", "@modelcontextprotocol/server-example"],
env: {},
},
null,
2
);
export function McpPage() {
const {
servers,
loading,
importing,
error,
addServer,
updateServer,
deleteServer,
importFromApp,
importFromAllApps,
syncAllToLive,
refresh,
} = useMcpServers();
const [selectedServer, setSelectedServer] = useState<McpServer | null>(null);
const [isCreating, setIsCreating] = useState(false);
// 编辑表单状态
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editConfig, setEditConfig] = useState("");
const [enabledClaude, setEnabledClaude] = useState(true);
const [enabledCodex, setEnabledCodex] = useState(true);
const [enabledGemini, setEnabledGemini] = useState(true);
const [saving, setSaving] = useState(false);
const [configError, setConfigError] = useState<string | null>(null);
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [showImportMenu, setShowImportMenu] = useState(false);
const handleImport = async (appType?: string) => {
setShowImportMenu(false);
try {
const count = appType
? await importFromApp(appType)
: await importFromAllApps();
if (count > 0) {
alert(`成功导入/更新 ${count} 个 MCP 服务器配置`);
} else {
alert("没有找到 MCP 配置可导入");
}
} catch (e) {
alert("导入失败: " + (e instanceof Error ? e.message : String(e)));
}
};
const handleSyncToLive = async () => {
try {
await syncAllToLive();
alert("同步完成");
} catch (e) {
alert("同步失败: " + (e instanceof Error ? e.message : String(e)));
}
};
const handleSelectServer = (server: McpServer) => {
setSelectedServer(server);
setIsCreating(false);
setEditName(server.name);
setEditDescription(server.description || "");
setEditConfig(JSON.stringify(server.server_config, null, 2));
setEnabledClaude(server.enabled_claude);
setEnabledCodex(server.enabled_codex);
setEnabledGemini(server.enabled_gemini);
setConfigError(null);
setSelectedPreset(null);
};
const handleCreateNew = () => {
setSelectedServer(null);
setIsCreating(true);
setEditName("");
setEditDescription("");
setEditConfig(defaultServerConfig);
setEnabledClaude(true);
setEnabledCodex(true);
setEnabledGemini(true);
setConfigError(null);
setSelectedPreset("custom");
};
const handlePresetSelect = (presetId: string) => {
const preset = mcpPresets.find((p) => p.id === presetId);
if (preset) {
setSelectedPreset(presetId);
if (presetId !== "custom") {
setEditName(preset.name);
setEditDescription(preset.description);
}
setEditConfig(JSON.stringify(preset.server_config, null, 2));
setConfigError(null);
}
};
const handleConfigChange = (value: string) => {
setEditConfig(value);
try {
JSON.parse(value);
setConfigError(null);
} catch {
setConfigError("JSON 格式错误");
}
};
const handleSave = async () => {
if (!editName.trim()) {
alert("请输入服务器名称");
return;
}
let serverConfig;
try {
serverConfig = JSON.parse(editConfig);
} catch {
setConfigError("JSON 格式错误,无法保存");
return;
}
setSaving(true);
try {
if (isCreating) {
await addServer({
name: editName.trim(),
description: editDescription.trim() || undefined,
server_config: serverConfig,
enabled_proxycast: false,
enabled_claude: enabledClaude,
enabled_codex: enabledCodex,
enabled_gemini: enabledGemini,
});
setIsCreating(false);
setSelectedServer(null);
} else if (selectedServer) {
await updateServer({
...selectedServer,
name: editName.trim(),
description: editDescription.trim() || undefined,
server_config: serverConfig,
enabled_claude: enabledClaude,
enabled_codex: enabledCodex,
enabled_gemini: enabledGemini,
});
}
} catch (e) {
alert("保存失败: " + (e instanceof Error ? e.message : String(e)));
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (confirm("确定要删除这个 MCP 服务器吗?")) {
await deleteServer(id);
if (selectedServer?.id === id) {
setSelectedServer(null);
}
}
};
// 获取启用的应用标签
const getEnabledApps = (server: McpServer) => {
const apps: string[] = [];
if (server.enabled_claude) apps.push("Claude");
if (server.enabled_codex) apps.push("Codex");
if (server.enabled_gemini) apps.push("Gemini");
return apps;
};
return (
<div className="h-full flex flex-col">
<div className="mb-4 flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold">MCP 服务器</h2>
<p className="text-muted-foreground">
管理 Model Context Protocol 服务器配置,同步到外部应用
</p>
</div>
<div className="flex items-center gap-2">
{/* 从外部导入按钮 */}
<div className="relative">
<button
onClick={() => setShowImportMenu(!showImportMenu)}
disabled={importing}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border hover:bg-muted text-sm"
title="从外部应用导入 MCP 配置"
>
<Download className={cn("h-4 w-4", importing && "animate-pulse")} />
{importing ? "导入中..." : "导入"}
</button>
{showImportMenu && (
<div className="absolute right-0 top-full mt-1 w-40 py-1 bg-popover border rounded-lg shadow-lg z-10">
<button
onClick={() => handleImport()}
className="w-full px-3 py-1.5 text-left text-sm hover:bg-muted"
>
全部导入
</button>
<button
onClick={() => handleImport("claude")}
className="w-full px-3 py-1.5 text-left text-sm hover:bg-muted"
>
从 Claude Code
</button>
<button
onClick={() => handleImport("codex")}
className="w-full px-3 py-1.5 text-left text-sm hover:bg-muted"
>
从 Codex
</button>
<button
onClick={() => handleImport("gemini")}
className="w-full px-3 py-1.5 text-left text-sm hover:bg-muted"
>
从 Gemini CLI
</button>
</div>
)}
</div>
{/* 同步到外部按钮 */}
<button
onClick={handleSyncToLive}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm"
title="同步配置到所有外部应用"
>
<Upload className="h-4 w-4" />
同步
</button>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive bg-destructive/10 p-4 mb-4">
<p className="text-destructive">{error}</p>
</div>
)}
{/* 主内容区域 - 左右分栏 */}
<div className="flex-1 flex gap-4 min-h-0">
{/* 左侧列表 */}
<div className="w-64 flex flex-col border rounded-lg">
<div className="p-3 border-b flex items-center justify-between">
<span className="text-sm font-medium">服务器列表</span>
<div className="flex gap-1">
<button
onClick={refresh}
className="p-1.5 rounded hover:bg-muted"
title="刷新"
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</button>
<button
onClick={handleCreateNew}
className="p-1.5 rounded hover:bg-muted text-primary"
title="新建"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-2 space-y-1">
{loading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : servers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
<p>暂无 MCP 服务器</p>
<button
onClick={handleCreateNew}
className="text-primary hover:underline mt-1"
>
添加第一个
</button>
</div>
) : (
servers.map((server) => (
<div
key={server.id}
onClick={() => handleSelectServer(server)}
className={cn(
"p-2.5 rounded-lg cursor-pointer transition-colors",
selectedServer?.id === server.id
? "bg-primary/10 border border-primary"
: "hover:bg-muted border border-transparent"
)}
>
<span className="font-medium text-sm truncate block">
{server.name}
</span>
{server.description && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{server.description}
</p>
)}
{/* 启用的应用标签 */}
<div className="flex flex-wrap gap-1 mt-1.5">
{getEnabledApps(server).map((app) => (
<span
key={app}
className="px-1.5 py-0.5 text-xs rounded bg-muted text-muted-foreground"
>
{app}
</span>
))}
</div>
</div>
))
)}
</div>
</div>
{/* 右侧编辑面板 */}
<div className="flex-1 border rounded-lg flex flex-col min-w-0">
{!selectedServer && !isCreating ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<p>选择一个 MCP 服务器进行编辑</p>
<p className="text-sm mt-1">或点击 + 添加新的服务器</p>
</div>
</div>
) : (
<>
<div className="p-4 border-b space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold">
{isCreating ? "添加 MCP 服务器" : "编辑 MCP 服务器"}
</h3>
{selectedServer && (
<button
onClick={() => handleDelete(selectedServer.id)}
className="p-1.5 rounded hover:bg-destructive/10 text-destructive"
title="删除"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
{/* 预设选择器(仅新建时显示) */}
{isCreating && (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">预设:</span>
<div className="flex flex-wrap gap-1.5">
{mcpPresets.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => handlePresetSelect(preset.id)}
className={cn(
"px-2.5 py-1 rounded text-xs transition-colors",
selectedPreset === preset.id
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80 text-muted-foreground"
)}
>
{preset.name}
</button>
))}
</div>
</div>
)}
{/* 名称和描述 - 横排 */}
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-xs font-medium mb-1 text-muted-foreground">
名称 <span className="text-destructive">*</span>
</label>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="w-full px-2.5 py-1.5 rounded border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none text-sm"
placeholder="服务器名称"
/>
</div>
<div className="flex-1">
<label className="block text-xs font-medium mb-1 text-muted-foreground">描述</label>
<input
type="text"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
className="w-full px-2.5 py-1.5 rounded border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none text-sm"
placeholder="可选描述"
/>
</div>
</div>
{/* 同步到哪些应用 - 横排 */}
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-muted-foreground">同步到:</span>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={enabledClaude}
onChange={(e) => setEnabledClaude(e.target.checked)}
className="w-3.5 h-3.5 rounded border-gray-300"
/>
<span className="text-xs">Claude Code</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={enabledCodex}
onChange={(e) => setEnabledCodex(e.target.checked)}
className="w-3.5 h-3.5 rounded border-gray-300"
/>
<span className="text-xs">Codex</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={enabledGemini}
onChange={(e) => setEnabledGemini(e.target.checked)}
className="w-3.5 h-3.5 rounded border-gray-300"
/>
<span className="text-xs">Gemini CLI</span>
</label>
</div>
</div>
{/* JSON 配置编辑器 */}
<div className="flex-1 p-4 flex flex-col min-h-0">
<label className="block text-xs font-medium mb-1.5 text-muted-foreground">
服务器配置 (JSON)
</label>
<textarea
value={editConfig}
onChange={(e) => handleConfigChange(e.target.value)}
className={cn(
"flex-1 w-full px-3 py-2 rounded-lg border bg-muted/50 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-mono text-sm resize-none",
configError && "border-destructive"
)}
placeholder='{"command": "npx", "args": [...], "env": {...}}'
/>
{configError && (
<p className="text-xs text-destructive mt-1">{configError}</p>
)}
</div>
<div className="p-3 border-t flex justify-end gap-2">
<button
onClick={() => {
setSelectedServer(null);
setIsCreating(false);
}}
className="px-3 py-1.5 rounded border hover:bg-muted text-sm"
>
取消
</button>
<button
onClick={handleSave}
disabled={saving || !editName.trim() || !!configError}
className="px-3 py-1.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 text-sm"
>
{saving ? "保存中..." : "保存"}
</button>
</div>
</>
)}
</div>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { McpPage } from "./McpPage";
+128
View File
@@ -0,0 +1,128 @@
import { Check, Edit2, Trash2, FileText } from "lucide-react";
import { Prompt } from "@/lib/api/prompts";
import { cn } from "@/lib/utils";
interface PromptCardProps {
prompt: Prompt;
onToggle: (enabled: boolean) => void;
onEdit: () => void;
onDelete: () => void;
}
/** Toggle Switch Component */
function ToggleSwitch({
enabled,
onChange,
disabled = false,
}: {
enabled: boolean;
onChange: (enabled: boolean) => void;
disabled?: boolean;
}) {
return (
<button
type="button"
role="switch"
aria-checked={enabled}
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
onChange(!enabled);
}}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/20",
enabled ? "bg-primary" : "bg-muted-foreground/30",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform",
enabled ? "translate-x-5" : "translate-x-1"
)}
/>
</button>
);
}
export function PromptCard({
prompt,
onToggle,
onEdit,
onDelete,
}: PromptCardProps) {
return (
<div
className={cn(
"relative rounded-lg border p-4 transition-all",
prompt.enabled
? "border-primary bg-primary/5 ring-1 ring-primary"
: "hover:border-muted-foreground/50"
)}
>
{prompt.enabled && (
<div className="absolute -top-2 -right-2 rounded-full bg-primary p-1">
<Check className="h-3 w-3 text-primary-foreground" />
</div>
)}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<ToggleSwitch
enabled={prompt.enabled}
onChange={onToggle}
/>
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
<FileText className="h-4 w-4" />
</div>
<div>
<h3 className="font-medium">{prompt.name}</h3>
{prompt.description && (
<span className="text-xs text-muted-foreground">
{prompt.description}
</span>
)}
</div>
</div>
<div className="flex gap-1">
<button
onClick={onEdit}
className="p-1.5 rounded hover:bg-muted"
title="编辑"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
onClick={onDelete}
disabled={prompt.enabled}
className={cn(
"p-1.5 rounded",
prompt.enabled
? "opacity-30 cursor-not-allowed"
: "hover:bg-destructive/10 text-destructive"
)}
title={prompt.enabled ? "无法删除已启用的提示词" : "删除"}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="mt-3 p-2 rounded bg-muted/50 max-h-24 overflow-auto">
<pre className="text-xs text-muted-foreground whitespace-pre-wrap font-mono">
{prompt.content.slice(0, 200)}
{prompt.content.length > 200 && "..."}
</pre>
</div>
<div className="mt-3">
{prompt.enabled ? (
<span className="text-sm text-primary font-medium">已启用 (同步到配置文件)</span>
) : (
<span className="text-sm text-muted-foreground">未启用</span>
)}
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import React, { useState } from "react";
import { X } from "lucide-react";
import { Prompt, AppType } from "@/lib/api/prompts";
interface PromptFormProps {
appType: AppType;
prompt: Prompt | null;
onSave: (data: Omit<Prompt, "id" | "createdAt">) => Promise<void>;
onCancel: () => void;
}
export function PromptForm({ appType, prompt, onSave, onCancel }: PromptFormProps) {
const [name, setName] = useState(prompt?.name || "");
const [content, setContent] = useState(prompt?.content || "");
const [description, setDescription] = useState(prompt?.description || "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
setSaving(true);
await onSave({
app_type: appType,
name,
content,
description: description || undefined,
enabled: prompt?.enabled || false,
updatedAt: Math.floor(Date.now() / 1000),
});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-background rounded-lg shadow-lg w-full max-w-2xl max-h-[90vh] overflow-auto">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold">
{prompt ? "编辑 Prompt" : "添加 Prompt"}
</h3>
<button onClick={onCancel} className="p-1 hover:bg-muted rounded">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium mb-1">名称</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background"
placeholder="Prompt 名称"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">描述</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background"
placeholder="可选描述"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">内容</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background font-mono text-sm"
rows={15}
placeholder="输入系统提示词内容..."
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg border hover:bg-muted"
>
取消
</button>
<button
type="submit"
disabled={saving || !name || !content}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground disabled:opacity-50"
>
{saving ? "保存中..." : "保存"}
</button>
</div>
</form>
</div>
</div>
);
}
+394
View File
@@ -0,0 +1,394 @@
import { useState, useEffect, useMemo } from "react";
import { Plus, RefreshCw, Check, Trash2, Download, FileText } from "lucide-react";
import { AppType, Prompt } from "@/lib/api/prompts";
import { usePrompts } from "@/hooks/usePrompts";
import { cn } from "@/lib/utils";
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" },
];
/** Toggle Switch Component */
function ToggleSwitch({
enabled,
onChange,
disabled = false,
}: {
enabled: boolean;
onChange: (enabled: boolean) => void;
disabled?: boolean;
}) {
return (
<button
type="button"
role="switch"
aria-checked={enabled}
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
onChange(!enabled);
}}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/20",
enabled ? "bg-primary" : "bg-muted-foreground/30",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform",
enabled ? "translate-x-5" : "translate-x-1"
)}
/>
</button>
);
}
export function PromptsPage() {
const [activeApp, setActiveApp] = useState<AppType>("claude");
const {
prompts,
loading,
reload,
savePrompt,
deletePrompt,
toggleEnabled,
importFromFile,
} = usePrompts(activeApp);
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
// Edit form state
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editContent, setEditContent] = useState("");
const [saving, setSaving] = useState(false);
const [importing, setImporting] = useState(false);
// Convert prompts map to array
const promptList = useMemo(() => Object.entries(prompts), [prompts]);
const enabledPrompt = promptList.find(([_, p]) => p.enabled);
const selectedPrompt = selectedPromptId ? prompts[selectedPromptId] : null;
const currentApp = apps.find((a) => a.id === activeApp)!;
// Load prompts on mount and app change
useEffect(() => {
reload();
}, [reload]);
const handleSelectPrompt = (id: string, prompt: Prompt) => {
setSelectedPromptId(id);
setIsCreating(false);
setEditName(prompt.name);
setEditDescription(prompt.description || "");
setEditContent(prompt.content);
};
const handleCreateNew = () => {
setSelectedPromptId(null);
setIsCreating(true);
setEditName("");
setEditDescription("");
setEditContent("");
};
const handleSave = async () => {
if (!editName.trim() || !editContent.trim()) return;
setSaving(true);
try {
const timestamp = Math.floor(Date.now() / 1000);
const id = isCreating ? `prompt-${Date.now()}` : selectedPromptId!;
const prompt: Prompt = {
id,
app_type: activeApp,
name: editName.trim(),
content: editContent,
description: editDescription.trim() || undefined,
enabled: selectedPrompt?.enabled || false,
createdAt: selectedPrompt?.createdAt || timestamp,
updatedAt: timestamp,
};
await savePrompt(id, prompt);
setIsCreating(false);
setSelectedPromptId(id);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
const prompt = prompts[id];
if (prompt?.enabled) {
alert("无法删除已启用的提示词。请先禁用它。");
return;
}
if (confirm("确定要删除这个 Prompt 吗?")) {
await deletePrompt(id);
if (selectedPromptId === id) {
setSelectedPromptId(null);
}
}
};
const handleToggle = async (id: string, enabled: boolean) => {
try {
await toggleEnabled(id, enabled);
} catch (error) {
console.error("Failed to toggle prompt:", error);
}
};
const handleImport = async () => {
setImporting(true);
try {
await importFromFile();
} catch (error) {
console.error("Failed to import:", error);
alert("导入失败:" + (error instanceof Error ? error.message : String(error)));
} finally {
setImporting(false);
}
};
// Reset selection when switching apps
const handleAppChange = (app: AppType) => {
setActiveApp(app);
setSelectedPromptId(null);
setIsCreating(false);
};
return (
<div className="h-full flex flex-col">
<div className="mb-4">
<h2 className="text-2xl font-bold">Prompts</h2>
<p className="text-muted-foreground">
管理不同应用的系统提示词({currentApp.filename})
</p>
</div>
{/* App tabs */}
<div className="flex gap-2 border-b pb-2 mb-4">
{apps.map((app) => (
<button
key={app.id}
onClick={() => handleAppChange(app.id)}
className={cn(
"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"
)}
>
{app.label}
</button>
))}
</div>
{/* Status bar */}
<div className="mb-4 p-3 rounded-lg bg-muted/50 border text-sm text-muted-foreground">
共 {promptList.length} 个提示词 · {enabledPrompt
? `当前启用: ${enabledPrompt[1].name}`
: "暂无启用的提示词"}
</div>
{/* Main content - left/right split */}
<div className="flex-1 flex gap-4 min-h-0">
{/* Left list */}
<div className="w-80 flex flex-col border rounded-lg">
<div className="p-3 border-b flex items-center justify-between">
<span className="text-sm font-medium">提示词列表</span>
<div className="flex gap-1">
<button
onClick={handleImport}
disabled={importing}
className="p-1.5 rounded hover:bg-muted"
title={`从 ${currentApp.filename} 导入`}
>
<Download className={cn("h-4 w-4", importing && "animate-pulse")} />
</button>
<button
onClick={reload}
className="p-1.5 rounded hover:bg-muted"
title="刷新"
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</button>
<button
onClick={handleCreateNew}
className="p-1.5 rounded hover:bg-muted text-primary"
title="新建"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-2 space-y-1">
{loading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : promptList.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
<FileText className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>暂无提示词</p>
<button
onClick={handleCreateNew}
className="text-primary hover:underline mt-1"
>
创建第一个
</button>
</div>
) : (
promptList.map(([id, prompt]) => (
<div
key={id}
onClick={() => handleSelectPrompt(id, prompt)}
className={cn(
"p-3 rounded-lg cursor-pointer transition-colors relative",
selectedPromptId === id
? "bg-primary/10 border border-primary"
: "hover:bg-muted border border-transparent"
)}
>
<div className="flex items-center gap-3">
{/* Toggle switch */}
<ToggleSwitch
enabled={prompt.enabled}
onChange={(enabled) => handleToggle(id, enabled)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{prompt.name}
</span>
{prompt.enabled && (
<span className="flex-shrink-0 w-4 h-4 rounded-full bg-primary flex items-center justify-center">
<Check className="h-2.5 w-2.5 text-primary-foreground" />
</span>
)}
</div>
{prompt.description && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{prompt.description}
</p>
)}
</div>
</div>
</div>
))
)}
</div>
</div>
{/* Right edit panel */}
<div className="flex-1 border rounded-lg flex flex-col min-w-0">
{!selectedPrompt && !isCreating ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>选择一个提示词进行编辑</p>
<p className="text-sm mt-1">或点击 + 创建新的提示词</p>
</div>
</div>
) : (
<>
<div className="p-4 border-b space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold">
{isCreating ? "新建提示词" : "编辑提示词"}
</h3>
{selectedPrompt && (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">启用</span>
<ToggleSwitch
enabled={selectedPrompt.enabled}
onChange={(enabled) => handleToggle(selectedPromptId!, enabled)}
/>
</div>
<button
onClick={() => handleDelete(selectedPromptId!)}
disabled={selectedPrompt.enabled}
className={cn(
"p-1.5 rounded",
selectedPrompt.enabled
? "opacity-30 cursor-not-allowed"
: "hover:bg-destructive/10 text-destructive"
)}
title={selectedPrompt.enabled ? "无法删除已启用的提示词" : "删除"}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">名称</label>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
placeholder="提示词名称"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">描述</label>
<input
type="text"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
placeholder="可选描述"
/>
</div>
</div>
</div>
<div className="flex-1 p-4 flex flex-col min-h-0">
<label className="block text-sm font-medium mb-2">
内容 <span className="text-xs text-muted-foreground font-normal">
(启用后将同步到 {currentApp.filename})
</span>
</label>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="flex-1 w-full px-3 py-2 rounded-lg border bg-muted/50 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-mono text-sm resize-none"
placeholder="输入系统提示词内容..."
/>
</div>
<div className="p-4 border-t flex justify-end gap-3">
<button
onClick={() => {
setSelectedPromptId(null);
setIsCreating(false);
}}
className="px-4 py-2 rounded-lg border hover:bg-muted"
>
取消
</button>
<button
onClick={handleSave}
disabled={saving || !editName.trim() || !editContent.trim()}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "保存中..." : "保存"}
</button>
</div>
</>
)}
</div>
</div>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { PromptsPage } from "./PromptsPage";
export { PromptCard } from "./PromptCard";
export { PromptForm } from "./PromptForm";
+131
View File
@@ -0,0 +1,131 @@
import { useState } from "react";
import { ExternalLink, RefreshCw, CheckCircle2, AlertCircle } from "lucide-react";
interface VersionInfo {
current: string;
latest?: string;
hasUpdate: boolean;
}
export function AboutSection() {
const [versionInfo] = useState<VersionInfo>({
current: "0.4.3",
latest: undefined,
hasUpdate: false,
});
const [checking, setChecking] = useState(false);
const handleCheckUpdate = async () => {
setChecking(true);
// TODO: 实现版本检查
await new Promise((resolve) => setTimeout(resolve, 1000));
setChecking(false);
};
return (
<div className="space-y-6 max-w-2xl">
{/* 应用信息 */}
<div className="p-6 rounded-lg border text-center space-y-4">
<div className="w-16 h-16 mx-auto rounded-2xl bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
<span className="text-2xl font-bold text-white">PC</span>
</div>
<div>
<h2 className="text-xl font-bold">ProxyCast</h2>
<p className="text-sm text-muted-foreground">AI API 代理服务</p>
</div>
<div className="flex items-center justify-center gap-2">
<span className="text-sm">版本 {versionInfo.current}</span>
{versionInfo.hasUpdate ? (
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 text-xs">
有新版本
</span>
) : (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<CheckCircle2 className="h-3 w-3" />
已是最新
</span>
)}
</div>
<button
onClick={handleCheckUpdate}
disabled={checking}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border text-sm hover:bg-muted disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${checking ? "animate-spin" : ""}`} />
检查更新
</button>
</div>
{/* 链接 */}
<div className="space-y-3">
<h3 className="text-sm font-medium">相关链接</h3>
<div className="space-y-2">
<a
href="https://github.com/anthropics/claude-code"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50"
>
<span className="text-sm">GitHub 仓库</span>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</a>
<a
href="#"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50"
>
<span className="text-sm">文档</span>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</a>
<a
href="#"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50"
>
<span className="text-sm">问题反馈</span>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</a>
</div>
</div>
{/* 本地工具版本 */}
<div className="space-y-3">
<h3 className="text-sm font-medium">本地工具版本</h3>
<div className="p-4 rounded-lg border space-y-3">
<ToolVersionItem name="Claude Code" version="检测中..." />
<ToolVersionItem name="Codex CLI" version="检测中..." />
<ToolVersionItem name="Gemini CLI" version="检测中..." />
</div>
</div>
{/* 版权信息 */}
<div className="text-center text-xs text-muted-foreground pt-4 border-t">
<p>Made with love for AI developers</p>
<p className="mt-1">2024-2025 ProxyCast</p>
</div>
</div>
);
}
function ToolVersionItem({ name, version }: { name: string; version: string }) {
const isInstalled = version !== "未安装" && !version.includes("检测");
return (
<div className="flex items-center justify-between">
<span className="text-sm">{name}</span>
<div className="flex items-center gap-2">
{isInstalled ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<AlertCircle className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm text-muted-foreground font-mono">{version}</span>
</div>
</div>
);
}
@@ -0,0 +1,120 @@
import { useState } from "react";
import { Folder, RotateCcw } from "lucide-react";
interface DirectoryConfig {
claudeConfigDir: string;
codexConfigDir: string;
geminiConfigDir: string;
}
const defaultDirs: DirectoryConfig = {
claudeConfigDir: "~/.claude",
codexConfigDir: "~/.codex",
geminiConfigDir: "~/.gemini",
};
export function DirectorySettings() {
const [dirs, setDirs] = useState<DirectoryConfig>(defaultDirs);
const [saving, setSaving] = useState(false);
const handleReset = (key: keyof DirectoryConfig) => {
setDirs((prev) => ({ ...prev, [key]: defaultDirs[key] }));
};
const handleSave = async () => {
setSaving(true);
// TODO: 保存目录配置到后端
await new Promise((resolve) => setTimeout(resolve, 500));
setSaving(false);
};
const directoryItems = [
{
key: "claudeConfigDir" as const,
label: "Claude 配置目录",
description: "Claude Code 配置文件存储位置",
},
{
key: "codexConfigDir" as const,
label: "Codex 配置目录",
description: "Codex CLI 配置文件存储位置",
},
{
key: "geminiConfigDir" as const,
label: "Gemini 配置目录",
description: "Gemini CLI 配置文件存储位置",
},
];
return (
<div className="space-y-6 max-w-2xl">
<div>
<h3 className="text-sm font-medium">配置目录</h3>
<p className="text-xs text-muted-foreground">
自定义各应用的配置文件目录位置。修改后需要重启应用生效。
</p>
</div>
<div className="space-y-4">
{directoryItems.map((item) => (
<div key={item.key} className="p-4 rounded-lg border space-y-2">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium">{item.label}</label>
<p className="text-xs text-muted-foreground">{item.description}</p>
</div>
<button
onClick={() => handleReset(item.key)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground"
title="重置为默认"
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
value={dirs[item.key]}
onChange={(e) =>
setDirs((prev) => ({ ...prev, [item.key]: e.target.value }))
}
className="w-full pl-9 pr-3 py-2 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
placeholder={defaultDirs[item.key]}
/>
</div>
</div>
</div>
))}
</div>
<div className="flex justify-end">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "保存中..." : "保存设置"}
</button>
</div>
{/* 数据管理 */}
<div className="pt-6 border-t space-y-4">
<div>
<h3 className="text-sm font-medium">数据管理</h3>
<p className="text-xs text-muted-foreground">导入或导出配置数据</p>
</div>
<div className="flex gap-3">
<button className="px-4 py-2 rounded-lg border text-sm hover:bg-muted">
导出配置
</button>
<button className="px-4 py-2 rounded-lg border text-sm hover:bg-muted">
导入配置
</button>
</div>
</div>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useState, useEffect } from "react";
import { Moon, Sun, Monitor } from "lucide-react";
import { cn } from "@/lib/utils";
type Theme = "light" | "dark" | "system";
export function GeneralSettings() {
const [theme, setTheme] = useState<Theme>("system");
const [launchOnStartup, setLaunchOnStartup] = useState(false);
const [minimizeToTray, setMinimizeToTray] = useState(true);
useEffect(() => {
// 读取当前主题
const savedTheme = localStorage.getItem("theme") as Theme | null;
if (savedTheme) {
setTheme(savedTheme);
}
}, []);
const handleThemeChange = (newTheme: Theme) => {
setTheme(newTheme);
localStorage.setItem("theme", newTheme);
// 应用主题
const root = document.documentElement;
if (newTheme === "system") {
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
root.classList.toggle("dark", systemDark);
} else {
root.classList.toggle("dark", newTheme === "dark");
}
};
const themeOptions = [
{ id: "light" as Theme, label: "浅色", icon: Sun },
{ id: "dark" as Theme, label: "深色", icon: Moon },
{ id: "system" as Theme, label: "跟随系统", icon: Monitor },
];
return (
<div className="space-y-6 max-w-2xl">
{/* 主题设置 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-medium">主题</h3>
<p className="text-xs text-muted-foreground">选择界面显示主题</p>
</div>
<div className="flex gap-2">
{themeOptions.map((option) => (
<button
key={option.id}
onClick={() => handleThemeChange(option.id)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg border transition-colors",
theme === option.id
? "border-primary bg-primary/10 text-primary"
: "border-border hover:border-muted-foreground/50"
)}
>
<option.icon className="h-4 w-4" />
<span className="text-sm">{option.label}</span>
</button>
))}
</div>
</div>
{/* 启动设置 */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">启动行为</h3>
<p className="text-xs text-muted-foreground">配置应用启动和关闭行为</p>
</div>
<div className="space-y-3">
<label className="flex items-center justify-between p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<div>
<span className="text-sm font-medium">开机自启动</span>
<p className="text-xs text-muted-foreground">系统启动时自动运行 ProxyCast</p>
</div>
<input
type="checkbox"
checked={launchOnStartup}
onChange={(e) => setLaunchOnStartup(e.target.checked)}
className="w-4 h-4 rounded border-gray-300"
/>
</label>
<label className="flex items-center justify-between p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<div>
<span className="text-sm font-medium">关闭时最小化到托盘</span>
<p className="text-xs text-muted-foreground">点击关闭按钮时最小化而不是退出</p>
</div>
<input
type="checkbox"
checked={minimizeToTray}
onChange={(e) => setMinimizeToTray(e.target.checked)}
className="w-4 h-4 rounded border-gray-300"
/>
</label>
</div>
</div>
</div>
);
}
+350
View File
@@ -0,0 +1,350 @@
import { useState, useEffect } from "react";
import {
Eye,
EyeOff,
Copy,
Check,
Shield,
AlertTriangle,
CheckCircle2,
XCircle,
Loader2,
RefreshCw,
} from "lucide-react";
import {
getConfig,
saveConfig,
Config,
checkApiCompatibility,
ApiCompatibilityResult,
} from "@/hooks/useTauri";
export function ProxySettings() {
const [config, setConfig] = useState<Config | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
const [copied, setCopied] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// API Compatibility Check
const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState<ApiCompatibilityResult | null>(null);
const [lastCheckTime, setLastCheckTime] = useState<Date | null>(null);
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
const c = await getConfig();
setConfig(c);
} catch (e) {
console.error(e);
}
};
const handleSave = async () => {
if (!config) return;
setSaving(true);
setMessage(null);
try {
await saveConfig(config);
setMessage({ type: "success", text: "设置已保存" });
setTimeout(() => setMessage(null), 3000);
} catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : String(e);
setMessage({ type: "error", text: `保存失败: ${errorMessage}` });
}
setSaving(false);
};
const copyApiKey = () => {
if (config) {
navigator.clipboard.writeText(config.server.api_key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleCheckApiCompatibility = async (provider: string) => {
setChecking(true);
setCheckResult(null);
try {
const result = await checkApiCompatibility(provider);
setCheckResult(result);
setLastCheckTime(new Date());
} catch (e) {
setMessage({ type: "error", text: `API 检测失败: ${e}` });
setTimeout(() => setMessage(null), 5000);
}
setChecking(false);
};
const getStatusIcon = (status: string) => {
switch (status) {
case "healthy":
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
case "partial":
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
case "error":
return <XCircle className="h-5 w-5 text-red-500" />;
default:
return null;
}
};
const getStatusText = (status: string) => {
switch (status) {
case "healthy":
return "所有模型可用";
case "partial":
return "部分模型可用";
case "error":
return "API 不可用";
default:
return "未知";
}
};
if (!config) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6 max-w-2xl">
{/* 消息提示 */}
{message && (
<div
className={`rounded-lg border p-3 text-sm ${
message.type === "error"
? "border-destructive bg-destructive/10 text-destructive"
: "border-green-500 bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400"
}`}
>
{message.text}
</div>
)}
{/* 服务器配置 */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">代理服务配置</h3>
<p className="text-xs text-muted-foreground">配置本地代理服务器参数</p>
</div>
<div className="space-y-4 p-4 rounded-lg border">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1.5">监听地址</label>
<input
type="text"
value={config.server.host}
onChange={(e) =>
setConfig({
...config,
server: { ...config.server, host: e.target.value },
})
}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">端口</label>
<input
type="number"
value={config.server.port}
onChange={(e) =>
setConfig({
...config,
server: {
...config.server,
port: parseInt(e.target.value) || 3001,
},
})
}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">API Key</label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={config.server.api_key}
onChange={(e) =>
setConfig({
...config,
server: { ...config.server, api_key: e.target.value },
})
}
className="w-full px-3 py-2 pr-20 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 gap-1">
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="p-1.5 rounded hover:bg-muted"
title={showApiKey ? "隐藏" : "显示"}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
<button
type="button"
onClick={copyApiKey}
className="p-1.5 rounded hover:bg-muted"
title="复制"
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">
用于验证 API 请求的密钥
</p>
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "保存中..." : "保存设置"}
</button>
</div>
</div>
{/* Claude Code 兼容性检测 */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-purple-500" />
<div>
<h3 className="text-sm font-medium">Claude Code 兼容性检测</h3>
<p className="text-xs text-muted-foreground">
检测 API 是否支持 Claude Code 所需的功能
</p>
</div>
</div>
<div className="p-4 rounded-lg border space-y-4">
<div className="rounded-lg bg-purple-50 dark:bg-purple-900/20 p-3 text-sm">
<p className="font-medium text-purple-700 dark:text-purple-300">检测项目:</p>
<ul className="mt-1 list-inside list-disc text-purple-600 dark:text-purple-400 text-xs">
<li>基础对话能力 (basic)</li>
<li>Tool Calls 支持 (tool_call) - Claude Code 核心功能</li>
</ul>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleCheckApiCompatibility("kiro")}
disabled={checking}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-medium hover:bg-purple-700 disabled:opacity-50"
>
{checking ? <Loader2 className="h-4 w-4 animate-spin" /> : <Shield className="h-4 w-4" />}
检测 Kiro
</button>
<button
onClick={() => handleCheckApiCompatibility("gemini")}
disabled={checking}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{checking ? <Loader2 className="h-4 w-4 animate-spin" /> : <Shield className="h-4 w-4" />}
检测 Gemini
</button>
<button
onClick={() => handleCheckApiCompatibility("qwen")}
disabled={checking}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm font-medium hover:bg-green-700 disabled:opacity-50"
>
{checking ? <Loader2 className="h-4 w-4 animate-spin" /> : <Shield className="h-4 w-4" />}
检测 Qwen
</button>
</div>
{lastCheckTime && (
<p className="text-xs text-muted-foreground">
最后检测时间: {lastCheckTime.toLocaleString()}
</p>
)}
{checkResult && (
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getStatusIcon(checkResult.overall_status)}
<span className="font-medium">
{checkResult.provider.toUpperCase()} - {getStatusText(checkResult.overall_status)}
</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(checkResult.checked_at).toLocaleString()}
</span>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">检测结果:</p>
{checkResult.results.map((r) => (
<div
key={r.model}
className={`flex items-center justify-between rounded p-2 text-sm ${
r.available
? "bg-green-50 dark:bg-green-900/20"
: "bg-red-50 dark:bg-red-900/20"
}`}
>
<div className="flex items-center gap-2">
{r.available ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-red-500" />
)}
<span className={r.model.includes("tool_call") ? "font-medium text-purple-600" : ""}>
{r.model}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{r.status > 0 && <span>HTTP {r.status}</span>}
<span>{r.time_ms}ms</span>
{r.error_type && (
<span className="rounded bg-red-100 dark:bg-red-900/50 px-1 text-red-600 dark:text-red-400">
{r.error_type}
</span>
)}
</div>
</div>
))}
</div>
{checkResult.warnings.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium text-yellow-600">警告:</p>
{checkResult.warnings.map((w, i) => (
<div
key={i}
className="flex items-start gap-2 rounded bg-yellow-50 dark:bg-yellow-900/20 p-2 text-sm text-yellow-700 dark:text-yellow-400"
>
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>{w}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import { GeneralSettings } from "./GeneralSettings";
import { ProxySettings } from "./ProxySettings";
import { DirectorySettings } from "./DirectorySettings";
import { AboutSection } from "./AboutSection";
type SettingsTab = "general" | "proxy" | "advanced" | "about";
const tabs: { id: SettingsTab; label: string }[] = [
{ id: "general", label: "通用" },
{ id: "proxy", label: "代理服务" },
{ id: "advanced", label: "高级" },
{ id: "about", label: "关于" },
];
export function SettingsPage() {
const [activeTab, setActiveTab] = useState<SettingsTab>("general");
return (
<div className="h-full flex flex-col">
<div className="mb-6">
<h2 className="text-2xl font-bold">设置</h2>
<p className="text-muted-foreground">配置应用参数和偏好</p>
</div>
{/* 标签页 */}
<div className="flex gap-1 border-b mb-6">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors relative",
activeTab === tab.id
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
{tab.label}
{activeTab === tab.id && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
))}
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-auto">
{activeTab === "general" && <GeneralSettings />}
{activeTab === "proxy" && <ProxySettings />}
{activeTab === "advanced" && <DirectorySettings />}
{activeTab === "about" && <AboutSection />}
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
export { SettingsPage } from "./SettingsPage";
export { GeneralSettings } from "./GeneralSettings";
export { ProxySettings } from "./ProxySettings";
export { DirectorySettings } from "./DirectorySettings";
export { AboutSection } from "./AboutSection";
+35
View File
@@ -0,0 +1,35 @@
import { cn } from "@/lib/utils";
import { AppType } from "@/lib/api/switch";
interface AppTabsProps {
activeApp: AppType;
onAppChange: (app: AppType) => void;
}
const apps: { id: AppType; label: string; description: string }[] = [
{ id: "claude", label: "Claude Code", description: "Claude CLI 配置" },
{ id: "codex", label: "Codex", description: "OpenAI Codex CLI" },
{ id: "gemini", label: "Gemini", description: "Google Gemini CLI" },
];
export function AppTabs({ activeApp, onAppChange }: AppTabsProps) {
return (
<div className="flex gap-2 border-b pb-2">
{apps.map((app) => (
<button
key={app.id}
onClick={() => onAppChange(app.id)}
className={cn(
"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"
)}
title={app.description}
>
{app.label}
</button>
))}
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { useState, useEffect } from "react";
import { X, RefreshCw, FileText } from "lucide-react";
import { switchApi, AppType } from "@/lib/api/switch";
interface LiveConfigModalProps {
appType: AppType;
onClose: () => void;
}
const configPaths: Record<AppType, string> = {
claude: "~/.claude/settings.json",
codex: "~/.codex/auth.json & config.toml",
gemini: "~/.gemini/.env & settings.json",
proxycast: "",
};
export function LiveConfigModal({ appType, onClose }: LiveConfigModalProps) {
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadConfig = async () => {
setLoading(true);
setError(null);
try {
const data = await switchApi.readLiveSettings(appType);
setConfig(data);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
loadConfig();
}, [appType]);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-background rounded-xl shadow-lg w-full max-w-2xl max-h-[80vh] overflow-hidden border border-border">
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">当前生效的配置</h3>
</div>
<div className="flex items-center gap-2">
<button
onClick={loadConfig}
disabled={loading}
className="p-1.5 rounded hover:bg-muted"
title="刷新"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</button>
<button onClick={onClose} className="p-1.5 rounded hover:bg-muted">
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="p-4 overflow-auto max-h-[60vh]">
{loading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
<p className="text-destructive">{error}</p>
</div>
) : config ? (
<pre className="p-4 rounded-lg bg-muted/50 font-mono text-sm overflow-auto whitespace-pre-wrap">
{JSON.stringify(config, null, 2)}
</pre>
) : (
<p className="text-muted-foreground text-center py-8">
无配置数据
</p>
)}
</div>
<div className="p-4 border-t bg-muted/30">
<p className="text-xs text-muted-foreground">
配置文件路径: <code className="px-1 py-0.5 rounded bg-muted">{configPaths[appType]}</code>
</p>
</div>
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { Check, Edit2, Trash2, Zap } from "lucide-react";
import { Provider } from "@/lib/api/switch";
import { cn } from "@/lib/utils";
interface ProviderCardProps {
provider: Provider;
isCurrent: boolean;
onSwitch: () => void;
onEdit: () => void;
onDelete: () => void;
}
export function ProviderCard({
provider,
isCurrent,
onSwitch,
onEdit,
onDelete,
}: ProviderCardProps) {
return (
<div
className={cn(
"relative rounded-lg border p-4 transition-all",
isCurrent
? "border-primary bg-primary/5 ring-1 ring-primary"
: "hover:border-muted-foreground/50"
)}
>
{isCurrent && (
<div className="absolute -top-2 -right-2 rounded-full bg-primary p-1">
<Check className="h-3 w-3 text-primary-foreground" />
</div>
)}
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div
className="h-8 w-8 rounded-lg flex items-center justify-center text-lg"
style={{ backgroundColor: provider.icon_color || "#6366f1" }}
>
{provider.icon || provider.name.charAt(0).toUpperCase()}
</div>
<div>
<h3 className="font-medium">{provider.name}</h3>
{provider.category && (
<span className="text-xs text-muted-foreground">
{provider.category}
</span>
)}
</div>
</div>
<div className="flex gap-1">
<button
onClick={onEdit}
className="p-1.5 rounded hover:bg-muted"
title="编辑"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (!isCurrent) {
onDelete();
}
}}
disabled={isCurrent}
className={cn(
"p-1.5 rounded text-destructive",
isCurrent
? "opacity-30 cursor-not-allowed"
: "hover:bg-destructive/10"
)}
title={isCurrent ? "无法删除当前使用中的配置" : "删除"}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
{provider.notes && (
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">
{provider.notes}
</p>
)}
<div className="mt-4">
{isCurrent ? (
<span className="text-sm text-primary font-medium">当前使用中</span>
) : (
<button
onClick={onSwitch}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<Zap className="h-3.5 w-3.5" />
切换到此配置
</button>
)}
</div>
</div>
);
}
+883
View File
@@ -0,0 +1,883 @@
import React, { useState, useMemo, useCallback } from "react";
import { X, ExternalLink, Wand2, Eye, EyeOff } from "lucide-react";
import { Provider, AppType } from "@/lib/api/switch";
import { getConfig } from "@/hooks/useTauri";
import { cn } from "@/lib/utils";
interface ProviderFormProps {
appType: AppType;
provider: Provider | null;
onSave: (data: Omit<Provider, "id" | "is_current" | "created_at">) => Promise<void>;
onCancel: () => void;
}
// 供应商分类
type ProviderCategory = "official" | "cn_official" | "aggregator" | "third_party" | "proxy" | "custom";
// 预设供应商接口
interface ProviderPreset {
id: string;
name: string;
category: ProviderCategory;
iconColor?: string;
websiteUrl?: string;
apiKeyUrl?: string;
// Claude 专属默认配置
defaultBaseUrl?: string;
defaultModel?: string;
// Codex 专属默认配置
defaultCodexAuth?: Record<string, unknown>;
defaultCodexConfig?: string;
// Gemini 专属默认配置
defaultGeminiEnv?: Record<string, string>;
}
// 预设供应商配置
const presets: Record<AppType, ProviderPreset[]> = {
claude: [
// 官方
{
id: "anthropic",
name: "Anthropic 官方",
category: "official",
iconColor: "#D97757",
websiteUrl: "https://www.anthropic.com/claude-code",
apiKeyUrl: "https://console.anthropic.com/settings/keys",
},
// 国内官方
{
id: "deepseek",
name: "DeepSeek",
category: "cn_official",
iconColor: "#0066FF",
websiteUrl: "https://platform.deepseek.com",
apiKeyUrl: "https://platform.deepseek.com/api_keys",
defaultBaseUrl: "https://api.deepseek.com/anthropic",
},
{
id: "zhipu",
name: "智谱 GLM",
category: "cn_official",
iconColor: "#5B6BE8",
websiteUrl: "https://open.bigmodel.cn",
apiKeyUrl: "https://open.bigmodel.cn/usercenter/apikeys",
defaultBaseUrl: "https://open.bigmodel.cn/api/anthropic",
},
{
id: "qwen",
name: "通义千问 Coder",
category: "cn_official",
iconColor: "#6B4FBB",
websiteUrl: "https://bailian.console.aliyun.com",
apiKeyUrl: "https://bailian.console.aliyun.com/?apiKey=1#/api-key",
defaultBaseUrl: "https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy",
},
{
id: "kimi",
name: "Kimi k2",
category: "cn_official",
iconColor: "#000000",
websiteUrl: "https://platform.moonshot.cn/console",
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
defaultBaseUrl: "https://api.moonshot.cn/anthropic",
},
{
id: "kimi-coding",
name: "Kimi For Coding",
category: "cn_official",
iconColor: "#000000",
websiteUrl: "https://www.kimi.com/coding/docs/",
apiKeyUrl: "https://www.kimi.com/coding/profile",
defaultBaseUrl: "https://api.kimi.com/coding/",
},
{
id: "minimax",
name: "MiniMax",
category: "cn_official",
iconColor: "#F97316",
websiteUrl: "https://platform.minimaxi.com",
apiKeyUrl: "https://platform.minimaxi.com/user-center/basic-information/interface-key",
defaultBaseUrl: "https://api.minimaxi.com/anthropic",
},
{
id: "doubao",
name: "豆包 Seed",
category: "cn_official",
iconColor: "#5DADEC",
websiteUrl: "https://www.volcengine.com/product/doubao",
apiKeyUrl: "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey",
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/coding",
},
// 聚合服务
{
id: "openrouter",
name: "OpenRouter",
category: "aggregator",
iconColor: "#6366f1",
websiteUrl: "https://openrouter.ai",
apiKeyUrl: "https://openrouter.ai/keys",
defaultBaseUrl: "https://openrouter.ai/api",
},
// 本地代理
{
id: "proxycast",
name: "ProxyCast",
category: "proxy",
iconColor: "#3b82f6",
defaultBaseUrl: "http://127.0.0.1:3001",
},
// 自定义
{
id: "custom",
name: "自定义",
category: "custom",
iconColor: "#8b5cf6",
},
],
codex: [
// 官方
{
id: "openai",
name: "OpenAI 官方",
category: "official",
iconColor: "#10a37f",
websiteUrl: "https://chatgpt.com/codex",
apiKeyUrl: "https://platform.openai.com/api-keys",
},
// 第三方
{
id: "azure",
name: "Azure OpenAI",
category: "third_party",
iconColor: "#0078D4",
websiteUrl: "https://learn.microsoft.com/azure/ai-services/openai",
defaultCodexAuth: {
api_key: "",
api_base_url: "https://YOUR_RESOURCE_NAME.openai.azure.com/openai",
},
defaultCodexConfig: `# Azure OpenAI 配置
model = "gpt-4o"
`,
},
// 本地代理
{
id: "proxycast",
name: "ProxyCast",
category: "proxy",
iconColor: "#3b82f6",
defaultCodexAuth: {
api_key: "proxycast",
api_base_url: "http://127.0.0.1:3001/v1",
},
},
// 自定义
{
id: "custom",
name: "自定义",
category: "custom",
iconColor: "#8b5cf6",
},
],
gemini: [
// 官方
{
id: "google",
name: "Google 官方",
category: "official",
iconColor: "#4285f4",
websiteUrl: "https://ai.google.dev/",
apiKeyUrl: "https://aistudio.google.com/app/apikey",
},
// 本地代理
{
id: "proxycast",
name: "ProxyCast",
category: "proxy",
iconColor: "#3b82f6",
defaultGeminiEnv: {
GEMINI_API_KEY: "proxycast",
GOOGLE_GEMINI_BASE_URL: "http://127.0.0.1:3001",
GEMINI_MODEL: "gemini-2.0-flash",
},
},
// 自定义
{
id: "custom",
name: "自定义",
category: "custom",
iconColor: "#8b5cf6",
},
],
proxycast: [],
};
const categoryLabels: Record<ProviderCategory, string> = {
official: "官方",
cn_official: "国内官方",
aggregator: "聚合服务",
third_party: "第三方",
proxy: "本地代理",
custom: "自定义",
};
// 默认 Codex auth.json 模板
const defaultCodexAuth = JSON.stringify({
api_key: "",
api_base_url: ""
}, null, 2);
// 默认 Codex config.toml 模板
const defaultCodexConfig = `# Codex 配置文件
model = "gpt-4"
`;
// 默认 Gemini .env 模板
const defaultGeminiEnv = `GEMINI_API_KEY=
GOOGLE_GEMINI_BASE_URL=
GEMINI_MODEL=gemini-2.0-flash`;
// 默认 Gemini settings.json 模板
const defaultGeminiSettings = JSON.stringify({
mcpServers: {}
}, null, 2);
export function ProviderForm({ appType, provider, onSave, onCancel }: ProviderFormProps) {
const isEditMode = Boolean(provider);
const appPresets = useMemo(() => presets[appType] || [], [appType]);
// 基础字段
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
isEditMode ? null : "custom"
);
const [name, setName] = useState(provider?.name || "");
const [notes, setNotes] = useState(provider?.notes || "");
const [iconColor, setIconColor] = useState(provider?.icon_color || "#6366f1");
// 从 provider.settings_config 中提取 Claude 配置
const extractClaudeConfig = () => {
if (!provider?.settings_config || typeof provider.settings_config !== 'object') {
return { apiKey: "", baseUrl: "", primaryModel: "claude-sonnet-4-20250514", haikuModel: "", sonnetModel: "", opusModel: "" };
}
const env = (provider.settings_config as Record<string, unknown>).env as Record<string, string> | undefined;
if (!env) {
return { apiKey: "", baseUrl: "", primaryModel: "claude-sonnet-4-20250514", haikuModel: "", sonnetModel: "", opusModel: "" };
}
return {
apiKey: env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || "",
baseUrl: env.ANTHROPIC_BASE_URL || "",
primaryModel: env.ANTHROPIC_MODEL || "claude-sonnet-4-20250514",
haikuModel: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || "",
sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL || "",
opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL || "",
};
};
const claudeConfig = extractClaudeConfig();
// Claude 专属字段 - 编辑模式从现有配置加载
const [apiKey, setApiKey] = useState(claudeConfig.apiKey);
const [baseUrl, setBaseUrl] = useState(claudeConfig.baseUrl);
const [primaryModel, setPrimaryModel] = useState(claudeConfig.primaryModel);
const [haikuModel, setHaikuModel] = useState(claudeConfig.haikuModel);
const [sonnetModel, setSonnetModel] = useState(claudeConfig.sonnetModel);
const [opusModel, setOpusModel] = useState(claudeConfig.opusModel);
// Claude 配置 JSON(可编辑)
const [claudeConfigJson, setClaudeConfigJson] = useState(() => {
if (provider?.settings_config && typeof provider.settings_config === 'object') {
return JSON.stringify(provider.settings_config, null, 2);
}
return JSON.stringify({ env: {} }, null, 2);
});
const [jsonError, setJsonError] = useState<string | null>(null);
// 当表单字段变化时,同步更新 JSON(仅当 JSON 未被手动修改时)
const [jsonManuallyEdited, setJsonManuallyEdited] = useState(false);
// 从表单字段生成 JSON
const generateJsonFromFields = useCallback(() => {
const env: Record<string, string> = {};
if (apiKey) {
env.ANTHROPIC_AUTH_TOKEN = apiKey;
env.ANTHROPIC_API_KEY = apiKey;
}
if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl;
if (primaryModel) env.ANTHROPIC_MODEL = primaryModel;
if (haikuModel) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = haikuModel;
if (sonnetModel) env.ANTHROPIC_DEFAULT_SONNET_MODEL = sonnetModel;
if (opusModel) env.ANTHROPIC_DEFAULT_OPUS_MODEL = opusModel;
return JSON.stringify({ env }, null, 2);
}, [apiKey, baseUrl, primaryModel, haikuModel, sonnetModel, opusModel]);
// 同步表单到 JSON(当表单字段变化且 JSON 未被手动编辑时)
React.useEffect(() => {
if (!jsonManuallyEdited && appType === "claude") {
setClaudeConfigJson(generateJsonFromFields());
}
}, [generateJsonFromFields, jsonManuallyEdited, appType]);
// 处理 JSON 编辑
const handleJsonChange = (value: string) => {
setClaudeConfigJson(value);
setJsonManuallyEdited(true);
setJsonError(null);
// 尝试解析并同步回表单字段
try {
const parsed = JSON.parse(value);
const env = parsed.env || {};
setApiKey(env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || "");
setBaseUrl(env.ANTHROPIC_BASE_URL || "");
setPrimaryModel(env.ANTHROPIC_MODEL || "");
setHaikuModel(env.ANTHROPIC_DEFAULT_HAIKU_MODEL || "");
setSonnetModel(env.ANTHROPIC_DEFAULT_SONNET_MODEL || "");
setOpusModel(env.ANTHROPIC_DEFAULT_OPUS_MODEL || "");
} catch {
setJsonError("JSON 格式错误");
}
};
// 格式化 JSON
const handleFormatJson = () => {
try {
const parsed = JSON.parse(claudeConfigJson);
setClaudeConfigJson(JSON.stringify(parsed, null, 2));
setJsonError(null);
} catch {
setJsonError("JSON 格式错误,无法格式化");
}
};
// Codex 专属字段 - 使用代码编辑器
const [codexAuth, setCodexAuth] = useState(() => {
if (provider?.settings_config && typeof provider.settings_config === 'object') {
const auth = (provider.settings_config as Record<string, unknown>).auth;
if (auth) return JSON.stringify(auth, null, 2);
}
return defaultCodexAuth;
});
const [codexConfig, setCodexConfig] = useState(() => {
if (provider?.settings_config && typeof provider.settings_config === 'object') {
const config = (provider.settings_config as Record<string, unknown>).config;
if (typeof config === 'string') return config;
}
return defaultCodexConfig;
});
// Gemini 专属字段 - 使用代码编辑器
const [geminiEnv, setGeminiEnv] = useState(() => {
if (provider?.settings_config && typeof provider.settings_config === 'object') {
const env = (provider.settings_config as Record<string, unknown>).env;
if (env && typeof env === 'object') {
return Object.entries(env as Record<string, string>)
.map(([k, v]) => `${k}=${v}`)
.join('\n');
}
}
return defaultGeminiEnv;
});
const [geminiSettings, setGeminiSettings] = useState(() => {
if (provider?.settings_config && typeof provider.settings_config === 'object') {
const config = (provider.settings_config as Record<string, unknown>).config;
if (config && typeof config === 'object') {
return JSON.stringify(config, null, 2);
}
}
return defaultGeminiSettings;
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
// 当前选中的预设
const selectedPreset = useMemo(() => {
return appPresets.find((p) => p.id === selectedPresetId);
}, [appPresets, selectedPresetId]);
// 是否显示 API Key 和端点字段(编辑模式始终显示,新增模式非官方时显示)
const showApiFields = isEditMode || selectedPreset?.category !== "official";
// 按分类分组预设
const groupedPresets = useMemo(() => {
const groups: Partial<Record<ProviderCategory, typeof appPresets>> = {};
for (const preset of appPresets) {
const cat = preset.category;
if (!groups[cat]) groups[cat] = [];
groups[cat]!.push(preset);
}
return groups;
}, [appPresets]);
const handlePresetChange = async (presetId: string) => {
setSelectedPresetId(presetId);
const preset = appPresets.find((p) => p.id === presetId);
if (preset) {
setName(preset.name);
setIconColor(preset.iconColor || "#6366f1");
// ProxyCast 预设:自动从设置中读取 API Key 和端口
if (preset.id === "proxycast") {
try {
const config = await getConfig();
const proxyApiKey = config.server.api_key || "";
const proxyHost = config.server.host || "127.0.0.1";
const proxyPort = config.server.port || 3001;
const proxyBaseUrl = `http://${proxyHost}:${proxyPort}`;
if (appType === "claude") {
setApiKey(proxyApiKey);
setBaseUrl(proxyBaseUrl);
// 重置 JSON 手动编辑标记,让表单同步到 JSON
setJsonManuallyEdited(false);
} else if (appType === "codex") {
setCodexAuth(JSON.stringify({
api_key: proxyApiKey,
api_base_url: `${proxyBaseUrl}/v1`,
}, null, 2));
} else if (appType === "gemini") {
setGeminiEnv(`GEMINI_API_KEY=${proxyApiKey}\nGOOGLE_GEMINI_BASE_URL=${proxyBaseUrl}\nGEMINI_MODEL=gemini-2.0-flash`);
}
return;
} catch (e) {
console.error("Failed to load ProxyCast config:", e);
}
}
// 应用预设的默认配置
if (appType === "claude") {
// Claude: 设置默认 base URL
if (preset.defaultBaseUrl) {
setBaseUrl(preset.defaultBaseUrl);
} else {
setBaseUrl("");
}
if (preset.defaultModel) {
setPrimaryModel(preset.defaultModel);
}
// 重置 JSON 手动编辑标记
setJsonManuallyEdited(false);
} else if (appType === "codex") {
// Codex: 设置默认 auth.json
if (preset.defaultCodexAuth) {
setCodexAuth(JSON.stringify(preset.defaultCodexAuth, null, 2));
} else {
setCodexAuth(defaultCodexAuth);
}
if (preset.defaultCodexConfig) {
setCodexConfig(preset.defaultCodexConfig);
} else {
setCodexConfig(defaultCodexConfig);
}
} else if (appType === "gemini") {
// Gemini: 设置默认 env
if (preset.defaultGeminiEnv) {
const envLines = Object.entries(preset.defaultGeminiEnv)
.map(([k, v]) => `${k}=${v}`)
.join('\n');
setGeminiEnv(envLines);
} else {
setGeminiEnv(defaultGeminiEnv);
}
}
}
};
const buildSettingsConfig = (): Record<string, unknown> => {
if (appType === "claude") {
// 直接使用 JSON 编辑器的值
try {
return JSON.parse(claudeConfigJson);
} catch {
throw new Error("配置 JSON 格式错误");
}
}
if (appType === "codex") {
try {
const auth = JSON.parse(codexAuth);
return { auth, config: codexConfig };
} catch {
throw new Error("auth.json 格式错误");
}
}
if (appType === "gemini") {
const env: Record<string, string> = {};
for (const line of geminiEnv.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf('=');
if (idx > 0) {
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
env[key] = value;
}
}
try {
const config = JSON.parse(geminiSettings);
return { env, config };
} catch {
throw new Error("settings.json 格式错误");
}
}
return {};
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("请填写供应商名称");
return;
}
// 非官方供应商必填校验(新增模式必填,编辑模式可留空保持原值)
if (!isEditMode && showApiFields && appType === "claude" && !apiKey.trim()) {
setError("请填写 API Key");
return;
}
try {
setSaving(true);
const settings_config = buildSettingsConfig();
await onSave({
app_type: appType,
name: name.trim(),
settings_config,
category: selectedPreset?.category || "custom",
icon_color: iconColor,
notes: notes.trim() || undefined,
});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-background rounded-xl shadow-lg w-full max-w-3xl max-h-[90vh] overflow-auto border border-border">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-semibold">
{isEditMode ? "编辑供应商" : "添加供应商"}
</h3>
<button onClick={onCancel} className="p-1 hover:bg-muted rounded">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* 预设选择器(仅新增模式) */}
{!isEditMode && (
<div className="space-y-3">
<label className="block text-sm font-medium">选择预设</label>
<div className="space-y-4">
{Object.entries(groupedPresets).map(([category, items]) => (
<div key={category}>
<p className="text-xs text-muted-foreground mb-2">
{categoryLabels[category as ProviderCategory] || category}
</p>
<div className="flex flex-wrap gap-2">
{items.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => handlePresetChange(preset.id)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-all",
selectedPresetId === preset.id
? "border-primary bg-primary/10 text-primary"
: "border-border hover:border-muted-foreground/50"
)}
>
<span
className="w-4 h-4 rounded"
style={{ backgroundColor: preset.iconColor }}
/>
{preset.name}
</button>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* 基础字段 */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1.5">
供应商名称 <span className="text-destructive">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
placeholder="输入供应商名称"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">备注</label>
<input
type="text"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
placeholder="可选备注信息"
/>
</div>
</div>
{/* Claude 表单字段 */}
{appType === "claude" && showApiFields && (
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-medium">
API Key <span className="text-destructive">*</span>
</label>
{(selectedPreset?.apiKeyUrl || selectedPreset?.websiteUrl) && (
<a
href={selectedPreset?.apiKeyUrl || selectedPreset?.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-1"
>
获取 API Key
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="w-full px-3 py-2 pr-10 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-mono"
placeholder="sk-..."
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-muted"
title={showApiKey ? "隐藏" : "显示"}
>
{showApiKey ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">API 端点</label>
<input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-mono"
placeholder="https://api.example.com/v1"
/>
<p className="text-xs text-muted-foreground mt-1">留空使用默认端点</p>
</div>
<div className="space-y-4">
<label className="block text-sm font-medium">模型配置</label>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-muted-foreground mb-1">主模型</label>
<input
type="text"
value={primaryModel}
onChange={(e) => setPrimaryModel(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none text-sm"
placeholder="claude-sonnet-4-20250514"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">Haiku 默认模型</label>
<input
type="text"
value={haikuModel}
onChange={(e) => setHaikuModel(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none text-sm"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">Sonnet 默认模型</label>
<input
type="text"
value={sonnetModel}
onChange={(e) => setSonnetModel(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none text-sm"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">Opus 默认模型</label>
<input
type="text"
value={opusModel}
onChange={(e) => setOpusModel(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none text-sm"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
可选:指定默认使用的 Claude 模型,留空则使用系统默认。
</p>
</div>
{/* 配置 JSON 编辑器 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-medium">配置 JSON</label>
<button
type="button"
onClick={handleFormatJson}
className="flex items-center gap-1 text-xs text-primary hover:underline"
>
<Wand2 className="h-3 w-3" />
格式化
</button>
</div>
<textarea
value={claudeConfigJson}
onChange={(e) => handleJsonChange(e.target.value)}
className={cn(
"w-full px-3 py-2 rounded-lg border bg-muted/50 font-mono text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none resize-none",
jsonError && "border-destructive focus:ring-destructive/20 focus:border-destructive"
)}
rows={10}
placeholder='{"env": {}}'
/>
{jsonError ? (
<p className="text-xs text-destructive mt-1">{jsonError}</p>
) : (
<p className="text-xs text-muted-foreground mt-1">
可手动编辑 JSON,修改会同步到上方表单
</p>
)}
</div>
</div>
)}
{/* Codex 配置编辑器 */}
{appType === "codex" && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* auth.json 编辑器 */}
<div>
<label className="block text-sm font-medium mb-1.5">
auth.json
</label>
<textarea
value={codexAuth}
onChange={(e) => setCodexAuth(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-muted/50 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-mono text-sm"
rows={8}
placeholder='{"api_key": "", "api_base_url": ""}'
/>
<p className="text-xs text-muted-foreground mt-1">
JSON 格式的认证配置
</p>
</div>
{/* config.toml 编辑器 */}
<div>
<label className="block text-sm font-medium mb-1.5">
config.toml
</label>
<textarea
value={codexConfig}
onChange={(e) => setCodexConfig(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-muted/50 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-mono text-sm"
rows={8}
placeholder='model = "gpt-4"'
/>
<p className="text-xs text-muted-foreground mt-1">
TOML 格式的配置文件
</p>
</div>
</div>
</div>
)}
{/* Gemini 配置编辑器 */}
{appType === "gemini" && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* .env 编辑器 */}
<div>
<label className="block text-sm font-medium mb-1.5">
env 环境变量
</label>
<textarea
value={geminiEnv}
onChange={(e) => setGeminiEnv(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-muted/50 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-mono text-sm"
rows={8}
placeholder="GEMINI_API_KEY=&#10;GOOGLE_GEMINI_BASE_URL=&#10;GEMINI_MODEL=gemini-2.0-flash"
/>
<p className="text-xs text-muted-foreground mt-1">
每行一个环境变量,格式:KEY=VALUE
</p>
</div>
{/* settings.json 编辑器 */}
<div>
<label className="block text-sm font-medium mb-1.5">
settings.json
</label>
<textarea
value={geminiSettings}
onChange={(e) => setGeminiSettings(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-muted/50 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-mono text-sm"
rows={8}
placeholder='{"mcpServers": {}}'
/>
<p className="text-xs text-muted-foreground mt-1">
JSON 格式的配置文件(MCP 服务器等)
</p>
</div>
</div>
</div>
)}
{/* 错误提示 */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* 按钮 */}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg border hover:bg-muted transition-colors"
>
取消
</button>
<button
type="submit"
disabled={saving || !name.trim()}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saving ? "保存中..." : isEditMode ? "保存" : "添加"}
</button>
</div>
</form>
</div>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { useState } from "react";
import { Plus, RefreshCw, Eye } from "lucide-react";
import { AppType } from "@/lib/api/switch";
import { useSwitch } from "@/hooks/useSwitch";
import { ProviderCard } from "./ProviderCard";
import { ProviderForm } from "./ProviderForm";
import { LiveConfigModal } from "./LiveConfigModal";
interface ProviderListProps {
appType: AppType;
}
export function ProviderList({ appType }: ProviderListProps) {
const {
providers,
currentProvider,
loading,
error,
addProvider,
updateProvider,
deleteProvider,
switchToProvider,
refresh,
} = useSwitch(appType);
const [showForm, setShowForm] = useState(false);
const [editingProvider, setEditingProvider] = useState<typeof providers[0] | null>(null);
const [showLiveConfig, setShowLiveConfig] = useState(false);
const handleAdd = () => {
setEditingProvider(null);
setShowForm(true);
};
const handleEdit = (provider: typeof providers[0]) => {
setEditingProvider(provider);
setShowForm(true);
};
const handleSave = async (data: Parameters<typeof addProvider>[0]) => {
if (editingProvider) {
await updateProvider({ ...editingProvider, ...data });
} else {
await addProvider(data);
}
setShowForm(false);
setEditingProvider(null);
};
const handleDelete = async (id: string) => {
// 不能删除当前使用中的 provider
if (currentProvider?.id === id) {
alert("无法删除当前使用中的配置");
return;
}
if (confirm("确定要删除这个 Provider 吗?")) {
try {
await deleteProvider(id);
} catch (e) {
alert("删除失败: " + (e instanceof Error ? e.message : String(e)));
}
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
<p className="text-destructive">{error}</p>
<button
onClick={refresh}
className="mt-2 text-sm text-muted-foreground hover:underline"
>
重试
</button>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">
当前: {currentProvider?.name || "未设置"}
</p>
<button
onClick={() => setShowLiveConfig(true)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title="查看当前生效的配置"
>
<Eye className="h-4 w-4" />
</button>
</div>
<div className="flex gap-2">
<button
onClick={refresh}
className="p-2 rounded-lg hover:bg-muted"
title="刷新"
>
<RefreshCw className="h-4 w-4" />
</button>
<button
onClick={handleAdd}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm"
>
<Plus className="h-4 w-4" />
添加 Provider
</button>
</div>
</div>
{providers.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>暂无 Provider 配置</p>
<p className="text-sm mt-1">点击上方按钮添加第一个配置</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{providers.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isCurrent={provider.id === currentProvider?.id}
onSwitch={() => switchToProvider(provider.id)}
onEdit={() => handleEdit(provider)}
onDelete={() => handleDelete(provider.id)}
/>
))}
</div>
)}
{showForm && (
<ProviderForm
appType={appType}
provider={editingProvider}
onSave={handleSave}
onCancel={() => {
setShowForm(false);
setEditingProvider(null);
}}
/>
)}
{showLiveConfig && (
<LiveConfigModal
appType={appType}
onClose={() => setShowLiveConfig(false)}
/>
)}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useState } from "react";
import { AppType } from "@/lib/api/switch";
import { AppTabs } from "./AppTabs";
import { ProviderList } from "./ProviderList";
export function SwitchPage() {
const [activeApp, setActiveApp] = useState<AppType>("claude");
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">Switch</h2>
<p className="text-muted-foreground">
管理和切换不同应用的 Provider 配置
</p>
</div>
<AppTabs activeApp={activeApp} onAppChange={setActiveApp} />
<ProviderList appType={activeApp} />
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
export { SwitchPage } from "./SwitchPage";
export { AppTabs } from "./AppTabs";
export { ProviderList } from "./ProviderList";
export { ProviderCard } from "./ProviderCard";
export { ProviderForm } from "./ProviderForm";
+111
View File
@@ -0,0 +1,111 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { mcpApi, McpServer } from "@/lib/api/mcp";
export function useMcpServers() {
const [servers, setServers] = useState<McpServer[]>([]);
const [loading, setLoading] = useState(true);
const [importing, setImporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const hasAutoImported = useRef(false);
const fetchServers = useCallback(async () => {
try {
setLoading(true);
setError(null);
const list = await mcpApi.getServers();
setServers(list);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
// 从指定应用导入 MCP 配置
const importFromApp = async (appType: string): Promise<number> => {
setImporting(true);
try {
const count = await mcpApi.importFromApp(appType);
await fetchServers();
return count;
} finally {
setImporting(false);
}
};
// 从所有应用导入 MCP 配置
const importFromAllApps = async (): Promise<number> => {
setImporting(true);
try {
let total = 0;
for (const app of ["claude", "codex", "gemini"]) {
const count = await mcpApi.importFromApp(app);
total += count;
}
await fetchServers();
return total;
} finally {
setImporting(false);
}
};
// 同步所有配置到实际配置文件
const syncAllToLive = async () => {
await mcpApi.syncAllToLive();
};
useEffect(() => {
const init = async () => {
await fetchServers();
// 首次加载时,如果没有数据,自动从所有应用导入
if (!hasAutoImported.current) {
hasAutoImported.current = true;
const list = await mcpApi.getServers();
if (list.length === 0) {
await importFromAllApps();
}
}
};
init();
}, [fetchServers]);
const addServer = async (server: Omit<McpServer, "id" | "created_at">) => {
const newServer: McpServer = {
...server,
id: crypto.randomUUID(),
created_at: Date.now(),
};
await mcpApi.addServer(newServer);
await fetchServers();
};
const updateServer = async (server: McpServer) => {
await mcpApi.updateServer(server);
await fetchServers();
};
const deleteServer = async (id: string) => {
await mcpApi.deleteServer(id);
await fetchServers();
};
const toggleServer = async (id: string, appType: string, enabled: boolean) => {
await mcpApi.toggleServer(id, appType, enabled);
await fetchServers();
};
return {
servers,
loading,
importing,
error,
addServer,
updateServer,
deleteServer,
toggleServer,
importFromApp,
importFromAllApps,
syncAllToLive,
refresh: fetchServers,
};
}
+137
View File
@@ -0,0 +1,137 @@
import { useState, useCallback, useEffect, useRef } from "react";
import {
credentialsApi,
OAuthProvider,
OAuthCredentialStatus,
EnvVariable,
} from "@/lib/api/credentials";
export function useOAuthCredentials(provider: OAuthProvider) {
const [credentials, setCredentials] = useState<OAuthCredentialStatus | null>(
null,
);
const [envVariables, setEnvVariables] = useState<EnvVariable[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const lastHashRef = useRef<string>("");
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [creds, vars] = await Promise.all([
credentialsApi.getCredentials(provider),
credentialsApi.getEnvVariables(provider),
]);
setCredentials(creds);
setEnvVariables(vars);
// Update hash for change detection
const hash = await credentialsApi.getTokenFileHash(provider);
lastHashRef.current = hash;
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [provider]);
const reloadFromFile = useCallback(async () => {
setLoading(true);
setError(null);
try {
await credentialsApi.reloadCredentials(provider);
await reload();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [provider, reload]);
const refreshToken = useCallback(async () => {
setRefreshing(true);
setError(null);
try {
await credentialsApi.refreshToken(provider);
await reload();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setRefreshing(false);
}
}, [provider, reload]);
// Auto-check for file changes
const checkForChanges = useCallback(async () => {
if (!lastHashRef.current) return;
try {
const result = await credentialsApi.checkAndReload(
provider,
lastHashRef.current,
);
if (result.changed && result.reloaded) {
lastHashRef.current = result.new_hash;
await reload();
}
} catch (e) {
console.error("Error checking for credential changes:", e);
}
}, [provider, reload]);
// Initial load
useEffect(() => {
reload();
}, [reload]);
// Periodic check for file changes
useEffect(() => {
const interval = setInterval(checkForChanges, 5000);
return () => clearInterval(interval);
}, [checkForChanges]);
return {
credentials,
envVariables,
loading,
refreshing,
error,
reload,
reloadFromFile,
refreshToken,
checkForChanges,
};
}
// Hook to get all credentials at once
export function useAllOAuthCredentials() {
const [credentials, setCredentials] = useState<OAuthCredentialStatus[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const creds = await credentialsApi.getAllCredentials();
setCredentials(creds);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
reload();
}, [reload]);
return {
credentials,
loading,
error,
reload,
};
}
+132
View File
@@ -0,0 +1,132 @@
import { useState, useCallback, useRef } from "react";
import { promptsApi, Prompt, AppType } from "@/lib/api/prompts";
export function usePrompts(appType: AppType) {
const [prompts, setPrompts] = useState<Record<string, Prompt>>({});
const [currentFileContent, setCurrentFileContent] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const hasAutoImported = useRef<Record<string, boolean>>({});
const reload = useCallback(async () => {
setLoading(true);
try {
// Auto-import on first load if no prompts exist
if (!hasAutoImported.current[appType]) {
hasAutoImported.current[appType] = true;
try {
const imported = await promptsApi.autoImport(appType);
if (imported > 0) {
console.log(`Auto-imported ${imported} prompt(s) for ${appType}`);
}
} catch (e) {
console.log("Auto-import skipped:", e);
}
}
const data = await promptsApi.getPrompts(appType);
setPrompts(data);
// Also load current file content
try {
const content = await promptsApi.getCurrentFileContent(appType);
setCurrentFileContent(content);
} catch {
setCurrentFileContent(null);
}
} catch (error) {
console.error("Failed to load prompts:", error);
} finally {
setLoading(false);
}
}, [appType]);
const savePrompt = useCallback(
async (id: string, prompt: Prompt) => {
await promptsApi.upsertPrompt(appType, id, prompt);
await reload();
},
[appType, reload],
);
const deletePrompt = useCallback(
async (id: string) => {
await promptsApi.deletePrompt(appType, id);
await reload();
},
[appType, reload],
);
const enablePrompt = useCallback(
async (id: string) => {
await promptsApi.enablePrompt(appType, id);
await reload();
},
[appType, reload],
);
const toggleEnabled = useCallback(
async (id: string, enabled: boolean) => {
// Optimistic update
const previousPrompts = prompts;
if (enabled) {
// If enabling, first disable all others
const updatedPrompts = Object.keys(prompts).reduce(
(acc, key) => {
acc[key] = {
...prompts[key],
enabled: key === id,
};
return acc;
},
{} as Record<string, Prompt>,
);
setPrompts(updatedPrompts);
} else {
setPrompts((prev) => ({
...prev,
[id]: {
...prev[id],
enabled: false,
},
}));
}
try {
if (enabled) {
await promptsApi.enablePrompt(appType, id);
} else {
// Disable by updating with enabled=false
await promptsApi.upsertPrompt(appType, id, {
...prompts[id],
enabled: false,
});
}
await reload();
} catch (error) {
// Rollback on failure
setPrompts(previousPrompts);
throw error;
}
},
[appType, prompts, reload],
);
const importFromFile = useCallback(async () => {
const id = await promptsApi.importFromFile(appType);
await reload();
return id;
}, [appType, reload]);
return {
prompts,
loading,
currentFileContent,
reload,
savePrompt,
deletePrompt,
enablePrompt,
toggleEnabled,
importFromFile,
};
}
+69
View File
@@ -0,0 +1,69 @@
import { useState, useEffect, useCallback } from "react";
import { switchApi, Provider, AppType } from "@/lib/api/switch";
export function useSwitch(appType: AppType) {
const [providers, setProviders] = useState<Provider[]>([]);
const [currentProvider, setCurrentProvider] = useState<Provider | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchProviders = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [list, current] = await Promise.all([
switchApi.getProviders(appType),
switchApi.getCurrentProvider(appType),
]);
setProviders(list);
setCurrentProvider(current);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [appType]);
useEffect(() => {
fetchProviders();
}, [fetchProviders]);
const addProvider = async (provider: Omit<Provider, "id" | "is_current" | "created_at">) => {
const newProvider: Provider = {
...provider,
id: crypto.randomUUID(),
app_type: appType,
is_current: false,
created_at: Date.now(),
};
await switchApi.addProvider(newProvider);
await fetchProviders();
};
const updateProvider = async (provider: Provider) => {
await switchApi.updateProvider(provider);
await fetchProviders();
};
const deleteProvider = async (id: string) => {
await switchApi.deleteProvider(appType, id);
await fetchProviders();
};
const switchToProvider = async (id: string) => {
await switchApi.switchProvider(appType, id);
await fetchProviders();
};
return {
providers,
currentProvider,
loading,
error,
addProvider,
updateProvider,
deleteProvider,
switchToProvider,
refresh: fetchProviders,
};
}
+62
View File
@@ -0,0 +1,62 @@
import { invoke } from "@tauri-apps/api/core";
export type OAuthProvider = "kiro" | "gemini" | "qwen";
export interface OAuthCredentialStatus {
provider: string;
loaded: boolean;
has_access_token: boolean;
has_refresh_token: boolean;
is_valid: boolean;
expiry_info: string | null;
creds_path: string;
extra: Record<string, unknown>;
}
export interface EnvVariable {
key: string;
value: string;
masked: string;
}
export interface CheckResult {
changed: boolean;
new_hash: string;
reloaded: boolean;
}
export const credentialsApi = {
/** Get credentials status for a specific provider */
getCredentials: (provider: OAuthProvider): Promise<OAuthCredentialStatus> =>
invoke("get_oauth_credentials", { provider }),
/** Get all OAuth credentials at once */
getAllCredentials: (): Promise<OAuthCredentialStatus[]> =>
invoke("get_all_oauth_credentials"),
/** Reload credentials from file */
reloadCredentials: (provider: OAuthProvider): Promise<string> =>
invoke("reload_oauth_credentials", { provider }),
/** Refresh OAuth token */
refreshToken: (provider: OAuthProvider): Promise<string> =>
invoke("refresh_oauth_token", { provider }),
/** Get environment variables for a provider */
getEnvVariables: (provider: OAuthProvider): Promise<EnvVariable[]> =>
invoke("get_oauth_env_variables", { provider }),
/** Get token file hash for change detection */
getTokenFileHash: (provider: OAuthProvider): Promise<string> =>
invoke("get_oauth_token_file_hash", { provider }),
/** Check and reload credentials if file changed */
checkAndReload: (
provider: OAuthProvider,
lastHash: string,
): Promise<CheckResult> =>
invoke("check_and_reload_oauth_credentials", {
provider,
lastHash,
}),
};
+42
View File
@@ -0,0 +1,42 @@
import { invoke } from "@tauri-apps/api/core";
export interface McpServer {
id: string;
name: string;
server_config: {
command: string;
args?: string[];
env?: Record<string, string>;
};
description?: string;
enabled_proxycast: boolean;
enabled_claude: boolean;
enabled_codex: boolean;
enabled_gemini: boolean;
created_at?: number;
}
export const mcpApi = {
getServers: (): Promise<McpServer[]> =>
invoke("get_mcp_servers"),
addServer: (server: McpServer): Promise<void> =>
invoke("add_mcp_server", { server }),
updateServer: (server: McpServer): Promise<void> =>
invoke("update_mcp_server", { server }),
deleteServer: (id: string): Promise<void> =>
invoke("delete_mcp_server", { id }),
toggleServer: (id: string, appType: string, enabled: boolean): Promise<void> =>
invoke("toggle_mcp_server", { id, appType, enabled }),
/** 从外部应用导入 MCP 配置 */
importFromApp: (appType: string): Promise<number> =>
invoke("import_mcp_from_app", { appType }),
/** 同步所有 MCP 配置到实际配置文件 */
syncAllToLive: (): Promise<void> =>
invoke("sync_all_mcp_to_live"),
};
+56
View File
@@ -0,0 +1,56 @@
import { invoke } from "@tauri-apps/api/core";
export interface Prompt {
id: string;
app_type: string;
name: string;
content: string;
description?: string;
enabled: boolean;
createdAt?: number;
updatedAt?: number;
}
export type AppType = "claude" | "codex" | "gemini";
export const promptsApi = {
/** Get all prompts as a map (id -> Prompt) */
getPrompts: (app: AppType): Promise<Record<string, Prompt>> =>
invoke("get_prompts", { app }),
/** Upsert a prompt (insert or update) */
upsertPrompt: (app: AppType, id: string, prompt: Prompt): Promise<void> =>
invoke("upsert_prompt", { app, id, prompt }),
/** Add a new prompt */
addPrompt: (prompt: Prompt): Promise<void> =>
invoke("add_prompt", { prompt }),
/** Update an existing prompt */
updatePrompt: (prompt: Prompt): Promise<void> =>
invoke("update_prompt", { prompt }),
/** Delete a prompt */
deletePrompt: (app: AppType, id: string): Promise<void> =>
invoke("delete_prompt", { app, id }),
/** Enable a prompt and sync to live file */
enablePrompt: (app: AppType, id: string): Promise<void> =>
invoke("enable_prompt", { app, id }),
/** Import prompt from live file */
importFromFile: (app: AppType): Promise<string> =>
invoke("import_prompt_from_file", { app }),
/** Get current live prompt file content */
getCurrentFileContent: (app: AppType): Promise<string | null> =>
invoke("get_current_prompt_file_content", { app }),
/** Auto-import from live file if no prompts exist */
autoImport: (app: AppType): Promise<number> =>
invoke("auto_import_prompt", { app }),
// Legacy API for compatibility
switchPrompt: (appType: AppType, id: string): Promise<void> =>
invoke("switch_prompt", { appType, id }),
};
+42
View File
@@ -0,0 +1,42 @@
import { invoke } from "@tauri-apps/api/core";
export interface Provider {
id: string;
app_type: string;
name: string;
settings_config: Record<string, unknown>;
category?: string;
icon?: string;
icon_color?: string;
notes?: string;
created_at?: number;
sort_index?: number;
is_current: boolean;
}
// proxycast 保留用于内部配置存储,但不在 UI 的 Tab 中显示
export type AppType = "claude" | "codex" | "gemini" | "proxycast";
export const switchApi = {
getProviders: (appType: AppType): Promise<Provider[]> =>
invoke("get_switch_providers", { appType }),
getCurrentProvider: (appType: AppType): Promise<Provider | null> =>
invoke("get_current_switch_provider", { appType }),
addProvider: (provider: Provider): Promise<void> =>
invoke("add_switch_provider", { provider }),
updateProvider: (provider: Provider): Promise<void> =>
invoke("update_switch_provider", { provider }),
deleteProvider: (appType: AppType, id: string): Promise<void> =>
invoke("delete_switch_provider", { appType, id }),
switchProvider: (appType: AppType, id: string): Promise<void> =>
invoke("switch_provider", { appType, id }),
/** 读取当前生效的配置(从实际配置文件读取) */
readLiveSettings: (appType: AppType): Promise<Record<string, unknown>> =>
invoke("read_live_provider_settings", { appType }),
};