diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index 4f87b2d..21d295e 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -7,7 +7,7 @@ LSMinimumSystemVersion 10.15 CFBundleShortVersionString - 0.1.0 + 1.2.0 CFBundleName Claudia CFBundleDisplayName diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 2db974f..0ad2849 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -724,3 +724,75 @@ pub async fn mcp_save_project_config( Ok("Project MCP configuration saved".to_string()) } + +/// Export configuration for MCP server +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MCPExportConfig { + pub name: String, + pub transport: String, + pub command: Option, + pub args: Vec, + pub env: HashMap, + pub url: Option, + pub scope: String, +} + +/// Export result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MCPExportResult { + pub servers: Vec, + pub format: String, // "single" or "multiple" +} + +/// Exports all MCP servers configuration +#[tauri::command] +pub async fn mcp_export_servers(app: AppHandle) -> Result { + info!("Exporting MCP servers configuration"); + + // Get all servers + let servers = mcp_list(app.clone()).await?; + + if servers.is_empty() { + return Ok(MCPExportResult { + servers: vec![], + format: "multiple".to_string(), + }); + } + + // Get detailed information for each server + let mut export_configs = Vec::new(); + + for server in &servers { + match mcp_get(app.clone(), server.name.clone()).await { + Ok(detailed_server) => { + export_configs.push(MCPExportConfig { + name: detailed_server.name, + transport: detailed_server.transport, + command: detailed_server.command, + args: detailed_server.args, + env: detailed_server.env, + url: detailed_server.url, + scope: detailed_server.scope, + }); + } + Err(e) => { + error!("Failed to get details for server {}: {}", server.name, e); + // Still include basic information + export_configs.push(MCPExportConfig { + name: server.name.clone(), + transport: server.transport.clone(), + command: server.command.clone(), + args: server.args.clone(), + env: server.env.clone(), + url: server.url.clone(), + scope: server.scope.clone(), + }); + } + } + } + + Ok(MCPExportResult { + format: if export_configs.len() == 1 { "single" } else { "multiple" }.to_string(), + servers: export_configs, + }) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 53c8c0b..b242638 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -36,7 +36,7 @@ use commands::claude::{ use commands::mcp::{ mcp_add, mcp_add_from_claude_desktop, mcp_add_json, mcp_get, mcp_get_server_status, mcp_list, mcp_read_project_config, mcp_remove, mcp_reset_project_choices, mcp_save_project_config, - mcp_serve, mcp_test_connection, + mcp_serve, mcp_test_connection, mcp_export_servers, }; use commands::usage::{ @@ -349,6 +349,7 @@ fn main() { mcp_get_server_status, mcp_read_project_config, mcp_save_project_config, + mcp_export_servers, // Storage Management storage_list_tables, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5d6d9da..ba7e017 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,7 +18,7 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost blob: data:; style-src 'self' 'unsafe-inline' blob: data: asset: https://asset.localhost; style-src-elem 'self' 'unsafe-inline' blob: data: asset: https://asset.localhost; style-src-attr 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval' https://app.posthog.com https://*.posthog.com https://*.i.posthog.com https://*.assets.i.posthog.com; worker-src 'self' blob: asset: https://asset.localhost; font-src 'self' data: blob: asset: https://asset.localhost; connect-src 'self' ipc: http://ipc.localhost https://ipc.localhost https://app.posthog.com https://*.posthog.com https://*.i.posthog.com", + "csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost blob: data:; style-src 'self' 'unsafe-inline' blob: data: asset: https://asset.localhost; style-src-elem 'self' 'unsafe-inline' blob: data: asset: https://asset.localhost; style-src-attr 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval' https://app.posthog.com https://*.posthog.com https://*.i.posthog.com https://*.assets.i.posthog.com; worker-src 'self' blob: asset: https://asset.localhost; font-src 'self' data: blob: asset: https://asset.localhost; connect-src 'self' ipc: http://ipc.localhost https://ipc.localhost https://app.posthog.com https://*.posthog.com https://*.i.posthog.com https://api.packycode.com https://api-hk-cn2.packycode.com https://api-us-cmin2.packycode.com https://api-us-4837.packycode.com https://api-us-cn2.packycode.com https://api-cf-pro.packycode.com https://share-api.packycode.com https://share-api-cf-pro.packycode.com https://share-api-hk-cn2.packycode.com", "assetProtocol": { "enable": true, "scope": [ diff --git a/src/components/MCPImportExport.tsx b/src/components/MCPImportExport.tsx index c60aeb9..a1cad60 100644 --- a/src/components/MCPImportExport.tsx +++ b/src/components/MCPImportExport.tsx @@ -29,6 +29,7 @@ export const MCPImportExport: React.FC = ({ const [importingDesktop, setImportingDesktop] = useState(false); const [importingJson, setImportingJson] = useState(false); const [importScope, setImportScope] = useState("local"); + const [exporting, setExporting] = useState(false); /** * Imports servers from Claude Desktop @@ -142,11 +143,84 @@ export const MCPImportExport: React.FC = ({ }; /** - * Handles exporting servers (placeholder) + * Handles exporting servers */ - const handleExport = () => { - // TODO: Implement export functionality - onError("Export functionality coming soon!"); + const handleExport = async () => { + try { + setExporting(true); + const result = await api.mcpExportServers(); + + if (result.servers.length === 0) { + onError("No MCP servers configured to export"); + return; + } + + let jsonContent: string; + let defaultFileName: string; + + if (result.format === "single" && result.servers.length === 1) { + // Single server format + const server = result.servers[0]; + const exportData: any = { + type: server.transport, + }; + + if (server.transport === "stdio") { + exportData.command = server.command; + exportData.args = server.args; + exportData.env = server.env; + } else if (server.transport === "sse") { + exportData.url = server.url; + } + + jsonContent = JSON.stringify(exportData, null, 2); + defaultFileName = `mcp-server-${server.name}.json`; + } else { + // Multiple servers format + const exportData: any = { + mcpServers: {} + }; + + for (const server of result.servers) { + const serverConfig: any = { + command: server.command || "", + args: server.args, + env: server.env + }; + + if (server.transport === "sse") { + serverConfig.url = server.url; + } + + exportData.mcpServers[server.name] = serverConfig; + } + + jsonContent = JSON.stringify(exportData, null, 2); + defaultFileName = "mcp-servers.json"; + } + + // Use Tauri's save dialog + const { save } = await import('@tauri-apps/plugin-dialog'); + const filePath = await save({ + defaultPath: defaultFileName, + filters: [{ + name: 'JSON', + extensions: ['json'] + }] + }); + + if (filePath) { + // Use Tauri's file system API to write the file + const { writeTextFile } = await import('@tauri-apps/plugin-fs'); + await writeTextFile(filePath, jsonContent); + onError(`Successfully exported ${result.servers.length} server(s) to ${filePath}`); + } + } catch (error: any) { + console.error("Failed to export servers:", error); + onError(error.toString() || "Failed to export servers"); + } finally { + setExporting(false); + } }; /** @@ -273,12 +347,12 @@ export const MCPImportExport: React.FC = ({ - {/* Export (Coming Soon) */} - + {/* Export Configuration */} +
-
- +
+

