Compare commits
57 Commits
yovinchen/
...
v3.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f55c6d3d91 | ||
|
|
ec83e2ca44 | ||
|
|
60e8351f60 | ||
|
|
4a9eb64f76 | ||
|
|
66bbf63300 | ||
|
|
6e2c80531d | ||
|
|
2ec0a10a2c | ||
|
|
e92d99b758 | ||
|
|
036d41b774 | ||
|
|
3bd70b9508 | ||
|
|
664efc7456 | ||
|
|
1415ef4d78 | ||
|
|
fb137c4a78 | ||
|
|
668ab710c6 | ||
|
|
ea7080a42e | ||
|
|
c2b27a4949 | ||
|
|
a6ee3ba35f | ||
|
|
2a60d20841 | ||
|
|
42329d4dce | ||
|
|
5013d3b4c9 | ||
|
|
9ba9cddf18 | ||
|
|
81356cacee | ||
|
|
3b142155c3 | ||
|
|
4543664ba2 | ||
|
|
e88562be98 | ||
|
|
bfdf7d4ad5 | ||
|
|
c350e64687 | ||
|
|
70d8d2cc43 | ||
|
|
56b2681a6f | ||
|
|
6cf7dacd0e | ||
|
|
428369cae0 | ||
|
|
7f1131dfae | ||
|
|
7493f3f9dd | ||
|
|
eb8d9352c8 | ||
|
|
29b8d5edde | ||
|
|
97d81c13b7 | ||
|
|
511980e3ea | ||
|
|
f6bf8611cd | ||
|
|
0be596afb5 | ||
|
|
2bb847cb3d | ||
|
|
9471cb0d19 | ||
|
|
d0fe9d7533 | ||
|
|
59c13c3366 | ||
|
|
96a4b4fe95 | ||
|
|
e0e84ca58a | ||
|
|
c6a062f64a | ||
|
|
94192a3720 | ||
|
|
e7a584c5ba | ||
|
|
e9833e9a57 | ||
|
|
6afc436946 | ||
|
|
c89bf0c6f0 | ||
|
|
a6d461282d | ||
|
|
75ce5a0723 | ||
|
|
3f3905fda0 | ||
|
|
01da9a1eac | ||
|
|
420a4234de | ||
|
|
ca488cf076 |
45
.github/workflows/release.yml
vendored
45
.github/workflows/release.yml
vendored
@@ -161,6 +161,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
mkdir -p release-assets
|
mkdir -p release-assets
|
||||||
|
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
|
||||||
echo "Looking for updater artifact (.tar.gz) and .app for zip..."
|
echo "Looking for updater artifact (.tar.gz) and .app for zip..."
|
||||||
TAR_GZ=""; APP_PATH=""
|
TAR_GZ=""; APP_PATH=""
|
||||||
for path in \
|
for path in \
|
||||||
@@ -177,15 +178,18 @@ jobs:
|
|||||||
echo "No macOS .tar.gz updater artifact found" >&2
|
echo "No macOS .tar.gz updater artifact found" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
cp "$TAR_GZ" release-assets/
|
# 重命名 tar.gz 为统一格式
|
||||||
[ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" release-assets/ || echo ".sig for macOS not found yet"
|
NEW_TAR_GZ="CC-Switch-${VERSION}-macOS.tar.gz"
|
||||||
echo "macOS updater artifact copied: $(basename "$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"
|
||||||
if [ -n "$APP_PATH" ]; then
|
if [ -n "$APP_PATH" ]; then
|
||||||
APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH")
|
APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH")
|
||||||
|
NEW_ZIP="CC-Switch-${VERSION}-macOS.zip"
|
||||||
cd "$APP_DIR"
|
cd "$APP_DIR"
|
||||||
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip"
|
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "$NEW_ZIP"
|
||||||
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/"
|
mv "$NEW_ZIP" "$GITHUB_WORKSPACE/release-assets/"
|
||||||
echo "macOS zip ready: CC-Switch-macOS.zip"
|
echo "macOS zip ready: $NEW_ZIP"
|
||||||
else
|
else
|
||||||
echo "No .app found to zip (optional)" >&2
|
echo "No .app found to zip (optional)" >&2
|
||||||
fi
|
fi
|
||||||
@@ -196,6 +200,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
|
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
|
||||||
|
$VERSION = $env:GITHUB_REF_NAME # e.g., v3.5.0
|
||||||
# 仅打包 MSI 安装器 + .sig(用于 Updater)
|
# 仅打包 MSI 安装器 + .sig(用于 Updater)
|
||||||
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
if ($null -eq $msi) {
|
if ($null -eq $msi) {
|
||||||
@@ -203,7 +208,7 @@ jobs:
|
|||||||
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
}
|
}
|
||||||
if ($null -ne $msi) {
|
if ($null -ne $msi) {
|
||||||
$dest = 'CC-Switch-Setup.msi'
|
$dest = "CC-Switch-$VERSION-Windows.msi"
|
||||||
Copy-Item $msi.FullName (Join-Path release-assets $dest)
|
Copy-Item $msi.FullName (Join-Path release-assets $dest)
|
||||||
Write-Host "Installer copied: $dest"
|
Write-Host "Installer copied: $dest"
|
||||||
$sigPath = "$($msi.FullName).sig"
|
$sigPath = "$($msi.FullName).sig"
|
||||||
@@ -232,9 +237,10 @@ jobs:
|
|||||||
'portable=true'
|
'portable=true'
|
||||||
)
|
)
|
||||||
$portableContent | Set-Content -Path $portableIniPath -Encoding UTF8
|
$portableContent | Set-Content -Path $portableIniPath -Encoding UTF8
|
||||||
Compress-Archive -Path "$portableDir/*" -DestinationPath 'release-assets/CC-Switch-Windows-Portable.zip' -Force
|
$portableZip = "release-assets/CC-Switch-$VERSION-Windows-Portable.zip"
|
||||||
|
Compress-Archive -Path "$portableDir/*" -DestinationPath $portableZip -Force
|
||||||
Remove-Item -Recurse -Force $portableDir
|
Remove-Item -Recurse -Force $portableDir
|
||||||
Write-Host 'Windows portable zip created'
|
Write-Host "Windows portable zip created: CC-Switch-$VERSION-Windows-Portable.zip"
|
||||||
} else {
|
} else {
|
||||||
Write-Warning 'Portable exe not found'
|
Write-Warning 'Portable exe not found'
|
||||||
}
|
}
|
||||||
@@ -245,20 +251,23 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
mkdir -p release-assets
|
mkdir -p release-assets
|
||||||
|
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
|
||||||
# Updater artifact: AppImage(含对应 .sig)
|
# Updater artifact: AppImage(含对应 .sig)
|
||||||
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
|
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
|
||||||
if [ -n "$APPIMAGE" ]; then
|
if [ -n "$APPIMAGE" ]; then
|
||||||
cp "$APPIMAGE" release-assets/
|
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux.AppImage"
|
||||||
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" release-assets/ || echo ".sig for AppImage not found"
|
cp "$APPIMAGE" "release-assets/$NEW_APPIMAGE"
|
||||||
echo "AppImage copied"
|
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" "release-assets/$NEW_APPIMAGE.sig" || echo ".sig for AppImage not found"
|
||||||
|
echo "AppImage copied: $NEW_APPIMAGE"
|
||||||
else
|
else
|
||||||
echo "No AppImage found under target/release/bundle" >&2
|
echo "No AppImage found under target/release/bundle" >&2
|
||||||
fi
|
fi
|
||||||
# 额外上传 .deb(用于手动安装,不参与 Updater)
|
# 额外上传 .deb(用于手动安装,不参与 Updater)
|
||||||
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
|
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
|
||||||
if [ -n "$DEB" ]; then
|
if [ -n "$DEB" ]; then
|
||||||
cp "$DEB" release-assets/
|
NEW_DEB="CC-Switch-${VERSION}-Linux.deb"
|
||||||
echo "Deb package copied"
|
cp "$DEB" "release-assets/$NEW_DEB"
|
||||||
|
echo "Deb package copied: $NEW_DEB"
|
||||||
else
|
else
|
||||||
echo "No .deb found (optional)"
|
echo "No .deb found (optional)"
|
||||||
fi
|
fi
|
||||||
@@ -288,12 +297,12 @@ jobs:
|
|||||||
|
|
||||||
### 下载
|
### 下载
|
||||||
|
|
||||||
- macOS: `CC-Switch-macOS.zip`(解压即用)
|
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`(Homebrew)
|
||||||
- Windows: `CC-Switch-Setup.msi`(安装版);`CC-Switch-Windows-Portable.zip`(绿色版)
|
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
|
||||||
- Linux: `*.deb`(Debian/Ubuntu 安装包)
|
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`(AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`(Debian/Ubuntu)
|
||||||
|
|
||||||
---
|
---
|
||||||
提示:macOS 如遇“已损坏”提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
||||||
files: release-assets/*
|
files: release-assets/*
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
79
CHANGELOG.md
79
CHANGELOG.md
@@ -5,21 +5,72 @@ 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/),
|
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).
|
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
|
## [3.4.0] - 2025-10-01
|
||||||
|
|
||||||
### ✨ Features
|
### ✨ Features
|
||||||
|
|
||||||
- Enable internationalization via i18next with a Chinese default and English fallback, plus an in-app language switcher
|
- 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)
|
- 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
|
- 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
|
- Support portable mode launches and enforce a single running instance to avoid conflicts
|
||||||
|
|
||||||
### 🔧 Improvements
|
### 🔧 Improvements
|
||||||
|
|
||||||
- Allow minimizing the window to the system tray and add macOS Dock visibility management for tray workflows
|
- 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
|
- 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
|
- 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
|
- Adjust the Windows MSI installer to target per-user LocalAppData and improve component tracking reliability
|
||||||
|
|
||||||
### 🐛 Fixes
|
### 🐛 Fixes
|
||||||
|
|
||||||
- Remove the unnecessary OpenAI auth requirement from third-party provider configurations
|
- Remove the unnecessary OpenAI auth requirement from third-party provider configurations
|
||||||
- Fix layout shifts while switching app types with Claude plugin sync enabled
|
- 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
|
- Align Enable/In Use button states to avoid visual jank across app views
|
||||||
@@ -27,18 +78,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [3.3.0] - 2025-09-22
|
## [3.3.0] - 2025-09-22
|
||||||
|
|
||||||
### ✨ Features
|
### ✨ 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 _(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)_
|
||||||
- Extend the Codex provider wizard with display name, dedicated API key URL, and clearer guidance
|
- 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
|
- Introduce shared common config snippets with JSON/TOML reuse, validation, and consistent error surfaces
|
||||||
|
|
||||||
### 🔧 Improvements
|
### 🔧 Improvements
|
||||||
|
|
||||||
- Keep the tray menu responsive when the window is hidden and standardize button styling and copy
|
- 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
|
- 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
|
- 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
|
- Add a `created_at` timestamp to provider records for future sorting and analytics
|
||||||
|
|
||||||
### 🐛 Fixes
|
### 🐛 Fixes
|
||||||
|
|
||||||
- Correct regex escapes and common snippet trimming in the Codex wizard to prevent validation issues
|
- 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
|
- 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
|
- Bundle `@codemirror/lint` to reinstate live linting in config editors
|
||||||
@@ -46,11 +100,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [3.2.0] - 2025-09-13
|
## [3.2.0] - 2025-09-13
|
||||||
|
|
||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
|
||||||
- System tray provider switching with dynamic menu for Claude/Codex
|
- System tray provider switching with dynamic menu for Claude/Codex
|
||||||
- Frontend receives `provider-switched` events and refreshes active app
|
- Frontend receives `provider-switched` events and refreshes active app
|
||||||
- Built-in update flow via Tauri Updater plugin with dismissible UpdateBadge
|
- Built-in update flow via Tauri Updater plugin with dismissible UpdateBadge
|
||||||
|
|
||||||
### 🔧 Improvements
|
### 🔧 Improvements
|
||||||
|
|
||||||
- Single source of truth for provider configs; no duplicate copy files
|
- Single source of truth for provider configs; no duplicate copy files
|
||||||
- One-time migration imports existing copies into `config.json` and archives originals
|
- One-time migration imports existing copies into `config.json` and archives originals
|
||||||
- Duplicate provider de-duplication by name + API key at startup
|
- Duplicate provider de-duplication by name + API key at startup
|
||||||
@@ -59,29 +115,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Tailwind v4 integration and refined dark mode handling
|
- Tailwind v4 integration and refined dark mode handling
|
||||||
|
|
||||||
### 🐛 Fixes
|
### 🐛 Fixes
|
||||||
|
|
||||||
- Remove/minimize debug console logs in production builds
|
- Remove/minimize debug console logs in production builds
|
||||||
- Fix CSS minifier warnings for scrollbar pseudo-elements
|
- Fix CSS minifier warnings for scrollbar pseudo-elements
|
||||||
- Prettier formatting across codebase for consistent style
|
- Prettier formatting across codebase for consistent style
|
||||||
|
|
||||||
### 📦 Dependencies
|
### 📦 Dependencies
|
||||||
|
|
||||||
- Tauri: 2.8.x (core, updater, process, opener, log plugins)
|
- Tauri: 2.8.x (core, updater, process, opener, log plugins)
|
||||||
- React: 18.2.x · TypeScript: 5.3.x · Vite: 5.x
|
- React: 18.2.x · TypeScript: 5.3.x · Vite: 5.x
|
||||||
|
|
||||||
### 🔄 Notes
|
### 🔄 Notes
|
||||||
|
|
||||||
- `connect-src` CSP remains permissive for compatibility; can be tightened later as needed
|
- `connect-src` CSP remains permissive for compatibility; can be tightened later as needed
|
||||||
|
|
||||||
## [3.1.1] - 2025-09-03
|
## [3.1.1] - 2025-09-03
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
- Fixed the default codex config.toml to match the latest modifications
|
- Fixed the default codex config.toml to match the latest modifications
|
||||||
- Improved provider configuration UX with custom option
|
- Improved provider configuration UX with custom option
|
||||||
|
|
||||||
### 📝 Documentation
|
### 📝 Documentation
|
||||||
|
|
||||||
- Updated README with latest information
|
- Updated README with latest information
|
||||||
|
|
||||||
## [3.1.0] - 2025-09-01
|
## [3.1.0] - 2025-09-01
|
||||||
|
|
||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
|
||||||
- **Added Codex application support** - Now supports both Claude Code and Codex configuration management
|
- **Added Codex application support** - Now supports both Claude Code and Codex configuration management
|
||||||
- Manage auth.json and config.toml for Codex
|
- Manage auth.json and config.toml for Codex
|
||||||
- Support for backup and restore operations
|
- Support for backup and restore operations
|
||||||
@@ -98,12 +160,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- TOML syntax validation for config.toml
|
- TOML syntax validation for config.toml
|
||||||
|
|
||||||
### 🔧 Technical Improvements
|
### 🔧 Technical Improvements
|
||||||
|
|
||||||
- Unified Tauri command API with app_type parameter
|
- Unified Tauri command API with app_type parameter
|
||||||
- Backward compatibility for app/appType parameters
|
- Backward compatibility for app/appType parameters
|
||||||
- Added get_config_status/open_config_folder/open_external commands
|
- Added get_config_status/open_config_folder/open_external commands
|
||||||
- Improved error handling for empty config.toml
|
- Improved error handling for empty config.toml
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
- Fixed config path reporting and folder opening for Codex
|
- Fixed config path reporting and folder opening for Codex
|
||||||
- Corrected default import behavior when main config is missing
|
- Corrected default import behavior when main config is missing
|
||||||
- Fixed non_snake_case warnings in commands.rs
|
- Fixed non_snake_case warnings in commands.rs
|
||||||
@@ -111,6 +175,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [3.0.0] - 2025-08-27
|
## [3.0.0] - 2025-08-27
|
||||||
|
|
||||||
### 🚀 Major Changes
|
### 🚀 Major Changes
|
||||||
|
|
||||||
- **Complete migration from Electron to Tauri 2.0** - The application has been completely rewritten using Tauri, resulting in:
|
- **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)
|
- **90% reduction in bundle size** (from ~150MB to ~15MB)
|
||||||
- **Significantly improved startup performance**
|
- **Significantly improved startup performance**
|
||||||
@@ -118,12 +183,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **Enhanced security** with Rust backend
|
- **Enhanced security** with Rust backend
|
||||||
|
|
||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
|
||||||
- **Native window controls** with transparent title bar on macOS
|
- **Native window controls** with transparent title bar on macOS
|
||||||
- **Improved file system operations** using Rust for better performance
|
- **Improved file system operations** using Rust for better performance
|
||||||
- **Enhanced security model** with explicit permission declarations
|
- **Enhanced security model** with explicit permission declarations
|
||||||
- **Better platform detection** using Tauri's native APIs
|
- **Better platform detection** using Tauri's native APIs
|
||||||
|
|
||||||
### 🔧 Technical Improvements
|
### 🔧 Technical Improvements
|
||||||
|
|
||||||
- Migrated from Electron IPC to Tauri command system
|
- Migrated from Electron IPC to Tauri command system
|
||||||
- Replaced Node.js file operations with Rust implementations
|
- Replaced Node.js file operations with Rust implementations
|
||||||
- Implemented proper CSP (Content Security Policy) for enhanced security
|
- Implemented proper CSP (Content Security Policy) for enhanced security
|
||||||
@@ -131,28 +198,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Integrated Rust cargo fmt and clippy for code quality
|
- Integrated Rust cargo fmt and clippy for code quality
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
- Fixed bundle identifier conflict on macOS (changed from .app to .desktop)
|
- Fixed bundle identifier conflict on macOS (changed from .app to .desktop)
|
||||||
- Resolved platform detection issues
|
- Resolved platform detection issues
|
||||||
- Improved error handling in configuration management
|
- Improved error handling in configuration management
|
||||||
|
|
||||||
### 📦 Dependencies
|
### 📦 Dependencies
|
||||||
|
|
||||||
- **Tauri**: 2.8.2
|
- **Tauri**: 2.8.2
|
||||||
- **React**: 18.2.0
|
- **React**: 18.2.0
|
||||||
- **TypeScript**: 5.3.0
|
- **TypeScript**: 5.3.0
|
||||||
- **Vite**: 5.0.0
|
- **Vite**: 5.0.0
|
||||||
|
|
||||||
### 🔄 Migration Notes
|
### 🔄 Migration Notes
|
||||||
|
|
||||||
For users upgrading from v2.x (Electron version):
|
For users upgrading from v2.x (Electron version):
|
||||||
|
|
||||||
- Configuration files remain compatible - no action required
|
- Configuration files remain compatible - no action required
|
||||||
- The app will automatically migrate your existing provider configurations
|
- The app will automatically migrate your existing provider configurations
|
||||||
- Window position and size preferences have been reset to defaults
|
- Window position and size preferences have been reset to defaults
|
||||||
|
|
||||||
#### Backup on v1→v2 Migration (cc-switch internal config)
|
#### 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.
|
- 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`
|
- 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.
|
- This only concerns cc-switch's own metadata file; your actual provider files under `~/.claude/` and `~/.codex/` are untouched.
|
||||||
|
|
||||||
### 🛠️ Development
|
### 🛠️ Development
|
||||||
|
|
||||||
- Added `pnpm typecheck` command for TypeScript validation
|
- Added `pnpm typecheck` command for TypeScript validation
|
||||||
- Added `pnpm format` and `pnpm format:check` for code formatting
|
- Added `pnpm format` and `pnpm format:check` for code formatting
|
||||||
- Rust code now uses cargo fmt for consistent formatting
|
- Rust code now uses cargo fmt for consistent formatting
|
||||||
@@ -160,6 +233,7 @@ For users upgrading from v2.x (Electron version):
|
|||||||
## [2.0.0] - Previous Electron Release
|
## [2.0.0] - Previous Electron Release
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- Multi-provider configuration management
|
- Multi-provider configuration management
|
||||||
- Quick provider switching
|
- Quick provider switching
|
||||||
- Import/export configurations
|
- Import/export configurations
|
||||||
@@ -170,6 +244,7 @@ For users upgrading from v2.x (Electron version):
|
|||||||
## [1.0.0] - Initial Release
|
## [1.0.0] - Initial Release
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- Basic provider management
|
- Basic provider management
|
||||||
- Claude Code integration
|
- Claude Code integration
|
||||||
- Configuration file handling
|
- Configuration file handling
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code & Codex 供应商切换器
|
# Claude Code & Codex 供应商切换器
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
|
|
||||||
@@ -42,21 +42,36 @@
|
|||||||
|
|
||||||
- **Windows**: Windows 10 及以上
|
- **Windows**: Windows 10 及以上
|
||||||
- **macOS**: macOS 10.15 (Catalina) 及以上
|
- **macOS**: macOS 10.15 (Catalina) 及以上
|
||||||
- **Linux**: Ubuntu 20.04+ / Debian 11+ / Fedora 34+ 等主流发行版
|
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ 等主流发行版
|
||||||
|
|
||||||
### Windows 用户
|
### Windows 用户
|
||||||
|
|
||||||
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-Setup.msi` 安装包或者 `CC-Switch-Windows-Portable.zip` 绿色版。
|
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Windows.msi` 安装包或者 `CC-Switch-v{版本号}-Windows-Portable.zip` 绿色版。
|
||||||
|
|
||||||
### macOS 用户
|
### macOS 用户
|
||||||
|
|
||||||
从 [Releases](../../releases) 页面下载 `CC-Switch-macOS.zip` 解压使用。
|
**方式一:通过 Homebrew 安装(推荐)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
**方式二:手动下载**
|
||||||
|
|
||||||
|
从 [Releases](../../releases) 页面下载 `CC-Switch-v{版本号}-macOS.zip` 解压使用。
|
||||||
|
|
||||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||||
|
|
||||||
### Linux 用户
|
### Linux 用户
|
||||||
|
|
||||||
从 [Releases](../../releases) 页面下载最新版本的 `.deb` 包或者 `AppImage`安装包。
|
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包。
|
||||||
|
|
||||||
## 使用说明
|
## 使用说明
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
- 自动升级自定义路径 ✅
|
- 自动升级自定义路径 ✅
|
||||||
- win 绿色版报毒问题 ✅
|
- win 绿色版报毒问题 ✅
|
||||||
- codex 更多预设供应商
|
- mcp 管理器 ✅
|
||||||
- mcp 管理器
|
- i18n ✅
|
||||||
- i18n
|
|
||||||
- gemini cli
|
- gemini cli
|
||||||
- homebrew 支持
|
- homebrew 支持
|
||||||
|
- memory 管理
|
||||||
|
- codex 更多预设供应商
|
||||||
|
- 云同步
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.4.0",
|
"version": "3.5.0",
|
||||||
"description": "Claude Code & Codex 供应商切换工具",
|
"description": "Claude Code & Codex 供应商切换工具",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm tauri dev",
|
"dev": "pnpm tauri dev",
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.38.2",
|
"@codemirror/view": "^6.38.2",
|
||||||
|
"smol-toml": "^1.4.2",
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
"@tauri-apps/api": "^2.8.0",
|
"@tauri-apps/api": "^2.8.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||||
|
|||||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
@@ -59,6 +59,9 @@ importers:
|
|||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: ^16.0.0
|
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)
|
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:
|
tailwindcss:
|
||||||
specifier: ^4.1.13
|
specifier: ^4.1.13
|
||||||
version: 4.1.13
|
version: 4.1.13
|
||||||
@@ -421,56 +424,67 @@ packages:
|
|||||||
resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==}
|
resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.46.2':
|
'@rollup/rollup-linux-arm-musleabihf@4.46.2':
|
||||||
resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==}
|
resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.46.2':
|
'@rollup/rollup-linux-arm64-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==}
|
resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.46.2':
|
'@rollup/rollup-linux-arm64-musl@4.46.2':
|
||||||
resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==}
|
resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loongarch64-gnu@4.46.2':
|
'@rollup/rollup-linux-loongarch64-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==}
|
resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.46.2':
|
'@rollup/rollup-linux-ppc64-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==}
|
resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.46.2':
|
'@rollup/rollup-linux-riscv64-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==}
|
resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.46.2':
|
'@rollup/rollup-linux-riscv64-musl@4.46.2':
|
||||||
resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==}
|
resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.46.2':
|
'@rollup/rollup-linux-s390x-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==}
|
resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.46.2':
|
'@rollup/rollup-linux-x64-gnu@4.46.2':
|
||||||
resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==}
|
resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.46.2':
|
'@rollup/rollup-linux-x64-musl@4.46.2':
|
||||||
resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==}
|
resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-win32-arm64-msvc@4.46.2':
|
'@rollup/rollup-win32-arm64-msvc@4.46.2':
|
||||||
resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==}
|
resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==}
|
||||||
@@ -525,24 +539,28 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.13':
|
'@tailwindcss/oxide-linux-arm64-musl@4.1.13':
|
||||||
resolution: {integrity: sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==}
|
resolution: {integrity: sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.13':
|
'@tailwindcss/oxide-linux-x64-gnu@4.1.13':
|
||||||
resolution: {integrity: sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==}
|
resolution: {integrity: sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-musl@4.1.13':
|
'@tailwindcss/oxide-linux-x64-musl@4.1.13':
|
||||||
resolution: {integrity: sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==}
|
resolution: {integrity: sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tailwindcss/oxide-wasm32-wasi@4.1.13':
|
'@tailwindcss/oxide-wasm32-wasi@4.1.13':
|
||||||
resolution: {integrity: sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==}
|
resolution: {integrity: sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==}
|
||||||
@@ -603,30 +621,35 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-arm64-musl@2.8.1':
|
'@tauri-apps/cli-linux-arm64-musl@2.8.1':
|
||||||
resolution: {integrity: sha512-VK/zwBzQY9SfyK7RSrxlIRQLJyhyssoByYWPK/FJMre8SV/y8zZ071cTQNG9dPWM1f+onI1WPTleG+TBUq/0Gw==}
|
resolution: {integrity: sha512-VK/zwBzQY9SfyK7RSrxlIRQLJyhyssoByYWPK/FJMre8SV/y8zZ071cTQNG9dPWM1f+onI1WPTleG+TBUq/0Gw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-riscv64-gnu@2.8.1':
|
'@tauri-apps/cli-linux-riscv64-gnu@2.8.1':
|
||||||
resolution: {integrity: sha512-bFw3zK6xkyurDR5kw2QgiU6YFlFNrfgtli3wRdTRv8zSVLZMQ2iZ8keYnd57vpvsbZ9PusFPYAMS7Fkzkf9I4g==}
|
resolution: {integrity: sha512-bFw3zK6xkyurDR5kw2QgiU6YFlFNrfgtli3wRdTRv8zSVLZMQ2iZ8keYnd57vpvsbZ9PusFPYAMS7Fkzkf9I4g==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-x64-gnu@2.8.1':
|
'@tauri-apps/cli-linux-x64-gnu@2.8.1':
|
||||||
resolution: {integrity: sha512-zOnFX+Rppuz0UVVSeCi67lMet8le+yT4UIiQ6t/QYGtpoWO/D4GpMoVYehJlR14klNXrC2CRxT9b3BUWTCEBwA==}
|
resolution: {integrity: sha512-zOnFX+Rppuz0UVVSeCi67lMet8le+yT4UIiQ6t/QYGtpoWO/D4GpMoVYehJlR14klNXrC2CRxT9b3BUWTCEBwA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-x64-musl@2.8.1':
|
'@tauri-apps/cli-linux-x64-musl@2.8.1':
|
||||||
resolution: {integrity: sha512-gLy6eisaeOTC6NQirs3a0XZNCVT/i7JPYHkXx6ArH6+Kb9IU8ogthTY4MQoYbkWmdOp3ijKX+RT1dD3IZURrEg==}
|
resolution: {integrity: sha512-gLy6eisaeOTC6NQirs3a0XZNCVT/i7JPYHkXx6ArH6+Kb9IU8ogthTY4MQoYbkWmdOp3ijKX+RT1dD3IZURrEg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tauri-apps/cli-win32-arm64-msvc@2.8.1':
|
'@tauri-apps/cli-win32-arm64-msvc@2.8.1':
|
||||||
resolution: {integrity: sha512-ciZ93Dm847zFDqRyc1e0YRiu/cdWne1bMhvifcZOibbyqSKB9o+b95Y5axMtXqR4Wsd2mHiC5TE+MVF3NDsdEw==}
|
resolution: {integrity: sha512-ciZ93Dm847zFDqRyc1e0YRiu/cdWne1bMhvifcZOibbyqSKB9o+b95Y5axMtXqR4Wsd2mHiC5TE+MVF3NDsdEw==}
|
||||||
@@ -820,24 +843,28 @@ packages:
|
|||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.30.1:
|
lightningcss-linux-arm64-musl@1.30.1:
|
||||||
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
|
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.30.1:
|
lightningcss-linux-x64-gnu@1.30.1:
|
||||||
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
|
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.30.1:
|
lightningcss-linux-x64-musl@1.30.1:
|
||||||
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
|
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.30.1:
|
lightningcss-win32-arm64-msvc@1.30.1:
|
||||||
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
|
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
|
||||||
@@ -947,6 +974,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
smol-toml@1.4.2:
|
||||||
|
resolution: {integrity: sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1793,6 +1824,8 @@ snapshots:
|
|||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
|
|
||||||
|
smol-toml@1.4.2: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
style-mod@4.1.2: {}
|
style-mod@4.1.2: {}
|
||||||
|
|||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -563,7 +563,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.4.0"
|
version = "3.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.4.0"
|
version = "3.5.0"
|
||||||
description = "Claude Code & Codex 供应商配置管理工具"
|
description = "Claude Code & Codex 供应商配置管理工具"
|
||||||
authors = ["Jason Young"]
|
authors = ["Jason Young"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
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::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||||
use crate::provider::ProviderManager;
|
use crate::provider::ProviderManager;
|
||||||
|
|
||||||
@@ -35,8 +52,12 @@ impl From<&str> for AppType {
|
|||||||
pub struct MultiAppConfig {
|
pub struct MultiAppConfig {
|
||||||
#[serde(default = "default_version")]
|
#[serde(default = "default_version")]
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
|
/// 应用管理器(claude/codex)
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub apps: HashMap<String, ProviderManager>,
|
pub apps: HashMap<String, ProviderManager>,
|
||||||
|
/// MCP 配置(按客户端分治)
|
||||||
|
#[serde(default)]
|
||||||
|
pub mcp: McpRoot,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_version() -> u32 {
|
fn default_version() -> u32 {
|
||||||
@@ -49,7 +70,11 @@ impl Default for MultiAppConfig {
|
|||||||
apps.insert("claude".to_string(), ProviderManager::default());
|
apps.insert("claude".to_string(), ProviderManager::default());
|
||||||
apps.insert("codex".to_string(), ProviderManager::default());
|
apps.insert("codex".to_string(), ProviderManager::default());
|
||||||
|
|
||||||
Self { version: 2, apps }
|
Self {
|
||||||
|
version: 2,
|
||||||
|
apps,
|
||||||
|
mcp: McpRoot::default(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +101,11 @@ impl MultiAppConfig {
|
|||||||
apps.insert("claude".to_string(), v1_config);
|
apps.insert("claude".to_string(), v1_config);
|
||||||
apps.insert("codex".to_string(), ProviderManager::default());
|
apps.insert("codex".to_string(), ProviderManager::default());
|
||||||
|
|
||||||
let config = Self { version: 2, apps };
|
let config = Self {
|
||||||
|
version: 2,
|
||||||
|
apps,
|
||||||
|
mcp: McpRoot::default(),
|
||||||
|
};
|
||||||
|
|
||||||
// 迁移前备份旧版(v1)配置文件
|
// 迁移前备份旧版(v1)配置文件
|
||||||
let backup_dir = get_app_config_dir();
|
let backup_dir = get_app_config_dir();
|
||||||
@@ -136,4 +165,20 @@ impl MultiAppConfig {
|
|||||||
.insert(app.as_str().to_string(), ProviderManager::default());
|
.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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
239
src-tauri/src/claude_mcp.rs
Normal file
239
src-tauri/src/claude_mcp.rs
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -3,9 +3,12 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
const CLAUDE_DIR: &str = ".claude";
|
const CLAUDE_DIR: &str = ".claude";
|
||||||
const CLAUDE_CONFIG_FILE: &str = "config.json";
|
const CLAUDE_CONFIG_FILE: &str = "config.json";
|
||||||
const CLAUDE_CONFIG_PAYLOAD: &str = "{\n \"primaryApiKey\": \"any\"\n}\n";
|
|
||||||
|
|
||||||
fn claude_dir() -> Result<PathBuf, String> {
|
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())?;
|
let home = dirs::home_dir().ok_or_else(|| "无法获取用户主目录".to_string())?;
|
||||||
Ok(home.join(CLAUDE_DIR))
|
Ok(home.join(CLAUDE_DIR))
|
||||||
}
|
}
|
||||||
@@ -45,17 +48,43 @@ fn is_managed_config(content: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_claude_config() -> Result<bool, String> {
|
pub fn write_claude_config() -> Result<bool, String> {
|
||||||
|
// 增量写入:仅设置 primaryApiKey = "any",保留其它字段
|
||||||
let path = claude_config_path()?;
|
let path = claude_config_path()?;
|
||||||
ensure_claude_dir_exists()?;
|
ensure_claude_dir_exists()?;
|
||||||
let need_write = match read_claude_config()? {
|
|
||||||
Some(existing) => existing != CLAUDE_CONFIG_PAYLOAD,
|
// 尝试读取并解析为对象
|
||||||
None => true,
|
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!({}),
|
||||||
};
|
};
|
||||||
if need_write {
|
|
||||||
fs::write(&path, CLAUDE_CONFIG_PAYLOAD)
|
let mut changed = false;
|
||||||
.map_err(|e| format!("写入 Claude 配置失败: {}", e))?;
|
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)
|
||||||
}
|
}
|
||||||
Ok(need_write)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_claude_config() -> Result<bool, String> {
|
pub fn clear_claude_config() -> Result<bool, String> {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ pub fn get_codex_provider_paths(
|
|||||||
provider_name: Option<&str>,
|
provider_name: Option<&str>,
|
||||||
) -> (PathBuf, PathBuf) {
|
) -> (PathBuf, PathBuf) {
|
||||||
let base_name = provider_name
|
let base_name = provider_name
|
||||||
.map(|name| sanitize_provider_name(name))
|
.map(sanitize_provider_name)
|
||||||
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
||||||
|
|
||||||
let auth_path = get_codex_config_dir().join(format!("auth-{}.json", base_name));
|
let auth_path = get_codex_config_dir().join(format!("auth-{}.json", base_name));
|
||||||
@@ -60,20 +60,27 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R
|
|||||||
let config_path = get_codex_config_path();
|
let config_path = get_codex_config_path();
|
||||||
|
|
||||||
if let Some(parent) = auth_path.parent() {
|
if let Some(parent) = auth_path.parent() {
|
||||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建 Codex 目录失败: {}", e))?;
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("创建 Codex 目录失败: {}: {}", parent.display(), e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取旧内容用于回滚
|
// 读取旧内容用于回滚
|
||||||
let old_auth = if auth_path.exists() {
|
let old_auth = if auth_path.exists() {
|
||||||
Some(fs::read(&auth_path).map_err(|e| format!("读取旧 auth.json 失败: {}", e))?)
|
Some(
|
||||||
} else {
|
fs::read(&auth_path)
|
||||||
None
|
.map_err(|e| format!("读取旧 auth.json 失败: {}: {}", auth_path.display(), e))?,
|
||||||
};
|
)
|
||||||
let _old_config = if config_path.exists() {
|
|
||||||
Some(fs::read(&config_path).map_err(|e| format!("读取旧 config.toml 失败: {}", e))?)
|
|
||||||
} else {
|
} else {
|
||||||
None
|
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 {
|
let cfg_text = match config_text_opt {
|
||||||
@@ -81,8 +88,13 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R
|
|||||||
None => String::new(),
|
None => String::new(),
|
||||||
};
|
};
|
||||||
if !cfg_text.trim().is_empty() {
|
if !cfg_text.trim().is_empty() {
|
||||||
toml::from_str::<toml::Table>(&cfg_text)
|
toml::from_str::<toml::Table>(&cfg_text).map_err(|e| {
|
||||||
.map_err(|e| format!("config.toml 格式错误: {}", e))?;
|
format!(
|
||||||
|
"config.toml 语法错误: {} (路径: {})",
|
||||||
|
e,
|
||||||
|
config_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第一步:写 auth.json
|
// 第一步:写 auth.json
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use tauri_plugin_dialog::DialogExt;
|
|||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
|
use crate::claude_mcp;
|
||||||
use crate::claude_plugin;
|
use crate::claude_plugin;
|
||||||
use crate::codex_config;
|
use crate::codex_config;
|
||||||
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
||||||
@@ -216,7 +217,7 @@ pub async fn update_provider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新内存并保存
|
// 更新内存并保存(保留/合并已有的 meta.custom_endpoints,避免丢失在编辑流程中新增的自定义端点)
|
||||||
{
|
{
|
||||||
let mut config = state
|
let mut config = state
|
||||||
.config
|
.config
|
||||||
@@ -225,9 +226,43 @@ pub async fn update_provider(
|
|||||||
let manager = config
|
let manager = config
|
||||||
.get_manager_mut(&app_type)
|
.get_manager_mut(&app_type)
|
||||||
.ok_or_else(|| format!("应用类型不存在: {:?}", 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_endpoints(URL 去重,保留旧端点的时间信息,补充新增端点)
|
||||||
|
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
|
manager
|
||||||
.providers
|
.providers
|
||||||
.insert(provider.id.clone(), provider.clone());
|
.insert(merged_provider.id.clone(), merged_provider);
|
||||||
}
|
}
|
||||||
state.save()?;
|
state.save()?;
|
||||||
|
|
||||||
@@ -313,16 +348,20 @@ pub async fn switch_provider(
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||||
|
|
||||||
let manager = config
|
// 为避免长期可变借用,尽快获取必要数据并缩小借用范围
|
||||||
.get_manager_mut(&app_type)
|
let provider = {
|
||||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
let manager = config
|
||||||
|
.get_manager_mut(&app_type)
|
||||||
|
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||||
|
|
||||||
// 检查供应商是否存在
|
// 检查供应商是否存在
|
||||||
let provider = manager
|
let provider = manager
|
||||||
.providers
|
.providers
|
||||||
.get(&id)
|
.get(&id)
|
||||||
.ok_or_else(|| format!("供应商不存在: {}", id))?
|
.ok_or_else(|| format!("供应商不存在: {}", id))?
|
||||||
.clone();
|
.clone();
|
||||||
|
provider
|
||||||
|
};
|
||||||
|
|
||||||
// SSOT 切换:先回填 live 配置到当前供应商,然后从内存写入目标主配置
|
// SSOT 切换:先回填 live 配置到当前供应商,然后从内存写入目标主配置
|
||||||
match app_type {
|
match app_type {
|
||||||
@@ -330,14 +369,20 @@ pub async fn switch_provider(
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
// 回填:读取 live(auth.json + config.toml)写回当前供应商 settings_config
|
// 回填:读取 live(auth.json + config.toml)写回当前供应商 settings_config
|
||||||
if !manager.current.is_empty() {
|
if !{
|
||||||
|
let cur = config
|
||||||
|
.get_manager_mut(&app_type)
|
||||||
|
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||||
|
cur.current.is_empty()
|
||||||
|
} {
|
||||||
let auth_path = codex_config::get_codex_auth_path();
|
let auth_path = codex_config::get_codex_auth_path();
|
||||||
let config_path = codex_config::get_codex_config_path();
|
let config_path = codex_config::get_codex_config_path();
|
||||||
if auth_path.exists() {
|
if auth_path.exists() {
|
||||||
let auth: Value = crate::config::read_json_file(&auth_path)?;
|
let auth: Value = crate::config::read_json_file(&auth_path)?;
|
||||||
let config_str = if config_path.exists() {
|
let config_str = if config_path.exists() {
|
||||||
std::fs::read_to_string(&config_path)
|
std::fs::read_to_string(&config_path).map_err(|e| {
|
||||||
.map_err(|e| format!("读取 config.toml 失败: {}", e))?
|
format!("读取 config.toml 失败: {}: {}", config_path.display(), e)
|
||||||
|
})?
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
@@ -347,7 +392,16 @@ pub async fn switch_provider(
|
|||||||
"config": config_str,
|
"config": config_str,
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(cur) = manager.providers.get_mut(&manager.current) {
|
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) {
|
||||||
cur.settings_config = live;
|
cur.settings_config = live;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,10 +424,21 @@ pub async fn switch_provider(
|
|||||||
let settings_path = get_claude_settings_path();
|
let settings_path = get_claude_settings_path();
|
||||||
|
|
||||||
// 回填:读取 live settings.json 写回当前供应商 settings_config
|
// 回填:读取 live settings.json 写回当前供应商 settings_config
|
||||||
if settings_path.exists() && !manager.current.is_empty() {
|
if settings_path.exists() {
|
||||||
if let Ok(live) = read_json_file::<serde_json::Value>(&settings_path) {
|
let cur_id = {
|
||||||
if let Some(cur) = manager.providers.get_mut(&manager.current) {
|
let m = config
|
||||||
cur.settings_config = live;
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,11 +450,56 @@ pub async fn switch_provider(
|
|||||||
|
|
||||||
// 不做归档,直接写入
|
// 不做归档,直接写入
|
||||||
write_json_file(&settings_path, &provider.settings_config)?;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新当前供应商
|
// 更新当前供应商(短借用范围)
|
||||||
manager.current = id;
|
{
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log::info!("成功切换到供应商: {}", provider.name);
|
log::info!("成功切换到供应商: {}", provider.name);
|
||||||
|
|
||||||
@@ -654,6 +764,240 @@ pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result<bool, St
|
|||||||
Ok(true)
|
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.json(Codex 作用域),返回变更数量
|
||||||
|
#[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]
|
#[tauri::command]
|
||||||
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
|
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
|
||||||
|
|||||||
@@ -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 {
|
pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
|
||||||
let base_name = provider_name
|
let base_name = provider_name
|
||||||
.map(|name| sanitize_provider_name(name))
|
.map(sanitize_provider_name)
|
||||||
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
||||||
|
|
||||||
get_claude_config_dir().join(format!("settings-{}.json", base_name))
|
get_claude_config_dir().join(format!("settings-{}.json", base_name))
|
||||||
@@ -118,16 +118,18 @@ pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, Stri
|
|||||||
return Err(format!("文件不存在: {}", path.display()));
|
return Err(format!("文件不存在: {}", path.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}", e))?;
|
let content =
|
||||||
|
fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?;
|
||||||
|
|
||||||
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}", e))
|
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写入 JSON 配置文件
|
/// 写入 JSON 配置文件
|
||||||
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), String> {
|
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), String> {
|
||||||
// 确保目录存在
|
// 确保目录存在
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let json =
|
let json =
|
||||||
@@ -139,7 +141,8 @@ pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), String
|
|||||||
/// 原子写入文本文件(用于 TOML/纯文本)
|
/// 原子写入文本文件(用于 TOML/纯文本)
|
||||||
pub fn write_text_file(path: &Path, data: &str) -> Result<(), String> {
|
pub fn write_text_file(path: &Path, data: &str) -> Result<(), String> {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
|
||||||
}
|
}
|
||||||
atomic_write(path, data.as_bytes())
|
atomic_write(path, data.as_bytes())
|
||||||
}
|
}
|
||||||
@@ -147,7 +150,8 @@ pub fn write_text_file(path: &Path, data: &str) -> Result<(), String> {
|
|||||||
/// 原子写入:写入临时文件后 rename 替换,避免半写状态
|
/// 原子写入:写入临时文件后 rename 替换,避免半写状态
|
||||||
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
|
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let parent = path.parent().ok_or_else(|| "无效的路径".to_string())?;
|
let parent = path.parent().ok_or_else(|| "无效的路径".to_string())?;
|
||||||
@@ -164,10 +168,12 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
|
|||||||
tmp.push(format!("{}.tmp.{}", file_name, ts));
|
tmp.push(format!("{}.tmp.{}", file_name, ts));
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut f = fs::File::create(&tmp).map_err(|e| format!("创建临时文件失败: {}", e))?;
|
let mut f = fs::File::create(&tmp)
|
||||||
|
.map_err(|e| format!("创建临时文件失败: {}: {}", tmp.display(), e))?;
|
||||||
f.write_all(data)
|
f.write_all(data)
|
||||||
.map_err(|e| format!("写入临时文件失败: {}", e))?;
|
.map_err(|e| format!("写入临时文件失败: {}: {}", tmp.display(), e))?;
|
||||||
f.flush().map_err(|e| format!("刷新临时文件失败: {}", e))?;
|
f.flush()
|
||||||
|
.map_err(|e| format!("刷新临时文件失败: {}: {}", tmp.display(), e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -185,12 +191,26 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
|
|||||||
if path.exists() {
|
if path.exists() {
|
||||||
let _ = fs::remove_file(path);
|
let _ = fs::remove_file(path);
|
||||||
}
|
}
|
||||||
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
|
fs::rename(&tmp, path).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"原子替换失败: {} -> {}: {}",
|
||||||
|
tmp.display(),
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
|
fs::rename(&tmp, path).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"原子替换失败: {} -> {}: {}",
|
||||||
|
tmp.display(),
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
mod app_config;
|
mod app_config;
|
||||||
|
mod claude_mcp;
|
||||||
mod claude_plugin;
|
mod claude_plugin;
|
||||||
mod codex_config;
|
mod codex_config;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod import_export;
|
mod import_export;
|
||||||
|
mod mcp;
|
||||||
mod migration;
|
mod migration;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod store;
|
|
||||||
mod speedtest;
|
mod speedtest;
|
||||||
|
mod store;
|
||||||
|
|
||||||
use store::AppState;
|
use store::AppState;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
@@ -217,7 +219,7 @@ async fn switch_provider_internal(
|
|||||||
let provider_id_clone = provider_id.clone();
|
let provider_id_clone = provider_id.clone();
|
||||||
|
|
||||||
crate::commands::switch_provider(
|
crate::commands::switch_provider(
|
||||||
app_state.clone().into(),
|
app_state.clone(),
|
||||||
Some(app_type),
|
Some(app_type),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -279,8 +281,8 @@ pub fn run() {
|
|||||||
|
|
||||||
let builder = builder
|
let builder = builder
|
||||||
// 拦截窗口关闭:根据设置决定是否最小化到托盘
|
// 拦截窗口关闭:根据设置决定是否最小化到托盘
|
||||||
.on_window_event(|window, event| match event {
|
.on_window_event(|window, event| {
|
||||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
let settings = crate::settings::get_settings();
|
let settings = crate::settings::get_settings();
|
||||||
|
|
||||||
if settings.minimize_to_tray_on_close {
|
if settings.minimize_to_tray_on_close {
|
||||||
@@ -292,13 +294,12 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
apply_tray_policy(&window.app_handle(), false);
|
apply_tray_policy(window.app_handle(), false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.app_handle().exit(0);
|
window.app_handle().exit(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
})
|
})
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
@@ -360,7 +361,7 @@ pub fn run() {
|
|||||||
// 首次启动迁移:扫描副本文件,合并到 config.json,并归档副本;旧 config.json 先归档
|
// 首次启动迁移:扫描副本文件,合并到 config.json,并归档副本;旧 config.json 先归档
|
||||||
{
|
{
|
||||||
let mut config_guard = app_state.config.lock().unwrap();
|
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 {
|
if migrated {
|
||||||
log::info!("已将副本文件导入到 config.json,并完成归档");
|
log::info!("已将副本文件导入到 config.json,并完成归档");
|
||||||
}
|
}
|
||||||
@@ -373,7 +374,7 @@ pub fn run() {
|
|||||||
let _ = app_state.save();
|
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")
|
let mut tray_builder = TrayIconBuilder::with_id("main")
|
||||||
@@ -413,6 +414,7 @@ pub fn run() {
|
|||||||
commands::open_external,
|
commands::open_external,
|
||||||
commands::get_app_config_path,
|
commands::get_app_config_path,
|
||||||
commands::open_app_config_folder,
|
commands::open_app_config_folder,
|
||||||
|
commands::read_live_provider_settings,
|
||||||
commands::get_settings,
|
commands::get_settings,
|
||||||
commands::save_settings,
|
commands::save_settings,
|
||||||
commands::check_for_updates,
|
commands::check_for_updates,
|
||||||
@@ -421,6 +423,21 @@ pub fn run() {
|
|||||||
commands::read_claude_plugin_config,
|
commands::read_claude_plugin_config,
|
||||||
commands::apply_claude_plugin_config,
|
commands::apply_claude_plugin_config,
|
||||||
commands::is_claude_plugin_applied,
|
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
|
// ours: endpoint speed test + custom endpoint management
|
||||||
commands::test_api_endpoints,
|
commands::test_api_endpoints,
|
||||||
commands::get_custom_endpoints,
|
commands::get_custom_endpoints,
|
||||||
@@ -442,20 +459,17 @@ pub fn run() {
|
|||||||
app.run(|app_handle, event| {
|
app.run(|app_handle, event| {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
|
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
|
||||||
match event {
|
if let RunEvent::Reopen { .. } = event {
|
||||||
RunEvent::Reopen { .. } => {
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
#[cfg(target_os = "windows")]
|
||||||
#[cfg(target_os = "windows")]
|
{
|
||||||
{
|
let _ = window.set_skip_taskbar(false);
|
||||||
let _ = window.set_skip_taskbar(false);
|
|
||||||
}
|
|
||||||
let _ = window.unminimize();
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
apply_tray_policy(app_handle, true);
|
|
||||||
}
|
}
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
apply_tray_policy(app_handle, true);
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
|||||||
732
src-tauri/src/mcp.rs
Normal file
732
src-tauri/src/mcp.rs
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
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.json(Codex 作用域),并将导入项设为 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(())
|
||||||
|
}
|
||||||
@@ -363,19 +363,13 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
|
|||||||
}
|
}
|
||||||
for (_, ap, cp, _) in codex_items.into_iter() {
|
for (_, ap, cp, _) in codex_items.into_iter() {
|
||||||
if let Some(ap) = ap {
|
if let Some(ap) = ap {
|
||||||
match archive_file(ts, "codex", &ap) {
|
if let Ok(Some(_)) = archive_file(ts, "codex", &ap) {
|
||||||
Ok(Some(_)) => {
|
let _ = delete_file(&ap);
|
||||||
let _ = delete_file(&ap);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(cp) = cp {
|
if let Some(cp) = cp {
|
||||||
match archive_file(ts, "codex", &cp) {
|
if let Ok(Some(_)) = archive_file(ts, "codex", &cp) {
|
||||||
Ok(Some(_)) => {
|
let _ = delete_file(&cp);
|
||||||
let _ = delete_file(&cp);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,21 +45,12 @@ impl Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 供应商管理器
|
/// 供应商管理器
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ProviderManager {
|
pub struct ProviderManager {
|
||||||
pub providers: HashMap<String, Provider>,
|
pub providers: HashMap<String, Provider>,
|
||||||
pub current: String,
|
pub current: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ProviderManager {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
providers: HashMap::new(),
|
|
||||||
current: String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 供应商元数据
|
/// 供应商元数据
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ProviderMeta {
|
pub struct ProviderMeta {
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ pub struct AppSettings {
|
|||||||
pub show_in_tray: bool,
|
pub show_in_tray: bool,
|
||||||
#[serde(default = "default_minimize_to_tray_on_close")]
|
#[serde(default = "default_minimize_to_tray_on_close")]
|
||||||
pub minimize_to_tray_on_close: bool,
|
pub minimize_to_tray_on_close: bool,
|
||||||
|
/// 是否启用 Claude 插件联动
|
||||||
|
#[serde(default)]
|
||||||
|
pub enable_claude_plugin_integration: bool,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub claude_config_dir: Option<String>,
|
pub claude_config_dir: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -49,6 +52,7 @@ impl Default for AppSettings {
|
|||||||
Self {
|
Self {
|
||||||
show_in_tray: true,
|
show_in_tray: true,
|
||||||
minimize_to_tray_on_close: true,
|
minimize_to_tray_on_close: true,
|
||||||
|
enable_claude_plugin_integration: false,
|
||||||
claude_config_dir: None,
|
claude_config_dir: None,
|
||||||
codex_config_dir: None,
|
codex_config_dir: None,
|
||||||
language: None,
|
language: None,
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ pub async fn test_endpoints(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 先进行一次“热身”请求,忽略其结果,仅用于复用连接/绕过首包惩罚
|
||||||
|
let _ = client.get(parsed_url.clone()).send().await;
|
||||||
|
|
||||||
|
// 第二次请求开始计时,并将其作为结果返回
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
match client.get(parsed_url).send().await {
|
match client.get(parsed_url).send().await {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CC Switch",
|
"productName": "CC Switch",
|
||||||
"version": "3.4.0",
|
"version": "3.5.0",
|
||||||
"identifier": "com.ccswitch.desktop",
|
"identifier": "com.ccswitch.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
73
src/App.tsx
73
src/App.tsx
@@ -10,6 +10,7 @@ import { AppSwitcher } from "./components/AppSwitcher";
|
|||||||
import SettingsModal from "./components/SettingsModal";
|
import SettingsModal from "./components/SettingsModal";
|
||||||
import { UpdateBadge } from "./components/UpdateBadge";
|
import { UpdateBadge } from "./components/UpdateBadge";
|
||||||
import { Plus, Settings, Moon, Sun } from "lucide-react";
|
import { Plus, Settings, Moon, Sun } from "lucide-react";
|
||||||
|
import McpPanel from "./components/mcp/McpPanel";
|
||||||
import { buttonStyles } from "./lib/styles";
|
import { buttonStyles } from "./lib/styles";
|
||||||
import { useDarkMode } from "./hooks/useDarkMode";
|
import { useDarkMode } from "./hooks/useDarkMode";
|
||||||
import { extractErrorMessage } from "./utils/errorUtils";
|
import { extractErrorMessage } from "./utils/errorUtils";
|
||||||
@@ -22,7 +23,7 @@ function App() {
|
|||||||
const [currentProviderId, setCurrentProviderId] = useState<string>("");
|
const [currentProviderId, setCurrentProviderId] = useState<string>("");
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(
|
const [editingProviderId, setEditingProviderId] = useState<string | null>(
|
||||||
null
|
null,
|
||||||
);
|
);
|
||||||
const [notification, setNotification] = useState<{
|
const [notification, setNotification] = useState<{
|
||||||
message: string;
|
message: string;
|
||||||
@@ -36,13 +37,14 @@ function App() {
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const [isMcpOpen, setIsMcpOpen] = useState(false);
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// 设置通知的辅助函数
|
// 设置通知的辅助函数
|
||||||
const showNotification = (
|
const showNotification = (
|
||||||
message: string,
|
message: string,
|
||||||
type: "success" | "error",
|
type: "success" | "error",
|
||||||
duration = 3000
|
duration = 3000,
|
||||||
) => {
|
) => {
|
||||||
// 清除之前的定时器
|
// 清除之前的定时器
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
@@ -181,9 +183,14 @@ function App() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 同步 Claude 插件配置(写入/移除固定 JSON)
|
// 同步 Claude 插件配置(按设置决定是否联动;开启时:非官方写入,官方移除)
|
||||||
const syncClaudePlugin = async (providerId: string, silent = false) => {
|
const syncClaudePlugin = async (providerId: string, silent = false) => {
|
||||||
try {
|
try {
|
||||||
|
const settings = await window.api.getSettings();
|
||||||
|
if (!(settings as any)?.enableClaudePluginIntegration) {
|
||||||
|
// 未开启联动:不执行写入/移除
|
||||||
|
return;
|
||||||
|
}
|
||||||
const provider = providers[providerId];
|
const provider = providers[providerId];
|
||||||
if (!provider) return;
|
if (!provider) return;
|
||||||
const isOfficial = provider.category === "official";
|
const isOfficial = provider.category === "official";
|
||||||
@@ -208,24 +215,33 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSwitchProvider = async (id: string) => {
|
const handleSwitchProvider = async (id: string) => {
|
||||||
const success = await window.api.switchProvider(id, activeApp);
|
try {
|
||||||
if (success) {
|
const success = await window.api.switchProvider(id, activeApp);
|
||||||
setCurrentProviderId(id);
|
if (success) {
|
||||||
// 显示重启提示
|
setCurrentProviderId(id);
|
||||||
const appName = t(`apps.${activeApp}`);
|
// 显示重启提示
|
||||||
showNotification(
|
const appName = t(`apps.${activeApp}`);
|
||||||
t("notifications.switchSuccess", { appName }),
|
showNotification(
|
||||||
"success",
|
t("notifications.switchSuccess", { appName }),
|
||||||
2000
|
"success",
|
||||||
);
|
2000,
|
||||||
// 更新托盘菜单
|
);
|
||||||
await window.api.updateTrayMenu();
|
// 更新托盘菜单
|
||||||
|
await window.api.updateTrayMenu();
|
||||||
|
|
||||||
if (activeApp === "claude") {
|
if (activeApp === "claude") {
|
||||||
await syncClaudePlugin(id, true);
|
await syncClaudePlugin(id, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showNotification(t("notifications.switchFailed"), "error");
|
||||||
}
|
}
|
||||||
} else {
|
} catch (error) {
|
||||||
showNotification(t("notifications.switchFailed"), "error");
|
const detail = extractErrorMessage(error);
|
||||||
|
const msg = detail
|
||||||
|
? `${t("notifications.switchFailed")}: ${detail}`
|
||||||
|
: t("notifications.switchFailed");
|
||||||
|
// 详细错误展示稍长时间,便于用户阅读
|
||||||
|
showNotification(msg, "error", detail ? 6000 : 3000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -297,6 +313,13 @@ function App() {
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
<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
|
<button
|
||||||
onClick={() => setIsAddModalOpen(true)}
|
onClick={() => setIsAddModalOpen(true)}
|
||||||
className={`inline-flex items-center gap-2 ${buttonStyles.primary}`}
|
className={`inline-flex items-center gap-2 ${buttonStyles.primary}`}
|
||||||
@@ -315,7 +338,7 @@ function App() {
|
|||||||
{/* 通知组件 - 相对于视窗定位 */}
|
{/* 通知组件 - 相对于视窗定位 */}
|
||||||
{notification && (
|
{notification && (
|
||||||
<div
|
<div
|
||||||
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 ${
|
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 ${
|
||||||
notification.type === "error"
|
notification.type === "error"
|
||||||
? "bg-red-500 text-white"
|
? "bg-red-500 text-white"
|
||||||
: "bg-green-500 text-white"
|
: "bg-green-500 text-white"
|
||||||
@@ -331,7 +354,6 @@ function App() {
|
|||||||
onSwitch={handleSwitchProvider}
|
onSwitch={handleSwitchProvider}
|
||||||
onDelete={handleDeleteProvider}
|
onDelete={handleDeleteProvider}
|
||||||
onEdit={setEditingProviderId}
|
onEdit={setEditingProviderId}
|
||||||
appType={activeApp}
|
|
||||||
onNotify={showNotification}
|
onNotify={showNotification}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,6 +391,15 @@ function App() {
|
|||||||
<SettingsModal
|
<SettingsModal
|
||||||
onClose={() => setIsSettingsOpen(false)}
|
onClose={() => setIsSettingsOpen(false)}
|
||||||
onImportSuccess={handleImportSuccess}
|
onImportSuccess={handleImportSuccess}
|
||||||
|
onNotify={showNotification}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMcpOpen && (
|
||||||
|
<McpPanel
|
||||||
|
appType={activeApp}
|
||||||
|
onClose={() => setIsMcpOpen(false)}
|
||||||
|
onNotify={showNotification}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,10 +17,15 @@ const AddProviderModal: React.FC<AddProviderModalProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const title =
|
||||||
|
appType === "claude"
|
||||||
|
? t("provider.addClaudeProvider")
|
||||||
|
: t("provider.addCodexProvider");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProviderForm
|
<ProviderForm
|
||||||
appType={appType}
|
appType={appType}
|
||||||
title={t("provider.addNewProvider")}
|
title={title}
|
||||||
submitText={t("common.add")}
|
submitText={t("common.add")}
|
||||||
showPresets={true}
|
showPresets={true}
|
||||||
onSubmit={onAdd}
|
onSubmit={onAdd}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Provider } from "../types";
|
import { Provider } from "../types";
|
||||||
import { AppType } from "../lib/tauri-api";
|
import { AppType } from "../lib/tauri-api";
|
||||||
@@ -18,6 +18,32 @@ const EditProviderModal: React.FC<EditProviderModalProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
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">) => {
|
const handleSubmit = (data: Omit<Provider, "id">) => {
|
||||||
onSave({
|
onSave({
|
||||||
@@ -26,12 +52,17 @@ const EditProviderModal: React.FC<EditProviderModalProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const title =
|
||||||
|
appType === "claude"
|
||||||
|
? t("provider.editClaudeProvider")
|
||||||
|
: t("provider.editCodexProvider");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProviderForm
|
<ProviderForm
|
||||||
appType={appType}
|
appType={appType}
|
||||||
title={t("common.edit")}
|
title={title}
|
||||||
submitText={t("common.save")}
|
submitText={t("common.save")}
|
||||||
initialData={provider}
|
initialData={effectiveProvider}
|
||||||
showPresets={false}
|
showPresets={false}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CheckCircle, Loader2, AlertCircle } from "lucide-react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface ImportProgressModalProps {
|
interface ImportProgressModalProps {
|
||||||
status: 'importing' | 'success' | 'error';
|
status: "importing" | "success" | "error";
|
||||||
message?: string;
|
message?: string;
|
||||||
backupId?: string;
|
backupId?: string;
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
@@ -15,16 +15,20 @@ export function ImportProgressModal({
|
|||||||
message,
|
message,
|
||||||
backupId,
|
backupId,
|
||||||
onComplete,
|
onComplete,
|
||||||
onSuccess
|
onSuccess,
|
||||||
}: ImportProgressModalProps) {
|
}: ImportProgressModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'success') {
|
if (status === "success") {
|
||||||
console.log('[ImportProgressModal] Success detected, starting 2 second countdown');
|
console.log(
|
||||||
|
"[ImportProgressModal] Success detected, starting 2 second countdown",
|
||||||
|
);
|
||||||
// 成功后等待2秒自动关闭并刷新数据
|
// 成功后等待2秒自动关闭并刷新数据
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
console.log('[ImportProgressModal] 2 seconds elapsed, calling callbacks...');
|
console.log(
|
||||||
|
"[ImportProgressModal] 2 seconds elapsed, calling callbacks...",
|
||||||
|
);
|
||||||
if (onSuccess) {
|
if (onSuccess) {
|
||||||
onSuccess();
|
onSuccess();
|
||||||
}
|
}
|
||||||
@@ -34,7 +38,7 @@ export function ImportProgressModal({
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log('[ImportProgressModal] Cleanup timer');
|
console.log("[ImportProgressModal] Cleanup timer");
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -46,7 +50,7 @@ export function ImportProgressModal({
|
|||||||
|
|
||||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl p-8 max-w-md w-full mx-4">
|
<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">
|
<div className="flex flex-col items-center text-center">
|
||||||
{status === 'importing' && (
|
{status === "importing" && (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
|
<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">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
@@ -58,7 +62,7 @@ export function ImportProgressModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'success' && (
|
{status === "success" && (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="w-12 h-12 text-green-500 mb-4" />
|
<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">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
@@ -75,7 +79,7 @@ export function ImportProgressModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'error' && (
|
{status === "error" && (
|
||||||
<>
|
<>
|
||||||
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
|
<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">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { oneDark } from "@codemirror/theme-one-dark";
|
|||||||
import { EditorState } from "@codemirror/state";
|
import { EditorState } from "@codemirror/state";
|
||||||
import { placeholder } from "@codemirror/view";
|
import { placeholder } from "@codemirror/view";
|
||||||
import { linter, Diagnostic } from "@codemirror/lint";
|
import { linter, Diagnostic } from "@codemirror/lint";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface JsonEditorProps {
|
interface JsonEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -23,6 +24,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
rows = 12,
|
rows = 12,
|
||||||
showValidation = true,
|
showValidation = true,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef<EditorView | null>(null);
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
|
||||||
@@ -46,12 +48,13 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
from: 0,
|
from: 0,
|
||||||
to: doc.length,
|
to: doc.length,
|
||||||
severity: "error",
|
severity: "error",
|
||||||
message: "配置必须是JSON对象,不能是数组或其他类型",
|
message: t("jsonEditor.mustBeObject"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 简单处理JSON解析错误
|
// 简单处理JSON解析错误
|
||||||
const message = e instanceof SyntaxError ? e.message : "JSON格式错误";
|
const message =
|
||||||
|
e instanceof SyntaxError ? e.message : t("jsonEditor.invalidJson");
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
from: 0,
|
from: 0,
|
||||||
to: doc.length,
|
to: doc.length,
|
||||||
@@ -62,7 +65,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
|
|
||||||
return diagnostics;
|
return diagnostics;
|
||||||
}),
|
}),
|
||||||
[showValidation],
|
[showValidation, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Provider, ProviderCategory, CustomEndpoint } from "../types";
|
import { Provider, ProviderCategory, CustomEndpoint } from "../types";
|
||||||
import { AppType } from "../lib/tauri-api";
|
import { AppType } from "../lib/tauri-api";
|
||||||
import {
|
import {
|
||||||
@@ -41,11 +42,11 @@ const collectTemplatePaths = (
|
|||||||
source: unknown,
|
source: unknown,
|
||||||
templateKeys: string[],
|
templateKeys: string[],
|
||||||
currentPath: TemplatePath = [],
|
currentPath: TemplatePath = [],
|
||||||
acc: TemplatePath[] = []
|
acc: TemplatePath[] = [],
|
||||||
): TemplatePath[] => {
|
): TemplatePath[] => {
|
||||||
if (typeof source === "string") {
|
if (typeof source === "string") {
|
||||||
const hasPlaceholder = templateKeys.some((key) =>
|
const hasPlaceholder = templateKeys.some((key) =>
|
||||||
source.includes(`\${${key}}`)
|
source.includes(`\${${key}}`),
|
||||||
);
|
);
|
||||||
if (hasPlaceholder) {
|
if (hasPlaceholder) {
|
||||||
acc.push([...currentPath]);
|
acc.push([...currentPath]);
|
||||||
@@ -55,14 +56,14 @@ const collectTemplatePaths = (
|
|||||||
|
|
||||||
if (Array.isArray(source)) {
|
if (Array.isArray(source)) {
|
||||||
source.forEach((item, index) =>
|
source.forEach((item, index) =>
|
||||||
collectTemplatePaths(item, templateKeys, [...currentPath, index], acc)
|
collectTemplatePaths(item, templateKeys, [...currentPath, index], acc),
|
||||||
);
|
);
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source && typeof source === "object") {
|
if (source && typeof source === "object") {
|
||||||
Object.entries(source).forEach(([key, value]) =>
|
Object.entries(source).forEach(([key, value]) =>
|
||||||
collectTemplatePaths(value, templateKeys, [...currentPath, key], acc)
|
collectTemplatePaths(value, templateKeys, [...currentPath, key], acc),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ const getValueAtPath = (source: any, path: TemplatePath) => {
|
|||||||
const setValueAtPath = (
|
const setValueAtPath = (
|
||||||
target: any,
|
target: any,
|
||||||
path: TemplatePath,
|
path: TemplatePath,
|
||||||
value: unknown
|
value: unknown,
|
||||||
): any => {
|
): any => {
|
||||||
if (path.length === 0) {
|
if (path.length === 0) {
|
||||||
return value;
|
return value;
|
||||||
@@ -119,7 +120,7 @@ const setValueAtPath = (
|
|||||||
const applyTemplateValuesToConfigString = (
|
const applyTemplateValuesToConfigString = (
|
||||||
presetConfig: any,
|
presetConfig: any,
|
||||||
currentConfigString: string,
|
currentConfigString: string,
|
||||||
values: TemplateValueMap
|
values: TemplateValueMap,
|
||||||
) => {
|
) => {
|
||||||
const replacedConfig = applyTemplateValues(presetConfig, values);
|
const replacedConfig = applyTemplateValues(presetConfig, values);
|
||||||
const templateKeys = Object.keys(values);
|
const templateKeys = Object.keys(values);
|
||||||
@@ -190,6 +191,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
// 对于 Codex,需要分离 auth 和 config
|
// 对于 Codex,需要分离 auth 和 config
|
||||||
const isCodex = appType === "codex";
|
const isCodex = appType === "codex";
|
||||||
|
|
||||||
@@ -201,7 +203,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
: "",
|
: "",
|
||||||
});
|
});
|
||||||
const [category, setCategory] = useState<ProviderCategory | undefined>(
|
const [category, setCategory] = useState<ProviderCategory | undefined>(
|
||||||
initialData?.category
|
initialData?.category,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Claude 模型配置状态
|
// Claude 模型配置状态
|
||||||
@@ -222,7 +224,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
useState(false);
|
useState(false);
|
||||||
// 新建供应商:收集端点测速弹窗中的“自定义端点”,提交时一次性落盘到 meta.custom_endpoints
|
// 新建供应商:收集端点测速弹窗中的“自定义端点”,提交时一次性落盘到 meta.custom_endpoints
|
||||||
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
|
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
// 端点测速弹窗状态
|
// 端点测速弹窗状态
|
||||||
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
|
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
|
||||||
@@ -230,7 +232,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
useState(false);
|
useState(false);
|
||||||
// -1 表示自定义,null 表示未选择,>= 0 表示预设索引
|
// -1 表示自定义,null 表示未选择,>= 0 表示预设索引
|
||||||
const [selectedCodexPreset, setSelectedCodexPreset] = useState<number | null>(
|
const [selectedCodexPreset, setSelectedCodexPreset] = useState<number | null>(
|
||||||
showPresets && isCodex ? -1 : null
|
showPresets && isCodex ? -1 : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const setCodexAuth = (value: string) => {
|
const setCodexAuth = (value: string) => {
|
||||||
@@ -242,7 +244,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
setCodexConfigState((prev) =>
|
setCodexConfigState((prev) =>
|
||||||
typeof value === "function"
|
typeof value === "function"
|
||||||
? (value as (input: string) => string)(prev)
|
? (value as (input: string) => string)(prev)
|
||||||
: value
|
: value,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -303,7 +305,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const stored = window.localStorage.getItem(
|
const stored = window.localStorage.getItem(
|
||||||
CODEX_COMMON_CONFIG_STORAGE_KEY
|
CODEX_COMMON_CONFIG_STORAGE_KEY,
|
||||||
);
|
);
|
||||||
if (stored && stored.trim()) {
|
if (stored && stored.trim()) {
|
||||||
return stored.trim();
|
return stored.trim();
|
||||||
@@ -320,7 +322,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
|
|
||||||
// -1 表示自定义,null 表示未选择,>= 0 表示预设索引
|
// -1 表示自定义,null 表示未选择,>= 0 表示预设索引
|
||||||
const [selectedPreset, setSelectedPreset] = useState<number | null>(
|
const [selectedPreset, setSelectedPreset] = useState<number | null>(
|
||||||
showPresets ? -1 : null
|
showPresets ? -1 : null,
|
||||||
);
|
);
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [codexAuthError, setCodexAuthError] = useState("");
|
const [codexAuthError, setCodexAuthError] = useState("");
|
||||||
@@ -331,21 +333,20 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
useState("");
|
useState("");
|
||||||
|
|
||||||
const validateSettingsConfig = (value: string): string => {
|
const validateSettingsConfig = (value: string): string => {
|
||||||
return validateJsonConfig(value, "配置内容");
|
const err = validateJsonConfig(value, "配置内容");
|
||||||
|
return err ? t("providerForm.configJsonError") : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateCodexAuth = (value: string): string => {
|
const validateCodexAuth = (value: string): string => {
|
||||||
if (!value.trim()) {
|
if (!value.trim()) return "";
|
||||||
return "";
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(value);
|
const parsed = JSON.parse(value);
|
||||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
return "auth.json 必须是 JSON 对象";
|
return t("providerForm.authJsonRequired");
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
} catch {
|
} catch {
|
||||||
return "auth.json 格式错误,请检查JSON语法";
|
return t("providerForm.authJsonError");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -389,11 +390,11 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
const configString = JSON.stringify(
|
const configString = JSON.stringify(
|
||||||
initialData.settingsConfig,
|
initialData.settingsConfig,
|
||||||
null,
|
null,
|
||||||
2
|
2,
|
||||||
);
|
);
|
||||||
const hasCommon = hasCommonConfigSnippet(
|
const hasCommon = hasCommonConfigSnippet(
|
||||||
configString,
|
configString,
|
||||||
commonConfigSnippet
|
commonConfigSnippet,
|
||||||
);
|
);
|
||||||
setUseCommonConfig(hasCommon);
|
setUseCommonConfig(hasCommon);
|
||||||
setSettingsConfigError(validateSettingsConfig(configString));
|
setSettingsConfigError(validateSettingsConfig(configString));
|
||||||
@@ -409,14 +410,14 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
if (config.env) {
|
if (config.env) {
|
||||||
setClaudeModel(config.env.ANTHROPIC_MODEL || "");
|
setClaudeModel(config.env.ANTHROPIC_MODEL || "");
|
||||||
setClaudeSmallFastModel(
|
setClaudeSmallFastModel(
|
||||||
config.env.ANTHROPIC_SMALL_FAST_MODEL || ""
|
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
|
||||||
);
|
);
|
||||||
setBaseUrl(config.env.ANTHROPIC_BASE_URL || ""); // 初始化基础 URL
|
setBaseUrl(config.env.ANTHROPIC_BASE_URL || ""); // 初始化基础 URL
|
||||||
|
|
||||||
// 初始化 Kimi 模型选择
|
// 初始化 Kimi 模型选择
|
||||||
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
|
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
|
||||||
setKimiAnthropicSmallFastModel(
|
setKimiAnthropicSmallFastModel(
|
||||||
config.env.ANTHROPIC_SMALL_FAST_MODEL || ""
|
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,7 +425,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
// Codex 初始化时检查 TOML 通用配置
|
// Codex 初始化时检查 TOML 通用配置
|
||||||
const hasCommon = hasTomlCommonConfigSnippet(
|
const hasCommon = hasTomlCommonConfigSnippet(
|
||||||
codexConfig,
|
codexConfig,
|
||||||
codexCommonConfigSnippet
|
codexCommonConfigSnippet,
|
||||||
);
|
);
|
||||||
setUseCodexCommonConfig(hasCommon);
|
setUseCodexCommonConfig(hasCommon);
|
||||||
}
|
}
|
||||||
@@ -444,7 +445,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
if (selectedPreset !== null && selectedPreset >= 0) {
|
if (selectedPreset !== null && selectedPreset >= 0) {
|
||||||
const preset = providerPresets[selectedPreset];
|
const preset = providerPresets[selectedPreset];
|
||||||
setCategory(
|
setCategory(
|
||||||
preset?.category || (preset?.isOfficial ? "official" : undefined)
|
preset?.category || (preset?.isOfficial ? "official" : undefined),
|
||||||
);
|
);
|
||||||
} else if (selectedPreset === -1) {
|
} else if (selectedPreset === -1) {
|
||||||
setCategory("custom");
|
setCategory("custom");
|
||||||
@@ -453,7 +454,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
if (selectedCodexPreset !== null && selectedCodexPreset >= 0) {
|
if (selectedCodexPreset !== null && selectedCodexPreset >= 0) {
|
||||||
const preset = codexProviderPresets[selectedCodexPreset];
|
const preset = codexProviderPresets[selectedCodexPreset];
|
||||||
setCategory(
|
setCategory(
|
||||||
preset?.category || (preset?.isOfficial ? "official" : undefined)
|
preset?.category || (preset?.isOfficial ? "official" : undefined),
|
||||||
);
|
);
|
||||||
} else if (selectedCodexPreset === -1) {
|
} else if (selectedCodexPreset === -1) {
|
||||||
setCategory("custom");
|
setCategory("custom");
|
||||||
@@ -505,7 +506,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
if (commonConfigSnippet.trim()) {
|
if (commonConfigSnippet.trim()) {
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
COMMON_CONFIG_STORAGE_KEY,
|
COMMON_CONFIG_STORAGE_KEY,
|
||||||
commonConfigSnippet
|
commonConfigSnippet,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
window.localStorage.removeItem(COMMON_CONFIG_STORAGE_KEY);
|
window.localStorage.removeItem(COMMON_CONFIG_STORAGE_KEY);
|
||||||
@@ -520,7 +521,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
if (!formData.name) {
|
if (!formData.name) {
|
||||||
setError("请填写供应商名称");
|
setError(t("providerForm.fillSupplierName"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,7 +536,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
}
|
}
|
||||||
// Codex: 仅要求 auth.json 必填;config.toml 可为空
|
// Codex: 仅要求 auth.json 必填;config.toml 可为空
|
||||||
if (!codexAuth.trim()) {
|
if (!codexAuth.trim()) {
|
||||||
setError("请填写 auth.json 配置");
|
setError(t("providerForm.fillAuthJson"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,7 +553,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
? authJson.OPENAI_API_KEY.trim()
|
? authJson.OPENAI_API_KEY.trim()
|
||||||
: "";
|
: "";
|
||||||
if (!key) {
|
if (!key) {
|
||||||
setError("请填写 OPENAI_API_KEY");
|
setError(t("providerForm.fillApiKey"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,16 +564,16 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
config: codexConfig ?? "",
|
config: codexConfig ?? "",
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("auth.json 格式错误,请检查JSON语法");
|
setError(t("providerForm.authJsonError"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const currentSettingsError = validateSettingsConfig(
|
const currentSettingsError = validateSettingsConfig(
|
||||||
formData.settingsConfig
|
formData.settingsConfig,
|
||||||
);
|
);
|
||||||
setSettingsConfigError(currentSettingsError);
|
setSettingsConfigError(currentSettingsError);
|
||||||
if (currentSettingsError) {
|
if (currentSettingsError) {
|
||||||
setError(currentSettingsError);
|
setError(t("providerForm.configJsonError"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,21 +587,21 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
""
|
""
|
||||||
).trim();
|
).trim();
|
||||||
if (!resolvedValue) {
|
if (!resolvedValue) {
|
||||||
setError(`请填写 ${config.label}`);
|
setError(t("providerForm.fillParameter", { label: config.label }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Claude: 原有逻辑
|
// Claude: 原有逻辑
|
||||||
if (!formData.settingsConfig.trim()) {
|
if (!formData.settingsConfig.trim()) {
|
||||||
setError("请填写配置内容");
|
setError(t("providerForm.fillConfigContent"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
settingsConfig = JSON.parse(formData.settingsConfig);
|
settingsConfig = JSON.parse(formData.settingsConfig);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("配置JSON格式错误,请检查语法");
|
setError(t("providerForm.configJsonError"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -614,26 +615,68 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
...(category ? { category } : {}),
|
...(category ? { category } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 若为“新建供应商”,且已在弹窗中添加了自定义端点,则随提交一并落盘
|
// 若为"新建供应商",将端点候选一并随提交落盘到 meta.custom_endpoints:
|
||||||
if (!initialData && draftCustomEndpoints.length > 0) {
|
// - 用户在弹窗中新增的自定义端点(draftCustomEndpoints,已去重)
|
||||||
const now = Date.now();
|
// - 预设中的 endpointCandidates(若存在)
|
||||||
const customMap: Record<string, CustomEndpoint> = {};
|
// - 当前选中的基础 URL(baseUrl/codexBaseUrl)
|
||||||
for (const raw of draftCustomEndpoints) {
|
if (!initialData) {
|
||||||
const url = raw.trim().replace(/\/+$/, "");
|
const urlSet = new Set<string>();
|
||||||
if (!url) continue;
|
const push = (raw?: string) => {
|
||||||
if (!customMap[url]) {
|
const url = (raw || "").trim().replace(/\/+$/, "");
|
||||||
customMap[url] = { url, addedAt: now };
|
if (url) urlSet.add(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自定义端点(仅来自用户新增)
|
||||||
|
for (const u of draftCustomEndpoints) push(u);
|
||||||
|
|
||||||
|
// 预设端点候选
|
||||||
|
if (!isCodex) {
|
||||||
|
if (
|
||||||
|
selectedPreset !== null &&
|
||||||
|
selectedPreset >= 0 &&
|
||||||
|
selectedPreset < providerPresets.length
|
||||||
|
) {
|
||||||
|
const preset = providerPresets[selectedPreset] as any;
|
||||||
|
if (Array.isArray(preset?.endpointCandidates)) {
|
||||||
|
for (const u of preset.endpointCandidates as string[]) push(u);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// 当前 Claude 基础地址
|
||||||
|
push(baseUrl);
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
selectedCodexPreset !== null &&
|
||||||
|
selectedCodexPreset >= 0 &&
|
||||||
|
selectedCodexPreset < codexProviderPresets.length
|
||||||
|
) {
|
||||||
|
const preset = codexProviderPresets[selectedCodexPreset] as any;
|
||||||
|
if (Array.isArray(preset?.endpointCandidates)) {
|
||||||
|
for (const u of preset.endpointCandidates as string[]) push(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 当前 Codex 基础地址
|
||||||
|
push(codexBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = Array.from(urlSet.values());
|
||||||
|
if (urls.length > 0) {
|
||||||
|
const now = Date.now();
|
||||||
|
const customMap: Record<string, CustomEndpoint> = {};
|
||||||
|
for (const url of urls) {
|
||||||
|
if (!customMap[url]) {
|
||||||
|
customMap[url] = { url, addedAt: now, lastUsed: undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSubmit({ ...basePayload, meta: { custom_endpoints: customMap } });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
onSubmit({ ...basePayload, meta: { custom_endpoints: customMap } });
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit(basePayload);
|
onSubmit(basePayload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (
|
const handleChange = (
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
) => {
|
) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
|
||||||
@@ -663,13 +706,13 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
const { updatedConfig, error: snippetError } = updateCommonConfigSnippet(
|
const { updatedConfig, error: snippetError } = updateCommonConfigSnippet(
|
||||||
formData.settingsConfig,
|
formData.settingsConfig,
|
||||||
commonConfigSnippet,
|
commonConfigSnippet,
|
||||||
checked
|
checked,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (snippetError) {
|
if (snippetError) {
|
||||||
setCommonConfigError(snippetError);
|
setCommonConfigError(snippetError);
|
||||||
if (snippetError.includes("配置 JSON 解析失败")) {
|
if (snippetError.includes("配置 JSON 解析失败")) {
|
||||||
setSettingsConfigError("配置JSON格式错误,请检查语法");
|
setSettingsConfigError(t("providerForm.configJsonError"));
|
||||||
}
|
}
|
||||||
setUseCommonConfig(false);
|
setUseCommonConfig(false);
|
||||||
return;
|
return;
|
||||||
@@ -696,7 +739,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
const { updatedConfig } = updateCommonConfigSnippet(
|
const { updatedConfig } = updateCommonConfigSnippet(
|
||||||
formData.settingsConfig,
|
formData.settingsConfig,
|
||||||
previousSnippet,
|
previousSnippet,
|
||||||
false
|
false,
|
||||||
);
|
);
|
||||||
// 直接更新 formData,不通过 handleChange
|
// 直接更新 formData,不通过 handleChange
|
||||||
updateSettingsConfigValue(updatedConfig);
|
updateSettingsConfigValue(updatedConfig);
|
||||||
@@ -718,25 +761,25 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
const removeResult = updateCommonConfigSnippet(
|
const removeResult = updateCommonConfigSnippet(
|
||||||
formData.settingsConfig,
|
formData.settingsConfig,
|
||||||
previousSnippet,
|
previousSnippet,
|
||||||
false
|
false,
|
||||||
);
|
);
|
||||||
if (removeResult.error) {
|
if (removeResult.error) {
|
||||||
setCommonConfigError(removeResult.error);
|
setCommonConfigError(removeResult.error);
|
||||||
if (removeResult.error.includes("配置 JSON 解析失败")) {
|
if (removeResult.error.includes("配置 JSON 解析失败")) {
|
||||||
setSettingsConfigError("配置JSON格式错误,请检查语法");
|
setSettingsConfigError(t("providerForm.configJsonError"));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const addResult = updateCommonConfigSnippet(
|
const addResult = updateCommonConfigSnippet(
|
||||||
removeResult.updatedConfig,
|
removeResult.updatedConfig,
|
||||||
value,
|
value,
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (addResult.error) {
|
if (addResult.error) {
|
||||||
setCommonConfigError(addResult.error);
|
setCommonConfigError(addResult.error);
|
||||||
if (addResult.error.includes("配置 JSON 解析失败")) {
|
if (addResult.error.includes("配置 JSON 解析失败")) {
|
||||||
setSettingsConfigError("配置JSON格式错误,请检查语法");
|
setSettingsConfigError(t("providerForm.configJsonError"));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -774,11 +817,11 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
? config.editorValue
|
? config.editorValue
|
||||||
: (config.defaultValue ?? ""),
|
: (config.defaultValue ?? ""),
|
||||||
},
|
},
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
appliedSettingsConfig = applyTemplateValues(
|
appliedSettingsConfig = applyTemplateValues(
|
||||||
preset.settingsConfig,
|
preset.settingsConfig,
|
||||||
initialTemplateValues
|
initialTemplateValues,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,7 +836,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
});
|
});
|
||||||
setSettingsConfigError(validateSettingsConfig(configString));
|
setSettingsConfigError(validateSettingsConfig(configString));
|
||||||
setCategory(
|
setCategory(
|
||||||
preset.category || (preset.isOfficial ? "official" : undefined)
|
preset.category || (preset.isOfficial ? "official" : undefined),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 设置选中的预设
|
// 设置选中的预设
|
||||||
@@ -823,7 +866,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
if (preset.name?.includes("Kimi")) {
|
if (preset.name?.includes("Kimi")) {
|
||||||
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
|
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
|
||||||
setKimiAnthropicSmallFastModel(
|
setKimiAnthropicSmallFastModel(
|
||||||
config.env.ANTHROPIC_SMALL_FAST_MODEL || ""
|
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -871,7 +914,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
// Codex: 应用预设
|
// Codex: 应用预设
|
||||||
const applyCodexPreset = (
|
const applyCodexPreset = (
|
||||||
preset: (typeof codexProviderPresets)[0],
|
preset: (typeof codexProviderPresets)[0],
|
||||||
index: number
|
index: number,
|
||||||
) => {
|
) => {
|
||||||
const authString = JSON.stringify(preset.auth || {}, null, 2);
|
const authString = JSON.stringify(preset.auth || {}, null, 2);
|
||||||
setCodexAuth(authString);
|
setCodexAuth(authString);
|
||||||
@@ -889,7 +932,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
|
|
||||||
setSelectedCodexPreset(index);
|
setSelectedCodexPreset(index);
|
||||||
setCategory(
|
setCategory(
|
||||||
preset.category || (preset.isOfficial ? "official" : undefined)
|
preset.category || (preset.isOfficial ? "official" : undefined),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 清空 API Key,让用户重新输入
|
// 清空 API Key,让用户重新输入
|
||||||
@@ -905,7 +948,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
const customConfig = generateThirdPartyConfig(
|
const customConfig = generateThirdPartyConfig(
|
||||||
"custom",
|
"custom",
|
||||||
"https://your-api-endpoint.com/v1",
|
"https://your-api-endpoint.com/v1",
|
||||||
"gpt-5-codex"
|
"gpt-5-codex",
|
||||||
);
|
);
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -928,7 +971,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
const configString = setApiKeyInConfig(
|
const configString = setApiKeyInConfig(
|
||||||
formData.settingsConfig,
|
formData.settingsConfig,
|
||||||
key.trim(),
|
key.trim(),
|
||||||
{ createIfMissing: selectedPreset !== null && selectedPreset !== -1 }
|
{ createIfMissing: selectedPreset !== null && selectedPreset !== -1 },
|
||||||
);
|
);
|
||||||
|
|
||||||
// 更新表单配置
|
// 更新表单配置
|
||||||
@@ -1024,7 +1067,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
const { updatedConfig } = updateTomlCommonConfigSnippet(
|
const { updatedConfig } = updateTomlCommonConfigSnippet(
|
||||||
codexConfig,
|
codexConfig,
|
||||||
previousSnippet,
|
previousSnippet,
|
||||||
false
|
false,
|
||||||
);
|
);
|
||||||
setCodexConfig(updatedConfig);
|
setCodexConfig(updatedConfig);
|
||||||
setUseCodexCommonConfig(false);
|
setUseCodexCommonConfig(false);
|
||||||
@@ -1037,12 +1080,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
const removeResult = updateTomlCommonConfigSnippet(
|
const removeResult = updateTomlCommonConfigSnippet(
|
||||||
codexConfig,
|
codexConfig,
|
||||||
previousSnippet,
|
previousSnippet,
|
||||||
false
|
false,
|
||||||
);
|
);
|
||||||
const addResult = updateTomlCommonConfigSnippet(
|
const addResult = updateTomlCommonConfigSnippet(
|
||||||
removeResult.updatedConfig,
|
removeResult.updatedConfig,
|
||||||
sanitizedValue,
|
sanitizedValue,
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (addResult.error) {
|
if (addResult.error) {
|
||||||
@@ -1064,7 +1107,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
try {
|
try {
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
CODEX_COMMON_CONFIG_STORAGE_KEY,
|
CODEX_COMMON_CONFIG_STORAGE_KEY,
|
||||||
sanitizedValue
|
sanitizedValue,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore localStorage 写入失败
|
// ignore localStorage 写入失败
|
||||||
@@ -1077,7 +1120,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
if (!isUpdatingFromCodexCommonConfig.current) {
|
if (!isUpdatingFromCodexCommonConfig.current) {
|
||||||
const hasCommon = hasTomlCommonConfigSnippet(
|
const hasCommon = hasTomlCommonConfigSnippet(
|
||||||
value,
|
value,
|
||||||
codexCommonConfigSnippet
|
codexCommonConfigSnippet,
|
||||||
);
|
);
|
||||||
setUseCodexCommonConfig(hasCommon);
|
setUseCodexCommonConfig(hasCommon);
|
||||||
}
|
}
|
||||||
@@ -1305,7 +1348,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
// 处理模型输入变化,自动更新 JSON 配置
|
// 处理模型输入变化,自动更新 JSON 配置
|
||||||
const handleModelChange = (
|
const handleModelChange = (
|
||||||
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
|
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
|
||||||
value: string
|
value: string,
|
||||||
) => {
|
) => {
|
||||||
if (field === "ANTHROPIC_MODEL") {
|
if (field === "ANTHROPIC_MODEL") {
|
||||||
setClaudeModel(value);
|
setClaudeModel(value);
|
||||||
@@ -1335,7 +1378,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
// Kimi 模型选择处理函数
|
// Kimi 模型选择处理函数
|
||||||
const handleKimiModelChange = (
|
const handleKimiModelChange = (
|
||||||
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
|
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
|
||||||
value: string
|
value: string,
|
||||||
) => {
|
) => {
|
||||||
if (field === "ANTHROPIC_MODEL") {
|
if (field === "ANTHROPIC_MODEL") {
|
||||||
setKimiAnthropicModel(value);
|
setKimiAnthropicModel(value);
|
||||||
@@ -1360,7 +1403,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialData) return;
|
if (!initialData) return;
|
||||||
const parsedKey = getApiKeyFromConfig(
|
const parsedKey = getApiKeyFromConfig(
|
||||||
JSON.stringify(initialData.settingsConfig)
|
JSON.stringify(initialData.settingsConfig),
|
||||||
);
|
);
|
||||||
if (parsedKey) setApiKey(parsedKey);
|
if (parsedKey) setApiKey(parsedKey);
|
||||||
}, [initialData]);
|
}, [initialData]);
|
||||||
@@ -1456,13 +1499,13 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
onCustomClick={handleCodexCustomClick}
|
onCustomClick={handleCodexCustomClick}
|
||||||
renderCustomDescription={() => (
|
renderCustomDescription={() => (
|
||||||
<>
|
<>
|
||||||
手动配置供应商,需要填写完整的配置信息,或者
|
{t("providerForm.manualConfig")}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsCodexTemplateModalOpen(true)}
|
onClick={() => setIsCodexTemplateModalOpen(true)}
|
||||||
className="text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors ml-1"
|
className="text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors ml-1"
|
||||||
>
|
>
|
||||||
使用配置向导
|
{t("providerForm.useConfigWizard")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1474,7 +1517,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
htmlFor="name"
|
htmlFor="name"
|
||||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
供应商名称 *
|
{t("providerForm.supplierNameRequired")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -1482,7 +1525,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
name="name"
|
name="name"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="例如:Anthropic 官方"
|
placeholder={t("providerForm.supplierNamePlaceholder")}
|
||||||
required
|
required
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
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 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"
|
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 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"
|
||||||
@@ -1494,7 +1537,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
htmlFor="websiteUrl"
|
htmlFor="websiteUrl"
|
||||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
官网地址
|
{t("providerForm.websiteUrl")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
@@ -1502,7 +1545,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
name="websiteUrl"
|
name="websiteUrl"
|
||||||
value={formData.websiteUrl}
|
value={formData.websiteUrl}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="https://example.com(可选)"
|
placeholder={t("providerForm.websiteUrlPlaceholder")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
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 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"
|
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 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"
|
||||||
/>
|
/>
|
||||||
@@ -1516,10 +1559,10 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
required={!isOfficialPreset}
|
required={!isOfficialPreset}
|
||||||
placeholder={
|
placeholder={
|
||||||
isOfficialPreset
|
isOfficialPreset
|
||||||
? "官方登录无需填写 API Key,直接保存即可"
|
? t("providerForm.officialNoApiKey")
|
||||||
: shouldShowKimiSelector
|
: shouldShowKimiSelector
|
||||||
? "填写后可获取模型列表"
|
? t("providerForm.kimiApiKeyHint")
|
||||||
: "只需要填这里,下方配置会自动填充"
|
: t("providerForm.apiKeyAutoFill")
|
||||||
}
|
}
|
||||||
disabled={isOfficialPreset}
|
disabled={isOfficialPreset}
|
||||||
/>
|
/>
|
||||||
@@ -1531,7 +1574,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||||
>
|
>
|
||||||
获取 API Key
|
{t("providerForm.getApiKey")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1543,7 +1586,9 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
templateValueEntries.length > 0 && (
|
templateValueEntries.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
参数配置 - {selectedTemplatePreset.name.trim()} *
|
{t("providerForm.parameterConfig", {
|
||||||
|
name: selectedTemplatePreset.name.trim(),
|
||||||
|
})}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{templateValueEntries.map(([key, config]) => (
|
{templateValueEntries.map(([key, config]) => (
|
||||||
@@ -1582,14 +1627,14 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
applyTemplateValuesToConfigString(
|
applyTemplateValuesToConfigString(
|
||||||
selectedTemplatePreset.settingsConfig,
|
selectedTemplatePreset.settingsConfig,
|
||||||
formData.settingsConfig,
|
formData.settingsConfig,
|
||||||
nextValues
|
nextValues,
|
||||||
);
|
);
|
||||||
setFormData((prevForm) => ({
|
setFormData((prevForm) => ({
|
||||||
...prevForm,
|
...prevForm,
|
||||||
settingsConfig: configString,
|
settingsConfig: configString,
|
||||||
}));
|
}));
|
||||||
setSettingsConfigError(
|
setSettingsConfigError(
|
||||||
validateSettingsConfig(configString)
|
validateSettingsConfig(configString),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("更新模板值失败:", err);
|
console.error("更新模板值失败:", err);
|
||||||
@@ -1616,7 +1661,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
htmlFor="baseUrl"
|
htmlFor="baseUrl"
|
||||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
请求地址
|
{t("providerForm.apiEndpoint")}
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1624,7 +1669,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||||
>
|
>
|
||||||
<Zap className="h-3.5 w-3.5" />
|
<Zap className="h-3.5 w-3.5" />
|
||||||
管理与测速
|
{t("providerForm.manageAndTest")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -1632,13 +1677,13 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
id="baseUrl"
|
id="baseUrl"
|
||||||
value={baseUrl}
|
value={baseUrl}
|
||||||
onChange={(e) => handleBaseUrlChange(e.target.value)}
|
onChange={(e) => handleBaseUrlChange(e.target.value)}
|
||||||
placeholder="https://your-api-endpoint.com"
|
placeholder={t("providerForm.apiEndpointPlaceholder")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
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 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"
|
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 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"
|
||||||
/>
|
/>
|
||||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
<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">
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
💡 填写兼容 Claude API 的服务端点地址
|
{t("providerForm.apiHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1677,8 +1722,8 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
onChange={handleCodexApiKeyChange}
|
onChange={handleCodexApiKeyChange}
|
||||||
placeholder={
|
placeholder={
|
||||||
isCodexOfficialPreset
|
isCodexOfficialPreset
|
||||||
? "官方无需填写 API Key,直接保存即可"
|
? t("providerForm.codexOfficialNoApiKey")
|
||||||
: "只需要填这里,下方 auth.json 会自动填充"
|
: t("providerForm.codexApiKeyAutoFill")
|
||||||
}
|
}
|
||||||
disabled={isCodexOfficialPreset}
|
disabled={isCodexOfficialPreset}
|
||||||
required={
|
required={
|
||||||
@@ -1695,7 +1740,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||||
>
|
>
|
||||||
获取 API Key
|
{t("providerForm.getApiKey")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1709,7 +1754,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
htmlFor="codexBaseUrl"
|
htmlFor="codexBaseUrl"
|
||||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
请求地址
|
{t("codexConfig.apiUrlLabel")}
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1717,7 +1762,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||||
>
|
>
|
||||||
<Zap className="h-3.5 w-3.5" />
|
<Zap className="h-3.5 w-3.5" />
|
||||||
管理与测速
|
{t("providerForm.manageAndTest")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -1725,10 +1770,15 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
id="codexBaseUrl"
|
id="codexBaseUrl"
|
||||||
value={codexBaseUrl}
|
value={codexBaseUrl}
|
||||||
onChange={(e) => handleCodexBaseUrlChange(e.target.value)}
|
onChange={(e) => handleCodexBaseUrlChange(e.target.value)}
|
||||||
placeholder="https://your-api-endpoint.com/v1"
|
placeholder={t("providerForm.codexApiEndpointPlaceholder")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
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 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"
|
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 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"
|
||||||
/>
|
/>
|
||||||
|
<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("providerForm.codexApiHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1800,7 +1850,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
htmlFor="anthropicModel"
|
htmlFor="anthropicModel"
|
||||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
主模型 (可选)
|
{t("providerForm.mainModel")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -1809,7 +1859,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleModelChange("ANTHROPIC_MODEL", e.target.value)
|
handleModelChange("ANTHROPIC_MODEL", e.target.value)
|
||||||
}
|
}
|
||||||
placeholder="例如: GLM-4.5"
|
placeholder={t("providerForm.mainModelPlaceholder")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
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 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"
|
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 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"
|
||||||
/>
|
/>
|
||||||
@@ -1820,7 +1870,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
htmlFor="anthropicSmallFastModel"
|
htmlFor="anthropicSmallFastModel"
|
||||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
快速模型 (可选)
|
{t("providerForm.fastModel")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -1829,10 +1879,10 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleModelChange(
|
handleModelChange(
|
||||||
"ANTHROPIC_SMALL_FAST_MODEL",
|
"ANTHROPIC_SMALL_FAST_MODEL",
|
||||||
e.target.value
|
e.target.value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder="例如: GLM-4.5-Air"
|
placeholder={t("providerForm.fastModelPlaceholder")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
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 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"
|
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 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"
|
||||||
/>
|
/>
|
||||||
@@ -1841,7 +1891,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
|
|
||||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
<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">
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
💡 留空将使用供应商的默认模型
|
{t("providerForm.modelHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1872,7 +1922,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface ApiKeyInputProps {
|
interface ApiKeyInputProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -14,12 +15,13 @@ interface ApiKeyInputProps {
|
|||||||
const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = "请输入API Key",
|
placeholder,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
required = false,
|
required = false,
|
||||||
label = "API Key",
|
label = "API Key",
|
||||||
id = "apiKey",
|
id = "apiKey",
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
|
||||||
const toggleShowKey = () => {
|
const toggleShowKey = () => {
|
||||||
@@ -46,7 +48,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
|||||||
id={id}
|
id={id}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder ?? t("apiKeyInput.placeholder")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
required={required}
|
required={required}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@@ -57,7 +59,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={toggleShowKey}
|
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"
|
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 ? "隐藏API Key" : "显示API Key"}
|
aria-label={showKey ? t("apiKeyInput.hide") : t("apiKeyInput.show")}
|
||||||
>
|
>
|
||||||
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react";
|
|||||||
import JsonEditor from "../JsonEditor";
|
import JsonEditor from "../JsonEditor";
|
||||||
import { X, Save } from "lucide-react";
|
import { X, Save } from "lucide-react";
|
||||||
import { isLinux } from "../../lib/platform";
|
import { isLinux } from "../../lib/platform";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface ClaudeConfigEditorProps {
|
interface ClaudeConfigEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -24,6 +25,7 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
|||||||
commonConfigError,
|
commonConfigError,
|
||||||
configError,
|
configError,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -82,7 +84,7 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
|||||||
htmlFor="settingsConfig"
|
htmlFor="settingsConfig"
|
||||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
Claude Code 配置 (JSON) *
|
{t("claudeConfig.configLabel")}
|
||||||
</label>
|
</label>
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -91,7 +93,7 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
|||||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
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"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
@@ -100,7 +102,7 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
|||||||
onClick={() => setIsCommonConfigModalOpen(true)}
|
onClick={() => setIsCommonConfigModalOpen(true)}
|
||||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||||
>
|
>
|
||||||
编辑通用配置
|
{t("claudeConfig.editCommonConfig")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{commonConfigError && !isCommonConfigModalOpen && (
|
{commonConfigError && !isCommonConfigModalOpen && (
|
||||||
@@ -124,7 +126,7 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
|||||||
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
|
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
完整的 Claude Code settings.json 配置内容
|
{t("claudeConfig.fullSettingsHint")}
|
||||||
</p>
|
</p>
|
||||||
{isCommonConfigModalOpen && (
|
{isCommonConfigModalOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -145,13 +147,13 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
|||||||
{/* Header - 统一标题栏样式 */}
|
{/* Header - 统一标题栏样式 */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
<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">
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
编辑通用配置片段
|
{t("claudeConfig.editCommonConfigTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeModal}
|
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"
|
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="关闭"
|
aria-label={t("common.close")}
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -160,7 +162,7 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
|||||||
{/* Content - 统一内容区域样式 */}
|
{/* Content - 统一内容区域样式 */}
|
||||||
<div className="flex-1 overflow-auto p-6 space-y-4">
|
<div className="flex-1 overflow-auto p-6 space-y-4">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
该片段会在勾选"写入通用配置"时合并到 settings.json 中
|
{t("claudeConfig.commonConfigHint")}
|
||||||
</p>
|
</p>
|
||||||
<JsonEditor
|
<JsonEditor
|
||||||
value={commonConfigSnippet}
|
value={commonConfigSnippet}
|
||||||
@@ -182,7 +184,7 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
|||||||
onClick={closeModal}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -190,7 +192,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"
|
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" />
|
<Save className="w-4 h-4" />
|
||||||
保存
|
{t("common.save")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import React, { useState, useEffect, useRef } from "react";
|
|||||||
import { X, Save } from "lucide-react";
|
import { X, Save } from "lucide-react";
|
||||||
|
|
||||||
import { isLinux } from "../../lib/platform";
|
import { isLinux } from "../../lib/platform";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
generateThirdPartyAuth,
|
generateThirdPartyAuth,
|
||||||
@@ -74,6 +75,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
|
|
||||||
setIsTemplateModalOpen: externalSetTemplateModalOpen,
|
setIsTemplateModalOpen: externalSetTemplateModalOpen,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||||
|
|
||||||
// 使用内部状态或外部状态
|
// 使用内部状态或外部状态
|
||||||
@@ -168,7 +170,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
|
|
||||||
trimmedBaseUrl,
|
trimmedBaseUrl,
|
||||||
|
|
||||||
trimmedModel
|
trimmedModel,
|
||||||
);
|
);
|
||||||
|
|
||||||
onAuthChange(JSON.stringify(auth, null, 2));
|
onAuthChange(JSON.stringify(auth, null, 2));
|
||||||
@@ -206,7 +208,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTemplateInputKeyDown = (
|
const handleTemplateInputKeyDown = (
|
||||||
e: React.KeyboardEvent<HTMLInputElement>
|
e: React.KeyboardEvent<HTMLInputElement>,
|
||||||
) => {
|
) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -236,7 +238,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
htmlFor="codexAuth"
|
htmlFor="codexAuth"
|
||||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
auth.json (JSON) *
|
{t("codexConfig.authJson")}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
@@ -244,9 +246,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
value={authValue}
|
value={authValue}
|
||||||
onChange={(e) => handleAuthChange(e.target.value)}
|
onChange={(e) => handleAuthChange(e.target.value)}
|
||||||
onBlur={onAuthBlur}
|
onBlur={onAuthBlur}
|
||||||
placeholder={`{
|
placeholder={t("codexConfig.authJsonPlaceholder")}
|
||||||
"OPENAI_API_KEY": "sk-your-api-key-here"
|
|
||||||
}`}
|
|
||||||
rows={6}
|
rows={6}
|
||||||
required
|
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]"
|
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">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Codex auth.json 配置内容
|
{t("codexConfig.authJsonHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
htmlFor="codexConfig"
|
htmlFor="codexConfig"
|
||||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
config.toml (TOML)
|
{t("codexConfig.configToml")}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
<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)}
|
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"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -296,7 +296,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
onClick={() => setIsCommonConfigModalOpen(true)}
|
onClick={() => setIsCommonConfigModalOpen(true)}
|
||||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||||
>
|
>
|
||||||
编辑通用配置
|
{t("codexConfig.editCommonConfig")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -325,7 +325,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Codex config.toml 配置内容
|
{t("codexConfig.configTomlHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 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">
|
<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">
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
快速配置向导
|
{t("codexConfig.quickWizard")}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeTemplateModal}
|
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"
|
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="关闭"
|
aria-label={t("common.close")}
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -364,15 +364,14 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
<div className="flex-1 min-h-0 space-y-4 overflow-auto p-6">
|
<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">
|
<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">
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
输入关键参数,系统将自动生成标准的 auth.json 和 config.toml
|
{t("codexConfig.wizardHint")}
|
||||||
配置。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
API 密钥 *
|
{t("codexConfig.apiKeyLabel")}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -382,8 +381,8 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
onChange={(e) => setTemplateApiKey(e.target.value)}
|
onChange={(e) => setTemplateApiKey(e.target.value)}
|
||||||
onKeyDown={handleTemplateInputKeyDown}
|
onKeyDown={handleTemplateInputKeyDown}
|
||||||
pattern=".*\S.*"
|
pattern=".*\S.*"
|
||||||
title="请输入有效的内容"
|
title={t("common.enterValidValue")}
|
||||||
placeholder="sk-your-api-key-here"
|
placeholder={t("codexConfig.apiKeyPlaceholder")}
|
||||||
required
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
@@ -391,7 +390,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
供应商名称 *
|
{t("codexConfig.supplierNameLabel")}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -405,21 +404,21 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleTemplateInputKeyDown}
|
onKeyDown={handleTemplateInputKeyDown}
|
||||||
placeholder="例如:Codex 官方"
|
placeholder={t("codexConfig.supplierNamePlaceholder")}
|
||||||
required
|
required
|
||||||
pattern=".*\S.*"
|
pattern=".*\S.*"
|
||||||
title="请输入有效的内容"
|
title={t("common.enterValidValue")}
|
||||||
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"
|
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">
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
将显示在供应商列表中,可使用中文
|
{t("codexConfig.supplierNameHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
供应商代号(英文)
|
{t("codexConfig.supplierCodeLabel")}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -427,18 +426,18 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
value={templateProviderName}
|
value={templateProviderName}
|
||||||
onChange={(e) => setTemplateProviderName(e.target.value)}
|
onChange={(e) => setTemplateProviderName(e.target.value)}
|
||||||
onKeyDown={handleTemplateInputKeyDown}
|
onKeyDown={handleTemplateInputKeyDown}
|
||||||
placeholder="custom(可选)"
|
placeholder={t("codexConfig.supplierCodePlaceholder")}
|
||||||
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"
|
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">
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
将用作配置文件中的标识符,默认为 custom
|
{t("codexConfig.supplierCodeHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
API 请求地址 *
|
{t("codexConfig.apiUrlLabel")}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -447,7 +446,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
ref={baseUrlInputRef}
|
ref={baseUrlInputRef}
|
||||||
onChange={(e) => setTemplateBaseUrl(e.target.value)}
|
onChange={(e) => setTemplateBaseUrl(e.target.value)}
|
||||||
onKeyDown={handleTemplateInputKeyDown}
|
onKeyDown={handleTemplateInputKeyDown}
|
||||||
placeholder="https://your-api-endpoint.com/v1"
|
placeholder={t("codexConfig.apiUrlPlaceholder")}
|
||||||
required
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
@@ -455,7 +454,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
官网地址
|
{t("codexConfig.websiteLabel")}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -463,18 +462,18 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
value={templateWebsiteUrl}
|
value={templateWebsiteUrl}
|
||||||
onChange={(e) => setTemplateWebsiteUrl(e.target.value)}
|
onChange={(e) => setTemplateWebsiteUrl(e.target.value)}
|
||||||
onKeyDown={handleTemplateInputKeyDown}
|
onKeyDown={handleTemplateInputKeyDown}
|
||||||
placeholder="https://example.com"
|
placeholder={t("codexConfig.websitePlaceholder")}
|
||||||
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"
|
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">
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
官方网站地址(可选)
|
{t("codexConfig.websiteHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
模型名称 *
|
{t("codexConfig.modelNameLabel")}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -484,8 +483,8 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
onChange={(e) => setTemplateModelName(e.target.value)}
|
onChange={(e) => setTemplateModelName(e.target.value)}
|
||||||
onKeyDown={handleTemplateInputKeyDown}
|
onKeyDown={handleTemplateInputKeyDown}
|
||||||
pattern=".*\S.*"
|
pattern=".*\S.*"
|
||||||
title="请输入有效的内容"
|
title={t("common.enterValidValue")}
|
||||||
placeholder="gpt-5-codex"
|
placeholder={t("codexConfig.modelNamePlaceholder")}
|
||||||
required
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
@@ -497,7 +496,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
templateBaseUrl) && (
|
templateBaseUrl) && (
|
||||||
<div className="space-y-2 border-t border-gray-200 pt-4 dark:border-gray-700">
|
<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">
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
配置预览
|
{t("codexConfig.configPreview")}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
@@ -510,7 +509,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
{JSON.stringify(
|
{JSON.stringify(
|
||||||
generateThirdPartyAuth(templateApiKey),
|
generateThirdPartyAuth(templateApiKey),
|
||||||
null,
|
null,
|
||||||
2
|
2,
|
||||||
)}
|
)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -527,7 +526,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
|
|
||||||
templateBaseUrl,
|
templateBaseUrl,
|
||||||
|
|
||||||
templateModelName
|
templateModelName,
|
||||||
)
|
)
|
||||||
: ""}
|
: ""}
|
||||||
</pre>
|
</pre>
|
||||||
@@ -543,7 +542,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
onClick={closeTemplateModal}
|
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"
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -558,7 +557,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"
|
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" />
|
<Save className="h-4 w-4" />
|
||||||
应用配置
|
{t("codexConfig.applyConfig")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -588,14 +587,14 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
|
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
<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">
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
编辑 Codex 通用配置片段
|
{t("codexConfig.editCommonConfigTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeModal}
|
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"
|
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="关闭"
|
aria-label={t("common.close")}
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -605,7 +604,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
|
|
||||||
<div className="flex-1 overflow-auto p-6 space-y-4">
|
<div className="flex-1 overflow-auto p-6 space-y-4">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
该片段会在勾选"写入通用配置"时追加到 config.toml 末尾
|
{t("codexConfig.commonConfigHint")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
@@ -646,7 +645,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
onClick={closeModal}
|
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"
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -655,7 +654,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"
|
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" />
|
<Save className="w-4 h-4" />
|
||||||
保存
|
{t("common.save")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Zap, Loader2, Plus, X, AlertCircle } from "lucide-react";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Zap, Loader2, Plus, X, AlertCircle, Save } from "lucide-react";
|
||||||
import { isLinux } from "../../lib/platform";
|
import { isLinux } from "../../lib/platform";
|
||||||
|
|
||||||
import type { AppType } from "../../lib/tauri-api";
|
import type { AppType } from "../../lib/tauri-api";
|
||||||
@@ -74,6 +75,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onCustomEndpointsChange,
|
onCustomEndpointsChange,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [entries, setEntries] = useState<EndpointEntry[]>(() =>
|
const [entries, setEntries] = useState<EndpointEntry[]>(() =>
|
||||||
buildInitialEntries(initialEndpoints, value),
|
buildInitialEntries(initialEndpoints, value),
|
||||||
);
|
);
|
||||||
@@ -127,14 +129,14 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("加载自定义端点失败:", error);
|
console.error(t("endpointTest.loadEndpointsFailed"), error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (visible) {
|
if (visible) {
|
||||||
loadCustomEndpoints();
|
loadCustomEndpoints();
|
||||||
}
|
}
|
||||||
}, [appType, visible, providerId]);
|
}, [appType, visible, providerId, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEntries((prev) => {
|
setEntries((prev) => {
|
||||||
@@ -208,81 +210,85 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
});
|
});
|
||||||
}, [entries]);
|
}, [entries]);
|
||||||
|
|
||||||
const handleAddEndpoint = useCallback(
|
const handleAddEndpoint = useCallback(async () => {
|
||||||
async () => {
|
const candidate = customUrl.trim();
|
||||||
const candidate = customUrl.trim();
|
let errorMsg: string | null = null;
|
||||||
let errorMsg: string | null = null;
|
|
||||||
|
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
errorMsg = "请输入有效的 URL";
|
errorMsg = t("endpointTest.enterValidUrl");
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: URL | null = null;
|
let parsed: URL | null = null;
|
||||||
if (!errorMsg) {
|
if (!errorMsg) {
|
||||||
try {
|
|
||||||
parsed = new URL(candidate);
|
|
||||||
} catch {
|
|
||||||
errorMsg = "URL 格式不正确";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!errorMsg && parsed && !parsed.protocol.startsWith("http")) {
|
|
||||||
errorMsg = "仅支持 HTTP/HTTPS";
|
|
||||||
}
|
|
||||||
|
|
||||||
let sanitized = "";
|
|
||||||
if (!errorMsg && parsed) {
|
|
||||||
sanitized = normalizeEndpointUrl(parsed.toString());
|
|
||||||
// 使用当前 entries 做去重校验,避免依赖可能过期的 addError
|
|
||||||
const isDuplicate = entries.some((entry) => entry.url === sanitized);
|
|
||||||
if (isDuplicate) {
|
|
||||||
errorMsg = "该地址已存在";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorMsg) {
|
|
||||||
setAddError(errorMsg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAddError(null);
|
|
||||||
|
|
||||||
// 保存到后端
|
|
||||||
try {
|
try {
|
||||||
if (providerId) {
|
parsed = new URL(candidate);
|
||||||
await window.api.addCustomEndpoint(appType, providerId, sanitized);
|
} catch {
|
||||||
}
|
errorMsg = t("endpointTest.invalidUrlFormat");
|
||||||
|
|
||||||
// 更新本地状态
|
|
||||||
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 || "保存失败,请重试");
|
|
||||||
console.error("添加自定义端点失败:", error);
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
[customUrl, entries, normalizedSelected, onChange, appType, providerId],
|
|
||||||
);
|
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(
|
const handleRemoveEndpoint = useCallback(
|
||||||
async (entry: EndpointEntry) => {
|
async (entry: EndpointEntry) => {
|
||||||
@@ -291,7 +297,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
try {
|
try {
|
||||||
await window.api.removeCustomEndpoint(appType, providerId, entry.url);
|
await window.api.removeCustomEndpoint(appType, providerId, entry.url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("删除自定义端点失败:", error);
|
console.error(t("endpointTest.removeEndpointFailed"), error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,24 +312,34 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[normalizedSelected, onChange, appType, providerId],
|
[normalizedSelected, onChange, appType, providerId, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const runSpeedTest = useCallback(async () => {
|
const runSpeedTest = useCallback(async () => {
|
||||||
const urls = entries.map((entry) => entry.url);
|
const urls = entries.map((entry) => entry.url);
|
||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
setLastError("请先添加端点");
|
setLastError(t("endpointTest.pleaseAddEndpoint"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window === "undefined" || !window.api?.testApiEndpoints) {
|
if (typeof window === "undefined" || !window.api?.testApiEndpoints) {
|
||||||
setLastError("测速功能不可用");
|
setLastError(t("endpointTest.testUnavailable"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsTesting(true);
|
setIsTesting(true);
|
||||||
setLastError(null);
|
setLastError(null);
|
||||||
|
|
||||||
|
// 清空所有延迟数据,显示 loading 状态
|
||||||
|
setEntries((prev) =>
|
||||||
|
prev.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
latency: null,
|
||||||
|
status: undefined,
|
||||||
|
error: null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await window.api.testApiEndpoints(urls, {
|
const results = await window.api.testApiEndpoints(urls, {
|
||||||
timeoutSecs: appType === "codex" ? 12 : 8,
|
timeoutSecs: appType === "codex" ? 12 : 8,
|
||||||
@@ -340,13 +356,15 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
...entry,
|
...entry,
|
||||||
latency: null,
|
latency: null,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
error: "未返回结果",
|
error: t("endpointTest.noResult"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...entry,
|
...entry,
|
||||||
latency:
|
latency:
|
||||||
typeof match.latency === "number" ? Math.round(match.latency) : null,
|
typeof match.latency === "number"
|
||||||
|
? Math.round(match.latency)
|
||||||
|
: null,
|
||||||
status: match.status,
|
status: match.status,
|
||||||
error: match.error ?? null,
|
error: match.error ?? null,
|
||||||
};
|
};
|
||||||
@@ -355,7 +373,9 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
|
|
||||||
if (autoSelect) {
|
if (autoSelect) {
|
||||||
const successful = results
|
const successful = results
|
||||||
.filter((item) => typeof item.latency === "number" && item.latency !== null)
|
.filter(
|
||||||
|
(item) => typeof item.latency === "number" && item.latency !== null,
|
||||||
|
)
|
||||||
.sort((a, b) => (a.latency! || 0) - (b.latency! || 0));
|
.sort((a, b) => (a.latency! || 0) - (b.latency! || 0));
|
||||||
const best = successful[0];
|
const best = successful[0];
|
||||||
if (best && best.url && best.url !== normalizedSelected) {
|
if (best && best.url && best.url !== normalizedSelected) {
|
||||||
@@ -364,12 +384,14 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : `测速失败: ${String(error)}`;
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: `${t("endpointTest.testFailed", { error: String(error) })}`;
|
||||||
setLastError(message);
|
setLastError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsTesting(false);
|
setIsTesting(false);
|
||||||
}
|
}
|
||||||
}, [entries, autoSelect, appType, normalizedSelected, onChange]);
|
}, [entries, autoSelect, appType, normalizedSelected, onChange, t]);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
async (url: string) => {
|
async (url: string) => {
|
||||||
@@ -421,13 +443,13 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
<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">
|
<h3 className="text-base font-medium text-gray-900 dark:text-gray-100">
|
||||||
请求地址管理
|
{t("endpointTest.title")}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
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"
|
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="关闭"
|
aria-label={t("common.close")}
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -438,7 +460,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
{/* 测速控制栏 */}
|
{/* 测速控制栏 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{entries.length} 个端点
|
{entries.length} {t("endpointTest.endpoints")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||||
@@ -448,23 +470,23 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
onChange={(event) => setAutoSelect(event.target.checked)}
|
onChange={(event) => setAutoSelect(event.target.checked)}
|
||||||
className="h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600"
|
className="h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
自动选择
|
{t("endpointTest.autoSelect")}
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={runSpeedTest}
|
onClick={runSpeedTest}
|
||||||
disabled={isTesting || !hasEndpoints}
|
disabled={isTesting || !hasEndpoints}
|
||||||
className="flex h-7 items-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"
|
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 ? (
|
{isTesting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
测速中
|
{t("endpointTest.testing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Zap className="h-3.5 w-3.5" />
|
<Zap className="h-3.5 w-3.5" />
|
||||||
测速
|
{t("endpointTest.testSpeed")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -477,7 +499,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
value={customUrl}
|
value={customUrl}
|
||||||
placeholder="https://api.example.com"
|
placeholder={t("endpointTest.addEndpointPlaceholder")}
|
||||||
onChange={(event) => setCustomUrl(event.target.value)}
|
onChange={(event) => setCustomUrl(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
@@ -542,14 +564,26 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{latency !== null ? (
|
{latency !== null ? (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="font-mono text-sm font-medium text-gray-900 dark:text-gray-100">
|
<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
|
{latency}ms
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isTesting ? (
|
) : isTesting ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||||
) : entry.error ? (
|
) : entry.error ? (
|
||||||
<div className="text-xs text-gray-400">失败</div>
|
<div className="text-xs text-gray-400">
|
||||||
|
{t("endpointTest.failed")}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-gray-400">—</div>
|
<div className="text-xs text-gray-400">—</div>
|
||||||
)}
|
)}
|
||||||
@@ -571,7 +605,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
</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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -589,9 +623,10 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronDown, RefreshCw, AlertCircle } from "lucide-react";
|
import { ChevronDown, RefreshCw, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
interface KimiModel {
|
interface KimiModel {
|
||||||
@@ -26,6 +27,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
|||||||
onModelChange,
|
onModelChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [models, setModels] = useState<KimiModel[]>([]);
|
const [models, setModels] = useState<KimiModel[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -34,7 +36,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
|||||||
// 获取模型列表
|
// 获取模型列表
|
||||||
const fetchModelsWithKey = async (key: string) => {
|
const fetchModelsWithKey = async (key: string) => {
|
||||||
if (!key) {
|
if (!key) {
|
||||||
setError("请先填写 API Key");
|
setError(t("kimiSelector.fillApiKeyFirst"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +52,11 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`请求失败: ${response.status} ${response.statusText}`);
|
throw new Error(
|
||||||
|
t("kimiSelector.requestFailed", {
|
||||||
|
error: `${response.status} ${response.statusText}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -58,11 +64,15 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
|||||||
if (data.data && Array.isArray(data.data)) {
|
if (data.data && Array.isArray(data.data)) {
|
||||||
setModels(data.data);
|
setModels(data.data);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("返回数据格式错误");
|
throw new Error(t("kimiSelector.invalidData"));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("获取模型列表失败:", err);
|
console.error(t("kimiSelector.fetchModelsFailed") + ":", err);
|
||||||
setError(err instanceof Error ? err.message : "获取模型列表失败");
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: t("kimiSelector.fetchModelsFailed"),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -110,10 +120,10 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
|||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{loading
|
{loading
|
||||||
? "加载中..."
|
? t("common.loading")
|
||||||
: models.length === 0
|
: models.length === 0
|
||||||
? "暂无模型"
|
? t("kimiSelector.noModels")
|
||||||
: "请选择模型"}
|
: t("kimiSelector.pleaseSelectModel")}
|
||||||
</option>
|
</option>
|
||||||
{models.map((model) => (
|
{models.map((model) => (
|
||||||
<option key={model.id} value={model.id}>
|
<option key={model.id} value={model.id}>
|
||||||
@@ -133,7 +143,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
模型配置
|
{t("kimiSelector.modelConfig")}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -142,7 +152,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"
|
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" : ""} />
|
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||||
刷新模型列表
|
{t("kimiSelector.refreshModels")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,12 +168,12 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<ModelSelect
|
<ModelSelect
|
||||||
label="主模型"
|
label={t("kimiSelector.mainModel")}
|
||||||
value={anthropicModel}
|
value={anthropicModel}
|
||||||
onChange={(value) => onModelChange("ANTHROPIC_MODEL", value)}
|
onChange={(value) => onModelChange("ANTHROPIC_MODEL", value)}
|
||||||
/>
|
/>
|
||||||
<ModelSelect
|
<ModelSelect
|
||||||
label="快速模型"
|
label={t("kimiSelector.fastModel")}
|
||||||
value={anthropicSmallFastModel}
|
value={anthropicSmallFastModel}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", value)
|
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", value)
|
||||||
@@ -174,7 +184,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
|||||||
{!apiKey.trim() && (
|
{!apiKey.trim() && (
|
||||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
<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">
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
💡 填写 API Key 后将自动获取可用模型列表
|
{t("kimiSelector.apiKeyHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Zap } from "lucide-react";
|
import { Zap } from "lucide-react";
|
||||||
import { ProviderCategory } from "../../types";
|
import { ProviderCategory } from "../../types";
|
||||||
import { ClaudeIcon, CodexIcon } from "../BrandIcons";
|
import { ClaudeIcon, CodexIcon } from "../BrandIcons";
|
||||||
@@ -20,14 +21,16 @@ interface PresetSelectorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PresetSelector: React.FC<PresetSelectorProps> = ({
|
const PresetSelector: React.FC<PresetSelectorProps> = ({
|
||||||
title = "选择配置类型",
|
title,
|
||||||
presets,
|
presets,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
onSelectPreset,
|
onSelectPreset,
|
||||||
onCustomClick,
|
onCustomClick,
|
||||||
customLabel = "自定义",
|
customLabel,
|
||||||
renderCustomDescription,
|
renderCustomDescription,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const getButtonClass = (index: number, preset?: Preset) => {
|
const getButtonClass = (index: number, preset?: Preset) => {
|
||||||
const isSelected = selectedIndex === index;
|
const isSelected = selectedIndex === index;
|
||||||
const baseClass =
|
const baseClass =
|
||||||
@@ -54,14 +57,14 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
|
|||||||
if (renderCustomDescription) {
|
if (renderCustomDescription) {
|
||||||
return renderCustomDescription();
|
return renderCustomDescription();
|
||||||
}
|
}
|
||||||
return "手动配置供应商,需要填写完整的配置信息";
|
return t("presetSelector.customDescription");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedIndex !== null && selectedIndex >= 0) {
|
if (selectedIndex !== null && selectedIndex >= 0) {
|
||||||
const preset = presets[selectedIndex];
|
const preset = presets[selectedIndex];
|
||||||
return preset?.isOfficial || preset?.category === "official"
|
return preset?.isOfficial || preset?.category === "official"
|
||||||
? "官方登录,不需要填写 API Key"
|
? t("presetSelector.officialDescription")
|
||||||
: "使用预设配置,只需填写 API Key";
|
: t("presetSelector.presetDescription");
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -71,7 +74,7 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||||
{title}
|
{title || t("presetSelector.title")}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -79,7 +82,7 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
|
|||||||
className={`${getButtonClass(-1)} ${selectedIndex === -1 ? "" : ""}`}
|
className={`${getButtonClass(-1)} ${selectedIndex === -1 ? "" : ""}`}
|
||||||
onClick={onCustomClick}
|
onClick={onCustomClick}
|
||||||
>
|
>
|
||||||
{customLabel}
|
{customLabel || t("presetSelector.custom")}
|
||||||
</button>
|
</button>
|
||||||
{presets.map((preset, index) => (
|
{presets.map((preset, index) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Provider } from "../types";
|
import { Provider } from "../types";
|
||||||
import { Play, Edit3, Trash2, CheckCircle2, Users, Check } from "lucide-react";
|
import { Play, Edit3, Trash2, CheckCircle2, Users, Check } from "lucide-react";
|
||||||
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
|
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
|
||||||
import { AppType } from "../lib/tauri-api";
|
|
||||||
// 不再在列表中显示分类徽章,避免造成困惑
|
// 不再在列表中显示分类徽章,避免造成困惑
|
||||||
|
|
||||||
interface ProviderListProps {
|
interface ProviderListProps {
|
||||||
@@ -12,11 +11,10 @@ interface ProviderListProps {
|
|||||||
onSwitch: (id: string) => void;
|
onSwitch: (id: string) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onEdit: (id: string) => void;
|
onEdit: (id: string) => void;
|
||||||
appType?: AppType;
|
|
||||||
onNotify?: (
|
onNotify?: (
|
||||||
message: string,
|
message: string,
|
||||||
type: "success" | "error",
|
type: "success" | "error",
|
||||||
duration?: number
|
duration?: number,
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,10 +24,9 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
onSwitch,
|
onSwitch,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
appType,
|
|
||||||
onNotify,
|
onNotify,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
// 提取API地址(兼容不同供应商配置:Claude env / Codex TOML)
|
// 提取API地址(兼容不同供应商配置:Claude env / Codex TOML)
|
||||||
const getApiUrl = (provider: Provider): string => {
|
const getApiUrl = (provider: Provider): string => {
|
||||||
try {
|
try {
|
||||||
@@ -55,58 +52,15 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
await window.api.openExternal(url);
|
await window.api.openExternal(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t("console.openLinkFailed"), error);
|
console.error(t("console.openLinkFailed"), error);
|
||||||
|
onNotify?.(
|
||||||
|
`${t("console.openLinkFailed")}: ${String(error)}`,
|
||||||
|
"error",
|
||||||
|
4000,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [claudeApplied, setClaudeApplied] = useState<boolean>(false);
|
// 列表页不再提供 Claude 插件按钮,统一在“设置”中控制
|
||||||
|
|
||||||
// 检查 Claude 插件配置是否已应用
|
|
||||||
useEffect(() => {
|
|
||||||
const checkClaude = async () => {
|
|
||||||
if (appType !== "claude" || !currentProviderId) {
|
|
||||||
setClaudeApplied(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const applied = await window.api.isClaudePluginApplied();
|
|
||||||
setClaudeApplied(applied);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("检测 Claude 插件配置失败:", error);
|
|
||||||
setClaudeApplied(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
checkClaude();
|
|
||||||
}, [appType, currentProviderId, providers]);
|
|
||||||
|
|
||||||
const handleApplyToClaudePlugin = async () => {
|
|
||||||
try {
|
|
||||||
await window.api.applyClaudePluginConfig({ official: false });
|
|
||||||
onNotify?.(t("notifications.appliedToClaudePlugin"), "success", 3000);
|
|
||||||
setClaudeApplied(true);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
const msg =
|
|
||||||
error && error.message
|
|
||||||
? error.message
|
|
||||||
: t("notifications.syncClaudePluginFailed");
|
|
||||||
onNotify?.(msg, "error", 5000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveFromClaudePlugin = async () => {
|
|
||||||
try {
|
|
||||||
await window.api.applyClaudePluginConfig({ official: true });
|
|
||||||
onNotify?.(t("notifications.removedFromClaudePlugin"), "success", 3000);
|
|
||||||
setClaudeApplied(false);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
const msg =
|
|
||||||
error && error.message
|
|
||||||
? error.message
|
|
||||||
: t("notifications.syncClaudePluginFailed");
|
|
||||||
onNotify?.(msg, "error", 5000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 对供应商列表进行排序
|
// 对供应商列表进行排序
|
||||||
const sortedProviders = Object.values(providers).sort((a, b) => {
|
const sortedProviders = Object.values(providers).sort((a, b) => {
|
||||||
@@ -118,7 +72,8 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
|
|
||||||
// 如果都没有时间戳,按名称排序
|
// 如果都没有时间戳,按名称排序
|
||||||
if (timeA === 0 && timeB === 0) {
|
if (timeA === 0 && timeB === 0) {
|
||||||
return a.name.localeCompare(b.name, "zh-CN");
|
const locale = i18n.language === "zh" ? "zh-CN" : "en-US";
|
||||||
|
return a.name.localeCompare(b.name, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果只有一个没有时间戳,没有时间戳的排在前面
|
// 如果只有一个没有时间戳,没有时间戳的排在前面
|
||||||
@@ -153,10 +108,10 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
<div
|
<div
|
||||||
key={provider.id}
|
key={provider.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
isCurrent ? cardStyles.selected : cardStyles.interactive
|
isCurrent ? cardStyles.selected : cardStyles.interactive,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
@@ -166,7 +121,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
badgeStyles.success,
|
badgeStyles.success,
|
||||||
!isCurrent && "invisible"
|
!isCurrent && "invisible",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CheckCircle2 size={12} />
|
<CheckCircle2 size={12} />
|
||||||
@@ -182,7 +137,9 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
handleUrlClick(provider.websiteUrl!);
|
handleUrlClick(provider.websiteUrl!);
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center gap-1 text-blue-500 dark:text-blue-400 hover:opacity-90 transition-colors"
|
className="inline-flex items-center gap-1 text-blue-500 dark:text-blue-400 hover:opacity-90 transition-colors"
|
||||||
title={`访问 ${provider.websiteUrl}`}
|
title={t("providerForm.visitWebsite", {
|
||||||
|
url: provider.websiteUrl,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{provider.websiteUrl}
|
{provider.websiteUrl}
|
||||||
</button>
|
</button>
|
||||||
@@ -198,34 +155,6 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="flex items-center gap-2 ml-4">
|
||||||
{appType === "claude" ? (
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{provider.category !== "official" && isCurrent && (
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
claudeApplied
|
|
||||||
? handleRemoveFromClaudePlugin()
|
|
||||||
: handleApplyToClaudePlugin()
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-full whitespace-nowrap justify-center",
|
|
||||||
claudeApplied
|
|
||||||
? "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-green-300 hover:text-green-600 hover:bg-green-50 dark:border-gray-600 dark:text-gray-300 dark:hover:border-green-700 dark:hover:text-green-400 dark:hover:bg-green-900/20"
|
|
||||||
)}
|
|
||||||
title={
|
|
||||||
claudeApplied
|
|
||||||
? t("provider.removeFromClaudePlugin")
|
|
||||||
: t("provider.applyToClaudePlugin")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{claudeApplied
|
|
||||||
? t("provider.removeFromClaudePlugin")
|
|
||||||
: t("provider.applyToClaudePlugin")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onSwitch(provider.id)}
|
onClick={() => onSwitch(provider.id)}
|
||||||
disabled={isCurrent}
|
disabled={isCurrent}
|
||||||
@@ -233,7 +162,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
"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.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[90px] justify-center whitespace-nowrap",
|
||||||
isCurrent
|
isCurrent
|
||||||
? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed"
|
? "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 ? <Check size={14} /> : <Play size={14} />}
|
||||||
@@ -255,7 +184,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
buttonStyles.icon,
|
buttonStyles.icon,
|
||||||
isCurrent
|
isCurrent
|
||||||
? "text-gray-400 cursor-not-allowed"
|
? "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")}
|
title={t("provider.deleteProvider")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -24,9 +24,18 @@ import { isLinux } from "../lib/platform";
|
|||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onImportSuccess?: () => void | Promise<void>;
|
onImportSuccess?: () => void | Promise<void>;
|
||||||
|
onNotify?: (
|
||||||
|
message: string,
|
||||||
|
type: "success" | "error",
|
||||||
|
duration?: number,
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsModal({ onClose, onImportSuccess }: SettingsModalProps) {
|
export default function SettingsModal({
|
||||||
|
onClose,
|
||||||
|
onImportSuccess,
|
||||||
|
onNotify,
|
||||||
|
}: SettingsModalProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
const normalizeLanguage = (lang?: string | null): "zh" | "en" =>
|
const normalizeLanguage = (lang?: string | null): "zh" | "en" =>
|
||||||
@@ -47,6 +56,7 @@ export default function SettingsModal({ onClose, onImportSuccess }: SettingsModa
|
|||||||
const [settings, setSettings] = useState<Settings>({
|
const [settings, setSettings] = useState<Settings>({
|
||||||
showInTray: true,
|
showInTray: true,
|
||||||
minimizeToTrayOnClose: true,
|
minimizeToTrayOnClose: true,
|
||||||
|
enableClaudePluginIntegration: false,
|
||||||
claudeConfigDir: undefined,
|
claudeConfigDir: undefined,
|
||||||
codexConfigDir: undefined,
|
codexConfigDir: undefined,
|
||||||
language: persistedLanguage,
|
language: persistedLanguage,
|
||||||
@@ -67,10 +77,12 @@ export default function SettingsModal({ onClose, onImportSuccess }: SettingsModa
|
|||||||
|
|
||||||
// 导入/导出相关状态
|
// 导入/导出相关状态
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
const [importStatus, setImportStatus] = useState<'idle' | 'importing' | 'success' | 'error'>('idle');
|
const [importStatus, setImportStatus] = useState<
|
||||||
|
"idle" | "importing" | "success" | "error"
|
||||||
|
>("idle");
|
||||||
const [importError, setImportError] = useState<string>("");
|
const [importError, setImportError] = useState<string>("");
|
||||||
const [importBackupId, setImportBackupId] = useState<string>("");
|
const [importBackupId, setImportBackupId] = useState<string>("");
|
||||||
const [selectedImportFile, setSelectedImportFile] = useState<string>('');
|
const [selectedImportFile, setSelectedImportFile] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
@@ -111,6 +123,11 @@ export default function SettingsModal({ onClose, onImportSuccess }: SettingsModa
|
|||||||
setSettings({
|
setSettings({
|
||||||
showInTray,
|
showInTray,
|
||||||
minimizeToTrayOnClose,
|
minimizeToTrayOnClose,
|
||||||
|
enableClaudePluginIntegration:
|
||||||
|
typeof (loadedSettings as any)?.enableClaudePluginIntegration ===
|
||||||
|
"boolean"
|
||||||
|
? (loadedSettings as any).enableClaudePluginIntegration
|
||||||
|
: false,
|
||||||
claudeConfigDir:
|
claudeConfigDir:
|
||||||
typeof (loadedSettings as any)?.claudeConfigDir === "string"
|
typeof (loadedSettings as any)?.claudeConfigDir === "string"
|
||||||
? (loadedSettings as any).claudeConfigDir
|
? (loadedSettings as any).claudeConfigDir
|
||||||
@@ -179,6 +196,16 @@ export default function SettingsModal({ onClose, onImportSuccess }: SettingsModa
|
|||||||
language: selectedLanguage,
|
language: selectedLanguage,
|
||||||
};
|
};
|
||||||
await window.api.saveSettings(payload);
|
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);
|
setSettings(payload);
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem("language", selectedLanguage);
|
window.localStorage.setItem("language", selectedLanguage);
|
||||||
@@ -340,7 +367,7 @@ export default function SettingsModal({ onClose, onImportSuccess }: SettingsModa
|
|||||||
// 如果未知或为空,回退到 releases 首页
|
// 如果未知或为空,回退到 releases 首页
|
||||||
if (!targetVersion || targetVersion === unknownLabel) {
|
if (!targetVersion || targetVersion === unknownLabel) {
|
||||||
await window.api.openExternal(
|
await window.api.openExternal(
|
||||||
"https://github.com/farion1231/cc-switch/releases"
|
"https://github.com/farion1231/cc-switch/releases",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -348,7 +375,7 @@ export default function SettingsModal({ onClose, onImportSuccess }: SettingsModa
|
|||||||
? targetVersion
|
? targetVersion
|
||||||
: `v${targetVersion}`;
|
: `v${targetVersion}`;
|
||||||
await window.api.openExternal(
|
await window.api.openExternal(
|
||||||
`https://github.com/farion1231/cc-switch/releases/tag/${tag}`
|
`https://github.com/farion1231/cc-switch/releases/tag/${tag}`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t("console.openReleaseNotesFailed"), error);
|
console.error(t("console.openReleaseNotesFailed"), error);
|
||||||
@@ -358,19 +385,34 @@ export default function SettingsModal({ onClose, onImportSuccess }: SettingsModa
|
|||||||
// 导出配置处理函数
|
// 导出配置处理函数
|
||||||
const handleExportConfig = async () => {
|
const handleExportConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const defaultName = `cc-switch-config-${new Date().toISOString().split('T')[0]}.json`;
|
const defaultName = `cc-switch-config-${new Date().toISOString().split("T")[0]}.json`;
|
||||||
const filePath = await window.api.saveFileDialog(defaultName);
|
const filePath = await window.api.saveFileDialog(defaultName);
|
||||||
|
|
||||||
if (!filePath) return; // 用户取消了
|
if (!filePath) {
|
||||||
|
onNotify?.(
|
||||||
|
`${t("settings.exportFailed")}: ${t("settings.selectFileFailed")}`,
|
||||||
|
"error",
|
||||||
|
4000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await window.api.exportConfigToFile(filePath);
|
const result = await window.api.exportConfigToFile(filePath);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert(`${t("settings.configExported")}\n${result.filePath}`);
|
onNotify?.(
|
||||||
|
`${t("settings.configExported")}\n${result.filePath}`,
|
||||||
|
"success",
|
||||||
|
4000,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("导出配置失败:", error);
|
console.error(t("settings.exportFailedError"), error);
|
||||||
alert(`${t("settings.exportFailed")}: ${error}`);
|
onNotify?.(
|
||||||
|
`${t("settings.exportFailed")}: ${String(error)}`,
|
||||||
|
"error",
|
||||||
|
5000,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -380,12 +422,16 @@ export default function SettingsModal({ onClose, onImportSuccess }: SettingsModa
|
|||||||
const filePath = await window.api.openFileDialog();
|
const filePath = await window.api.openFileDialog();
|
||||||
if (filePath) {
|
if (filePath) {
|
||||||
setSelectedImportFile(filePath);
|
setSelectedImportFile(filePath);
|
||||||
setImportStatus('idle'); // 重置状态
|
setImportStatus("idle"); // 重置状态
|
||||||
setImportError('');
|
setImportError("");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('选择文件失败:', error);
|
console.error(t("settings.selectFileFailed") + ":", error);
|
||||||
alert(`${t("settings.selectFileFailed")}: ${error}`);
|
onNotify?.(
|
||||||
|
`${t("settings.selectFileFailed")}: ${String(error)}`,
|
||||||
|
"error",
|
||||||
|
5000,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -394,22 +440,22 @@ export default function SettingsModal({ onClose, onImportSuccess }: SettingsModa
|
|||||||
if (!selectedImportFile || isImporting) return;
|
if (!selectedImportFile || isImporting) return;
|
||||||
|
|
||||||
setIsImporting(true);
|
setIsImporting(true);
|
||||||
setImportStatus('importing');
|
setImportStatus("importing");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.api.importConfigFromFile(selectedImportFile);
|
const result = await window.api.importConfigFromFile(selectedImportFile);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setImportBackupId(result.backupId || '');
|
setImportBackupId(result.backupId || "");
|
||||||
setImportStatus('success');
|
setImportStatus("success");
|
||||||
// ImportProgressModal 会在2秒后触发数据刷新回调
|
// ImportProgressModal 会在2秒后触发数据刷新回调
|
||||||
} else {
|
} else {
|
||||||
setImportError(result.message || t("settings.configCorrupted"));
|
setImportError(result.message || t("settings.configCorrupted"));
|
||||||
setImportStatus('error');
|
setImportStatus("error");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setImportError(String(error));
|
setImportError(String(error));
|
||||||
setImportStatus('error');
|
setImportStatus("error");
|
||||||
} finally {
|
} finally {
|
||||||
setIsImporting(false);
|
setIsImporting(false);
|
||||||
}
|
}
|
||||||
@@ -501,6 +547,28 @@ export default function SettingsModal({ onClose, onImportSuccess }: SettingsModa
|
|||||||
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
|
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -642,18 +710,22 @@ export default function SettingsModal({ onClose, onImportSuccess }: SettingsModa
|
|||||||
disabled={!selectedImportFile || isImporting}
|
disabled={!selectedImportFile || isImporting}
|
||||||
className={`px-3 py-2 text-xs font-medium rounded-lg transition-colors text-white ${
|
className={`px-3 py-2 text-xs font-medium rounded-lg transition-colors text-white ${
|
||||||
!selectedImportFile || isImporting
|
!selectedImportFile || isImporting
|
||||||
? 'bg-gray-400 cursor-not-allowed'
|
? "bg-gray-400 cursor-not-allowed"
|
||||||
: 'bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700'
|
: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isImporting ? t("settings.importing") : t("settings.import")}
|
{isImporting
|
||||||
|
? t("settings.importing")
|
||||||
|
: t("settings.import")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 显示选择的文件 */}
|
{/* 显示选择的文件 */}
|
||||||
{selectedImportFile && (
|
{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">
|
<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}
|
{selectedImportFile.split("/").pop() ||
|
||||||
|
selectedImportFile.split("\\").pop() ||
|
||||||
|
selectedImportFile}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -757,15 +829,15 @@ export default function SettingsModal({ onClose, onImportSuccess }: SettingsModa
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Import Progress Modal */}
|
{/* Import Progress Modal */}
|
||||||
{importStatus !== 'idle' && (
|
{importStatus !== "idle" && (
|
||||||
<ImportProgressModal
|
<ImportProgressModal
|
||||||
status={importStatus}
|
status={importStatus}
|
||||||
message={importError}
|
message={importError}
|
||||||
backupId={importBackupId}
|
backupId={importBackupId}
|
||||||
onComplete={() => {
|
onComplete={() => {
|
||||||
setImportStatus('idle');
|
setImportStatus("idle");
|
||||||
setImportError('');
|
setImportError("");
|
||||||
setSelectedImportFile('');
|
setSelectedImportFile("");
|
||||||
}}
|
}}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
if (onImportSuccess) {
|
if (onImportSuccess) {
|
||||||
@@ -773,7 +845,12 @@ export default function SettingsModal({ onClose, onImportSuccess }: SettingsModa
|
|||||||
}
|
}
|
||||||
void window.api
|
void window.api
|
||||||
.updateTrayMenu()
|
.updateTrayMenu()
|
||||||
.catch((error) => console.error("[SettingsModal] Failed to refresh tray menu", error));
|
.catch((error) =>
|
||||||
|
console.error(
|
||||||
|
"[SettingsModal] Failed to refresh tray menu",
|
||||||
|
error,
|
||||||
|
),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { X, Download } from "lucide-react";
|
import { X, Download } from "lucide-react";
|
||||||
import { useUpdate } from "../contexts/UpdateContext";
|
import { useUpdate } from "../contexts/UpdateContext";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface UpdateBadgeProps {
|
interface UpdateBadgeProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -8,6 +9,7 @@ interface UpdateBadgeProps {
|
|||||||
|
|
||||||
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
||||||
const { hasUpdate, updateInfo, isDismissed, dismissUpdate } = useUpdate();
|
const { hasUpdate, updateInfo, isDismissed, dismissUpdate } = useUpdate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 如果没有更新或已关闭,不显示
|
// 如果没有更新或已关闭,不显示
|
||||||
if (!hasUpdate || isDismissed || !updateInfo) {
|
if (!hasUpdate || isDismissed || !updateInfo) {
|
||||||
@@ -52,7 +54,7 @@ export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
|||||||
transition-colors
|
transition-colors
|
||||||
focus:outline-none focus:ring-2 focus:ring-blue-500/20
|
focus:outline-none focus:ring-2 focus:ring-blue-500/20
|
||||||
"
|
"
|
||||||
aria-label="关闭更新提醒"
|
aria-label={t("common.close")}
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3 text-gray-400 dark:text-gray-500" />
|
<X className="w-3 h-3 text-gray-400 dark:text-gray-500" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
669
src/components/mcp/McpFormModal.tsx
Normal file
669
src/components/mcp/McpFormModal.tsx
Normal file
@@ -0,0 +1,669 @@
|
|||||||
|
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;
|
||||||
117
src/components/mcp/McpListItem.tsx
Normal file
117
src/components/mcp/McpListItem.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
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;
|
||||||
306
src/components/mcp/McpPanel.tsx
Normal file
306
src/components/mcp/McpPanel.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
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;
|
||||||
41
src/components/mcp/McpToggle.tsx
Normal file
41
src/components/mcp/McpToggle.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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;
|
||||||
390
src/components/mcp/McpWizardModal.tsx
Normal file
390
src/components/mcp/McpWizardModal.tsx
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
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;
|
||||||
@@ -32,7 +32,7 @@ export function generateThirdPartyAuth(apiKey: string): Record<string, any> {
|
|||||||
export function generateThirdPartyConfig(
|
export function generateThirdPartyConfig(
|
||||||
providerName: string,
|
providerName: string,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
modelName = "gpt-5-codex"
|
modelName = "gpt-5-codex",
|
||||||
): string {
|
): string {
|
||||||
// 清理供应商名称,确保符合TOML键名规范
|
// 清理供应商名称,确保符合TOML键名规范
|
||||||
const cleanProviderName =
|
const cleanProviderName =
|
||||||
@@ -49,12 +49,13 @@ disable_response_storage = true
|
|||||||
[model_providers.${cleanProviderName}]
|
[model_providers.${cleanProviderName}]
|
||||||
name = "${cleanProviderName}"
|
name = "${cleanProviderName}"
|
||||||
base_url = "${baseUrl}"
|
base_url = "${baseUrl}"
|
||||||
wire_api = "responses"`;
|
wire_api = "responses"
|
||||||
|
requires_openai_auth = true`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const codexProviderPresets: CodexProviderPreset[] = [
|
export const codexProviderPresets: CodexProviderPreset[] = [
|
||||||
{
|
{
|
||||||
name: "Codex官方",
|
name: "Codex Official",
|
||||||
websiteUrl: "https://chatgpt.com/codex",
|
websiteUrl: "https://chatgpt.com/codex",
|
||||||
isOfficial: true,
|
isOfficial: true,
|
||||||
category: "official",
|
category: "official",
|
||||||
@@ -71,7 +72,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
|||||||
config: generateThirdPartyConfig(
|
config: generateThirdPartyConfig(
|
||||||
"packycode",
|
"packycode",
|
||||||
"https://codex-api.packycode.com/v1",
|
"https://codex-api.packycode.com/v1",
|
||||||
"gpt-5-codex"
|
"gpt-5-codex",
|
||||||
),
|
),
|
||||||
// Codex 请求地址候选(用于地址管理/测速)
|
// Codex 请求地址候选(用于地址管理/测速)
|
||||||
endpointCandidates: [
|
endpointCandidates: [
|
||||||
|
|||||||
85
src/config/mcpPresets.ts
Normal file
85
src/config/mcpPresets.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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;
|
||||||
@@ -26,7 +26,7 @@ export interface ProviderPreset {
|
|||||||
|
|
||||||
export const providerPresets: ProviderPreset[] = [
|
export const providerPresets: ProviderPreset[] = [
|
||||||
{
|
{
|
||||||
name: "Claude官方",
|
name: "Claude Official",
|
||||||
websiteUrl: "https://www.anthropic.com/claude-code",
|
websiteUrl: "https://www.anthropic.com/claude-code",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {},
|
env: {},
|
||||||
@@ -48,7 +48,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
category: "cn_official",
|
category: "cn_official",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "智谱GLM",
|
name: "Zhipu GLM",
|
||||||
websiteUrl: "https://open.bigmodel.cn",
|
websiteUrl: "https://open.bigmodel.cn",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
@@ -65,7 +65,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
category: "cn_official",
|
category: "cn_official",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Qwen-Coder",
|
name: "Qwen Coder",
|
||||||
websiteUrl: "https://bailian.console.aliyun.com",
|
websiteUrl: "https://bailian.console.aliyun.com",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
@@ -92,7 +92,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
category: "cn_official",
|
category: "cn_official",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "魔搭",
|
name: "ModelScope",
|
||||||
websiteUrl: "https://modelscope.cn",
|
websiteUrl: "https://modelscope.cn",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
@@ -104,6 +104,47 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
},
|
},
|
||||||
category: "aggregator",
|
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",
|
name: "PackyCode",
|
||||||
websiteUrl: "https://www.packycode.com",
|
websiteUrl: "https://www.packycode.com",
|
||||||
@@ -124,26 +165,4 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
],
|
],
|
||||||
category: "third_party",
|
category: "third_party",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
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: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ const getInitialLanguage = (): "zh" | "en" => {
|
|||||||
|
|
||||||
const navigatorLang =
|
const navigatorLang =
|
||||||
typeof navigator !== "undefined"
|
typeof navigator !== "undefined"
|
||||||
? navigator.language?.toLowerCase() ?? navigator.languages?.[0]?.toLowerCase()
|
? (navigator.language?.toLowerCase() ??
|
||||||
|
navigator.languages?.[0]?.toLowerCase())
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (navigatorLang?.startsWith("zh")) {
|
if (navigatorLang?.startsWith("zh")) {
|
||||||
|
|||||||
@@ -8,16 +8,36 @@
|
|||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
"saving": "Saving...",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
|
"done": "Done",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"unknown": "Unknown"
|
"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"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"viewOnGithub": "View on GitHub",
|
"viewOnGithub": "View on GitHub",
|
||||||
@@ -36,6 +56,10 @@
|
|||||||
"editProvider": "Edit Provider",
|
"editProvider": "Edit Provider",
|
||||||
"deleteProvider": "Delete Provider",
|
"deleteProvider": "Delete Provider",
|
||||||
"addNewProvider": "Add New 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",
|
"configError": "Configuration Error",
|
||||||
"notConfigured": "Not configured for official website",
|
"notConfigured": "Not configured for official website",
|
||||||
"applyToClaudePlugin": "Apply to Claude plugin",
|
"applyToClaudePlugin": "Apply to Claude plugin",
|
||||||
@@ -79,6 +103,8 @@
|
|||||||
"windowBehavior": "Window Behavior",
|
"windowBehavior": "Window Behavior",
|
||||||
"minimizeToTray": "Minimize to tray on close",
|
"minimizeToTray": "Minimize to tray on close",
|
||||||
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
|
"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",
|
"configFileLocation": "Configuration File Location",
|
||||||
"openFolder": "Open Folder",
|
"openFolder": "Open Folder",
|
||||||
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
|
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
|
||||||
@@ -96,7 +122,8 @@
|
|||||||
"upToDate": "Up to Date",
|
"upToDate": "Up to Date",
|
||||||
"releaseNotes": "Release Notes",
|
"releaseNotes": "Release Notes",
|
||||||
"viewReleaseNotes": "View release notes for this version",
|
"viewReleaseNotes": "View release notes for this version",
|
||||||
"viewCurrentReleaseNotes": "View current version release notes"
|
"viewCurrentReleaseNotes": "View current version release notes",
|
||||||
|
"exportFailedError": "Export config failed:"
|
||||||
},
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"claude": "Claude Code",
|
"claude": "Claude Code",
|
||||||
@@ -120,5 +147,243 @@
|
|||||||
"selectConfigDirFailed": "Failed to select config directory:",
|
"selectConfigDirFailed": "Failed to select config directory:",
|
||||||
"getDefaultConfigDirFailed": "Failed to get default config directory:",
|
"getDefaultConfigDirFailed": "Failed to get default config directory:",
|
||||||
"openReleaseNotesFailed": "Failed to open release notes:"
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,36 @@
|
|||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
|
"saving": "保存中...",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "确定",
|
"confirm": "确定",
|
||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
|
"done": "完成",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"about": "关于",
|
"about": "关于",
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"success": "成功",
|
"success": "成功",
|
||||||
"error": "错误",
|
"error": "错误",
|
||||||
"unknown": "未知"
|
"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 配置内容"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"viewOnGithub": "在 GitHub 上查看",
|
"viewOnGithub": "在 GitHub 上查看",
|
||||||
@@ -36,6 +56,10 @@
|
|||||||
"editProvider": "编辑供应商",
|
"editProvider": "编辑供应商",
|
||||||
"deleteProvider": "删除供应商",
|
"deleteProvider": "删除供应商",
|
||||||
"addNewProvider": "添加新供应商",
|
"addNewProvider": "添加新供应商",
|
||||||
|
"addClaudeProvider": "添加 Claude Code 供应商",
|
||||||
|
"addCodexProvider": "添加 Codex 供应商",
|
||||||
|
"editClaudeProvider": "编辑 Claude Code 供应商",
|
||||||
|
"editCodexProvider": "编辑 Codex 供应商",
|
||||||
"configError": "配置错误",
|
"configError": "配置错误",
|
||||||
"notConfigured": "未配置官网地址",
|
"notConfigured": "未配置官网地址",
|
||||||
"applyToClaudePlugin": "应用到 Claude 插件",
|
"applyToClaudePlugin": "应用到 Claude 插件",
|
||||||
@@ -79,6 +103,8 @@
|
|||||||
"windowBehavior": "窗口行为",
|
"windowBehavior": "窗口行为",
|
||||||
"minimizeToTray": "关闭时最小化到托盘",
|
"minimizeToTray": "关闭时最小化到托盘",
|
||||||
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
|
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
|
||||||
|
"enableClaudePluginIntegration": "应用到 Claude Code 插件",
|
||||||
|
"enableClaudePluginIntegrationDescription": "开启后可以在 Vscode Claude Code 插件里使用第三方供应商",
|
||||||
"configFileLocation": "配置文件位置",
|
"configFileLocation": "配置文件位置",
|
||||||
"openFolder": "打开文件夹",
|
"openFolder": "打开文件夹",
|
||||||
"configDirectoryOverride": "配置目录覆盖(高级)",
|
"configDirectoryOverride": "配置目录覆盖(高级)",
|
||||||
@@ -96,7 +122,8 @@
|
|||||||
"upToDate": "已是最新",
|
"upToDate": "已是最新",
|
||||||
"releaseNotes": "更新日志",
|
"releaseNotes": "更新日志",
|
||||||
"viewReleaseNotes": "查看该版本更新日志",
|
"viewReleaseNotes": "查看该版本更新日志",
|
||||||
"viewCurrentReleaseNotes": "查看当前版本更新日志"
|
"viewCurrentReleaseNotes": "查看当前版本更新日志",
|
||||||
|
"exportFailedError": "导出配置失败:"
|
||||||
},
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"claude": "Claude Code",
|
"claude": "Claude Code",
|
||||||
@@ -120,5 +147,243 @@
|
|||||||
"selectConfigDirFailed": "选择配置目录失败:",
|
"selectConfigDirFailed": "选择配置目录失败:",
|
||||||
"getDefaultConfigDirFailed": "获取默认配置目录失败:",
|
"getDefaultConfigDirFailed": "获取默认配置目录失败:",
|
||||||
"openReleaseNotesFailed": "打开更新日志失败:"
|
"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 会有更高限额"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ export const isLinux = (): boolean => {
|
|||||||
try {
|
try {
|
||||||
const ua = navigator.userAgent || "";
|
const ua = navigator.userAgent || "";
|
||||||
// WebKitGTK/Chromium 在 Linux/Wayland/X11 下 UA 通常包含 Linux 或 X11
|
// 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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export const buttonStyles = {
|
|||||||
danger:
|
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",
|
"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:
|
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",
|
"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",
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import { Provider, Settings, CustomEndpoint } from "../types";
|
import {
|
||||||
|
Provider,
|
||||||
|
Settings,
|
||||||
|
CustomEndpoint,
|
||||||
|
McpStatus,
|
||||||
|
McpServer,
|
||||||
|
McpServerSpec,
|
||||||
|
McpConfigResponse,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
// 应用类型
|
// 应用类型
|
||||||
export type AppType = "claude" | "codex";
|
export type AppType = "claude" | "codex";
|
||||||
@@ -92,8 +100,9 @@ export const tauriAPI = {
|
|||||||
app,
|
app,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 让调用方拿到后端的详细错误信息
|
||||||
console.error("切换供应商失败:", error);
|
console.error("切换供应商失败:", error);
|
||||||
return false;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -149,9 +158,12 @@ export const tauriAPI = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 选择配置目录(可选默认路径)
|
// 选择配置目录(可选默认路径)
|
||||||
selectConfigDirectory: async (defaultPath?: string): Promise<string | null> => {
|
selectConfigDirectory: async (
|
||||||
|
defaultPath?: string,
|
||||||
|
): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
return await invoke("pick_directory", { defaultPath });
|
// 后端参数为 snake_case:default_path
|
||||||
|
return await invoke("pick_directory", { default_path: defaultPath });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("选择配置目录失败:", error);
|
console.error("选择配置目录失败:", error);
|
||||||
return null;
|
return null;
|
||||||
@@ -275,6 +287,165 @@ export const tauriAPI = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 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("写入 MCP(config.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("删除 MCP(config.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 导入 MCP(Codex 作用域)
|
||||||
|
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: 第三方/自定义供应商——测速与端点管理
|
// ours: 第三方/自定义供应商——测速与端点管理
|
||||||
// 第三方/自定义供应商:批量测试端点延迟
|
// 第三方/自定义供应商:批量测试端点延迟
|
||||||
testApiEndpoints: async (
|
testApiEndpoints: async (
|
||||||
@@ -382,26 +553,38 @@ export const tauriAPI = {
|
|||||||
|
|
||||||
// theirs: 导入导出与文件对话框
|
// theirs: 导入导出与文件对话框
|
||||||
// 导出配置到文件
|
// 导出配置到文件
|
||||||
exportConfigToFile: async (filePath: string): Promise<{
|
exportConfigToFile: async (
|
||||||
|
filePath: string,
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
}> => {
|
}> => {
|
||||||
try {
|
try {
|
||||||
return await invoke("export_config_to_file", { filePath });
|
// 兼容参数命名差异:同时传递 file_path 与 filePath
|
||||||
|
return await invoke("export_config_to_file", {
|
||||||
|
file_path: filePath,
|
||||||
|
filePath: filePath,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`导出配置失败: ${String(error)}`);
|
throw new Error(`导出配置失败: ${String(error)}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 从文件导入配置
|
// 从文件导入配置
|
||||||
importConfigFromFile: async (filePath: string): Promise<{
|
importConfigFromFile: async (
|
||||||
|
filePath: string,
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
backupId?: string;
|
backupId?: string;
|
||||||
}> => {
|
}> => {
|
||||||
try {
|
try {
|
||||||
return await invoke("import_config_from_file", { filePath });
|
// 兼容参数命名差异:同时传递 file_path 与 filePath
|
||||||
|
return await invoke("import_config_from_file", {
|
||||||
|
file_path: filePath,
|
||||||
|
filePath: filePath,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`导入配置失败: ${String(error)}`);
|
throw new Error(`导入配置失败: ${String(error)}`);
|
||||||
}
|
}
|
||||||
@@ -410,7 +593,11 @@ export const tauriAPI = {
|
|||||||
// 保存文件对话框
|
// 保存文件对话框
|
||||||
saveFileDialog: async (defaultName: string): Promise<string | null> => {
|
saveFileDialog: async (defaultName: string): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
const result = await invoke<string | null>("save_file_dialog", { defaultName });
|
// 兼容参数命名差异:同时传递 default_name 与 defaultName
|
||||||
|
const result = await invoke<string | null>("save_file_dialog", {
|
||||||
|
default_name: defaultName,
|
||||||
|
defaultName: defaultName,
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("打开保存对话框失败:", error);
|
console.error("打开保存对话框失败:", error);
|
||||||
|
|||||||
@@ -25,5 +25,5 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
<UpdateProvider>
|
<UpdateProvider>
|
||||||
<App />
|
<App />
|
||||||
</UpdateProvider>
|
</UpdateProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
45
src/types.ts
45
src/types.ts
@@ -41,6 +41,8 @@ export interface Settings {
|
|||||||
showInTray: boolean;
|
showInTray: boolean;
|
||||||
// 点击关闭按钮时是否最小化到托盘而不是关闭应用
|
// 点击关闭按钮时是否最小化到托盘而不是关闭应用
|
||||||
minimizeToTrayOnClose: boolean;
|
minimizeToTrayOnClose: boolean;
|
||||||
|
// 启用 Claude 插件联动(写入 ~/.claude/config.json 的 primaryApiKey)
|
||||||
|
enableClaudePluginIntegration?: boolean;
|
||||||
// 覆盖 Claude Code 配置目录(可选)
|
// 覆盖 Claude Code 配置目录(可选)
|
||||||
claudeConfigDir?: string;
|
claudeConfigDir?: string;
|
||||||
// 覆盖 Codex 配置目录(可选)
|
// 覆盖 Codex 配置目录(可选)
|
||||||
@@ -52,3 +54,46 @@ export interface Settings {
|
|||||||
// Codex 自定义端点列表
|
// Codex 自定义端点列表
|
||||||
customEndpointsCodex?: Record<string, CustomEndpoint>;
|
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>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,3 +36,72 @@ export const extractErrorMessage = (error: unknown): string => {
|
|||||||
|
|
||||||
return "";
|
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 "";
|
||||||
|
};
|
||||||
|
|||||||
@@ -178,16 +178,16 @@ export const getApiKeyFromConfig = (jsonString: string): string => {
|
|||||||
// 模板变量替换
|
// 模板变量替换
|
||||||
export const applyTemplateValues = (
|
export const applyTemplateValues = (
|
||||||
config: any,
|
config: any,
|
||||||
templateValues: Record<string, TemplateValueConfig> | undefined
|
templateValues: Record<string, TemplateValueConfig> | undefined,
|
||||||
): any => {
|
): any => {
|
||||||
const resolvedValues = Object.fromEntries(
|
const resolvedValues = Object.fromEntries(
|
||||||
Object.entries(templateValues ?? {}).map(([key, value]) => {
|
Object.entries(templateValues ?? {}).map(([key, value]) => {
|
||||||
const resolvedValue =
|
const resolvedValue =
|
||||||
value.editorValue !== undefined
|
value.editorValue !== undefined
|
||||||
? value.editorValue
|
? value.editorValue
|
||||||
: value.defaultValue ?? "";
|
: (value.defaultValue ?? "");
|
||||||
return [key, resolvedValue];
|
return [key, resolvedValue];
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const replaceInString = (str: string): string => {
|
const replaceInString = (str: string): string => {
|
||||||
@@ -384,6 +384,7 @@ export const setCodexBaseUrl = (
|
|||||||
return configText.replace(pattern, replacementLine);
|
return configText.replace(pattern, replacementLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = configText && !configText.endsWith("\n") ? `${configText}\n` : configText;
|
const prefix =
|
||||||
|
configText && !configText.endsWith("\n") ? `${configText}\n` : configText;
|
||||||
return `${prefix}${replacementLine}\n`;
|
return `${prefix}${replacementLine}\n`;
|
||||||
};
|
};
|
||||||
|
|||||||
202
src/utils/tomlUtils.ts
Normal file
202
src/utils/tomlUtils.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
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 "";
|
||||||
|
};
|
||||||
65
src/vite-env.d.ts
vendored
65
src/vite-env.d.ts
vendored
@@ -1,6 +1,14 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
import { Provider, Settings, CustomEndpoint } from "./types";
|
import {
|
||||||
|
Provider,
|
||||||
|
Settings,
|
||||||
|
CustomEndpoint,
|
||||||
|
McpStatus,
|
||||||
|
McpConfigResponse,
|
||||||
|
McpServer,
|
||||||
|
McpServerSpec,
|
||||||
|
} from "./types";
|
||||||
import { AppType } from "./lib/tauri-api";
|
import { AppType } from "./lib/tauri-api";
|
||||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
@@ -61,34 +69,69 @@ declare global {
|
|||||||
official: boolean;
|
official: boolean;
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
isClaudePluginApplied: () => 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: (
|
testApiEndpoints: (
|
||||||
urls: string[],
|
urls: string[],
|
||||||
options?: { timeoutSecs?: number },
|
options?: { timeoutSecs?: number },
|
||||||
) => Promise<Array<{
|
) => Promise<
|
||||||
url: string;
|
Array<{
|
||||||
latency: number | null;
|
url: string;
|
||||||
status?: number;
|
latency: number | null;
|
||||||
error?: string;
|
status?: number;
|
||||||
}>>;
|
error?: string;
|
||||||
|
}>
|
||||||
|
>;
|
||||||
// 自定义端点管理
|
// 自定义端点管理
|
||||||
getCustomEndpoints: (
|
getCustomEndpoints: (
|
||||||
appType: AppType,
|
appType: AppType,
|
||||||
providerId: string
|
providerId: string,
|
||||||
) => Promise<CustomEndpoint[]>;
|
) => Promise<CustomEndpoint[]>;
|
||||||
addCustomEndpoint: (
|
addCustomEndpoint: (
|
||||||
appType: AppType,
|
appType: AppType,
|
||||||
providerId: string,
|
providerId: string,
|
||||||
url: string
|
url: string,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
removeCustomEndpoint: (
|
removeCustomEndpoint: (
|
||||||
appType: AppType,
|
appType: AppType,
|
||||||
providerId: string,
|
providerId: string,
|
||||||
url: string
|
url: string,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
updateEndpointLastUsed: (
|
updateEndpointLastUsed: (
|
||||||
appType: AppType,
|
appType: AppType,
|
||||||
providerId: string,
|
providerId: string,
|
||||||
url: string
|
url: string,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
};
|
};
|
||||||
platform: {
|
platform: {
|
||||||
|
|||||||
Reference in New Issue
Block a user