diff --git a/deplink.html b/deplink.html index 5fe2c5d..900850b 100644 --- a/deplink.html +++ b/deplink.html @@ -1,5 +1,6 @@ + @@ -60,6 +61,18 @@ border-bottom: 2px solid #ecf0f1; } + .version-badge { + display: inline-block; + background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%); + color: white; + padding: 4px 12px; + border-radius: 12px; + font-size: 14px; + font-weight: 600; + margin-left: 8px; + vertical-align: middle; + } + .link-card { background: #f8f9fa; border-radius: 12px; @@ -263,6 +276,34 @@ color: #e91e63; } + .param-list { + background: #f8f9fa; + border-left: 3px solid #3498db; + padding: 12px; + border-radius: 6px; + margin: 12px 0; + font-size: 13px; + line-height: 1.8; + color: #495057; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + } + + .param-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + margin-right: 6px; + background: #3498db; + color: white; + } + + .param-tag.optional { + background: #95a5a6; + } + @media (max-width: 768px) { .header h1 { font-size: 24px; @@ -278,6 +319,7 @@ } +
@@ -293,29 +335,134 @@ + + + + + +
@@ -326,15 +473,53 @@ + + + +
@@ -356,6 +579,32 @@

Gemini 供应商

+ + + + +
+ + +
+

📦 配置文件导入示例 v3.8+

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

✨ 配置文件导入新特性 (v3.8+)

+
@@ -397,14 +935,59 @@ + +
+

🔍 深链接解析器

+

粘贴深链接 URL,查看解析结果

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

🛠️ 深链接生成器

填写下方表单,生成您自己的深链接

+ +
+ + + + URL 参数模式:直接在 URL 中传递参数 | 配置文件模式:使用 Base64 编码的 JSON/TOML + +
+
- @@ -414,26 +997,116 @@
+ + ⚠️ 唯一必填项 + +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
- - -
- -
- - -
- -
- - -
- -
- + + + 通用模型字段,适用于所有应用类型 + +
+ + +
+
+

+ 📋 Claude 专用模型字段(可选) +

+

+ 可以根据需要设置特定的模型字段,这些字段仅在 Claude 应用中生效 +

