mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
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:
co-authored by
Claude Sonnet 4.5
parent
cf0bf919c2
commit
42d140dfd5
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.4.3",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+57
-2
@@ -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"
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod config_cmd;
|
||||
pub mod mcp_cmd;
|
||||
pub mod oauth_cmd;
|
||||
pub mod prompt_cmd;
|
||||
pub mod switch_cmd;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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!({})),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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, ¤t_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)
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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= GOOGLE_GEMINI_BASE_URL= 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { McpPage } from "./McpPage";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { PromptsPage } from "./PromptsPage";
|
||||
export { PromptCard } from "./PromptCard";
|
||||
export { PromptForm } from "./PromptForm";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { SettingsPage } from "./SettingsPage";
|
||||
export { GeneralSettings } from "./GeneralSettings";
|
||||
export { ProxySettings } from "./ProxySettings";
|
||||
export { DirectorySettings } from "./DirectorySettings";
|
||||
export { AboutSection } from "./AboutSection";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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= GOOGLE_GEMINI_BASE_URL= 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { SwitchPage } from "./SwitchPage";
|
||||
export { AppTabs } from "./AppTabs";
|
||||
export { ProviderList } from "./ProviderList";
|
||||
export { ProviderCard } from "./ProviderCard";
|
||||
export { ProviderForm } from "./ProviderForm";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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 }),
|
||||
};
|
||||
Reference in New Issue
Block a user