diff --git a/deplink.html b/deplink.html index 900850b3..eab057c3 100644 --- a/deplink.html +++ b/deplink.html @@ -923,6 +923,696 @@ + +
+

🏢 供应商导入 v3.8+

+ + + + + + +
+ + +
+

🔌 MCP Servers 导入 v3.8+

+ + + + + + + + +
+ + +
+

💬 Prompt 导入 v3.8+

+ + + + + + +
+ + +
+

🛠️ Skill 仓库导入 v3.8+

+ + +
+ + +
+

🚀 深链接生成器

+

+ 填写参数信息,自动生成深链接并导入到 CC Switch +

+ + +
+

🏢 供应商导入生成器

+ +
+ + +
+ +
+ + +
+ +
+ + + 您的 API 密钥 +
+ +
+ + + 完整的 API 端点 URL +
+ +
+ + +
+ +
+ + + 可选,留空使用系统默认 +
+ +
+ + +
+ +
+ + +
+ + + + +
+ + +
+

🔌 MCP Servers 导入生成器

+ +
+ + + 多个应用用逗号分隔 +
+ +
+ + + 完整的 MCP 配置 JSON +
+ +
+ + +
+ + + + +
+ + +
+

💬 Prompt 导入生成器

+ +
+ + +
+ +
+ + +
+ +
+ + + 支持 Markdown 格式,自动 Base64 编码 +
+ +
+ + +
+ +
+ + +
+ + + + +
+ + +
+

🛠️ Skill 仓库导入生成器

+ +
+ + + 格式: 所有者/仓库名 +
+ +
+ + +
+ +
+ + + 仓库中技能文件所在的子目录 +
+ +
+ + + 克隆到本地的目录名(可选) +
+ + + + +
+
+ + +
+

🔐 Base64 编解码器

+ +
+

编码器 (UTF-8 → Base64)

+
+ + +
+ + +
+ +
+

解码器 (Base64 → UTF-8)

+
+ + +
+ + +
+ +
+

💡 使用建议

+ +
+
+

⚠️ 使用注意事项

