2 Commits
v3.5.0 ... i18n

Author SHA1 Message Date
Jason
ea81c3d839 feat: improve i18n implementation with better translations and accessibility
- Add proper i18n keys for language switcher tooltips and aria-labels
- Replace hardcoded Chinese console error messages with i18n keys
- Add missing translation keys for new UI elements
- Improve accessibility with proper aria-label attributes
2025-09-28 20:35:14 +08:00
TinsFox
aa05a8475f feat: integrate i18next for internationalization support
- Added i18next and react-i18next dependencies for localization.
- Updated various components to utilize translation functions for user-facing text.
- Enhanced user experience by providing multilingual support across the application.
2025-09-28 18:23:23 +08:00
63 changed files with 1164 additions and 7755 deletions

View File

@@ -161,7 +161,6 @@ jobs:
run: |
set -euxo pipefail
mkdir -p release-assets
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
echo "Looking for updater artifact (.tar.gz) and .app for zip..."
TAR_GZ=""; APP_PATH=""
for path in \
@@ -178,18 +177,15 @@ jobs:
echo "No macOS .tar.gz updater artifact found" >&2
exit 1
fi
# 重命名 tar.gz 为统一格式
NEW_TAR_GZ="CC-Switch-${VERSION}-macOS.tar.gz"
cp "$TAR_GZ" "release-assets/$NEW_TAR_GZ"
[ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" "release-assets/$NEW_TAR_GZ.sig" || echo ".sig for macOS not found yet"
echo "macOS updater artifact copied: $NEW_TAR_GZ"
cp "$TAR_GZ" release-assets/
[ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" release-assets/ || echo ".sig for macOS not found yet"
echo "macOS updater artifact copied: $(basename "$TAR_GZ")"
if [ -n "$APP_PATH" ]; then
APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH")
NEW_ZIP="CC-Switch-${VERSION}-macOS.zip"
cd "$APP_DIR"
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "$NEW_ZIP"
mv "$NEW_ZIP" "$GITHUB_WORKSPACE/release-assets/"
echo "macOS zip ready: $NEW_ZIP"
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip"
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/"
echo "macOS zip ready: CC-Switch-macOS.zip"
else
echo "No .app found to zip (optional)" >&2
fi
@@ -200,7 +196,6 @@ jobs:
run: |
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
$VERSION = $env:GITHUB_REF_NAME # e.g., v3.5.0
# 仅打包 MSI 安装器 + .sig用于 Updater
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -eq $msi) {
@@ -208,7 +203,7 @@ jobs:
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
}
if ($null -ne $msi) {
$dest = "CC-Switch-$VERSION-Windows.msi"
$dest = 'CC-Switch-Setup.msi'
Copy-Item $msi.FullName (Join-Path release-assets $dest)
Write-Host "Installer copied: $dest"
$sigPath = "$($msi.FullName).sig"
@@ -237,10 +232,9 @@ jobs:
'portable=true'
)
$portableContent | Set-Content -Path $portableIniPath -Encoding UTF8
$portableZip = "release-assets/CC-Switch-$VERSION-Windows-Portable.zip"
Compress-Archive -Path "$portableDir/*" -DestinationPath $portableZip -Force
Compress-Archive -Path "$portableDir/*" -DestinationPath 'release-assets/CC-Switch-Windows-Portable.zip' -Force
Remove-Item -Recurse -Force $portableDir
Write-Host "Windows portable zip created: CC-Switch-$VERSION-Windows-Portable.zip"
Write-Host 'Windows portable zip created'
} else {
Write-Warning 'Portable exe not found'
}
@@ -251,23 +245,20 @@ jobs:
run: |
set -euxo pipefail
mkdir -p release-assets
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
# Updater artifact: AppImage含对应 .sig
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
if [ -n "$APPIMAGE" ]; then
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux.AppImage"
cp "$APPIMAGE" "release-assets/$NEW_APPIMAGE"
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" "release-assets/$NEW_APPIMAGE.sig" || echo ".sig for AppImage not found"
echo "AppImage copied: $NEW_APPIMAGE"
cp "$APPIMAGE" release-assets/
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" release-assets/ || echo ".sig for AppImage not found"
echo "AppImage copied"
else
echo "No AppImage found under target/release/bundle" >&2
fi
# 额外上传 .deb用于手动安装不参与 Updater
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
if [ -n "$DEB" ]; then
NEW_DEB="CC-Switch-${VERSION}-Linux.deb"
cp "$DEB" "release-assets/$NEW_DEB"
echo "Deb package copied: $NEW_DEB"
cp "$DEB" release-assets/
echo "Deb package copied"
else
echo "No .deb found (optional)"
fi
@@ -297,12 +288,12 @@ jobs:
### 下载
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`Homebrew
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)`CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`AppImage或 `CC-Switch-${{ github.ref_name }}-Linux.deb`Debian/Ubuntu
- macOS: `CC-Switch-macOS.zip`(解压即用
- Windows: `CC-Switch-Setup.msi`(安装版)`CC-Switch-Windows-Portable.zip`(绿色版)
- Linux: `*.deb`Debian/Ubuntu 安装包
---
提示macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
提示macOS 如遇已损坏提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
files: release-assets/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@@ -9,4 +9,3 @@ release/
.npmrc
CLAUDE.md
AGENTS.md
/.claude

View File

@@ -1 +0,0 @@
v22.4.1

View File

@@ -5,94 +5,21 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.5.0] - 2025-01-15
### ✨ New Features
- **MCP (Model Context Protocol) Management** - Complete MCP server configuration management system
- Add, edit, delete, and toggle MCP servers in `~/.claude.json`
- Support for stdio and http server types with command validation
- Built-in templates for popular MCP servers (mcp-fetch, etc.)
- Real-time enable/disable toggle for MCP servers
- Atomic file writing to prevent configuration corruption
- **Configuration Import/Export** - Backup and restore your provider configurations
- Export all configurations to JSON file with one click
- Import configurations with validation and automatic backup
- Automatic backup rotation (keeps 10 most recent backups)
- Progress modal with detailed status feedback
- **Endpoint Speed Testing** - Test API endpoint response times
- Measure latency to different provider endpoints
- Visual indicators for connection quality
- Help users choose the fastest provider
### 🔧 Improvements
- Complete internationalization (i18n) coverage for all UI components
- Enhanced error handling and user feedback throughout the application
- Improved configuration file management with better validation
- Added new provider presets: Longcat, kat-coder
- Updated GLM provider configurations with latest models
- Refined UI/UX with better spacing, icons, and visual feedback
- Enhanced tray menu functionality and responsiveness
- **Standardized release artifact naming** - All platform releases now use consistent version-tagged filenames:
- macOS: `CC-Switch-v{version}-macOS.tar.gz` / `.zip`
- Windows: `CC-Switch-v{version}-Windows.msi` / `-Portable.zip`
- Linux: `CC-Switch-v{version}-Linux.AppImage` / `.deb`
### 🐛 Bug Fixes
- Fixed layout shifts during provider switching
- Improved config file path handling across different platforms
- Better error messages for configuration validation failures
- Fixed various edge cases in configuration import/export
### 📦 Technical Details
- Enhanced `import_export.rs` module with backup management
- New `claude_mcp.rs` module for MCP configuration handling
- Improved state management and lock handling in Rust backend
- Better TypeScript type safety across the codebase
## [3.4.0] - 2025-10-01
### ✨ Features
- Enable internationalization via i18next with a Chinese default and English fallback, plus an in-app language switcher
- Add Claude plugin sync while retiring the legacy VS Code integration controls (Codex no longer requires settings.json edits)
- Extend provider presets with optional API key URLs and updated models, including DeepSeek-V3.1-Terminus and Qwen3-Max
- Support portable mode launches and enforce a single running instance to avoid conflicts
### 🔧 Improvements
- Allow minimizing the window to the system tray and add macOS Dock visibility management for tray workflows
- Refresh the Settings modal with a scrollable layout, save icon, and cleaner language section
- Smooth provider toggle states with consistent button widths/icons and prevent layout shifts when switching between Claude and Codex
- Adjust the Windows MSI installer to target per-user LocalAppData and improve component tracking reliability
### 🐛 Fixes
- Remove the unnecessary OpenAI auth requirement from third-party provider configurations
- Fix layout shifts while switching app types with Claude plugin sync enabled
- Align Enable/In Use button states to avoid visual jank across app views
## [3.3.0] - 2025-09-22
### ✨ Features
- Add “Apply to VS Code / Remove from VS Code” actions on provider cards, writing settings for Code/Insiders/VSCodium variants _(Removed in 3.4.x)_
- Enable VS Code auto-sync by default with window broadcast and tray hooks so Codex switches sync silently _(Removed in 3.4.x)_
- Add “Apply to VS Code / Remove from VS Code” actions on provider cards, writing settings for Code/Insiders/VSCodium variants
- Enable VS Code auto-sync by default with window broadcast and tray hooks so Codex switches sync silently
- Extend the Codex provider wizard with display name, dedicated API key URL, and clearer guidance
- Introduce shared common config snippets with JSON/TOML reuse, validation, and consistent error surfaces
### 🔧 Improvements
- Keep the tray menu responsive when the window is hidden and standardize button styling and copy
- Disable modal backdrop blur on Linux (WebKitGTK/Wayland) to avoid freezes; restore the window when clicking the macOS Dock icon
- Support overriding config directories on WSL, refine placeholders/descriptions, and fix VS Code button wrapping on Windows
- Add a `created_at` timestamp to provider records for future sorting and analytics
### 🐛 Fixes
- Correct regex escapes and common snippet trimming in the Codex wizard to prevent validation issues
- Harden the VS Code sync flow with more reliable TOML/JSON parsing while reducing layout jank
- Bundle `@codemirror/lint` to reinstate live linting in config editors
@@ -100,13 +27,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [3.2.0] - 2025-09-13
### ✨ New Features
- System tray provider switching with dynamic menu for Claude/Codex
- Frontend receives `provider-switched` events and refreshes active app
- Built-in update flow via Tauri Updater plugin with dismissible UpdateBadge
### 🔧 Improvements
- Single source of truth for provider configs; no duplicate copy files
- One-time migration imports existing copies into `config.json` and archives originals
- Duplicate provider de-duplication by name + API key at startup
@@ -115,35 +40,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Tailwind v4 integration and refined dark mode handling
### 🐛 Fixes
- Remove/minimize debug console logs in production builds
- Fix CSS minifier warnings for scrollbar pseudo-elements
- Prettier formatting across codebase for consistent style
### 📦 Dependencies
- Tauri: 2.8.x (core, updater, process, opener, log plugins)
- React: 18.2.x · TypeScript: 5.3.x · Vite: 5.x
### 🔄 Notes
- `connect-src` CSP remains permissive for compatibility; can be tightened later as needed
## [3.1.1] - 2025-09-03
### 🐛 Bug Fixes
- Fixed the default codex config.toml to match the latest modifications
- Improved provider configuration UX with custom option
### 📝 Documentation
- Updated README with latest information
## [3.1.0] - 2025-09-01
### ✨ New Features
- **Added Codex application support** - Now supports both Claude Code and Codex configuration management
- Manage auth.json and config.toml for Codex
- Support for backup and restore operations
@@ -160,14 +79,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- TOML syntax validation for config.toml
### 🔧 Technical Improvements
- Unified Tauri command API with app_type parameter
- Backward compatibility for app/appType parameters
- Added get_config_status/open_config_folder/open_external commands
- Improved error handling for empty config.toml
### 🐛 Bug Fixes
- Fixed config path reporting and folder opening for Codex
- Corrected default import behavior when main config is missing
- Fixed non_snake_case warnings in commands.rs
@@ -175,7 +92,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [3.0.0] - 2025-08-27
### 🚀 Major Changes
- **Complete migration from Electron to Tauri 2.0** - The application has been completely rewritten using Tauri, resulting in:
- **90% reduction in bundle size** (from ~150MB to ~15MB)
- **Significantly improved startup performance**
@@ -183,14 +99,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Enhanced security** with Rust backend
### ✨ New Features
- **Native window controls** with transparent title bar on macOS
- **Improved file system operations** using Rust for better performance
- **Enhanced security model** with explicit permission declarations
- **Better platform detection** using Tauri's native APIs
### 🔧 Technical Improvements
- Migrated from Electron IPC to Tauri command system
- Replaced Node.js file operations with Rust implementations
- Implemented proper CSP (Content Security Policy) for enhanced security
@@ -198,34 +112,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Integrated Rust cargo fmt and clippy for code quality
### 🐛 Bug Fixes
- Fixed bundle identifier conflict on macOS (changed from .app to .desktop)
- Resolved platform detection issues
- Improved error handling in configuration management
### 📦 Dependencies
- **Tauri**: 2.8.2
- **React**: 18.2.0
- **TypeScript**: 5.3.0
- **Vite**: 5.0.0
### 🔄 Migration Notes
For users upgrading from v2.x (Electron version):
- Configuration files remain compatible - no action required
- The app will automatically migrate your existing provider configurations
- Window position and size preferences have been reset to defaults
#### Backup on v1→v2 Migration (cc-switch internal config)
- When the app detects an old v1 config structure at `~/.cc-switch/config.json`, it now creates a timestamped backup before writing the new v2 structure.
- Backup location: `~/.cc-switch/config.v1.backup.<timestamp>.json`
- This only concerns cc-switch's own metadata file; your actual provider files under `~/.claude/` and `~/.codex/` are untouched.
### 🛠️ Development
- Added `pnpm typecheck` command for TypeScript validation
- Added `pnpm format` and `pnpm format:check` for code formatting
- Rust code now uses cargo fmt for consistent formatting
@@ -233,7 +141,6 @@ For users upgrading from v2.x (Electron version):
## [2.0.0] - Previous Electron Release
### Features
- Multi-provider configuration management
- Quick provider switching
- Import/export configurations
@@ -244,7 +151,6 @@ For users upgrading from v2.x (Electron version):
## [1.0.0] - Initial Release
### Features
- Basic provider management
- Claude Code integration
- Configuration file handling

View File

@@ -1,14 +1,12 @@
# Claude Code & Codex 供应商切换器
[![Version](https://img.shields.io/badge/version-3.5.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.3.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
> v3.4.0 新增 i18next 国际化还有部分未完成、对新模型qwen-3-max, GLM-4.6, DeepSeek-V3.2-Exp的支持、Claude 插件、单实例守护、托盘最小化及安装器优化
> v3.3.0 VS Code Codex 插件一键配置/移除默认自动同步、Codex 通用配置片段与自定义向导增强、WSL 环境支持、跨平台托盘与 UI 优化。(该 VS Code 写入功能已在 v3.4.x 停用)
> v3.3.0 VS Code Codex 插件一键配置/移除默认自动同步、Codex 通用配置片段与自定义向导增强、WSL 环境支持、跨平台托盘与 UI 优化。
> v3.2.0 :全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源SSOT与一次性迁移/归档。
@@ -16,15 +14,14 @@
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积显著降低、启动性能大幅提升。
## 功能特性v3.4.0
## 功能特性v3.3.0
- **国际化与语言切换**:内置 i18next默认显示中文可在设置中快速切换到英文界面文文案自动实时刷新。
- **Claude 插件同步**:内置按钮可一键应用或恢复 Claude 插件配置,切换供应商后立即生效。
- **VS Code Codex 设置停用**:由于新版 Codex 插件无需修改 `settings.json`,应用不再写入 VS Code 设置,避免潜在冲突。
- **供应商预设扩展**:新增 DeepSeek--V3.2-Exp、Qwen3-Max、GLM-4.6 等最新模型。
- **系统托盘与窗口行为**窗口关闭可最小化到托盘macOS 支持托盘模式下隐藏/显示 Dock托盘切换时同步 Claude/Codex/插件状态。
- **单实例**:保证同一时间仅运行一个实例,避免多开冲突。
- **UI 与安装体验优化**设置面板改为可滚动布局并加入保存图标按钮宽度与状态一致性加强Windows MSI 安装默认写入 per-user LocalAppData 并改进组件跟踪Windows 便携版现在指向最新 release 页面,不再自动更为为安装版。
- **VS Code Codex 插件一键配置**:供应商卡片支持「应用到 VS Code / 从 VS Code 移除」,默认开启自动同步,并可跨 Code / Insiders / VSCodium 写入 `settings.json`
- **通用配置片段**Claude 与 Codex 共用 JSON/TOML 片段,提供编辑器 lint、内容校验、统一错误提示与本地持久化
- **Codex 配置向导**:新增显示名称、专用 API Key URL、HTML5 校验与预设模板,方便快速配置第三方服务
- **系统托盘与快捷操作**:窗口隐藏时仍可通过托盘切换供应商,并在自动同步开启时触发 VS Code 写入
- **平台适配**:新增 Windows WSL 环境支持、Linux 自动禁用模态背景模糊解决白屏问题、macOS Dock 点击即可恢复窗口
- **UI优化**:多处 UI 和使用体验优化
## 界面预览
@@ -42,36 +39,21 @@
- **Windows**: Windows 10 及以上
- **macOS**: macOS 10.15 (Catalina) 及以上
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ 等主流发行版
- **Linux**: Ubuntu 20.04+ / Debian 11+ / Fedora 34+ 等主流发行版
### Windows 用户
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Windows.msi` 安装包或者 `CC-Switch-v{版本号}-Windows-Portable.zip` 绿色版。
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-Setup.msi` 安装包或者 `CC-Switch-Windows-Portable.zip` 绿色版。
### macOS 用户
**方式一:通过 Homebrew 安装(推荐)**
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
**方式二:手动下载**
从 [Releases](../../releases) 页面下载 `CC-Switch-v{版本号}-macOS.zip` 解压使用。
从 [Releases](../../releases) 页面下载 `CC-Switch-macOS.zip` 解压使用。
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### Linux 用户
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包。
从 [Releases](../../releases) 页面下载最新版本的 `.deb` 包或者 `AppImage`安装包。
## 使用说明

View File

@@ -1,9 +1,8 @@
- 自动升级自定义路径 ✅
- win 绿色版报毒问题 ✅
- mcp 管理器 ✅
- i18n ✅
- codex 更多预设供应商
- mcp 管理器
- i18n
- gemini cli
- homebrew 支持
- memory 管理
- codex 更多预设供应商
- 云同步
- 自定义 vscode 路径

View File

@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.5.0",
"version": "3.3.1",
"description": "Claude Code & Codex 供应商切换工具",
"scripts": {
"dev": "pnpm tauri dev",
@@ -31,7 +31,6 @@
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.2",
"smol-toml": "^1.4.2",
"@tailwindcss/vite": "^4.1.13",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0",

33
pnpm-lock.yaml generated
View File

@@ -59,9 +59,6 @@ importers:
react-i18next:
specifier: ^16.0.0
version: 16.0.0(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2)
smol-toml:
specifier: ^1.4.2
version: 1.4.2
tailwindcss:
specifier: ^4.1.13
version: 4.1.13
@@ -424,67 +421,56 @@ packages:
resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.46.2':
resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.46.2':
resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.46.2':
resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.46.2':
resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.46.2':
resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.46.2':
resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.46.2':
resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.46.2':
resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.46.2':
resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.46.2':
resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.46.2':
resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==}
@@ -539,28 +525,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.13':
resolution: {integrity: sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.13':
resolution: {integrity: sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.13':
resolution: {integrity: sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.13':
resolution: {integrity: sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==}
@@ -621,35 +603,30 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.8.1':
resolution: {integrity: sha512-VK/zwBzQY9SfyK7RSrxlIRQLJyhyssoByYWPK/FJMre8SV/y8zZ071cTQNG9dPWM1f+onI1WPTleG+TBUq/0Gw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.8.1':
resolution: {integrity: sha512-bFw3zK6xkyurDR5kw2QgiU6YFlFNrfgtli3wRdTRv8zSVLZMQ2iZ8keYnd57vpvsbZ9PusFPYAMS7Fkzkf9I4g==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.8.1':
resolution: {integrity: sha512-zOnFX+Rppuz0UVVSeCi67lMet8le+yT4UIiQ6t/QYGtpoWO/D4GpMoVYehJlR14klNXrC2CRxT9b3BUWTCEBwA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.8.1':
resolution: {integrity: sha512-gLy6eisaeOTC6NQirs3a0XZNCVT/i7JPYHkXx6ArH6+Kb9IU8ogthTY4MQoYbkWmdOp3ijKX+RT1dD3IZURrEg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.8.1':
resolution: {integrity: sha512-ciZ93Dm847zFDqRyc1e0YRiu/cdWne1bMhvifcZOibbyqSKB9o+b95Y5axMtXqR4Wsd2mHiC5TE+MVF3NDsdEw==}
@@ -843,28 +820,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
@@ -974,10 +947,6 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
smol-toml@1.4.2:
resolution: {integrity: sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==}
engines: {node: '>= 18'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1824,8 +1793,6 @@ snapshots:
semver@6.3.1: {}
smol-toml@1.4.2: {}
source-map-js@1.2.1: {}
style-mod@4.1.2: {}

289
src-tauri/Cargo.lock generated
View File

@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "addr2line"
version = "0.25.1"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
dependencies = [
"gimli",
]
@@ -173,7 +173,7 @@ dependencies = [
"polling",
"rustix",
"slab",
"windows-sys 0.61.1",
"windows-sys 0.61.0",
]
[[package]]
@@ -231,7 +231,7 @@ dependencies = [
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.1",
"windows-sys 0.61.0",
]
[[package]]
@@ -288,9 +288,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "backtrace"
version = "0.3.76"
version = "0.3.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002"
dependencies = [
"addr2line",
"cfg-if",
@@ -298,7 +298,7 @@ dependencies = [
"miniz_oxide",
"object",
"rustc-demangle",
"windows-link 0.2.0",
"windows-targets 0.52.6",
]
[[package]]
@@ -465,9 +465,9 @@ dependencies = [
[[package]]
name = "bytemuck"
version = "1.24.0"
version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677"
[[package]]
name = "byteorder"
@@ -511,9 +511,9 @@ dependencies = [
[[package]]
name = "camino"
version = "1.2.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609"
checksum = "e1de8bc0aa9e9385ceb3bf0c152e3a9b9544f6c4a912c8ae504e80c1f0368603"
dependencies = [
"serde_core",
]
@@ -538,7 +538,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"thiserror 2.0.17",
"thiserror 2.0.16",
]
[[package]]
@@ -553,9 +553,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.40"
version = "1.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb"
checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -563,15 +563,12 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.5.0"
version = "3.3.1"
dependencies = [
"chrono",
"dirs 5.0.1",
"futures",
"log",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"reqwest",
"serde",
"serde_json",
"tauri",
@@ -582,7 +579,6 @@ dependencies = [
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tokio",
"toml 0.8.2",
]
@@ -632,10 +628,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.0",
]
@@ -831,12 +825,12 @@ dependencies = [
[[package]]
name = "deranged"
version = "0.5.4"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071"
checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc"
dependencies = [
"powerfmt",
"serde_core",
"serde",
]
[[package]]
@@ -912,7 +906,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.1",
"windows-sys 0.61.0",
]
[[package]]
@@ -1099,7 +1093,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.1",
"windows-sys 0.61.0",
]
[[package]]
@@ -1171,9 +1165,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.3"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3"
checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959"
[[package]]
name = "flate2"
@@ -1243,21 +1237,6 @@ dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
@@ -1265,7 +1244,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -1333,7 +1311,6 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -1503,9 +1480,9 @@ dependencies = [
[[package]]
name = "gimli"
version = "0.32.3"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "gio"
@@ -1820,7 +1797,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.1",
"windows-core 0.62.0",
]
[[package]]
@@ -2086,9 +2063,9 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "js-sys"
version = "0.3.81"
version = "0.3.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e"
dependencies = [
"once_cell",
"wasm-bindgen",
@@ -2171,9 +2148,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.176"
version = "0.2.175"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
[[package]]
name = "libloading"
@@ -2220,10 +2197,11 @@ checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
[[package]]
name = "lock_api"
version = "0.4.14"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765"
dependencies = [
"autocfg",
"scopeguard",
]
@@ -2281,9 +2259,9 @@ checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "memchr"
version = "2.7.6"
version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "memoffset"
@@ -2344,7 +2322,7 @@ dependencies = [
"once_cell",
"png",
"serde",
"thiserror 2.0.17",
"thiserror 2.0.16",
"windows-sys 0.60.2",
]
@@ -2740,9 +2718,9 @@ dependencies = [
[[package]]
name = "object"
version = "0.37.3"
version = "0.36.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [
"memchr",
]
@@ -2792,7 +2770,7 @@ dependencies = [
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.17",
"thiserror 2.0.16",
]
[[package]]
@@ -2828,9 +2806,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13"
dependencies = [
"lock_api",
"parking_lot_core",
@@ -2838,15 +2816,15 @@ dependencies = [
[[package]]
name = "parking_lot_core"
version = "0.9.12"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link 0.2.0",
"windows-targets 0.52.6",
]
[[package]]
@@ -3061,7 +3039,7 @@ dependencies = [
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.1",
"windows-sys 0.61.0",
]
[[package]]
@@ -3214,7 +3192,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.17",
"thiserror 2.0.16",
"tokio",
"tracing",
"web-time",
@@ -3235,7 +3213,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.17",
"thiserror 2.0.16",
"tinyvec",
"tracing",
"web-time",
@@ -3257,9 +3235,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.41"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
@@ -3420,23 +3398,23 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 2.0.17",
"thiserror 2.0.16",
]
[[package]]
name = "ref-cast"
version = "1.0.25"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.25"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7"
dependencies = [
"proc-macro2",
"quote",
@@ -3445,9 +3423,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.11.3"
version = "1.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c"
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
dependencies = [
"aho-corasick",
"memchr",
@@ -3457,9 +3435,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.11"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad"
checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
dependencies = [
"aho-corasick",
"memchr",
@@ -3637,7 +3615,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.1",
"windows-sys 0.61.0",
]
[[package]]
@@ -3666,9 +3644,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.7"
version = "0.103.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf"
checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb"
dependencies = [
"ring",
"rustls-pki-types",
@@ -3795,9 +3773,9 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.228"
version = "1.0.226"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd"
dependencies = [
"serde_core",
"serde_derive",
@@ -3817,18 +3795,18 @@ dependencies = [
[[package]]
name = "serde_core"
version = "1.0.228"
version = "1.0.226"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
version = "1.0.226"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33"
dependencies = [
"proc-macro2",
"quote",
@@ -3902,9 +3880,9 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.15.0"
version = "3.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6093cd8c01b25262b84927e0f7151692158fab02d961e04c979d3903eba7ecc5"
checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -3913,7 +3891,8 @@ dependencies = [
"indexmap 2.11.4",
"schemars 0.9.0",
"schemars 1.0.4",
"serde_core",
"serde",
"serde_derive",
"serde_json",
"serde_with_macros",
"time",
@@ -3921,9 +3900,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.15.0"
version = "3.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7e6c180db0816026a61afa1cff5344fb7ebded7e4d3062772179f2501481c27"
checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e"
dependencies = [
"darling",
"proc-macro2",
@@ -4313,7 +4292,7 @@ dependencies = [
"tauri-runtime",
"tauri-runtime-wry",
"tauri-utils",
"thiserror 2.0.17",
"thiserror 2.0.16",
"tokio",
"tray-icon",
"url",
@@ -4366,7 +4345,7 @@ dependencies = [
"sha2",
"syn 2.0.106",
"tauri-utils",
"thiserror 2.0.17",
"thiserror 2.0.16",
"time",
"url",
"uuid",
@@ -4418,7 +4397,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.17",
"thiserror 2.0.16",
"url",
]
@@ -4439,7 +4418,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.17",
"thiserror 2.0.16",
"toml 0.9.7",
"url",
]
@@ -4462,7 +4441,7 @@ dependencies = [
"swift-rs",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
"thiserror 2.0.16",
"time",
]
@@ -4482,7 +4461,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
"thiserror 2.0.16",
"url",
"windows",
"zbus",
@@ -4507,7 +4486,7 @@ dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.17",
"thiserror 2.0.16",
"tracing",
"windows-sys 0.60.2",
"zbus",
@@ -4537,7 +4516,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.17",
"thiserror 2.0.16",
"time",
"tokio",
"url",
@@ -4563,7 +4542,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"thiserror 2.0.17",
"thiserror 2.0.16",
"url",
"webkit2gtk",
"webview2-com",
@@ -4627,7 +4606,7 @@ dependencies = [
"serde_json",
"serde_with",
"swift-rs",
"thiserror 2.0.17",
"thiserror 2.0.16",
"toml 0.9.7",
"url",
"urlpattern",
@@ -4647,15 +4626,15 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.23.0"
version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53"
dependencies = [
"fastrand",
"getrandom 0.3.3",
"once_cell",
"rustix",
"windows-sys 0.61.1",
"windows-sys 0.61.0",
]
[[package]]
@@ -4680,11 +4659,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.17"
version = "2.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0"
dependencies = [
"thiserror-impl 2.0.17",
"thiserror-impl 2.0.16",
]
[[package]]
@@ -4700,9 +4679,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.17"
version = "2.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960"
dependencies = [
"proc-macro2",
"quote",
@@ -4782,27 +4761,15 @@ dependencies = [
"signal-hook-registry",
"slab",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.59.0",
]
[[package]]
name = "tokio-macros"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd"
dependencies = [
"rustls",
"tokio",
@@ -5011,7 +4978,7 @@ dependencies = [
"once_cell",
"png",
"serde",
"thiserror 2.0.17",
"thiserror 2.0.16",
"windows-sys 0.59.0",
]
@@ -5029,9 +4996,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.19.0"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "uds_windows"
@@ -5246,9 +5213,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.104"
version = "0.2.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819"
dependencies = [
"cfg-if",
"once_cell",
@@ -5259,9 +5226,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.104"
version = "0.2.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c"
dependencies = [
"bumpalo",
"log",
@@ -5273,9 +5240,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.54"
version = "0.4.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c"
checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67"
dependencies = [
"cfg-if",
"js-sys",
@@ -5286,9 +5253,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.104"
version = "0.2.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5296,9 +5263,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.104"
version = "0.2.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32"
dependencies = [
"proc-macro2",
"quote",
@@ -5309,9 +5276,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.104"
version = "0.2.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf"
dependencies = [
"unicode-ident",
]
@@ -5391,9 +5358,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.81"
version = "0.3.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120"
checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5493,7 +5460,7 @@ version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c"
dependencies = [
"thiserror 2.0.17",
"thiserror 2.0.16",
"windows",
"windows-core 0.61.2",
]
@@ -5520,7 +5487,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.1",
"windows-sys 0.61.0",
]
[[package]]
@@ -5581,9 +5548,9 @@ dependencies = [
[[package]]
name = "windows-core"
version = "0.62.1"
version = "0.62.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9"
checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c"
dependencies = [
"windows-implement",
"windows-interface",
@@ -5605,9 +5572,9 @@ dependencies = [
[[package]]
name = "windows-implement"
version = "0.60.1"
version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0"
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [
"proc-macro2",
"quote",
@@ -5616,9 +5583,9 @@ dependencies = [
[[package]]
name = "windows-interface"
version = "0.59.2"
version = "0.59.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5"
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
dependencies = [
"proc-macro2",
"quote",
@@ -5725,14 +5692,14 @@ version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.4",
"windows-targets 0.53.3",
]
[[package]]
name = "windows-sys"
version = "0.61.1"
version = "0.61.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f"
checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa"
dependencies = [
"windows-link 0.2.0",
]
@@ -5785,11 +5752,11 @@ dependencies = [
[[package]]
name = "windows-targets"
version = "0.53.4"
version = "0.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b"
checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91"
dependencies = [
"windows-link 0.2.0",
"windows-link 0.1.3",
"windows_aarch64_gnullvm 0.53.0",
"windows_aarch64_msvc 0.53.0",
"windows_i686_gnu 0.53.0",
@@ -5811,9 +5778,9 @@ dependencies = [
[[package]]
name = "windows-version"
version = "0.1.6"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "700dad7c058606087f6fdc1f88da5841e06da40334413c6cd4367b25ef26d24e"
checksum = "69e061eb0a22b4a1d778ad70f7575ec7845490abb35b08fa320df7895882cacb"
dependencies = [
"windows-link 0.2.0",
]
@@ -6072,7 +6039,7 @@ dependencies = [
"sha2",
"soup3",
"tao-macros",
"thiserror 2.0.17",
"thiserror 2.0.16",
"url",
"webkit2gtk",
"webkit2gtk-sys",
@@ -6251,9 +6218,9 @@ dependencies = [
[[package]]
name = "zeroize"
version = "1.8.2"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
[[package]]
name = "zerotrie"

View File

@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.5.0"
version = "3.3.1"
description = "Claude Code & Codex 供应商配置管理工具"
authors = ["Jason Young"]
license = "MIT"
@@ -21,7 +21,6 @@ tauri-build = { version = "2.4.0", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
chrono = "0.4"
tauri = { version = "2.8.2", features = ["tray-icon"] }
tauri-plugin-log = "2"
tauri-plugin-opener = "2"
@@ -30,9 +29,6 @@ tauri-plugin-updater = "2"
tauri-plugin-dialog = "2"
dirs = "5.0"
toml = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
futures = "0.3"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"

View File

@@ -1,23 +1,6 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// MCP 配置单客户端维度claude 或 codex 下的一组服务器)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpConfig {
/// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段)
#[serde(default)]
pub servers: HashMap<String, serde_json::Value>,
}
/// MCP 根:按客户端分开维护(无历史兼容压力,直接以 v2 结构落地)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpRoot {
#[serde(default)]
pub claude: McpConfig,
#[serde(default)]
pub codex: McpConfig,
}
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
use crate::provider::ProviderManager;
@@ -52,12 +35,8 @@ impl From<&str> for AppType {
pub struct MultiAppConfig {
#[serde(default = "default_version")]
pub version: u32,
/// 应用管理器claude/codex
#[serde(flatten)]
pub apps: HashMap<String, ProviderManager>,
/// MCP 配置(按客户端分治)
#[serde(default)]
pub mcp: McpRoot,
}
fn default_version() -> u32 {
@@ -70,11 +49,7 @@ impl Default for MultiAppConfig {
apps.insert("claude".to_string(), ProviderManager::default());
apps.insert("codex".to_string(), ProviderManager::default());
Self {
version: 2,
apps,
mcp: McpRoot::default(),
}
Self { version: 2, apps }
}
}
@@ -101,11 +76,7 @@ impl MultiAppConfig {
apps.insert("claude".to_string(), v1_config);
apps.insert("codex".to_string(), ProviderManager::default());
let config = Self {
version: 2,
apps,
mcp: McpRoot::default(),
};
let config = Self { version: 2, apps };
// 迁移前备份旧版(v1)配置文件
let backup_dir = get_app_config_dir();
@@ -165,20 +136,4 @@ impl MultiAppConfig {
.insert(app.as_str().to_string(), ProviderManager::default());
}
}
/// 获取指定客户端的 MCP 配置(不可变引用)
pub fn mcp_for(&self, app: &AppType) -> &McpConfig {
match app {
AppType::Claude => &self.mcp.claude,
AppType::Codex => &self.mcp.codex,
}
}
/// 获取指定客户端的 MCP 配置(可变引用)
pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {
match app {
AppType::Claude => &mut self.mcp.claude,
AppType::Codex => &mut self.mcp.codex,
}
}
}

View File

@@ -1,239 +0,0 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::atomic_write;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpStatus {
pub user_config_path: String,
pub user_config_exists: bool,
pub server_count: usize,
}
fn user_config_path() -> PathBuf {
// 用户级 MCP 配置文件:~/.claude.json
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".claude.json")
}
fn read_json_value(path: &Path) -> Result<Value, String> {
if !path.exists() {
return Ok(serde_json::json!({}));
}
let content =
fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?;
let value: Value = serde_json::from_str(&content)
.map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))?;
Ok(value)
}
fn write_json_value(path: &Path, value: &Value) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
}
let json =
serde_json::to_string_pretty(value).map_err(|e| format!("序列化 JSON 失败: {}", e))?;
atomic_write(path, json.as_bytes())
}
pub fn get_mcp_status() -> Result<McpStatus, String> {
let path = user_config_path();
let (exists, count) = if path.exists() {
let v = read_json_value(&path)?;
let servers = v.get("mcpServers").and_then(|x| x.as_object());
(true, servers.map(|m| m.len()).unwrap_or(0))
} else {
(false, 0)
};
Ok(McpStatus {
user_config_path: path.to_string_lossy().to_string(),
user_config_exists: exists,
server_count: count,
})
}
pub fn read_mcp_json() -> Result<Option<String>, String> {
let path = user_config_path();
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path).map_err(|e| format!("读取 MCP 配置失败: {}", e))?;
Ok(Some(content))
}
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, String> {
if id.trim().is_empty() {
return Err("MCP 服务器 ID 不能为空".into());
}
// 基础字段校验(尽量宽松)
if !spec.is_object() {
return Err("MCP 服务器定义必须为 JSON 对象".into());
}
let t_opt = spec.get("type").and_then(|x| x.as_str());
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理)
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
if !(is_stdio || is_http) {
return Err("MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio".into());
}
// stdio 类型必须有 command
if is_stdio {
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
if cmd.is_empty() {
return Err("stdio 类型的 MCP 服务器缺少 command 字段".into());
}
}
// http 类型必须有 url
if is_http {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.is_empty() {
return Err("http 类型的 MCP 服务器缺少 url 字段".into());
}
}
let path = user_config_path();
let mut root = if path.exists() {
read_json_value(&path)?
} else {
serde_json::json!({})
};
// 确保 mcpServers 对象存在
{
let obj = root
.as_object_mut()
.ok_or_else(|| "mcp.json 根必须是对象".to_string())?;
if !obj.contains_key("mcpServers") {
obj.insert("mcpServers".into(), serde_json::json!({}));
}
}
let before = root.clone();
if let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
servers.insert(id.to_string(), spec);
}
if before == root && path.exists() {
return Ok(false);
}
write_json_value(&path, &root)?;
Ok(true)
}
pub fn delete_mcp_server(id: &str) -> Result<bool, String> {
if id.trim().is_empty() {
return Err("MCP 服务器 ID 不能为空".into());
}
let path = user_config_path();
if !path.exists() {
return Ok(false);
}
let mut root = read_json_value(&path)?;
let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) else {
return Ok(false);
};
let existed = servers.remove(id).is_some();
if !existed {
return Ok(false);
}
write_json_value(&path, &root)?;
Ok(true)
}
pub fn validate_command_in_path(cmd: &str) -> Result<bool, String> {
if cmd.trim().is_empty() {
return Ok(false);
}
// 如果包含路径分隔符,直接判断是否存在可执行文件
if cmd.contains('/') || cmd.contains('\\') {
return Ok(Path::new(cmd).exists());
}
let path_var = env::var_os("PATH").unwrap_or_default();
let paths = env::split_paths(&path_var);
#[cfg(windows)]
let exts: Vec<String> = env::var("PATHEXT")
.unwrap_or(".COM;.EXE;.BAT;.CMD".into())
.split(';')
.map(|s| s.trim().to_uppercase())
.collect();
for p in paths {
let candidate = p.join(cmd);
if candidate.is_file() {
return Ok(true);
}
#[cfg(windows)]
{
for ext in &exts {
let cand = p.join(format!("{}{}", cmd, ext));
if cand.is_file() {
return Ok(true);
}
}
}
}
Ok(false)
}
/// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段
/// 仅覆盖 mcpServers其他字段保持不变
pub fn set_mcp_servers_map(
servers: &std::collections::HashMap<String, Value>,
) -> Result<(), String> {
let path = user_config_path();
let mut root = if path.exists() {
read_json_value(&path)?
} else {
serde_json::json!({})
};
// 构建 mcpServers 对象:移除 UI 辅助字段enabled/source仅保留实际 MCP 规范
let mut out: Map<String, Value> = Map::new();
for (id, spec) in servers.iter() {
let mut obj = if let Some(map) = spec.as_object() {
map.clone()
} else {
return Err(format!("MCP 服务器 '{}' 不是对象", id));
};
if let Some(server_val) = obj.remove("server") {
let server_obj = server_val
.as_object()
.cloned()
.ok_or_else(|| format!("MCP 服务器 '{}' server 字段不是对象", id))?;
obj = server_obj;
}
obj.remove("enabled");
obj.remove("source");
obj.remove("id");
obj.remove("name");
obj.remove("description");
obj.remove("tags");
obj.remove("homepage");
obj.remove("docs");
out.insert(id.clone(), Value::Object(obj));
}
{
let obj = root
.as_object_mut()
.ok_or_else(|| "~/.claude.json 根必须是对象".to_string())?;
obj.insert("mcpServers".into(), Value::Object(out));
}
write_json_value(&path, &root)?;
Ok(())
}

View File

@@ -1,132 +0,0 @@
use std::fs;
use std::path::PathBuf;
const CLAUDE_DIR: &str = ".claude";
const CLAUDE_CONFIG_FILE: &str = "config.json";
fn claude_dir() -> Result<PathBuf, String> {
// 优先使用设置中的覆盖目录
if let Some(dir) = crate::settings::get_claude_override_dir() {
return Ok(dir);
}
let home = dirs::home_dir().ok_or_else(|| "无法获取用户主目录".to_string())?;
Ok(home.join(CLAUDE_DIR))
}
pub fn claude_config_path() -> Result<PathBuf, String> {
Ok(claude_dir()?.join(CLAUDE_CONFIG_FILE))
}
pub fn ensure_claude_dir_exists() -> Result<PathBuf, String> {
let dir = claude_dir()?;
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("创建 Claude 配置目录失败: {}", e))?;
}
Ok(dir)
}
pub fn read_claude_config() -> Result<Option<String>, String> {
let path = claude_config_path()?;
if path.exists() {
let content =
fs::read_to_string(&path).map_err(|e| format!("读取 Claude 配置失败: {}", e))?;
Ok(Some(content))
} else {
Ok(None)
}
}
fn is_managed_config(content: &str) -> bool {
match serde_json::from_str::<serde_json::Value>(content) {
Ok(value) => value
.get("primaryApiKey")
.and_then(|v| v.as_str())
.map(|val| val == "any")
.unwrap_or(false),
Err(_) => false,
}
}
pub fn write_claude_config() -> Result<bool, String> {
// 增量写入:仅设置 primaryApiKey = "any",保留其它字段
let path = claude_config_path()?;
ensure_claude_dir_exists()?;
// 尝试读取并解析为对象
let mut obj = match read_claude_config()? {
Some(existing) => match serde_json::from_str::<serde_json::Value>(&existing) {
Ok(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
_ => serde_json::json!({}),
},
None => serde_json::json!({}),
};
let mut changed = false;
if let Some(map) = obj.as_object_mut() {
let cur = map
.get("primaryApiKey")
.and_then(|v| v.as_str())
.unwrap_or("");
if cur != "any" {
map.insert(
"primaryApiKey".to_string(),
serde_json::Value::String("any".to_string()),
);
changed = true;
}
}
if changed || !path.exists() {
let serialized = serde_json::to_string_pretty(&obj)
.map_err(|e| format!("序列化 Claude 配置失败: {}", e))?;
fs::write(&path, format!("{}\n", serialized))
.map_err(|e| format!("写入 Claude 配置失败: {}", e))?;
Ok(true)
} else {
Ok(false)
}
}
pub fn clear_claude_config() -> Result<bool, String> {
let path = claude_config_path()?;
if !path.exists() {
return Ok(false);
}
let content = match read_claude_config()? {
Some(content) => content,
None => return Ok(false),
};
let mut value = match serde_json::from_str::<serde_json::Value>(&content) {
Ok(value) => value,
Err(_) => return Ok(false),
};
let obj = match value.as_object_mut() {
Some(obj) => obj,
None => return Ok(false),
};
if obj.remove("primaryApiKey").is_none() {
return Ok(false);
}
let serialized = serde_json::to_string_pretty(&value)
.map_err(|e| format!("序列化 Claude 配置失败: {}", e))?;
fs::write(&path, format!("{}\n", serialized))
.map_err(|e| format!("写入 Claude 配置失败: {}", e))?;
Ok(true)
}
pub fn claude_config_status() -> Result<(bool, PathBuf), String> {
let path = claude_config_path()?;
Ok((path.exists(), path))
}
pub fn is_claude_config_applied() -> Result<bool, String> {
match read_claude_config()? {
Some(content) => Ok(is_managed_config(&content)),
None => Ok(false),
}
}

View File

@@ -33,7 +33,7 @@ pub fn get_codex_provider_paths(
provider_name: Option<&str>,
) -> (PathBuf, PathBuf) {
let base_name = provider_name
.map(sanitize_provider_name)
.map(|name| sanitize_provider_name(name))
.unwrap_or_else(|| sanitize_provider_name(provider_id));
let auth_path = get_codex_config_dir().join(format!("auth-{}.json", base_name));
@@ -60,27 +60,20 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R
let config_path = get_codex_config_path();
if let Some(parent) = auth_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("创建 Codex 目录失败: {}: {}", parent.display(), e))?;
std::fs::create_dir_all(parent).map_err(|e| format!("创建 Codex 目录失败: {}", e))?;
}
// 读取旧内容用于回滚
let old_auth = if auth_path.exists() {
Some(
fs::read(&auth_path)
.map_err(|e| format!("读取旧 auth.json 失败: {}: {}", auth_path.display(), e))?,
)
Some(fs::read(&auth_path).map_err(|e| format!("读取旧 auth.json 失败: {}", e))?)
} else {
None
};
let _old_config = if config_path.exists() {
Some(fs::read(&config_path).map_err(|e| format!("读取旧 config.toml 失败: {}", e))?)
} else {
None
};
let _old_config =
if config_path.exists() {
Some(fs::read(&config_path).map_err(|e| {
format!("读取旧 config.toml 失败: {}: {}", config_path.display(), e)
})?)
} else {
None
};
// 准备写入内容
let cfg_text = match config_text_opt {
@@ -88,13 +81,8 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R
None => String::new(),
};
if !cfg_text.trim().is_empty() {
toml::from_str::<toml::Table>(&cfg_text).map_err(|e| {
format!(
"config.toml 语法错误: {} (路径: {})",
e,
config_path.display()
)
})?;
toml::from_str::<toml::Table>(&cfg_text)
.map_err(|e| format!("config.toml 格式错误: {}", e))?;
}
// 第一步:写 auth.json

View File

@@ -6,13 +6,11 @@ use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::claude_mcp;
use crate::claude_plugin;
use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::provider::{Provider, ProviderMeta};
use crate::speedtest;
use crate::provider::Provider;
use crate::store::AppState;
use crate::vscode;
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
match app_type {
@@ -217,7 +215,7 @@ pub async fn update_provider(
}
}
// 更新内存并保存(保留/合并已有的 meta.custom_endpoints避免丢失在编辑流程中新增的自定义端点
// 更新内存并保存
{
let mut config = state
.config
@@ -226,43 +224,9 @@ pub async fn update_provider(
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
// 若已存在旧供应商,合并其 meta尤其是 custom_endpoints到新对象
let merged_provider = if let Some(existing) = manager.providers.get(&provider.id) {
// 克隆入参作为基准
let mut updated = provider.clone();
match (existing.meta.as_ref(), updated.meta.take()) {
// 入参未携带 meta直接沿用旧 meta
(Some(old_meta), None) => {
updated.meta = Some(old_meta.clone());
}
// 入参携带 meta与旧 meta 合并(以旧值为准,保留新增项)
(Some(old_meta), Some(mut new_meta)) => {
// 合并 custom_endpointsURL 去重,保留旧端点的时间信息,补充新增端点)
let mut merged_map = old_meta.custom_endpoints.clone();
for (url, ep) in new_meta.custom_endpoints.drain() {
merged_map.entry(url).or_insert(ep);
}
updated.meta = Some(crate::provider::ProviderMeta {
custom_endpoints: merged_map,
});
}
// 旧 meta 不存在:使用入参(可能为 None
(None, maybe_new) => {
updated.meta = maybe_new;
}
}
updated
} else {
// 不存在旧供应商(理论上不应发生,因为前面已校验 exists
provider.clone()
};
manager
.providers
.insert(merged_provider.id.clone(), merged_provider);
.insert(provider.id.clone(), provider.clone());
}
state.save()?;
@@ -348,20 +312,16 @@ pub async fn switch_provider(
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
// 为避免长期可变借用,尽快获取必要数据并缩小借用范围
let provider = {
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
// 检查供应商是否存在
let provider = manager
.providers
.get(&id)
.ok_or_else(|| format!("供应商不存在: {}", id))?
.clone();
provider
};
// 检查供应商是否存在
let provider = manager
.providers
.get(&id)
.ok_or_else(|| format!("供应商不存在: {}", id))?
.clone();
// SSOT 切换:先回填 live 配置到当前供应商,然后从内存写入目标主配置
match app_type {
@@ -369,20 +329,14 @@ pub async fn switch_provider(
use serde_json::Value;
// 回填:读取 liveauth.json + config.toml写回当前供应商 settings_config
if !{
let cur = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
cur.current.is_empty()
} {
if !manager.current.is_empty() {
let auth_path = codex_config::get_codex_auth_path();
let config_path = codex_config::get_codex_config_path();
if auth_path.exists() {
let auth: Value = crate::config::read_json_file(&auth_path)?;
let config_str = if config_path.exists() {
std::fs::read_to_string(&config_path).map_err(|e| {
format!("读取 config.toml 失败: {}: {}", config_path.display(), e)
})?
std::fs::read_to_string(&config_path)
.map_err(|e| format!("读取 config.toml 失败: {}", e))?
} else {
String::new()
};
@@ -392,16 +346,7 @@ pub async fn switch_provider(
"config": config_str,
});
let cur_id2 = {
let m = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
m.current.clone()
};
let m = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(cur) = m.providers.get_mut(&cur_id2) {
if let Some(cur) = manager.providers.get_mut(&manager.current) {
cur.settings_config = live;
}
}
@@ -424,21 +369,10 @@ pub async fn switch_provider(
let settings_path = get_claude_settings_path();
// 回填:读取 live settings.json 写回当前供应商 settings_config
if settings_path.exists() {
let cur_id = {
let m = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
m.current.clone()
};
if !cur_id.is_empty() {
if let Ok(live) = read_json_file::<serde_json::Value>(&settings_path) {
let m = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(cur) = m.providers.get_mut(&cur_id) {
cur.settings_config = live;
}
if settings_path.exists() && !manager.current.is_empty() {
if let Ok(live) = read_json_file::<serde_json::Value>(&settings_path) {
if let Some(cur) = manager.providers.get_mut(&manager.current) {
cur.settings_config = live;
}
}
}
@@ -450,56 +384,11 @@ pub async fn switch_provider(
// 不做归档,直接写入
write_json_file(&settings_path, &provider.settings_config)?;
// 写入后回读 live并回填到目标供应商的 SSOT保证一致
if settings_path.exists() {
if let Ok(live_after) = read_json_file::<serde_json::Value>(&settings_path) {
let m = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(target) = m.providers.get_mut(&id) {
target.settings_config = live_after;
}
}
}
}
}
// 更新当前供应商(短借用范围)
{
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager.current = id;
}
// 对 Codex切换完成后同步 MCP 到 config.toml并将最新的 config.toml 回填到当前供应商 settings_config.config
if let AppType::Codex = app_type {
// 1) 依据 SSOT 将启用的 MCP 投影到 ~/.codex/config.toml
crate::mcp::sync_enabled_to_codex(&config)?;
// 2) 读取投影后的 live config.toml 文本
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
// 3) 回填到当前(目标)供应商的 settings_config.config确保编辑面板读取到最新 MCP
let cur_id = {
let m = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
m.current.clone()
};
let m = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(p) = m.providers.get_mut(&cur_id) {
if let Some(obj) = p.settings_config.as_object_mut() {
obj.insert(
"config".to_string(),
serde_json::Value::String(cfg_text_after),
);
}
}
}
// 更新当前供应商
manager.current = id;
log::info!("成功切换到供应商: {}", provider.name);
@@ -764,240 +653,6 @@ pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result<bool, St
Ok(true)
}
// =====================
// Claude MCP 管理命令
// =====================
/// 获取 Claude MCP 状态settings.local.json 与 mcp.json
#[tauri::command]
pub async fn get_claude_mcp_status() -> Result<crate::claude_mcp::McpStatus, String> {
claude_mcp::get_mcp_status()
}
/// 读取 mcp.json 文本内容(不存在则返回 Ok(None)
#[tauri::command]
pub async fn read_claude_mcp_config() -> Result<Option<String>, String> {
claude_mcp::read_mcp_json()
}
/// 新增或更新一个 MCP 服务器条目
#[tauri::command]
pub async fn upsert_claude_mcp_server(id: String, spec: serde_json::Value) -> Result<bool, String> {
claude_mcp::upsert_mcp_server(&id, spec)
}
/// 删除一个 MCP 服务器条目
#[tauri::command]
pub async fn delete_claude_mcp_server(id: String) -> Result<bool, String> {
claude_mcp::delete_mcp_server(&id)
}
/// 校验命令是否在 PATH 中可用(不执行)
#[tauri::command]
pub async fn validate_mcp_command(cmd: String) -> Result<bool, String> {
claude_mcp::validate_command_in_path(&cmd)
}
// =====================
// 新:集中以 config.json 为 SSOT 的 MCP 配置命令
// =====================
#[derive(serde::Serialize)]
pub struct McpConfigResponse {
pub config_path: String,
pub servers: std::collections::HashMap<String, serde_json::Value>,
}
/// 获取 MCP 配置(来自 ~/.cc-switch/config.json
#[tauri::command]
pub async fn get_mcp_config(
state: State<'_, AppState>,
app: Option<String>,
) -> Result<McpConfigResponse, String> {
let config_path = crate::config::get_app_config_path()
.to_string_lossy()
.to_string();
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude"));
let (servers, normalized) = crate::mcp::get_servers_snapshot_for(&mut cfg, &app_ty);
let need_save = normalized > 0;
drop(cfg);
if need_save {
state.save()?;
}
Ok(McpConfigResponse {
config_path,
servers,
})
}
/// 在 config.json 中新增或更新一个 MCP 服务器定义
#[tauri::command]
pub async fn upsert_mcp_server_in_config(
state: State<'_, AppState>,
app: Option<String>,
id: String,
spec: serde_json::Value,
) -> Result<bool, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude"));
let changed = crate::mcp::upsert_in_config_for(&mut cfg, &app_ty, &id, spec)?;
drop(cfg);
state.save()?;
Ok(changed)
}
/// 在 config.json 中删除一个 MCP 服务器定义
#[tauri::command]
pub async fn delete_mcp_server_in_config(
state: State<'_, AppState>,
app: Option<String>,
id: String,
) -> Result<bool, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude"));
let existed = crate::mcp::delete_in_config_for(&mut cfg, &app_ty, &id)?;
drop(cfg);
state.save()?;
// 若删除的是 Claude/Codex 客户端的条目,则同步一次,确保启用项从对应 live 配置中移除
let cfg2 = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
match app_ty {
crate::app_config::AppType::Claude => crate::mcp::sync_enabled_to_claude(&cfg2)?,
crate::app_config::AppType::Codex => crate::mcp::sync_enabled_to_codex(&cfg2)?,
}
Ok(existed)
}
/// 设置启用状态并同步到 ~/.claude.json
#[tauri::command]
pub async fn set_mcp_enabled(
state: State<'_, AppState>,
app: Option<String>,
id: String,
enabled: bool,
) -> Result<bool, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude"));
let changed = crate::mcp::set_enabled_and_sync_for(&mut cfg, &app_ty, &id, enabled)?;
drop(cfg);
state.save()?;
Ok(changed)
}
/// 手动同步:将启用的 MCP 投影到 ~/.claude.json不更改 config.json
#[tauri::command]
pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result<bool, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let normalized = crate::mcp::normalize_servers_for(&mut cfg, &AppType::Claude);
crate::mcp::sync_enabled_to_claude(&cfg)?;
let need_save = normalized > 0;
drop(cfg);
if need_save {
state.save()?;
}
Ok(true)
}
/// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml不更改 config.json
#[tauri::command]
pub async fn sync_enabled_mcp_to_codex(state: State<'_, AppState>) -> Result<bool, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let normalized = crate::mcp::normalize_servers_for(&mut cfg, &AppType::Codex);
crate::mcp::sync_enabled_to_codex(&cfg)?;
let need_save = normalized > 0;
drop(cfg);
if need_save {
state.save()?;
}
Ok(true)
}
/// 从 ~/.claude.json 导入 MCP 定义到 config.json返回变更数量
#[tauri::command]
pub async fn import_mcp_from_claude(state: State<'_, AppState>) -> Result<usize, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let changed = crate::mcp::import_from_claude(&mut cfg)?;
drop(cfg);
if changed > 0 {
state.save()?;
}
Ok(changed)
}
/// 从 ~/.codex/config.toml 导入 MCP 定义到 config.jsonCodex 作用域),返回变更数量
#[tauri::command]
pub async fn import_mcp_from_codex(state: State<'_, AppState>) -> Result<usize, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let changed = crate::mcp::import_from_codex(&mut cfg)?;
drop(cfg);
if changed > 0 {
state.save()?;
}
Ok(changed)
}
/// 读取当前生效live的配置内容返回可直接作为 provider.settings_config 的对象
/// - Codex: 返回 { auth: JSON, config: string }
/// - Claude: 返回 settings.json 的 JSON 内容
#[tauri::command]
pub async fn read_live_provider_settings(
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
) -> Result<serde_json::Value, String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
match app_type {
AppType::Codex => {
let auth_path = crate::codex_config::get_codex_auth_path();
if !auth_path.exists() {
return Err("Codex 配置文件不存在:缺少 auth.json".to_string());
}
let auth: serde_json::Value = crate::config::read_json_file(&auth_path)?;
let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?;
Ok(serde_json::json!({ "auth": auth, "config": cfg_text }))
}
AppType::Claude => {
let path = crate::config::get_claude_settings_path();
if !path.exists() {
return Err("Claude Code 配置文件不存在".to_string());
}
let v: serde_json::Value = crate::config::read_json_file(&path)?;
Ok(v)
}
}
}
/// 获取设置
#[tauri::command]
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
@@ -1037,224 +692,41 @@ pub async fn is_portable_mode() -> Result<bool, String> {
}
}
/// Claude 插件:获取 ~/.claude/config.json 状态
/// VS Code: 获取用户 settings.json 状态
#[tauri::command]
pub async fn get_claude_plugin_status() -> Result<ConfigStatus, String> {
match claude_plugin::claude_config_status() {
Ok((exists, path)) => Ok(ConfigStatus {
exists,
path: path.to_string_lossy().to_string(),
}),
Err(err) => Err(err),
}
}
/// Claude 插件:读取配置内容(若不存在返回 Ok(None)
#[tauri::command]
pub async fn read_claude_plugin_config() -> Result<Option<String>, String> {
claude_plugin::read_claude_config()
}
/// Claude 插件:写入/清除固定配置
#[tauri::command]
pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String> {
if official {
claude_plugin::clear_claude_config()
pub async fn get_vscode_settings_status() -> Result<ConfigStatus, String> {
if let Some(p) = vscode::find_existing_settings() {
Ok(ConfigStatus {
exists: true,
path: p.to_string_lossy().to_string(),
})
} else {
claude_plugin::write_claude_config()
// 默认返回 macOS 稳定版路径(或其他平台首选项的第一个候选),但标记不存在
let preferred = vscode::candidate_settings_paths().into_iter().next();
Ok(ConfigStatus {
exists: false,
path: preferred.unwrap_or_default().to_string_lossy().to_string(),
})
}
}
/// Claude 插件:检测是否已写入目标配置
/// VS Code: 读取 settings.json 文本(仅当文件存在)
#[tauri::command]
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
claude_plugin::is_claude_config_applied()
}
/// 测试第三方/自定义供应商端点的网络延迟
#[tauri::command]
pub async fn test_api_endpoints(
urls: Vec<String>,
timeout_secs: Option<u64>,
) -> Result<Vec<speedtest::EndpointLatency>, String> {
let filtered: Vec<String> = urls
.into_iter()
.filter(|url| !url.trim().is_empty())
.collect();
speedtest::test_endpoints(filtered, timeout_secs).await
}
/// 获取自定义端点列表
#[tauri::command]
pub async fn get_custom_endpoints(
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
) -> Result<Vec<crate::settings::CustomEndpoint>, String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let provider_id = provider_id
.or(providerId)
.ok_or_else(|| "缺少 providerId".to_string())?;
let mut cfg_guard = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = cfg_guard
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
let Some(provider) = manager.providers.get_mut(&provider_id) else {
return Ok(vec![]);
};
// 首选从 provider.meta 读取
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
if !meta.custom_endpoints.is_empty() {
let mut result: Vec<_> = meta.custom_endpoints.values().cloned().collect();
result.sort_by(|a, b| b.added_at.cmp(&a.added_at));
return Ok(result);
pub async fn read_vscode_settings() -> Result<String, String> {
if let Some(p) = vscode::find_existing_settings() {
std::fs::read_to_string(&p).map_err(|e| format!("读取 VS Code 设置失败: {}", e))
} else {
Err("未找到 VS Code 用户设置文件".to_string())
}
Ok(vec![])
}
/// 添加自定义端点
/// VS Code: 写入 settings.json 文本(仅当文件存在;不自动创建)
#[tauri::command]
pub async fn add_custom_endpoint(
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
url: String,
) -> Result<(), String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let provider_id = provider_id
.or(providerId)
.ok_or_else(|| "缺少 providerId".to_string())?;
let normalized = url.trim().trim_end_matches('/').to_string();
if normalized.is_empty() {
return Err("URL 不能为空".to_string());
pub async fn write_vscode_settings(content: String) -> Result<bool, String> {
if let Some(p) = vscode::find_existing_settings() {
config::write_text_file(&p, &content)?;
Ok(true)
} else {
Err("未找到 VS Code 用户设置文件".to_string())
}
let mut cfg_guard = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = cfg_guard
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
let Some(provider) = manager.providers.get_mut(&provider_id) else {
return Err("供应商不存在或未选择".to_string());
};
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let endpoint = crate::settings::CustomEndpoint {
url: normalized.clone(),
added_at: timestamp,
last_used: None,
};
meta.custom_endpoints.insert(normalized, endpoint);
drop(cfg_guard);
state.save()?;
Ok(())
}
/// 删除自定义端点
#[tauri::command]
pub async fn remove_custom_endpoint(
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
url: String,
) -> Result<(), String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let provider_id = provider_id
.or(providerId)
.ok_or_else(|| "缺少 providerId".to_string())?;
let normalized = url.trim().trim_end_matches('/').to_string();
let mut cfg_guard = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = cfg_guard
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(provider) = manager.providers.get_mut(&provider_id) {
if let Some(meta) = provider.meta.as_mut() {
meta.custom_endpoints.remove(&normalized);
}
}
drop(cfg_guard);
state.save()?;
Ok(())
}
/// 更新端点最后使用时间
#[tauri::command]
pub async fn update_endpoint_last_used(
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
url: String,
) -> Result<(), String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let provider_id = provider_id
.or(providerId)
.ok_or_else(|| "缺少 providerId".to_string())?;
let normalized = url.trim().trim_end_matches('/').to_string();
let mut cfg_guard = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = cfg_guard
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(provider) = manager.providers.get_mut(&provider_id) {
if let Some(meta) = provider.meta.as_mut() {
if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
endpoint.last_used = Some(timestamp);
}
}
}
drop(cfg_guard);
state.save()?;
Ok(())
}

View File

@@ -106,7 +106,7 @@ pub fn sanitize_provider_name(name: &str) -> String {
/// 获取供应商配置文件路径
pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
let base_name = provider_name
.map(sanitize_provider_name)
.map(|name| sanitize_provider_name(name))
.unwrap_or_else(|| sanitize_provider_name(provider_id));
get_claude_config_dir().join(format!("settings-{}.json", base_name))
@@ -118,18 +118,16 @@ pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, Stri
return Err(format!("文件不存在: {}", path.display()));
}
let content =
fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?;
let content = fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}", e))?;
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}", e))
}
/// 写入 JSON 配置文件
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), String> {
// 确保目录存在
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
}
let json =
@@ -141,8 +139,7 @@ pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), String
/// 原子写入文本文件(用于 TOML/纯文本)
pub fn write_text_file(path: &Path, data: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
}
atomic_write(path, data.as_bytes())
}
@@ -150,8 +147,7 @@ pub fn write_text_file(path: &Path, data: &str) -> Result<(), String> {
/// 原子写入:写入临时文件后 rename 替换,避免半写状态
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
}
let parent = path.parent().ok_or_else(|| "无效的路径".to_string())?;
@@ -168,12 +164,10 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
tmp.push(format!("{}.tmp.{}", file_name, ts));
{
let mut f = fs::File::create(&tmp)
.map_err(|e| format!("创建临时文件失败: {}: {}", tmp.display(), e))?;
let mut f = fs::File::create(&tmp).map_err(|e| format!("创建临时文件失败: {}", e))?;
f.write_all(data)
.map_err(|e| format!("写入临时文件失败: {}: {}", tmp.display(), e))?;
f.flush()
.map_err(|e| format!("刷新临时文件失败: {}: {}", tmp.display(), e))?;
.map_err(|e| format!("写入临时文件失败: {}", e))?;
f.flush().map_err(|e| format!("刷新临时文件失败: {}", e))?;
}
#[cfg(unix)]
@@ -191,26 +185,12 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
if path.exists() {
let _ = fs::remove_file(path);
}
fs::rename(&tmp, path).map_err(|e| {
format!(
"原子替换失败: {} -> {}: {}",
tmp.display(),
path.display(),
e
)
})?;
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
}
#[cfg(not(windows))]
{
fs::rename(&tmp, path).map_err(|e| {
format!(
"原子替换失败: {} -> {}: {}",
tmp.display(),
path.display(),
e
)
})?;
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
}
Ok(())
}

View File

@@ -1,170 +0,0 @@
use chrono::Utc;
use serde_json::{json, Value};
use std::fs;
use std::path::PathBuf;
// 默认仅保留最近 10 份备份,避免目录无限膨胀
const MAX_BACKUPS: usize = 10;
/// 创建配置文件备份
pub fn create_backup(config_path: &PathBuf) -> Result<String, String> {
if !config_path.exists() {
return Ok(String::new());
}
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
let backup_id = format!("backup_{}", timestamp);
let backup_dir = config_path
.parent()
.ok_or("Invalid config path")?
.join("backups");
// 创建备份目录
fs::create_dir_all(&backup_dir)
.map_err(|e| format!("Failed to create backup directory: {}", e))?;
let backup_path = backup_dir.join(format!("{}.json", backup_id));
// 复制配置文件到备份
fs::copy(config_path, backup_path).map_err(|e| format!("Failed to create backup: {}", e))?;
// 备份完成后清理旧的备份文件(仅保留最近 MAX_BACKUPS 份)
cleanup_old_backups(&backup_dir, MAX_BACKUPS)?;
Ok(backup_id)
}
fn cleanup_old_backups(backup_dir: &PathBuf, retain: usize) -> Result<(), String> {
if retain == 0 {
return Ok(());
}
let mut entries: Vec<_> = match fs::read_dir(backup_dir) {
Ok(iter) => iter
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.map(|ext| ext == "json")
.unwrap_or(false)
})
.collect(),
Err(_) => return Ok(()),
};
if entries.len() <= retain {
return Ok(());
}
let remove_count = entries.len().saturating_sub(retain);
entries.sort_by(|a, b| {
let a_time = a.metadata().and_then(|m| m.modified()).ok();
let b_time = b.metadata().and_then(|m| m.modified()).ok();
a_time.cmp(&b_time)
});
for entry in entries.into_iter().take(remove_count) {
if let Err(err) = fs::remove_file(entry.path()) {
log::warn!(
"Failed to remove old backup {}: {}",
entry.path().display(),
err
);
}
}
Ok(())
}
/// 导出配置文件
#[tauri::command]
pub async fn export_config_to_file(file_path: String) -> Result<Value, String> {
// 读取当前配置文件
let config_path = crate::config::get_app_config_path();
let config_content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read configuration: {}", e))?;
// 写入到指定文件
fs::write(&file_path, &config_content).map_err(|e| format!("Failed to write file: {}", e))?;
Ok(json!({
"success": true,
"message": "Configuration exported successfully",
"filePath": file_path
}))
}
/// 从文件导入配置
#[tauri::command]
pub async fn import_config_from_file(
file_path: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<Value, String> {
// 读取导入的文件
let import_content =
fs::read_to_string(&file_path).map_err(|e| format!("Failed to read import file: {}", e))?;
// 验证并解析为配置对象
let new_config: crate::app_config::MultiAppConfig = serde_json::from_str(&import_content)
.map_err(|e| format!("Invalid configuration file: {}", e))?;
// 备份当前配置
let config_path = crate::config::get_app_config_path();
let backup_id = create_backup(&config_path)?;
// 写入新配置到磁盘
fs::write(&config_path, &import_content)
.map_err(|e| format!("Failed to write configuration: {}", e))?;
// 更新内存中的状态
{
let mut config_state = state
.config
.lock()
.map_err(|e| format!("Failed to lock config: {}", e))?;
*config_state = new_config;
}
Ok(json!({
"success": true,
"message": "Configuration imported successfully",
"backupId": backup_id
}))
}
/// 保存文件对话框
#[tauri::command]
pub async fn save_file_dialog<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
default_name: String,
) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let dialog = app.dialog();
let result = dialog
.file()
.add_filter("JSON", &["json"])
.set_file_name(&default_name)
.blocking_save_file();
Ok(result.map(|p| p.to_string()))
}
/// 打开文件对话框
#[tauri::command]
pub async fn open_file_dialog<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let dialog = app.dialog();
let result = dialog
.file()
.add_filter("JSON", &["json"])
.blocking_pick_file();
Ok(result.map(|p| p.to_string()))
}

View File

@@ -1,24 +1,20 @@
mod app_config;
mod claude_mcp;
mod claude_plugin;
mod codex_config;
mod commands;
mod config;
mod import_export;
mod mcp;
mod migration;
mod provider;
mod settings;
mod speedtest;
mod store;
mod vscode;
use store::AppState;
#[cfg(target_os = "macos")]
use tauri::RunEvent;
use tauri::{
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
tray::{TrayIconBuilder, TrayIconEvent},
};
#[cfg(target_os = "macos")]
use tauri::{ActivationPolicy, RunEvent};
use tauri::{Emitter, Manager};
/// 创建动态托盘菜单
@@ -120,23 +116,6 @@ fn create_tray_menu(
.map_err(|e| format!("构建菜单失败: {}", e))
}
#[cfg(target_os = "macos")]
fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
let desired_policy = if dock_visible {
ActivationPolicy::Regular
} else {
ActivationPolicy::Accessory
};
if let Err(err) = app.set_dock_visibility(dock_visible) {
log::warn!("设置 Dock 显示状态失败: {}", err);
}
if let Err(err) = app.set_activation_policy(desired_policy) {
log::warn!("设置激活策略失败: {}", err);
}
}
/// 处理托盘菜单事件
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
log::info!("处理托盘菜单事件: {}", event_id);
@@ -151,10 +130,6 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "macos")]
{
apply_tray_policy(app, true);
}
}
}
"quit" => {
@@ -219,7 +194,7 @@ async fn switch_provider_internal(
let provider_id_clone = provider_id.clone();
crate::commands::switch_provider(
app_state.clone(),
app_state.clone().into(),
Some(app_type),
None,
None,
@@ -281,8 +256,8 @@ pub fn run() {
let builder = builder
// 拦截窗口关闭:根据设置决定是否最小化到托盘
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
.on_window_event(|window, event| match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
let settings = crate::settings::get_settings();
if settings.minimize_to_tray_on_close {
@@ -292,14 +267,11 @@ pub fn run() {
{
let _ = window.set_skip_taskbar(true);
}
#[cfg(target_os = "macos")]
{
apply_tray_policy(window.app_handle(), false);
}
} else {
window.app_handle().exit(0);
}
}
_ => {}
})
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_dialog::init())
@@ -361,7 +333,7 @@ pub fn run() {
// 首次启动迁移:扫描副本文件,合并到 config.json并归档副本旧 config.json 先归档
{
let mut config_guard = app_state.config.lock().unwrap();
let migrated = migration::migrate_copies_into_config(&mut config_guard)?;
let migrated = migration::migrate_copies_into_config(&mut *config_guard)?;
if migrated {
log::info!("已将副本文件导入到 config.json并完成归档");
}
@@ -374,7 +346,7 @@ pub fn run() {
let _ = app_state.save();
// 创建动态托盘菜单
let menu = create_tray_menu(app.handle(), &app_state)?;
let menu = create_tray_menu(&app.handle(), &app_state)?;
// 构建托盘
let mut tray_builder = TrayIconBuilder::with_id("main")
@@ -414,41 +386,13 @@ pub fn run() {
commands::open_external,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::read_live_provider_settings,
commands::get_settings,
commands::save_settings,
commands::check_for_updates,
commands::is_portable_mode,
commands::get_claude_plugin_status,
commands::read_claude_plugin_config,
commands::apply_claude_plugin_config,
commands::is_claude_plugin_applied,
// Claude MCP management
commands::get_claude_mcp_status,
commands::read_claude_mcp_config,
commands::upsert_claude_mcp_server,
commands::delete_claude_mcp_server,
commands::validate_mcp_command,
// New MCP via config.json (SSOT)
commands::get_mcp_config,
commands::upsert_mcp_server_in_config,
commands::delete_mcp_server_in_config,
commands::set_mcp_enabled,
commands::sync_enabled_mcp_to_claude,
commands::sync_enabled_mcp_to_codex,
commands::import_mcp_from_claude,
commands::import_mcp_from_codex,
// ours: endpoint speed test + custom endpoint management
commands::test_api_endpoints,
commands::get_custom_endpoints,
commands::add_custom_endpoint,
commands::remove_custom_endpoint,
commands::update_endpoint_last_used,
// theirs: config import/export and dialogs
import_export::export_config_to_file,
import_export::import_config_from_file,
import_export::save_file_dialog,
import_export::open_file_dialog,
commands::get_vscode_settings_status,
commands::read_vscode_settings,
commands::write_vscode_settings,
update_tray_menu,
]);
@@ -459,17 +403,19 @@ pub fn run() {
app.run(|app_handle, event| {
#[cfg(target_os = "macos")]
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
if let RunEvent::Reopen { .. } = event {
if let Some(window) = app_handle.get_webview_window("main") {
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
match event {
RunEvent::Reopen { .. } => {
if let Some(window) = app_handle.get_webview_window("main") {
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
apply_tray_policy(app_handle, true);
}
_ => {}
}
#[cfg(not(target_os = "macos"))]

View File

@@ -1,732 +0,0 @@
use serde_json::{json, Value};
use std::collections::HashMap;
use crate::app_config::{AppType, McpConfig, MultiAppConfig};
/// 基础校验:允许 stdio/http或省略 type视为 stdio。对应必填字段存在
fn validate_server_spec(spec: &Value) -> Result<(), String> {
if !spec.is_object() {
return Err("MCP 服务器连接定义必须为 JSON 对象".into());
}
let t_opt = spec.get("type").and_then(|x| x.as_str());
// 支持两种stdio/http若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true);
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
if !(is_stdio || is_http) {
return Err("MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio".into());
}
if is_stdio {
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
if cmd.trim().is_empty() {
return Err("stdio 类型的 MCP 服务器缺少 command 字段".into());
}
}
if is_http {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.trim().is_empty() {
return Err("http 类型的 MCP 服务器缺少 url 字段".into());
}
}
Ok(())
}
fn validate_mcp_entry(entry: &Value) -> Result<(), String> {
let obj = entry
.as_object()
.ok_or_else(|| "MCP 服务器条目必须为 JSON 对象".to_string())?;
let server = obj
.get("server")
.ok_or_else(|| "MCP 服务器条目缺少 server 字段".to_string())?;
validate_server_spec(server)?;
for key in ["name", "description", "homepage", "docs"] {
if let Some(val) = obj.get(key) {
if !val.is_string() {
return Err(format!("MCP 服务器 {} 必须为字符串", key));
}
}
}
if let Some(tags) = obj.get("tags") {
let arr = tags
.as_array()
.ok_or_else(|| "MCP 服务器 tags 必须为字符串数组".to_string())?;
if !arr.iter().all(|item| item.is_string()) {
return Err("MCP 服务器 tags 必须为字符串数组".into());
}
}
if let Some(enabled) = obj.get("enabled") {
if !enabled.is_boolean() {
return Err("MCP 服务器 enabled 必须为布尔值".into());
}
}
Ok(())
}
fn normalize_server_keys(map: &mut HashMap<String, Value>) -> usize {
let mut change_count = 0usize;
let mut renames: Vec<(String, String)> = Vec::new();
for (key_ref, value) in map.iter_mut() {
let key = key_ref.clone();
let Some(obj) = value.as_object_mut() else {
continue;
};
let id_value = obj.get("id").cloned();
let target_id: String;
match id_value {
Some(id_val) => match id_val.as_str() {
Some(id_str) => {
let trimmed = id_str.trim();
if trimmed.is_empty() {
obj.insert("id".into(), json!(key.clone()));
change_count += 1;
target_id = key.clone();
} else {
if trimmed != id_str {
obj.insert("id".into(), json!(trimmed));
change_count += 1;
}
target_id = trimmed.to_string();
}
}
None => {
obj.insert("id".into(), json!(key.clone()));
change_count += 1;
target_id = key.clone();
}
},
None => {
obj.insert("id".into(), json!(key.clone()));
change_count += 1;
target_id = key.clone();
}
}
if target_id != key {
renames.push((key, target_id));
}
}
for (old_key, new_key) in renames {
if old_key == new_key {
continue;
}
if map.contains_key(&new_key) {
log::warn!(
"MCP 条目 '{}' 的内部 id '{}' 与现有键冲突,回退为原键",
old_key,
new_key
);
if let Some(value) = map.get_mut(&old_key) {
if let Some(obj) = value.as_object_mut() {
if obj
.get("id")
.and_then(|v| v.as_str())
.map(|s| s != old_key)
.unwrap_or(true)
{
obj.insert("id".into(), json!(old_key.clone()));
change_count += 1;
}
}
}
continue;
}
if let Some(mut value) = map.remove(&old_key) {
if let Some(obj) = value.as_object_mut() {
obj.insert("id".into(), json!(new_key.clone()));
}
log::info!("MCP 条目键名已自动修复: '{}' -> '{}'", old_key, new_key);
map.insert(new_key, value);
change_count += 1;
}
}
change_count
}
pub fn normalize_servers_for(config: &mut MultiAppConfig, app: &AppType) -> usize {
let servers = &mut config.mcp_for_mut(app).servers;
normalize_server_keys(servers)
}
fn extract_server_spec(entry: &Value) -> Result<Value, String> {
let obj = entry
.as_object()
.ok_or_else(|| "MCP 服务器条目必须为 JSON 对象".to_string())?;
let server = obj
.get("server")
.ok_or_else(|| "MCP 服务器条目缺少 server 字段".to_string())?;
if !server.is_object() {
return Err("MCP 服务器 server 字段必须为 JSON 对象".into());
}
Ok(server.clone())
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new();
for (id, entry) in cfg.servers.iter() {
let enabled = entry
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !enabled {
continue;
}
match extract_server_spec(entry) {
Ok(spec) => {
out.insert(id.clone(), spec);
}
Err(err) => {
log::warn!("跳过无效的 MCP 条目 '{}': {}", id, err);
}
}
}
out
}
pub fn get_servers_snapshot_for(
config: &mut MultiAppConfig,
app: &AppType,
) -> (HashMap<String, Value>, usize) {
let normalized = normalize_servers_for(config, app);
let mut snapshot = config.mcp_for(app).servers.clone();
snapshot.retain(|id, value| {
let Some(obj) = value.as_object_mut() else {
log::warn!("跳过无效的 MCP 条目 '{}': 必须为 JSON 对象", id);
return false;
};
obj.entry(String::from("id")).or_insert(json!(id));
match validate_mcp_entry(value) {
Ok(()) => true,
Err(err) => {
log::error!("config.json 中存在无效的 MCP 条目 '{}': {}", id, err);
false
}
}
});
(snapshot, normalized)
}
pub fn upsert_in_config_for(
config: &mut MultiAppConfig,
app: &AppType,
id: &str,
spec: Value,
) -> Result<bool, String> {
if id.trim().is_empty() {
return Err("MCP 服务器 ID 不能为空".into());
}
normalize_servers_for(config, app);
validate_mcp_entry(&spec)?;
let mut entry_obj = spec
.as_object()
.cloned()
.ok_or_else(|| "MCP 服务器条目必须为 JSON 对象".to_string())?;
if let Some(existing_id) = entry_obj.get("id") {
let Some(existing_id_str) = existing_id.as_str() else {
return Err("MCP 服务器 id 必须为字符串".into());
};
if existing_id_str != id {
return Err(format!(
"MCP 服务器条目中的 id '{}' 与参数 id '{}' 不一致",
existing_id_str, id
));
}
} else {
entry_obj.insert(String::from("id"), json!(id));
}
let value = Value::Object(entry_obj);
let servers = &mut config.mcp_for_mut(app).servers;
let before = servers.get(id).cloned();
servers.insert(id.to_string(), value);
Ok(before.is_none())
}
pub fn delete_in_config_for(
config: &mut MultiAppConfig,
app: &AppType,
id: &str,
) -> Result<bool, String> {
if id.trim().is_empty() {
return Err("MCP 服务器 ID 不能为空".into());
}
normalize_servers_for(config, app);
let existed = config.mcp_for_mut(app).servers.remove(id).is_some();
Ok(existed)
}
/// 设置启用状态并同步到 ~/.claude.json
pub fn set_enabled_and_sync_for(
config: &mut MultiAppConfig,
app: &AppType,
id: &str,
enabled: bool,
) -> Result<bool, String> {
if id.trim().is_empty() {
return Err("MCP 服务器 ID 不能为空".into());
}
normalize_servers_for(config, app);
if let Some(spec) = config.mcp_for_mut(app).servers.get_mut(id) {
// 写入 enabled 字段
let mut obj = spec
.as_object()
.cloned()
.ok_or_else(|| "MCP 服务器定义必须为 JSON 对象".to_string())?;
obj.insert("enabled".into(), json!(enabled));
*spec = Value::Object(obj);
} else {
// 若不存在则直接返回 false
return Ok(false);
}
// 同步启用项
match app {
AppType::Claude => {
// 将启用项投影到 ~/.claude.json
sync_enabled_to_claude(config)?;
}
AppType::Codex => {
// 将启用项投影到 ~/.codex/config.toml
sync_enabled_to_codex(config)?;
}
}
Ok(true)
}
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), String> {
let enabled = collect_enabled_servers(&config.mcp.claude);
crate::claude_mcp::set_mcp_servers_map(&enabled)
}
/// 从 ~/.claude.json 导入 mcpServers 到 config.json设为 enabled=true
/// 已存在的项仅强制 enabled=true不覆盖其他字段。
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, String> {
let text_opt = crate::claude_mcp::read_mcp_json()?;
let Some(text) = text_opt else { return Ok(0) };
let mut changed = normalize_servers_for(config, &AppType::Claude);
let v: Value =
serde_json::from_str(&text).map_err(|e| format!("解析 ~/.claude.json 失败: {}", e))?;
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
return Ok(changed);
};
for (id, spec) in map.iter() {
// 校验目标 spec
validate_server_spec(spec)?;
let entry = config
.mcp_for_mut(&AppType::Claude)
.servers
.entry(id.clone());
use std::collections::hash_map::Entry;
match entry {
Entry::Vacant(vac) => {
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec.clone());
obj.insert(String::from("enabled"), json!(true));
vac.insert(Value::Object(obj));
changed += 1;
}
Entry::Occupied(mut occ) => {
let value = occ.get_mut();
let Some(existing) = value.as_object_mut() else {
log::warn!("MCP 条目 '{}' 不是 JSON 对象,覆盖为导入数据", id);
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec.clone());
obj.insert(String::from("enabled"), json!(true));
occ.insert(Value::Object(obj));
changed += 1;
continue;
};
let mut modified = false;
let prev_enabled = existing
.get("enabled")
.and_then(|b| b.as_bool())
.unwrap_or(false);
if !prev_enabled {
existing.insert(String::from("enabled"), json!(true));
modified = true;
}
if existing.get("server").is_none() {
log::warn!("MCP 条目 '{}' 缺少 server 字段,覆盖为导入数据", id);
existing.insert(String::from("server"), spec.clone());
modified = true;
}
if existing.get("id").is_none() {
log::warn!("MCP 条目 '{}' 缺少 id 字段,自动填充", id);
existing.insert(String::from("id"), json!(id));
modified = true;
}
if modified {
changed += 1;
}
}
}
}
Ok(changed)
}
/// 从 ~/.codex/config.toml 导入 MCP 到 config.jsonCodex 作用域),并将导入项设为 enabled=true。
/// 支持两种 schema[mcp.servers.<id>] 与 [mcp_servers.<id>]。
/// 已存在的项仅强制 enabled=true不覆盖其他字段。
pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, String> {
let text = crate::codex_config::read_and_validate_codex_config_text()?;
if text.trim().is_empty() {
return Ok(0);
}
let mut changed_total = normalize_servers_for(config, &AppType::Codex);
let root: toml::Table =
toml::from_str(&text).map_err(|e| format!("解析 ~/.codex/config.toml 失败: {}", e))?;
// helper处理一组 servers 表
let mut import_servers_tbl = |servers_tbl: &toml::value::Table| {
let mut changed = 0usize;
for (id, entry_val) in servers_tbl.iter() {
let Some(entry_tbl) = entry_val.as_table() else {
continue;
};
// type 缺省为 stdio
let typ = entry_tbl
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("stdio");
// 构建 JSON 规范
let mut spec = serde_json::Map::new();
spec.insert("type".into(), json!(typ));
match typ {
"stdio" => {
if let Some(cmd) = entry_tbl.get("command").and_then(|v| v.as_str()) {
spec.insert("command".into(), json!(cmd));
}
if let Some(args) = entry_tbl.get("args").and_then(|v| v.as_array()) {
let arr = args
.iter()
.filter_map(|x| x.as_str())
.map(|s| json!(s))
.collect::<Vec<_>>();
if !arr.is_empty() {
spec.insert("args".into(), serde_json::Value::Array(arr));
}
}
if let Some(cwd) = entry_tbl.get("cwd").and_then(|v| v.as_str()) {
if !cwd.trim().is_empty() {
spec.insert("cwd".into(), json!(cwd));
}
}
if let Some(env_tbl) = entry_tbl.get("env").and_then(|v| v.as_table()) {
let mut env_json = serde_json::Map::new();
for (k, v) in env_tbl.iter() {
if let Some(sv) = v.as_str() {
env_json.insert(k.clone(), json!(sv));
}
}
if !env_json.is_empty() {
spec.insert("env".into(), serde_json::Value::Object(env_json));
}
}
}
"http" => {
if let Some(url) = entry_tbl.get("url").and_then(|v| v.as_str()) {
spec.insert("url".into(), json!(url));
}
if let Some(headers_tbl) = entry_tbl.get("headers").and_then(|v| v.as_table()) {
let mut headers_json = serde_json::Map::new();
for (k, v) in headers_tbl.iter() {
if let Some(sv) = v.as_str() {
headers_json.insert(k.clone(), json!(sv));
}
}
if !headers_json.is_empty() {
spec.insert("headers".into(), serde_json::Value::Object(headers_json));
}
}
}
_ => {}
}
let spec_v = serde_json::Value::Object(spec);
// 校验
if let Err(e) = validate_server_spec(&spec_v) {
log::warn!("跳过无效 Codex MCP 项 '{}': {}", id, e);
continue;
}
// 合并:仅强制 enabled=true
use std::collections::hash_map::Entry;
let entry = config
.mcp_for_mut(&AppType::Codex)
.servers
.entry(id.clone());
match entry {
Entry::Vacant(vac) => {
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec_v.clone());
obj.insert(String::from("enabled"), json!(true));
vac.insert(serde_json::Value::Object(obj));
changed += 1;
}
Entry::Occupied(mut occ) => {
let value = occ.get_mut();
let Some(existing) = value.as_object_mut() else {
log::warn!("MCP 条目 '{}' 不是 JSON 对象,覆盖为导入数据", id);
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec_v.clone());
obj.insert(String::from("enabled"), json!(true));
occ.insert(serde_json::Value::Object(obj));
changed += 1;
continue;
};
let mut modified = false;
let prev = existing
.get("enabled")
.and_then(|b| b.as_bool())
.unwrap_or(false);
if !prev {
existing.insert(String::from("enabled"), json!(true));
modified = true;
}
if existing.get("server").is_none() {
log::warn!("MCP 条目 '{}' 缺少 server 字段,覆盖为导入数据", id);
existing.insert(String::from("server"), spec_v.clone());
modified = true;
}
if existing.get("id").is_none() {
log::warn!("MCP 条目 '{}' 缺少 id 字段,自动填充", id);
existing.insert(String::from("id"), json!(id));
modified = true;
}
if modified {
changed += 1;
}
}
}
}
changed
};
// 1) 处理 mcp.servers
if let Some(mcp_val) = root.get("mcp") {
if let Some(mcp_tbl) = mcp_val.as_table() {
if let Some(servers_val) = mcp_tbl.get("servers") {
if let Some(servers_tbl) = servers_val.as_table() {
changed_total += import_servers_tbl(servers_tbl);
}
}
}
}
// 2) 处理 mcp_servers
if let Some(servers_val) = root.get("mcp_servers") {
if let Some(servers_tbl) = servers_val.as_table() {
changed_total += import_servers_tbl(servers_tbl);
}
}
Ok(changed_total)
}
/// 将 config.json 中 Codex 的 enabled==true 项以 TOML 形式写入 ~/.codex/config.toml 的 [mcp.servers]
/// 策略:
/// - 读取现有 config.toml若语法无效则报错不尝试覆盖
/// - 仅更新 `mcp.servers` 或 `mcp_servers` 子表,保留 `mcp` 其它键
/// - 仅写入启用项;无启用项时清理对应子表
pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), String> {
use toml::{value::Value as TomlValue, Table as TomlTable};
// 1) 收集启用项Codex 维度)
let enabled = collect_enabled_servers(&config.mcp.codex);
// 2) 读取现有 config.toml 并解析为 Table允许空文件
let base_text = crate::codex_config::read_and_validate_codex_config_text()?;
let mut root: TomlTable = if base_text.trim().is_empty() {
TomlTable::new()
} else {
toml::from_str::<TomlTable>(&base_text)
.map_err(|e| format!("解析 config.toml 失败: {}", e))?
};
// 3) 写入 servers 表(支持 mcp.servers 与 mcp_servers优先沿用已有风格默认 mcp_servers
let prefer_mcp_servers = root.get("mcp_servers").is_some() || root.get("mcp").is_none();
if enabled.is_empty() {
// 无启用项:移除两种节点
// 清除 mcp.servers但保留其他 mcp 字段
let mut should_drop_mcp = false;
if let Some(mcp_val) = root.get_mut("mcp") {
match mcp_val {
TomlValue::Table(tbl) => {
tbl.remove("servers");
should_drop_mcp = tbl.is_empty();
}
_ => should_drop_mcp = true,
}
}
if should_drop_mcp {
root.remove("mcp");
}
// 清除顶层 mcp_servers
root.remove("mcp_servers");
} else {
let mut servers_tbl = TomlTable::new();
for (id, spec) in enabled.iter() {
let mut s = TomlTable::new();
// 类型(缺省视为 stdio
let typ = spec.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
s.insert("type".into(), TomlValue::String(typ.to_string()));
match typ {
"stdio" => {
let cmd = spec
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
s.insert("command".into(), TomlValue::String(cmd));
if let Some(args) = spec.get("args").and_then(|v| v.as_array()) {
let arr = args
.iter()
.filter_map(|x| x.as_str())
.map(|x| TomlValue::String(x.to_string()))
.collect::<Vec<_>>();
if !arr.is_empty() {
s.insert("args".into(), TomlValue::Array(arr));
}
}
if let Some(cwd) = spec.get("cwd").and_then(|v| v.as_str()) {
if !cwd.trim().is_empty() {
s.insert("cwd".into(), TomlValue::String(cwd.to_string()));
}
}
if let Some(env) = spec.get("env").and_then(|v| v.as_object()) {
let mut env_tbl = TomlTable::new();
for (k, v) in env.iter() {
if let Some(sv) = v.as_str() {
env_tbl.insert(k.clone(), TomlValue::String(sv.to_string()));
}
}
if !env_tbl.is_empty() {
s.insert("env".into(), TomlValue::Table(env_tbl));
}
}
}
"http" => {
let url = spec
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
s.insert("url".into(), TomlValue::String(url));
if let Some(headers) = spec.get("headers").and_then(|v| v.as_object()) {
let mut h_tbl = TomlTable::new();
for (k, v) in headers.iter() {
if let Some(sv) = v.as_str() {
h_tbl.insert(k.clone(), TomlValue::String(sv.to_string()));
}
}
if !h_tbl.is_empty() {
s.insert("headers".into(), TomlValue::Table(h_tbl));
}
}
}
_ => {}
}
servers_tbl.insert(id.clone(), TomlValue::Table(s));
}
let servers_value = TomlValue::Table(servers_tbl.clone());
if prefer_mcp_servers {
root.insert("mcp_servers".into(), servers_value);
// 若存在 mcp则仅移除 servers 字段,保留其他键
let mut should_drop_mcp = false;
if let Some(mcp_val) = root.get_mut("mcp") {
match mcp_val {
TomlValue::Table(tbl) => {
tbl.remove("servers");
should_drop_mcp = tbl.is_empty();
}
_ => should_drop_mcp = true,
}
}
if should_drop_mcp {
root.remove("mcp");
}
} else {
let mut inserted = false;
if let Some(mcp_val) = root.get_mut("mcp") {
match mcp_val {
TomlValue::Table(tbl) => {
tbl.insert("servers".into(), TomlValue::Table(servers_tbl.clone()));
inserted = true;
}
_ => {
let mut mcp_tbl = TomlTable::new();
mcp_tbl.insert("servers".into(), TomlValue::Table(servers_tbl.clone()));
*mcp_val = TomlValue::Table(mcp_tbl);
inserted = true;
}
}
}
if !inserted {
let mut mcp_tbl = TomlTable::new();
mcp_tbl.insert("servers".into(), TomlValue::Table(servers_tbl));
root.insert("mcp".into(), TomlValue::Table(mcp_tbl));
}
root.remove("mcp_servers");
}
}
// 4) 序列化并写回 config.toml仅改 TOML不触碰 auth.json
let new_text = toml::to_string(&TomlValue::Table(root))
.map_err(|e| format!("序列化 config.toml 失败: {}", e))?;
let path = crate::codex_config::get_codex_config_path();
crate::config::write_text_file(&path, &new_text)?;
Ok(())
}

View File

@@ -363,13 +363,19 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
}
for (_, ap, cp, _) in codex_items.into_iter() {
if let Some(ap) = ap {
if let Ok(Some(_)) = archive_file(ts, "codex", &ap) {
let _ = delete_file(&ap);
match archive_file(ts, "codex", &ap) {
Ok(Some(_)) => {
let _ = delete_file(&ap);
}
_ => {}
}
}
if let Some(cp) = cp {
if let Ok(Some(_)) = archive_file(ts, "codex", &cp) {
let _ = delete_file(&cp);
match archive_file(ts, "codex", &cp) {
Ok(Some(_)) => {
let _ = delete_file(&cp);
}
_ => {}
}
}
}

View File

@@ -19,9 +19,6 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "createdAt")]
pub created_at: Option<i64>,
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
}
impl Provider {
@@ -39,24 +36,24 @@ impl Provider {
website_url,
category: None,
created_at: None,
meta: None,
}
}
}
/// 供应商管理器
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderManager {
pub providers: HashMap<String, Provider>,
pub current: String,
}
/// 供应商元数据
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta {
/// 自定义端点列表(按 URL 去重存储)
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
impl Default for ProviderManager {
fn default() -> Self {
Self {
providers: HashMap::new(),
current: String::new(),
}
}
}
impl ProviderManager {

View File

@@ -1,19 +1,8 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
/// 自定义端点配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomEndpoint {
pub url: String,
pub added_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_used: Option<i64>,
}
/// 应用设置结构,允许覆盖默认配置目录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -22,21 +11,10 @@ pub struct AppSettings {
pub show_in_tray: bool,
#[serde(default = "default_minimize_to_tray_on_close")]
pub minimize_to_tray_on_close: bool,
/// 是否启用 Claude 插件联动
#[serde(default)]
pub enable_claude_plugin_integration: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// Claude 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
/// Codex 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_codex: HashMap<String, CustomEndpoint>,
}
fn default_show_in_tray() -> bool {
@@ -52,12 +30,8 @@ impl Default for AppSettings {
Self {
show_in_tray: true,
minimize_to_tray_on_close: true,
enable_claude_plugin_integration: false,
claude_config_dir: None,
codex_config_dir: None,
language: None,
custom_endpoints_claude: HashMap::new(),
custom_endpoints_codex: HashMap::new(),
}
}
}
@@ -81,13 +55,6 @@ impl AppSettings {
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.language = self
.language
.as_ref()
.map(|s| s.trim())
.filter(|s| matches!(*s, "en" | "zh"))
.map(|s| s.to_string());
}
pub fn load() -> Self {

View File

@@ -1,106 +0,0 @@
use futures::future::join_all;
use reqwest::{Client, Url};
use serde::Serialize;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_SECS: u64 = 8;
const MAX_TIMEOUT_SECS: u64 = 30;
const MIN_TIMEOUT_SECS: u64 = 2;
#[derive(Debug, Clone, Serialize)]
pub struct EndpointLatency {
pub url: String,
pub latency: Option<u128>,
pub status: Option<u16>,
pub error: Option<String>,
}
fn build_client(timeout_secs: u64) -> Result<Client, String> {
Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.redirect(reqwest::redirect::Policy::limited(5))
.user_agent("cc-switch-speedtest/1.0")
.build()
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))
}
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
let secs = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
secs.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)
}
pub async fn test_endpoints(
urls: Vec<String>,
timeout_secs: Option<u64>,
) -> Result<Vec<EndpointLatency>, String> {
if urls.is_empty() {
return Ok(vec![]);
}
let timeout = sanitize_timeout(timeout_secs);
let client = build_client(timeout)?;
let tasks = urls.into_iter().map(|raw_url| {
let client = client.clone();
async move {
let trimmed = raw_url.trim().to_string();
if trimmed.is_empty() {
return EndpointLatency {
url: raw_url,
latency: None,
status: None,
error: Some("URL 不能为空".to_string()),
};
}
let parsed_url = match Url::parse(&trimmed) {
Ok(url) => url,
Err(err) => {
return EndpointLatency {
url: trimmed,
latency: None,
status: None,
error: Some(format!("URL 无效: {err}")),
};
}
};
// 先进行一次“热身”请求,忽略其结果,仅用于复用连接/绕过首包惩罚
let _ = client.get(parsed_url.clone()).send().await;
// 第二次请求开始计时,并将其作为结果返回
let start = Instant::now();
match client.get(parsed_url).send().await {
Ok(resp) => {
let latency = start.elapsed().as_millis();
EndpointLatency {
url: trimmed,
latency: Some(latency),
status: Some(resp.status().as_u16()),
error: None,
}
}
Err(err) => {
let status = err.status().map(|s| s.as_u16());
let error_message = if err.is_timeout() {
"请求超时".to_string()
} else if err.is_connect() {
"连接失败".to_string()
} else {
err.to_string()
};
EndpointLatency {
url: trimmed,
latency: None,
status,
error: Some(error_message),
}
}
}
}
});
let results = join_all(tasks).await;
Ok(results)
}

65
src-tauri/src/vscode.rs Normal file
View File

@@ -0,0 +1,65 @@
use std::path::PathBuf;
/// 枚举可能的 VS Code 发行版配置目录名称
fn vscode_product_dirs() -> Vec<&'static str> {
vec![
"Code", // VS Code Stable
"Code - Insiders", // VS Code Insiders
"VSCodium", // VSCodium
"Code - OSS", // OSS 发行版
]
}
/// 获取 VS Code 用户 settings.json 的候选路径列表(按优先级排序)
pub fn candidate_settings_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
#[cfg(target_os = "macos")]
{
if let Some(home) = dirs::home_dir() {
for prod in vscode_product_dirs() {
paths.push(
home.join("Library")
.join("Application Support")
.join(prod)
.join("User")
.join("settings.json"),
);
}
}
}
#[cfg(target_os = "windows")]
{
// Windows: %APPDATA%\Code\User\settings.json
if let Some(roaming) = dirs::config_dir() {
for prod in vscode_product_dirs() {
paths.push(roaming.join(prod).join("User").join("settings.json"));
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
{
// Linux: ~/.config/Code/User/settings.json
if let Some(config) = dirs::config_dir() {
for prod in vscode_product_dirs() {
paths.push(config.join(prod).join("User").join("settings.json"));
}
}
}
paths
}
/// 返回第一个存在的 settings.json 路径
pub fn find_existing_settings() -> Option<PathBuf> {
for p in candidate_settings_paths() {
if let Ok(meta) = std::fs::metadata(&p) {
if meta.is_file() {
return Some(p);
}
}
}
None
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.5.0",
"version": "3.3.1",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",

View File

@@ -9,21 +9,25 @@ import { ConfirmDialog } from "./components/ConfirmDialog";
import { AppSwitcher } from "./components/AppSwitcher";
import SettingsModal from "./components/SettingsModal";
import { UpdateBadge } from "./components/UpdateBadge";
import LanguageSwitcher from "./components/LanguageSwitcher";
import { Plus, Settings, Moon, Sun } from "lucide-react";
import McpPanel from "./components/mcp/McpPanel";
import { buttonStyles } from "./lib/styles";
import { useDarkMode } from "./hooks/useDarkMode";
import { extractErrorMessage } from "./utils/errorUtils";
import { applyProviderToVSCode } from "./utils/vscodeSettings";
import { getCodexBaseUrl } from "./utils/providerConfigUtils";
import { useVSCodeAutoSync } from "./hooks/useVSCodeAutoSync";
function App() {
const { t } = useTranslation();
const { isDarkMode, toggleDarkMode } = useDarkMode();
const { isAutoSyncEnabled } = useVSCodeAutoSync();
const [activeApp, setActiveApp] = useState<AppType>("claude");
const [providers, setProviders] = useState<Record<string, Provider>>({});
const [currentProviderId, setCurrentProviderId] = useState<string>("");
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [editingProviderId, setEditingProviderId] = useState<string | null>(
null,
null
);
const [notification, setNotification] = useState<{
message: string;
@@ -37,14 +41,13 @@ function App() {
onConfirm: () => void;
} | null>(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isMcpOpen, setIsMcpOpen] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 设置通知的辅助函数
const showNotification = (
message: string,
type: "success" | "error",
duration = 3000,
duration = 3000
) => {
// 清除之前的定时器
if (timeoutRef.current) {
@@ -96,9 +99,9 @@ function App() {
await loadProviders();
}
// 若为 Claude则同步插件配置
if (data.appType === "claude") {
await syncClaudePlugin(data.providerId, true);
// 若为 Codex 且开启自动同步,则静默同步到 VS Code覆盖
if (data.appType === "codex" && isAutoSyncEnabled) {
await syncCodexToVSCode(data.providerId, true);
}
});
} catch (error) {
@@ -114,7 +117,7 @@ function App() {
unlisten();
}
};
}, [activeApp]);
}, [activeApp, isAutoSyncEnabled]);
const loadProviders = async () => {
const loadedProviders = await window.api.getProviders(activeApp);
@@ -183,74 +186,81 @@ function App() {
});
};
// 同步 Claude 插件配置(按设置决定是否联动;开启时:非官方写入,官方移除
const syncClaudePlugin = async (providerId: string, silent = false) => {
// 同步Codex供应商到VS Code设置静默覆盖
const syncCodexToVSCode = async (providerId: string, silent = false) => {
try {
const settings = await window.api.getSettings();
if (!(settings as any)?.enableClaudePluginIntegration) {
// 未开启联动:不执行写入/移除
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
if (!silent) {
showNotification(
t("notifications.vscodeSettingsNotFound"),
"error",
3000
);
}
return;
}
const raw = await window.api.readVSCodeSettings();
const provider = providers[providerId];
if (!provider) return;
const isOfficial = provider.category === "official";
await window.api.applyClaudePluginConfig({ official: isOfficial });
if (!silent) {
showNotification(
isOfficial
? t("notifications.removedFromClaudePlugin")
: t("notifications.appliedToClaudePlugin"),
"success",
2000,
);
const isOfficial = provider?.category === "official";
// 非官方供应商需要解析 base_url使用公共工具函数
let baseUrl: string | undefined = undefined;
if (!isOfficial) {
const parsed = getCodexBaseUrl(provider);
if (!parsed) {
if (!silent) {
showNotification(t("notifications.missingBaseUrl"), "error", 4000);
}
return;
}
baseUrl = parsed;
}
const updatedSettings = applyProviderToVSCode(raw, {
baseUrl,
isOfficial,
});
if (updatedSettings !== raw) {
await window.api.writeVSCodeSettings(updatedSettings);
if (!silent) {
showNotification(t("notifications.syncedToVSCode"), "success", 1500);
}
}
// 触发providers重新加载以更新VS Code按钮状态
await loadProviders();
} catch (error: any) {
console.error("同步 Claude 插件失败:", error);
console.error(t("console.syncToVSCodeFailed"), error);
if (!silent) {
const message =
error?.message || t("notifications.syncClaudePluginFailed");
showNotification(message, "error", 5000);
const errorMessage =
error?.message || t("notifications.syncVSCodeFailed");
showNotification(errorMessage, "error", 5000);
}
}
};
const handleSwitchProvider = async (id: string) => {
try {
const success = await window.api.switchProvider(id, activeApp);
if (success) {
setCurrentProviderId(id);
// 显示重启提示
const appName = t(`apps.${activeApp}`);
showNotification(
t("notifications.switchSuccess", { appName }),
"success",
2000,
);
// 更新托盘菜单
await window.api.updateTrayMenu();
if (activeApp === "claude") {
await syncClaudePlugin(id, true);
}
} else {
showNotification(t("notifications.switchFailed"), "error");
}
} catch (error) {
const detail = extractErrorMessage(error);
const msg = detail
? `${t("notifications.switchFailed")}: ${detail}`
: t("notifications.switchFailed");
// 详细错误展示稍长时间,便于用户阅读
showNotification(msg, "error", detail ? 6000 : 3000);
}
};
const handleImportSuccess = async () => {
await loadProviders();
try {
const success = await window.api.switchProvider(id, activeApp);
if (success) {
setCurrentProviderId(id);
// 显示重启提示
const appName = t(`apps.${activeApp}`);
showNotification(
t("notifications.switchSuccess", { appName }),
"success",
2000
);
// 更新托盘菜单
await window.api.updateTrayMenu();
} catch (error) {
console.error("[App] Failed to refresh tray menu after import", error);
// Codex: 切换供应商后,只在自动同步启用时同步到 VS Code
if (activeApp === "codex" && isAutoSyncEnabled) {
await syncCodexToVSCode(id, true); // silent模式不显示通知
}
} else {
showNotification(t("notifications.switchFailed"), "error");
}
};
@@ -298,6 +308,7 @@ function App() {
>
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
</button>
<LanguageSwitcher />
<div className="flex items-center gap-2">
<button
onClick={() => setIsSettingsOpen(true)}
@@ -313,13 +324,6 @@ function App() {
<div className="flex items-center gap-4">
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<button
onClick={() => setIsMcpOpen(true)}
className="inline-flex items-center gap-2 px-7 py-2 text-sm font-medium rounded-lg transition-colors bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
>
MCP
</button>
<button
onClick={() => setIsAddModalOpen(true)}
className={`inline-flex items-center gap-2 ${buttonStyles.primary}`}
@@ -338,7 +342,7 @@ function App() {
{/* 通知组件 - 相对于视窗定位 */}
{notification && (
<div
className={`fixed top-20 left-1/2 transform -translate-x-1/2 z-[80] px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
className={`fixed top-20 left-1/2 transform -translate-x-1/2 z-50 px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
notification.type === "error"
? "bg-red-500 text-white"
: "bg-green-500 text-white"
@@ -354,6 +358,7 @@ function App() {
onSwitch={handleSwitchProvider}
onDelete={handleDeleteProvider}
onEdit={setEditingProviderId}
appType={activeApp}
onNotify={showNotification}
/>
</div>
@@ -388,19 +393,7 @@ function App() {
)}
{isSettingsOpen && (
<SettingsModal
onClose={() => setIsSettingsOpen(false)}
onImportSuccess={handleImportSuccess}
onNotify={showNotification}
/>
)}
{isMcpOpen && (
<McpPanel
appType={activeApp}
onClose={() => setIsMcpOpen(false)}
onNotify={showNotification}
/>
<SettingsModal onClose={() => setIsSettingsOpen(false)} />
)}
</div>
);

View File

@@ -17,15 +17,10 @@ const AddProviderModal: React.FC<AddProviderModalProps> = ({
}) => {
const { t } = useTranslation();
const title =
appType === "claude"
? t("provider.addClaudeProvider")
: t("provider.addCodexProvider");
return (
<ProviderForm
appType={appType}
title={title}
title={t("provider.addNewProvider")}
submitText={t("common.add")}
showPresets={true}
onSubmit={onAdd}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../types";
import { AppType } from "../lib/tauri-api";
@@ -18,32 +18,6 @@ const EditProviderModal: React.FC<EditProviderModalProps> = ({
onClose,
}) => {
const { t } = useTranslation();
const [effectiveProvider, setEffectiveProvider] =
useState<Provider>(provider);
// 若为当前应用且正在编辑“当前供应商”,则优先读取 live 配置作为初始值Claude/Codex 均适用)
useEffect(() => {
let mounted = true;
const maybeLoadLive = async () => {
try {
const currentId = await window.api.getCurrentProvider(appType);
if (currentId && currentId === provider.id) {
const live = await window.api.getLiveProviderSettings(appType);
if (!mounted) return;
setEffectiveProvider({ ...provider, settingsConfig: live });
} else {
setEffectiveProvider(provider);
}
} catch (e) {
// 读取失败则回退到原 provider
setEffectiveProvider(provider);
}
};
maybeLoadLive();
return () => {
mounted = false;
};
}, [appType, provider]);
const handleSubmit = (data: Omit<Provider, "id">) => {
onSave({
@@ -52,17 +26,12 @@ const EditProviderModal: React.FC<EditProviderModalProps> = ({
});
};
const title =
appType === "claude"
? t("provider.editClaudeProvider")
: t("provider.editCodexProvider");
return (
<ProviderForm
appType={appType}
title={title}
title={t("common.edit")}
submitText={t("common.save")}
initialData={effectiveProvider}
initialData={provider}
showPresets={false}
onSubmit={handleSubmit}
onClose={onClose}

View File

@@ -1,107 +0,0 @@
import { useEffect } from "react";
import { CheckCircle, Loader2, AlertCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
interface ImportProgressModalProps {
status: "importing" | "success" | "error";
message?: string;
backupId?: string;
onComplete?: () => void;
onSuccess?: () => void;
}
export function ImportProgressModal({
status,
message,
backupId,
onComplete,
onSuccess,
}: ImportProgressModalProps) {
const { t } = useTranslation();
useEffect(() => {
if (status === "success") {
console.log(
"[ImportProgressModal] Success detected, starting 2 second countdown",
);
// 成功后等待2秒自动关闭并刷新数据
const timer = setTimeout(() => {
console.log(
"[ImportProgressModal] 2 seconds elapsed, calling callbacks...",
);
if (onSuccess) {
onSuccess();
}
if (onComplete) {
onComplete();
}
}, 2000);
return () => {
console.log("[ImportProgressModal] Cleanup timer");
clearTimeout(timer);
};
}
}, [status, onComplete, onSuccess]);
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl p-8 max-w-md w-full mx-4">
<div className="flex flex-col items-center text-center">
{status === "importing" && (
<>
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{t("settings.importing")}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t("common.loading")}
</p>
</>
)}
{status === "success" && (
<>
<CheckCircle className="w-12 h-12 text-green-500 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{t("settings.importSuccess")}
</h3>
{backupId && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{t("settings.backupId")}: {backupId}
</p>
)}
<p className="text-sm text-gray-600 dark:text-gray-400">
{t("settings.autoReload")}
</p>
</>
)}
{status === "error" && (
<>
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{t("settings.importFailed")}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{message || t("settings.configCorrupted")}
</p>
<button
onClick={() => {
if (onComplete) {
onComplete();
}
}}
className="mt-4 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
>
{t("common.close")}
</button>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -5,7 +5,6 @@ import { oneDark } from "@codemirror/theme-one-dark";
import { EditorState } from "@codemirror/state";
import { placeholder } from "@codemirror/view";
import { linter, Diagnostic } from "@codemirror/lint";
import { useTranslation } from "react-i18next";
interface JsonEditorProps {
value: string;
@@ -24,7 +23,6 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
rows = 12,
showValidation = true,
}) => {
const { t } = useTranslation();
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
@@ -48,13 +46,12 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
from: 0,
to: doc.length,
severity: "error",
message: t("jsonEditor.mustBeObject"),
message: "配置必须是JSON对象不能是数组或其他类型",
});
}
} catch (e) {
// 简单处理JSON解析错误
const message =
e instanceof SyntaxError ? e.message : t("jsonEditor.invalidJson");
const message = e instanceof SyntaxError ? e.message : "JSON格式错误";
diagnostics.push({
from: 0,
to: doc.length,
@@ -65,7 +62,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
return diagnostics;
}),
[showValidation, t],
[showValidation],
);
useEffect(() => {

View File

@@ -0,0 +1,31 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Globe } from "lucide-react";
import { buttonStyles } from "../lib/styles";
const LanguageSwitcher: React.FC = () => {
const { t, i18n } = useTranslation();
const toggleLanguage = () => {
const newLang = i18n.language === "en" ? "zh" : "en";
i18n.changeLanguage(newLang);
};
const titleKey =
i18n.language === "en"
? "header.switchToChinese"
: "header.switchToEnglish";
return (
<button
onClick={toggleLanguage}
className={buttonStyles.icon}
title={t(titleKey)}
aria-label={t(titleKey)}
>
<Globe size={18} />
</button>
);
};
export default LanguageSwitcher;

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
import React, { useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { useTranslation } from "react-i18next";
interface ApiKeyInputProps {
value: string;
@@ -15,13 +14,12 @@ interface ApiKeyInputProps {
const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
value,
onChange,
placeholder,
placeholder = "请输入API Key",
disabled = false,
required = false,
label = "API Key",
id = "apiKey",
}) => {
const { t } = useTranslation();
const [showKey, setShowKey] = useState(false);
const toggleShowKey = () => {
@@ -48,7 +46,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? t("apiKeyInput.placeholder")}
placeholder={placeholder}
disabled={disabled}
required={required}
autoComplete="off"
@@ -59,7 +57,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
type="button"
onClick={toggleShowKey}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
aria-label={showKey ? t("apiKeyInput.hide") : t("apiKeyInput.show")}
aria-label={showKey ? "隐藏API Key" : "显示API Key"}
>
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>

View File

@@ -2,7 +2,6 @@ import React, { useEffect, useState } from "react";
import JsonEditor from "../JsonEditor";
import { X, Save } from "lucide-react";
import { isLinux } from "../../lib/platform";
import { useTranslation } from "react-i18next";
interface ClaudeConfigEditorProps {
value: string;
@@ -25,7 +24,6 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
commonConfigError,
configError,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
@@ -84,7 +82,7 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
htmlFor="settingsConfig"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
{t("claudeConfig.configLabel")}
Claude Code (JSON) *
</label>
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
<input
@@ -93,7 +91,7 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
{t("claudeConfig.writeCommonConfig")}
</label>
</div>
<div className="flex items-center justify-end">
@@ -102,7 +100,7 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
onClick={() => setIsCommonConfigModalOpen(true)}
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
>
{t("claudeConfig.editCommonConfig")}
</button>
</div>
{commonConfigError && !isCommonConfigModalOpen && (
@@ -126,7 +124,7 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("claudeConfig.fullSettingsHint")}
Claude Code settings.json
</p>
{isCommonConfigModalOpen && (
<div
@@ -147,13 +145,13 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
{/* Header - 统一标题栏样式 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{t("claudeConfig.editCommonConfigTitle")}
</h2>
<button
type="button"
onClick={closeModal}
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
aria-label={t("common.close")}
aria-label="关闭"
>
<X size={18} />
</button>
@@ -162,7 +160,7 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
{/* Content - 统一内容区域样式 */}
<div className="flex-1 overflow-auto p-6 space-y-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("claudeConfig.commonConfigHint")}
"写入通用配置" settings.json
</p>
<JsonEditor
value={commonConfigSnippet}
@@ -184,7 +182,7 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
onClick={closeModal}
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{t("common.cancel")}
</button>
<button
type="button"
@@ -192,7 +190,7 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
>
<Save className="w-4 h-4" />
{t("common.save")}
</button>
</div>
</div>

View File

@@ -3,7 +3,6 @@ import React, { useState, useEffect, useRef } from "react";
import { X, Save } from "lucide-react";
import { isLinux } from "../../lib/platform";
import { useTranslation } from "react-i18next";
import {
generateThirdPartyAuth,
@@ -75,7 +74,6 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
setIsTemplateModalOpen: externalSetTemplateModalOpen,
}) => {
const { t } = useTranslation();
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
// 使用内部状态或外部状态
@@ -170,7 +168,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
trimmedBaseUrl,
trimmedModel,
trimmedModel
);
onAuthChange(JSON.stringify(auth, null, 2));
@@ -208,7 +206,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
};
const handleTemplateInputKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>,
e: React.KeyboardEvent<HTMLInputElement>
) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -238,7 +236,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
htmlFor="codexAuth"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
{t("codexConfig.authJson")}
auth.json (JSON) *
</label>
<textarea
@@ -246,7 +244,9 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
value={authValue}
onChange={(e) => handleAuthChange(e.target.value)}
onBlur={onAuthBlur}
placeholder={t("codexConfig.authJsonPlaceholder")}
placeholder={`{
"OPENAI_API_KEY": "sk-your-api-key-here"
}`}
rows={6}
required
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[8rem]"
@@ -266,7 +266,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("codexConfig.authJsonHint")}
Codex auth.json
</p>
</div>
@@ -276,7 +276,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
htmlFor="codexConfig"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
{t("codexConfig.configToml")}
config.toml (TOML)
</label>
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
@@ -286,7 +286,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
{t("codexConfig.writeCommonConfig")}
</label>
</div>
@@ -296,7 +296,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
onClick={() => setIsCommonConfigModalOpen(true)}
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
>
{t("codexConfig.editCommonConfig")}
</button>
</div>
@@ -325,7 +325,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("codexConfig.configTomlHint")}
Codex config.toml
</p>
</div>
@@ -348,14 +348,14 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
<div className="flex h-full min-h-0 flex-col" role="form">
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-800">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{t("codexConfig.quickWizard")}
</h2>
<button
type="button"
onClick={closeTemplateModal}
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
aria-label={t("common.close")}
aria-label="关闭"
>
<X size={18} />
</button>
@@ -364,14 +364,15 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
<div className="flex-1 min-h-0 space-y-4 overflow-auto p-6">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
<p className="text-sm text-blue-800 dark:text-blue-200">
{t("codexConfig.wizardHint")}
auth.json config.toml
</p>
</div>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("codexConfig.apiKeyLabel")}
API *
</label>
<input
@@ -381,8 +382,8 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
onChange={(e) => setTemplateApiKey(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
pattern=".*\S.*"
title={t("common.enterValidValue")}
placeholder={t("codexConfig.apiKeyPlaceholder")}
title="请输入有效的内容"
placeholder="sk-your-api-key-here"
required
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
@@ -390,7 +391,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("codexConfig.supplierNameLabel")}
*
</label>
<input
@@ -404,21 +405,21 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
}
}}
onKeyDown={handleTemplateInputKeyDown}
placeholder={t("codexConfig.supplierNamePlaceholder")}
placeholder="例如Codex 官方"
required
pattern=".*\S.*"
title={t("common.enterValidValue")}
title="请输入有效的内容"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t("codexConfig.supplierNameHint")}
使
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("codexConfig.supplierCodeLabel")}
</label>
<input
@@ -426,18 +427,18 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
value={templateProviderName}
onChange={(e) => setTemplateProviderName(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
placeholder={t("codexConfig.supplierCodePlaceholder")}
placeholder="custom可选"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t("codexConfig.supplierCodeHint")}
custom
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("codexConfig.apiUrlLabel")}
API *
</label>
<input
@@ -446,7 +447,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
ref={baseUrlInputRef}
onChange={(e) => setTemplateBaseUrl(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
placeholder={t("codexConfig.apiUrlPlaceholder")}
placeholder="https://your-api-endpoint.com/v1"
required
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
@@ -454,7 +455,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("codexConfig.websiteLabel")}
</label>
<input
@@ -462,18 +463,18 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
value={templateWebsiteUrl}
onChange={(e) => setTemplateWebsiteUrl(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
placeholder={t("codexConfig.websitePlaceholder")}
placeholder="https://example.com"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t("codexConfig.websiteHint")}
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("codexConfig.modelNameLabel")}
*
</label>
<input
@@ -483,8 +484,8 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
onChange={(e) => setTemplateModelName(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
pattern=".*\S.*"
title={t("common.enterValidValue")}
placeholder={t("codexConfig.modelNamePlaceholder")}
title="请输入有效的内容"
placeholder="gpt-5-codex"
required
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
@@ -496,7 +497,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
templateBaseUrl) && (
<div className="space-y-2 border-t border-gray-200 pt-4 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{t("codexConfig.configPreview")}
</h3>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
@@ -509,7 +510,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
{JSON.stringify(
generateThirdPartyAuth(templateApiKey),
null,
2,
2
)}
</pre>
</div>
@@ -526,7 +527,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
templateBaseUrl,
templateModelName,
templateModelName
)
: ""}
</pre>
@@ -542,7 +543,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
onClick={closeTemplateModal}
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-500 transition-colors hover:bg-white hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
>
{t("common.cancel")}
</button>
<button
@@ -557,7 +558,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
className="flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
>
<Save className="h-4 w-4" />
{t("codexConfig.applyConfig")}
</button>
</div>
</div>
@@ -587,14 +588,14 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{t("codexConfig.editCommonConfigTitle")}
Codex
</h2>
<button
type="button"
onClick={closeModal}
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
aria-label={t("common.close")}
aria-label="关闭"
>
<X size={18} />
</button>
@@ -604,7 +605,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
<div className="flex-1 overflow-auto p-6 space-y-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("codexConfig.commonConfigHint")}
"写入通用配置" config.toml
</p>
<textarea
@@ -645,7 +646,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
onClick={closeModal}
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{t("common.cancel")}
</button>
<button
@@ -654,7 +655,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
>
<Save className="w-4 h-4" />
{t("common.save")}
</button>
</div>
</div>

View File

@@ -1,637 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Zap, Loader2, Plus, X, AlertCircle, Save } from "lucide-react";
import { isLinux } from "../../lib/platform";
import type { AppType } from "../../lib/tauri-api";
export interface EndpointCandidate {
id?: string;
url: string;
isCustom?: boolean;
}
interface EndpointSpeedTestProps {
appType: AppType;
providerId?: string;
value: string;
onChange: (url: string) => void;
initialEndpoints: EndpointCandidate[];
visible?: boolean;
onClose: () => void;
// 当自定义端点列表变化时回传(仅包含 isCustom 的条目)
onCustomEndpointsChange?: (urls: string[]) => void;
}
interface EndpointEntry extends EndpointCandidate {
id: string;
latency: number | null;
status?: number;
error?: string | null;
}
const randomId = () => `ep_${Math.random().toString(36).slice(2, 9)}`;
const normalizeEndpointUrl = (url: string): string =>
url.trim().replace(/\/+$/, "");
const buildInitialEntries = (
candidates: EndpointCandidate[],
selected: string,
): EndpointEntry[] => {
const map = new Map<string, EndpointEntry>();
const addCandidate = (candidate: EndpointCandidate) => {
const sanitized = candidate.url ? normalizeEndpointUrl(candidate.url) : "";
if (!sanitized) return;
if (map.has(sanitized)) return;
map.set(sanitized, {
id: candidate.id ?? randomId(),
url: sanitized,
isCustom: candidate.isCustom ?? false,
latency: null,
status: undefined,
error: null,
});
};
candidates.forEach(addCandidate);
const selectedUrl = normalizeEndpointUrl(selected);
if (selectedUrl && !map.has(selectedUrl)) {
addCandidate({ url: selectedUrl, isCustom: true });
}
return Array.from(map.values());
};
const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
appType,
providerId,
value,
onChange,
initialEndpoints,
visible = true,
onClose,
onCustomEndpointsChange,
}) => {
const { t } = useTranslation();
const [entries, setEntries] = useState<EndpointEntry[]>(() =>
buildInitialEntries(initialEndpoints, value),
);
const [customUrl, setCustomUrl] = useState("");
const [addError, setAddError] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [isTesting, setIsTesting] = useState(false);
const [lastError, setLastError] = useState<string | null>(null);
const normalizedSelected = normalizeEndpointUrl(value);
const hasEndpoints = entries.length > 0;
// 加载保存的自定义端点(按正在编辑的供应商)
useEffect(() => {
const loadCustomEndpoints = async () => {
try {
if (!providerId) return;
const customEndpoints = await window.api.getCustomEndpoints(
appType,
providerId,
);
const candidates: EndpointCandidate[] = customEndpoints.map((ep) => ({
url: ep.url,
isCustom: true,
}));
setEntries((prev) => {
const map = new Map<string, EndpointEntry>();
// 先添加现有端点
prev.forEach((entry) => {
map.set(entry.url, entry);
});
// 合并自定义端点
candidates.forEach((candidate) => {
const sanitized = normalizeEndpointUrl(candidate.url);
if (sanitized && !map.has(sanitized)) {
map.set(sanitized, {
id: randomId(),
url: sanitized,
isCustom: true,
latency: null,
status: undefined,
error: null,
});
}
});
return Array.from(map.values());
});
} catch (error) {
console.error(t("endpointTest.loadEndpointsFailed"), error);
}
};
if (visible) {
loadCustomEndpoints();
}
}, [appType, visible, providerId, t]);
useEffect(() => {
setEntries((prev) => {
const map = new Map<string, EndpointEntry>();
prev.forEach((entry) => {
map.set(entry.url, entry);
});
let changed = false;
const mergeCandidate = (candidate: EndpointCandidate) => {
const sanitized = candidate.url
? normalizeEndpointUrl(candidate.url)
: "";
if (!sanitized) return;
const existing = map.get(sanitized);
if (existing) return;
map.set(sanitized, {
id: candidate.id ?? randomId(),
url: sanitized,
isCustom: candidate.isCustom ?? false,
latency: null,
status: undefined,
error: null,
});
changed = true;
};
initialEndpoints.forEach(mergeCandidate);
if (normalizedSelected && !map.has(normalizedSelected)) {
mergeCandidate({ url: normalizedSelected, isCustom: true });
}
if (!changed) {
return prev;
}
return Array.from(map.values());
});
}, [initialEndpoints, normalizedSelected]);
// 将自定义端点变化透传给父组件(仅限 isCustom
useEffect(() => {
if (!onCustomEndpointsChange) return;
try {
const customUrls = Array.from(
new Set(
entries
.filter((e) => e.isCustom)
.map((e) => (e.url ? normalizeEndpointUrl(e.url) : ""))
.filter(Boolean),
),
);
onCustomEndpointsChange(customUrls);
} catch (err) {
// ignore
}
// 仅在 entries 变化时同步
}, [entries, onCustomEndpointsChange]);
const sortedEntries = useMemo(() => {
return entries.slice().sort((a, b) => {
const aLatency = a.latency ?? Number.POSITIVE_INFINITY;
const bLatency = b.latency ?? Number.POSITIVE_INFINITY;
if (aLatency === bLatency) {
return a.url.localeCompare(b.url);
}
return aLatency - bLatency;
});
}, [entries]);
const handleAddEndpoint = useCallback(async () => {
const candidate = customUrl.trim();
let errorMsg: string | null = null;
if (!candidate) {
errorMsg = t("endpointTest.enterValidUrl");
}
let parsed: URL | null = null;
if (!errorMsg) {
try {
parsed = new URL(candidate);
} catch {
errorMsg = t("endpointTest.invalidUrlFormat");
}
}
if (!errorMsg && parsed && !parsed.protocol.startsWith("http")) {
errorMsg = t("endpointTest.onlyHttps");
}
let sanitized = "";
if (!errorMsg && parsed) {
sanitized = normalizeEndpointUrl(parsed.toString());
// 使用当前 entries 做去重校验,避免依赖可能过期的 addError
const isDuplicate = entries.some((entry) => entry.url === sanitized);
if (isDuplicate) {
errorMsg = t("endpointTest.urlExists");
}
}
if (errorMsg) {
setAddError(errorMsg);
return;
}
setAddError(null);
// 保存到后端
try {
if (providerId) {
await window.api.addCustomEndpoint(appType, providerId, sanitized);
}
// 更新本地状态
setEntries((prev) => {
if (prev.some((e) => e.url === sanitized)) return prev;
return [
...prev,
{
id: randomId(),
url: sanitized,
isCustom: true,
latency: null,
status: undefined,
error: null,
},
];
});
if (!normalizedSelected) {
onChange(sanitized);
}
setCustomUrl("");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAddError(message || t("endpointTest.saveFailed"));
console.error(t("endpointTest.addEndpointFailed"), error);
}
}, [
customUrl,
entries,
normalizedSelected,
onChange,
appType,
providerId,
t,
]);
const handleRemoveEndpoint = useCallback(
async (entry: EndpointEntry) => {
// 如果是自定义端点,尝试从后端删除(无 providerId 则仅本地删除)
if (entry.isCustom && providerId) {
try {
await window.api.removeCustomEndpoint(appType, providerId, entry.url);
} catch (error) {
console.error(t("endpointTest.removeEndpointFailed"), error);
return;
}
}
// 更新本地状态
setEntries((prev) => {
const next = prev.filter((item) => item.id !== entry.id);
if (entry.url === normalizedSelected) {
const fallback = next[0];
onChange(fallback ? fallback.url : "");
}
return next;
});
},
[normalizedSelected, onChange, appType, providerId, t],
);
const runSpeedTest = useCallback(async () => {
const urls = entries.map((entry) => entry.url);
if (urls.length === 0) {
setLastError(t("endpointTest.pleaseAddEndpoint"));
return;
}
if (typeof window === "undefined" || !window.api?.testApiEndpoints) {
setLastError(t("endpointTest.testUnavailable"));
return;
}
setIsTesting(true);
setLastError(null);
// 清空所有延迟数据,显示 loading 状态
setEntries((prev) =>
prev.map((entry) => ({
...entry,
latency: null,
status: undefined,
error: null,
})),
);
try {
const results = await window.api.testApiEndpoints(urls, {
timeoutSecs: appType === "codex" ? 12 : 8,
});
const resultMap = new Map(
results.map((item) => [normalizeEndpointUrl(item.url), item]),
);
setEntries((prev) =>
prev.map((entry) => {
const match = resultMap.get(entry.url);
if (!match) {
return {
...entry,
latency: null,
status: undefined,
error: t("endpointTest.noResult"),
};
}
return {
...entry,
latency:
typeof match.latency === "number"
? Math.round(match.latency)
: null,
status: match.status,
error: match.error ?? null,
};
}),
);
if (autoSelect) {
const successful = results
.filter(
(item) => typeof item.latency === "number" && item.latency !== null,
)
.sort((a, b) => (a.latency! || 0) - (b.latency! || 0));
const best = successful[0];
if (best && best.url && best.url !== normalizedSelected) {
onChange(best.url);
}
}
} catch (error) {
const message =
error instanceof Error
? error.message
: `${t("endpointTest.testFailed", { error: String(error) })}`;
setLastError(message);
} finally {
setIsTesting(false);
}
}, [entries, autoSelect, appType, normalizedSelected, onChange, t]);
const handleSelect = useCallback(
async (url: string) => {
if (!url || url === normalizedSelected) return;
// 更新最后使用时间(对自定义端点)
const entry = entries.find((e) => e.url === url);
if (entry?.isCustom && providerId) {
await window.api.updateEndpointLastUsed(appType, providerId, url);
}
onChange(url);
},
[normalizedSelected, onChange, appType, entries, providerId],
);
// 支持按下 ESC 关闭弹窗
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
if (!visible) {
return null;
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg w-full max-w-2xl mx-4 max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-base font-medium text-gray-900 dark:text-gray-100">
{t("endpointTest.title")}
</h3>
<button
type="button"
onClick={onClose}
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
aria-label={t("common.close")}
>
<X size={16} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
{/* 测速控制栏 */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{entries.length} {t("endpointTest.endpoints")}
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={autoSelect}
onChange={(event) => setAutoSelect(event.target.checked)}
className="h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600"
/>
{t("endpointTest.autoSelect")}
</label>
<button
type="button"
onClick={runSpeedTest}
disabled={isTesting || !hasEndpoints}
className="flex h-7 w-20 items-center justify-center gap-1.5 rounded-md bg-blue-500 px-2.5 text-xs font-medium text-white transition hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-blue-600 dark:hover:bg-blue-700"
>
{isTesting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("endpointTest.testing")}
</>
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t("endpointTest.testSpeed")}
</>
)}
</button>
</div>
</div>
{/* 添加输入 */}
<div className="space-y-1.5">
<div className="flex gap-2">
<input
type="url"
value={customUrl}
placeholder={t("endpointTest.addEndpointPlaceholder")}
onChange={(event) => setCustomUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddEndpoint();
}
}}
className="flex-1 rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 transition focus:border-gray-400 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:border-gray-600"
/>
<button
type="button"
onClick={handleAddEndpoint}
className="flex h-8 w-8 items-center justify-center rounded-md border border-gray-200 transition hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-800"
>
<Plus className="h-4 w-4 text-gray-600 dark:text-gray-400" />
</button>
</div>
{addError && (
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3 w-3" />
{addError}
</div>
)}
</div>
{/* 端点列表 */}
{hasEndpoints ? (
<div className="space-y-2">
{sortedEntries.map((entry) => {
const isSelected = normalizedSelected === entry.url;
const latency = entry.latency;
return (
<div
key={entry.id}
onClick={() => handleSelect(entry.url)}
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
isSelected
? "border-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20"
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600 dark:hover:bg-gray-850"
}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
{/* 选择指示器 */}
<div
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full transition ${
isSelected
? "bg-blue-500 dark:bg-blue-400"
: "bg-gray-300 dark:bg-gray-700"
}`}
/>
{/* 内容 */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-gray-900 dark:text-gray-100">
{entry.url}
</div>
</div>
</div>
{/* 右侧信息 */}
<div className="flex items-center gap-2">
{latency !== null ? (
<div className="text-right">
<div
className={`font-mono text-sm font-medium ${
latency < 300
? "text-green-600 dark:text-green-400"
: latency < 500
? "text-yellow-600 dark:text-yellow-400"
: latency < 800
? "text-orange-600 dark:text-orange-400"
: "text-red-600 dark:text-red-400"
}`}
>
{latency}ms
</div>
</div>
) : isTesting ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
) : entry.error ? (
<div className="text-xs text-gray-400">
{t("endpointTest.failed")}
</div>
) : (
<div className="text-xs text-gray-400"></div>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveEndpoint(entry);
}}
className="opacity-0 transition hover:text-red-600 group-hover:opacity-100 dark:hover:text-red-400"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed border-gray-200 bg-gray-50 py-8 text-center text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400">
{t("endpointTest.noEndpoints")}
</div>
)}
{/* 错误提示 */}
{lastError && (
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3 w-3" />
{lastError}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
>
<Save className="w-4 h-4" />
{t("common.save")}
</button>
</div>
</div>
</div>
);
};
export default EndpointSpeedTest;