+ +
+ + + + 对应环境变量:ANTHROPIC_DEFAULT_HAIKU_MODEL + +
+ +
+ + + + 对应环境变量:ANTHROPIC_DEFAULT_SONNET_MODEL + +
+ +
+ + + + 对应环境变量:ANTHROPIC_DEFAULT_OPUS_MODEL + +
+
@@ -458,46 +1131,274 @@
- + \ No newline at end of file diff --git a/package.json b/package.json index a3dd8cd..7801754 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.2.2", + "@lobehub/icons-static-svg": "^1.73.0", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9aebb80..b7e013f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.65.0(react@18.3.1)) + '@lobehub/icons-static-svg': + specifier: ^1.73.0 + version: 1.73.0 '@radix-ui/react-checkbox': specifier: ^1.3.3 version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -609,6 +612,9 @@ packages: '@lezer/markdown@1.6.0': resolution: {integrity: sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==} + '@lobehub/icons-static-svg@1.73.0': + resolution: {integrity: sha512-ydKUCDoopdmulbjDZo/gppaODd5Ju5nPneVcN9A5dAz9IJZUMkLms8bqostMLrqcdMQ8resKjLuV9RhJaWhaag==} + '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} @@ -2839,6 +2845,8 @@ snapshots: '@lezer/common': 1.2.3 '@lezer/highlight': 1.2.1 + '@lobehub/icons-static-svg@1.73.0': {} + '@marijn/find-cluster-break@1.0.2': {} '@mswjs/interceptors@0.40.0': diff --git a/scripts/extract-icons.js b/scripts/extract-icons.js new file mode 100644 index 0000000..2e14d4c --- /dev/null +++ b/scripts/extract-icons.js @@ -0,0 +1,208 @@ +const fs = require('fs'); +const path = require('path'); + +// 要提取的图标列表(按分类组织) +const ICONS_TO_EXTRACT = { + // AI 服务商(必需) + aiProviders: [ + 'openai', 'anthropic', 'claude', 'google', 'gemini', + 'deepseek', 'kimi', 'moonshot', 'zhipu', 'minimax', + 'baidu', 'alibaba', 'tencent', 'meta', 'microsoft', + 'cohere', 'perplexity', 'mistral', 'huggingface' + ], + + // 云平台 + cloudPlatforms: [ + 'aws', 'azure', 'huawei', 'cloudflare' + ], + + // 开发工具 + devTools: [ + 'github', 'gitlab', 'docker', 'kubernetes', 'vscode' + ], + + // 其他 + others: [ + 'settings', 'folder', 'file', 'link' + ] +}; + +// 合并所有图标 +const ALL_ICONS = [ + ...ICONS_TO_EXTRACT.aiProviders, + ...ICONS_TO_EXTRACT.cloudPlatforms, + ...ICONS_TO_EXTRACT.devTools, + ...ICONS_TO_EXTRACT.others +]; + +// 提取逻辑 +const OUTPUT_DIR = path.join(__dirname, '../src/icons/extracted'); +const SOURCE_DIR = path.join(__dirname, '../node_modules/@lobehub/icons-static-svg/icons'); + +// 确保输出目录存在 +if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); +} + +console.log('🎨 CC-Switch Icon Extractor\n'); +console.log('========================================'); +console.log('📦 Extracting icons...\n'); + +let extracted = 0; +let notFound = []; + +// 提取图标 +ALL_ICONS.forEach(iconName => { + const sourceFile = path.join(SOURCE_DIR, `${iconName}.svg`); + const targetFile = path.join(OUTPUT_DIR, `${iconName}.svg`); + + if (fs.existsSync(sourceFile)) { + fs.copyFileSync(sourceFile, targetFile); + console.log(` ✓ ${iconName}.svg`); + extracted++; + } else { + console.log(` ✗ ${iconName}.svg (not found)`); + notFound.push(iconName); + } +}); + +// 生成索引文件 +console.log('\n📝 Generating index file...\n'); + +const indexContent = `// Auto-generated icon index +// Do not edit manually + +export const icons: Record = { +${ALL_ICONS.filter(name => !notFound.includes(name)) + .map(name => { + const svg = fs.readFileSync(path.join(OUTPUT_DIR, `${name}.svg`), 'utf-8'); + const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$'); + return ` '${name}': \`${escaped}\`,`; + }) + .join('\n')} +}; + +export const iconList = Object.keys(icons); + +export function getIcon(name: string): string { + return icons[name.toLowerCase()] || ''; +} + +export function hasIcon(name: string): boolean { + return name.toLowerCase() in icons; +} +`; + +fs.writeFileSync(path.join(OUTPUT_DIR, 'index.ts'), indexContent); +console.log('✓ Generated: src/icons/extracted/index.ts'); + +// 生成图标元数据 +const metadataContent = `// Icon metadata for search and categorization +import { IconMetadata } from '@/types/icon'; + +export const iconMetadata: Record = { + // AI Providers + openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' }, + anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' }, + claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' }, + google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' }, + gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' }, + deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' }, + moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' }, + kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' }, + zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' }, + minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' }, + baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' }, + alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' }, + tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' }, + meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' }, + 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' }, + 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' }, + + // Cloud Platforms + aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' }, + azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' }, + huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' }, + cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' }, + + // Dev Tools + github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' }, + gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' }, + docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' }, + kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' }, + vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' }, + + // Others + settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' }, + folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' }, + file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' }, + link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' }, +}; + +export function getIconMetadata(name: string): IconMetadata | undefined { + return iconMetadata[name.toLowerCase()]; +} + +export function searchIcons(query: string): string[] { + const lowerQuery = query.toLowerCase(); + return Object.values(iconMetadata) + .filter(meta => + meta.name.includes(lowerQuery) || + meta.displayName.toLowerCase().includes(lowerQuery) || + meta.keywords.some(k => k.includes(lowerQuery)) + ) + .map(meta => meta.name); +} +`; + +fs.writeFileSync(path.join(OUTPUT_DIR, 'metadata.ts'), metadataContent); +console.log('✓ Generated: src/icons/extracted/metadata.ts'); + +// 生成 README +const readmeContent = `# Extracted Icons + +This directory contains extracted icons from @lobehub/icons-static-svg. + +## Statistics +- Total extracted: ${extracted} icons +- Not found: ${notFound.length} icons + +## Extracted Icons +${ALL_ICONS.filter(name => !notFound.includes(name)).map(name => `- ${name}`).join('\n')} + +${notFound.length > 0 ? `\n## Not Found\n${notFound.map(name => `- ${name}`).join('\n')}` : ''} + +## Usage + +\`\`\`typescript +import { getIcon, hasIcon, iconList } from './extracted'; + +// Get icon SVG +const svg = getIcon('openai'); + +// Check if icon exists +if (hasIcon('openai')) { + // ... +} + +// Get all available icons +console.log(iconList); +\`\`\` + +--- +Last updated: ${new Date().toISOString()} +Generated by: scripts/extract-icons.js +`; + +fs.writeFileSync(path.join(OUTPUT_DIR, 'README.md'), readmeContent); +console.log('✓ Generated: src/icons/extracted/README.md'); + +console.log('\n========================================'); +console.log('✅ Extraction complete!\n'); +console.log(` ✓ Extracted: ${extracted} icons`); +console.log(` ✗ Not found: ${notFound.length} icons`); +console.log(` 📉 Bundle size reduction: ~${Math.round((1 - extracted / 723) * 100)}%`); +console.log('========================================\n'); diff --git a/scripts/filter-icons.js b/scripts/filter-icons.js new file mode 100644 index 0000000..d7448c6 --- /dev/null +++ b/scripts/filter-icons.js @@ -0,0 +1,95 @@ +const fs = require('fs'); +const path = require('path'); + +const ICONS_DIR = path.join(__dirname, '../src/icons/extracted'); + +// List of "Famous" icons to keep +// Based on common AI providers and tools +const KEEP_LIST = [ + // AI Providers + 'openai', 'anthropic', 'claude', 'google', 'gemini', 'gemma', 'palm', + 'microsoft', 'azure', 'copilot', 'meta', 'llama', + 'alibaba', 'qwen', 'tencent', 'hunyuan', 'baidu', 'wenxin', + 'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi', + 'zhipu', 'chatglm', 'glm', 'minimax', 'mistral', 'cohere', + 'perplexity', 'huggingface', 'midjourney', 'stability', + 'xai', 'grok', 'yi', 'zeroone', 'ollama', + + // Cloud/Tools + 'aws', 'googlecloud', 'huawei', 'cloudflare', + 'github', 'githubcopilot', 'vercel', 'notion', 'discord', + 'gitlab', 'docker', 'kubernetes', 'vscode', 'settings', 'folder', 'file', 'link' +]; + +// Get all SVG files +const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg')); + +console.log(`Scanning ${files.length} files...`); + +let keptCount = 0; +let deletedCount = 0; +let renamedCount = 0; + +// First pass: Identify files to keep and prefer color versions +const fileMap = {}; // name -> { hasColor: bool, hasMono: bool } + +files.forEach(file => { + const isColor = file.endsWith('-color.svg'); + const baseName = isColor ? file.replace('-color.svg', '') : file.replace('.svg', ''); + + if (!fileMap[baseName]) { + fileMap[baseName] = { hasColor: false, hasMono: false }; + } + + if (isColor) { + fileMap[baseName].hasColor = true; + } else { + fileMap[baseName].hasMono = true; + } +}); + +// Second pass: Process files +Object.keys(fileMap).forEach(baseName => { + const info = fileMap[baseName]; + const shouldKeep = KEEP_LIST.includes(baseName); + + if (!shouldKeep) { + // Delete both versions if not in keep list + if (info.hasColor) { + fs.unlinkSync(path.join(ICONS_DIR, `${baseName}-color.svg`)); + deletedCount++; + } + if (info.hasMono) { + fs.unlinkSync(path.join(ICONS_DIR, `${baseName}.svg`)); + deletedCount++; + } + return; + } + + // If keeping, prefer color + if (info.hasColor) { + // Rename color version to base version (overwrite mono if exists) + const colorPath = path.join(ICONS_DIR, `${baseName}-color.svg`); + const targetPath = path.join(ICONS_DIR, `${baseName}.svg`); + + try { + // If mono exists, it will be overwritten/replaced + fs.renameSync(colorPath, targetPath); + renamedCount++; + keptCount++; + } catch (e) { + console.error(`Error renaming ${baseName}:`, e); + } + } else if (info.hasMono) { + // Keep mono if no color version + keptCount++; + } +}); + +console.log(`\nCleanup complete:`); +console.log(`- Kept: ${keptCount}`); +console.log(`- Deleted: ${deletedCount}`); +console.log(`- Renamed (Color -> Standard): ${renamedCount}`); + +// Regenerate index and metadata +require('./generate-icon-index.js'); diff --git a/scripts/generate-icon-index.js b/scripts/generate-icon-index.js new file mode 100644 index 0000000..897a065 --- /dev/null +++ b/scripts/generate-icon-index.js @@ -0,0 +1,113 @@ +const fs = require('fs'); +const path = require('path'); + +const ICONS_DIR = path.join(__dirname, '../src/icons/extracted'); +const INDEX_FILE = path.join(ICONS_DIR, 'index.ts'); +const METADATA_FILE = path.join(ICONS_DIR, 'metadata.ts'); + +// Known metadata from previous configuration +const KNOWN_METADATA = { + openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' }, + anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' }, + claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' }, + google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' }, + gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' }, + deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' }, + moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' }, + kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' }, + zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' }, + minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' }, + baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' }, + alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' }, + tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' }, + meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' }, + 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' }, + 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' }, + azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' }, + huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' }, + cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' }, + github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' }, + gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' }, + docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' }, + kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' }, + vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' }, + settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' }, + folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' }, + file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' }, + link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' }, +}; + +// Get all SVG files +const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg')); + +console.log(`Found ${files.length} SVG files.`); + +// Generate index.ts +const indexContent = `// Auto-generated icon index +// Do not edit manually + +export const icons: Record = { +${files.map(file => { + const name = path.basename(file, '.svg'); + const svg = fs.readFileSync(path.join(ICONS_DIR, file), 'utf-8'); + const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$'); + return ` '${name}': \`${escaped}\`,`; +}).join('\n')} +}; + +export const iconList = Object.keys(icons); + +export function getIcon(name: string): string { + return icons[name.toLowerCase()] || ''; +} + +export function hasIcon(name: string): boolean { + return name.toLowerCase() in icons; +} +`; + +fs.writeFileSync(INDEX_FILE, indexContent); +console.log(`Generated ${INDEX_FILE}`); + +// Generate metadata.ts +const metadataEntries = files.map(file => { + const name = path.basename(file, '.svg').toLowerCase(); + const known = KNOWN_METADATA[name]; + + if (known) { + return ` ${name}: ${JSON.stringify(known)},`; + } + + // Default metadata for unknown icons + return ` '${name}': { name: '${name}', displayName: '${name}', category: 'other', keywords: [], defaultColor: 'currentColor' },`; +}); + +const metadataContent = `// Icon metadata for search and categorization +import { IconMetadata } from '@/types/icon'; + +export const iconMetadata: Record = { +${metadataEntries.join('\n')} +}; + +export function getIconMetadata(name: string): IconMetadata | undefined { + return iconMetadata[name.toLowerCase()]; +} + +export function searchIcons(query: string): string[] { + const lowerQuery = query.toLowerCase(); + return Object.values(iconMetadata) + .filter(meta => + meta.name.includes(lowerQuery) || + meta.displayName.toLowerCase().includes(lowerQuery) || + meta.keywords.some(k => k.includes(lowerQuery)) + ) + .map(meta => meta.name); +} +`; + +fs.writeFileSync(METADATA_FILE, metadataContent); +console.log(`Generated ${METADATA_FILE}`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7bcb55c..a14e778 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -291,6 +291,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -595,15 +606,18 @@ dependencies = [ [[package]] name = "cc-switch" -version = "3.7.0" +version = "3.7.1" dependencies = [ "anyhow", + "auto-launch", + "base64 0.22.1", "chrono", "dirs 5.0.1", "futures", "log", "objc2 0.5.2", "objc2-app-kit 0.2.2", + "once_cell", "regex", "reqwest", "rquickjs", @@ -982,6 +996,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "5.0.1" @@ -1000,6 +1023,17 @@ dependencies = [ "dirs-sys 0.5.0", ] +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + [[package]] name = "dirs-sys" version = "0.4.1" @@ -6397,6 +6431,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.52.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8eda3e0..85ad685 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -48,6 +48,9 @@ zip = "2.2" serde_yaml = "0.9" tempfile = "3" url = "2.5" +auto-launch = "0.5" +once_cell = "1.21.3" +base64 = "0.22" [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 37ad55b..5614cb2 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,7 +10,8 @@ "opener:default", "updater:default", "core:window:allow-set-skip-taskbar", + "core:window:allow-start-dragging", "process:allow-restart", "dialog:default" ] -} +} \ No newline at end of file diff --git a/src-tauri/src/auto_launch.rs b/src-tauri/src/auto_launch.rs new file mode 100644 index 0000000..a0ae6e9 --- /dev/null +++ b/src-tauri/src/auto_launch.rs @@ -0,0 +1,40 @@ +use crate::error::AppError; +use auto_launch::AutoLaunch; + +/// 初始化 AutoLaunch 实例 +fn get_auto_launch() -> Result { + let app_name = "CC Switch"; + let app_path = + std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?; + + let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), false, &[] as &[&str]); + Ok(auto_launch) +} + +/// 启用开机自启 +pub fn enable_auto_launch() -> Result<(), AppError> { + let auto_launch = get_auto_launch()?; + auto_launch + .enable() + .map_err(|e| AppError::Message(format!("启用开机自启失败: {e}")))?; + log::info!("已启用开机自启"); + Ok(()) +} + +/// 禁用开机自启 +pub fn disable_auto_launch() -> Result<(), AppError> { + let auto_launch = get_auto_launch()?; + auto_launch + .disable() + .map_err(|e| AppError::Message(format!("禁用开机自启失败: {e}")))?; + log::info!("已禁用开机自启"); + Ok(()) +} + +/// 检查是否已启用开机自启 +pub fn is_auto_launch_enabled() -> Result { + let auto_launch = get_auto_launch()?; + auto_launch + .is_enabled() + .map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}"))) +} diff --git a/src-tauri/src/commands/deeplink.rs b/src-tauri/src/commands/deeplink.rs index 663231f..755f773 100644 --- a/src-tauri/src/commands/deeplink.rs +++ b/src-tauri/src/commands/deeplink.rs @@ -9,6 +9,16 @@ pub fn parse_deeplink(url: String) -> Result { parse_deeplink_url(&url).map_err(|e| e.to_string()) } +/// Merge configuration from Base64/URL into a deep link request +/// This is used by the frontend to show the complete configuration in the confirmation dialog +#[tauri::command] +pub fn merge_deeplink_config( + request: DeepLinkImportRequest, +) -> Result { + 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) #[tauri::command] pub fn import_from_deeplink( diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index ee76526..63f18e4 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -37,3 +37,20 @@ pub async fn set_app_config_dir_override( crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?; Ok(true) } + +/// 设置开机自启 +#[tauri::command] +pub async fn set_auto_launch(enabled: bool) -> Result { + if enabled { + crate::auto_launch::enable_auto_launch().map_err(|e| format!("启用开机自启失败: {e}"))?; + } else { + crate::auto_launch::disable_auto_launch().map_err(|e| format!("禁用开机自启失败: {e}"))?; + } + Ok(true) +} + +/// 获取开机自启状态 +#[tauri::command] +pub async fn get_auto_launch_status() -> Result { + crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}")) +} diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index 6b12850..3ed526c 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -56,26 +56,20 @@ pub async fn install_skill( if !skill.installed { let repo = SkillRepo { - owner: skill - .repo_owner - .clone() - .ok_or_else(|| { - format_skill_error( - "MISSING_REPO_INFO", - &[("directory", &directory), ("field", "owner")], - None, - ) - })?, - name: skill - .repo_name - .clone() - .ok_or_else(|| { - format_skill_error( - "MISSING_REPO_INFO", - &[("directory", &directory), ("field", "name")], - None, - ) - })?, + owner: skill.repo_owner.clone().ok_or_else(|| { + format_skill_error( + "MISSING_REPO_INFO", + &[("directory", &directory), ("field", "owner")], + None, + ) + })?, + name: skill.repo_name.clone().ok_or_else(|| { + format_skill_error( + "MISSING_REPO_INFO", + &[("directory", &directory), ("field", "name")], + None, + ) + })?, branch: skill .repo_branch .clone() diff --git a/src-tauri/src/deeplink.rs b/src-tauri/src/deeplink.rs index b6d062f..f81079d 100644 --- a/src-tauri/src/deeplink.rs +++ b/src-tauri/src/deeplink.rs @@ -37,6 +37,24 @@ pub struct DeepLinkImportRequest { /// Optional notes/description #[serde(skip_serializing_if = "Option::is_none")] pub notes: Option, + /// Optional Haiku model (Claude only, v3.7.1+) + #[serde(skip_serializing_if = "Option::is_none")] + pub haiku_model: Option, + /// Optional Sonnet model (Claude only, v3.7.1+) + #[serde(skip_serializing_if = "Option::is_none")] + pub sonnet_model: Option, + /// 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+) + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option, + /// Optional config format (json/toml, v3.8+) + #[serde(skip_serializing_if = "Option::is_none")] + pub config_format: Option, + /// Optional remote config URL (v3.8+) + #[serde(skip_serializing_if = "Option::is_none")] + pub config_url: Option, } /// Parse a ccswitch:// URL into a DeepLinkImportRequest @@ -110,29 +128,33 @@ pub fn parse_deeplink_url(url_str: &str) -> Result Result Result<(), AppError> { /// /// This function: /// 1. Validates the request -/// 2. Converts it to a Provider structure -/// 3. Delegates to ProviderService for actual import +/// 2. Merges config file if provided (v3.8+) +/// 3. Converts it to a Provider structure +/// 4. Delegates to ProviderService for actual import pub fn import_provider_from_deeplink( state: &AppState, request: DeepLinkImportRequest, ) -> Result { + // 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() { + 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(), + )); + } + // Parse app type - let app_type = AppType::from_str(&request.app) - .map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", request.app)))?; + let app_type = AppType::from_str(&merged_request.app) + .map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", merged_request.app)))?; // Build provider configuration based on app type - let mut provider = build_provider_from_request(&app_type, &request)?; + let mut provider = build_provider_from_request(&app_type, &merged_request)?; // 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 = request + let sanitized_name = merged_request .name .chars() .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') @@ -211,11 +260,31 @@ fn build_provider_from_request( env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(request.api_key)); env.insert("ANTHROPIC_BASE_URL".to_string(), json!(request.endpoint)); - // Add model if provided (use as default model) + // Add default model if provided if let Some(model) = &request.model { env.insert("ANTHROPIC_MODEL".to_string(), json!(model)); } + // Add Claude-specific model fields (v3.7.1+) + if let Some(haiku_model) = &request.haiku_model { + env.insert( + "ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), + json!(haiku_model), + ); + } + if let Some(sonnet_model) = &request.sonnet_model { + env.insert( + "ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), + json!(sonnet_model), + ); + } + if let Some(opus_model) = &request.opus_model { + env.insert( + "ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), + json!(opus_model), + ); + } + json!({ "env": env }) } AppType::Codex => { @@ -319,11 +388,254 @@ requires_openai_auth = true sort_index: None, notes: request.notes.clone(), meta: None, + icon: None, + icon_color: None, }; Ok(provider) } +/// Parse and merge configuration from Base64 encoded config or remote URL +/// +/// Priority: URL params > inline config > remote config +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()); + } + + // 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}")))?; + 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 { + // Fetch remote config (TODO: implement remote fetching in next phase) + return Err(AppError::InvalidInput( + "Remote config URL is not yet supported. Use inline config instead.".to_string(), + )); + } else { + return Ok(request.clone()); + }; + + // Step 2: Parse config based on format + let format = request.config_format.as_deref().unwrap_or("json"); + let config_value: serde_json::Value = match format { + "json" => serde_json::from_str(&config_content) + .map_err(|e| AppError::InvalidInput(format!("Invalid JSON config: {e}")))?, + "toml" => { + let toml_value: toml::Value = toml::from_str(&config_content) + .map_err(|e| AppError::InvalidInput(format!("Invalid TOML config: {e}")))?; + // Convert TOML to JSON for uniform processing + serde_json::to_value(toml_value) + .map_err(|e| AppError::Message(format!("Failed to convert TOML to JSON: {e}")))? + } + _ => { + return Err(AppError::InvalidInput(format!( + "Unsupported config format: {format}" + ))) + } + }; + + // 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() { + "claude" => merge_claude_config(&mut merged, &config_value)?, + "codex" => merge_codex_config(&mut merged, &config_value)?, + "gemini" => merge_gemini_config(&mut merged, &config_value)?, + _ => { + return Err(AppError::InvalidInput(format!( + "Invalid app type: {}", + request.app + ))) + } + } + + Ok(merged) +} + +/// Merge Claude configuration from config file +/// +/// Priority: URL params override config file values +fn merge_claude_config( + request: &mut DeepLinkImportRequest, + config: &serde_json::Value, +) -> Result<(), AppError> { + let env = config + .get("env") + .and_then(|v| v.as_object()) + .ok_or_else(|| { + AppError::InvalidInput("Claude config must have 'env' object".to_string()) + })?; + + // Auto-fill API key if not provided in URL + if request.api_key.is_empty() { + if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) { + request.api_key = token.to_string(); + } + } + + // Auto-fill endpoint if not provided in URL + if request.endpoint.is_empty() { + if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) { + request.endpoint = 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()); + } + + // Auto-fill model fields (URL params take priority) + if request.model.is_none() { + request.model = env + .get("ANTHROPIC_MODEL") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + if request.haiku_model.is_none() { + request.haiku_model = env + .get("ANTHROPIC_DEFAULT_HAIKU_MODEL") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + if request.sonnet_model.is_none() { + request.sonnet_model = env + .get("ANTHROPIC_DEFAULT_SONNET_MODEL") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + if request.opus_model.is_none() { + request.opus_model = env + .get("ANTHROPIC_DEFAULT_OPUS_MODEL") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + + Ok(()) +} + +/// Merge Codex configuration from config file +fn merge_codex_config( + request: &mut DeepLinkImportRequest, + config: &serde_json::Value, +) -> Result<(), AppError> { + // Auto-fill API key from auth.OPENAI_API_KEY + if request.api_key.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(); + } + } + + // Auto-fill endpoint and model from config string + if let Some(config_str) = config.get("config").and_then(|v| v.as_str()) { + // 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 let Some(base_url) = extract_codex_base_url(&toml_value) { + request.endpoint = base_url; + } + } + + // Extract model + if request.model.is_none() { + if let Some(model) = toml_value.get("model").and_then(|v| v.as_str()) { + request.model = Some(model.to_string()); + } + } + } + } + + // 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()); + } + + Ok(()) +} + +/// Merge Gemini configuration from config file +fn merge_gemini_config( + request: &mut DeepLinkImportRequest, + config: &serde_json::Value, +) -> Result<(), AppError> { + // Gemini uses flat env structure + if request.api_key.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(); + } + } + + if request.endpoint.is_empty() { + if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) { + request.endpoint = base_url.to_string(); + } + } + + if request.model.is_none() { + request.model = config + .get("GEMINI_MODEL") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + + // 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()); + } + + Ok(()) +} + +/// Extract base_url from Codex TOML config +fn extract_codex_base_url(toml_value: &toml::Value) -> Option { + // Try to find base_url in model_providers section + if let Some(providers) = toml_value.get("model_providers").and_then(|v| v.as_table()) { + for (_key, provider) in providers.iter() { + if let Some(base_url) = provider.get("base_url").and_then(|v| v.as_str()) { + return Some(base_url.to_string()); + } + } + } + None +} + +/// Infer homepage URL from API endpoint +/// +/// Examples: +/// - https://api.anthropic.com/v1 → https://anthropic.com +/// - https://api.openai.com/v1 → https://openai.com +/// - https://api-test.company.com/v1 → https://company.com +fn infer_homepage_from_endpoint(endpoint: &str) -> Option { + let url = Url::parse(endpoint).ok()?; + let host = url.host_str()?; + + // Remove common API prefixes + let clean_host = host + .strip_prefix("api.") + .or_else(|| host.strip_prefix("api-")) + .unwrap_or(host); + + Some(format!("https://{clean_host}")) +} + #[cfg(test)] mod tests { use super::*; @@ -375,14 +687,15 @@ mod tests { #[test] fn test_parse_missing_required_field() { - let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test"; + // Name is still required even in v3.8+ (only homepage/endpoint/apiKey are optional) + let url = "ccswitch://v1/import?resource=provider&app=claude"; let result = parse_deeplink_url(url); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() - .contains("Missing 'homepage' parameter")); + .contains("Missing 'name' parameter")); } #[test] @@ -413,6 +726,12 @@ mod tests { api_key: "test-api-key".to_string(), model: Some("gemini-2.0-flash".to_string()), notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + config: None, + config_format: None, + config_url: None, }; let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap(); @@ -443,6 +762,12 @@ mod tests { api_key: "test-api-key".to_string(), model: None, notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + config: None, + config_format: None, + config_url: None, }; let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap(); @@ -454,4 +779,88 @@ mod tests { // Model should not be present assert!(env.get("GEMINI_MODEL").is_none()); } + + #[test] + fn test_infer_homepage() { + assert_eq!( + infer_homepage_from_endpoint("https://api.anthropic.com/v1"), + Some("https://anthropic.com".to_string()) + ); + assert_eq!( + infer_homepage_from_endpoint("https://api-test.company.com/v1"), + Some("https://test.company.com".to_string()) + ); + assert_eq!( + infer_homepage_from_endpoint("https://example.com"), + Some("https://example.com".to_string()) + ); + } + + #[test] + fn test_parse_and_merge_config_claude() { + use base64::prelude::*; + + // Prepare Base64 encoded Claude config + let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-ant-xxx","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1","ANTHROPIC_MODEL":"claude-sonnet-4.5"}}"#; + let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes()); + + 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(), + model: None, + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + config: Some(config_b64), + config_format: Some("json".to_string()), + config_url: 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.model, Some("claude-sonnet-4.5".to_string())); + } + + #[test] + fn test_parse_and_merge_config_url_override() { + use base64::prelude::*; + + let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#; + let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes()); + + 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 + model: None, + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + config: Some(config_b64), + config_format: Some("json".to_string()), + config_url: None, + }; + + let merged = parse_and_merge_config(&request).unwrap(); + + // URL param should take priority + assert_eq!(merged.api_key, "sk-new"); + // Config file value should be used + assert_eq!(merged.endpoint, "https://api.anthropic.com/v1"); + } } diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index d9b0262..cd4cf3b 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -116,6 +116,6 @@ pub fn format_skill_error( serde_json::to_string(&error_obj).unwrap_or_else(|_| { // 如果 JSON 序列化失败,返回简单格式 - format!("ERROR:{}", code) + format!("ERROR:{code}") }) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0ed21ab..9319a87 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod app_config; mod app_store; +mod auto_launch; mod claude_mcp; mod claude_plugin; mod codex_config; @@ -14,6 +15,7 @@ mod mcp; mod prompt; mod prompt_files; mod provider; +mod provider_defaults; mod services; mod settings; mod store; @@ -704,6 +706,7 @@ pub fn run() { commands::sync_current_providers_live, // Deep link import commands::parse_deeplink, + commands::merge_deeplink_config, commands::import_from_deeplink, update_tray_menu, // Environment variable management @@ -717,6 +720,9 @@ pub fn run() { commands::get_skill_repos, commands::add_skill_repo, commands::remove_skill_repo, + // Auto launch + commands::set_auto_launch, + commands::get_auto_launch_status, ]); let app = builder diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index e3b6298..2d753b5 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -28,6 +28,13 @@ pub struct Provider { /// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json) #[serde(skip_serializing_if = "Option::is_none")] pub meta: Option, + /// 图标名称(如 "openai", "anthropic") + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// 图标颜色(Hex 格式,如 "#00A67E") + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "iconColor")] + pub icon_color: Option, } impl Provider { @@ -48,6 +55,8 @@ impl Provider { sort_index: None, notes: None, meta: None, + icon: None, + icon_color: None, } } } diff --git a/src-tauri/src/provider_defaults.rs b/src-tauri/src/provider_defaults.rs new file mode 100644 index 0000000..3fb2ad0 --- /dev/null +++ b/src-tauri/src/provider_defaults.rs @@ -0,0 +1,238 @@ +use once_cell::sync::Lazy; +use std::collections::HashMap; + +/// 供应商图标信息 +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct ProviderIcon { + pub name: &'static str, + pub color: &'static str, +} + +/// 供应商名称到图标的默认映射 +#[allow(dead_code)] +pub static DEFAULT_PROVIDER_ICONS: Lazy> = Lazy::new(|| { + let mut m = HashMap::new(); + + // AI 服务商 + m.insert( + "openai", + ProviderIcon { + name: "openai", + color: "#00A67E", + }, + ); + m.insert( + "anthropic", + ProviderIcon { + name: "anthropic", + color: "#D4915D", + }, + ); + m.insert( + "claude", + ProviderIcon { + name: "claude", + color: "#D4915D", + }, + ); + m.insert( + "google", + ProviderIcon { + name: "google", + color: "#4285F4", + }, + ); + m.insert( + "gemini", + ProviderIcon { + name: "gemini", + color: "#4285F4", + }, + ); + m.insert( + "deepseek", + ProviderIcon { + name: "deepseek", + color: "#1E88E5", + }, + ); + m.insert( + "kimi", + ProviderIcon { + name: "kimi", + color: "#6366F1", + }, + ); + m.insert( + "moonshot", + ProviderIcon { + name: "moonshot", + color: "#6366F1", + }, + ); + m.insert( + "zhipu", + ProviderIcon { + name: "zhipu", + color: "#0F62FE", + }, + ); + m.insert( + "minimax", + ProviderIcon { + name: "minimax", + color: "#FF6B6B", + }, + ); + m.insert( + "baidu", + ProviderIcon { + name: "baidu", + color: "#2932E1", + }, + ); + m.insert( + "alibaba", + ProviderIcon { + name: "alibaba", + color: "#FF6A00", + }, + ); + m.insert( + "tencent", + ProviderIcon { + name: "tencent", + color: "#00A4FF", + }, + ); + m.insert( + "meta", + ProviderIcon { + name: "meta", + color: "#0081FB", + }, + ); + m.insert( + "microsoft", + ProviderIcon { + name: "microsoft", + color: "#00A4EF", + }, + ); + m.insert( + "cohere", + ProviderIcon { + name: "cohere", + color: "#39594D", + }, + ); + m.insert( + "perplexity", + ProviderIcon { + name: "perplexity", + color: "#20808D", + }, + ); + m.insert( + "mistral", + ProviderIcon { + name: "mistral", + color: "#FF7000", + }, + ); + m.insert( + "huggingface", + ProviderIcon { + name: "huggingface", + color: "#FFD21E", + }, + ); + + // 云平台 + m.insert( + "aws", + ProviderIcon { + name: "aws", + color: "#FF9900", + }, + ); + m.insert( + "azure", + ProviderIcon { + name: "azure", + color: "#0078D4", + }, + ); + m.insert( + "huawei", + ProviderIcon { + name: "huawei", + color: "#FF0000", + }, + ); + m.insert( + "cloudflare", + ProviderIcon { + name: "cloudflare", + color: "#F38020", + }, + ); + + m +}); + +/// 根据供应商名称智能推断图标 +#[allow(dead_code)] +pub fn infer_provider_icon(provider_name: &str) -> Option { + let name_lower = provider_name.to_lowercase(); + + // 精确匹配 + if let Some(icon) = DEFAULT_PROVIDER_ICONS.get(name_lower.as_str()) { + return Some(icon.clone()); + } + + // 模糊匹配(包含关键词) + for (key, icon) in DEFAULT_PROVIDER_ICONS.iter() { + if name_lower.contains(key) { + return Some(icon.clone()); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exact_match() { + let icon = infer_provider_icon("openai"); + assert!(icon.is_some()); + let icon = icon.unwrap(); + assert_eq!(icon.name, "openai"); + assert_eq!(icon.color, "#00A67E"); + } + + #[test] + fn test_fuzzy_match() { + let icon = infer_provider_icon("OpenAI Official"); + assert!(icon.is_some()); + let icon = icon.unwrap(); + assert_eq!(icon.name, "openai"); + } + + #[test] + fn test_case_insensitive() { + let icon = infer_provider_icon("ANTHROPIC"); + assert!(icon.is_some()); + assert_eq!(icon.unwrap().name, "anthropic"); + } + + #[test] + fn test_no_match() { + let icon = infer_provider_icon("unknown provider"); + assert!(icon.is_none()); + } +} diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 1c06f73..1af2f47 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -490,7 +490,9 @@ impl SkillService { // 根据 skills_path 确定源目录路径 let source = if let Some(ref skills_path) = repo.skills_path { // 如果指定了 skills_path,源路径为: temp_dir/skills_path/directory - temp_dir.join(skills_path.trim_matches('/')).join(&directory) + temp_dir + .join(skills_path.trim_matches('/')) + .join(&directory) } else { // 否则源路径为: temp_dir/directory temp_dir.join(&directory) diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 75b4c3c..518e1cb 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -49,6 +49,9 @@ pub struct AppSettings { pub gemini_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, + /// 是否开机自启 + #[serde(default)] + pub launch_on_startup: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub security: Option, /// Claude 自定义端点列表 @@ -77,6 +80,7 @@ impl Default for AppSettings { codex_config_dir: None, gemini_config_dir: None, language: None, + launch_on_startup: false, security: None, custom_endpoints_claude: HashMap::new(), custom_endpoints_codex: HashMap::new(), diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8ad7bf3..a4805ab 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -14,6 +14,7 @@ { "label": "main", "title": "", + "titleBarStyle": "Overlay", "width": 1000, "height": 650, "minWidth": 900, diff --git a/src/App.tsx b/src/App.tsx index 6402605..75b9263 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,16 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useRef } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { Plus, Settings, Edit3 } from "lucide-react"; +import { + Plus, + Settings, + ArrowLeft, + Bot, + Book, + Wrench, + Server, + RefreshCw, +} from "lucide-react"; import type { Provider } from "@/types"; import type { EnvConflict } from "@/types/env"; import { useProvidersQuery } from "@/lib/query"; @@ -19,7 +28,7 @@ import { ProviderList } from "@/components/providers/ProviderList"; import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; import { EditProviderDialog } from "@/components/providers/EditProviderDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { SettingsDialog } from "@/components/settings/SettingsDialog"; +import { SettingsPage } from "@/components/settings/SettingsPage"; import { UpdateBadge } from "@/components/UpdateBadge"; import { EnvWarningBanner } from "@/components/env/EnvWarningBanner"; import UsageScriptModal from "@/components/UsageScriptModal"; @@ -27,34 +36,34 @@ import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import PromptPanel from "@/components/prompts/PromptPanel"; import { SkillsPage } from "@/components/skills/SkillsPage"; import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; +import { AgentsPanel } from "@/components/agents/AgentsPanel"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; + +type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents"; function App() { const { t } = useTranslation(); const [activeApp, setActiveApp] = useState("claude"); - const [isEditMode, setIsEditMode] = useState(false); - const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [currentView, setCurrentView] = useState("providers"); const [isAddOpen, setIsAddOpen] = useState(false); - const [isMcpOpen, setIsMcpOpen] = useState(false); - const [isPromptOpen, setIsPromptOpen] = useState(false); - const [isSkillsOpen, setIsSkillsOpen] = useState(false); + const [editingProvider, setEditingProvider] = useState(null); const [usageProvider, setUsageProvider] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); const [envConflicts, setEnvConflicts] = useState([]); const [showEnvBanner, setShowEnvBanner] = useState(false); + const promptPanelRef = useRef(null); + const mcpPanelRef = useRef(null); + const skillsPageRef = useRef(null); + const addActionButtonClass = + "bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8"; + const { data, isLoading, refetch } = useProvidersQuery(activeApp); const providers = useMemo(() => data?.providers ?? {}, [data]); const currentProviderId = data?.currentProviderId ?? ""; + const isClaudeApp = activeApp === "claude"; // 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作 const { @@ -98,7 +107,10 @@ function App() { if (flatConflicts.length > 0) { setEnvConflicts(flatConflicts); - setShowEnvBanner(true); + const dismissed = sessionStorage.getItem("env_banner_dismissed"); + if (!dismissed) { + setShowEnvBanner(true); + } } } catch (error) { console.error( @@ -128,7 +140,10 @@ function App() { ); return [...prev, ...newConflicts]; }); - setShowEnvBanner(true); + const dismissed = sessionStorage.getItem("env_banner_dismissed"); + if (!dismissed) { + setShowEnvBanner(true); + } } } catch (error) { console.error( @@ -229,13 +244,81 @@ function App() { } }; + const renderContent = () => { + switch (currentView) { + case "settings": + return ( + setCurrentView("providers")} + onImportSuccess={handleImportSuccess} + /> + ); + case "prompts": + return ( + setCurrentView("providers")} + appId={activeApp} + /> + ); + case "skills": + return ( + setCurrentView("providers")} + /> + ); + case "mcp": + return ( + setCurrentView("providers")} + /> + ); + case "agents": + return setCurrentView("providers")} />; + default: + return ( +
+ setIsAddOpen(true)} + /> +
+ ); + } + }; + return ( -
+
+ {/* 全局拖拽区域(顶部 4px),避免上边框无法拖动 */} +
{/* 环境变量警告横幅 */} {showEnvBanner && envConflicts.length > 0 && ( setShowEnvBanner(false)} + onDismiss={() => { + setShowEnvBanner(false); + sessionStorage.setItem("env_banner_dismissed", "true"); + }} onDeleted={async () => { // 删除后重新检测 try { @@ -255,92 +338,182 @@ function App() { /> )} -
-
-
- - CC Switch - - - - setIsSettingsOpen(true)} /> +
+
+
+
+ {currentView !== "providers" ? ( +
+ +

+ {currentView === "settings" && t("settings.title")} + {currentView === "prompts" && + t("prompts.title", { appName: t(`apps.${activeApp}`) })} + {currentView === "skills" && t("skills.title")} + {currentView === "mcp" && t("mcp.unifiedPanel.title")} + {currentView === "agents" && "Agents"} +

+
+ ) : ( + <> +
+ + CC Switch + +
+ +
+ setCurrentView("settings")} /> + + )}
-
- - - - - +
+ {currentView === "prompts" && ( + + )} + {currentView === "mcp" && ( + + )} + {currentView === "skills" && ( + <> + + + + )} + {currentView === "providers" && ( + <> + + +
+ +
+ + {isClaudeApp && ( + + )} + + {isClaudeApp && ( + + )} +
+ + + + )}
-
-
- setIsAddOpen(true)} - /> -
+
+ {renderContent()}
setConfirmDelete(null)} /> - - - - - - - - - - - {t("skills.title")} - - - setIsSkillsOpen(false)} /> - -
); diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index f7ac954..5d9a7e4 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -13,13 +13,13 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) { }; return ( -
+
@@ -52,7 +59,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) { onClick={() => handleSwitch("gemini")} className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${ activeApp === "gemini" - ? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none" + ? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100" : "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" }`} > @@ -60,8 +67,8 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) { size={16} className={ activeApp === "gemini" - ? "text-[#4285F4] dark:text-[#4285F4] transition-colors duration-200" - : "text-gray-500 dark:text-gray-400 group-hover:text-[#4285F4] dark:group-hover:text-[#4285F4] transition-colors duration-200" + ? "text-foreground" + : "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors" } /> Gemini diff --git a/src/components/BrandIcons.tsx b/src/components/BrandIcons.tsx index db20277..54ead10 100644 --- a/src/components/BrandIcons.tsx +++ b/src/components/BrandIcons.tsx @@ -3,47 +3,46 @@ interface IconProps { className?: string; } +// 导入本地 SVG 图标 +import ClaudeSvg from "@/icons/extracted/claude.svg?url"; +import OpenAISvg from "@/icons/extracted/openai.svg?url"; +import GeminiSvg from "@/icons/extracted/gemini.svg?url"; + export function ClaudeIcon({ size = 16, className = "" }: IconProps) { return ( - - - + alt="Claude" + loading="lazy" + /> ); } export function CodexIcon({ size = 16, className = "" }: IconProps) { return ( - - - + className={`dark:brightness-0 dark:invert ${className}`} + alt="Codex" + loading="lazy" + /> ); } export function GeminiIcon({ size = 16, className = "" }: IconProps) { return ( - - - + alt="Gemini" + loading="lazy" + /> ); } diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx new file mode 100644 index 0000000..9948c6d --- /dev/null +++ b/src/components/ColorPicker.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +interface ColorPickerProps { + value?: string; + onValueChange: (color: string) => void; + label?: string; + presets?: string[]; +} + +const DEFAULT_PRESETS = [ + "#00A67E", + "#D4915D", + "#4285F4", + "#FF6A00", + "#00A4FF", + "#FF9900", + "#0078D4", + "#FF0000", + "#1E88E5", + "#6366F1", + "#0F62FE", + "#2932E1", +]; + +export const ColorPicker: React.FC = ({ + value = "#4285F4", + onValueChange, + label = "图标颜色", + presets = DEFAULT_PRESETS, +}) => { + return ( +
+ + + {/* 颜色预设 */} +
+ {presets.map((color) => ( +
+ + {/* 自定义颜色输入 */} +
+ onValueChange(e.target.value)} + className="w-16 h-10 p-1 cursor-pointer" + /> + onValueChange(e.target.value)} + placeholder="#4285F4" + className="flex-1 font-mono" + /> +
+
+ ); +}; diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index 49f9f65..fb61133 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { listen } from "@tauri-apps/api/event"; import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink"; import { @@ -30,9 +30,30 @@ export function DeepLinkImportDialog() { // Listen for deep link import events const unlistenImport = listen( "deeplink-import", - (event) => { + async (event) => { console.log("Deep link import event received:", event.payload); - setRequest(event.payload); + + // If config is present, merge it to get the complete configuration + if (event.payload.config || event.payload.configUrl) { + try { + const mergedRequest = await deeplinkApi.mergeDeeplinkConfig( + event.payload, + ); + console.log("Config merged successfully:", mergedRequest); + setRequest(mergedRequest); + } catch (error) { + console.error("Failed to merge config:", error); + toast.error(t("deeplink.configMergeError"), { + description: + error instanceof Error ? error.message : String(error), + }); + // Fall back to original request + setRequest(event.payload); + } + } else { + setRequest(event.payload); + } + setIsOpen(true); }, ); @@ -71,7 +92,6 @@ export function DeepLinkImportDialog() { }); setIsOpen(false); - setRequest(null); } catch (error) { console.error("Failed to import provider from deep link:", error); toast.error(t("deeplink.importError"), { @@ -84,120 +104,326 @@ export function DeepLinkImportDialog() { const handleCancel = () => { setIsOpen(false); - setRequest(null); }; - if (!request) return null; - // Mask API key for display (show first 4 chars + ***) const maskedApiKey = - request.apiKey.length > 4 + request?.apiKey && request.apiKey.length > 4 ? `${request.apiKey.substring(0, 4)}${"*".repeat(20)}` : "****"; + // Check if config file is present + const hasConfigFile = !!(request?.config || request?.configUrl); + const configSource = request?.config + ? "base64" + : request?.configUrl + ? "url" + : null; + + // Parse config file content for display + interface ParsedConfig { + type: "claude" | "codex" | "gemini"; + env?: Record; + auth?: Record; + tomlConfig?: string; + raw: Record; + } + + // Helper to decode base64 with UTF-8 support + const b64ToUtf8 = (str: string): string => { + try { + const binString = atob(str); + const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0) || 0); + return new TextDecoder().decode(bytes); + } catch (e) { + console.error("Failed to decode base64:", e); + return atob(str); + } + }; + + const parsedConfig = useMemo((): ParsedConfig | null => { + if (!request?.config) return null; + try { + const decoded = b64ToUtf8(request.config); + const parsed = JSON.parse(decoded) as Record; + + if (request.app === "claude") { + // Claude 格式: { env: { ANTHROPIC_AUTH_TOKEN: ..., ... } } + return { + type: "claude", + env: (parsed.env as Record) || {}, + raw: parsed, + }; + } else if (request.app === "codex") { + // Codex 格式: { auth: { OPENAI_API_KEY: ... }, config: "TOML string" } + return { + type: "codex", + auth: (parsed.auth as Record) || {}, + tomlConfig: (parsed.config as string) || "", + raw: parsed, + }; + } else if (request.app === "gemini") { + // Gemini 格式: 扁平结构 { GEMINI_API_KEY: ..., GEMINI_BASE_URL: ... } + return { + type: "gemini", + env: parsed as Record, + raw: parsed, + }; + } + return null; + } catch (e) { + console.error("Failed to parse config:", e); + return null; + } + }, [request?.config, request?.app]); + + // Helper to mask sensitive values + const maskValue = (key: string, value: string): string => { + const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"]; + const isSensitive = sensitiveKeys.some((k) => + key.toUpperCase().includes(k), + ); + if (isSensitive && value.length > 8) { + return `${value.substring(0, 8)}${"*".repeat(12)}`; + } + return value; + }; + return ( - - - {/* 标题显式左对齐,避免默认居中样式影响 */} - - {t("deeplink.confirmImport")} - - {t("deeplink.confirmImportDescription")} - - + + + {request && ( + <> + {/* 标题显式左对齐,避免默认居中样式影响 */} + + {t("deeplink.confirmImport")} + + {t("deeplink.confirmImportDescription")} + + - {/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */} -
- {/* 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 (if present) */} - {request.model && ( -
-
- {t("deeplink.model")} + {/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */} +
+ {/* App Type */} +
+
+ {t("deeplink.app")} +
+
+ {request.app} +
-
- {request.model} + + {/* 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 (if present) */} + {request.model && ( +
+
+ {t("deeplink.model")} +
+
+ {request.model} +
+
+ )} + + {/* 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 && ( +
+ {Object.entries(parsedConfig.env).map( + ([key, value]) => ( +
+ + {key} + + + {maskValue(key, String(value))} + +
+ ), + )} +
+ )} + + {/* 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))} + +
+ ), + )} +
+ )} +
+ )} + + {/* Config URL (if remote) */} + {request.configUrl && ( +
+
+ {t("deeplink.configUrl")} +
+
+ {request.configUrl} +
+
+ )} +
+ )} + + {/* Warning */} +
+ {t("deeplink.warning")}
- )} - {/* Notes (if present) */} - {request.notes && ( -
-
- {t("deeplink.notes")} -
-
- {request.notes} -
-
- )} - - {/* Warning */} -
- {t("deeplink.warning")} -
-
- - - - - + + + + + + )}
); diff --git a/src/components/IconPicker.tsx b/src/components/IconPicker.tsx new file mode 100644 index 0000000..6c8f5f8 --- /dev/null +++ b/src/components/IconPicker.tsx @@ -0,0 +1,85 @@ +import React, { useState, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ProviderIcon } from "./ProviderIcon"; +import { iconList } from "@/icons/extracted"; +import { searchIcons, getIconMetadata } from "@/icons/extracted/metadata"; +import { cn } from "@/lib/utils"; + +interface IconPickerProps { + value?: string; // 当前选中的图标 + onValueChange: (icon: string) => void; // 选择回调 + color?: string; // 预览颜色 +} + +export const IconPicker: React.FC = ({ + value, + onValueChange, +}) => { + const { t } = useTranslation(); + const [searchQuery, setSearchQuery] = useState(""); + + // 过滤图标列表 + const filteredIcons = useMemo(() => { + if (!searchQuery) return iconList; + return searchIcons(searchQuery); + }, [searchQuery]); + + return ( +
+
+ + setSearchQuery(e.target.value)} + className="mt-2" + /> +
+ +
+
+ {filteredIcons.map((iconName) => { + const meta = getIconMetadata(iconName); + const isSelected = value === iconName; + + return ( + + ); + })} +
+
+ + {filteredIcons.length === 0 && ( +
+ {t("iconPicker.noResults", { defaultValue: "未找到匹配的图标" })} +
+ )} +
+ ); +}; diff --git a/src/components/JsonEditor.tsx b/src/components/JsonEditor.tsx index 5764ea4..72972e5 100644 --- a/src/components/JsonEditor.tsx +++ b/src/components/JsonEditor.tsx @@ -12,6 +12,7 @@ import { toast } from "sonner"; import { formatJSON } from "@/utils/formatters"; interface JsonEditorProps { + id?: string; value: string; onChange: (value: string) => void; placeholder?: string; @@ -19,7 +20,8 @@ interface JsonEditorProps { rows?: number; showValidation?: boolean; language?: "json" | "javascript"; - height?: string; + height?: string | number; + showMinimap?: boolean; // 添加此属性以防未来使用 } const JsonEditor: React.FC = ({ @@ -84,19 +86,47 @@ const JsonEditor: React.FC = ({ // 使用 baseTheme 定义基础样式,优先级低于 oneDark,但可以正确响应主题 const baseTheme = EditorView.baseTheme({ - "&light .cm-editor, &dark .cm-editor": { + ".cm-editor": { border: "1px solid hsl(var(--border))", borderRadius: "0.5rem", + background: "transparent", }, - "&light .cm-editor.cm-focused, &dark .cm-editor.cm-focused": { + ".cm-editor.cm-focused": { outline: "none", borderColor: "hsl(var(--primary))", }, + ".cm-scroller": { + background: "transparent", + }, + ".cm-gutters": { + background: "transparent", + borderRight: "1px solid hsl(var(--border))", + color: "hsl(var(--muted-foreground))", + }, + ".cm-selectionBackground, .cm-content ::selection": { + background: "hsl(var(--primary) / 0.18)", + }, + ".cm-selectionMatch": { + background: "hsl(var(--primary) / 0.12)", + }, + ".cm-activeLine": { + background: "hsl(var(--primary) / 0.08)", + }, + ".cm-activeLineGutter": { + background: "hsl(var(--primary) / 0.08)", + }, }); // 使用 theme 定义尺寸和字体样式 + const heightValue = height + ? typeof height === "number" + ? `${height}px` + : height + : undefined; const sizingTheme = EditorView.theme({ - "&": height ? { height } : { minHeight: `${minHeightPx}px` }, + "&": heightValue + ? { height: heightValue } + : { minHeight: `${minHeightPx}px` }, ".cm-scroller": { overflow: "auto" }, ".cm-content": { fontFamily: @@ -129,11 +159,32 @@ const JsonEditor: React.FC = ({ ".cm-editor": { border: "1px solid hsl(var(--border))", borderRadius: "0.5rem", + background: "transparent", }, ".cm-editor.cm-focused": { outline: "none", borderColor: "hsl(var(--primary))", }, + ".cm-scroller": { + background: "transparent", + }, + ".cm-gutters": { + background: "transparent", + borderRight: "1px solid hsl(var(--border))", + color: "hsl(var(--muted-foreground))", + }, + ".cm-selectionBackground, .cm-content ::selection": { + background: "hsl(var(--primary) / 0.18)", + }, + ".cm-selectionMatch": { + background: "hsl(var(--primary) / 0.12)", + }, + ".cm-activeLine": { + background: "hsl(var(--primary) / 0.08)", + }, + ".cm-activeLineGutter": { + background: "hsl(var(--primary) / 0.08)", + }, }), ); } @@ -196,14 +247,23 @@ const JsonEditor: React.FC = ({ } }; + const isFullHeight = height === "100%"; + return ( -
-
+
+
{language === "json" && (
- {/* 第二行:已用 + 剩余 + 单位 */} + {/* 第二行:用量和剩余 */}
{/* 已用 */} {firstUsage.used !== undefined && ( @@ -153,14 +156,13 @@ const UsageFooter: React.FC = ({ {t("usage.remaining")} {firstUsage.remaining.toFixed(2)} @@ -179,7 +181,7 @@ const UsageFooter: React.FC = ({ } return ( -
+
{/* 标题行:包含刷新按钮和自动查询时间 */}
@@ -196,7 +198,7 @@ const UsageFooter: React.FC = ({ + +
+ +
+ + +
+ + ); + return ( - !open && onClose()}> - - - - {t("usageScript.title")} - {provider.name} - - + +
+
+

+ {t("usageScript.enableUsageQuery")} +

+

+ {t("usageScript.autoQueryIntervalHint")} +

+
+ + setScript({ ...script, enabled: checked }) + } + aria-label={t("usageScript.enableUsageQuery")} + /> +
- {/* Content - Scrollable */} -
- {/* 启用开关 */} -
-
-

- {t("usageScript.enableUsageQuery")} -

+ {script.enabled && ( +
+ {/* 预设模板选择 */} +
+
+ + + {t("usageScript.variablesHint")} + +
+
+ {Object.keys(PRESET_TEMPLATES).map((name) => { + const isSelected = selectedTemplate === name; + return ( + + ); + })}
- - setScript({ ...script, enabled: checked }) - } - aria-label={t("usageScript.enableUsageQuery")} - /> -
- {script.enabled && ( - <> - {/* 预设模板选择 */} -
- -
- {Object.keys(PRESET_TEMPLATES).map((name) => { - const isSelected = selectedTemplate === name; - return ( - - ); - })} -
-
+ {/* 凭证配置 */} + {shouldShowCredentialsConfig && ( +
+

+ {t("usageScript.credentialsConfig")} +

- {/* 凭证配置区域:通用和 NewAPI 模板显示 */} - {shouldShowCredentialsConfig && ( -
-

- {t("usageScript.credentialsConfig")} -

- - {/* 通用模板:显示 apiKey + baseUrl */} +
{selectedTemplate === TEMPLATE_KEYS.GENERAL && ( <>
@@ -426,12 +423,13 @@ const UsageScriptModal: React.FC = ({ } placeholder="sk-xxxxx" autoComplete="off" + className="border-white/10" /> {script.apiKey && (
)} - {/* NewAPI 模板:显示 baseUrl + accessToken + userId */} {selectedTemplate === TEMPLATE_KEYS.NEW_API && ( <>
@@ -478,6 +476,7 @@ const UsageScriptModal: React.FC = ({ } placeholder="https://api.newapi.com" autoComplete="off" + className="border-white/10" />
@@ -500,6 +499,7 @@ const UsageScriptModal: React.FC = ({ "usageScript.accessTokenPlaceholder", )} autoComplete="off" + className="border-white/10" /> {script.accessToken && (
)}
- )} +
+ )} +
- {/* 脚本编辑器 */} -
- - setScript({ ...script, code })} - height="300px" - language="javascript" + {/* 脚本配置 */} +
+
+

+ {t("usageScript.scriptConfig")} +

+

+ {t("usageScript.variablesHint")} +

+
+ +
+
+ + { + setScript({ + ...script, + request: { ...script.request, url: e.target.value }, + }); + }} + placeholder={t("usageScript.requestUrlPlaceholder")} + className="border-white/10" /> -

- {t("usageScript.variablesHint", { - apiKey: "{{apiKey}}", - baseUrl: "{{baseUrl}}", - })} -

- {/* 配置选项 */} -
+
+
+ + { + setScript({ + ...script, + request: { + ...script.request, + method: e.target.value.toUpperCase(), + }, + }); + }} + placeholder="GET / POST" + className="border-white/10" + /> +
+
- - {/* 🆕 自动查询间隔 */} -
- - { - // 输入时:只清理格式,允许临时为空 - const cleaned = sanitizeNumberInput(e.target.value); - setScript((prev) => ({ - ...prev, - autoQueryInterval: - cleaned === "" ? undefined : parseInt(cleaned, 10), - })); - }} - onBlur={(e) => { - // 失焦时:严格验证并约束范围 - const validated = validateAndClampInterval( - e.target.value, - ); - setScript({ ...script, autoQueryInterval: validated }); - }} + value={script.timeout ?? 10} + onChange={(e) => + setScript({ + ...script, + timeout: validateTimeout(e.target.value), + }) + } + onBlur={(e) => + setScript({ + ...script, + timeout: validateTimeout(e.target.value), + }) + } + className="border-white/10" /> -

- {t("usageScript.autoQueryIntervalHint")} -

- {/* 脚本说明 */} -
-

- {t("usageScript.scriptHelp")} -

-
-
- {t("usageScript.configFormat")} -
-                      {`({
+              
+ + { + try { + const parsed = JSON.parse(value || "{}"); + setScript({ + ...script, + request: { ...script.request, headers: parsed }, + }); + } catch (error) { + console.error("Invalid headers JSON", error); + } + }} + height={180} + /> +
+ +
+ + { + try { + const parsed = + value?.trim() === "" ? undefined : JSON.parse(value); + setScript({ + ...script, + request: { ...script.request, body: parsed }, + }); + } catch (error) { + toast.error( + t("usageScript.invalidJson") || "Body 必须是合法 JSON", + ); + } + }} + height={220} + /> +
+ +
+ + + setScript({ + ...script, + autoIntervalMinutes: validateAndClampInterval( + e.target.value, + ), + }) + } + onBlur={(e) => + setScript({ + ...script, + autoIntervalMinutes: validateAndClampInterval( + e.target.value, + ), + }) + } + className="border-white/10" + /> +