@@ -1109,6 +1799,22 @@
+
+ + + + 图标名称,用于在界面中显示 + +
+ +
+ + +
+
@@ -1274,6 +1980,8 @@ requires_openai_auth = true`; const endpoint = document.getElementById('endpoint').value.trim(); const apiKey = document.getElementById('apiKey').value.trim(); const model = document.getElementById('model').value.trim(); + const icon = document.getElementById('icon').value.trim(); + const enabled = document.getElementById('enabled').value; const notes = document.getElementById('notes').value.trim(); // Claude 专用字段 @@ -1309,6 +2017,8 @@ requires_openai_auth = true`; params.append('endpoint', endpoint); params.append('apiKey', apiKey); if (model) params.append('model', model); + if (icon) params.append('icon', icon); + if (enabled) params.append('enabled', enabled); if (notes) params.append('notes', notes); // 添加 Claude 专用模型字段 @@ -1366,6 +2076,8 @@ requires_openai_auth = true`; const overrideApiKey = document.getElementById('overrideApiKey').value.trim(); const overrideEndpoint = document.getElementById('overrideEndpoint').value.trim(); const model = document.getElementById('model').value.trim(); + const icon = document.getElementById('icon').value.trim(); + const enabled = document.getElementById('enabled').value; const notes = document.getElementById('notes').value.trim(); // Claude 专用字段 @@ -1391,6 +2103,8 @@ requires_openai_auth = true`; } if (model) params.append('model', model); + if (icon) params.append('icon', icon); + if (enabled) params.append('enabled', enabled); if (notes) params.append('notes', notes); // 添加 Claude 专用模型字段 @@ -1668,6 +2382,333 @@ requires_openai_auth = true`; // 初始化显示 Claude 字段 updateModelFields(); }); + + // Base64 编码功能 + function encodeToBase64() { + const input = document.getElementById('encodeInput').value; + + if (!input.trim()) { + alert('❌ 请输入要编码的内容!'); + return; + } + + try { + const encoded = utf8_to_b64(input); + document.getElementById('encodeOutput').textContent = encoded; + document.getElementById('encodeResult').style.display = 'block'; + + // 滚动到结果 + document.getElementById('encodeResult').scrollIntoView({ + behavior: 'smooth', + block: 'nearest' + }); + } catch (e) { + alert('❌ 编码失败:' + e.message); + console.error('Encode error:', e); + } + } + + // Base64 解码功能 + function decodeFromBase64() { + const input = document.getElementById('decodeInput').value.trim(); + + if (!input) { + alert('❌ 请输入要解码的 Base64 内容!'); + return; + } + + try { + const decoded = b64_to_utf8(input); + document.getElementById('decodeOutput').textContent = decoded; + document.getElementById('decodeResult').style.display = 'block'; + + // 检查是否是 JSON,如果是则显示格式化按钮 + try { + JSON.parse(decoded); + document.getElementById('jsonFormat').style.display = 'block'; + } catch { + document.getElementById('jsonFormat').style.display = 'none'; + } + + // 滚动到结果 + document.getElementById('decodeResult').scrollIntoView({ + behavior: 'smooth', + block: 'nearest' + }); + } catch (e) { + alert('❌ 解码失败:' + e.message + '\n\n请确保输入的是有效的 Base64 编码'); + console.error('Decode error:', e); + } + } + + // 格式化 JSON + function formatJson() { + try { + const text = document.getElementById('decodeOutput').textContent; + const obj = JSON.parse(text); + const formatted = JSON.stringify(obj, null, 2); + document.getElementById('decodeOutput').textContent = formatted; + } catch (e) { + alert('❌ JSON 格式化失败:' + e.message); + } + } + + // 复制编码结果 + function copyEncoded() { + const text = document.getElementById('encodeOutput').textContent; + navigator.clipboard.writeText(text).then(() => { + const btn = event.target; + const originalText = btn.textContent; + btn.textContent = '✅ 已复制!'; + btn.style.background = 'linear-gradient(135deg, #27ae60 0%, #229954 100%)'; + + setTimeout(() => { + btn.textContent = originalText; + btn.style.background = ''; + }, 2000); + }).catch(err => { + console.error('复制失败:', err); + alert('❌ 复制失败,请手动复制'); + }); + } + + // 复制解码结果 + function copyDecoded() { + const text = document.getElementById('decodeOutput').textContent; + navigator.clipboard.writeText(text).then(() => { + const btn = event.target; + const originalText = btn.textContent; + btn.textContent = '✅ 已复制!'; + btn.style.background = 'linear-gradient(135deg, #27ae60 0%, #229954 100%)'; + + setTimeout(() => { + btn.textContent = originalText; + btn.style.background = ''; + }, 2000); + }).catch(err => { + console.error('复制失败:', err); + alert('❌ 复制失败,请手动复制'); + }); + } + + // ==================== 深链接生成器函数 ==================== + + // 生成供应商深链接 + function generateProviderLink() { + const app = document.getElementById('providerApp').value; + const name = document.getElementById('providerName').value.trim(); + const apiKey = document.getElementById('providerApiKey').value.trim(); + const endpoint = document.getElementById('providerEndpoint').value.trim(); + const homepage = document.getElementById('providerHomepage').value.trim(); + const model = document.getElementById('providerModel').value.trim(); + const notes = document.getElementById('providerNotes').value.trim(); + const enabled = document.getElementById('providerEnabled').value; + + // 验证必填字段 + if (!name) { + alert('❌ 请填写供应商名称'); + return; + } + + if (!apiKey) { + alert('❌ 请填写 API Key'); + return; + } + + if (!endpoint) { + alert('❌ 请填写 API Endpoint'); + return; + } + + if (!homepage) { + alert('❌ 请填写主页链接'); + return; + } + + // 构建深链接 + let url = `ccswitch://v1/import?resource=provider&app=${app}&name=${encodeURIComponent(name)}&endpoint=${encodeURIComponent(endpoint)}&homepage=${encodeURIComponent(homepage)}&apiKey=${encodeURIComponent(apiKey)}`; + + if (model) { + url += `&model=${encodeURIComponent(model)}`; + } + + if (notes) { + url += `¬es=${encodeURIComponent(notes)}`; + } + + if (enabled === 'true') { + url += '&enabled=true'; + } + + // 显示结果 + document.getElementById('providerUrl').textContent = url; + document.getElementById('providerImportBtn').href = url; + document.getElementById('providerResult').style.display = 'block'; + + // 滚动到结果 + document.getElementById('providerResult').scrollIntoView({ + behavior: 'smooth', + block: 'nearest' + }); + } + + // 生成 MCP 深链接 + function generateMcpLink() { + const apps = document.getElementById('mcpApps').value.trim(); + const config = document.getElementById('mcpConfig').value.trim(); + const enabled = document.getElementById('mcpEnabled').value; + + if (!apps) { + alert('❌ 请填写目标应用'); + return; + } + + if (!config) { + alert('❌ 请填写 MCP 配置'); + return; + } + + try { + // 验证 JSON 格式 + const jsonObj = JSON.parse(config); + if (!jsonObj.mcpServers) { + alert('❌ 配置必须包含 mcpServers 字段'); + return; + } + + // Base64 编码配置 + const configB64 = utf8_to_b64(config); + + // 构建深链接 + let url = `ccswitch://v1/import?resource=mcp&apps=${encodeURIComponent(apps)}&config=${encodeURIComponent(configB64)}`; + + if (enabled === 'true') { + url += '&enabled=true'; + } + + // 显示结果 + document.getElementById('mcpUrl').textContent = url; + document.getElementById('mcpImportBtn').href = url; + document.getElementById('mcpResult').style.display = 'block'; + + // 滚动到结果 + document.getElementById('mcpResult').scrollIntoView({ + behavior: 'smooth', + block: 'nearest' + }); + } catch (e) { + alert('❌ JSON 格式错误:' + e.message); + } + } + + // 生成 Prompt 深链接 + function generatePromptLink() { + const app = document.getElementById('promptApp').value; + const name = document.getElementById('promptName').value.trim(); + const content = document.getElementById('promptContent').value.trim(); + const description = document.getElementById('promptDescription').value.trim(); + const enabled = document.getElementById('promptEnabled').value; + + if (!name) { + alert('❌ 请填写提示词名称'); + return; + } + + if (!content) { + alert('❌ 请填写提示词内容'); + return; + } + + // Base64 编码内容 + const contentB64 = utf8_to_b64(content); + + // 构建深链接 + let url = `ccswitch://v1/import?resource=prompt&app=${app}&name=${encodeURIComponent(name)}&content=${encodeURIComponent(contentB64)}`; + + if (description) { + url += `&description=${encodeURIComponent(description)}`; + } + + if (enabled === 'true') { + url += '&enabled=true'; + } + + // 显示结果 + document.getElementById('promptUrl').textContent = url; + document.getElementById('promptImportBtn').href = url; + document.getElementById('promptResult').style.display = 'block'; + + // 滚动到结果 + document.getElementById('promptResult').scrollIntoView({ + behavior: 'smooth', + block: 'nearest' + }); + } + + // 生成 Skill 深链接 + function generateSkillLink() { + const repo = document.getElementById('skillRepo').value.trim(); + const branch = document.getElementById('skillBranch').value.trim() || 'main'; + const skillsPath = document.getElementById('skillPath').value.trim() || 'skills'; + const directory = document.getElementById('skillDirectory').value.trim(); + + if (!repo) { + alert('❌ 请填写 GitHub 仓库'); + return; + } + + // 验证仓库格式 + if (!repo.includes('/')) { + alert('❌ 仓库格式应为: owner/repo-name'); + return; + } + + // 构建深链接 + let url = `ccswitch://v1/import?resource=skill&repo=${encodeURIComponent(repo)}&branch=${encodeURIComponent(branch)}&skills_path=${encodeURIComponent(skillsPath)}`; + + if (directory) { + url += `&directory=${encodeURIComponent(directory)}`; + } + + // 显示结果 + document.getElementById('skillUrl').textContent = url; + document.getElementById('skillImportBtn').href = url; + document.getElementById('skillResult').style.display = 'block'; + + // 滚动到结果 + document.getElementById('skillResult').scrollIntoView({ + behavior: 'smooth', + block: 'nearest' + }); + } + + // 复制生成的链接 + function copyGeneratedLink(elementId) { + const text = document.getElementById(elementId).textContent; + navigator.clipboard.writeText(text).then(() => { + const btn = event.target; + const originalText = btn.textContent; + btn.textContent = '✅ 已复制!'; + btn.style.background = 'linear-gradient(135deg, #27ae60 0%, #229954 100%)'; + + setTimeout(() => { + btn.textContent = originalText; + btn.style.background = ''; + }, 2000); + }).catch(err => { + console.error('复制失败:', err); + alert('❌ 复制失败,请手动复制'); + }); + } + + // 选中文本(点击 URL 时) + function selectText(element) { + const range = document.createRange(); + range.selectNodeContents(element); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } diff --git a/scripts/filter-icons.js b/scripts/filter-icons.js index d7448c69..b4567ff8 100644 --- a/scripts/filter-icons.js +++ b/scripts/filter-icons.js @@ -14,6 +14,7 @@ const KEEP_LIST = [ 'zhipu', 'chatglm', 'glm', 'minimax', 'mistral', 'cohere', 'perplexity', 'huggingface', 'midjourney', 'stability', 'xai', 'grok', 'yi', 'zeroone', 'ollama', + 'packycode', // Cloud/Tools 'aws', 'googlecloud', 'huawei', 'cloudflare', diff --git a/scripts/generate-icon-index.js b/scripts/generate-icon-index.js index 897a065e..3e13f687 100644 --- a/scripts/generate-icon-index.js +++ b/scripts/generate-icon-index.js @@ -24,6 +24,7 @@ const KNOWN_METADATA = { microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' }, cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' }, perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' }, + packycode: { name: 'packycode', displayName: 'PackyCode', category: 'ai-provider', keywords: ['packycode', 'packy', 'packyapi'], defaultColor: 'currentColor' }, mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' }, huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' }, aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' }, diff --git a/src-tauri/src/commands/deeplink.rs b/src-tauri/src/commands/deeplink.rs index 755f773b..0ef03e41 100644 --- a/src-tauri/src/commands/deeplink.rs +++ b/src-tauri/src/commands/deeplink.rs @@ -1,4 +1,7 @@ -use crate::deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest}; +use crate::deeplink::{ + import_mcp_from_deeplink, import_prompt_from_deeplink, import_provider_from_deeplink, + import_skill_from_deeplink, parse_deeplink_url, DeepLinkImportRequest, +}; use crate::store::AppState; use tauri::State; @@ -15,18 +18,18 @@ pub fn parse_deeplink(url: String) -> Result { pub fn merge_deeplink_config( request: DeepLinkImportRequest, ) -> Result { - log::info!("Merging config for deep link request: {}", request.name); + log::info!("Merging config for deep link request: {:?}", request.name); crate::deeplink::parse_and_merge_config(&request).map_err(|e| e.to_string()) } -/// Import a provider from a deep link request (after user confirmation) +/// Import a provider from a deep link request (legacy, kept for compatibility) #[tauri::command] pub fn import_from_deeplink( state: State, request: DeepLinkImportRequest, ) -> Result { log::info!( - "Importing provider from deep link: {} for app {}", + "Importing provider from deep link: {:?} for app {:?}", request.name, request.app ); @@ -37,3 +40,50 @@ pub fn import_from_deeplink( Ok(provider_id) } + +/// Import resource from a deep link request (unified handler) +#[tauri::command] +pub async fn import_from_deeplink_unified( + state: State<'_, AppState>, + request: DeepLinkImportRequest, +) -> Result { + log::info!("Importing {} resource from deep link", request.resource); + + match request.resource.as_str() { + "provider" => { + let provider_id = + import_provider_from_deeplink(&state, request).map_err(|e| e.to_string())?; + Ok(serde_json::json!({ + "type": "provider", + "id": provider_id + })) + } + "prompt" => { + let prompt_id = + import_prompt_from_deeplink(&state, request).map_err(|e| e.to_string())?; + Ok(serde_json::json!({ + "type": "prompt", + "id": prompt_id + })) + } + "mcp" => { + let result = import_mcp_from_deeplink(&state, request).map_err(|e| e.to_string())?; + // Add type field to the result + Ok(serde_json::json!({ + "type": "mcp", + "importedCount": result.imported_count, + "importedIds": result.imported_ids, + "failed": result.failed + })) + } + "skill" => { + let skill_key = + import_skill_from_deeplink(&state, request).map_err(|e| e.to_string())?; + Ok(serde_json::json!({ + "type": "skill", + "key": skill_key + })) + } + _ => Err(format!("Unsupported resource type: {}", request.resource)), + } +} diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index 486d7283..0c2cd8cc 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -63,6 +63,22 @@ impl Database { Ok(db) } + /// 创建内存数据库(用于测试) + pub fn memory() -> Result { + let conn = Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?; + + // 启用外键约束 + conn.execute("PRAGMA foreign_keys = ON;", []) + .map_err(|e| AppError::Database(e.to_string()))?; + + let db = Self { + conn: Mutex::new(conn), + }; + db.create_tables()?; + + Ok(db) + } + fn create_tables(&self) -> Result<(), AppError> { let conn = lock_conn!(self.conn); Self::create_tables_on_conn(&conn) @@ -373,8 +389,8 @@ impl Database { // 导出 schema let mut stmt = conn .prepare( - "SELECT type, name, tbl_name, sql - FROM sqlite_master + "SELECT type, name, tbl_name, sql + FROM sqlite_master WHERE sql NOT NULL AND type IN ('table','index','trigger','view') ORDER BY type='table' DESC, name", ) @@ -500,7 +516,7 @@ impl Database { tx.execute( "INSERT OR REPLACE INTO providers ( - id, app_type, name, settings_config, website_url, category, + id, app_type, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, is_current ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", params![ @@ -793,7 +809,7 @@ impl Database { tx.execute( "INSERT OR REPLACE INTO providers ( - id, app_type, name, settings_config, website_url, category, + id, app_type, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, is_current ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", params![ diff --git a/src-tauri/src/deeplink.rs b/src-tauri/src/deeplink.rs index f81079d7..d541ac94 100644 --- a/src-tauri/src/deeplink.rs +++ b/src-tauri/src/deeplink.rs @@ -1,13 +1,19 @@ +use crate::app_config::{McpApps, McpServer}; /// Deep link import functionality for CC Switch /// /// This module implements the ccswitch:// protocol for importing provider configurations /// via deep links. See docs/ccswitch-deeplink-design.md for detailed design. use crate::error::AppError; +use crate::prompt::Prompt; use crate::provider::Provider; +use crate::services::skill::SkillRepo; use crate::services::ProviderService; use crate::store::AppState; use crate::AppType; +use base64::prelude::*; +use base64::Engine; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::collections::HashMap; use std::str::FromStr; use url::Url; @@ -19,18 +25,33 @@ use url::Url; pub struct DeepLinkImportRequest { /// Protocol version (e.g., "v1") pub version: String, - /// Resource type to import (e.g., "provider") + /// Resource type to import: "provider" | "prompt" | "mcp" | "skill" pub resource: String, - /// Target application (claude/codex/gemini) - pub app: String, - /// Provider name - pub name: String, + + // ============ Common fields ============ + /// Target application (claude/codex/gemini) - for provider, prompt, skill + #[serde(skip_serializing_if = "Option::is_none")] + pub app: Option, + /// Resource name + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Whether to enable after import (default: false) + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + + // ============ Provider-specific fields (existing) ============ /// Provider homepage URL - pub homepage: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub homepage: Option, /// API endpoint/base URL - pub endpoint: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub endpoint: Option, /// API key - pub api_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, + /// Optional provider icon name (maps to built-in SVG) + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, /// Optional model name #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, @@ -46,21 +67,72 @@ pub struct DeepLinkImportRequest { /// Optional Opus model (Claude only, v3.7.1+) #[serde(skip_serializing_if = "Option::is_none")] pub opus_model: Option, - /// Optional Base64 encoded config content (v3.8+) + + // ============ Prompt-specific fields ============ + /// Base64 encoded Markdown content + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + /// Prompt description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + // ============ MCP-specific fields ============ + /// Target applications for MCP (comma-separated: "claude,codex,gemini") + #[serde(skip_serializing_if = "Option::is_none")] + pub apps: Option, + + // ============ Skill-specific fields ============ + /// GitHub repository (format: "owner/name") + #[serde(skip_serializing_if = "Option::is_none")] + pub repo: Option, + /// Skill directory name + #[serde(skip_serializing_if = "Option::is_none")] + pub directory: Option, + /// Repository branch (default: "main") + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + /// Skills subdirectory path (e.g., "skills") + #[serde(skip_serializing_if = "Option::is_none")] + pub skills_path: Option, + + // ============ Config file fields (v3.8+) ============ + /// Base64 encoded config content #[serde(skip_serializing_if = "Option::is_none")] pub config: Option, - /// Optional config format (json/toml, v3.8+) + /// Config format (json/toml) #[serde(skip_serializing_if = "Option::is_none")] pub config_format: Option, - /// Optional remote config URL (v3.8+) + /// Remote config URL #[serde(skip_serializing_if = "Option::is_none")] pub config_url: Option, } +/// MCP import result +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpImportResult { + /// Number of successfully imported MCP servers + pub imported_count: usize, + /// IDs of successfully imported MCP servers + pub imported_ids: Vec, + /// Failed imports with error messages + pub failed: Vec, +} + +/// MCP import error +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpImportError { + /// MCP server ID + pub id: String, + /// Error message + pub error: String, +} + /// Parse a ccswitch:// URL into a DeepLinkImportRequest /// /// Expected format: -/// ccswitch://v1/import?resource=provider&app=claude&name=...&homepage=...&endpoint=...&apiKey=... +/// ccswitch://v1/import?resource={type}&... pub fn parse_deeplink_url(url_str: &str) -> Result { // Parse URL let url = Url::parse(url_str) @@ -104,13 +176,24 @@ pub fn parse_deeplink_url(url_str: &str) -> Result parse_provider_deeplink(¶ms, version, resource), + "prompt" => parse_prompt_deeplink(¶ms, version, resource), + "mcp" => parse_mcp_deeplink(¶ms, version, resource), + "skill" => parse_skill_deeplink(¶ms, version, resource), + _ => Err(AppError::InvalidInput(format!( "Unsupported resource type: {resource}" - ))); + ))), } +} - // Extract required fields +/// Parse provider deep link parameters +fn parse_provider_deeplink( + params: &HashMap, + version: String, + resource: String, +) -> Result { let app = params .get("app") .ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter".to_string()))? @@ -129,51 +212,236 @@ pub fn parse_deeplink_url(url_str: &str) -> Result().ok()); Ok(DeepLinkImportRequest { version, resource, - app, - name, + app: Some(app), + name: Some(name), + enabled, homepage, endpoint, api_key, + icon, model, notes, haiku_model, sonnet_model, opus_model, + content: None, + description: None, + apps: None, + repo: None, + directory: None, + branch: None, + skills_path: None, config, config_format, config_url, }) } +/// Parse prompt deep link parameters +fn parse_prompt_deeplink( + params: &HashMap, + version: String, + resource: String, +) -> Result { + let app = params + .get("app") + .ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter for prompt".to_string()))? + .clone(); + + // Validate app type + if app != "claude" && app != "codex" && app != "gemini" { + return Err(AppError::InvalidInput(format!( + "Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'" + ))); + } + + let name = params + .get("name") + .ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter for prompt".to_string()))? + .clone(); + + let content = params + .get("content") + .ok_or_else(|| { + AppError::InvalidInput("Missing 'content' parameter for prompt".to_string()) + })? + .clone(); + + let description = params.get("description").cloned(); + let enabled = params.get("enabled").and_then(|v| v.parse::().ok()); + + Ok(DeepLinkImportRequest { + version, + resource, + app: Some(app), + name: Some(name), + enabled, + content: Some(content), + description, + icon: None, + homepage: None, + endpoint: None, + api_key: None, + model: None, + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + apps: None, + repo: None, + directory: None, + branch: None, + skills_path: None, + config: None, + config_format: None, + config_url: None, + }) +} + +/// Parse MCP deep link parameters +fn parse_mcp_deeplink( + params: &HashMap, + version: String, + resource: String, +) -> Result { + let apps = params + .get("apps") + .ok_or_else(|| AppError::InvalidInput("Missing 'apps' parameter for MCP".to_string()))? + .clone(); + + // Validate apps format + for app in apps.split(',') { + let trimmed = app.trim(); + if trimmed != "claude" && trimmed != "codex" && trimmed != "gemini" { + return Err(AppError::InvalidInput(format!( + "Invalid app in 'apps': must be 'claude', 'codex', or 'gemini', got '{trimmed}'" + ))); + } + } + + let config = params + .get("config") + .ok_or_else(|| AppError::InvalidInput("Missing 'config' parameter for MCP".to_string()))? + .clone(); + + let enabled = params.get("enabled").and_then(|v| v.parse::().ok()); + + Ok(DeepLinkImportRequest { + version, + resource, + apps: Some(apps), + enabled, + config: Some(config), + config_format: Some("json".to_string()), // MCP config is always JSON + app: None, + name: None, + icon: None, + homepage: None, + endpoint: None, + api_key: None, + model: None, + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + content: None, + description: None, + repo: None, + directory: None, + branch: None, + skills_path: None, + config_url: None, + }) +} + +/// Parse skill deep link parameters +fn parse_skill_deeplink( + params: &HashMap, + version: String, + resource: String, +) -> Result { + let repo = params + .get("repo") + .ok_or_else(|| AppError::InvalidInput("Missing 'repo' parameter for skill".to_string()))? + .clone(); + + // Validate repo format (should be "owner/name") + if !repo.contains('/') || repo.split('/').count() != 2 { + return Err(AppError::InvalidInput(format!( + "Invalid repo format: expected 'owner/name', got '{repo}'" + ))); + } + + let directory = params.get("directory").cloned(); + + let branch = params.get("branch").cloned(); + let skills_path = params + .get("skills_path") + .or_else(|| params.get("skillsPath")) + .cloned(); + + Ok(DeepLinkImportRequest { + version, + resource, + repo: Some(repo), + directory, + branch, + skills_path, + icon: None, + app: Some("claude".to_string()), // Skills are Claude-only + name: None, + enabled: None, + homepage: None, + endpoint: None, + api_key: None, + model: None, + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + content: None, + description: None, + apps: None, + config: None, + config_format: None, + config_url: None, + }) +} + /// Validate that a string is a valid HTTP(S) URL fn validate_url(url_str: &str, field_name: &str) -> Result<(), AppError> { let url = Url::parse(url_str) @@ -196,33 +464,66 @@ fn validate_url(url_str: &str, field_name: &str) -> Result<(), AppError> { /// 2. Merges config file if provided (v3.8+) /// 3. Converts it to a Provider structure /// 4. Delegates to ProviderService for actual import +/// 5. Optionally sets as current provider if enabled=true pub fn import_provider_from_deeplink( state: &AppState, request: DeepLinkImportRequest, ) -> Result { + // Verify this is a provider request + if request.resource != "provider" { + return Err(AppError::InvalidInput(format!( + "Expected provider resource, got '{}'", + request.resource + ))); + } + // Step 1: Merge config file if provided (v3.8+) let merged_request = parse_and_merge_config(&request)?; - // Step 2: Validate required fields after merge - if merged_request.api_key.is_empty() { + // Extract required fields (now as Option) + let app_str = merged_request + .app + .as_ref() + .ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?; + + let api_key = merged_request.api_key.as_ref().ok_or_else(|| { + AppError::InvalidInput("API key is required (either in URL or config file)".to_string()) + })?; + + if api_key.is_empty() { return Err(AppError::InvalidInput( - "API key is required (either in URL or config file)".to_string(), - )); - } - if merged_request.endpoint.is_empty() { - return Err(AppError::InvalidInput( - "Endpoint is required (either in URL or config file)".to_string(), - )); - } - if merged_request.homepage.is_empty() { - return Err(AppError::InvalidInput( - "Homepage is required (either in URL or config file)".to_string(), + "API key cannot be empty".to_string(), )); } + let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| { + AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string()) + })?; + + if endpoint.is_empty() { + return Err(AppError::InvalidInput( + "Endpoint cannot be empty".to_string(), + )); + } + + let homepage = merged_request.homepage.as_ref().ok_or_else(|| { + AppError::InvalidInput("Homepage is required (either in URL or config file)".to_string()) + })?; + + if homepage.is_empty() { + return Err(AppError::InvalidInput( + "Homepage cannot be empty".to_string(), + )); + } + + let name = merged_request + .name + .as_ref() + .ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?; + // Parse app type - let app_type = AppType::from_str(&merged_request.app) - .map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", merged_request.app)))?; + let app_type = AppType::from_str(app_str) + .map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?; // Build provider configuration based on app type let mut provider = build_provider_from_request(&app_type, &merged_request)?; @@ -230,8 +531,7 @@ pub fn import_provider_from_deeplink( // Generate a unique ID for the provider using timestamp + sanitized name // This is similar to how frontend generates IDs let timestamp = chrono::Utc::now().timestamp_millis(); - let sanitized_name = merged_request - .name + let sanitized_name = name .chars() .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') .collect::() @@ -241,7 +541,14 @@ pub fn import_provider_from_deeplink( let provider_id = provider.id.clone(); // Use ProviderService to add the provider - ProviderService::add(state, app_type, provider)?; + ProviderService::add(state, app_type.clone(), provider)?; + + // If enabled=true, set as current provider + if merged_request.enabled.unwrap_or(false) { + // Use ProviderService::switch to set as current and sync to live config + ProviderService::switch(state, app_type.clone(), &provider_id)?; + log::info!("Provider '{provider_id}' set as current for {app_type:?}"); + } Ok(provider_id) } @@ -257,8 +564,14 @@ fn build_provider_from_request( AppType::Claude => { // Claude configuration structure let mut env = serde_json::Map::new(); - env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(request.api_key)); - env.insert("ANTHROPIC_BASE_URL".to_string(), json!(request.endpoint)); + env.insert( + "ANTHROPIC_AUTH_TOKEN".to_string(), + json!(request.api_key.clone().unwrap_or_default()), + ); + env.insert( + "ANTHROPIC_BASE_URL".to_string(), + json!(request.endpoint.clone().unwrap_or_default()), + ); // Add default model if provided if let Some(model) = &request.model { @@ -302,7 +615,13 @@ fn build_provider_from_request( // - 去掉首尾下划线 // - 若结果为空,则使用 "custom" let clean_provider_name = { - let raw: String = request.name.chars().filter(|c| !c.is_control()).collect(); + let raw: String = request + .name + .clone() + .unwrap_or_else(|| "custom".to_string()) + .chars() + .filter(|c| !c.is_control()) + .collect(); let lower = raw.to_lowercase(); let mut key: String = lower .chars() @@ -335,7 +654,13 @@ fn build_provider_from_request( .to_string(); // 3. 端点:与 UI 中 Base URL 处理方式保持一致,去掉结尾多余的斜杠 - let endpoint = request.endpoint.trim().trim_end_matches('/').to_string(); + let endpoint = request + .endpoint + .as_deref() + .unwrap_or("") + .trim() + .trim_end_matches('/') + .to_string(); // 4. 组装 config.toml 内容 // 使用 Rust 1.58+ 的内联格式化语法,避免 clippy::uninlined_format_args 警告 @@ -380,15 +705,15 @@ requires_openai_auth = true let provider = Provider { id: String::new(), // Will be generated by ProviderService - name: request.name.clone(), + name: request.name.clone().unwrap_or_default(), settings_config, - website_url: Some(request.homepage.clone()), + website_url: request.homepage.clone(), category: None, created_at: None, sort_index: None, notes: request.notes.clone(), meta: None, - icon: None, + icon: request.icon.clone(), icon_color: None, }; @@ -401,8 +726,6 @@ requires_openai_auth = true pub fn parse_and_merge_config( request: &DeepLinkImportRequest, ) -> Result { - use base64::prelude::*; - // If no config provided, return original request if request.config.is_none() && request.config_url.is_none() { return Ok(request.clone()); @@ -411,9 +734,7 @@ pub fn parse_and_merge_config( // Step 1: Get config content let config_content = if let Some(config_b64) = &request.config { // Decode Base64 inline config - let decoded = BASE64_STANDARD - .decode(config_b64) - .map_err(|e| AppError::InvalidInput(format!("Invalid Base64 encoding: {e}")))?; + let decoded = decode_base64_param("config", config_b64)?; String::from_utf8(decoded) .map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))? } else if let Some(_config_url) = &request.config_url { @@ -447,13 +768,23 @@ pub fn parse_and_merge_config( // Step 3: Extract values from config based on app type and merge with URL params let mut merged = request.clone(); - match request.app.as_str() { + // MCP, Skill and other resource types don't need config merging (they use config directly) + // Only provider resource type needs merging + if request.resource != "provider" { + return Ok(merged); + } + + match request.app.as_deref().unwrap_or("") { "claude" => merge_claude_config(&mut merged, &config_value)?, "codex" => merge_codex_config(&mut merged, &config_value)?, "gemini" => merge_gemini_config(&mut merged, &config_value)?, + "" => { + // No app specified, skip merging (this is valid for MCP imports) + return Ok(merged); + } _ => { return Err(AppError::InvalidInput(format!( - "Invalid app type: {}", + "Invalid app type: {:?}", request.app ))) } @@ -477,23 +808,28 @@ fn merge_claude_config( })?; // Auto-fill API key if not provided in URL - if request.api_key.is_empty() { + if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() { if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) { - request.api_key = token.to_string(); + request.api_key = Some(token.to_string()); } } // Auto-fill endpoint if not provided in URL - if request.endpoint.is_empty() { + if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() { if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) { - request.endpoint = base_url.to_string(); + request.endpoint = Some(base_url.to_string()); } } // Auto-fill homepage from endpoint if not provided - if request.homepage.is_empty() && !request.endpoint.is_empty() { - request.homepage = infer_homepage_from_endpoint(&request.endpoint) - .unwrap_or_else(|| "https://anthropic.com".to_string()); + if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty()) + && request.endpoint.is_some() + && !request.endpoint.as_ref().unwrap().is_empty() + { + request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap()); + if request.homepage.is_none() { + request.homepage = Some("https://anthropic.com".to_string()); + } } // Auto-fill model fields (URL params take priority) @@ -531,13 +867,13 @@ fn merge_codex_config( config: &serde_json::Value, ) -> Result<(), AppError> { // Auto-fill API key from auth.OPENAI_API_KEY - if request.api_key.is_empty() { + if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() { if let Some(api_key) = config .get("auth") .and_then(|v| v.get("OPENAI_API_KEY")) .and_then(|v| v.as_str()) { - request.api_key = api_key.to_string(); + request.api_key = Some(api_key.to_string()); } } @@ -546,9 +882,9 @@ fn merge_codex_config( // Parse TOML config string to extract base_url and model if let Ok(toml_value) = toml::from_str::(config_str) { // Extract base_url from model_providers section - if request.endpoint.is_empty() { + if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() { if let Some(base_url) = extract_codex_base_url(&toml_value) { - request.endpoint = base_url; + request.endpoint = Some(base_url); } } @@ -562,9 +898,14 @@ fn merge_codex_config( } // Auto-fill homepage from endpoint - if request.homepage.is_empty() && !request.endpoint.is_empty() { - request.homepage = infer_homepage_from_endpoint(&request.endpoint) - .unwrap_or_else(|| "https://openai.com".to_string()); + if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty()) + && request.endpoint.is_some() + && !request.endpoint.as_ref().unwrap().is_empty() + { + request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap()); + if request.homepage.is_none() { + request.homepage = Some("https://openai.com".to_string()); + } } Ok(()) @@ -576,15 +917,15 @@ fn merge_gemini_config( config: &serde_json::Value, ) -> Result<(), AppError> { // Gemini uses flat env structure - if request.api_key.is_empty() { + if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() { if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) { - request.api_key = api_key.to_string(); + request.api_key = Some(api_key.to_string()); } } - if request.endpoint.is_empty() { + if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() { if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) { - request.endpoint = base_url.to_string(); + request.endpoint = Some(base_url.to_string()); } } @@ -596,9 +937,14 @@ fn merge_gemini_config( } // Auto-fill homepage from endpoint - if request.homepage.is_empty() && !request.endpoint.is_empty() { - request.homepage = infer_homepage_from_endpoint(&request.endpoint) - .unwrap_or_else(|| "https://ai.google.dev".to_string()); + if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty()) + && request.endpoint.is_some() + && !request.endpoint.as_ref().unwrap().is_empty() + { + request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap()); + if request.homepage.is_none() { + request.homepage = Some("https://ai.google.dev".to_string()); + } } Ok(()) @@ -636,23 +982,82 @@ fn infer_homepage_from_endpoint(endpoint: &str) -> Option { Some(format!("https://{clean_host}")) } +/// 解码 deeplink 里的 Base64 参数,容忍 `+` 被解析为空格、缺少 padding 等常见问题 +fn decode_base64_param(field: &str, raw: &str) -> Result, AppError> { + let mut candidates: Vec = Vec::new(); + // 保留空格(用于还原 `+`),但去掉换行符避免复制/粘贴带来的污染 + let trimmed = raw.trim_matches(|c| c == '\r' || c == '\n'); + + // 优先尝试将空格还原成 "+",避免直接解码时被忽略导致内容缺失 + if trimmed.contains(' ') { + let replaced = trimmed.replace(' ', "+"); + if !replaced.is_empty() && !candidates.contains(&replaced) { + candidates.push(replaced); + } + } + + // 原始值(放在替换版本之后) + if !trimmed.is_empty() && !candidates.contains(&trimmed.to_string()) { + candidates.push(trimmed.to_string()); + } + + // 补齐 padding,避免前端去掉结尾 `=` + let existing = candidates.clone(); + for candidate in existing { + let mut padded = candidate.clone(); + let remainder = padded.len() % 4; + if remainder != 0 { + padded.extend(std::iter::repeat_n('=', 4 - remainder)); + } + if !candidates.contains(&padded) { + candidates.push(padded); + } + } + + let mut last_error: Option = None; + for candidate in candidates { + for engine in [ + &BASE64_STANDARD, + &BASE64_STANDARD_NO_PAD, + &BASE64_URL_SAFE, + &BASE64_URL_SAFE_NO_PAD, + ] { + match engine.decode(&candidate) { + Ok(bytes) => return Ok(bytes), + Err(err) => last_error = Some(err.to_string()), + } + } + } + + Err(AppError::InvalidInput(format!( + "{field} 参数 Base64 解码失败:{}。请确认链接参数已用 Base64 编码并经过 URL 转义(尤其是将 '+' 编码为 %2B,或使用 URL-safe Base64)。", + last_error.unwrap_or_else(|| "未知错误".to_string()) + ))) +} + #[cfg(test)] mod tests { use super::*; + use crate::{store::AppState, Database}; + use std::sync::Arc; #[test] fn test_parse_valid_claude_deeplink() { - let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test-123"; + let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test-123&icon=claude"; let request = parse_deeplink_url(url).unwrap(); assert_eq!(request.version, "v1"); assert_eq!(request.resource, "provider"); - assert_eq!(request.app, "claude"); - assert_eq!(request.name, "Test Provider"); - assert_eq!(request.homepage, "https://example.com"); - assert_eq!(request.endpoint, "https://api.example.com"); - assert_eq!(request.api_key, "sk-test-123"); + assert_eq!(request.app, Some("claude".to_string())); + assert_eq!(request.name, Some("Test Provider".to_string())); + assert_eq!(request.homepage, Some("https://example.com".to_string())); + assert_eq!( + request.endpoint, + Some("https://api.example.com".to_string()) + ); + assert_eq!(request.api_key, Some("sk-test-123".to_string())); + assert_eq!(request.icon, Some("claude".to_string())); } #[test] @@ -719,11 +1124,12 @@ mod tests { let request = DeepLinkImportRequest { version: "v1".to_string(), resource: "provider".to_string(), - app: "gemini".to_string(), - name: "Test Gemini".to_string(), - homepage: "https://example.com".to_string(), - endpoint: "https://api.example.com".to_string(), - api_key: "test-api-key".to_string(), + app: Some("gemini".to_string()), + name: Some("Test Gemini".to_string()), + homepage: Some("https://example.com".to_string()), + endpoint: Some("https://api.example.com".to_string()), + api_key: Some("test-api-key".to_string()), + icon: None, model: Some("gemini-2.0-flash".to_string()), notes: None, haiku_model: None, @@ -732,6 +1138,14 @@ mod tests { config: None, config_format: None, config_url: None, + apps: None, + repo: None, + directory: None, + branch: None, + skills_path: None, + content: None, + description: None, + enabled: None, }; let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap(); @@ -755,11 +1169,12 @@ mod tests { let request = DeepLinkImportRequest { version: "v1".to_string(), resource: "provider".to_string(), - app: "gemini".to_string(), - name: "Test Gemini".to_string(), - homepage: "https://example.com".to_string(), - endpoint: "https://api.example.com".to_string(), - api_key: "test-api-key".to_string(), + app: Some("gemini".to_string()), + name: Some("Test Gemini".to_string()), + homepage: Some("https://example.com".to_string()), + endpoint: Some("https://api.example.com".to_string()), + api_key: Some("test-api-key".to_string()), + icon: None, model: None, notes: None, haiku_model: None, @@ -768,6 +1183,14 @@ mod tests { config: None, config_format: None, config_url: None, + apps: None, + repo: None, + directory: None, + branch: None, + skills_path: None, + content: None, + description: None, + enabled: None, }; let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap(); @@ -807,11 +1230,12 @@ mod tests { let request = DeepLinkImportRequest { version: "v1".to_string(), resource: "provider".to_string(), - app: "claude".to_string(), - name: "Test".to_string(), - homepage: String::new(), - endpoint: String::new(), - api_key: String::new(), + app: Some("claude".to_string()), + name: Some("Test".to_string()), + homepage: None, + endpoint: None, + api_key: None, + icon: None, model: None, notes: None, haiku_model: None, @@ -820,14 +1244,25 @@ mod tests { config: Some(config_b64), config_format: Some("json".to_string()), config_url: None, + apps: None, + repo: None, + directory: None, + branch: None, + skills_path: None, + content: None, + description: None, + enabled: None, }; let merged = parse_and_merge_config(&request).unwrap(); // Should auto-fill from config - assert_eq!(merged.api_key, "sk-ant-xxx"); - assert_eq!(merged.endpoint, "https://api.anthropic.com/v1"); - assert_eq!(merged.homepage, "https://anthropic.com"); + assert_eq!(merged.api_key, Some("sk-ant-xxx".to_string())); + assert_eq!( + merged.endpoint, + Some("https://api.anthropic.com/v1".to_string()) + ); + assert_eq!(merged.homepage, Some("https://anthropic.com".to_string())); assert_eq!(merged.model, Some("claude-sonnet-4.5".to_string())); } @@ -841,11 +1276,12 @@ mod tests { let request = DeepLinkImportRequest { version: "v1".to_string(), resource: "provider".to_string(), - app: "claude".to_string(), - name: "Test".to_string(), - homepage: String::new(), - endpoint: String::new(), - api_key: "sk-new".to_string(), // URL param should override + app: Some("claude".to_string()), + name: Some("Test".to_string()), + homepage: None, + endpoint: None, + api_key: Some("sk-new".to_string()), // URL param should override + icon: None, model: None, notes: None, haiku_model: None, @@ -854,13 +1290,402 @@ mod tests { config: Some(config_b64), config_format: Some("json".to_string()), config_url: None, + apps: None, + repo: None, + directory: None, + branch: None, + skills_path: None, + content: None, + description: None, + enabled: None, }; let merged = parse_and_merge_config(&request).unwrap(); // URL param should take priority - assert_eq!(merged.api_key, "sk-new"); + assert_eq!(merged.api_key, Some("sk-new".to_string())); // Config file value should be used - assert_eq!(merged.endpoint, "https://api.anthropic.com/v1"); + assert_eq!( + merged.endpoint, + Some("https://api.anthropic.com/v1".to_string()) + ); + } + + #[test] + fn test_import_prompt_allows_space_in_base64_content() { + let url = "ccswitch://v1/import?resource=prompt&app=codex&name=PromptPlus&content=Pj4+"; + let request = parse_deeplink_url(url).unwrap(); + + // URL 解码后 content 中的 "+" 会变成空格,确保解码逻辑可以恢复 + assert_eq!(request.content.as_deref(), Some("Pj4 ")); + + let db = Arc::new(Database::memory().expect("create memory db")); + let state = AppState::new(db.clone()); + + let prompt_id = + import_prompt_from_deeplink(&state, request.clone()).expect("import prompt"); + + let prompts = state.db.get_prompts("codex").expect("get prompts"); + let prompt = prompts.get(&prompt_id).expect("prompt saved"); + + assert_eq!(prompt.content, ">>>"); + assert_eq!(prompt.name, request.name.unwrap()); + } +} + +// ============================================ +// MCP Server Import Implementation +// ============================================ + +/// Import MCP servers from deep link request +/// +/// This function handles batch import of MCP servers from standard MCP JSON format +pub fn import_mcp_from_deeplink( + state: &AppState, + request: DeepLinkImportRequest, +) -> Result { + // Verify this is an MCP request + if request.resource != "mcp" { + return Err(AppError::InvalidInput(format!( + "Expected mcp resource, got '{}'", + request.resource + ))); + } + + // Extract and validate apps parameter + let apps_str = request + .apps + .as_ref() + .ok_or_else(|| AppError::InvalidInput("Missing 'apps' parameter for MCP".to_string()))?; + + // Parse apps into McpApps struct + let target_apps = parse_mcp_apps(apps_str)?; + + // Extract config + let config_b64 = request + .config + .as_ref() + .ok_or_else(|| AppError::InvalidInput("Missing 'config' parameter for MCP".to_string()))?; + + // Decode Base64 config + let decoded = decode_base64_param("config", config_b64)?; + + let config_str = String::from_utf8(decoded) + .map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))?; + + // Parse JSON + let config_json: Value = serde_json::from_str(&config_str) + .map_err(|e| AppError::InvalidInput(format!("Invalid JSON in MCP config: {e}")))?; + + // Extract mcpServers object + let mcp_servers = config_json + .get("mcpServers") + .and_then(|v| v.as_object()) + .ok_or_else(|| { + AppError::InvalidInput("MCP config must contain 'mcpServers' object".to_string()) + })?; + + if mcp_servers.is_empty() { + return Err(AppError::InvalidInput( + "No MCP servers found in config".to_string(), + )); + } + + // Get existing servers to check for duplicates + let existing_servers = state.db.get_all_mcp_servers()?; + + // Import each MCP server + let mut imported_ids = Vec::new(); + let mut failed = Vec::new(); + + use crate::services::McpService; + + for (id, server_spec) in mcp_servers.iter() { + // Check if server already exists + let server = if let Some(existing) = existing_servers.get(id) { + // Server exists - merge apps only, keep other fields unchanged + log::info!("MCP server '{id}' already exists, merging apps only"); + + let mut merged_apps = existing.apps.clone(); + // Merge new apps into existing apps + if target_apps.claude { + merged_apps.claude = true; + } + if target_apps.codex { + merged_apps.codex = true; + } + if target_apps.gemini { + merged_apps.gemini = true; + } + + McpServer { + id: existing.id.clone(), + name: existing.name.clone(), + server: existing.server.clone(), // Keep existing server config + apps: merged_apps, // Merged apps + description: existing.description.clone(), + homepage: existing.homepage.clone(), + docs: existing.docs.clone(), + tags: existing.tags.clone(), + } + } else { + // New server - create with provided config + log::info!("Creating new MCP server: {id}"); + McpServer { + id: id.clone(), + name: id.clone(), + server: server_spec.clone(), + apps: target_apps.clone(), + description: None, + homepage: None, + docs: None, + tags: vec!["imported".to_string()], + } + }; + + match McpService::upsert_server(state, server) { + Ok(_) => { + imported_ids.push(id.clone()); + log::info!("Successfully imported/updated MCP server: {id}"); + } + Err(e) => { + failed.push(McpImportError { + id: id.clone(), + error: format!("{e}"), + }); + log::warn!("Failed to import MCP server '{id}': {e}"); + } + } + } + + Ok(McpImportResult { + imported_count: imported_ids.len(), + imported_ids, + failed, + }) +} + +/// Parse apps string into McpApps struct +fn parse_mcp_apps(apps_str: &str) -> Result { + let mut apps = McpApps { + claude: false, + codex: false, + gemini: false, + }; + + for app in apps_str.split(',') { + match app.trim() { + "claude" => apps.claude = true, + "codex" => apps.codex = true, + "gemini" => apps.gemini = true, + other => { + return Err(AppError::InvalidInput(format!( + "Invalid app in 'apps': {other}" + ))) + } + } + } + + if apps.is_empty() { + return Err(AppError::InvalidInput( + "At least one app must be specified in 'apps'".to_string(), + )); + } + + Ok(apps) +} + +// ============================================ +// Prompt Import Implementation +// ============================================ + +/// Import a prompt from deep link request +pub fn import_prompt_from_deeplink( + state: &AppState, + request: DeepLinkImportRequest, +) -> Result { + // Verify this is a prompt request + if request.resource != "prompt" { + return Err(AppError::InvalidInput(format!( + "Expected prompt resource, got '{}'", + request.resource + ))); + } + + // Extract required fields + let app_str = request + .app + .as_ref() + .ok_or_else(|| AppError::InvalidInput("Missing 'app' field for prompt".to_string()))?; + + let name = request + .name + .ok_or_else(|| AppError::InvalidInput("Missing 'name' field for prompt".to_string()))?; + + // Parse app type + let app_type = AppType::from_str(app_str) + .map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?; + + // Decode content + let content_b64 = request + .content + .as_ref() + .ok_or_else(|| AppError::InvalidInput("Missing 'content' field for prompt".to_string()))?; + + let content = decode_base64_param("content", content_b64)?; + let content = String::from_utf8(content) + .map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in content: {e}")))?; + + // Generate ID + let timestamp = chrono::Utc::now().timestamp_millis(); + let sanitized_name = name + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') + .collect::() + .to_lowercase(); + let id = format!("{sanitized_name}-{timestamp}"); + + // Check if we should enable this prompt + let should_enable = request.enabled.unwrap_or(false); + + // Create Prompt (initially disabled) + let prompt = Prompt { + id: id.clone(), + name: name.clone(), + content, + description: request.description, + enabled: false, // Always start as disabled, will be enabled later if needed + created_at: Some(timestamp), + updated_at: Some(timestamp), + }; + + // Save using PromptService + use crate::services::PromptService; + PromptService::upsert_prompt(state, app_type.clone(), &id, prompt)?; + + // If enabled flag is set, enable this prompt (which will disable others) + if should_enable { + PromptService::enable_prompt(state, app_type, &id)?; + log::info!("Successfully imported and enabled prompt '{name}' for {app_str}"); + } else { + log::info!("Successfully imported prompt '{name}' for {app_str} (disabled)"); + } + + Ok(id) +} + +// ============================================ +// Skill Import Implementation +// ============================================ + +/// Import a skill from deep link request +pub fn import_skill_from_deeplink( + state: &AppState, + request: DeepLinkImportRequest, +) -> Result { + // Verify this is a skill request + if request.resource != "skill" { + return Err(AppError::InvalidInput(format!( + "Expected skill resource, got '{}'", + request.resource + ))); + } + + // Parse repo + let repo_str = request + .repo + .ok_or_else(|| AppError::InvalidInput("Missing 'repo' field for skill".to_string()))?; + + let parts: Vec<&str> = repo_str.split('/').collect(); + if parts.len() != 2 { + return Err(AppError::InvalidInput(format!( + "Invalid repo format: expected 'owner/name', got '{repo_str}'" + ))); + } + let owner = parts[0].to_string(); + let name = parts[1].to_string(); + + // Create SkillRepo + let repo = SkillRepo { + owner: owner.clone(), + name: name.clone(), + branch: request.branch.unwrap_or_else(|| "main".to_string()), + enabled: request.enabled.unwrap_or(true), + skills_path: request.skills_path, + }; + + // Save using Database + state.db.save_skill_repo(&repo)?; + + log::info!("Successfully added skill repo '{owner}/{name}'"); + + Ok(format!("{owner}/{name}")) +} + +#[cfg(test)] +mod tests_imports { + use super::*; + use base64::Engine; + + #[test] + fn test_parse_mcp_apps() { + let apps = parse_mcp_apps("claude,codex").unwrap(); + assert!(apps.claude); + assert!(apps.codex); + assert!(!apps.gemini); + + let apps = parse_mcp_apps("gemini").unwrap(); + assert!(!apps.claude); + assert!(!apps.codex); + assert!(apps.gemini); + + let err = parse_mcp_apps("invalid").unwrap_err(); + assert!(err.to_string().contains("Invalid app")); + } + + #[test] + fn test_parse_prompt_deeplink() { + let content = "Hello World"; + let content_b64 = BASE64_STANDARD.encode(content); + let url = format!( + "ccswitch://v1/import?resource=prompt&app=claude&name=test&content={}&description=desc&enabled=true", + content_b64 + ); + + let request = parse_deeplink_url(&url).unwrap(); + assert_eq!(request.resource, "prompt"); + assert_eq!(request.app.unwrap(), "claude"); + assert_eq!(request.name.unwrap(), "test"); + assert_eq!(request.content.unwrap(), content_b64); + assert_eq!(request.description.unwrap(), "desc"); + assert_eq!(request.enabled.unwrap(), true); + } + + #[test] + fn test_parse_mcp_deeplink() { + let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#; + let config_b64 = BASE64_STANDARD.encode(config); + let url = format!( + "ccswitch://v1/import?resource=mcp&apps=claude,codex&config={}&enabled=true", + config_b64 + ); + + let request = parse_deeplink_url(&url).unwrap(); + assert_eq!(request.resource, "mcp"); + assert_eq!(request.apps.unwrap(), "claude,codex"); + assert_eq!(request.config.unwrap(), config_b64); + assert_eq!(request.enabled.unwrap(), true); + } + + #[test] + fn test_parse_skill_deeplink() { + let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev&skills_path=src"; + let request = parse_deeplink_url(&url).unwrap(); + + assert_eq!(request.resource, "skill"); + assert_eq!(request.repo.unwrap(), "owner/repo"); + assert_eq!(request.directory.unwrap(), "skills"); + assert_eq!(request.branch.unwrap(), "dev"); + assert_eq!(request.skills_path.unwrap(), "src"); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f9fa2d5b..caef786a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -26,6 +26,7 @@ pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig}; pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; pub use commands::*; pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; +pub use database::Database; pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest}; pub use error::AppError; pub use mcp::{ @@ -314,7 +315,7 @@ fn handle_deeplink_url( match crate::deeplink::parse_deeplink_url(url_str) { Ok(request) => { log::info!( - "✓ Successfully parsed deep link: resource={}, app={}, name={}", + "✓ Successfully parsed deep link: resource={}, app={:?}, name={:?}", request.resource, request.app, request.name @@ -840,6 +841,7 @@ pub fn run() { commands::parse_deeplink, commands::merge_deeplink_config, commands::import_from_deeplink, + commands::import_from_deeplink_unified, update_tray_menu, // Environment variable management commands::check_env_conflicts, @@ -889,7 +891,7 @@ pub fn run() { match crate::deeplink::parse_deeplink_url(&url_str) { Ok(request) => { log::info!( - "Successfully parsed deep link from RunEvent::Opened: resource={}, app={}", + "Successfully parsed deep link from RunEvent::Opened: resource={}, app={:?}", request.resource, request.app ); diff --git a/src-tauri/tests/deeplink_import.rs b/src-tauri/tests/deeplink_import.rs index d70a0601..2768f895 100644 --- a/src-tauri/tests/deeplink_import.rs +++ b/src-tauri/tests/deeplink_import.rs @@ -1,46 +1,36 @@ -use std::sync::RwLock; +use std::sync::Arc; -use cc_switch_lib::{ - import_provider_from_deeplink, parse_deeplink_url, AppState, AppType, MultiAppConfig, -}; +use cc_switch_lib::{import_provider_from_deeplink, parse_deeplink_url, AppState, Database}; #[path = "support.rs"] mod support; use support::{ensure_test_home, reset_test_fs, test_mutex}; #[test] -fn deeplink_import_claude_provider_persists_to_config() { +fn deeplink_import_claude_provider_persists_to_db() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); - let home = ensure_test_home(); + let _home = ensure_test_home(); - let url = "ccswitch://v1/import?resource=provider&app=claude&name=DeepLink%20Claude&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-test-claude-key&model=claude-sonnet-4"; + let url = "ccswitch://v1/import?resource=provider&app=claude&name=DeepLink%20Claude&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-test-claude-key&model=claude-sonnet-4&icon=claude"; let request = parse_deeplink_url(url).expect("parse deeplink url"); - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::Claude); + let db = Arc::new(Database::memory().expect("create memory db")); - let state = AppState { - config: RwLock::new(config), - }; + let state = AppState { db: db.clone() }; let provider_id = import_provider_from_deeplink(&state, request.clone()) .expect("import provider from deeplink"); - // 验证内存状态 - let guard = state.config.read().expect("read config"); - let manager = guard - .get_manager(&AppType::Claude) - .expect("claude manager should exist"); - let provider = manager - .providers + // Verify DB state + let providers = db.get_all_providers("claude").expect("get providers"); + let provider = providers .get(&provider_id) .expect("provider created via deeplink"); - assert_eq!(provider.name, request.name); - assert_eq!( - provider.website_url.as_deref(), - Some(request.homepage.as_str()) - ); + + assert_eq!(provider.name, request.name.clone().unwrap()); + assert_eq!(provider.website_url.as_deref(), request.homepage.as_deref()); + assert_eq!(provider.icon.as_deref(), Some("claude")); let auth_token = provider .settings_config .pointer("/env/ANTHROPIC_AUTH_TOKEN") @@ -49,50 +39,34 @@ fn deeplink_import_claude_provider_persists_to_config() { .settings_config .pointer("/env/ANTHROPIC_BASE_URL") .and_then(|v| v.as_str()); - assert_eq!(auth_token, Some(request.api_key.as_str())); - assert_eq!(base_url, Some(request.endpoint.as_str())); - drop(guard); - - // 验证配置已持久化 - let config_path = home.join(".cc-switch").join("config.json"); - assert!( - config_path.exists(), - "importing provider from deeplink should persist config.json" - ); + assert_eq!(auth_token, request.api_key.as_deref()); + assert_eq!(base_url, request.endpoint.as_deref()); } #[test] fn deeplink_import_codex_provider_builds_auth_and_config() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); - let home = ensure_test_home(); + let _home = ensure_test_home(); - let url = "ccswitch://v1/import?resource=provider&app=codex&name=DeepLink%20Codex&homepage=https%3A%2F%2Fopenai.example&endpoint=https%3A%2F%2Fapi.openai.example%2Fv1&apiKey=sk-test-codex-key&model=gpt-4o"; + let url = "ccswitch://v1/import?resource=provider&app=codex&name=DeepLink%20Codex&homepage=https%3A%2F%2Fopenai.example&endpoint=https%3A%2F%2Fapi.openai.example%2Fv1&apiKey=sk-test-codex-key&model=gpt-4o&icon=openai"; let request = parse_deeplink_url(url).expect("parse deeplink url"); - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::Codex); + let db = Arc::new(Database::memory().expect("create memory db")); - let state = AppState { - config: RwLock::new(config), - }; + let state = AppState { db: db.clone() }; let provider_id = import_provider_from_deeplink(&state, request.clone()) .expect("import provider from deeplink"); - let guard = state.config.read().expect("read config"); - let manager = guard - .get_manager(&AppType::Codex) - .expect("codex manager should exist"); - let provider = manager - .providers + let providers = db.get_all_providers("codex").expect("get providers"); + let provider = providers .get(&provider_id) .expect("provider created via deeplink"); - assert_eq!(provider.name, request.name); - assert_eq!( - provider.website_url.as_deref(), - Some(request.homepage.as_str()) - ); + + assert_eq!(provider.name, request.name.clone().unwrap()); + assert_eq!(provider.website_url.as_deref(), request.homepage.as_deref()); + assert_eq!(provider.icon.as_deref(), Some("openai")); let auth_value = provider .settings_config .pointer("/auth/OPENAI_API_KEY") @@ -102,20 +76,13 @@ fn deeplink_import_codex_provider_builds_auth_and_config() { .get("config") .and_then(|v| v.as_str()) .unwrap_or_default(); - assert_eq!(auth_value, Some(request.api_key.as_str())); + assert_eq!(auth_value, request.api_key.as_deref()); assert!( - config_text.contains(request.endpoint.as_str()), + config_text.contains(request.endpoint.as_deref().unwrap()), "config.toml content should contain endpoint" ); assert!( config_text.contains("model = \"gpt-4o\""), "config.toml content should contain model setting" ); - drop(guard); - - let config_path = home.join(".cc-switch").join("config.json"); - assert!( - config_path.exists(), - "importing provider from deeplink should persist config.json" - ); } diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index 5d9a7e45..c1143949 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -1,5 +1,5 @@ import type { AppId } from "@/lib/api"; -import { ClaudeIcon, CodexIcon, GeminiIcon } from "./BrandIcons"; +import { ProviderIcon } from "@/components/ProviderIcon"; interface AppSwitcherProps { activeApp: AppId; @@ -11,6 +11,17 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) { if (app === activeApp) return; onSwitch(app); }; + const iconSize = 20; + const appIconName: Record = { + claude: "claude", + codex: "openai", + gemini: "gemini", + }; + const appDisplayName: Record = { + claude: "Claude", + codex: "Codex", + gemini: "Gemini", + }; return (
@@ -23,15 +34,17 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) { : "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60" }`} > - - Claude + {appDisplayName.claude}
); diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index 3cd3e75a..f479b5e6 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -13,6 +13,10 @@ import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; +import { PromptConfirmation } from "./deeplink/PromptConfirmation"; +import { McpConfirmation } from "./deeplink/McpConfirmation"; +import { SkillConfirmation } from "./deeplink/SkillConfirmation"; +import { ProviderIcon } from "./ProviderIcon"; interface DeeplinkError { url: string; @@ -26,6 +30,24 @@ export function DeepLinkImportDialog() { const [isImporting, setIsImporting] = useState(false); const [isOpen, setIsOpen] = useState(false); + // 容错判断:MCP 导入结果可能缺少 type 字段 + const isMcpImportResult = ( + value: unknown, + ): value is { + importedCount: number; + importedIds: string[]; + failed: Array<{ id: string; error: string }>; + type?: "mcp"; + } => { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return ( + typeof v.importedCount === "number" && + Array.isArray(v.importedIds) && + Array.isArray(v.failed) + ); + }; + useEffect(() => { // Listen for deep link import events const unlistenImport = listen( @@ -78,22 +100,89 @@ export function DeepLinkImportDialog() { setIsImporting(true); try { - await deeplinkApi.importFromDeeplink(request); + const result = await deeplinkApi.importFromDeeplink(request); + const refreshMcp = async (summary: { + importedCount: number; + importedIds: string[]; + failed: Array<{ id: string; error: string }>; + }) => { + // 强制刷新 MCP 相关缓存,确保管理页重新从数据库加载 + await queryClient.invalidateQueries({ + queryKey: ["mcp", "all"], + refetchType: "all", + }); + await queryClient.refetchQueries({ + queryKey: ["mcp", "all"], + type: "all", + }); - // Invalidate provider queries to refresh the list - await queryClient.invalidateQueries({ - queryKey: ["providers", request.app], - }); + if (summary.failed.length > 0) { + toast.warning(`部分导入成功`, { + description: `成功: ${summary.importedCount}, 失败: ${summary.failed.length}`, + }); + } else { + toast.success("MCP Servers 导入成功", { + description: `成功导入 ${summary.importedCount} 个服务器`, + }); + } + }; - toast.success(t("deeplink.importSuccess"), { - description: t("deeplink.importSuccessDescription", { - name: request.name, - }), - }); + // Handle different result types + if ("type" in result) { + if (result.type === "provider") { + await queryClient.invalidateQueries({ + queryKey: ["providers", request.app], + }); + toast.success(t("deeplink.importSuccess"), { + description: t("deeplink.importSuccessDescription", { + name: request.name, + }), + }); + } else if (result.type === "prompt") { + // Prompts don't use React Query, trigger a custom event for refresh + window.dispatchEvent( + new CustomEvent("prompt-imported", { + detail: { app: request.app }, + }), + ); + toast.success("提示词导入成功", { + description: `已导入提示词: ${request.name}`, + }); + } else if (result.type === "mcp") { + await refreshMcp(result); + } else if (result.type === "skill") { + // Refresh Skills with aggressive strategy + queryClient.invalidateQueries({ + queryKey: ["skills"], + refetchType: "all", + }); + await queryClient.refetchQueries({ + queryKey: ["skills"], + type: "all", + }); + toast.success("Skill 仓库添加成功", { + description: `已添加仓库: ${request.repo}`, + }); + } + } else if (isMcpImportResult(result)) { + // 兜底处理:旧版本后端可能未返回 type 字段 + await refreshMcp(result); + } else { + // Legacy return type (string ID) - assume provider + await queryClient.invalidateQueries({ + queryKey: ["providers", request.app], + }); + toast.success(t("deeplink.importSuccess"), { + description: t("deeplink.importSuccessDescription", { + name: request.name, + }), + }); + } + // Close dialog after all refreshes complete setIsOpen(false); } catch (error) { - console.error("Failed to import provider from deep link:", error); + console.error("Failed to import from deep link:", error); toast.error(t("deeplink.importError"), { description: error instanceof Error ? error.message : String(error), }); @@ -189,6 +278,34 @@ export function DeepLinkImportDialog() { return value; }; + const getTitle = () => { + if (!request) return t("deeplink.confirmImport"); + switch (request.resource) { + case "prompt": + return "导入提示词"; + case "mcp": + return "导入 MCP Servers"; + case "skill": + return "添加 Skill 仓库"; + default: + return t("deeplink.confirmImport"); + } + }; + + const getDescription = () => { + if (!request) return t("deeplink.confirmImportDescription"); + switch (request.resource) { + case "prompt": + return "请确认是否导入此系统提示词"; + case "mcp": + return "请确认是否导入这些 MCP Servers"; + case "skill": + return "请确认是否添加此 Skill 仓库"; + default: + return t("deeplink.confirmImportDescription"); + } + }; + return ( @@ -196,200 +313,197 @@ export function DeepLinkImportDialog() { <> {/* 标题显式左对齐,避免默认居中样式影响 */} - {t("deeplink.confirmImport")} - - {t("deeplink.confirmImportDescription")} - + {getTitle()} + {getDescription()} {/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */}
- {/* App Type */} -
-
- {t("deeplink.app")} -
-
- {request.app} -
-
- - {/* Provider Name */} -
-
- {t("deeplink.providerName")} -
-
- {request.name} -
-
- - {/* Homepage */} -
-
- {t("deeplink.homepage")} -
-
- {request.homepage} -
-
- - {/* API Endpoint */} -
-
- {t("deeplink.endpoint")} -
-
- {request.endpoint} -
-
- - {/* API Key (masked) */} -
-
- {t("deeplink.apiKey")} -
-
- {maskedApiKey} -
-
- - {/* Model Fields - 根据应用类型显示不同的模型字段 */} - {request.app === "claude" ? ( - <> - {/* Claude 四种模型字段 */} - {request.haikuModel && ( -
-
- {t("deeplink.haikuModel")} -
-
- {request.haikuModel} -
-
- )} - {request.sonnetModel && ( -
-
- {t("deeplink.sonnetModel")} -
-
- {request.sonnetModel} -
-
- )} - {request.opusModel && ( -
-
- {t("deeplink.opusModel")} -
-
- {request.opusModel} -
-
- )} - {request.model && ( -
-
- {t("deeplink.multiModel")} -
-
- {request.model} -
-
- )} - - ) : ( - <> - {/* Codex 和 Gemini 使用通用 model 字段 */} - {request.model && ( -
-
- {t("deeplink.model")} -
-
- {request.model} -
-
- )} - + {request.resource === "prompt" && ( + + )} + {request.resource === "mcp" && ( + + )} + {request.resource === "skill" && ( + )} - {/* Notes (if present) */} - {request.notes && ( -
-
- {t("deeplink.notes")} -
-
- {request.notes} -
-
- )} + {/* Legacy Provider View */} + {(request.resource === "provider" || !request.resource) && ( + <> + {/* Provider Icon - enlarge and center near the top */} + {request.icon && ( +
+ +
+ )} - {/* Config File Details (v3.8+) */} - {hasConfigFile && ( -
+ {/* App Type */}
- {t("deeplink.configSource")} + {t("deeplink.app")}
-
- - {configSource === "base64" - ? t("deeplink.configEmbedded") - : t("deeplink.configRemote")} - - {request.configFormat && ( - - {request.configFormat} - - )} +
+ {request.app}
- {/* Parsed Config Details */} - {parsedConfig && ( -
-
- {t("deeplink.configDetails")} -
+ {/* Provider Name */} +
+
+ {t("deeplink.providerName")} +
+
+ {request.name} +
+
- {/* Claude config */} - {parsedConfig.type === "claude" && parsedConfig.env && ( -
- {Object.entries(parsedConfig.env).map( - ([key, value]) => ( -
- - {key} - - - {maskValue(key, String(value))} - -
- ), - )} + {/* Homepage */} +
+
+ {t("deeplink.homepage")} +
+
+ {request.homepage} +
+
+ + {/* API Endpoint */} +
+
+ {t("deeplink.endpoint")} +
+
+ {request.endpoint} +
+
+ + {/* API Key (masked) */} +
+
+ {t("deeplink.apiKey")} +
+
+ {maskedApiKey} +
+
+ + {/* Model Fields - 根据应用类型显示不同的模型字段 */} + {request.app === "claude" ? ( + <> + {/* Claude 四种模型字段 */} + {request.haikuModel && ( +
+
+ {t("deeplink.haikuModel")} +
+
+ {request.haikuModel} +
)} + {request.sonnetModel && ( +
+
+ {t("deeplink.sonnetModel")} +
+
+ {request.sonnetModel} +
+
+ )} + {request.opusModel && ( +
+
+ {t("deeplink.opusModel")} +
+
+ {request.opusModel} +
+
+ )} + {request.model && ( +
+
+ {t("deeplink.multiModel")} +
+
+ {request.model} +
+
+ )} + + ) : ( + <> + {/* Codex 和 Gemini 使用通用 model 字段 */} + {request.model && ( +
+
+ {t("deeplink.model")} +
+
+ {request.model} +
+
+ )} + + )} - {/* Codex config */} - {parsedConfig.type === "codex" && ( -
- {parsedConfig.auth && - Object.keys(parsedConfig.auth).length > 0 && ( + {/* Notes (if present) */} + {request.notes && ( +
+
+ {t("deeplink.notes")} +
+
+ {request.notes} +
+
+ )} + + {/* Config File Details (v3.8+) */} + {hasConfigFile && ( +
+
+
+ {t("deeplink.configSource")} +
+
+ + {configSource === "base64" + ? t("deeplink.configEmbedded") + : t("deeplink.configRemote")} + + {request.configFormat && ( + + {request.configFormat} + + )} +
+
+ + {/* Parsed Config Details */} + {parsedConfig && ( +
+
+ {t("deeplink.configDetails")} +
+ + {/* Claude config */} + {parsedConfig.type === "claude" && + parsedConfig.env && (
-
- Auth: -
- {Object.entries(parsedConfig.auth).map( + {Object.entries(parsedConfig.env).map( ([key, value]) => (
{key} @@ -402,61 +516,92 @@ export function DeepLinkImportDialog() { )}
)} - {parsedConfig.tomlConfig && ( -
-
- TOML Config: -
-
-                                {parsedConfig.tomlConfig.substring(0, 300)}
-                                {parsedConfig.tomlConfig.length > 300 && "..."}
-                              
+ + {/* Codex config */} + {parsedConfig.type === "codex" && ( +
+ {parsedConfig.auth && + Object.keys(parsedConfig.auth).length > 0 && ( +
+
+ Auth: +
+ {Object.entries(parsedConfig.auth).map( + ([key, value]) => ( +
+ + {key} + + + {maskValue(key, String(value))} + +
+ ), + )} +
+ )} + {parsedConfig.tomlConfig && ( +
+
+ TOML Config: +
+
+                                    {parsedConfig.tomlConfig.substring(0, 300)}
+                                    {parsedConfig.tomlConfig.length > 300 &&
+                                      "..."}
+                                  
+
+ )}
)} -
- )} - {/* Gemini config */} - {parsedConfig.type === "gemini" && parsedConfig.env && ( -
- {Object.entries(parsedConfig.env).map( - ([key, value]) => ( -
- - {key} - - - {maskValue(key, String(value))} - + {/* Gemini config */} + {parsedConfig.type === "gemini" && + parsedConfig.env && ( +
+ {Object.entries(parsedConfig.env).map( + ([key, value]) => ( +
+ + {key} + + + {maskValue(key, String(value))} + +
+ ), + )}
- ), - )} + )} +
+ )} + + {/* Config URL (if remote) */} + {request.configUrl && ( +
+
+ {t("deeplink.configUrl")} +
+
+ {request.configUrl} +
)}
)} - {/* Config URL (if remote) */} - {request.configUrl && ( -
-
- {t("deeplink.configUrl")} -
-
- {request.configUrl} -
-
- )} -
+ {/* Warning */} +
+ {t("deeplink.warning")} +
+ )} - - {/* Warning */} -
- {t("deeplink.warning")} -
diff --git a/src/components/ProviderIcon.tsx b/src/components/ProviderIcon.tsx index 788efb02..1044dd35 100644 --- a/src/components/ProviderIcon.tsx +++ b/src/components/ProviderIcon.tsx @@ -32,6 +32,9 @@ export const ProviderIcon: React.FC = ({ return { width: sizeValue, height: sizeValue, + // 内嵌 SVG 使用 1em 作为尺寸基准,这里同步 fontSize 让图标实际跟随 size 放大 + fontSize: sizeValue, + lineHeight: 1, }; }, [size]); @@ -57,6 +60,8 @@ export const ProviderIcon: React.FC = ({ .join("") .toUpperCase() .slice(0, 2); + const fallbackFontSize = + typeof size === "number" ? `${Math.max(size * 0.5, 12)}px` : "0.5em"; return ( = ({ > {initials} diff --git a/src/components/deeplink/McpConfirmation.tsx b/src/components/deeplink/McpConfirmation.tsx new file mode 100644 index 00000000..00da52e8 --- /dev/null +++ b/src/components/deeplink/McpConfirmation.tsx @@ -0,0 +1,71 @@ +import { useMemo } from "react"; +import { DeepLinkImportRequest } from "../../lib/api/deeplink"; +import { decodeBase64Utf8 } from "../../lib/utils/base64"; + +export function McpConfirmation({ + request, +}: { + request: DeepLinkImportRequest; +}) { + const mcpServers = useMemo(() => { + if (!request.config) return null; + try { + const decoded = decodeBase64Utf8(request.config); + const parsed = JSON.parse(decoded); + return parsed.mcpServers || {}; + } catch (e) { + console.error("Failed to parse MCP config:", e); + return null; + } + }, [request.config]); + + const targetApps = request.apps?.split(",") || []; + + return ( +
+

批量导入 MCP Servers

+ +
+ +
+ {targetApps.map((app) => ( + + {app.trim()} + + ))} +
+
+ +
+ +
+ {mcpServers && + Object.entries(mcpServers).map(([id, spec]: [string, any]) => ( +
+
{id}
+
+ {spec.command + ? `Command: ${spec.command} ` + : `URL: ${spec.url} `} +
+
+ ))} +
+
+ + {request.enabled && ( +
+ ⚠️ + 导入后将立即写入所有指定应用的配置文件 +
+ )} +
+ ); +} diff --git a/src/components/deeplink/PromptConfirmation.tsx b/src/components/deeplink/PromptConfirmation.tsx new file mode 100644 index 00000000..8570763b --- /dev/null +++ b/src/components/deeplink/PromptConfirmation.tsx @@ -0,0 +1,60 @@ +import { useMemo } from "react"; +import { DeepLinkImportRequest } from "../../lib/api/deeplink"; +import { decodeBase64Utf8 } from "../../lib/utils/base64"; + +export function PromptConfirmation({ + request, +}: { + request: DeepLinkImportRequest; +}) { + const decodedContent = useMemo(() => { + if (!request.content) return ""; + return decodeBase64Utf8(request.content); + }, [request.content]); + + return ( +
+

导入系统提示词

+ +
+ +
{request.app}
+
+ +
+ +
{request.name}
+
+ + {request.description && ( +
+ +
{request.description}
+
+ )} + +
+ +
+          {decodedContent.substring(0, 500)}
+          {decodedContent.length > 500 && "..."}
+        
+
+ + {request.enabled && ( +
+ ⚠️ + 导入后将立即启用此提示词,其他提示词将被禁用 +
+ )} +
+ ); +} diff --git a/src/components/deeplink/SkillConfirmation.tsx b/src/components/deeplink/SkillConfirmation.tsx new file mode 100644 index 00000000..7594538f --- /dev/null +++ b/src/components/deeplink/SkillConfirmation.tsx @@ -0,0 +1,56 @@ +import { DeepLinkImportRequest } from "../../lib/api/deeplink"; + +export function SkillConfirmation({ + request, +}: { + request: DeepLinkImportRequest; +}) { + return ( +
+

添加 Claude Skill 仓库

+ +
+ +
+ {request.repo} +
+
+ +
+ +
+ {request.directory} +
+
+ +
+
+ +
{request.branch || "main"}
+
+ + {request.skillsPath && ( +
+ +
{request.skillsPath}
+
+ )} +
+ +
+

ℹ️ 此操作将添加 Skill 仓库到列表。

+

+ 添加后,您可以在 Skills 管理界面中选择安装具体的 Skill。 +

+
+
+ ); +} diff --git a/src/components/prompts/PromptPanel.tsx b/src/components/prompts/PromptPanel.tsx index 4b1761a8..12b9b2b9 100644 --- a/src/components/prompts/PromptPanel.tsx +++ b/src/components/prompts/PromptPanel.tsx @@ -43,6 +43,22 @@ const PromptPanel = React.forwardRef( if (open) reload(); }, [open, reload]); + // Listen for prompt import events from deep link + useEffect(() => { + const handlePromptImported = (event: Event) => { + const customEvent = event as CustomEvent; + // Reload if the import is for this app + if (customEvent.detail?.app === appId) { + reload(); + } + }; + + window.addEventListener("prompt-imported", handlePromptImported); + return () => { + window.removeEventListener("prompt-imported", handlePromptImported); + }; + }, [appId, reload]); + const handleAdd = () => { setEditingId(null); setIsFormOpen(true); diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 1a60cb6e..caa41b7f 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -141,12 +141,12 @@ export function ProviderCard({ {/* 供应商图标 */} -
+
diff --git a/src/components/providers/forms/BasicFormFields.tsx b/src/components/providers/forms/BasicFormFields.tsx index 50863cfa..4064db76 100644 --- a/src/components/providers/forms/BasicFormFields.tsx +++ b/src/components/providers/forms/BasicFormFields.tsx @@ -84,7 +84,7 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
-
+
( const [loading, setLoading] = useState(true); const [repoManagerOpen, setRepoManagerOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [filterStatus, setFilterStatus] = useState< + "all" | "installed" | "uninstalled" + >("all"); const loadSkills = async (afterLoad?: (data: Skill[]) => void) => { try { @@ -172,10 +182,16 @@ export const SkillsPage = forwardRef( // 过滤技能列表 const filteredSkills = useMemo(() => { - if (!searchQuery.trim()) return skills; + const byStatus = skills.filter((skill) => { + if (filterStatus === "installed") return skill.installed; + if (filterStatus === "uninstalled") return !skill.installed; + return true; + }); + + if (!searchQuery.trim()) return byStatus; const query = searchQuery.toLowerCase(); - return skills.filter((skill) => { + return byStatus.filter((skill) => { const name = skill.name?.toLowerCase() || ""; const description = skill.description?.toLowerCase() || ""; const directory = skill.directory?.toLowerCase() || ""; @@ -186,7 +202,7 @@ export const SkillsPage = forwardRef( directory.includes(query) ); }); - }, [skills, searchQuery]); + }, [skills, searchQuery, filterStatus]); return (
@@ -218,17 +234,54 @@ export const SkillsPage = forwardRef( ) : ( <> {/* 搜索框 */} -
-
+
+
setSearchQuery(e.target.value)} - className="pl-9" + className="pl-9 pr-3" />
+
+ +
{searchQuery && (

{t("skills.count", { count: filteredSkills.length })} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6276f489..2d64dca9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -738,6 +738,12 @@ }, "search": "Search Skills", "searchPlaceholder": "Search skill name or description...", + "filter": { + "placeholder": "Filter by status", + "all": "All", + "installed": "Installed", + "uninstalled": "Not installed" + }, "noResults": "No matching skills found" }, "deeplink": { @@ -748,6 +754,7 @@ "homepage": "Homepage", "endpoint": "API Endpoint", "apiKey": "API Key", + "icon": "Icon", "model": "Model", "haikuModel": "Haiku Model", "sonnetModel": "Sonnet Model", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 91c82799..318bc94e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -738,6 +738,12 @@ }, "search": "搜索技能", "searchPlaceholder": "搜索技能名称或描述...", + "filter": { + "placeholder": "状态筛选", + "all": "全部", + "installed": "已安装", + "uninstalled": "未安装" + }, "noResults": "未找到匹配的技能" }, "deeplink": { @@ -748,6 +754,7 @@ "homepage": "官网地址", "endpoint": "API 端点", "apiKey": "API 密钥", + "icon": "图标", "model": "模型", "haikuModel": "Haiku 模型", "sonnetModel": "Sonnet 模型", diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index b79e3046..cac4d5aa 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -33,6 +33,7 @@ export const icons: Record = { notion: `Notion`, ollama: `Ollama`, openai: `OpenAI`, + packycode: `PackyCode`, palm: `PaLM`, perplexity: `Perplexity`, qwen: `Qwen`, diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index 7fe3bc6b..3caa7eea 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -219,6 +219,13 @@ export const iconMetadata: Record = { keywords: ["gpt", "chatgpt"], defaultColor: "#00A67E", }, + packycode: { + name: "packycode", + displayName: "PackyCode", + category: "ai-provider", + keywords: ["packycode", "packy", "packyapi"], + defaultColor: "currentColor", + }, palm: { name: "palm", displayName: "palm", diff --git a/src/icons/extracted/packycode.svg b/src/icons/extracted/packycode.svg new file mode 100644 index 00000000..600dd6b8 --- /dev/null +++ b/src/icons/extracted/packycode.svg @@ -0,0 +1 @@ +PackyCode \ No newline at end of file diff --git a/src/lib/api/deeplink.ts b/src/lib/api/deeplink.ts index c85341cc..e13f8cf4 100644 --- a/src/lib/api/deeplink.ts +++ b/src/lib/api/deeplink.ts @@ -1,25 +1,66 @@ import { invoke } from "@tauri-apps/api/core"; +export type ResourceType = "provider" | "prompt" | "mcp" | "skill"; + export interface DeepLinkImportRequest { version: string; - resource: string; - app: "claude" | "codex" | "gemini"; - name: string; - homepage: string; - endpoint: string; - apiKey: string; + resource: ResourceType; + + // Common fields + app?: "claude" | "codex" | "gemini"; + name?: string; + enabled?: boolean; + + // Provider fields + homepage?: string; + endpoint?: string; + apiKey?: string; + icon?: string; model?: string; notes?: string; - // Claude 专用模型字段 (v3.7.1+) haikuModel?: string; sonnetModel?: string; opusModel?: string; - // 配置文件导入字段 (v3.8+) - config?: string; // Base64 编码的配置内容 - configFormat?: string; // json/toml - configUrl?: string; // 远程配置 URL + + // Prompt fields + content?: string; + description?: string; + + // MCP fields + apps?: string; // "claude,codex,gemini" + + // Skill fields + repo?: string; + directory?: string; + branch?: string; + skillsPath?: string; + + // Config file fields + config?: string; + configFormat?: string; + configUrl?: string; } +export interface McpImportResult { + importedCount: number; + importedIds: string[]; + failed: Array<{ + id: string; + error: string; + }>; +} + +export type ImportResult = + | { type: "provider"; id: string } + | { type: "prompt"; id: string } + | { + type: "mcp"; + importedCount: number; + importedIds: string[]; + failed: Array<{ id: string; error: string }>; + } + | { type: "skill"; key: string }; + export const deeplinkApi = { /** * Parse a deep link URL @@ -43,13 +84,13 @@ export const deeplinkApi = { }, /** - * Import a provider from a deep link request + * Import a resource from a deep link request (unified handler) * @param request The deep link import request - * @returns The ID of the imported provider + * @returns Import result based on resource type */ importFromDeeplink: async ( request: DeepLinkImportRequest, - ): Promise => { - return invoke("import_from_deeplink", { request }); + ): Promise => { + return invoke("import_from_deeplink_unified", { request }); }, }; diff --git a/src/lib/utils/base64.ts b/src/lib/utils/base64.ts new file mode 100644 index 00000000..a93d1f8b --- /dev/null +++ b/src/lib/utils/base64.ts @@ -0,0 +1,43 @@ +/** + * Decode Base64 encoded UTF-8 string + * + * This function handles various Base64 edge cases that can occur when + * Base64 strings are passed through URLs: + * - Spaces (URL parsing may convert '+' to space) + * - Missing padding ('=' characters) + * - Different Base64 variants + * + * @param str - Base64 encoded string + * @returns Decoded UTF-8 string + */ +export function decodeBase64Utf8(str: string): string { + try { + // Clean up the input: replace spaces with + (URL parsing may convert + to space) + let cleaned = str.trim().replace(/ /g, "+"); + + // Try to decode with standard Base64 first + try { + const binString = atob(cleaned); + const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0)!); + return new TextDecoder("utf-8", { fatal: false }).decode(bytes); + } catch (e1) { + // If standard fails, try adding padding + const remainder = cleaned.length % 4; + if (remainder !== 0) { + cleaned += "=".repeat(4 - remainder); + } + const binString = atob(cleaned); + const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0)!); + return new TextDecoder("utf-8", { fatal: false }).decode(bytes); + } + } catch (e) { + console.error("Base64 decode error:", e, "Input:", str); + // Last resort fallback using deprecated but sometimes working method + try { + return decodeURIComponent(escape(atob(str.replace(/ /g, "+")))); + } catch { + // If all else fails, return original string + return str; + } + } +}