View File

@@ -1,5 +1,4 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, RefreshCw, AlertCircle } from "lucide-react";
interface KimiModel {
@@ -27,7 +26,6 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
onModelChange,
disabled = false,
}) => {
const { t } = useTranslation();
const [models, setModels] = useState<KimiModel[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -36,7 +34,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
// 获取模型列表
const fetchModelsWithKey = async (key: string) => {
if (!key) {
setError(t("kimiSelector.fillApiKeyFirst"));
setError("请先填写 API Key");
return;
}
@@ -52,11 +50,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
});
if (!response.ok) {
throw new Error(
t("kimiSelector.requestFailed", {
error: `${response.status} ${response.statusText}`,
}),
);
throw new Error(`请求失败: ${response.status} ${response.statusText}`);
}
const data = await response.json();
@@ -64,15 +58,11 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
if (data.data && Array.isArray(data.data)) {
setModels(data.data);
} else {
throw new Error(t("kimiSelector.invalidData"));
throw new Error("返回数据格式错误");
}
} catch (err) {
console.error(t("kimiSelector.fetchModelsFailed") + ":", err);
setError(
err instanceof Error
? err.message
: t("kimiSelector.fetchModelsFailed"),
);
console.error("获取模型列表失败:", err);
setError(err instanceof Error ? err.message : "获取模型列表失败");
} finally {
setLoading(false);
}
@@ -120,10 +110,10 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
>
<option value="">
{loading
? t("common.loading")
? "加载中..."
: models.length === 0
? t("kimiSelector.noModels")
: t("kimiSelector.pleaseSelectModel")}
? "暂无模型"
: "请选择模型"}
</option>
{models.map((model) => (
<option key={model.id} value={model.id}>
@@ -143,7 +133,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{t("kimiSelector.modelConfig")}
</h3>
<button
type="button"
@@ -152,7 +142,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
{t("kimiSelector.refreshModels")}
</button>
</div>
@@ -168,12 +158,12 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ModelSelect
label={t("kimiSelector.mainModel")}
label="主模型"
value={anthropicModel}
onChange={(value) => onModelChange("ANTHROPIC_MODEL", value)}
/>
<ModelSelect
label={t("kimiSelector.fastModel")}
label="快速模型"
value={anthropicSmallFastModel}
onChange={(value) =>
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", value)
@@ -184,7 +174,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
{!apiKey.trim() && (
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
<p className="text-xs text-amber-600 dark:text-amber-400">
{t("kimiSelector.apiKeyHint")}
💡 API Key
</p>
</div>
)}

View File

@@ -1,5 +1,4 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Zap } from "lucide-react";
import { ProviderCategory } from "../../types";
import { ClaudeIcon, CodexIcon } from "../BrandIcons";
@@ -21,16 +20,14 @@ interface PresetSelectorProps {
}
const PresetSelector: React.FC<PresetSelectorProps> = ({
title,
title = "选择配置类型",
presets,
selectedIndex,
onSelectPreset,
onCustomClick,
customLabel,
customLabel = "自定义",
renderCustomDescription,
}) => {
const { t } = useTranslation();
const getButtonClass = (index: number, preset?: Preset) => {
const isSelected = selectedIndex === index;
const baseClass =
@@ -57,14 +54,14 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
if (renderCustomDescription) {
return renderCustomDescription();
}
return t("presetSelector.customDescription");
return "手动配置供应商,需要填写完整的配置信息";
}
if (selectedIndex !== null && selectedIndex >= 0) {
const preset = presets[selectedIndex];
return preset?.isOfficial || preset?.category === "official"
? t("presetSelector.officialDescription")
: t("presetSelector.presetDescription");
? "官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key";
}
return null;
@@ -74,7 +71,7 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{title || t("presetSelector.title")}
{title}
</label>
<div className="flex flex-wrap gap-2">
<button
@@ -82,7 +79,7 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
className={`${getButtonClass(-1)} ${selectedIndex === -1 ? "" : ""}`}
onClick={onCustomClick}
>
{customLabel || t("presetSelector.custom")}
{customLabel}
</button>
{presets.map((preset, index) => (
<button

View File

@@ -1,8 +1,16 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../types";
import { Play, Edit3, Trash2, CheckCircle2, Users, Check } from "lucide-react";
import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react";
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
import { AppType } from "../lib/tauri-api";
import {
applyProviderToVSCode,
detectApplied,
normalizeBaseUrl,
} from "../utils/vscodeSettings";
import { getCodexBaseUrl } from "../utils/providerConfigUtils";
import { useVSCodeAutoSync } from "../hooks/useVSCodeAutoSync";
// 不再在列表中显示分类徽章,避免造成困惑
interface ProviderListProps {
@@ -11,10 +19,11 @@ interface ProviderListProps {
onSwitch: (id: string) => void;
onDelete: (id: string) => void;
onEdit: (id: string) => void;
appType?: AppType;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
duration?: number
) => void;
}
@@ -24,9 +33,10 @@ const ProviderList: React.FC<ProviderListProps> = ({
onSwitch,
onDelete,
onEdit,
appType,
onNotify,
}) => {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
// 提取API地址兼容不同供应商配置Claude env / Codex TOML
const getApiUrl = (provider: Provider): string => {
try {
@@ -52,15 +62,124 @@ const ProviderList: React.FC<ProviderListProps> = ({
await window.api.openExternal(url);
} catch (error) {
console.error(t("console.openLinkFailed"), error);
onNotify?.(
`${t("console.openLinkFailed")}: ${String(error)}`,
"error",
4000,
);
}
};
// 列表页不再提供 Claude 插件按钮,统一在“设置”中控制
// 解析 Codex 配置中的 base_url已提取到公共工具
// VS Code 按钮:仅在 Codex + 当前供应商显示;按钮文案根据是否"已应用"变化
const [vscodeAppliedFor, setVscodeAppliedFor] = useState<string | null>(null);
const { enableAutoSync, disableAutoSync } = useVSCodeAutoSync();
// 当当前供应商或 appType 变化时,尝试读取 VS Code settings 并检测状态
useEffect(() => {
const check = async () => {
if (appType !== "codex" || !currentProviderId) {
setVscodeAppliedFor(null);
return;
}
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
setVscodeAppliedFor(null);
return;
}
try {
const content = await window.api.readVSCodeSettings();
const detected = detectApplied(content);
// 认为“已应用”的条件非官方供应商VS Code 中的 apiBase 与当前供应商的 base_url 完全一致
const current = providers[currentProviderId];
let applied = false;
if (current && current.category !== "official") {
const base = getCodexBaseUrl(current);
if (detected.apiBase && base) {
applied =
normalizeBaseUrl(detected.apiBase) === normalizeBaseUrl(base);
}
}
setVscodeAppliedFor(applied ? currentProviderId : null);
} catch {
setVscodeAppliedFor(null);
}
};
check();
}, [appType, currentProviderId, providers]);
const handleApplyToVSCode = async (provider: Provider) => {
try {
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
onNotify?.(t("notifications.vscodeSettingsNotFound"), "error", 3000);
return;
}
const raw = await window.api.readVSCodeSettings();
const isOfficial = provider.category === "official";
// 非官方且缺少 base_url 时直接报错并返回,避免“空写入”假成功
if (!isOfficial) {
const parsed = getCodexBaseUrl(provider);
if (!parsed) {
onNotify?.(t("notifications.missingBaseUrl"), "error", 4000);
return;
}
}
const baseUrl = isOfficial ? undefined : getCodexBaseUrl(provider);
const next = applyProviderToVSCode(raw, { baseUrl, isOfficial });
if (next === raw) {
// 幂等:没有变化也提示成功
onNotify?.(t("notifications.appliedToVSCode"), "success", 3000);
setVscodeAppliedFor(provider.id);
// 用户手动应用时,启用自动同步
enableAutoSync();
return;
}
await window.api.writeVSCodeSettings(next);
onNotify?.(t("notifications.appliedToVSCode"), "success", 3000);
setVscodeAppliedFor(provider.id);
// 用户手动应用时,启用自动同步
enableAutoSync();
} catch (e: any) {
console.error(e);
const msg =
e && e.message ? e.message : t("notifications.syncVSCodeFailed");
onNotify?.(msg, "error", 5000);
}
};
const handleRemoveFromVSCode = async () => {
try {
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
onNotify?.(t("notifications.vscodeSettingsNotFound"), "error", 3000);
return;
}
const raw = await window.api.readVSCodeSettings();
const next = applyProviderToVSCode(raw, {
baseUrl: undefined,
isOfficial: true,
});
if (next === raw) {
onNotify?.(t("notifications.removedFromVSCode"), "success", 3000);
setVscodeAppliedFor(null);
// 用户手动移除时,禁用自动同步
disableAutoSync();
return;
}
await window.api.writeVSCodeSettings(next);
onNotify?.(t("notifications.removedFromVSCode"), "success", 3000);
setVscodeAppliedFor(null);
// 用户手动移除时,禁用自动同步
disableAutoSync();
} catch (e: any) {
console.error(e);
const msg =
e && e.message ? e.message : t("notifications.syncVSCodeFailed");
onNotify?.(msg, "error", 5000);
}
};
// 对供应商列表进行排序
const sortedProviders = Object.values(providers).sort((a, b) => {
@@ -72,8 +191,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
// 如果都没有时间戳,按名称排序
if (timeA === 0 && timeB === 0) {
const locale = i18n.language === "zh" ? "zh-CN" : "en-US";
return a.name.localeCompare(b.name, locale);
return a.name.localeCompare(b.name, "zh-CN");
}
// 如果只有一个没有时间戳,没有时间戳的排在前面
@@ -108,10 +226,10 @@ const ProviderList: React.FC<ProviderListProps> = ({
<div
key={provider.id}
className={cn(
isCurrent ? cardStyles.selected : cardStyles.interactive,
isCurrent ? cardStyles.selected : cardStyles.interactive
)}
>
<div className="flex items-center justify-between">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
@@ -121,7 +239,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
<div
className={cn(
badgeStyles.success,
!isCurrent && "invisible",
!isCurrent && "invisible"
)}
>
<CheckCircle2 size={12} />
@@ -137,9 +255,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
handleUrlClick(provider.websiteUrl!);
}}
className="inline-flex items-center gap-1 text-blue-500 dark:text-blue-400 hover:opacity-90 transition-colors"
title={t("providerForm.visitWebsite", {
url: provider.websiteUrl,
})}
title={`访问 ${provider.websiteUrl}`}
>
{provider.websiteUrl}
</button>
@@ -155,17 +271,43 @@ const ProviderList: React.FC<ProviderListProps> = ({
</div>
<div className="flex items-center gap-2 ml-4">
{appType === "codex" &&
provider.category !== "official" && (
<button
onClick={() =>
vscodeAppliedFor === provider.id
? handleRemoveFromVSCode()
: handleApplyToVSCode(provider)
}
className={cn(
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[130px] whitespace-nowrap justify-center",
!isCurrent && "invisible",
vscodeAppliedFor === provider.id
? "border border-gray-300 text-gray-600 hover:border-red-300 hover:text-red-600 hover:bg-red-50 dark:border-gray-600 dark:text-gray-400 dark:hover:border-red-800 dark:hover:text-red-400 dark:hover:bg-red-900/20"
: "border border-gray-300 text-gray-700 hover:border-blue-300 hover:text-blue-600 hover:bg-blue-50 dark:border-gray-600 dark:text-gray-300 dark:hover:border-blue-700 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
)}
title={
vscodeAppliedFor === provider.id
? t("provider.removeFromVSCode")
: t("provider.applyToVSCode")
}
>
{vscodeAppliedFor === provider.id
? t("provider.removeFromVSCode")
: t("provider.applyToVSCode")}
</button>
)}
<button
onClick={() => onSwitch(provider.id)}
disabled={isCurrent}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[90px] justify-center whitespace-nowrap",
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[76px] justify-center whitespace-nowrap",
isCurrent
? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed"
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
)}
>
{isCurrent ? <Check size={14} /> : <Play size={14} />}
{!isCurrent && <Play size={14} />}
{isCurrent ? t("provider.inUse") : t("provider.enable")}
</button>
@@ -184,7 +326,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
buttonStyles.icon,
isCurrent
? "text-gray-400 cursor-not-allowed"
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10",
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10"
)}
title={t("provider.deleteProvider")}
>

View File

@@ -12,7 +12,6 @@ import {
Save,
} from "lucide-react";
import { getVersion } from "@tauri-apps/api/app";
import { ImportProgressModal } from "./ImportProgressModal";
import { homeDir, join } from "@tauri-apps/api/path";
import "../lib/tauri-api";
import { relaunchApp } from "../lib/updater";
@@ -23,47 +22,16 @@ import { isLinux } from "../lib/platform";
interface SettingsModalProps {
onClose: () => void;
onImportSuccess?: () => void | Promise<void>;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
}
export default function SettingsModal({
onClose,
onImportSuccess,
onNotify,
}: SettingsModalProps) {
const { t, i18n } = useTranslation();
const normalizeLanguage = (lang?: string | null): "zh" | "en" =>
lang === "en" ? "en" : "zh";
const readPersistedLanguage = (): "zh" | "en" => {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("language");
if (stored === "en" || stored === "zh") {
return stored;
}
}
return normalizeLanguage(i18n.language);
};
const persistedLanguage = readPersistedLanguage();
export default function SettingsModal({ onClose }: SettingsModalProps) {
const { t } = useTranslation();
const [settings, setSettings] = useState<Settings>({
showInTray: true,
minimizeToTrayOnClose: true,
enableClaudePluginIntegration: false,
claudeConfigDir: undefined,
codexConfigDir: undefined,
language: persistedLanguage,
});
const [initialLanguage, setInitialLanguage] = useState<"zh" | "en">(
persistedLanguage,
);
const [configPath, setConfigPath] = useState<string>("");
const [version, setVersion] = useState<string>("");
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
@@ -75,15 +43,6 @@ export default function SettingsModal({
const { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } =
useUpdate();
// 导入/导出相关状态
const [isImporting, setIsImporting] = useState(false);
const [importStatus, setImportStatus] = useState<
"idle" | "importing" | "success" | "error"
>("idle");
const [importError, setImportError] = useState<string>("");
const [importBackupId, setImportBackupId] = useState<string>("");
const [selectedImportFile, setSelectedImportFile] = useState<string>("");
useEffect(() => {
loadSettings();
loadConfigPath();
@@ -114,20 +73,9 @@ export default function SettingsModal({
(loadedSettings as any)?.minimizeToTrayOnClose ??
(loadedSettings as any)?.minimize_to_tray_on_close ??
true;
const storedLanguage = normalizeLanguage(
typeof (loadedSettings as any)?.language === "string"
? (loadedSettings as any).language
: persistedLanguage,
);
setSettings({
showInTray,
minimizeToTrayOnClose,
enableClaudePluginIntegration:
typeof (loadedSettings as any)?.enableClaudePluginIntegration ===
"boolean"
? (loadedSettings as any).enableClaudePluginIntegration
: false,
claudeConfigDir:
typeof (loadedSettings as any)?.claudeConfigDir === "string"
? (loadedSettings as any).claudeConfigDir
@@ -136,12 +84,7 @@ export default function SettingsModal({
typeof (loadedSettings as any)?.codexConfigDir === "string"
? (loadedSettings as any).codexConfigDir
: undefined,
language: storedLanguage,
});
setInitialLanguage(storedLanguage);
if (i18n.language !== storedLanguage) {
void i18n.changeLanguage(storedLanguage);
}
} catch (error) {
console.error(t("console.loadSettingsFailed"), error);
}
@@ -182,7 +125,6 @@ export default function SettingsModal({
const saveSettings = async () => {
try {
const selectedLanguage = settings.language === "en" ? "en" : "zh";
const payload: Settings = {
...settings,
claudeConfigDir:
@@ -193,52 +135,15 @@ export default function SettingsModal({
settings.codexConfigDir && settings.codexConfigDir.trim() !== ""
? settings.codexConfigDir.trim()
: undefined,
language: selectedLanguage,
};
await window.api.saveSettings(payload);
// 立即生效:根据开关无条件写入/移除 ~/.claude/config.json
try {
if (payload.enableClaudePluginIntegration) {
await window.api.applyClaudePluginConfig({ official: false });
} else {
await window.api.applyClaudePluginConfig({ official: true });
}
} catch (e) {
console.warn("[Settings] Apply Claude plugin config on save failed", e);
}
setSettings(payload);
try {
window.localStorage.setItem("language", selectedLanguage);
} catch (error) {
console.warn("[Settings] Failed to persist language preference", error);
}
setInitialLanguage(selectedLanguage);
if (i18n.language !== selectedLanguage) {
void i18n.changeLanguage(selectedLanguage);
}
onClose();
} catch (error) {
console.error(t("console.saveSettingsFailed"), error);
}
};
const handleLanguageChange = (lang: "zh" | "en") => {
setSettings((prev) => ({ ...prev, language: lang }));
if (i18n.language !== lang) {
void i18n.changeLanguage(lang);
}
};
const handleCancel = () => {
if (settings.language !== initialLanguage) {
setSettings((prev) => ({ ...prev, language: initialLanguage }));
if (i18n.language !== initialLanguage) {
void i18n.changeLanguage(initialLanguage);
}
}
onClose();
};
const handleCheckUpdate = async () => {
if (hasUpdate && updateHandle) {
if (isPortable) {
@@ -367,7 +272,7 @@ export default function SettingsModal({
// 如果未知或为空,回退到 releases 首页
if (!targetVersion || targetVersion === unknownLabel) {
await window.api.openExternal(
"https://github.com/farion1231/cc-switch/releases",
"https://github.com/farion1231/cc-switch/releases"
);
return;
}
@@ -375,97 +280,18 @@ export default function SettingsModal({
? targetVersion
: `v${targetVersion}`;
await window.api.openExternal(
`https://github.com/farion1231/cc-switch/releases/tag/${tag}`,
`https://github.com/farion1231/cc-switch/releases/tag/${tag}`
);
} catch (error) {
console.error(t("console.openReleaseNotesFailed"), error);
}
};
// 导出配置处理函数
const handleExportConfig = async () => {
try {
const defaultName = `cc-switch-config-${new Date().toISOString().split("T")[0]}.json`;
const filePath = await window.api.saveFileDialog(defaultName);
if (!filePath) {
onNotify?.(
`${t("settings.exportFailed")}: ${t("settings.selectFileFailed")}`,
"error",
4000,
);
return;
}
const result = await window.api.exportConfigToFile(filePath);
if (result.success) {
onNotify?.(
`${t("settings.configExported")}\n${result.filePath}`,
"success",
4000,
);
}
} catch (error) {
console.error(t("settings.exportFailedError"), error);
onNotify?.(
`${t("settings.exportFailed")}: ${String(error)}`,
"error",
5000,
);
}
};
// 选择要导入的文件
const handleSelectImportFile = async () => {
try {
const filePath = await window.api.openFileDialog();
if (filePath) {
setSelectedImportFile(filePath);
setImportStatus("idle"); // 重置状态
setImportError("");
}
} catch (error) {
console.error(t("settings.selectFileFailed") + ":", error);
onNotify?.(
`${t("settings.selectFileFailed")}: ${String(error)}`,
"error",
5000,
);
}
};
// 执行导入
const handleExecuteImport = async () => {
if (!selectedImportFile || isImporting) return;
setIsImporting(true);
setImportStatus("importing");
try {
const result = await window.api.importConfigFromFile(selectedImportFile);
if (result.success) {
setImportBackupId(result.backupId || "");
setImportStatus("success");
// ImportProgressModal 会在2秒后触发数据刷新回调
} else {
setImportError(result.message || t("settings.configCorrupted"));
setImportStatus("error");
}
} catch (error) {
setImportError(String(error));
setImportStatus("error");
} finally {
setIsImporting(false);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) handleCancel();
if (e.target === e.currentTarget) onClose();
}}
>
<div
@@ -480,7 +306,7 @@ export default function SettingsModal({
{t("settings.title")}
</h2>
<button
onClick={handleCancel}
onClick={onClose}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
@@ -489,37 +315,6 @@ export default function SettingsModal({
{/* 设置内容 */}
<div className="px-6 py-4 space-y-6 overflow-y-auto flex-1">
{/* 语言设置 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("settings.language")}
</h3>
<div className="inline-flex p-0.5 bg-gray-100 dark:bg-gray-800 rounded-lg">
<button
type="button"
onClick={() => handleLanguageChange("zh")}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all min-w-[80px] ${
(settings.language ?? "zh") === "zh"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
{t("settings.languageOptionChinese")}
</button>
<button
type="button"
onClick={() => handleLanguageChange("en")}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all min-w-[80px] ${
settings.language === "en"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
{t("settings.languageOptionEnglish")}
</button>
</div>
</div>
{/* 窗口行为设置 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
@@ -547,31 +342,11 @@ export default function SettingsModal({
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
/>
</label>
{/* Claude 插件联动开关 */}
<label className="flex items-center justify-between">
<div>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("settings.enableClaudePluginIntegration")}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 max-w-[34rem]">
{t("settings.enableClaudePluginIntegrationDescription")}
</p>
</div>
<input
type="checkbox"
checked={!!settings.enableClaudePluginIntegration}
onChange={(e) =>
setSettings((prev) => ({
...prev,
enableClaudePluginIntegration: e.target.checked,
}))
}
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
/>
</label>
</div>
</div>
{/* VS Code 自动同步设置已移除 */}
{/* 配置文件位置 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
@@ -679,60 +454,6 @@ export default function SettingsModal({
</div>
</div>
{/* 导入导出 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("settings.importExport")}
</h3>
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="space-y-3">
{/* 导出按钮 */}
<button
onClick={handleExportConfig}
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium rounded-lg transition-colors bg-gray-500 hover:bg-gray-600 dark:bg-gray-600 dark:hover:bg-gray-700 text-white"
>
<Save size={12} />
{t("settings.exportConfig")}
</button>
{/* 导入区域 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={handleSelectImportFile}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium rounded-lg transition-colors bg-gray-500 hover:bg-gray-600 dark:bg-gray-600 dark:hover:bg-gray-700 text-white"
>
<FolderOpen size={12} />
{t("settings.selectConfigFile")}
</button>
<button
onClick={handleExecuteImport}
disabled={!selectedImportFile || isImporting}
className={`px-3 py-2 text-xs font-medium rounded-lg transition-colors text-white ${
!selectedImportFile || isImporting
? "bg-gray-400 cursor-not-allowed"
: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
}`}
>
{isImporting
? t("settings.importing")
: t("settings.import")}
</button>
</div>
{/* 显示选择的文件 */}
{selectedImportFile && (
<div className="text-xs text-gray-600 dark:text-gray-400 px-2 py-1 bg-gray-50 dark:bg-gray-900 rounded break-all">
{selectedImportFile.split("/").pop() ||
selectedImportFile.split("\\").pop() ||
selectedImportFile}
</div>
)}
</div>
</div>
</div>
</div>
{/* 关于 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
@@ -813,7 +534,7 @@ export default function SettingsModal({
{/* 底部按钮 */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-800">
<button
onClick={handleCancel}
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
{t("common.cancel")}
@@ -827,33 +548,6 @@ export default function SettingsModal({
</button>
</div>
</div>
{/* Import Progress Modal */}
{importStatus !== "idle" && (
<ImportProgressModal
status={importStatus}
message={importError}
backupId={importBackupId}
onComplete={() => {
setImportStatus("idle");
setImportError("");
setSelectedImportFile("");
}}
onSuccess={() => {
if (onImportSuccess) {
void onImportSuccess();
}
void window.api
.updateTrayMenu()
.catch((error) =>
console.error(
"[SettingsModal] Failed to refresh tray menu",
error,
),
);
}}
/>
)}
</div>
);
}

View File

@@ -1,6 +1,5 @@
import { X, Download } from "lucide-react";
import { useUpdate } from "../contexts/UpdateContext";
import { useTranslation } from "react-i18next";
interface UpdateBadgeProps {
className?: string;
@@ -9,7 +8,6 @@ interface UpdateBadgeProps {
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
const { hasUpdate, updateInfo, isDismissed, dismissUpdate } = useUpdate();
const { t } = useTranslation();
// 如果没有更新或已关闭,不显示
if (!hasUpdate || isDismissed || !updateInfo) {
@@ -54,7 +52,7 @@ export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
transition-colors
focus:outline-none focus:ring-2 focus:ring-blue-500/20
"
aria-label={t("common.close")}
aria-label="关闭更新提醒"
>
<X className="w-3 h-3 text-gray-400 dark:text-gray-500" />
</button>

View File

@@ -1,669 +0,0 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { X, Save, AlertCircle, ChevronDown, ChevronUp } from "lucide-react";
import { McpServer, McpServerSpec } from "../../types";
import {
mcpPresets,
getMcpPresetWithDescription,
} from "../../config/mcpPresets";
import { buttonStyles, inputStyles } from "../../lib/styles";
import McpWizardModal from "./McpWizardModal";
import {
extractErrorMessage,
translateMcpBackendError,
} from "../../utils/errorUtils";
import { AppType } from "../../lib/tauri-api";
import {
validateToml,
tomlToMcpServer,
extractIdFromToml,
mcpServerToToml,
} from "../../utils/tomlUtils";
interface McpFormModalProps {
appType: AppType;
editingId?: string;
initialData?: McpServer;
onSave: (id: string, server: McpServer) => Promise<void>;
onClose: () => void;
existingIds?: string[];
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
}
/**
* MCP 表单模态框组件(简化版)
* Claude: 使用 JSON 格式
* Codex: 使用 TOML 格式
*/
const McpFormModal: React.FC<McpFormModalProps> = ({
appType,
editingId,
initialData,
onSave,
onClose,
existingIds = [],
onNotify,
}) => {
const { t } = useTranslation();
// JSON 基本校验(返回 i18n 文案)
const validateJson = (text: string): string => {
if (!text.trim()) return "";
try {
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return t("mcp.error.jsonInvalid");
}
return "";
} catch {
return t("mcp.error.jsonInvalid");
}
};
// 统一格式化 TOML 错误(本地化 + 详情)
const formatTomlError = (err: string): string => {
if (!err) return "";
if (err === "mustBeObject" || err === "parseError") {
return t("mcp.error.tomlInvalid");
}
return `${t("mcp.error.tomlInvalid")}: ${err}`;
};
const [formId, setFormId] = useState(
() => editingId || initialData?.id || "",
);
const [formName, setFormName] = useState(initialData?.name || "");
const [formDescription, setFormDescription] = useState(
initialData?.description || "",
);
const [formHomepage, setFormHomepage] = useState(initialData?.homepage || "");
const [formDocs, setFormDocs] = useState(initialData?.docs || "");
const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || "");
// 编辑模式下禁止修改 ID
const isEditing = !!editingId;
// 判断是否在编辑模式下有附加信息
const hasAdditionalInfo = !!(
initialData?.description ||
initialData?.tags?.length ||
initialData?.homepage ||
initialData?.docs
);
// 附加信息展开状态(编辑模式下有值时默认展开)
const [showMetadata, setShowMetadata] = useState(
isEditing ? hasAdditionalInfo : false,
);
// 根据 appType 决定初始格式
const [formConfig, setFormConfig] = useState(() => {
const spec = initialData?.server;
if (!spec) return "";
if (appType === "codex") {
return mcpServerToToml(spec);
}
return JSON.stringify(spec, null, 2);
});
const [configError, setConfigError] = useState("");
const [saving, setSaving] = useState(false);
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [idError, setIdError] = useState("");
// 判断是否使用 TOML 格式
const useToml = appType === "codex";
// 预设选择状态(仅新增模式显示;-1 表示自定义)
const [selectedPreset, setSelectedPreset] = useState<number | null>(
isEditing ? null : -1,
);
const handleIdChange = (value: string) => {
setFormId(value);
if (!isEditing) {
const exists = existingIds.includes(value.trim());
setIdError(exists ? t("mcp.error.idExists") : "");
}
};
const ensureUniqueId = (base: string): string => {
let candidate = base.trim();
if (!candidate) candidate = "mcp-server";
if (!existingIds.includes(candidate)) return candidate;
let i = 1;
while (existingIds.includes(`${candidate}-${i}`)) i++;
return `${candidate}-${i}`;
};
// 应用预设(写入表单但不落库)
const applyPreset = (index: number) => {
if (index < 0 || index >= mcpPresets.length) return;
const preset = mcpPresets[index];
const presetWithDesc = getMcpPresetWithDescription(preset, t);
const id = ensureUniqueId(presetWithDesc.id);
setFormId(id);
setFormName(presetWithDesc.name || presetWithDesc.id);
setFormDescription(presetWithDesc.description || "");
setFormHomepage(presetWithDesc.homepage || "");
setFormDocs(presetWithDesc.docs || "");
setFormTags(presetWithDesc.tags?.join(", ") || "");
// 根据格式转换配置
if (useToml) {
const toml = mcpServerToToml(presetWithDesc.server);
setFormConfig(toml);
{
const err = validateToml(toml);
setConfigError(formatTomlError(err));
}
} else {
const json = JSON.stringify(presetWithDesc.server, null, 2);
setFormConfig(json);
setConfigError(validateJson(json));
}
setSelectedPreset(index);
};
// 切回自定义
const applyCustom = () => {
setSelectedPreset(-1);
// 恢复到空白模板
setFormId("");
setFormName("");
setFormDescription("");
setFormHomepage("");
setFormDocs("");
setFormTags("");
setFormConfig("");
setConfigError("");
};
const handleConfigChange = (value: string) => {
setFormConfig(value);
if (useToml) {
// TOML 校验
const err = validateToml(value);
if (err) {
setConfigError(formatTomlError(err));
return;
}
// 尝试解析并做必填字段提示
if (value.trim()) {
try {
const server = tomlToMcpServer(value);
if (server.type === "stdio" && !server.command?.trim()) {
setConfigError(t("mcp.error.commandRequired"));
return;
}
if (server.type === "http" && !server.url?.trim()) {
setConfigError(t("mcp.wizard.urlRequired"));
return;
}
// 尝试提取 ID如果用户还没有填写
if (!formId.trim()) {
const extractedId = extractIdFromToml(value);
if (extractedId) {
setFormId(extractedId);
}
}
} catch (e: any) {
const msg = e?.message || String(e);
setConfigError(formatTomlError(msg));
return;
}
}
} else {
// JSON 校验
const baseErr = validateJson(value);
if (baseErr) {
setConfigError(baseErr);
return;
}
// 进一步结构校验
if (value.trim()) {
try {
const obj = JSON.parse(value);
if (obj && typeof obj === "object") {
if (Object.prototype.hasOwnProperty.call(obj, "mcpServers")) {
setConfigError(t("mcp.error.singleServerObjectRequired"));
return;
}
const typ = (obj as any)?.type;
if (typ === "stdio" && !(obj as any)?.command?.trim()) {
setConfigError(t("mcp.error.commandRequired"));
return;
}
if (typ === "http" && !(obj as any)?.url?.trim()) {
setConfigError(t("mcp.wizard.urlRequired"));
return;
}
}
} catch {
// 解析异常已在基础校验覆盖
}
}
}
setConfigError("");
};
const handleWizardApply = (title: string, json: string) => {
setFormId(title);
if (!formName.trim()) {
setFormName(title);
}
// Wizard 返回的是 JSON根据格式决定是否需要转换
if (useToml) {
try {
const server = JSON.parse(json) as McpServerSpec;
const toml = mcpServerToToml(server);
setFormConfig(toml);
const err = validateToml(toml);
setConfigError(formatTomlError(err));
} catch (e: any) {
setConfigError(t("mcp.error.jsonInvalid"));
}
} else {
setFormConfig(json);
setConfigError(validateJson(json));
}
};
const handleSubmit = async () => {
const trimmedId = formId.trim();
if (!trimmedId) {
onNotify?.(t("mcp.error.idRequired"), "error", 3000);
return;
}
// 新增模式:阻止提交重名 ID
if (!isEditing && existingIds.includes(trimmedId)) {
setIdError(t("mcp.error.idExists"));
return;
}
// 验证配置格式
let serverSpec: McpServerSpec;
if (useToml) {
// TOML 模式
const tomlError = validateToml(formConfig);
setConfigError(formatTomlError(tomlError));
if (tomlError) {
onNotify?.(t("mcp.error.tomlInvalid"), "error", 3000);
return;
}
if (!formConfig.trim()) {
// 空配置
serverSpec = {
type: "stdio",
command: "",
args: [],
};
} else {
try {
serverSpec = tomlToMcpServer(formConfig);
} catch (e: any) {
const msg = e?.message || String(e);
setConfigError(formatTomlError(msg));
onNotify?.(t("mcp.error.tomlInvalid"), "error", 4000);
return;
}
}
} else {
// JSON 模式
const jsonError = validateJson(formConfig);
setConfigError(jsonError);
if (jsonError) {
onNotify?.(t("mcp.error.jsonInvalid"), "error", 3000);
return;
}
if (!formConfig.trim()) {
// 空配置
serverSpec = {
type: "stdio",
command: "",
args: [],
};
} else {
try {
serverSpec = JSON.parse(formConfig) as McpServerSpec;
} catch (e: any) {
setConfigError(t("mcp.error.jsonInvalid"));
onNotify?.(t("mcp.error.jsonInvalid"), "error", 4000);
return;
}
}
}
// 前置必填校验
if (serverSpec?.type === "stdio" && !serverSpec?.command?.trim()) {
onNotify?.(t("mcp.error.commandRequired"), "error", 3000);
return;
}
if (serverSpec?.type === "http" && !serverSpec?.url?.trim()) {
onNotify?.(t("mcp.wizard.urlRequired"), "error", 3000);
return;
}
setSaving(true);
try {
const entry: McpServer = {
...(initialData ? { ...initialData } : {}),
id: trimmedId,
server: serverSpec,
};
if (initialData?.enabled !== undefined) {
entry.enabled = initialData.enabled;
} else if (!initialData) {
delete entry.enabled;
}
const nameTrimmed = (formName || trimmedId).trim();
entry.name = nameTrimmed || trimmedId;
const descriptionTrimmed = formDescription.trim();
if (descriptionTrimmed) {
entry.description = descriptionTrimmed;
} else {
delete entry.description;
}
const homepageTrimmed = formHomepage.trim();
if (homepageTrimmed) {
entry.homepage = homepageTrimmed;
} else {
delete entry.homepage;
}
const docsTrimmed = formDocs.trim();
if (docsTrimmed) {
entry.docs = docsTrimmed;
} else {
delete entry.docs;
}
const parsedTags = formTags
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
if (parsedTags.length > 0) {
entry.tags = parsedTags;
} else {
delete entry.tags;
}
// 显式等待父组件保存流程
await onSave(trimmedId, entry);
} catch (error: any) {
const detail = extractErrorMessage(error);
const mapped = translateMcpBackendError(detail, t);
const msg = mapped || detail || t("mcp.error.saveFailed");
onNotify?.(msg, "error", mapped || detail ? 6000 : 4000);
} finally {
setSaving(false);
}
};
const getFormTitle = () => {
if (appType === "claude") {
return isEditing ? t("mcp.editClaudeServer") : t("mcp.addClaudeServer");
} else {
return isEditing ? t("mcp.editCodexServer") : t("mcp.addCodexServer");
}
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-3xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex-shrink-0 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{getFormTitle()}
</h3>
<button
onClick={onClose}
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
<X size={18} />
</button>
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{/* 预设选择(仅新增时展示) */}
{!isEditing && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("mcp.presets.title")}
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={applyCustom}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPreset === -1
? "bg-emerald-500 text-white dark:bg-emerald-600"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
{t("presetSelector.custom")}
</button>
{mcpPresets.map((preset, idx) => {
const descriptionKey = `mcp.presets.${preset.id}.description`;
return (
<button
key={preset.id}
type="button"
onClick={() => applyPreset(idx)}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPreset === idx
? "bg-emerald-500 text-white dark:bg-emerald-600"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
title={t(descriptionKey)}
>
{preset.id}
</button>
);
})}
</div>
</div>
)}
{/* ID (标题) */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{t("mcp.form.title")} <span className="text-red-500">*</span>
</label>
{!isEditing && idError && (
<span className="text-xs text-red-500 dark:text-red-400">
{idError}
</span>
)}
</div>
<input
className={inputStyles.text}
placeholder={t("mcp.form.titlePlaceholder")}
value={formId}
onChange={(e) => handleIdChange(e.target.value)}
disabled={isEditing}
/>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.name")}
</label>
<input
className={inputStyles.text}
placeholder={t("mcp.form.namePlaceholder")}
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
{/* 可折叠的附加信息按钮 */}
<div>
<button
type="button"
onClick={() => setShowMetadata(!showMetadata)}
className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
>
{showMetadata ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)}
{t("mcp.form.additionalInfo")}
</button>
</div>
{/* 附加信息区域(可折叠) */}
{showMetadata && (
<>
{/* Description (描述) */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.description")}
</label>
<input
className={inputStyles.text}
placeholder={t("mcp.form.descriptionPlaceholder")}
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
/>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.tags")}
</label>
<input
className={inputStyles.text}
placeholder={t("mcp.form.tagsPlaceholder")}
value={formTags}
onChange={(e) => setFormTags(e.target.value)}
/>
</div>
{/* Homepage */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.homepage")}
</label>
<input
className={inputStyles.text}
placeholder={t("mcp.form.homepagePlaceholder")}
value={formHomepage}
onChange={(e) => setFormHomepage(e.target.value)}
/>
</div>
{/* Docs */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.docs")}
</label>
<input
className={inputStyles.text}
placeholder={t("mcp.form.docsPlaceholder")}
value={formDocs}
onChange={(e) => setFormDocs(e.target.value)}
/>
</div>
</>
)}
{/* 配置输入框(根据格式显示 JSON 或 TOML */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{useToml ? t("mcp.form.tomlConfig") : t("mcp.form.jsonConfig")}
</label>
{(isEditing || selectedPreset === -1) && (
<button
type="button"
onClick={() => setIsWizardOpen(true)}
className="text-sm text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
>
{t("mcp.form.useWizard")}
</button>
)}
</div>
<textarea
className={`${inputStyles.text} h-48 resize-none font-mono text-xs`}
placeholder={
useToml
? t("mcp.form.tomlPlaceholder")
: t("mcp.form.jsonPlaceholder")
}
value={formConfig}
onChange={(e) => handleConfigChange(e.target.value)}
/>
{configError && (
<div className="flex items-center gap-2 mt-2 text-red-500 dark:text-red-400 text-sm">
<AlertCircle size={16} />
<span>{configError}</span>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex-shrink-0 flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
<button
onClick={onClose}
className="px-4 py-2 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200 rounded-lg transition-colors text-sm font-medium"
>
{t("common.cancel")}
</button>
<button
onClick={handleSubmit}
disabled={saving || (!isEditing && !!idError)}
className={`inline-flex items-center gap-2 ${buttonStyles.mcp}`}
>
<Save size={16} />
{saving
? t("common.saving")
: isEditing
? t("common.save")
: t("common.add")}
</button>
</div>
</div>
{/* Wizard Modal */}
<McpWizardModal
isOpen={isWizardOpen}
onClose={() => setIsWizardOpen(false)}
onApply={handleWizardApply}
onNotify={onNotify}
/>
</div>
);
};
export default McpFormModal;

View File

@@ -1,117 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Edit3, Trash2 } from "lucide-react";
import { McpServer } from "../../types";
import { mcpPresets } from "../../config/mcpPresets";
import { cardStyles, buttonStyles, cn } from "../../lib/styles";
import McpToggle from "./McpToggle";
interface McpListItemProps {
id: string;
server: McpServer;
onToggle: (id: string, enabled: boolean) => void;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}
/**
* MCP 列表项组件
* 每个 MCP 占一行,左侧是 Toggle 开关,中间是名称和详细信息,右侧是编辑和删除按钮
*/
const McpListItem: React.FC<McpListItemProps> = ({
id,
server,
onToggle,
onEdit,
onDelete,
}) => {
const { t } = useTranslation();
// 仅当显式为 true 时视为启用;避免 undefined 被误判为启用
const enabled = server.enabled === true;
const name = server.name || id;
// 只显示 description没有则留空
const description = server.description || "";
// 匹配预设元信息(用于展示文档链接等)
const meta = mcpPresets.find((p) => p.id === id);
const docsUrl = server.docs || meta?.docs;
const homepageUrl = server.homepage || meta?.homepage;
const tags = server.tags || meta?.tags;
const openDocs = async () => {
const url = docsUrl || homepageUrl;
if (!url) return;
try {
await window.api.openExternal(url);
} catch {
// ignore
}
};
return (
<div className={cn(cardStyles.interactive, "!p-4 h-16")}>
<div className="flex items-center gap-4 h-full">
{/* 左侧Toggle 开关 */}
<div className="flex-shrink-0">
<McpToggle
enabled={enabled}
onChange={(newEnabled) => onToggle(id, newEnabled)}
/>
</div>
{/* 中间:名称和详细信息 */}
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
{name}
</h3>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
{description}
</p>
)}
{!description && tags && tags.length > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
{tags.join(", ")}
</p>
)}
{/* 预设标记已移除 */}
</div>
{/* 右侧:操作按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
{docsUrl && (
<button
onClick={openDocs}
className={buttonStyles.ghost}
title={t("mcp.presets.docs")}
>
{t("mcp.presets.docs")}
</button>
)}
<button
onClick={() => onEdit(id)}
className={buttonStyles.icon}
title={t("common.edit")}
>
<Edit3 size={16} />
</button>
<button
onClick={() => onDelete(id)}
className={cn(
buttonStyles.icon,
"hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10",
)}
title={t("common.delete")}
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
);
};
export default McpListItem;

View File

@@ -1,306 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { X, Plus, Server, Check } from "lucide-react";
import { McpServer } from "../../types";
import McpListItem from "./McpListItem";
import McpFormModal from "./McpFormModal";
import { ConfirmDialog } from "../ConfirmDialog";
import {
extractErrorMessage,
translateMcpBackendError,
} from "../../utils/errorUtils";
// 预设相关逻辑已迁移到“新增 MCP”面板列表此处无需引用
import { buttonStyles } from "../../lib/styles";
import { AppType } from "../../lib/tauri-api";
interface McpPanelProps {
onClose: () => void;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
appType: AppType;
}
/**
* MCP 管理面板
* 采用与主界面一致的设计风格,右上角添加按钮,每个 MCP 占一行
*/
const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify, appType }) => {
const { t } = useTranslation();
const [servers, setServers] = useState<Record<string, McpServer>>({});
const [loading, setLoading] = useState(true);
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
} | null>(null);
const reload = async () => {
setLoading(true);
try {
const cfg = await window.api.getMcpConfig(appType);
setServers(cfg.servers || {});
} finally {
setLoading(false);
}
};
useEffect(() => {
const setup = async () => {
try {
// 初始化:仅从对应客户端导入已有 MCP不做“预设落库”
if (appType === "claude") {
await window.api.importMcpFromClaude();
} else if (appType === "codex") {
await window.api.importMcpFromCodex();
}
} catch (e) {
console.warn("MCP 初始化导入失败(忽略继续)", e);
} finally {
await reload();
}
};
setup();
// appType 改变时重新初始化
}, [appType]);
const handleToggle = async (id: string, enabled: boolean) => {
// 乐观更新:立即更新 UI
const previousServers = servers;
setServers((prev) => ({
...prev,
[id]: {
...prev[id],
enabled,
},
}));
try {
// 后台调用 API
await window.api.setMcpEnabled(appType, id, enabled);
onNotify?.(
enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"),
"success",
1500,
);
} catch (e: any) {
// 失败时回滚
setServers(previousServers);
const detail = extractErrorMessage(e);
const mapped = translateMcpBackendError(detail, t);
onNotify?.(
mapped || detail || t("mcp.error.saveFailed"),
"error",
mapped || detail ? 6000 : 5000,
);
}
};
const handleEdit = (id: string) => {
setEditingId(id);
setIsFormOpen(true);
};
const handleAdd = () => {
setEditingId(null);
setIsFormOpen(true);
};
const handleDelete = (id: string) => {
setConfirmDialog({
isOpen: true,
title: t("mcp.confirm.deleteTitle"),
message: t("mcp.confirm.deleteMessage", { id }),
onConfirm: async () => {
try {
await window.api.deleteMcpServerInConfig(appType, id);
await reload();
setConfirmDialog(null);
onNotify?.(t("mcp.msg.deleted"), "success", 1500);
} catch (e: any) {
const detail = extractErrorMessage(e);
const mapped = translateMcpBackendError(detail, t);
onNotify?.(
mapped || detail || t("mcp.error.deleteFailed"),
"error",
mapped || detail ? 6000 : 5000,
);
}
},
});
};
const handleSave = async (id: string, server: McpServer) => {
try {
const payload: McpServer = { ...server, id };
await window.api.upsertMcpServerInConfig(appType, id, payload);
await reload();
setIsFormOpen(false);
setEditingId(null);
onNotify?.(t("mcp.msg.saved"), "success", 1500);
} catch (e: any) {
const detail = extractErrorMessage(e);
const mapped = translateMcpBackendError(detail, t);
onNotify?.(
mapped || detail || t("mcp.error.saveFailed"),
"error",
mapped || detail ? 6000 : 5000,
);
// 继续抛出错误,让表单层可以给到直观反馈(避免被更高层遮挡)
throw e;
}
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingId(null);
};
const serverEntries = useMemo(
() => Object.entries(servers) as Array<[string, McpServer]>,
[servers],
);
const enabledCount = useMemo(
() => serverEntries.filter(([_, server]) => server.enabled).length,
[serverEntries],
);
const panelTitle =
appType === "claude" ? t("mcp.claudeTitle") : t("mcp.codexTitle");
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-3xl w-full mx-4 overflow-hidden flex flex-col max-h-[85vh] min-h-[600px]">
{/* Header */}
<div className="flex-shrink-0 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{panelTitle}
</h3>
<div className="flex items-center gap-3">
<button
onClick={handleAdd}
className={`inline-flex items-center gap-2 ${buttonStyles.mcp}`}
>
<Plus size={16} />
{t("mcp.add")}
</button>
<button
onClick={onClose}
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
<X size={18} />
</button>
</div>
</div>
{/* Info Section */}
<div className="flex-shrink-0 px-6 pt-4 pb-2">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t("mcp.serverCount", { count: Object.keys(servers).length })} ·{" "}
{t("mcp.enabledCount", { count: enabledCount })}
</div>
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("mcp.loading")}
</div>
) : (
(() => {
const hasAny = serverEntries.length > 0;
if (!hasAny) {
return (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<Server
size={24}
className="text-gray-400 dark:text-gray-500"
/>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("mcp.empty")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("mcp.emptyDescription")}
</p>
</div>
);
}
return (
<div className="space-y-3">
{/* 已安装 */}
{serverEntries.map(([id, server]) => (
<McpListItem
key={`installed-${id}`}
id={id}
server={server}
onToggle={handleToggle}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
{/* 预设已移至"新增 MCP"面板中展示与套用 */}
</div>
);
})()
)}
</div>
{/* Footer */}
<div className="flex-shrink-0 flex items-center justify-end p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
<button
onClick={onClose}
className={`inline-flex items-center gap-2 ${buttonStyles.mcp}`}
>
<Check size={16} />
{t("common.done")}
</button>
</div>
</div>
{/* Form Modal */}
{isFormOpen && (
<McpFormModal
appType={appType}
editingId={editingId || undefined}
initialData={editingId ? servers[editingId] : undefined}
existingIds={Object.keys(servers)}
onSave={handleSave}
onClose={handleCloseForm}
onNotify={onNotify}
/>
)}
{/* Confirm Dialog */}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
</div>
);
};
export default McpPanel;

View File

@@ -1,41 +0,0 @@
import React from "react";
interface McpToggleProps {
enabled: boolean;
onChange: (enabled: boolean) => void;
disabled?: boolean;
}
/**
* Toggle 开关组件
* 启用时为淡绿色,禁用时为灰色
*/
const McpToggle: React.FC<McpToggleProps> = ({
enabled,
onChange,
disabled = false,
}) => {
return (
<button
type="button"
role="switch"
aria-checked={enabled}
disabled={disabled}
onClick={() => onChange(!enabled)}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/20
${enabled ? "bg-emerald-500 dark:bg-emerald-600" : "bg-gray-300 dark:bg-gray-600"}
${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
${enabled ? "translate-x-6" : "translate-x-1"}
`}
/>
</button>
);
};
export default McpToggle;

View File

@@ -1,390 +0,0 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { X, Save } from "lucide-react";
import { McpServerSpec } from "../../types";
import { isLinux } from "../../lib/platform";
interface McpWizardModalProps {
isOpen: boolean;
onClose: () => void;
onApply: (title: string, json: string) => void;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
}
/**
* 解析环境变量文本为对象
*/
const parseEnvText = (text: string): Record<string, string> => {
const lines = text
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
const env: Record<string, string> = {};
for (const l of lines) {
const idx = l.indexOf("=");
if (idx > 0) {
const k = l.slice(0, idx).trim();
const v = l.slice(idx + 1).trim();
if (k) env[k] = v;
}
}
return env;
};
/**
* 解析headers文本为对象支持 KEY: VALUE 或 KEY=VALUE
*/
const parseHeadersText = (text: string): Record<string, string> => {
const lines = text
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
const headers: Record<string, string> = {};
for (const l of lines) {
// 支持 KEY: VALUE 或 KEY=VALUE
const colonIdx = l.indexOf(":");
const equalIdx = l.indexOf("=");
let idx = -1;
if (colonIdx > 0 && (equalIdx === -1 || colonIdx < equalIdx)) {
idx = colonIdx;
} else if (equalIdx > 0) {
idx = equalIdx;
}
if (idx > 0) {
const k = l.slice(0, idx).trim();
const v = l.slice(idx + 1).trim();
if (k) headers[k] = v;
}
}
return headers;
};
/**
* MCP 配置向导模态框
* 帮助用户快速生成 MCP JSON 配置
*/
const McpWizardModal: React.FC<McpWizardModalProps> = ({
isOpen,
onClose,
onApply,
onNotify,
}) => {
const { t } = useTranslation();
const [wizardType, setWizardType] = useState<"stdio" | "http">("stdio");
const [wizardTitle, setWizardTitle] = useState("");
// stdio 字段
const [wizardCommand, setWizardCommand] = useState("");
const [wizardArgs, setWizardArgs] = useState("");
const [wizardEnv, setWizardEnv] = useState("");
// http 字段
const [wizardUrl, setWizardUrl] = useState("");
const [wizardHeaders, setWizardHeaders] = useState("");
// 生成预览 JSON
const generatePreview = (): string => {
const config: McpServerSpec = {
type: wizardType,
};
if (wizardType === "stdio") {
// stdio 类型必需字段
config.command = wizardCommand.trim();
// 可选字段
if (wizardArgs.trim()) {
config.args = wizardArgs
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
if (wizardEnv.trim()) {
const env = parseEnvText(wizardEnv);
if (Object.keys(env).length > 0) {
config.env = env;
}
}
} else {
// http 类型必需字段
config.url = wizardUrl.trim();
// 可选字段
if (wizardHeaders.trim()) {
const headers = parseHeadersText(wizardHeaders);
if (Object.keys(headers).length > 0) {
config.headers = headers;
}
}
}
return JSON.stringify(config, null, 2);
};
const handleApply = () => {
if (!wizardTitle.trim()) {
onNotify?.(t("mcp.error.idRequired"), "error", 3000);
return;
}
if (wizardType === "stdio" && !wizardCommand.trim()) {
onNotify?.(t("mcp.error.commandRequired"), "error", 3000);
return;
}
if (wizardType === "http" && !wizardUrl.trim()) {
onNotify?.(t("mcp.wizard.urlRequired"), "error", 3000);
return;
}
const json = generatePreview();
onApply(wizardTitle.trim(), json);
handleClose();
};
const handleClose = () => {
// 重置表单
setWizardType("stdio");
setWizardTitle("");
setWizardCommand("");
setWizardArgs("");
setWizardEnv("");
setWizardUrl("");
setWizardHeaders("");
onClose();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && e.metaKey) {
e.preventDefault();
handleApply();
}
};
if (!isOpen) return null;
const preview = generatePreview();
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
handleClose();
}
}}
>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
{/* Modal */}
<div className="relative mx-4 flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white shadow-lg dark:bg-gray-900">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-800">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{t("mcp.wizard.title")}
</h2>
<button
type="button"
onClick={handleClose}
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
aria-label={t("common.close")}
>
<X size={18} />
</button>
</div>
{/* Content */}
<div className="flex-1 min-h-0 space-y-4 overflow-auto p-6">
{/* Hint */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
<p className="text-sm text-blue-800 dark:text-blue-200">
{t("mcp.wizard.hint")}
</p>
</div>
{/* Form Fields */}
<div className="space-y-4 min-h-[400px]">
{/* Type */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.type")} <span className="text-red-500">*</span>
</label>
<div className="flex gap-4">
<label className="inline-flex items-center gap-2 cursor-pointer">
<input
type="radio"
value="stdio"
checked={wizardType === "stdio"}
onChange={(e) =>
setWizardType(e.target.value as "stdio" | "http")
}
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
/>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("mcp.wizard.typeStdio")}
</span>
</label>
<label className="inline-flex items-center gap-2 cursor-pointer">
<input
type="radio"
value="http"
checked={wizardType === "http"}
onChange={(e) =>
setWizardType(e.target.value as "stdio" | "http")
}
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
/>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("mcp.wizard.typeHttp")}
</span>
</label>
</div>
</div>
{/* Title */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.form.title")} <span className="text-red-500">*</span>
</label>
<input
type="text"
value={wizardTitle}
onChange={(e) => setWizardTitle(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("mcp.form.titlePlaceholder")}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
{/* Stdio 类型字段 */}
{wizardType === "stdio" && (
<>
{/* Command */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.command")}{" "}
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={wizardCommand}
onChange={(e) => setWizardCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("mcp.wizard.commandPlaceholder")}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
{/* Args */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.args")}
</label>
<textarea
value={wizardArgs}
onChange={(e) => setWizardArgs(e.target.value)}
placeholder={t("mcp.wizard.argsPlaceholder")}
rows={3}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 resize-y"
/>
</div>
{/* Env */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.env")}
</label>
<textarea
value={wizardEnv}
onChange={(e) => setWizardEnv(e.target.value)}
placeholder={t("mcp.wizard.envPlaceholder")}
rows={3}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 resize-y"
/>
</div>
</>
)}
{/* HTTP 类型字段 */}
{wizardType === "http" && (
<>
{/* URL */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.url")}{" "}
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={wizardUrl}
onChange={(e) => setWizardUrl(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("mcp.wizard.urlPlaceholder")}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
{/* Headers */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.headers")}
</label>
<textarea
value={wizardHeaders}
onChange={(e) => setWizardHeaders(e.target.value)}
placeholder={t("mcp.wizard.headersPlaceholder")}
rows={3}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 resize-y"
/>
</div>
</>
)}
</div>
{/* Preview */}
{(wizardCommand ||
wizardArgs ||
wizardEnv ||
wizardUrl ||
wizardHeaders) && (
<div className="space-y-2 border-t border-gray-200 pt-4 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.preview")}
</h3>
<pre className="overflow-x-auto rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{preview}
</pre>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 border-t border-gray-200 bg-gray-100 p-6 dark:border-gray-800 dark:bg-gray-800">
<button
type="button"
onClick={handleClose}
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-500 transition-colors hover:bg-white hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handleApply}
className="flex items-center gap-2 rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
>
<Save className="h-4 w-4" />
{t("mcp.wizard.apply")}
</button>
</div>
</div>
</div>
);
};
export default McpWizardModal;

View File

@@ -6,15 +6,11 @@ import { ProviderCategory } from "../types";
export interface CodexProviderPreset {
name: string;
websiteUrl: string;
// 第三方供应商可提供单独的获取 API Key 链接
apiKeyUrl?: string;
auth: Record<string, any>; // 将写入 ~/.codex/auth.json
config: string; // 将写入 ~/.codex/config.tomlTOML 字符串)
isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
isCustomTemplate?: boolean; // 标识是否为自定义模板
// 新增:请求地址候选列表(用于地址管理/测速)
endpointCandidates?: string[];
}
/**
@@ -32,7 +28,7 @@ export function generateThirdPartyAuth(apiKey: string): Record<string, any> {
export function generateThirdPartyConfig(
providerName: string,
baseUrl: string,
modelName = "gpt-5-codex",
modelName = "gpt-5-codex"
): string {
// 清理供应商名称确保符合TOML键名规范
const cleanProviderName =
@@ -49,13 +45,12 @@ disable_response_storage = true
[model_providers.${cleanProviderName}]
name = "${cleanProviderName}"
base_url = "${baseUrl}"
wire_api = "responses"
requires_openai_auth = true`;
wire_api = "responses"`;
}
export const codexProviderPresets: CodexProviderPreset[] = [
{
name: "Codex Official",
name: "Codex官方",
websiteUrl: "https://chatgpt.com/codex",
isOfficial: true,
category: "official",
@@ -72,13 +67,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
config: generateThirdPartyConfig(
"packycode",
"https://codex-api.packycode.com/v1",
"gpt-5-codex",
"gpt-5-codex"
),
// Codex 请求地址候选(用于地址管理/测速)
endpointCandidates: [
"https://codex-api.packycode.com/v1",
"https://codex-api-hk-cn2.packycode.com/v1",
"https://codex-api-hk-cdn.packycode.com/v1",
],
},
];

View File

@@ -1,85 +0,0 @@
import { McpServer, McpServerSpec } from "../types";
export type McpPreset = Omit<McpServer, "enabled" | "description">;
// 预设 MCP逻辑简化版
// - 仅包含最常用、可快速落地的 stdio 模式示例
// - 不涉及分类/模板/测速等复杂逻辑,默认以 disabled 形式"回种"到 config.json
// - 用户可在 MCP 面板中一键启用/编辑
// - description 字段使用国际化 key在使用时通过 t() 函数获取翻译
export const mcpPresets: McpPreset[] = [
{
id: "fetch",
name: "mcp-server-fetch",
tags: ["stdio", "http", "web"],
server: {
type: "stdio",
command: "uvx",
args: ["mcp-server-fetch"],
} as McpServerSpec,
homepage: "https://github.com/modelcontextprotocol/servers",
docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
},
{
id: "time",
name: "@modelcontextprotocol/server-time",
tags: ["stdio", "time", "utility"],
server: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-time"],
} as McpServerSpec,
homepage: "https://github.com/modelcontextprotocol/servers",
docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/time",
},
{
id: "memory",
name: "@modelcontextprotocol/server-memory",
tags: ["stdio", "memory", "graph"],
server: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-memory"],
} as McpServerSpec,
homepage: "https://github.com/modelcontextprotocol/servers",
docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
},
{
id: "sequential-thinking",
name: "@modelcontextprotocol/server-sequential-thinking",
tags: ["stdio", "thinking", "reasoning"],
server: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-sequential-thinking"],
} as McpServerSpec,
homepage: "https://github.com/modelcontextprotocol/servers",
docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
},
{
id: "context7",
name: "@upstash/context7-mcp",
tags: ["stdio", "docs", "search"],
server: {
type: "stdio",
command: "npx",
args: ["-y", "@upstash/context7-mcp"],
} as McpServerSpec,
homepage: "https://context7.com",
docs: "https://github.com/upstash/context7/blob/master/README.md",
},
];
// 获取带国际化描述的预设
export const getMcpPresetWithDescription = (
preset: McpPreset,
t: (key: string) => string,
): McpServer => {
const descriptionKey = `mcp.presets.${preset.id}.description`;
return {
...preset,
description: t(descriptionKey),
} as McpServer;
};
export default mcpPresets;

View File

@@ -3,13 +3,6 @@
*/
import { ProviderCategory } from "../types";
export interface TemplateValueConfig {
label: string;
placeholder: string;
defaultValue?: string;
editorValue: string;
}
export interface ProviderPreset {
name: string;
websiteUrl: string;
@@ -18,15 +11,11 @@ export interface ProviderPreset {
settingsConfig: object;
isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
// 新增:模板变量定义,用于动态替换配置中的值
templateValues?: Record<string, TemplateValueConfig>; // editorValue 存储编辑器中的实时输入值
// 新增:请求地址候选列表(用于地址管理/测速)
endpointCandidates?: string[];
}
export const providerPresets: ProviderPreset[] = [
{
name: "Claude Official",
name: "Claude官方",
websiteUrl: "https://www.anthropic.com/claude-code",
settingsConfig: {
env: {},
@@ -41,31 +30,27 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "DeepSeek-V3.2-Exp",
ANTHROPIC_SMALL_FAST_MODEL: "DeepSeek-V3.2-Exp",
ANTHROPIC_MODEL: "DeepSeek-V3.1-Terminus",
ANTHROPIC_SMALL_FAST_MODEL: "DeepSeek-V3.1-Terminus",
},
},
category: "cn_official",
},
{
name: "Zhipu GLM",
name: "智谱GLM",
websiteUrl: "https://open.bigmodel.cn",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
// 兼容旧键名,保持前端读取一致
ANTHROPIC_MODEL: "GLM-4.6",
ANTHROPIC_SMALL_FAST_MODEL: "glm-4.5-air",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.5-air",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.6",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.6",
ANTHROPIC_MODEL: "GLM-4.5",
ANTHROPIC_SMALL_FAST_MODEL: "GLM-4.5-Air",
},
},
category: "cn_official",
},
{
name: "Qwen Coder",
name: "Qwen-Coder",
websiteUrl: "https://bailian.console.aliyun.com",
settingsConfig: {
env: {
@@ -92,59 +77,18 @@ export const providerPresets: ProviderPreset[] = [
category: "cn_official",
},
{
name: "ModelScope",
name: "魔搭",
websiteUrl: "https://modelscope.cn",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.6",
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.6",
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.5",
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.5",
},
},
category: "aggregator",
},
{
name: "KAT-Coder",
websiteUrl: "https://console.streamlake.ai/wanqing/",
apiKeyUrl: "https://console.streamlake.ai/console/wanqing/api-key",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL:
"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/claude-code-proxy",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "KAT-Coder",
ANTHROPIC_SMALL_FAST_MODEL: "KAT-Coder",
},
},
category: "cn_official",
templateValues: {
ENDPOINT_ID: {
label: "Vanchin Endpoint ID",
placeholder: "ep-xxx-xxx",
defaultValue: "",
editorValue: "",
},
},
},
{
name: "Longcat",
websiteUrl: "https://longcat.chat/platform",
apiKeyUrl: "https://longcat.chat/platform/api_keys",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.longcat.chat/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "LongCat-Flash-Chat",
ANTHROPIC_SMALL_FAST_MODEL: "LongCat-Flash-Chat",
ANTHROPIC_DEFAULT_SONNET_MODEL: "LongCat-Flash-Chat",
ANTHROPIC_DEFAULT_OPUS_MODEL: "LongCat-Flash-Chat",
CLAUDE_CODE_MAX_OUTPUT_TOKENS: "6000",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
},
},
category: "cn_official",
},
{
name: "PackyCode",
websiteUrl: "https://www.packycode.com",
@@ -155,14 +99,6 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_AUTH_TOKEN: "",
},
},
// 请求地址候选(用于地址管理/测速)
endpointCandidates: [
"https://api.packycode.com",
"https://api-hk-cn2.packycode.com",
"https://api-hk-g.packycode.com",
"https://api-us-cn2.packycode.com",
"https://api-cf-pro.packycode.com",
],
category: "third_party",
},
];

View File

@@ -0,0 +1,99 @@
import { useState, useEffect, useCallback } from "react";
const VSCODE_AUTO_SYNC_KEY = "vscode-auto-sync-enabled";
const VSCODE_AUTO_SYNC_EVENT = "vscode-auto-sync-changed";
export function useVSCodeAutoSync() {
// 默认开启自动同步;若本地存储存在记录,则以记录为准
const [isAutoSyncEnabled, setIsAutoSyncEnabled] = useState<boolean>(true);
// 从 localStorage 读取初始状态
useEffect(() => {
try {
const saved = localStorage.getItem(VSCODE_AUTO_SYNC_KEY);
if (saved !== null) {
setIsAutoSyncEnabled(saved === "true");
}
} catch (error) {
console.error("读取自动同步状态失败:", error);
}
}, []);
// 订阅同窗口的自定义事件,以及跨窗口的 storage 事件,实现全局同步
useEffect(() => {
const onCustom = (e: Event) => {
try {
const detail = (e as CustomEvent).detail as
| { enabled?: boolean }
| undefined;
if (detail && typeof detail.enabled === "boolean") {
setIsAutoSyncEnabled(detail.enabled);
} else {
// 兜底:从 localStorage 读取
const saved = localStorage.getItem(VSCODE_AUTO_SYNC_KEY);
if (saved !== null) setIsAutoSyncEnabled(saved === "true");
}
} catch {
// 忽略
}
};
const onStorage = (e: StorageEvent) => {
if (e.key === VSCODE_AUTO_SYNC_KEY) {
setIsAutoSyncEnabled(e.newValue === "true");
}
};
window.addEventListener(VSCODE_AUTO_SYNC_EVENT, onCustom as EventListener);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(
VSCODE_AUTO_SYNC_EVENT,
onCustom as EventListener,
);
window.removeEventListener("storage", onStorage);
};
}, []);
// 启用自动同步
const enableAutoSync = useCallback(() => {
try {
localStorage.setItem(VSCODE_AUTO_SYNC_KEY, "true");
setIsAutoSyncEnabled(true);
// 通知同窗口其他订阅者
window.dispatchEvent(
new CustomEvent(VSCODE_AUTO_SYNC_EVENT, { detail: { enabled: true } }),
);
} catch (error) {
console.error("保存自动同步状态失败:", error);
}
}, []);
// 禁用自动同步
const disableAutoSync = useCallback(() => {
try {
localStorage.setItem(VSCODE_AUTO_SYNC_KEY, "false");
setIsAutoSyncEnabled(false);
// 通知同窗口其他订阅者
window.dispatchEvent(
new CustomEvent(VSCODE_AUTO_SYNC_EVENT, { detail: { enabled: false } }),
);
} catch (error) {
console.error("保存自动同步状态失败:", error);
}
}, []);
// 切换自动同步状态
const toggleAutoSync = useCallback(() => {
if (isAutoSyncEnabled) {
disableAutoSync();
} else {
enableAutoSync();
}
}, [isAutoSyncEnabled, enableAutoSync, disableAutoSync]);
return {
isAutoSyncEnabled,
enableAutoSync,
disableAutoSync,
toggleAutoSync,
};
}

View File

@@ -4,37 +4,6 @@ import { initReactI18next } from "react-i18next";
import en from "./locales/en.json";
import zh from "./locales/zh.json";
const DEFAULT_LANGUAGE: "zh" | "en" = "zh";
const getInitialLanguage = (): "zh" | "en" => {
if (typeof window !== "undefined") {
try {
const stored = window.localStorage.getItem("language");
if (stored === "zh" || stored === "en") {
return stored;
}
} catch (error) {
console.warn("[i18n] Failed to read stored language preference", error);
}
}
const navigatorLang =
typeof navigator !== "undefined"
? (navigator.language?.toLowerCase() ??
navigator.languages?.[0]?.toLowerCase())
: undefined;
if (navigatorLang?.startsWith("zh")) {
return "zh";
}
if (navigatorLang?.startsWith("en")) {
return "en";
}
return DEFAULT_LANGUAGE;
};
const resources = {
en: {
translation: en,
@@ -46,8 +15,8 @@ const resources = {
i18n.use(initReactI18next).init({
resources,
lng: getInitialLanguage(), // 根据本地存储或系统语言选择默认语言
fallbackLng: "en", // 如果缺少中文翻译则退回英文
lng: "en", // 默认语言设置为英文
fallbackLng: "en", // 回退语言也设置为英文
interpolation: {
escapeValue: false, // React 已经默认转义

View File

@@ -8,36 +8,16 @@
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel",
"confirm": "Confirm",
"close": "Close",
"done": "Done",
"settings": "Settings",
"about": "About",
"version": "Version",
"loading": "Loading...",
"success": "Success",
"error": "Error",
"unknown": "Unknown",
"enterValidValue": "Please enter a valid value"
},
"apiKeyInput": {
"placeholder": "Enter API Key",
"show": "Show API Key",
"hide": "Hide API Key"
},
"jsonEditor": {
"mustBeObject": "Configuration must be a JSON object, not an array or other type",
"invalidJson": "Invalid JSON format"
},
"claudeConfig": {
"configLabel": "Claude Code settings.json (JSON) *",
"writeCommonConfig": "Write Common Config",
"editCommonConfig": "Edit Common Config",
"editCommonConfigTitle": "Edit Common Config Snippet",
"commonConfigHint": "This snippet will be merged into settings.json when 'Write Common Config' is checked",
"fullSettingsHint": "Full Claude Code settings.json content"
"unknown": "Unknown"
},
"header": {
"viewOnGithub": "View on GitHub",
@@ -56,14 +36,10 @@
"editProvider": "Edit Provider",
"deleteProvider": "Delete Provider",
"addNewProvider": "Add New Provider",
"addClaudeProvider": "Add Claude Code Provider",
"addCodexProvider": "Add Codex Provider",
"editClaudeProvider": "Edit Claude Code Provider",
"editCodexProvider": "Edit Codex Provider",
"configError": "Configuration Error",
"notConfigured": "Not configured for official website",
"applyToClaudePlugin": "Apply to Claude plugin",
"removeFromClaudePlugin": "Remove from Claude plugin"
"applyToVSCode": "Apply to VS Code",
"removeFromVSCode": "Remove from VS Code"
},
"notifications": {
"providerSaved": "Provider configuration saved",
@@ -71,11 +47,14 @@
"switchSuccess": "Switch successful! Please restart {{appName}} terminal to take effect",
"switchFailed": "Switch failed, please check configuration",
"autoImported": "Default provider created from existing configuration",
"appliedToVSCode": "Applied to VS Code, restart Codex plugin to take effect",
"removedFromVSCode": "Removed from VS Code, restart Codex plugin to take effect",
"syncedToVSCode": "Synced to VS Code",
"vscodeSettingsNotFound": "VS Code user settings file (settings.json) not found",
"missingBaseUrl": "Current configuration missing base_url, cannot write to VS Code",
"saveFailed": "Save failed: {{error}}",
"saveFailedGeneric": "Save failed, please try again",
"appliedToClaudePlugin": "Applied to Claude plugin",
"removedFromClaudePlugin": "Removed from Claude plugin",
"syncClaudePluginFailed": "Sync Claude plugin failed"
"syncVSCodeFailed": "Sync to VS Code failed"
},
"confirm": {
"deleteProvider": "Delete Provider",
@@ -83,28 +62,9 @@
},
"settings": {
"title": "Settings",
"general": "General",
"language": "Language",
"importExport": "Import/Export Config",
"exportConfig": "Export Config to File",
"selectConfigFile": "Select Config File",
"import": "Import",
"importing": "Importing...",
"importSuccess": "Import Successful!",
"importFailed": "Import Failed",
"configExported": "Config exported to:",
"exportFailed": "Export failed",
"selectFileFailed": "Failed to select file",
"configCorrupted": "Config file may be corrupted or invalid",
"backupId": "Backup ID",
"autoReload": "Data will refresh automatically in 2 seconds...",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"windowBehavior": "Window Behavior",
"minimizeToTray": "Minimize to tray on close",
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
"enableClaudePluginIntegration": "Apply to Claude Code extension",
"enableClaudePluginIntegrationDescription": "When enabled, you can use third-party providers in the VS Code Claude Code extension",
"configFileLocation": "Configuration File Location",
"openFolder": "Open Folder",
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
@@ -122,8 +82,7 @@
"upToDate": "Up to Date",
"releaseNotes": "Release Notes",
"viewReleaseNotes": "View release notes for this version",
"viewCurrentReleaseNotes": "View current version release notes",
"exportFailedError": "Export config failed:"
"viewCurrentReleaseNotes": "View current version release notes"
},
"apps": {
"claude": "Claude Code",
@@ -133,6 +92,7 @@
"providerSwitchReceived": "Received provider switch event:",
"setupListenerFailed": "Failed to setup provider switch listener:",
"updateProviderFailed": "Update provider failed:",
"syncToVSCodeFailed": "Sync to VS Code failed:",
"autoImportFailed": "Auto import default configuration failed:",
"openLinkFailed": "Failed to open link:",
"getVersionFailed": "Failed to get version info:",
@@ -147,243 +107,5 @@
"selectConfigDirFailed": "Failed to select config directory:",
"getDefaultConfigDirFailed": "Failed to get default config directory:",
"openReleaseNotesFailed": "Failed to open release notes:"
},
"providerForm": {
"supplierName": "Provider Name",
"supplierNameRequired": "Provider Name *",
"supplierNamePlaceholder": "e.g., Anthropic Official",
"websiteUrl": "Website URL",
"websiteUrlPlaceholder": "https://example.com (optional)",
"apiEndpoint": "API Endpoint",
"apiEndpointPlaceholder": "https://your-api-endpoint.com",
"codexApiEndpointPlaceholder": "https://your-api-endpoint.com/v1",
"manageAndTest": "Manage & Test",
"configContent": "Config Content",
"useConfigWizard": "Use Configuration Wizard",
"manualConfig": "Manually configure provider, requires complete configuration, or",
"officialNoApiKey": "Official login does not require API Key, save directly",
"codexOfficialNoApiKey": "Official does not require API Key, save directly",
"kimiApiKeyHint": "Fill in to get model list",
"apiKeyAutoFill": "Just fill in here, config below will be auto-filled",
"codexApiKeyAutoFill": "Just fill in here, auth.json below will be auto-filled",
"getApiKey": "Get API Key",
"parameterConfig": "Parameter Config - {{name}} *",
"mainModel": "Main Model (optional)",
"mainModelPlaceholder": "e.g., GLM-4.6",
"fastModel": "Fast Model (optional)",
"fastModelPlaceholder": "e.g., GLM-4.5-Air",
"modelHint": "💡 Leave blank to use provider's default model",
"apiHint": "💡 Fill in Claude API compatible service endpoint",
"codexApiHint": "💡 Fill in service endpoint compatible with OpenAI Response format",
"fillSupplierName": "Please fill in provider name",
"fillConfigContent": "Please fill in configuration content",
"fillParameter": "Please fill in {{label}}",
"configJsonError": "Config JSON format error, please check syntax",
"authJsonRequired": "auth.json must be a JSON object",
"authJsonError": "auth.json format error, please check JSON syntax",
"fillAuthJson": "Please fill in auth.json configuration",
"fillApiKey": "Please fill in OPENAI_API_KEY",
"visitWebsite": "Visit {{url}}"
},
"endpointTest": {
"title": "API Endpoint Management",
"endpoints": "endpoints",
"autoSelect": "Auto Select",
"testSpeed": "Test",
"testing": "Testing",
"addEndpointPlaceholder": "https://api.example.com",
"done": "Done",
"noEndpoints": "No endpoints",
"failed": "Failed",
"enterValidUrl": "Please enter a valid URL",
"invalidUrlFormat": "Invalid URL format",
"onlyHttps": "Only HTTP/HTTPS supported",
"urlExists": "This URL already exists",
"saveFailed": "Save failed, please try again",
"loadEndpointsFailed": "Failed to load custom endpoints:",
"addEndpointFailed": "Failed to add custom endpoint:",
"removeEndpointFailed": "Failed to remove custom endpoint:",
"pleaseAddEndpoint": "Please add an endpoint first",
"testUnavailable": "Speed test unavailable",
"noResult": "No result returned",
"testFailed": "Speed test failed: {{error}}"
},
"codexConfig": {
"quickWizard": "Quick Configuration Wizard",
"authJson": "auth.json (JSON) *",
"authJsonPlaceholder": "{\n \"OPENAI_API_KEY\": \"sk-your-api-key-here\"\n}",
"authJsonHint": "Codex auth.json configuration content",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex config.toml configuration content",
"writeCommonConfig": "Write Common Config",
"editCommonConfig": "Edit Common Config",
"editCommonConfigTitle": "Edit Codex Common Config Snippet",
"commonConfigHint": "This snippet will be appended to the end of config.toml when 'Write Common Config' is checked",
"wizardHint": "Enter key parameters, the system will automatically generate standard auth.json and config.toml configuration.",
"apiKeyLabel": "API Key *",
"apiKeyPlaceholder": "sk-your-api-key-here",
"supplierNameLabel": "Provider Name *",
"supplierNamePlaceholder": "e.g., Codex Official",
"supplierNameHint": "Will be displayed in the provider list, can use Chinese",
"supplierCodeLabel": "Provider Code (English)",
"supplierCodePlaceholder": "custom (optional)",
"supplierCodeHint": "Will be used as identifier in config file, defaults to custom",
"apiUrlLabel": "API Request URL *",
"apiUrlPlaceholder": "https://your-api-endpoint.com/v1",
"websiteLabel": "Website URL",
"websitePlaceholder": "https://example.com",
"websiteHint": "Official website address (optional)",
"modelNameLabel": "Model Name *",
"modelNamePlaceholder": "gpt-5-codex",
"configPreview": "Configuration Preview",
"applyConfig": "Apply Configuration"
},
"kimiSelector": {
"modelConfig": "Model Configuration",
"mainModel": "Main Model",
"fastModel": "Fast Model",
"refreshModels": "Refresh Model List",
"pleaseSelectModel": "Please select a model",
"noModels": "No models available",
"fillApiKeyFirst": "Please fill in API Key first",
"requestFailed": "Request failed: {{error}}",
"invalidData": "Invalid response data format",
"fetchModelsFailed": "Failed to fetch model list",
"apiKeyHint": "💡 Fill in API Key to automatically fetch available model list"
},
"presetSelector": {
"title": "Select Configuration Type",
"custom": "Custom",
"customDescription": "Manually configure provider, requires complete configuration",
"officialDescription": "Official login, no API Key required",
"presetDescription": "Use preset configuration, only API Key required"
},
"mcp": {
"title": "MCP Management",
"claudeTitle": "Claude Code MCP Management",
"codexTitle": "Codex MCP Management",
"userLevelPath": "User-level MCP path",
"serverList": "Servers",
"loading": "Loading...",
"empty": "No MCP servers",
"emptyDescription": "Click the button in the top right to add your first MCP server",
"add": "Add MCP",
"addServer": "Add MCP",
"editServer": "Edit MCP",
"addClaudeServer": "Add Claude Code MCP",
"editClaudeServer": "Edit Claude Code MCP",
"addCodexServer": "Add Codex MCP",
"editCodexServer": "Edit Codex MCP",
"configPath": "Config Path",
"serverCount": "{{count}} MCP server(s) configured",
"enabledCount": "{{count}} enabled",
"template": {
"fetch": "Quick Template: mcp-fetch"
},
"form": {
"title": "MCP Title (Unique)",
"titlePlaceholder": "my-mcp-server",
"name": "Display Name",
"namePlaceholder": "e.g. @modelcontextprotocol/server-time",
"description": "Description",
"descriptionPlaceholder": "Optional description",
"tags": "Tags (comma separated)",
"tagsPlaceholder": "stdio, time, utility",
"homepage": "Homepage",
"homepagePlaceholder": "https://example.com",
"docs": "Docs",
"docsPlaceholder": "https://example.com/docs",
"additionalInfo": "Additional Info",
"jsonConfig": "JSON Configuration",
"jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}",
"tomlConfig": "TOML Configuration",
"tomlPlaceholder": "type = \"stdio\"\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]",
"useWizard": "Config Wizard"
},
"wizard": {
"title": "MCP Configuration Wizard",
"hint": "Quickly configure MCP server and auto-generate JSON configuration",
"type": "Type",
"typeStdio": "stdio",
"typeHttp": "http",
"command": "Command",
"commandPlaceholder": "npx or uvx",
"args": "Arguments",
"argsPlaceholder": "arg1\narg2",
"env": "Environment Variables",
"envPlaceholder": "KEY1=value1\nKEY2=value2",
"url": "URL",
"urlPlaceholder": "https://api.example.com/mcp",
"urlRequired": "Please enter URL",
"headers": "Headers (optional)",
"headersPlaceholder": "Authorization: Bearer your_token_here\nContent-Type: application/json",
"preview": "Configuration Preview",
"apply": "Apply Configuration"
},
"id": "Identifier (unique)",
"type": "Type",
"command": "Command",
"validateCommand": "Validate Command",
"args": "Args",
"argsPlaceholder": "e.g., mcp-server-fetch --help",
"env": "Environment (one per line, KEY=VALUE)",
"envPlaceholder": "FOO=bar\nHELLO=world",
"reset": "Reset",
"notice": {
"restartClaude": "Written. Restart Claude to take effect."
},
"msg": {
"saved": "Saved",
"deleted": "Deleted",
"enabled": "Enabled",
"disabled": "Disabled",
"templateAdded": "Template added"
},
"error": {
"idRequired": "Please enter identifier",
"idExists": "Identifier already exists. Please choose another.",
"jsonInvalid": "Invalid JSON format",
"tomlInvalid": "Invalid TOML format",
"commandRequired": "Please enter command",
"singleServerObjectRequired": "Please paste a single MCP server object (do not include top-level mcpServers)",
"saveFailed": "Save failed",
"deleteFailed": "Delete failed"
},
"validation": {
"ok": "Command available",
"fail": "Command not found"
},
"confirm": {
"deleteTitle": "Delete MCP Server",
"deleteMessage": "Are you sure you want to delete MCP server \"{{id}}\"? This action cannot be undone."
},
"presets": {
"title": "Select MCP Type",
"enable": "Enable",
"enabled": "Enabled",
"installed": "Installed",
"docs": "Docs",
"requiresEnv": "Requires env",
"fetch": {
"name": "mcp-server-fetch",
"description": "Universal HTTP request tool, supports GET/POST and other HTTP methods, suitable for quick API requests and web data scraping"
},
"time": {
"name": "@modelcontextprotocol/server-time",
"description": "Time query tool providing current time, timezone conversion, and date calculation features"
},
"memory": {
"name": "@modelcontextprotocol/server-memory",
"description": "Knowledge graph memory system supporting entities, relations, and observations to help AI remember important information from conversations"
},
"sequential-thinking": {
"name": "@modelcontextprotocol/server-sequential-thinking",
"description": "Sequential thinking tool helping AI break down complex problems into multiple steps for deeper thinking"
},
"context7": {
"name": "@upstash/context7-mcp",
"description": "Context7 documentation search tool providing latest library docs and code examples, with higher limits when configured with a key"
}
}
}
}

View File

@@ -8,36 +8,16 @@
"edit": "编辑",
"delete": "删除",
"save": "保存",
"saving": "保存中...",
"cancel": "取消",
"confirm": "确定",
"close": "关闭",
"done": "完成",
"settings": "设置",
"about": "关于",
"version": "版本",
"loading": "加载中...",
"success": "成功",
"error": "错误",
"unknown": "未知",
"enterValidValue": "请输入有效的内容"
},
"apiKeyInput": {
"placeholder": "请输入API Key",
"show": "显示API Key",
"hide": "隐藏API Key"
},
"jsonEditor": {
"mustBeObject": "配置必须是JSON对象不能是数组或其他类型",
"invalidJson": "JSON格式错误"
},
"claudeConfig": {
"configLabel": "Claude Code 配置 (JSON) *",
"writeCommonConfig": "写入通用配置",
"editCommonConfig": "编辑通用配置",
"editCommonConfigTitle": "编辑通用配置片段",
"commonConfigHint": "该片段会在勾选\"写入通用配置\"时合并到 settings.json 中",
"fullSettingsHint": "完整的 Claude Code settings.json 配置内容"
"unknown": "未知"
},
"header": {
"viewOnGithub": "在 GitHub 上查看",
@@ -56,14 +36,10 @@
"editProvider": "编辑供应商",
"deleteProvider": "删除供应商",
"addNewProvider": "添加新供应商",
"addClaudeProvider": "添加 Claude Code 供应商",
"addCodexProvider": "添加 Codex 供应商",
"editClaudeProvider": "编辑 Claude Code 供应商",
"editCodexProvider": "编辑 Codex 供应商",
"configError": "配置错误",
"notConfigured": "未配置官网地址",
"applyToClaudePlugin": "应用到 Claude 插件",
"removeFromClaudePlugin": "从 Claude 插件移除"
"applyToVSCode": "应用到 VS Code",
"removeFromVSCode": "从 VS Code 移除"
},
"notifications": {
"providerSaved": "供应商配置已保存",
@@ -71,11 +47,14 @@
"switchSuccess": "切换成功!请重启 {{appName}} 终端以生效",
"switchFailed": "切换失败,请检查配置",
"autoImported": "已从现有配置创建默认供应商",
"appliedToVSCode": "已应用到 VS Code重启 Codex 插件以生效",
"removedFromVSCode": "已从 VS Code 移除,重启 Codex 插件以生效",
"syncedToVSCode": "已同步到 VS Code",
"vscodeSettingsNotFound": "未找到 VS Code 用户设置文件 (settings.json)",
"missingBaseUrl": "当前配置缺少 base_url无法写入 VS Code",
"saveFailed": "保存失败:{{error}}",
"saveFailedGeneric": "保存失败,请重试",
"appliedToClaudePlugin": "已应用到 Claude 插件",
"removedFromClaudePlugin": "已从 Claude 插件移除",
"syncClaudePluginFailed": "同步 Claude 插件失败"
"syncVSCodeFailed": "同步 VS Code 失败"
},
"confirm": {
"deleteProvider": "删除供应商",
@@ -83,28 +62,9 @@
},
"settings": {
"title": "设置",
"general": "通用",
"language": "界面语言",
"importExport": "导入导出配置",
"exportConfig": "导出配置到文件",
"selectConfigFile": "选择配置文件",
"import": "导入",
"importing": "导入中...",
"importSuccess": "导入成功!",
"importFailed": "导入失败",
"configExported": "配置已导出到:",
"exportFailed": "导出失败",
"selectFileFailed": "选择文件失败",
"configCorrupted": "配置文件可能已损坏或格式不正确",
"backupId": "备份ID",
"autoReload": "数据将在2秒后自动刷新...",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"windowBehavior": "窗口行为",
"minimizeToTray": "关闭时最小化到托盘",
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
"enableClaudePluginIntegration": "应用到 Claude Code 插件",
"enableClaudePluginIntegrationDescription": "开启后可以在 Vscode Claude Code 插件里使用第三方供应商",
"configFileLocation": "配置文件位置",
"openFolder": "打开文件夹",
"configDirectoryOverride": "配置目录覆盖(高级)",
@@ -122,8 +82,7 @@
"upToDate": "已是最新",
"releaseNotes": "更新日志",
"viewReleaseNotes": "查看该版本更新日志",
"viewCurrentReleaseNotes": "查看当前版本更新日志",
"exportFailedError": "导出配置失败:"
"viewCurrentReleaseNotes": "查看当前版本更新日志"
},
"apps": {
"claude": "Claude Code",
@@ -133,6 +92,7 @@
"providerSwitchReceived": "收到供应商切换事件:",
"setupListenerFailed": "设置供应商切换监听器失败:",
"updateProviderFailed": "更新供应商失败:",
"syncToVSCodeFailed": "同步到VS Code失败:",
"autoImportFailed": "自动导入默认配置失败:",
"openLinkFailed": "打开链接失败:",
"getVersionFailed": "获取版本信息失败:",
@@ -147,243 +107,5 @@
"selectConfigDirFailed": "选择配置目录失败:",
"getDefaultConfigDirFailed": "获取默认配置目录失败:",
"openReleaseNotesFailed": "打开更新日志失败:"
},
"providerForm": {
"supplierName": "供应商名称",
"supplierNameRequired": "供应商名称 *",
"supplierNamePlaceholder": "例如Anthropic 官方",
"websiteUrl": "官网地址",
"websiteUrlPlaceholder": "https://example.com可选",
"apiEndpoint": "请求地址",
"apiEndpointPlaceholder": "https://your-api-endpoint.com",
"codexApiEndpointPlaceholder": "https://your-api-endpoint.com/v1",
"manageAndTest": "管理与测速",
"configContent": "配置内容",
"useConfigWizard": "使用配置向导",
"manualConfig": "手动配置供应商,需要填写完整的配置信息,或者",
"officialNoApiKey": "官方登录无需填写 API Key直接保存即可",
"codexOfficialNoApiKey": "官方无需填写 API Key直接保存即可",
"kimiApiKeyHint": "填写后可获取模型列表",
"apiKeyAutoFill": "只需要填这里,下方配置会自动填充",
"codexApiKeyAutoFill": "只需要填这里,下方 auth.json 会自动填充",
"getApiKey": "获取 API Key",
"parameterConfig": "参数配置 - {{name}} *",
"mainModel": "主模型 (可选)",
"mainModelPlaceholder": "例如: GLM-4.6",
"fastModel": "快速模型 (可选)",
"fastModelPlaceholder": "例如: GLM-4.5-Air",
"modelHint": "💡 留空将使用供应商的默认模型",
"apiHint": "💡 填写兼容 Claude API 的服务端点地址",
"codexApiHint": "💡 填写兼容 OpenAI Response 格式的服务端点地址",
"fillSupplierName": "请填写供应商名称",
"fillConfigContent": "请填写配置内容",
"fillParameter": "请填写 {{label}}",
"configJsonError": "配置JSON格式错误请检查语法",
"authJsonRequired": "auth.json 必须是 JSON 对象",
"authJsonError": "auth.json 格式错误请检查JSON语法",
"fillAuthJson": "请填写 auth.json 配置",
"fillApiKey": "请填写 OPENAI_API_KEY",
"visitWebsite": "访问 {{url}}"
},
"endpointTest": {
"title": "请求地址管理",
"endpoints": "个端点",
"autoSelect": "自动选择",
"testSpeed": "测速",
"testing": "测速中",
"addEndpointPlaceholder": "https://api.example.com",
"done": "完成",
"noEndpoints": "暂无端点",
"failed": "失败",
"enterValidUrl": "请输入有效的 URL",
"invalidUrlFormat": "URL 格式不正确",
"onlyHttps": "仅支持 HTTP/HTTPS",
"urlExists": "该地址已存在",
"saveFailed": "保存失败,请重试",
"loadEndpointsFailed": "加载自定义端点失败:",
"addEndpointFailed": "添加自定义端点失败:",
"removeEndpointFailed": "删除自定义端点失败:",
"pleaseAddEndpoint": "请先添加端点",
"testUnavailable": "测速功能不可用",
"noResult": "未返回结果",
"testFailed": "测速失败: {{error}}"
},
"codexConfig": {
"quickWizard": "快速配置向导",
"authJson": "auth.json (JSON) *",
"authJsonPlaceholder": "{\n \"OPENAI_API_KEY\": \"sk-your-api-key-here\"\n}",
"authJsonHint": "Codex auth.json 配置内容",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex config.toml 配置内容",
"writeCommonConfig": "写入通用配置",
"editCommonConfig": "编辑通用配置",
"editCommonConfigTitle": "编辑 Codex 通用配置片段",
"commonConfigHint": "该片段会在勾选'写入通用配置'时追加到 config.toml 末尾",
"wizardHint": "输入关键参数,系统将自动生成标准的 auth.json 和 config.toml 配置。",
"apiKeyLabel": "API 密钥 *",
"apiKeyPlaceholder": "sk-your-api-key-here",
"supplierNameLabel": "供应商名称 *",
"supplierNamePlaceholder": "例如Codex 官方",
"supplierNameHint": "将显示在供应商列表中,可使用中文",
"supplierCodeLabel": "供应商代号(英文)",
"supplierCodePlaceholder": "custom可选",
"supplierCodeHint": "将用作配置文件中的标识符,默认为 custom",
"apiUrlLabel": "API 请求地址 *",
"apiUrlPlaceholder": "https://your-api-endpoint.com/v1",
"websiteLabel": "官网地址",
"websitePlaceholder": "https://example.com",
"websiteHint": "官方网站地址(可选)",
"modelNameLabel": "模型名称 *",
"modelNamePlaceholder": "gpt-5-codex",
"configPreview": "配置预览",
"applyConfig": "应用配置"
},
"kimiSelector": {
"modelConfig": "模型配置",
"mainModel": "主模型",
"fastModel": "快速模型",
"refreshModels": "刷新模型列表",
"pleaseSelectModel": "请选择模型",
"noModels": "暂无模型",
"fillApiKeyFirst": "请先填写 API Key",
"requestFailed": "请求失败: {{error}}",
"invalidData": "返回数据格式错误",
"fetchModelsFailed": "获取模型列表失败",
"apiKeyHint": "💡 填写 API Key 后将自动获取可用模型列表"
},
"presetSelector": {
"title": "选择配置类型",
"custom": "自定义",
"customDescription": "手动配置供应商,需要填写完整的配置信息",
"officialDescription": "官方登录,不需要填写 API Key",
"presetDescription": "使用预设配置,只需填写 API Key"
},
"mcp": {
"title": "MCP 管理",
"claudeTitle": "Claude Code MCP 管理",
"codexTitle": "Codex MCP 管理",
"userLevelPath": "用户级 MCP 配置路径",
"serverList": "服务器列表",
"loading": "加载中...",
"empty": "暂无 MCP 服务器",
"emptyDescription": "点击右上角按钮添加第一个 MCP 服务器",
"add": "添加 MCP",
"addServer": "新增 MCP",
"editServer": "编辑 MCP",
"addClaudeServer": "新增 Claude Code MCP",
"editClaudeServer": "编辑 Claude Code MCP",
"addCodexServer": "新增 Codex MCP",
"editCodexServer": "编辑 Codex MCP",
"configPath": "配置路径",
"serverCount": "已配置 {{count}} 个 MCP 服务器",
"enabledCount": "已启用 {{count}} 个",
"template": {
"fetch": "快速模板mcp-fetch"
},
"form": {
"title": "MCP 标题(唯一)",
"titlePlaceholder": "my-mcp-server",
"name": "显示名称",
"namePlaceholder": "例如 @modelcontextprotocol/server-time",
"description": "描述",
"descriptionPlaceholder": "可选的描述信息",
"tags": "标签(逗号分隔)",
"tagsPlaceholder": "stdio, time, utility",
"homepage": "主页链接",
"homepagePlaceholder": "https://example.com",
"docs": "文档链接",
"docsPlaceholder": "https://example.com/docs",
"additionalInfo": "附加信息",
"jsonConfig": "JSON 配置",
"jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}",
"tomlConfig": "TOML 配置",
"tomlPlaceholder": "type = \"stdio\"\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]",
"useWizard": "配置向导"
},
"wizard": {
"title": "MCP 配置向导",
"hint": "快速配置 MCP 服务器,自动生成 JSON 配置",
"type": "类型",
"typeStdio": "stdio",
"typeHttp": "http",
"command": "命令",
"commandPlaceholder": "npx 或 uvx",
"args": "参数",
"argsPlaceholder": "arg1\narg2",
"env": "环境变量",
"envPlaceholder": "KEY1=value1\nKEY2=value2",
"url": "URL",
"urlPlaceholder": "https://api.example.com/mcp",
"urlRequired": "请输入 URL",
"headers": "请求头(可选)",
"headersPlaceholder": "Authorization: Bearer your_token_here\nContent-Type: application/json",
"preview": "配置预览",
"apply": "应用配置"
},
"id": "标识 (唯一)",
"type": "类型",
"command": "命令",
"validateCommand": "校验命令",
"args": "参数",
"argsPlaceholder": "例如mcp-server-fetch --help",
"env": "环境变量 (一行一个KEY=VALUE)",
"envPlaceholder": "FOO=bar\nHELLO=world",
"reset": "重置",
"notice": {
"restartClaude": "已写入配置,重启 Claude 生效"
},
"msg": {
"saved": "已保存",
"deleted": "已删除",
"enabled": "已启用",
"disabled": "已禁用",
"templateAdded": "已添加模板"
},
"error": {
"idRequired": "请填写标识",
"idExists": "该标识已存在,请更换",
"jsonInvalid": "JSON 格式错误,请检查",
"tomlInvalid": "TOML 格式错误,请检查",
"commandRequired": "请填写命令",
"singleServerObjectRequired": "此处只需单个服务器对象,请不要粘贴包含 mcpServers 的整份配置",
"saveFailed": "保存失败",
"deleteFailed": "删除失败"
},
"validation": {
"ok": "命令可用",
"fail": "命令不可用"
},
"confirm": {
"deleteTitle": "删除 MCP 服务器",
"deleteMessage": "确定要删除 MCP 服务器 \"{{id}}\" 吗?此操作无法撤销。"
},
"presets": {
"title": "选择 MCP 类型",
"enable": "启用",
"enabled": "已启用",
"installed": "已安装",
"docs": "文档",
"requiresEnv": "需要环境变量",
"fetch": {
"name": "mcp-server-fetch",
"description": "通用 HTTP 请求工具,支持 GET/POST 等 HTTP 方法,适合快速请求接口/抓取网页数据"
},
"time": {
"name": "@modelcontextprotocol/server-time",
"description": "时间查询工具,提供当前时间、时区转换、日期计算等功能"
},
"memory": {
"name": "@modelcontextprotocol/server-memory",
"description": "知识图谱记忆系统,支持存储实体、关系和观察,让 AI 记住对话中的重要信息"
},
"sequential-thinking": {
"name": "@modelcontextprotocol/server-sequential-thinking",
"description": "顺序思考工具,帮助 AI 将复杂问题分解为多个步骤,逐步深入思考"
},
"context7": {
"name": "@upstash/context7-mcp",
"description": "Context7 文档搜索工具,提供最新的库文档和代码示例,配置 key 会有更高限额"
}
}
}
}

View File

@@ -22,10 +22,9 @@ export const isLinux = (): boolean => {
try {
const ua = navigator.userAgent || "";
// WebKitGTK/Chromium 在 Linux/Wayland/X11 下 UA 通常包含 Linux 或 X11
return (
/linux|x11/i.test(ua) && !/android/i.test(ua) && !isMac() && !isWindows()
);
return /linux|x11/i.test(ua) && !/android/i.test(ua) && !isMac() && !isWindows();
} catch {
return false;
}
};

View File

@@ -16,9 +16,6 @@ export const buttonStyles = {
danger:
"px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700 transition-colors text-sm font-medium",
// MCP 专属按钮:绿底白字
mcp: "px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700 transition-colors text-sm font-medium",
// 幽灵按钮:无背景,仅悬浮反馈
ghost:
"px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-sm font-medium",
@@ -37,7 +34,7 @@ export const cardStyles = {
// 带悬浮效果的卡片
interactive:
"bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 hover:shadow-sm dark:bg-gray-900 dark:border-gray-700 dark:hover:border-gray-600 transition-[border-color,box-shadow] duration-200",
"bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 hover:shadow-sm dark:bg-gray-900 dark:border-gray-700 dark:hover:border-gray-600 transition-all duration-200",
// 选中/激活态卡片
selected:

View File

@@ -1,14 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import {
Provider,
Settings,
CustomEndpoint,
McpStatus,
McpServer,
McpServerSpec,
McpConfigResponse,
} from "../types";
import { Provider, Settings } from "../types";
// 应用类型
export type AppType = "claude" | "codex";
@@ -26,13 +18,6 @@ interface ImportResult {
message?: string;
}
export interface EndpointLatencyResult {
url: string;
latency: number | null;
status?: number;
error?: string;
}
// Tauri API 封装,提供统一的全局 API 接口
export const tauriAPI = {
// 获取所有供应商
@@ -100,9 +85,8 @@ export const tauriAPI = {
app,
});
} catch (error) {
// 让调用方拿到后端的详细错误信息
console.error("切换供应商失败:", error);
throw error;
return false;
}
},
@@ -148,25 +132,40 @@ export const tauriAPI = {
}
},
// 打开配置目录(按应用类型)
// 获取 Claude Code 配置状态
getClaudeConfigStatus: async (): Promise<ConfigStatus> => {
try {
return await invoke("get_claude_config_status");
} catch (error) {
console.error("获取配置状态失败:", error);
return {
exists: false,
path: "",
error: String(error),
};
}
},
// 获取应用配置状态(通用)
getConfigStatus: async (app?: AppType): Promise<ConfigStatus> => {
try {
return await invoke("get_config_status", { app_type: app, app });
} catch (error) {
console.error("获取配置状态失败:", error);
return {
exists: false,
path: "",
error: String(error),
};
}
},
// 打开配置文件夹
openConfigFolder: async (app?: AppType): Promise<void> => {
try {
await invoke("open_config_folder", { app_type: app, app });
} catch (error) {
console.error("打开配置目录失败:", error);
}
},
// 选择配置目录(可选默认路径)
selectConfigDirectory: async (
defaultPath?: string,
): Promise<string | null> => {
try {
// 后端参数为 snake_casedefault_path
return await invoke("pick_directory", { default_path: defaultPath });
} catch (error) {
console.error("选择配置目录失败:", error);
return null;
console.error("打开配置文件夹失败:", error);
}
},
@@ -182,20 +181,49 @@ export const tauriAPI = {
// 更新托盘菜单
updateTrayMenu: async (): Promise<boolean> => {
try {
return await invoke<boolean>("update_tray_menu");
return await invoke("update_tray_menu");
} catch (error) {
console.error("更新托盘菜单失败:", error);
return false;
}
},
// 获取应用设置
// 监听供应商切换事件
onProviderSwitched: async (
callback: (data: { appType: string; providerId: string }) => void,
): Promise<UnlistenFn> => {
return await listen("provider-switched", (event) => {
callback(event.payload as { appType: string; providerId: string });
});
},
// (保留空位,取消迁移提示)
// 选择配置目录
selectConfigDirectory: async (
defaultPath?: string,
): Promise<string | null> => {
try {
const sanitized =
defaultPath && defaultPath.trim() !== ""
? defaultPath
: undefined;
return await invoke<string | null>("pick_directory", {
defaultPath: sanitized,
});
} catch (error) {
console.error("选择配置目录失败:", error);
return null;
}
},
// 获取设置
getSettings: async (): Promise<Settings> => {
try {
return await invoke("get_settings");
} catch (error) {
console.error("获取设置失败:", error);
throw error;
return { showInTray: true, minimizeToTrayOnClose: true };
}
},
@@ -247,389 +275,37 @@ export const tauriAPI = {
}
},
// Claude 插件:获取 ~/.claude/config.json 状态
getClaudePluginStatus: async (): Promise<ConfigStatus> => {
// VS Code: 获取 settings.json 状态
getVSCodeSettingsStatus: async (): Promise<{
exists: boolean;
path: string;
error?: string;
}> => {
try {
return await invoke<ConfigStatus>("get_claude_plugin_status");
return await invoke("get_vscode_settings_status");
} catch (error) {
console.error("获取 Claude 插件状态失败:", error);
console.error("获取 VS Code 设置状态失败:", error);
return { exists: false, path: "", error: String(error) };
}
},
// Claude 插件:读取配置内容
readClaudePluginConfig: async (): Promise<string | null> => {
// VS Code: 读取 settings.json 文本
readVSCodeSettings: async (): Promise<string> => {
try {
return await invoke<string | null>("read_claude_plugin_config");
return await invoke("read_vscode_settings");
} catch (error) {
throw new Error(`读取 Claude 插件配置失败: ${String(error)}`);
throw new Error(`读取 VS Code 设置失败: ${String(error)}`);
}
},
// Claude 插件:应用或移除固定配置
applyClaudePluginConfig: async (options: {
official: boolean;
}): Promise<boolean> => {
const { official } = options;
// VS Code: 写回 settings.json 文本(不自动创建)
writeVSCodeSettings: async (content: string): Promise<boolean> => {
try {
return await invoke<boolean>("apply_claude_plugin_config", { official });
return await invoke("write_vscode_settings", { content });
} catch (error) {
throw new Error(`写入 Claude 插件配置失败: ${String(error)}`);
throw new Error(`写入 VS Code 设置失败: ${String(error)}`);
}
},
// Claude 插件:检测是否已应用目标配置
isClaudePluginApplied: async (): Promise<boolean> => {
try {
return await invoke<boolean>("is_claude_plugin_applied");
} catch (error) {
throw new Error(`检测 Claude 插件配置失败: ${String(error)}`);
}
},
// Claude MCP获取状态用户级 ~/.claude.json
getClaudeMcpStatus: async (): Promise<McpStatus> => {
try {
return await invoke<McpStatus>("get_claude_mcp_status");
} catch (error) {
console.error("获取 MCP 状态失败:", error);
throw error;
}
},
// Claude MCP读取 ~/.claude.json 文本
readClaudeMcpConfig: async (): Promise<string | null> => {
try {
return await invoke<string | null>("read_claude_mcp_config");
} catch (error) {
console.error("读取 mcp.json 失败:", error);
throw error;
}
},
// Claude MCP新增/更新服务器定义
upsertClaudeMcpServer: async (
id: string,
spec: McpServerSpec | Record<string, any>,
): Promise<boolean> => {
try {
return await invoke<boolean>("upsert_claude_mcp_server", { id, spec });
} catch (error) {
console.error("保存 MCP 服务器失败:", error);
throw error;
}
},
// Claude MCP删除服务器定义
deleteClaudeMcpServer: async (id: string): Promise<boolean> => {
try {
return await invoke<boolean>("delete_claude_mcp_server", { id });
} catch (error) {
console.error("删除 MCP 服务器失败:", error);
throw error;
}
},
// Claude MCP校验命令是否在 PATH 中
validateMcpCommand: async (cmd: string): Promise<boolean> => {
try {
return await invoke<boolean>("validate_mcp_command", { cmd });
} catch (error) {
console.error("校验 MCP 命令失败:", error);
return false;
}
},
// 新config.json 为 SSOT 的 MCP API按客户端
getMcpConfig: async (app: AppType = "claude"): Promise<McpConfigResponse> => {
try {
return await invoke<McpConfigResponse>("get_mcp_config", { app });
} catch (error) {
console.error("获取 MCP 配置失败:", error);
throw error;
}
},
upsertMcpServerInConfig: async (
app: AppType = "claude",
id: string,
spec: McpServer,
): Promise<boolean> => {
try {
return await invoke<boolean>("upsert_mcp_server_in_config", {
app,
id,
spec,
});
} catch (error) {
console.error("写入 MCPconfig.json失败:", error);
throw error;
}
},
deleteMcpServerInConfig: async (
app: AppType = "claude",
id: string,
): Promise<boolean> => {
try {
return await invoke<boolean>("delete_mcp_server_in_config", { app, id });
} catch (error) {
console.error("删除 MCPconfig.json失败:", error);
throw error;
}
},
setMcpEnabled: async (
app: AppType = "claude",
id: string,
enabled: boolean,
): Promise<boolean> => {
try {
return await invoke<boolean>("set_mcp_enabled", { app, id, enabled });
} catch (error) {
console.error("设置 MCP 启用状态失败:", error);
throw error;
}
},
syncEnabledMcpToClaude: async (): Promise<boolean> => {
try {
return await invoke<boolean>("sync_enabled_mcp_to_claude");
} catch (error) {
console.error("同步启用 MCP 到 .claude.json 失败:", error);
throw error;
}
},
// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml
syncEnabledMcpToCodex: async (): Promise<boolean> => {
try {
return await invoke<boolean>("sync_enabled_mcp_to_codex");
} catch (error) {
console.error("同步启用 MCP 到 config.toml 失败:", error);
throw error;
}
},
importMcpFromClaude: async (): Promise<number> => {
try {
return await invoke<number>("import_mcp_from_claude");
} catch (error) {
console.error("从 ~/.claude.json 导入 MCP 失败:", error);
throw error;
}
},
// 从 ~/.codex/config.toml 导入 MCPCodex 作用域)
importMcpFromCodex: async (): Promise<number> => {
try {
return await invoke<number>("import_mcp_from_codex");
} catch (error) {
console.error("从 ~/.codex/config.toml 导入 MCP 失败:", error);
throw error;
}
},
// 读取当前生效live的 provider settings根据 appType
// Codex: { auth: object, config: string }
// Claude: settings.json 内容
getLiveProviderSettings: async (app?: AppType): Promise<any> => {
try {
return await invoke<any>("read_live_provider_settings", {
app_type: app,
app,
appType: app,
});
} catch (error) {
console.error("读取 live 配置失败:", error);
throw error;
}
},
// ours: 第三方/自定义供应商——测速与端点管理
// 第三方/自定义供应商:批量测试端点延迟
testApiEndpoints: async (
urls: string[],
options?: { timeoutSecs?: number },
): Promise<EndpointLatencyResult[]> => {
try {
return await invoke<EndpointLatencyResult[]>("test_api_endpoints", {
urls,
timeout_secs: options?.timeoutSecs,
});
} catch (error) {
console.error("测速调用失败:", error);
throw error;
}
},
// 获取自定义端点列表
getCustomEndpoints: async (
appType: AppType,
providerId: string,
): Promise<CustomEndpoint[]> => {
try {
return await invoke<CustomEndpoint[]>("get_custom_endpoints", {
// 兼容不同后端参数命名
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
});
} catch (error) {
console.error("获取自定义端点列表失败:", error);
return [];
}
},
// 添加自定义端点
addCustomEndpoint: async (
appType: AppType,
providerId: string,
url: string,
): Promise<void> => {
try {
await invoke("add_custom_endpoint", {
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
url,
});
} catch (error) {
console.error("添加自定义端点失败:", error);
// 尽量抛出可读信息
if (error instanceof Error) {
throw error;
} else {
throw new Error(String(error));
}
}
},
// 删除自定义端点
removeCustomEndpoint: async (
appType: AppType,
providerId: string,
url: string,
): Promise<void> => {
try {
await invoke("remove_custom_endpoint", {
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
url,
});
} catch (error) {
console.error("删除自定义端点失败:", error);
throw error;
}
},
// 更新端点最后使用时间
updateEndpointLastUsed: async (
appType: AppType,
providerId: string,
url: string,
): Promise<void> => {
try {
await invoke("update_endpoint_last_used", {
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
url,
});
} catch (error) {
console.error("更新端点最后使用时间失败:", error);
// 不抛出错误,因为这不是关键操作
}
},
// theirs: 导入导出与文件对话框
// 导出配置到文件
exportConfigToFile: async (
filePath: string,
): Promise<{
success: boolean;
message: string;
filePath: string;
}> => {
try {
// 兼容参数命名差异:同时传递 file_path 与 filePath
return await invoke("export_config_to_file", {
file_path: filePath,
filePath: filePath,
});
} catch (error) {
throw new Error(`导出配置失败: ${String(error)}`);
}
},
// 从文件导入配置
importConfigFromFile: async (
filePath: string,
): Promise<{
success: boolean;
message: string;
backupId?: string;
}> => {
try {
// 兼容参数命名差异:同时传递 file_path 与 filePath
return await invoke("import_config_from_file", {
file_path: filePath,
filePath: filePath,
});
} catch (error) {
throw new Error(`导入配置失败: ${String(error)}`);
}
},
// 保存文件对话框
saveFileDialog: async (defaultName: string): Promise<string | null> => {
try {
// 兼容参数命名差异:同时传递 default_name 与 defaultName
const result = await invoke<string | null>("save_file_dialog", {
default_name: defaultName,
defaultName: defaultName,
});
return result;
} catch (error) {
console.error("打开保存对话框失败:", error);
return null;
}
},
// 打开文件对话框
openFileDialog: async (): Promise<string | null> => {
try {
const result = await invoke<string | null>("open_file_dialog");
return result;
} catch (error) {
console.error("打开文件对话框失败:", error);
return null;
}
},
// 监听供应商切换事件
onProviderSwitched: async (
callback: (data: { appType: string; providerId: string }) => void,
): Promise<UnlistenFn> => {
const unlisten = await listen("provider-switched", (event) => {
try {
// 事件 payload 形如 { appType: string, providerId: string }
callback(event.payload as any);
} catch (e) {
console.error("处理 provider-switched 事件失败: ", e);
}
});
return unlisten;
},
};
// 创建全局 API 对象,兼容现有代码

View File

@@ -25,5 +25,5 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<UpdateProvider>
<App />
</UpdateProvider>
</React.StrictMode>,
</React.StrictMode>
);

View File

@@ -13,8 +13,6 @@ export interface Provider {
// 新增:供应商分类(用于差异化提示/能力开关)
category?: ProviderCategory;
createdAt?: number; // 添加时间戳(毫秒)
// 可选:供应商元数据(仅存于 ~/.cc-switch/config.json不写入 live 配置)
meta?: ProviderMeta;
}
export interface AppConfig {
@@ -22,78 +20,14 @@ export interface AppConfig {
current: string;
}
// 自定义端点配置
export interface CustomEndpoint {
url: string;
addedAt: number;
lastUsed?: number;
}
// 供应商元数据(字段名与后端一致,保持 snake_case
export interface ProviderMeta {
// 自定义端点:以 URL 为键,值为端点信息
custom_endpoints?: Record<string, CustomEndpoint>;
}
// 应用设置类型(用于 SettingsModal 与 Tauri API
export interface Settings {
// 是否在系统托盘macOS 菜单栏)显示图标
showInTray: boolean;
// 点击关闭按钮时是否最小化到托盘而不是关闭应用
minimizeToTrayOnClose: boolean;
// 启用 Claude 插件联动(写入 ~/.claude/config.json 的 primaryApiKey
enableClaudePluginIntegration?: boolean;
// 覆盖 Claude Code 配置目录(可选)
claudeConfigDir?: string;
// 覆盖 Codex 配置目录(可选)
codexConfigDir?: string;
// 首选语言(可选,默认中文)
language?: "en" | "zh";
// Claude 自定义端点列表
customEndpointsClaude?: Record<string, CustomEndpoint>;
// Codex 自定义端点列表
customEndpointsCodex?: Record<string, CustomEndpoint>;
}
// MCP 服务器连接参数(宽松:允许扩展字段)
export interface McpServerSpec {
// 可选:社区常见 .mcp.json 中 stdio 配置可不写 type
type?: "stdio" | "http";
// stdio 字段
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
// http 字段
url?: string;
headers?: Record<string, string>;
// 通用字段
[key: string]: any;
}
// MCP 服务器条目(含元信息)
export interface McpServer {
id: string;
name?: string;
description?: string;
tags?: string[];
homepage?: string;
docs?: string;
enabled?: boolean;
server: McpServerSpec;
source?: string;
[key: string]: any;
}
// MCP 配置状态
export interface McpStatus {
userConfigPath: string;
userConfigExists: boolean;
serverCount: number;
}
// 新:来自 config.json 的 MCP 列表响应
export interface McpConfigResponse {
configPath: string;
servers: Record<string, McpServer>;
}

View File

@@ -36,72 +36,3 @@ export const extractErrorMessage = (error: unknown): string => {
return "";
};
/**
* 将已知的 MCP 相关后端错误(通常为中文硬编码)映射为 i18n 文案
* 采用包含式匹配,尽量稳健地覆盖不同上下文的相似消息。
* 若无法识别,返回空字符串以便调用方回退到原始 detail 或默认 i18n。
*/
export const translateMcpBackendError = (
message: string,
t: (key: string, opts?: any) => string,
): string => {
if (!message) return "";
const msg = String(message).trim();
// 基础字段与结构校验相关
if (msg.includes("MCP 服务器 ID 不能为空")) {
return t("mcp.error.idRequired");
}
if (
msg.includes("MCP 服务器定义必须为 JSON 对象") ||
msg.includes("MCP 服务器条目必须为 JSON 对象") ||
msg.includes("MCP 服务器条目缺少 server 字段") ||
msg.includes("MCP 服务器 server 字段必须为 JSON 对象") ||
msg.includes("MCP 服务器连接定义必须为 JSON 对象") ||
msg.includes("MCP 服务器 '" /* 不是对象 */) ||
msg.includes("不是对象") ||
msg.includes("服务器配置必须是对象") ||
msg.includes("MCP 服务器 name 必须为字符串") ||
msg.includes("MCP 服务器 description 必须为字符串") ||
msg.includes("MCP 服务器 homepage 必须为字符串") ||
msg.includes("MCP 服务器 docs 必须为字符串") ||
msg.includes("MCP 服务器 tags 必须为字符串数组") ||
msg.includes("MCP 服务器 enabled 必须为布尔值")
) {
return t("mcp.error.jsonInvalid");
}
if (msg.includes("MCP 服务器 type 必须是")) {
return t("mcp.error.jsonInvalid");
}
// 必填字段
if (
msg.includes("stdio 类型的 MCP 服务器缺少 command 字段") ||
msg.includes("必须包含 command 字段")
) {
return t("mcp.error.commandRequired");
}
if (
msg.includes("http 类型的 MCP 服务器缺少 url 字段") ||
msg.includes("必须包含 url 字段") ||
msg === "URL 不能为空"
) {
return t("mcp.wizard.urlRequired");
}
// 文件解析/序列化
if (
msg.includes("解析 ~/.claude.json 失败") ||
msg.includes("解析 config.toml 失败") ||
msg.includes("无法识别的 TOML 格式") ||
msg.includes("TOML 内容不能为空")
) {
return t("mcp.error.tomlInvalid");
}
if (msg.includes("序列化 config.toml 失败")) {
return t("mcp.error.tomlInvalid");
}
return "";
};

View File

@@ -1,7 +1,5 @@
// 供应商配置处理工具函数
import type { TemplateValueConfig } from "../config/providerPresets";
const isPlainObject = (value: unknown): value is Record<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]";
};
@@ -175,51 +173,6 @@ export const getApiKeyFromConfig = (jsonString: string): string => {
}
};
// 模板变量替换
export const applyTemplateValues = (
config: any,
templateValues: Record<string, TemplateValueConfig> | undefined,
): any => {
const resolvedValues = Object.fromEntries(
Object.entries(templateValues ?? {}).map(([key, value]) => {
const resolvedValue =
value.editorValue !== undefined
? value.editorValue
: (value.defaultValue ?? "");
return [key, resolvedValue];
}),
);
const replaceInString = (str: string): string => {
return Object.entries(resolvedValues).reduce((acc, [key, value]) => {
const placeholder = `\${${key}}`;
if (!acc.includes(placeholder)) {
return acc;
}
return acc.split(placeholder).join(value ?? "");
}, str);
};
const traverse = (obj: any): any => {
if (typeof obj === "string") {
return replaceInString(obj);
}
if (Array.isArray(obj)) {
return obj.map(traverse);
}
if (obj && typeof obj === "object") {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = traverse(value);
}
return result;
}
return obj;
};
return traverse(config);
};
// 判断配置中是否存在 API Key 字段
export const hasApiKeyField = (jsonString: string): boolean => {
try {
@@ -365,26 +318,3 @@ export const getCodexBaseUrl = (
return undefined;
}
};
// 在 Codex 的 TOML 配置文本中写入或更新 base_url 字段
export const setCodexBaseUrl = (
configText: string,
baseUrl: string,
): string => {
const trimmed = baseUrl.trim();
if (!trimmed) {
return configText;
}
const normalizedUrl = trimmed.replace(/\s+/g, "").replace(/\/+$/, "");
const replacementLine = `base_url = "${normalizedUrl}"`;
const pattern = /base_url\s*=\s*(["'])([^"']+)\1/;
if (pattern.test(configText)) {
return configText.replace(pattern, replacementLine);
}
const prefix =
configText && !configText.endsWith("\n") ? `${configText}\n` : configText;
return `${prefix}${replacementLine}\n`;
};

View File

@@ -1,202 +0,0 @@
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import { McpServerSpec } from "../types";
/**
* 验证 TOML 格式并转换为 JSON 对象
* @param text TOML 文本
* @returns 错误信息(空字符串表示成功)
*/
export const validateToml = (text: string): string => {
if (!text.trim()) return "";
try {
const parsed = parseToml(text);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return "mustBeObject";
}
return "";
} catch (e: any) {
// 返回底层错误信息,由上层进行 i18n 包装
return e?.message || "parseError";
}
};
/**
* 将 McpServerSpec 对象转换为 TOML 字符串
* 使用 @iarna/toml 的 stringify自动处理转义与嵌套表
*/
export const mcpServerToToml = (server: McpServerSpec): string => {
const obj: any = {};
if (server.type) obj.type = server.type;
if (server.type === "stdio") {
if (server.command !== undefined) obj.command = server.command;
if (server.args && Array.isArray(server.args)) obj.args = server.args;
if (server.cwd !== undefined) obj.cwd = server.cwd;
if (server.env && typeof server.env === "object") obj.env = server.env;
} else if (server.type === "http") {
if (server.url !== undefined) obj.url = server.url;
if (server.headers && typeof server.headers === "object")
obj.headers = server.headers;
}
// 去除未定义字段,确保输出更干净
for (const k of Object.keys(obj)) {
if (obj[k] === undefined) delete obj[k];
}
// stringify 默认会带换行,做一次 trim 以适配文本框展示
return stringifyToml(obj).trim();
};
/**
* 将 TOML 文本转换为 McpServerSpec 对象(单个服务器配置)
* 支持两种格式:
* 1. 直接的服务器配置type, command, args 等)
* 2. [mcp.servers.<id>] 或 [mcp_servers.<id>] 格式(取第一个服务器)
* @param tomlText TOML 文本
* @returns McpServer 对象
* @throws 解析或转换失败时抛出错误
*/
export const tomlToMcpServer = (tomlText: string): McpServerSpec => {
if (!tomlText.trim()) {
throw new Error("TOML 内容不能为空");
}
const parsed = parseToml(tomlText);
// 情况 1: 直接是服务器配置(包含 type/command/url 等字段)
if (
parsed.type ||
parsed.command ||
parsed.url ||
parsed.args ||
parsed.env
) {
return normalizeServerConfig(parsed);
}
// 情况 2: [mcp.servers.<id>] 格式
if (parsed.mcp && typeof parsed.mcp === "object") {
const mcpObj = parsed.mcp as any;
if (mcpObj.servers && typeof mcpObj.servers === "object") {
const serverIds = Object.keys(mcpObj.servers);
if (serverIds.length > 0) {
const firstServer = mcpObj.servers[serverIds[0]];
return normalizeServerConfig(firstServer);
}
}
}
// 情况 3: [mcp_servers.<id>] 格式
if (parsed.mcp_servers && typeof parsed.mcp_servers === "object") {
const serverIds = Object.keys(parsed.mcp_servers);
if (serverIds.length > 0) {
const firstServer = (parsed.mcp_servers as any)[serverIds[0]];
return normalizeServerConfig(firstServer);
}
}
throw new Error(
"无法识别的 TOML 格式。请提供单个 MCP 服务器配置,或使用 [mcp.servers.<id>] 格式",
);
};
/**
* 规范化服务器配置对象为 McpServer 格式
*/
function normalizeServerConfig(config: any): McpServerSpec {
if (!config || typeof config !== "object") {
throw new Error("服务器配置必须是对象");
}
const type = (config.type as string) || "stdio";
if (type === "stdio") {
if (!config.command || typeof config.command !== "string") {
throw new Error("stdio 类型的 MCP 服务器必须包含 command 字段");
}
const server: McpServerSpec = {
type: "stdio",
command: config.command,
};
// 可选字段
if (config.args && Array.isArray(config.args)) {
server.args = config.args.map((arg: any) => String(arg));
}
if (config.env && typeof config.env === "object") {
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(config.env)) {
env[k] = String(v);
}
server.env = env;
}
if (config.cwd && typeof config.cwd === "string") {
server.cwd = config.cwd;
}
return server;
} else if (type === "http") {
if (!config.url || typeof config.url !== "string") {
throw new Error("http 类型的 MCP 服务器必须包含 url 字段");
}
const server: McpServerSpec = {
type: "http",
url: config.url,
};
// 可选字段
if (config.headers && typeof config.headers === "object") {
const headers: Record<string, string> = {};
for (const [k, v] of Object.entries(config.headers)) {
headers[k] = String(v);
}
server.headers = headers;
}
return server;
} else {
throw new Error(`不支持的 MCP 服务器类型: ${type}`);
}
}
/**
* 尝试从 TOML 中提取合理的服务器 ID/标题
* @param tomlText TOML 文本
* @returns 建议的 ID失败返回空字符串
*/
export const extractIdFromToml = (tomlText: string): string => {
try {
const parsed = parseToml(tomlText);
// 尝试从 [mcp.servers.<id>] 或 [mcp_servers.<id>] 中提取 ID
if (parsed.mcp && typeof parsed.mcp === "object") {
const mcpObj = parsed.mcp as any;
if (mcpObj.servers && typeof mcpObj.servers === "object") {
const serverIds = Object.keys(mcpObj.servers);
if (serverIds.length > 0) {
return serverIds[0];
}
}
}
if (parsed.mcp_servers && typeof parsed.mcp_servers === "object") {
const serverIds = Object.keys(parsed.mcp_servers);
if (serverIds.length > 0) {
return serverIds[0];
}
}
// 尝试从 command 中推断
if (parsed.command && typeof parsed.command === "string") {
const cmd = parsed.command.split(/[\\/]/).pop() || "";
return cmd.replace(/\.(exe|bat|sh|js|py)$/i, "");
}
} catch {
// 解析失败,返回空
}
return "";
};

134
src/utils/vscodeSettings.ts Normal file
View File

@@ -0,0 +1,134 @@
import { applyEdits, modify, parse } from "jsonc-parser";
const fmt = { insertSpaces: true, tabSize: 2, eol: "\n" } as const;
export interface AppliedCheck {
hasApiBase: boolean;
apiBase?: string;
hasPreferredAuthMethod: boolean;
}
export function normalizeBaseUrl(url: string): string {
return url.replace(/\/+$/, "");
}
const isDocEmpty = (s: string) => s.trim().length === 0;
// 检查 settings.jsonJSONC 文本)中是否已经应用了我们的键
export function detectApplied(content: string): AppliedCheck {
try {
// 允许 JSONC 的宽松解析jsonc-parser 的 parse 可以直接处理注释
const data = parse(content) as any;
const apiBase = data?.["chatgpt.apiBase"];
const method = data?.["chatgpt.config"]?.preferred_auth_method;
return {
hasApiBase: typeof apiBase === "string",
apiBase,
hasPreferredAuthMethod: typeof method === "string",
};
} catch {
return { hasApiBase: false, hasPreferredAuthMethod: false };
}
}
// 生成“清理我们管理的键”后的文本(仅删除我们写入的两个键)
export function removeManagedKeys(content: string): string {
if (isDocEmpty(content)) return content; // 空文档无需删除
let out = content;
// 删除 chatgpt.apiBase
try {
out = applyEdits(
out,
modify(out, ["chatgpt.apiBase"], undefined, { formattingOptions: fmt }),
);
} catch {
// 忽略删除失败
}
// 删除 chatgpt.config.preferred_auth_method注意 chatgpt.config 是顶层带点的键)
try {
out = applyEdits(
out,
modify(out, ["chatgpt.config", "preferred_auth_method"], undefined, {
formattingOptions: fmt,
}),
);
} catch {
// 忽略删除失败
}
// 兼容早期错误写入:若曾写成嵌套 chatgpt.config.preferred_auth_method也一并清理
try {
out = applyEdits(
out,
modify(out, ["chatgpt", "config", "preferred_auth_method"], undefined, {
formattingOptions: fmt,
}),
);
} catch {
// 忽略删除失败
}
// 清理 chatgpt.config 的异常情况:
// 1. 早期遗留的标量值(字符串/数字/null等
// 2. 空对象
// 3. 数组类型
try {
const data = parse(out) as any;
const cfg = data?.["chatgpt.config"];
// 需要清理的情况:
// - 标量值null、字符串、数字等
// - 数组
// - 空对象
const shouldRemove = cfg !== undefined && (
cfg === null ||
typeof cfg !== "object" ||
Array.isArray(cfg) ||
(typeof cfg === "object" && Object.keys(cfg).length === 0)
);
if (shouldRemove) {
out = applyEdits(
out,
modify(out, ["chatgpt.config"], undefined, { formattingOptions: fmt }),
);
}
} catch {
// 忽略解析失败,保持已删除的键
}
return out;
}
// 生成“应用供应商到 VS Code”后的文本
// - 先清理我们管理的键
// - 再根据是否官方决定写入(官方:不写入;非官方:写入两个键)
export function applyProviderToVSCode(
content: string,
opts: { baseUrl?: string | null; isOfficial?: boolean },
): string {
let out = removeManagedKeys(content);
if (!opts.isOfficial && opts.baseUrl) {
const apiBase = normalizeBaseUrl(opts.baseUrl);
if (isDocEmpty(out)) {
// 简化:空文档直接写入新对象
const obj: any = {
"chatgpt.apiBase": apiBase,
"chatgpt.config": { preferred_auth_method: "apikey" },
};
out = JSON.stringify(obj, null, 2) + "\n";
} else {
out = applyEdits(
out,
modify(out, ["chatgpt.apiBase"], apiBase, { formattingOptions: fmt }),
);
out = applyEdits(
out,
modify(out, ["chatgpt.config", "preferred_auth_method"], "apikey", {
formattingOptions: fmt,
}),
);
}
}
return out;
}

97
src/vite-env.d.ts vendored
View File

@@ -1,14 +1,6 @@
/// <reference types="vite/client" />
import {
Provider,
Settings,
CustomEndpoint,
McpStatus,
McpConfigResponse,
McpServer,
McpServerSpec,
} from "./types";
import { Provider, Settings } from "./types";
import { AppType } from "./lib/tauri-api";
import type { UnlistenFn } from "@tauri-apps/api/event";
@@ -37,18 +29,6 @@ declare global {
getClaudeConfigStatus: () => Promise<ConfigStatus>;
getConfigStatus: (app?: AppType) => Promise<ConfigStatus>;
getConfigDir: (app?: AppType) => Promise<string>;
saveFileDialog: (defaultName: string) => Promise<string | null>;
openFileDialog: () => Promise<string | null>;
exportConfigToFile: (filePath: string) => Promise<{
success: boolean;
message: string;
filePath: string;
}>;
importConfigFromFile: (filePath: string) => Promise<{
success: boolean;
message: string;
backupId?: string;
}>;
selectConfigDirectory: (defaultPath?: string) => Promise<string | null>;
openConfigFolder: (app?: AppType) => Promise<void>;
openExternal: (url: string) => Promise<void>;
@@ -62,77 +42,10 @@ declare global {
isPortable: () => Promise<boolean>;
getAppConfigPath: () => Promise<string>;
openAppConfigFolder: () => Promise<void>;
// Claude 插件配置能力
getClaudePluginStatus: () => Promise<ConfigStatus>;
readClaudePluginConfig: () => Promise<string | null>;
applyClaudePluginConfig: (options: {
official: boolean;
}) => Promise<boolean>;
isClaudePluginApplied: () => Promise<boolean>;
// Claude MCP
getClaudeMcpStatus: () => Promise<McpStatus>;
readClaudeMcpConfig: () => Promise<string | null>;
upsertClaudeMcpServer: (
id: string,
spec: McpServerSpec | Record<string, any>,
) => Promise<boolean>;
deleteClaudeMcpServer: (id: string) => Promise<boolean>;
validateMcpCommand: (cmd: string) => Promise<boolean>;
// 新config.json 为 SSOT 的 MCP API
getMcpConfig: (app?: AppType) => Promise<McpConfigResponse>;
upsertMcpServerInConfig: (
app: AppType | undefined,
id: string,
spec: McpServer,
) => Promise<boolean>;
deleteMcpServerInConfig: (
app: AppType | undefined,
id: string,
) => Promise<boolean>;
setMcpEnabled: (
app: AppType | undefined,
id: string,
enabled: boolean,
) => Promise<boolean>;
syncEnabledMcpToClaude: () => Promise<boolean>;
syncEnabledMcpToCodex: () => Promise<boolean>;
importMcpFromClaude: () => Promise<number>;
importMcpFromCodex: () => Promise<number>;
// 读取当前生效live的 provider settings根据 appType
// Codex: { auth: object, config: string }
// Claude: settings.json 内容
getLiveProviderSettings: (app?: AppType) => Promise<any>;
testApiEndpoints: (
urls: string[],
options?: { timeoutSecs?: number },
) => Promise<
Array<{
url: string;
latency: number | null;
status?: number;
error?: string;
}>
>;
// 自定义端点管理
getCustomEndpoints: (
appType: AppType,
providerId: string,
) => Promise<CustomEndpoint[]>;
addCustomEndpoint: (
appType: AppType,
providerId: string,
url: string,
) => Promise<void>;
removeCustomEndpoint: (
appType: AppType,
providerId: string,
url: string,
) => Promise<void>;
updateEndpointLastUsed: (
appType: AppType,
providerId: string,
url: string,
) => Promise<void>;
// VS Code settings.json 能力
getVSCodeSettingsStatus: () => Promise<ConfigStatus>;
readVSCodeSettings: () => Promise<string>;
writeVSCodeSettings: (content: string) => Promise<boolean>;
};
platform: {
isMac: boolean;