{t('mcp.exportConfiguration')}

@@ -289,12 +363,21 @@ export const MCPImportExport: React.FC = ({
diff --git a/src/components/RelayStationManager.tsx b/src/components/RelayStationManager.tsx index 3c3860e..1c33de1 100644 --- a/src/components/RelayStationManager.tsx +++ b/src/components/RelayStationManager.tsx @@ -1145,7 +1145,53 @@ const CreateStationDialog: React.FC<{ try { setSubmitting(true); - await api.relayStationCreate(formData); + + // PackyCode 保存时自动选择最佳节点 + if (formData.adapter === 'packycode') { + let finalApiUrl = formData.api_url; + + if (packycodeService === 'bus') { + // 公交车自动选择 + const busNodes = [ + { url: "https://api.packycode.com", name: "🚌 直连1(默认公交车)" }, + { url: "https://api-hk-cn2.packycode.com", name: "🇭🇰 直连2 (HK-CN2)" }, + { url: "https://api-us-cmin2.packycode.com", name: "🇺🇸 直连3 (US-CMIN2)" }, + { url: "https://api-us-4837.packycode.com", name: "🇺🇸 直连4 (US-4837)" }, + { url: "https://api-us-cn2.packycode.com", name: "🔄 备用1 (US-CN2)" }, + { url: "https://api-cf-pro.packycode.com", name: "☁️ 备用2 (CF-Pro)" } + ]; + + await performSpeedTest(busNodes, (bestNode) => { + finalApiUrl = bestNode.url; + setPackycodeNode(bestNode.url); + }); + } else if (packycodeService === 'taxi') { + // 滴滴车自动选择 + const taxiNodes = [ + { url: "https://share-api.packycode.com", name: "🚗 直连1(默认滴滴车)" }, + { url: "https://share-api-cf-pro.packycode.com", name: "☁️ 备用1 (CF-Pro)" }, + { url: "https://share-api-hk-cn2.packycode.com", name: "🇭🇰 备用2 (HK-CN2)" } + ]; + + await performSpeedTest(taxiNodes, (bestNode) => { + finalApiUrl = bestNode.url; + setPackycodeTaxiNode(bestNode.url); + }); + } + + // 使用选择的最佳节点创建中转站 + await api.relayStationCreate({ + ...formData, + api_url: finalApiUrl, + adapter_config: { + service_type: packycodeService + } + }); + } else { + // 非 PackyCode 适配器直接创建 + await api.relayStationCreate(formData); + } + onSuccess(); } catch (error) { console.error('Failed to create station:', error); @@ -1718,7 +1764,7 @@ const EditStationDialog: React.FC<{ // PackyCode 特定状态 const [packycodeService, setPackycodeService] = useState(() => { // 从API URL判断服务类型 - if (station.adapter === 'packycode' && station.api_url.includes('share-api')) { + if (station.adapter === 'packycode' && (station.api_url.includes('share-api') || station.api_url.includes('codex-api'))) { return 'taxi'; } return 'bus'; @@ -1730,6 +1776,13 @@ const EditStationDialog: React.FC<{ } return 'https://api.packycode.com'; }); + const [packycodeTaxiNode, setPackycodeTaxiNode] = useState(() => { + // 如果是PackyCode滴滴车,使用当前的API URL + if (station.adapter === 'packycode' && (station.api_url.includes('share-api') || station.api_url.includes('codex-api'))) { + return station.api_url; + } + return 'https://share-api.packycode.com'; + }); const [showSpeedTestModal, setShowSpeedTestModal] = useState(false); const [speedTestResults, setSpeedTestResults] = useState<{ url: string; name: string; responseTime: number | null; status: 'testing' | 'success' | 'failed' }[]>([]); @@ -1876,7 +1929,158 @@ const EditStationDialog: React.FC<{ try { setSubmitting(true); - await api.relayStationUpdate(formData); + + // PackyCode 保存时自动选择最佳节点 + if (formData.adapter === 'packycode') { + let finalApiUrl = formData.api_url; + + if (packycodeService === 'bus') { + // 公交车自动选择 + const busNodes = [ + { url: "https://api.packycode.com", name: "🚌 直连1(默认公交车)" }, + { url: "https://api-hk-cn2.packycode.com", name: "🇭🇰 直连2 (HK-CN2)" }, + { url: "https://api-us-cmin2.packycode.com", name: "🇺🇸 直连3 (US-CMIN2)" }, + { url: "https://api-us-4837.packycode.com", name: "🇺🇸 直连4 (US-4837)" }, + { url: "https://api-us-cn2.packycode.com", name: "🔄 备用1 (US-CN2)" }, + { url: "https://api-cf-pro.packycode.com", name: "☁️ 备用2 (CF-Pro)" } + ]; + + await new Promise((resolve) => { + // 内联的测速逻辑 + setShowSpeedTestModal(true); + setSpeedTestInProgress(true); + + const initialResults = busNodes.map(node => ({ + url: node.url, + name: node.name, + responseTime: null, + status: 'testing' as const + })); + setSpeedTestResults(initialResults); + + let bestNode = busNodes[0]; + let minTime = Infinity; + + const testPromises = busNodes.map(async (node, index) => { + try { + const startTime = Date.now(); + await fetch(node.url, { + method: 'HEAD', + mode: 'no-cors' + }); + const responseTime = Date.now() - startTime; + + setSpeedTestResults(prev => prev.map((result, i) => + i === index ? { ...result, responseTime, status: 'success' } : result + )); + + if (responseTime < minTime) { + minTime = responseTime; + bestNode = node; + } + + return { node, responseTime }; + } catch (error) { + console.log(`Node ${node.url} failed:`, error); + setSpeedTestResults(prev => prev.map((result, i) => + i === index ? { ...result, responseTime: null, status: 'failed' } : result + )); + return { node, responseTime: null }; + } + }); + + Promise.all(testPromises).then(() => { + setTimeout(() => { + setSpeedTestInProgress(false); + finalApiUrl = bestNode.url; + setPackycodeNode(bestNode.url); + setTimeout(() => { + setShowSpeedTestModal(false); + resolve(); + }, 1000); + }, 2000); + }); + }); + } else if (packycodeService === 'taxi') { + // 滴滴车自动选择 + const taxiNodes = [ + { url: "https://share-api.packycode.com", name: "🚗 直连1(默认滴滴车)" }, + { url: "https://share-api-cf-pro.packycode.com", name: "☁️ 备用1 (CF-Pro)" }, + { url: "https://share-api-hk-cn2.packycode.com", name: "🇭🇰 备用2 (HK-CN2)" } + ]; + + await new Promise((resolve) => { + // 内联的测速逻辑 + setShowSpeedTestModal(true); + setSpeedTestInProgress(true); + + const initialResults = taxiNodes.map(node => ({ + url: node.url, + name: node.name, + responseTime: null, + status: 'testing' as const + })); + setSpeedTestResults(initialResults); + + let bestNode = taxiNodes[0]; + let minTime = Infinity; + + const testPromises = taxiNodes.map(async (node, index) => { + try { + const startTime = Date.now(); + await fetch(node.url, { + method: 'HEAD', + mode: 'no-cors' + }); + const responseTime = Date.now() - startTime; + + setSpeedTestResults(prev => prev.map((result, i) => + i === index ? { ...result, responseTime, status: 'success' } : result + )); + + if (responseTime < minTime) { + minTime = responseTime; + bestNode = node; + } + + return { node, responseTime }; + } catch (error) { + console.log(`Node ${node.url} failed:`, error); + setSpeedTestResults(prev => prev.map((result, i) => + i === index ? { ...result, responseTime: null, status: 'failed' } : result + )); + return { node, responseTime: null }; + } + }); + + Promise.all(testPromises).then(() => { + setTimeout(() => { + setSpeedTestInProgress(false); + finalApiUrl = bestNode.url; + setPackycodeTaxiNode(bestNode.url); + setFormData(prev => ({ ...prev, api_url: bestNode.url })); + setTimeout(() => { + setShowSpeedTestModal(false); + resolve(); + }, 1000); + }, 2000); + }); + }); + } + + // 使用选择的最佳节点更新中转站 + await api.relayStationUpdate({ + ...formData, + api_url: finalApiUrl, + adapter_config: { + service_type: packycodeService + } + }); + } else { + // 非 PackyCode 适配器直接更新 + await api.relayStationUpdate(formData); + } + onSuccess(); } catch (error) { console.error('Failed to update station:', error); @@ -2168,6 +2372,118 @@ const EditStationDialog: React.FC<{
)} + {formData.adapter === 'packycode' && packycodeService === 'taxi' && ( +
+ +
+
+
+ +
+ +
+ +

+ {t('relayStation.selectedNode') + ': ' + packycodeTaxiNode} +

+
+
+ )} +