+ {t("usageScript.autoQueryIntervalHint")} +

+
+
+
+ + {/* 提取器代码 */} +
+
+ +
+ {t("usageScript.extractorHint")} +
+
+ setScript({ ...script, code: value })} + height={480} + language="javascript" + showMinimap={false} + /> +
+ + {/* 帮助信息 */} +
+

{t("usageScript.scriptHelp")}

+
+
+ {t("usageScript.configFormat")} +
+                  {`({
   request: {
     url: "{{baseUrl}}/api/usage",
     method: "POST",
     headers: {
       "Authorization": "Bearer {{apiKey}}",
       "User-Agent": "cc-switch/1.0"
-    },
-    body: JSON.stringify({ key: "value" })  // ${t("usageScript.commentOptional")}
+    }
   },
   extractor: function(response) {
-    // ${t("usageScript.commentResponseIsJson")}
     return {
       isValid: !response.error,
       remaining: response.balance,
@@ -654,79 +759,41 @@ const UsageScriptModal: React.FC = ({
     };
   }
 })`}
-                    
-
- -
- {t("usageScript.extractorFormat")} -
    -
  • {t("usageScript.fieldIsValid")}
  • -
  • {t("usageScript.fieldInvalidMessage")}
  • -
  • {t("usageScript.fieldRemaining")}
  • -
  • {t("usageScript.fieldUnit")}
  • -
  • {t("usageScript.fieldPlanName")}
  • -
  • {t("usageScript.fieldTotal")}
  • -
  • {t("usageScript.fieldUsed")}
  • -
  • {t("usageScript.fieldExtra")}
  • -
-
- -
- {t("usageScript.tips")} -
    -
  • - {t("usageScript.tip1", { - apiKey: "{{apiKey}}", - baseUrl: "{{baseUrl}}", - })} -
  • -
  • {t("usageScript.tip2")}
  • -
  • {t("usageScript.tip3")}
  • -
-
-
+
- - )} + +
+ {t("usageScript.extractorFormat")} +
    +
  • {t("usageScript.fieldIsValid")}
  • +
  • {t("usageScript.fieldInvalidMessage")}
  • +
  • {t("usageScript.fieldRemaining")}
  • +
  • {t("usageScript.fieldUnit")}
  • +
  • {t("usageScript.fieldPlanName")}
  • +
  • {t("usageScript.fieldTotal")}
  • +
  • {t("usageScript.fieldUsed")}
  • +
  • {t("usageScript.fieldExtra")}
  • +
+
+ +
+ {t("usageScript.tips")} +
    +
  • + {t("usageScript.tip1", { + apiKey: "{{apiKey}}", + baseUrl: "{{baseUrl}}", + })} +
  • +
  • {t("usageScript.tip2")}
  • +
  • {t("usageScript.tip3")}
  • +
+
+
+
- - {/* Footer */} - - {/* Left side - Test and Format buttons */} -
- - -
- - {/* Right side - Cancel and Save buttons */} -
- - -
-
- -
+ )} + ); }; diff --git a/src/components/agents/AgentsPanel.tsx b/src/components/agents/AgentsPanel.tsx new file mode 100644 index 0000000..7f53f40 --- /dev/null +++ b/src/components/agents/AgentsPanel.tsx @@ -0,0 +1,22 @@ +import { Bot } from "lucide-react"; + +interface AgentsPanelProps { + onOpenChange: (open: boolean) => void; +} + +export function AgentsPanel({}: AgentsPanelProps) { + return ( +
+
+
+ +
+

Coming Soon

+

+ The Agents management feature is currently under development. Stay + tuned for powerful autonomous capabilities. +

+
+
+ ); +} diff --git a/src/components/common/FullScreenPanel.tsx b/src/components/common/FullScreenPanel.tsx new file mode 100644 index 0000000..56e3cab --- /dev/null +++ b/src/components/common/FullScreenPanel.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { createPortal } from "react-dom"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface FullScreenPanelProps { + isOpen: boolean; + title: string; + onClose: () => void; + children: React.ReactNode; + footer?: React.ReactNode; +} + +/** + * Reusable full-screen panel component + * Handles portal rendering, header with back button, and footer + * Uses solid theme colors without transparency + */ +export const FullScreenPanel: React.FC = ({ + isOpen, + title, + onClose, + children, + footer, +}) => { + React.useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } + return () => { + document.body.style.overflow = ""; + }; + }, [isOpen]); + + if (!isOpen) return null; + + return createPortal( +
+ {/* Header */} +
+
+
+ +

{title}

+
+
+ + {/* Content */} +
+
+ {children} +
+
+ + {/* Footer */} + {footer && ( +
+
+ {footer} +
+
+ )} +
, + document.body, + ); +}; diff --git a/src/components/env/EnvWarningBanner.tsx b/src/components/env/EnvWarningBanner.tsx index 76a167f..7a2ec5d 100644 --- a/src/components/env/EnvWarningBanner.tsx +++ b/src/components/env/EnvWarningBanner.tsx @@ -110,7 +110,7 @@ export function EnvWarningBanner({ return ( <> -
+
@@ -241,7 +241,7 @@ export function EnvWarningBanner({
- + diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 526051a..d75e5b3 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -1,25 +1,11 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo, useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { - Save, - Plus, - AlertCircle, - ChevronDown, - ChevronUp, - Wand2, -} from "lucide-react"; +import { Save, Plus, AlertCircle, ChevronDown, ChevronUp } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; +import JsonEditor from "@/components/JsonEditor"; import type { AppId } from "@/lib/api/types"; import { McpServer, McpServerSpec } from "@/types"; import { mcpPresets, getMcpPresetWithDescription } from "@/config/mcpPresets"; @@ -34,25 +20,21 @@ import { mcpServerToToml, } from "@/utils/tomlUtils"; import { normalizeTomlText } from "@/utils/textNormalization"; -import { formatJSON, parseSmartMcpJson } from "@/utils/formatters"; +import { parseSmartMcpJson } from "@/utils/formatters"; import { useMcpValidation } from "./useMcpValidation"; import { useUpsertMcpServer } from "@/hooks/useMcp"; +import { FullScreenPanel } from "@/components/common/FullScreenPanel"; interface McpFormModalProps { editingId?: string; initialData?: McpServer; - onSave: () => Promise; // v3.7.0: 简化为仅用于关闭表单的回调 + onSave: () => Promise; onClose: () => void; existingIds?: string[]; - defaultFormat?: "json" | "toml"; // 默认配置格式(可选,默认为 JSON) - defaultEnabledApps?: AppId[]; // 默认启用到哪些应用(可选,默认为全部应用) + defaultFormat?: "json" | "toml"; + defaultEnabledApps?: AppId[]; } -/** - * MCP 表单模态框组件(v3.7.0 完整重构版) - * - 支持 JSON 和 TOML 两种格式 - * - 统一管理,通过复选框选择启用到哪些应用 - */ const McpFormModal: React.FC = ({ editingId, initialData, @@ -79,7 +61,6 @@ const McpFormModal: React.FC = ({ const [formDocs, setFormDocs] = useState(initialData?.docs || ""); const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || ""); - // 启用状态:编辑模式使用现有值,新增模式使用默认值 const [enabledApps, setEnabledApps] = useState<{ claude: boolean; codex: boolean; @@ -88,7 +69,6 @@ const McpFormModal: React.FC = ({ if (initialData?.apps) { return { ...initialData.apps }; } - // 新增模式:根据 defaultEnabledApps 设置初始值 return { claude: defaultEnabledApps.includes("claude"), codex: defaultEnabledApps.includes("codex"), @@ -96,10 +76,8 @@ const McpFormModal: React.FC = ({ }; }); - // 编辑模式下禁止修改 ID const isEditing = !!editingId; - // 判断是否在编辑模式下有附加信息 const hasAdditionalInfo = !!( initialData?.description || initialData?.tags?.length || @@ -107,21 +85,17 @@ const McpFormModal: React.FC = ({ initialData?.docs ); - // 附加信息展开状态(编辑模式下有值时默认展开) const [showMetadata, setShowMetadata] = useState( isEditing ? hasAdditionalInfo : false, ); - // 配置格式:优先使用 defaultFormat,编辑模式下可从现有数据推断 const useTomlFormat = useMemo(() => { if (initialData?.server) { - // 编辑模式:尝试从现有数据推断格式(这里简化处理,默认 JSON) return defaultFormat === "toml"; } return defaultFormat === "toml"; }, [defaultFormat, initialData]); - // 根据格式决定初始配置 const [formConfig, setFormConfig] = useState(() => { const spec = initialData?.server; if (!spec) return ""; @@ -135,8 +109,23 @@ const McpFormModal: React.FC = ({ const [saving, setSaving] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false); const [idError, setIdError] = useState(""); + const [isDarkMode, setIsDarkMode] = useState(false); + + useEffect(() => { + setIsDarkMode(document.documentElement.classList.contains("dark")); + + const observer = new MutationObserver(() => { + setIsDarkMode(document.documentElement.classList.contains("dark")); + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + return () => observer.disconnect(); + }, []); - // 判断是否使用 TOML 格式(向后兼容,后续可扩展为格式切换按钮) const useToml = useTomlFormat; const wizardInitialSpec = useMemo(() => { @@ -164,7 +153,6 @@ const McpFormModal: React.FC = ({ } }, [formConfig, initialData, useToml]); - // 预设选择状态(仅新增模式显示;-1 表示自定义) const [selectedPreset, setSelectedPreset] = useState( isEditing ? null : -1, ); @@ -186,7 +174,6 @@ const McpFormModal: React.FC = ({ return `${candidate}-${i}`; }; - // 应用预设(写入表单但不落库) const applyPreset = (index: number) => { if (index < 0 || index >= mcpPresets.length) return; const preset = mcpPresets[index]; @@ -200,7 +187,6 @@ const McpFormModal: React.FC = ({ setFormDocs(presetWithDesc.docs || ""); setFormTags(presetWithDesc.tags?.join(", ") || ""); - // 根据格式转换配置 if (useToml) { const toml = mcpServerToToml(presetWithDesc.server); setFormConfig(toml); @@ -213,10 +199,8 @@ const McpFormModal: React.FC = ({ setSelectedPreset(index); }; - // 切回自定义 const applyCustom = () => { setSelectedPreset(-1); - // 恢复到空白模板 setFormId(""); setFormName(""); setFormDescription(""); @@ -228,19 +212,16 @@ const McpFormModal: React.FC = ({ }; const handleConfigChange = (value: string) => { - // 若为 TOML 模式,先做引号归一化,避免中文输入法导致的格式错误 const nextValue = useToml ? normalizeTomlText(value) : value; setFormConfig(nextValue); if (useToml) { - // TOML validation (use hook's complete validation) const err = validateTomlConfig(nextValue); if (err) { setConfigError(err); return; } - // Try to extract ID (if user hasn't filled it yet) if (nextValue.trim() && !formId.trim()) { const extractedId = extractIdFromToml(nextValue); if (extractedId) { @@ -248,11 +229,8 @@ const McpFormModal: React.FC = ({ } } } else { - // JSON validation with smart parsing try { const result = parseSmartMcpJson(value); - - // 验证解析后的配置对象 const configJson = JSON.stringify(result.config); const validationErr = validateJsonConfig(configJson); @@ -261,20 +239,15 @@ const McpFormModal: React.FC = ({ return; } - // 自动填充提取的 id(仅当表单 id 为空且不在编辑模式时) if (result.id && !formId.trim() && !isEditing) { const uniqueId = ensureUniqueId(result.id); setFormId(uniqueId); - // 如果 name 也为空,同时填充 name if (!formName.trim()) { setFormName(result.id); } } - // 不在输入时自动格式化,保持用户输入的原样 - // 格式清理将在提交时进行 - setConfigError(""); } catch (err: any) { const errorMessage = err?.message || String(err); @@ -283,30 +256,11 @@ const McpFormModal: React.FC = ({ } }; - const handleFormatJson = () => { - if (!formConfig.trim()) return; - - try { - const formatted = formatJSON(formConfig); - setFormConfig(formatted); - toast.success(t("common.formatSuccess")); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - toast.error( - t("common.formatError", { - error: errorMessage, - }), - ); - } - }; - const handleWizardApply = (title: string, json: string) => { setFormId(title); if (!formName.trim()) { setFormName(title); } - // Wizard returns JSON, convert based on format if needed if (useToml) { try { const server = JSON.parse(json) as McpServerSpec; @@ -329,17 +283,14 @@ const McpFormModal: React.FC = ({ return; } - // 新增模式:阻止提交重名 ID if (!isEditing && existingIds.includes(trimmedId)) { setIdError(t("mcp.error.idExists")); return; } - // Validate configuration format let serverSpec: McpServerSpec; if (useToml) { - // TOML mode const tomlError = validateTomlConfig(formConfig); setConfigError(tomlError); if (tomlError) { @@ -348,7 +299,6 @@ const McpFormModal: React.FC = ({ } if (!formConfig.trim()) { - // Empty configuration serverSpec = { type: "stdio", command: "", @@ -365,9 +315,7 @@ const McpFormModal: React.FC = ({ } } } else { - // JSON mode if (!formConfig.trim()) { - // Empty configuration serverSpec = { type: "stdio", command: "", @@ -375,7 +323,6 @@ const McpFormModal: React.FC = ({ }; } else { try { - // 使用智能解析器,支持带外层键的格式 const result = parseSmartMcpJson(formConfig); serverSpec = result.config as McpServerSpec; } catch (e: any) { @@ -387,7 +334,6 @@ const McpFormModal: React.FC = ({ } } - // 前置必填校验 if (serverSpec?.type === "stdio" && !serverSpec?.command?.trim()) { toast.error(t("mcp.error.commandRequired"), { duration: 3000 }); return; @@ -402,7 +348,6 @@ const McpFormModal: React.FC = ({ setSaving(true); try { - // 先处理 name 字段(必填) const nameTrimmed = (formName || trimmedId).trim(); const finalName = nameTrimmed || trimmedId; @@ -411,7 +356,6 @@ const McpFormModal: React.FC = ({ id: trimmedId, name: finalName, server: serverSpec, - // 使用表单中的启用状态(v3.7.0 完整重构) apps: enabledApps, }; @@ -446,10 +390,9 @@ const McpFormModal: React.FC = ({ delete entry.tags; } - // 保存到统一配置 await upsertMutation.mutateAsync(entry); toast.success(t("common.success")); - await onSave(); // 通知父组件关闭表单 + await onSave(); } catch (error: any) { const detail = extractErrorMessage(error); const mapped = translateMcpBackendError(detail, t); @@ -466,18 +409,33 @@ const McpFormModal: React.FC = ({ return ( <> - !open && onClose()}> - - - {getFormTitle()} - - - {/* Content - Scrollable */} -
+ + {isEditing ? : } + {saving + ? t("common.saving") + : isEditing + ? t("common.save") + : t("common.add")} + + } + > +
+ {/* 上半部分:表单字段 */} +
{/* 预设选择(仅新增时展示) */} {!isEditing && (
-
)} + {/* ID (标题) */}
-